mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
fix(pi-ssh): harden remote execution and search
This commit is contained in:
@@ -2,10 +2,13 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
import { parseConnectInput, SSH_CONNECT_TOOL_METADATA } from "../src/agent-connection.ts";
|
||||
import { getConfiguredHost, parseConnectInput, SSH_CONNECT_TOOL_METADATA } from "../src/agent-connection.ts";
|
||||
|
||||
test("defines the reviewed agent-controlled SSH connection tool", () => {
|
||||
assert.equal(SSH_CONNECT_TOOL_METADATA.name, "ssh_connect");
|
||||
assert.equal(SSH_CONNECT_TOOL_METADATA.executionMode, "sequential");
|
||||
assert.match(SSH_CONNECT_TOOL_METADATA.description, /separate step/);
|
||||
assert.match(SSH_CONNECT_TOOL_METADATA.description, /wait for success/);
|
||||
assert.deepEqual(SSH_CONNECT_TOOL_METADATA.parameters.required, ["hostId"]);
|
||||
assert.ok(SSH_CONNECT_TOOL_METADATA.parameters.properties.remotePath);
|
||||
assert.deepEqual(parseConnectInput({ hostId: " packaging-server " }), { hostId: "packaging-server" });
|
||||
@@ -17,6 +20,32 @@ test("defines the reviewed agent-controlled SSH connection tool", () => {
|
||||
assert.throws(() => parseConnectInput({ hostId: "packaging-server", remotePath: "relative" }), /remote path/);
|
||||
});
|
||||
|
||||
test("unknown hosts report bounded imported host ID alternatives", () => {
|
||||
const host = {
|
||||
hostName: "example.test",
|
||||
user: "builder",
|
||||
port: 22,
|
||||
auth: { type: "password" as const, password: "secret" },
|
||||
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:fixture" },
|
||||
};
|
||||
assert.equal(getConfiguredHost({ version: 1, hosts: { "packaging-server": host } }, "packaging-server"), host);
|
||||
assert.throws(
|
||||
() => getConfiguredHost({ version: 1, hosts: { "packaging-server": host, "build-server": host } }, "connect-packaging-server"),
|
||||
/available imported host IDs: build-server, packaging-server/,
|
||||
);
|
||||
const manyHosts = Object.fromEntries(Array.from({ length: 12 }, (_, index) => [`host-${String(index).padStart(2, "0")}`, host]));
|
||||
assert.throws(
|
||||
() => getConfiguredHost({ version: 1, hosts: manyHosts }, "missing"),
|
||||
(error: unknown) => {
|
||||
assert.match(String(error), /host-00, host-01, host-02/);
|
||||
assert.match(String(error), /… \(\+2 more\)/);
|
||||
assert.doesNotMatch(String(error), /example\.test|secret/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.throws(() => getConfiguredHost({ version: 1, hosts: {} }, "missing"), /no hosts are imported/);
|
||||
});
|
||||
|
||||
test("removes manual and implicit SSH connection surfaces", async () => {
|
||||
const source = await readFile(new URL("../index.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /\.\.\.SSH_CONNECT_TOOL_METADATA/);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { validatePiSshConfig } from "../src/config.ts";
|
||||
import { effectiveValue, effectiveValues, parseSshG } from "../src/import.ts";
|
||||
@@ -61,3 +62,10 @@ test("formats SSH host keys as pinned SHA256 fingerprints", () => {
|
||||
fingerprint: `SHA256:${expected}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("configuration import validates remote HOME and cwd with framed probes", async () => {
|
||||
const source = await readFile(new URL("../scripts/ssh-config.mjs", import.meta.url), "utf8");
|
||||
assert.match(source, /probeRemotePath\(transport, "home"\)/);
|
||||
assert.match(source, /probeRemotePath\(transport, "cwd"\)/);
|
||||
assert.doesNotMatch(source, /transport\.capture\(/);
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ const connection: SshPermissionConnection = {
|
||||
remote: "packaging-server",
|
||||
port: 2222,
|
||||
remoteCwd: "/srv/build",
|
||||
remoteHome: "/home/builder",
|
||||
};
|
||||
|
||||
test("formats reviewed connection requests without exposing credentials", () => {
|
||||
@@ -59,19 +60,27 @@ test("formats reviewed connection requests without exposing credentials", () =>
|
||||
test("formats the SSH target and bounded operation details", () => {
|
||||
assert.equal(
|
||||
formatSshPermissionInput("ssh_read", { path: "src/main.ts", offset: 5, limit: 20 }, connection),
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; read remote path 'src/main.ts', offset 5, limit 20",
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; read remote path '/srv/build/src/main.ts' (requested 'src/main.ts'), offset 5, limit 20",
|
||||
);
|
||||
assert.equal(
|
||||
formatSshPermissionInput("ssh_write", { path: "dist/a.txt", content: "one\ntwo" }, connection),
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; write remote path 'dist/a.txt' (2 lines, 7 characters)",
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; write remote path '/srv/build/dist/a.txt' (requested 'dist/a.txt') (2 lines, 7 characters)",
|
||||
);
|
||||
assert.equal(
|
||||
formatSshPermissionInput("ssh_grep", { pattern: "TODO", path: "src", include: "*.ts", limit: 25 }, connection),
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; search remote file contents under 'src', for 'TODO', limit 25, file glob '*.ts'",
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; search remote file contents under '/srv/build/src' (requested 'src'), for 'TODO', limit 25, file glob '*.ts'",
|
||||
);
|
||||
assert.equal(
|
||||
formatSshPermissionInput("ssh_cd", { path: "../release" }, connection),
|
||||
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; change the active remote cwd to '/srv/release' (requested '../release')",
|
||||
);
|
||||
assert.match(
|
||||
formatSshPermissionInput("ssh_read", { path: "~/logs/app.log" }, connection),
|
||||
/remote path '\/home\/builder\/logs\/app\.log' \(requested '~\/logs\/app\.log'\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("registers previews and disables local path extraction for remote file tools", () => {
|
||||
test("registers previews and disables local path extraction for remote path tools", () => {
|
||||
const { service, formatters, extractors } = makeService();
|
||||
const pi = makePi();
|
||||
const dispose = installSshPermissionIntegration(
|
||||
@@ -84,10 +93,11 @@ test("registers previews and disables local path extraction for remote file tool
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual([...formatters.keys()], ["ssh_connect", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]);
|
||||
assert.deepEqual([...extractors.keys()], ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"]);
|
||||
assert.deepEqual([...formatters.keys()], ["ssh_connect", "ssh_cd", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]);
|
||||
assert.deepEqual([...extractors.keys()], ["ssh_cd", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"]);
|
||||
assert.equal(extractors.get("ssh_read")?.({ path: "/remote/secret" }), undefined);
|
||||
assert.equal(extractors.get("ssh_grep")?.({ path: "/remote/src" }), undefined);
|
||||
assert.equal(extractors.get("ssh_cd")?.({ path: "/remote/release" }), undefined);
|
||||
assert.match(formatters.get("ssh_bash")?.({ command: "git push" }) ?? "", /packaging-server:2222/);
|
||||
assert.match(formatters.get("ssh_connect")?.({ hostId: "packaging-server" }) ?? "", /establish a persistent SSH2 connection/);
|
||||
|
||||
@@ -108,8 +118,8 @@ test("registers when the permission service becomes ready and cleans up on shutd
|
||||
|
||||
published = service;
|
||||
pi.emitEvent("permissions:ready");
|
||||
assert.equal(formatters.size, 7);
|
||||
assert.equal(extractors.size, 5);
|
||||
assert.equal(formatters.size, 8);
|
||||
assert.equal(extractors.size, 6);
|
||||
|
||||
pi.emit("session_shutdown");
|
||||
assert.equal(formatters.size, 0);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createRemoteBashOps } from "../src/remote-bash.ts";
|
||||
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
|
||||
|
||||
class CapturingTransport implements RemoteTransport {
|
||||
calls: Array<{ command: string; cwd: string }> = [];
|
||||
connect(): Promise<void> { return Promise.resolve(); }
|
||||
dispose(): Promise<void> { return Promise.resolve(); }
|
||||
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||
this.calls.push({ command, cwd });
|
||||
options.onData(Buffer.from("ok\n"));
|
||||
return Promise.resolve({ exitCode: 0 });
|
||||
}
|
||||
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
|
||||
readFile(): Promise<Buffer> { throw new Error("not used"); }
|
||||
ensureReadable(): Promise<void> { throw new Error("not used"); }
|
||||
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
|
||||
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
|
||||
mkdir(): Promise<void> { throw new Error("not used"); }
|
||||
writeFile(): Promise<void> { throw new Error("not used"); }
|
||||
}
|
||||
|
||||
test("ssh_bash always executes from the active remote cwd", async () => {
|
||||
const connection = { remoteCwd: "/srv/project" };
|
||||
const transport = new CapturingTransport();
|
||||
const operations = createRemoteBashOps(connection, transport);
|
||||
|
||||
await operations.exec("pwd", "/Users/local/project", { onData() {} });
|
||||
assert.deepEqual(transport.calls, [{ command: "pwd", cwd: "/srv/project" }]);
|
||||
|
||||
connection.remoteCwd = "/opt/next-project";
|
||||
await operations.exec("npm test", "/another/local/path", { onData() {} });
|
||||
assert.deepEqual(transport.calls[1], { command: "npm test", cwd: "/opt/next-project" });
|
||||
});
|
||||
|
||||
test("ssh_bash rejects an invalid non-absolute remote cwd", () => {
|
||||
const operations = createRemoteBashOps({ remoteCwd: "relative/path" }, new CapturingTransport());
|
||||
assert.throws(
|
||||
() => operations.exec("pwd", "/Users/local/project", { onData() {} }),
|
||||
/requires an absolute remote cwd/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createRemoteBashOps } from "../src/remote-bash.ts";
|
||||
import {
|
||||
changeRemoteCwd,
|
||||
mapLocalPathToRemote,
|
||||
resolveRemoteCwd,
|
||||
SSH_CD_EXECUTION_MODE,
|
||||
} from "../src/remote-cwd.ts";
|
||||
import { resolveRemoteSearchPath } from "../src/remote-search.ts";
|
||||
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
|
||||
|
||||
class CapturingTransport implements RemoteTransport {
|
||||
calls: Array<{ command: string; cwd: string }> = [];
|
||||
connect(): Promise<void> { return Promise.resolve(); }
|
||||
dispose(): Promise<void> { return Promise.resolve(); }
|
||||
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||
this.calls.push({ command, cwd });
|
||||
options.onData(Buffer.from("ok\n"));
|
||||
return Promise.resolve({ exitCode: 0 });
|
||||
}
|
||||
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
|
||||
readFile(): Promise<Buffer> { throw new Error("not used"); }
|
||||
ensureReadable(): Promise<void> { throw new Error("not used"); }
|
||||
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
|
||||
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
|
||||
mkdir(): Promise<void> { throw new Error("not used"); }
|
||||
writeFile(): Promise<void> { throw new Error("not used"); }
|
||||
}
|
||||
|
||||
test("resolves explicit remote workspace changes", () => {
|
||||
assert.equal(resolveRemoteCwd("services/api", "/srv/project", "/home/build"), "/srv/project/services/api");
|
||||
assert.equal(resolveRemoteCwd("../shared", "/srv/project", "/home/build"), "/srv/shared");
|
||||
assert.equal(resolveRemoteCwd("/opt/app/../release", "/srv/project", "/home/build"), "/opt/release");
|
||||
assert.equal(resolveRemoteCwd("~", "/srv/project", "/home/build"), "/home/build");
|
||||
assert.equal(resolveRemoteCwd("~/jobs/app", "/srv/project", "/home/build"), "/home/build/jobs/app");
|
||||
});
|
||||
|
||||
test("rejects invalid remote workspace paths", () => {
|
||||
assert.throws(() => resolveRemoteCwd("", "/srv/project", "/home/build"), /non-empty/);
|
||||
assert.throws(() => resolveRemoteCwd("bad\npath", "/srv/project", "/home/build"), /NUL or newline/);
|
||||
assert.throws(() => resolveRemoteCwd("~other/project", "/srv/project", "/home/build"), /only '~' or '~\/'/);
|
||||
});
|
||||
|
||||
test("ssh_cd is a sequential workspace transition", () => {
|
||||
assert.equal(SSH_CD_EXECUTION_MODE, "sequential");
|
||||
});
|
||||
|
||||
test("a validated workspace change drives subsequent remote tools", async () => {
|
||||
const connection = {
|
||||
remoteCwd: "/srv/project",
|
||||
remoteHome: "/home/build",
|
||||
localCwd: "/Users/local/project",
|
||||
localHome: "/Users/local",
|
||||
};
|
||||
const verified: string[] = [];
|
||||
|
||||
const changed = await changeRemoteCwd(connection, "../release", async (requestedCwd) => {
|
||||
verified.push(requestedCwd);
|
||||
return requestedCwd;
|
||||
});
|
||||
|
||||
assert.deepEqual(verified, ["/srv/release"]);
|
||||
assert.deepEqual(changed, { previousCwd: "/srv/project", remoteCwd: "/srv/release" });
|
||||
assert.equal(connection.remoteCwd, "/srv/release");
|
||||
assert.equal(mapLocalPathToRemote("/Users/local/project/logs/build.log", connection), "/srv/release/logs/build.log");
|
||||
assert.equal(resolveRemoteSearchPath(undefined, connection.remoteCwd, connection.remoteHome), "/srv/release");
|
||||
|
||||
const transport = new CapturingTransport();
|
||||
const operations = createRemoteBashOps(connection, transport);
|
||||
await operations.exec("npm test", "/Users/local/project", { onData() {} });
|
||||
assert.deepEqual(transport.calls, [{ command: "npm test", cwd: "/srv/release" }]);
|
||||
});
|
||||
|
||||
test("a failed workspace validation leaves the previous cwd active", async () => {
|
||||
const connection = { remoteCwd: "/srv/project", remoteHome: "/home/build" };
|
||||
|
||||
await assert.rejects(
|
||||
changeRemoteCwd(connection, "missing", async () => {
|
||||
throw new Error("not a directory");
|
||||
}),
|
||||
/not a directory/,
|
||||
);
|
||||
assert.equal(connection.remoteCwd, "/srv/project");
|
||||
});
|
||||
|
||||
test("successive workspace changes resolve from the latest confirmed cwd", async () => {
|
||||
const connection = { remoteCwd: "/srv/project", remoteHome: "/home/build" };
|
||||
const verify = async (requestedCwd: string) => requestedCwd;
|
||||
|
||||
await changeRemoteCwd(connection, "services/api", verify);
|
||||
await changeRemoteCwd(connection, "../worker", verify);
|
||||
|
||||
assert.equal(connection.remoteCwd, "/srv/project/services/worker");
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { parseRemotePathProbe, probeRemotePath } from "../src/remote-probe.ts";
|
||||
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
|
||||
|
||||
class ProbeTransport implements RemoteTransport {
|
||||
command = "";
|
||||
cwd = "";
|
||||
connect(): Promise<void> { return Promise.resolve(); }
|
||||
dispose(): Promise<void> { return Promise.resolve(); }
|
||||
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
|
||||
this.command = command;
|
||||
this.cwd = cwd;
|
||||
const start = command.match(/'(__PI_SSH_PROBE_[a-f0-9]+_START__)'/)?.[1];
|
||||
const end = command.match(/'(__PI_SSH_PROBE_[a-f0-9]+_END__)'/)?.[1];
|
||||
if (!start || !end) throw new Error("probe markers missing");
|
||||
options.onData(Buffer.from(`login banner\n${start}/srv/project${end}\nlogout banner\n`));
|
||||
return Promise.resolve({ exitCode: 0 });
|
||||
}
|
||||
capture(): Promise<{ exitCode: number | null; output: Buffer }> { throw new Error("not used"); }
|
||||
readFile(): Promise<Buffer> { throw new Error("not used"); }
|
||||
ensureReadable(): Promise<void> { throw new Error("not used"); }
|
||||
ensureReadableWritable(): Promise<void> { throw new Error("not used"); }
|
||||
detectImageMimeType(): Promise<string | null> { throw new Error("not used"); }
|
||||
mkdir(): Promise<void> { throw new Error("not used"); }
|
||||
writeFile(): Promise<void> { throw new Error("not used"); }
|
||||
}
|
||||
|
||||
test("extracts one framed absolute path while ignoring shell startup output", async () => {
|
||||
const transport = new ProbeTransport();
|
||||
assert.equal(await probeRemotePath(transport, "cwd", "/srv"), "/srv/project");
|
||||
assert.equal(transport.cwd, "/srv");
|
||||
assert.match(transport.command, /pwd -P/);
|
||||
});
|
||||
|
||||
test("rejects unframed, relative, multiline, and oversized path probes", () => {
|
||||
assert.throws(() => parseRemotePathProbe(Buffer.from("/srv"), "START", "END", "cwd"), /invalid framed/);
|
||||
assert.throws(() => parseRemotePathProbe(Buffer.from("STARTrelativeEND"), "START", "END", "cwd"), /absolute POSIX path/);
|
||||
assert.throws(() => parseRemotePathProbe(Buffer.from("START/srv\notherEND"), "START", "END", "cwd"), /absolute POSIX path/);
|
||||
assert.throws(
|
||||
() => parseRemotePathProbe(Buffer.alloc(64 * 1024 + 1), "START", "END", "cwd"),
|
||||
/exceeded 65536 bytes/,
|
||||
);
|
||||
});
|
||||
|
||||
test("forwards cancellation to the probe exec call", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const transport = new ProbeTransport();
|
||||
const original = transport.exec.bind(transport);
|
||||
transport.exec = (command, cwd, options) => {
|
||||
assert.equal(options.signal, controller.signal);
|
||||
if (options.signal?.aborted) return Promise.reject(new Error("SSH command aborted"));
|
||||
return original(command, cwd, options);
|
||||
};
|
||||
await assert.rejects(probeRemotePath(transport, "home", ".", controller.signal), /SSH command aborted/);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -182,12 +182,15 @@ test("connects with password auth, pins the host key, and streams exec output",
|
||||
assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey("wrong")), false);
|
||||
|
||||
const output: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
const result = await transport.exec("printf ok", "/srv/build", {
|
||||
onData: (data) => output.push(data),
|
||||
onStderr: (data) => stderr.push(data),
|
||||
timeout: 5,
|
||||
});
|
||||
assert.equal(result.exitCode, 7);
|
||||
assert.equal(Buffer.concat(output).toString("utf8"), "stdout\nstderr\n");
|
||||
assert.equal(Buffer.concat(output).toString("utf8"), "stdout\n");
|
||||
assert.equal(Buffer.concat(stderr).toString("utf8"), "stderr\n");
|
||||
assert.match(fake.command ?? "", /^cd -- '\/srv\/build' && bash -lc 'printf ok' <\/dev\/null$/);
|
||||
await transport.dispose();
|
||||
assert.equal(fake.ended, true);
|
||||
@@ -279,6 +282,64 @@ test("falls back to direct overwrite when SFTP v3 rename cannot replace", async
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test("aborts an in-progress SSH connection attempt", async () => {
|
||||
const fake = new FakeClient();
|
||||
fake.connectAction = () => {};
|
||||
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
|
||||
const controller = new AbortController();
|
||||
const connecting = transport.connect(controller.signal);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
controller.abort();
|
||||
await assert.rejects(connecting, /SSH connection aborted/);
|
||||
assert.equal(fake.destroyed, true);
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test("allows four independent exec channels and bounds additional commands", async () => {
|
||||
const fake = new FakeClient();
|
||||
const channels: FakeChannel[] = [];
|
||||
fake.execAction = (_command, callback) => {
|
||||
const channel = new FakeChannel();
|
||||
channels.push(channel);
|
||||
callback(undefined, channel as ClientChannel);
|
||||
};
|
||||
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
|
||||
await transport.connect();
|
||||
const commands = Array.from({ length: 5 }, (_, index) =>
|
||||
transport.exec(`command-${index}`, "/srv", { onData() {}, timeout: 0 }),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(channels.length, 4);
|
||||
channels[0]?.emit("close", 0);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(channels.length, 5);
|
||||
for (const channel of channels.slice(1)) channel.emit("close", 0);
|
||||
await Promise.all(commands);
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test("cancels an exec while it is waiting for a concurrency slot", async () => {
|
||||
const fake = new FakeClient();
|
||||
const channels: FakeChannel[] = [];
|
||||
fake.execAction = (_command, callback) => {
|
||||
const channel = new FakeChannel();
|
||||
channels.push(channel);
|
||||
callback(undefined, channel as ClientChannel);
|
||||
};
|
||||
const transport = new Ssh2Transport(passwordHost(), fake as unknown as Client);
|
||||
await transport.connect();
|
||||
const active = Array.from({ length: 4 }, () => transport.exec("sleep", "/srv", { onData() {}, timeout: 0 }));
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const controller = new AbortController();
|
||||
const queued = transport.exec("queued", "/srv", { onData() {}, timeout: 0, signal: controller.signal });
|
||||
controller.abort();
|
||||
await assert.rejects(queued, /SSH command aborted/);
|
||||
assert.equal(channels.length, 4);
|
||||
for (const channel of channels) channel.emit("close", 0);
|
||||
await Promise.all(active);
|
||||
await transport.dispose();
|
||||
});
|
||||
|
||||
test("aborts and times out commands by closing the active channel", async () => {
|
||||
const abortClient = new FakeClient();
|
||||
const abortChannel = new FakeChannel();
|
||||
|
||||
Reference in New Issue
Block a user