fix(pi-ssh): harden remote execution and search

This commit is contained in:
云服务部-叶林立
2026-08-24 23:04:29 +08:00
parent a7891f18bf
commit aff91b0972
30 changed files with 1063 additions and 222 deletions
+147 -56
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
@@ -14,13 +14,20 @@ import {
} from "../src/remote-search.ts";
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
function grepProtocol(backend: string, rows: Array<[string, number, string]> = []): Buffer {
const fields = rows.flatMap(([path, line, content]) => [path, String(line), content]);
return Buffer.from(`__PI_SSH_SEARCH_BACKEND__:${backend}\n${fields.length > 0 ? `${fields.join("\0")}\0` : ""}`);
}
class SearchTransport implements RemoteTransport {
command = "";
cwd = "";
private readonly output: string;
private readonly output: Buffer;
private readonly stderr: Buffer;
private readonly exitCode: number | null;
constructor(output: string, exitCode: number | null = 0) {
this.output = output;
constructor(output: string | Buffer, exitCode: number | null = 0, stderr = "") {
this.output = Buffer.isBuffer(output) ? output : Buffer.from(output);
this.stderr = Buffer.from(stderr);
this.exitCode = exitCode;
}
connect(): Promise<void> { return Promise.resolve(); }
@@ -28,7 +35,8 @@ class SearchTransport implements RemoteTransport {
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
this.command = command;
this.cwd = cwd;
options.onData(Buffer.from(this.output));
options.onData(this.output);
if (this.stderr.length > 0) (options.onStderr ?? options.onData)(this.stderr);
return Promise.resolve({ exitCode: this.exitCode });
}
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
@@ -40,14 +48,30 @@ class SearchTransport implements RemoteTransport {
writeFile(): Promise<void> { throw new Error("not used"); }
}
function toolPath(name: string): string {
return execFileSync("/bin/sh", ["-c", `command -v ${name}`], { encoding: "utf8" }).trim();
}
function toolBin(root: string, names: string[]): string {
const bin = join(root, `bin-${names.join("-")}`);
mkdirSync(bin);
for (const name of names) symlinkSync(toolPath(name), join(bin, name));
return bin;
}
function execute(command: string, cwd: string, path: string) {
return spawnSync("/bin/bash", ["-c", command], { cwd, env: { ...process.env, PATH: path } });
}
test("resolves remote search roots without using local filesystem semantics", () => {
assert.equal(resolveRemoteSearchPath(undefined, "/srv/app", "/home/build"), "/srv/app");
assert.equal(resolveRemoteSearchPath("src", "/srv/app", "/home/build"), "/srv/app/src");
assert.equal(resolveRemoteSearchPath("~/logs", "/srv/app", "/home/build"), "/home/build/logs");
assert.equal(resolveRemoteSearchPath("/var/log", "/srv/app", "/home/build"), "/var/log");
assert.throws(() => resolveRemoteSearchPath("~other/project", "/srv/app", "/home/build"), /only ~ or ~\//);
});
test("builds bounded capability-adaptive commands with shell-quoted user input", () => {
test("builds bounded capability-adaptive commands with unified grep semantics", () => {
const find = buildRemoteFindCommand({ pattern: "it's-app", limit: 12 }, "/srv/app dir");
assert.match(find.command, /command -v fd/);
assert.match(find.command, /git-ls-files/);
@@ -55,18 +79,20 @@ test("builds bounded capability-adaptive commands with shell-quoted user input",
assert.match(find.command, /'it'"'"'s-app'/);
assert.match(find.command, /'\/srv\/app dir'/);
const grep = buildRemoteGrepCommand({ pattern: "TODO", include: "*.ts", limit: 20 }, "/srv/app");
assert.match(grep.command, /command -v rg/);
assert.match(grep.command, /git-grep/);
assert.match(grep.command, /find .* -exec grep/);
assert.match(grep.command, /head -n 21/);
const grep = buildRemoteGrepCommand({ pattern: "needle+", literal: false, include: "*.ts", includeHidden: true, limit: 20 }, "/srv/app");
assert.match(grep.command, /rg --null/);
assert.match(grep.command, /git .* grep .* -z .* -E/);
assert.match(grep.command, /grep -n -I -E/);
assert.match(grep.command, /PI_SSH_TAKE=21/);
assert.match(grep.command, /--hidden/);
assert.throws(() => buildRemoteGrepCommand({ pattern: "x", include: "sub\/*.ts" }, "/srv/app"), /basename glob without \//);
assert.throws(() => buildRemoteGrepCommand({ pattern: "bad\npattern" }, "/srv/app"), /newline/);
assert.throws(() => buildRemoteFindCommand({ pattern: "x", limit: 201 }, "/srv/app"), /limit/);
});
test("normalizes git paths, truncates rows, and reports the backend", () => {
test("normalizes NUL-delimited git paths, escaped newlines, truncation, and backend", () => {
const result = formatRemoteSearchOutput(
"__PI_SSH_SEARCH_BACKEND__:git-grep\nsrc/a.ts:2:TODO\nsrc/b.ts:3:TODO\nsrc/c.ts:4:TODO\n",
grepProtocol("git-grep", [["src/a.ts", 2, "TODO"], ["src/line\nb.ts", 3, "TODO"], ["src/c.ts", 4, "TODO"]]),
"/srv/app",
2,
"grep",
@@ -75,25 +101,39 @@ test("normalizes git paths, truncates rows, and reports the backend", () => {
assert.equal(result.matchCount, 2);
assert.equal(result.truncated, true);
assert.match(result.text, /\/srv\/app\/src\/a\.ts:2:TODO/);
assert.match(result.text, /src\/line\\nb\.ts:3:TODO/);
assert.doesNotMatch(result.text, /src\/c\.ts/);
});
test("executes remote find through the structured transport with bounded output", async () => {
const transport = new SearchTransport("__PI_SSH_SEARCH_BACKEND__:fd\n/srv/app/a.ts\n");
const result = await runRemoteFind(transport, { pattern: "a", limit: 5 }, "/srv/app", "/home/build");
test("executes remote find from the active workspace while searching a separate root", async () => {
const transport = new SearchTransport("__PI_SSH_SEARCH_BACKEND__:fd\n/data/search/a.ts\n");
const result = await runRemoteFind(transport, { pattern: "a", path: "/data/search", limit: 5 }, "/srv/app", "/home/build");
assert.equal(transport.cwd, "/srv/app");
assert.match(transport.command, /command -v fd/);
assert.match(transport.command, /\/data\/search/);
assert.equal(result.matchCount, 1);
assert.match(result.text, /backend: fd/);
});
test("propagates backend failures without exposing the internal marker", async () => {
const transport = new SearchTransport(
"__PI_SSH_SEARCH_BACKEND__:git-grep\nfatal: invalid regular expression\n",
128,
);
test("searches a single remote file without treating the file as execution cwd", async () => {
const transport = new SearchTransport(grepProtocol("ripgrep", [["/var/log/application.log", 7, "ERROR"]]));
const result = await runRemoteGrep(transport, { pattern: "ERROR", path: "/var/log/application.log" }, "/srv/app", "/home/build");
assert.equal(transport.cwd, "/srv/app");
assert.match(transport.command, /\/var\/log\/application\.log/);
assert.equal(result.matchCount, 1);
assert.match(result.text, /application\.log:7:ERROR/);
});
test("keeps successful stderr warnings out of match rows and uses stderr for failures", async () => {
const warningTransport = new SearchTransport(grepProtocol("ripgrep"), 0, "warning: skipped socket\n");
const warningResult = await runRemoteGrep(warningTransport, { pattern: "ABSENT" }, "/srv/app", "/home/build");
assert.equal(warningResult.matchCount, 0);
assert.match(warningResult.text, /No matches found/);
assert.match(warningResult.text, /Remote warning: warning: skipped socket/);
const failed = new SearchTransport(grepProtocol("git-grep"), 128, "fatal: invalid regular expression\n");
await assert.rejects(
runRemoteGrep(transport, { pattern: "[", literal: false }, "/srv/app", "/home/build"),
runRemoteGrep(failed, { pattern: "[", literal: false }, "/srv/app", "/home/build"),
(error: unknown) => {
assert.match(String(error), /remote grep failed with exit code 128: fatal: invalid regular expression/);
assert.doesNotMatch(String(error), /__PI_SSH_SEARCH_BACKEND__/);
@@ -102,49 +142,100 @@ test("propagates backend failures without exposing the internal marker", async (
);
});
test("adaptive commands preserve no-match success and propagate real backend errors", async () => {
test("reports actionable bounded-search timeout guidance", async () => {
const transport = new SearchTransport("");
transport.exec = () => Promise.reject(new Error("SSH command timed out after 30s"));
await assert.rejects(
runRemoteGrep(transport, { pattern: "TODO", path: "/home/build" }, "/srv/app", "/home/build"),
/remote grep timed out after 30s \(root: \/home\/build\); narrow the remote path with ssh_find/,
);
});
test("forces git-grep and fallback no-match paths and preserves real errors", () => {
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-"));
try {
mkdirSync(join(root, "src"));
writeFileSync(join(root, "src", "a.ts"), "const value = 'TODO';\n", "utf8");
execFileSync("/usr/bin/git", ["init", "-q", root]);
execFileSync("/usr/bin/git", ["-C", root, "add", "src/a.ts"]);
const environment = { ...process.env, PATH: "/usr/bin:/bin" };
const execute = (command: string) => execFileSync("/bin/bash", ["-c", command], {
cwd: root,
encoding: "utf8",
env: environment,
});
const find = buildRemoteFindCommand({ pattern: "a.ts", limit: 5 }, root);
const findResult = formatRemoteSearchOutput(execute(find.command), root, find.limit, "find");
assert.equal(findResult.backend, "git-ls-files");
assert.match(findResult.text, /src\/a\.ts/);
execFileSync(toolPath("git"), ["init", "-q", root]);
execFileSync(toolPath("git"), ["-C", root, "add", "src/a.ts"]);
const gitPath = toolBin(root, ["git"]);
const fallbackPath = toolBin(root, ["find", "grep"]);
const grep = buildRemoteGrepCommand({ pattern: "TODO", include: "*.ts", limit: 5 }, root);
const grepResult = formatRemoteSearchOutput(execute(grep.command), root, grep.limit, "grep");
assert.equal(grepResult.backend, "git-grep");
assert.match(grepResult.text, /src\/a\.ts:1:/);
const gitProcess = execute(grep.command, root, gitPath);
assert.equal(gitProcess.status, 0, gitProcess.stderr.toString());
const gitResult = formatRemoteSearchOutput(gitProcess.stdout, root, grep.limit, "grep");
assert.equal(gitResult.backend, "git-grep");
assert.equal(gitResult.matchCount, 1);
const fallbackProcess = execute(grep.command, root, fallbackPath);
assert.equal(fallbackProcess.status, 0, fallbackProcess.stderr.toString());
const fallbackResult = formatRemoteSearchOutput(fallbackProcess.stdout, root, grep.limit, "grep");
assert.equal(fallbackResult.backend, "grep");
assert.equal(fallbackResult.matchCount, 1);
const noMatch = buildRemoteGrepCommand({ pattern: "ABSENT", include: "*.ts" }, root);
const noMatchProcess = spawnSync("/bin/bash", ["-c", noMatch.command], { cwd: root, encoding: "utf8", env: environment });
assert.equal(noMatchProcess.status, 0);
assert.equal(formatRemoteSearchOutput(noMatchProcess.stdout, root, noMatch.limit, "grep").matchCount, 0);
for (const path of [gitPath, fallbackPath]) {
const process = execute(noMatch.command, root, path);
assert.equal(process.status, 0, process.stderr.toString());
assert.equal(formatRemoteSearchOutput(process.stdout, root, noMatch.limit, "grep").matchCount, 0);
}
const invalidRegex = buildRemoteGrepCommand({ pattern: "[", literal: false }, root);
const invalidProcess = spawnSync("/bin/bash", ["-c", invalidRegex.command], { cwd: root, encoding: "utf8", env: environment });
const invalidProcess = execute(invalidRegex.command, root, fallbackPath);
assert.notEqual(invalidProcess.status, 0);
assert.match(`${invalidProcess.stdout}${invalidProcess.stderr}`, /git-grep|fatal|regular expression/i);
assert.match(invalidProcess.stderr.toString(), /regular expression|bracket/i);
const missingRoot = join(root, "missing");
const missing = buildRemoteFindCommand({ pattern: "anything" }, missingRoot);
const missingProcess = spawnSync("/bin/bash", ["-c", missing.command], { cwd: root, encoding: "utf8", env: environment });
const missing = buildRemoteFindCommand({ pattern: "anything" }, join(root, "missing"));
const missingProcess = execute(missing.command, root, fallbackPath);
assert.notEqual(missingProcess.status, 0);
assert.match(`${missingProcess.stdout}${missingProcess.stderr}`, /find|No such file|not found/i);
assert.match(missingProcess.stderr.toString(), /find|No such file|not found/i);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
const transport = new SearchTransport(execute(grep.command));
const throughTransport = await runRemoteGrep(transport, { pattern: "TODO", include: "*.ts" }, root, root);
assert.equal(throughTransport.matchCount, 1);
test("rg, git-grep, and fallback agree on hidden files, basename globs, and portable ERE", (context) => {
let rg: string;
try { rg = toolPath("rg"); } catch { context.skip("rg is unavailable"); return; }
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-matrix-"));
try {
mkdirSync(join(root, "sub"));
mkdirSync(join(root, ".secret"));
for (const path of ["visible.ts", "sub/nested.ts", ".hidden.ts", ".secret/deep.ts"]) {
writeFileSync(join(root, path), "needle\nneedlee\n", "utf8");
}
execFileSync(toolPath("git"), ["init", "-q", root]);
execFileSync(toolPath("git"), ["-C", root, "add", "."]);
const paths = [toolBin(root, ["rg"]), toolBin(root, ["git"]), toolBin(root, ["find", "grep"])];
assert.equal(toolPath("rg"), rg);
const runCounts = (input: Parameters<typeof buildRemoteGrepCommand>[0]) => paths.map((path) => {
const built = buildRemoteGrepCommand(input, root);
const process = execute(built.command, root, path);
assert.equal(process.status, 0, process.stderr.toString());
return formatRemoteSearchOutput(process.stdout, root, built.limit, "grep").matchCount;
});
assert.deepEqual(runCounts({ pattern: "needle", include: "*.ts" }), [4, 4, 4]);
assert.deepEqual(runCounts({ pattern: "needle", include: "*.ts", includeHidden: true }), [8, 8, 8]);
assert.deepEqual(runCounts({ pattern: "needle+", literal: false, include: "*.ts" }), [4, 4, 4]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("fallback NUL protocol keeps newline filenames as one bounded result", () => {
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-newline-"));
try {
const filename = "line\nbreak.ts";
writeFileSync(join(root, filename), "needle\n", "utf8");
const fallbackPath = toolBin(root, ["find", "grep"]);
const built = buildRemoteGrepCommand({ pattern: "needle", include: "*.ts" }, root);
const process = execute(built.command, root, fallbackPath);
assert.equal(process.status, 0, process.stderr.toString());
const result = formatRemoteSearchOutput(process.stdout, root, built.limit, "grep");
assert.equal(result.matchCount, 1);
assert.match(result.text, /line\\nbreak\.ts:1:needle/);
} finally {
rmSync(root, { recursive: true, force: true });
}
@@ -162,17 +253,17 @@ test("propagates fd and ripgrep failures while preserving ripgrep no-match", ()
const environment = { ...process.env, PATH: `${bin}:/usr/bin:/bin` };
const find = buildRemoteFindCommand({ pattern: "anything" }, root);
const failedFind = spawnSync("/bin/bash", ["-c", find.command], { cwd: root, encoding: "utf8", env: environment });
const failedFind = spawnSync("/bin/bash", ["-c", find.command], { cwd: root, env: environment });
assert.equal(failedFind.status, 3);
assert.match(failedFind.stdout, /fd exploded/);
assert.match(failedFind.stderr.toString(), /fd exploded/);
const grep = buildRemoteGrepCommand({ pattern: "anything" }, root);
const failedGrep = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, encoding: "utf8", env: environment });
const failedGrep = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, env: environment });
assert.equal(failedGrep.status, 2);
assert.match(failedGrep.stdout, /rg exploded/);
assert.match(failedGrep.stderr.toString(), /rg exploded/);
writeFileSync(rg, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
const noMatch = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, encoding: "utf8", env: environment });
const noMatch = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, env: environment });
assert.equal(noMatch.status, 0);
assert.equal(formatRemoteSearchOutput(noMatch.stdout, root, grep.limit, "grep").matchCount, 0);
} finally {