feat: add pure ssh2 remote operations

This commit is contained in:
云服务部-叶林立
2026-08-21 19:51:43 +08:00
parent d3bf562189
commit 0ac50eb581
62 changed files with 3701 additions and 47 deletions
+63
View File
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import test from "node:test";
import { validatePiSshConfig } from "../src/config.ts";
import { effectiveValue, effectiveValues, parseSshG } from "../src/import.ts";
import { fingerprintHostKey } from "../src/ssh2-transport.ts";
test("parses the effective ssh -G output including repeated identity files", () => {
const parsed = parseSshG([
"host packaging-server",
"hostname 192.0.2.20",
"user builder",
"port 2222",
"identityfile ~/.ssh/first",
"identityfile ~/.ssh/second",
"proxyjump none",
].join("\n"));
assert.equal(effectiveValue(parsed, "hostname"), "192.0.2.20");
assert.equal(effectiveValue(parsed, "port"), "2222");
assert.deepEqual(effectiveValues(parsed, "identityfile"), ["~/.ssh/first", "~/.ssh/second"]);
});
test("validates password and private-key host definitions", () => {
const password = validatePiSshConfig({
version: 1,
hosts: {
build: {
hostName: "build.example.test",
user: "builder",
port: 22,
auth: { type: "password", password: "secret" },
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:value" },
},
},
groups: { builders: { label: "Builders", hosts: ["build"] } },
});
assert.equal(password.hosts.build.auth.type, "password");
assert.throws(() => validatePiSshConfig({
version: 1,
hosts: {
build: {
hostName: "build.example.test",
user: "builder",
port: 22,
auth: { type: "password", password: "" },
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:value" },
},
},
}), /password/);
});
test("formats SSH host keys as pinned SHA256 fingerprints", () => {
const algorithm = Buffer.from("ssh-ed25519", "ascii");
const length = Buffer.alloc(4);
length.writeUInt32BE(algorithm.length);
const key = Buffer.concat([length, algorithm, Buffer.from("public-key-fixture")]);
const expected = createHash("sha256").update(key).digest("base64").replace(/=+$/, "");
assert.deepEqual(fingerprintHostKey(key), {
algorithm: "ssh-ed25519",
fingerprint: `SHA256:${expected}`,
});
});
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import test from "node:test";
import { resolveVaultPaths, type PiSshConfig } from "../src/config.ts";
import { saveVault } from "../src/vault.ts";
const fixture: PiSshConfig = {
version: 1,
hosts: {
packaging: {
label: "Packaging",
hostName: "192.0.2.15",
user: "builder",
port: 22,
auth: { type: "password", password: "never-print-this" },
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:fixture" },
},
},
};
test("the configuration CLI lists hosts without revealing credentials", () => {
const home = mkdtempSync(join(tmpdir(), "pi-ssh-cli-"));
const configHome = join(home, "config");
saveVault(fixture, resolveVaultPaths({ XDG_CONFIG_HOME: configHome }, process.platform, home));
const result = spawnSync(resolve("../ssh_config.sh"), ["list"], {
encoding: "utf8",
env: { ...process.env, HOME: home, XDG_CONFIG_HOME: configHome },
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /packaging\s+Packaging\s+builder@192\.0\.2\.15:22\s+password/);
assert.doesNotMatch(`${result.stdout}${result.stderr}`, /never-print-this/);
});
+10
View File
@@ -0,0 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const extensionSource = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
test("does not discover or inject remote project instruction files", () => {
assert.doesNotMatch(extensionSource, /AGENTS\.md|CLAUDE\.md/);
assert.doesNotMatch(extensionSource, /loadRemoteContext|Remote Project Context/);
});
+101
View File
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { PermissionsService } from "@gotgenes/pi-permission-system";
import {
formatSshPermissionInput,
installSshPermissionIntegration,
type SshPermissionConnection,
} from "../permission-integration.ts";
function makeService() {
const formatters = new Map<string, (input: Record<string, unknown>) => string | undefined>();
const extractors = new Map<string, (input: Record<string, unknown>) => string | undefined>();
const service = {
registerToolInputFormatter(name: string, formatter: (input: Record<string, unknown>) => string | undefined) {
formatters.set(name, formatter);
return () => formatters.delete(name);
},
registerToolAccessExtractor(name: string, extractor: (input: Record<string, unknown>) => string | undefined) {
extractors.set(name, extractor);
return () => extractors.delete(name);
},
} as unknown as PermissionsService;
return { service, formatters, extractors };
}
function makePi() {
const hooks = new Map<string, Array<() => void>>();
const eventHooks = new Map<string, Array<() => void>>();
const add = (map: Map<string, Array<() => void>>, name: string, handler: () => void) => {
map.set(name, [...(map.get(name) ?? []), handler]);
};
return {
api: {
on: (name: string, handler: () => void) => add(hooks, name, handler),
events: { on: (name: string, handler: () => void) => add(eventHooks, name, handler) },
},
emit: (name: string) => hooks.get(name)?.forEach((handler) => handler()),
emitEvent: (name: string) => eventHooks.get(name)?.forEach((handler) => handler()),
};
}
const connection: SshPermissionConnection = {
remote: "packaging-server",
port: 2222,
remoteCwd: "/srv/build",
};
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",
);
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)",
);
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'",
);
});
test("registers previews and disables local path extraction for remote file tools", () => {
const { service, formatters, extractors } = makeService();
const pi = makePi();
const dispose = installSshPermissionIntegration(
pi.api as never,
() => connection,
{ getPermissionsService: () => service, permissionsReadyChannel: "permissions:ready" },
);
assert.deepEqual([...formatters.keys()], ["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.equal(extractors.get("ssh_read")?.({ path: "/remote/secret" }), undefined);
assert.equal(extractors.get("ssh_grep")?.({ path: "/remote/src" }), undefined);
assert.match(formatters.get("ssh_bash")?.({ command: "git push" }) ?? "", /packaging-server:2222/);
dispose();
assert.equal(formatters.size, 0);
assert.equal(extractors.size, 0);
});
test("registers when the permission service becomes ready and cleans up on shutdown", () => {
const { service, formatters, extractors } = makeService();
const pi = makePi();
let published: PermissionsService | undefined;
installSshPermissionIntegration(pi.api as never, () => connection, {
getPermissionsService: () => published,
permissionsReadyChannel: "permissions:ready",
});
assert.equal(formatters.size, 0);
published = service;
pi.emitEvent("permissions:ready");
assert.equal(formatters.size, 6);
assert.equal(extractors.size, 5);
pi.emit("session_shutdown");
assert.equal(formatters.size, 0);
assert.equal(extractors.size, 0);
});
+181
View File
@@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
buildRemoteFindCommand,
buildRemoteGrepCommand,
formatRemoteSearchOutput,
resolveRemoteSearchPath,
runRemoteFind,
runRemoteGrep,
} from "../src/remote-search.ts";
import type { RemoteExecOptions, RemoteTransport } from "../src/ssh2-transport.ts";
class SearchTransport implements RemoteTransport {
command = "";
cwd = "";
private readonly output: string;
private readonly exitCode: number | null;
constructor(output: string, exitCode: number | null = 0) {
this.output = output;
this.exitCode = exitCode;
}
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;
options.onData(Buffer.from(this.output));
return Promise.resolve({ exitCode: this.exitCode });
}
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 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");
});
test("builds bounded capability-adaptive commands with shell-quoted user input", () => {
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/);
assert.match(find.command, /head -n 13/);
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/);
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", () => {
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",
"/srv/app",
2,
"grep",
);
assert.equal(result.backend, "git-grep");
assert.equal(result.matchCount, 2);
assert.equal(result.truncated, true);
assert.match(result.text, /\/srv\/app\/src\/a\.ts:2: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");
assert.equal(transport.cwd, "/srv/app");
assert.match(transport.command, /command -v fd/);
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,
);
await assert.rejects(
runRemoteGrep(transport, { 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__/);
return true;
},
);
});
test("adaptive commands preserve no-match success and propagate real backend errors", async () => {
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/);
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 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);
const invalidRegex = buildRemoteGrepCommand({ pattern: "[", literal: false }, root);
const invalidProcess = spawnSync("/bin/bash", ["-c", invalidRegex.command], { cwd: root, encoding: "utf8", env: environment });
assert.notEqual(invalidProcess.status, 0);
assert.match(`${invalidProcess.stdout}${invalidProcess.stderr}`, /git-grep|fatal|regular expression/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 });
assert.notEqual(missingProcess.status, 0);
assert.match(`${missingProcess.stdout}${missingProcess.stderr}`, /find|No such file|not found/i);
const transport = new SearchTransport(execute(grep.command));
const throughTransport = await runRemoteGrep(transport, { pattern: "TODO", include: "*.ts" }, root, root);
assert.equal(throughTransport.matchCount, 1);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("propagates fd and ripgrep failures while preserving ripgrep no-match", () => {
const root = mkdtempSync(join(tmpdir(), "pi-ssh-search-backends-"));
try {
const bin = join(root, "bin");
mkdirSync(bin);
const fd = join(bin, "fd");
const rg = join(bin, "rg");
writeFileSync(fd, "#!/bin/sh\necho 'fd exploded' >&2\nexit 3\n", { mode: 0o755 });
writeFileSync(rg, "#!/bin/sh\necho 'rg exploded' >&2\nexit 2\n", { mode: 0o755 });
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 });
assert.equal(failedFind.status, 3);
assert.match(failedFind.stdout, /fd exploded/);
const grep = buildRemoteGrepCommand({ pattern: "anything" }, root);
const failedGrep = spawnSync("/bin/bash", ["-c", grep.command], { cwd: root, encoding: "utf8", env: environment });
assert.equal(failedGrep.status, 2);
assert.match(failedGrep.stdout, /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 });
assert.equal(noMatch.status, 0);
assert.equal(formatRemoteSearchOutput(noMatch.stdout, root, grep.limit, "grep").matchCount, 0);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+359
View File
@@ -0,0 +1,359 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { Client, ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
import type { SshHostConfig } from "../src/config.ts";
import { fingerprintHostKey, probeHostKey, Ssh2Transport } from "../src/ssh2-transport.ts";
function fixtureKey(label = "fixture"): Buffer {
const algorithm = Buffer.from("ssh-ed25519");
const length = Buffer.alloc(4);
length.writeUInt32BE(algorithm.length);
return Buffer.concat([length, algorithm, Buffer.from(label)]);
}
class FakeChannel extends EventEmitter {
stderr = new EventEmitter();
closeCalls = 0;
signals: string[] = [];
close(): void { this.closeCalls += 1; }
signal(value: string): void { this.signals.push(value); }
}
type ConnectAction = (config: ConnectConfig, client: FakeClient) => void;
type ExecAction = (command: string, callback: (error: Error | undefined, channel: ClientChannel) => void) => void;
class FakeClient extends EventEmitter {
connectConfig?: ConnectConfig;
command?: string;
channel?: FakeChannel;
sftpCalls = 0;
ended = false;
destroyed = false;
sftpValue?: SFTPWrapper;
connectAction?: ConnectAction;
execAction?: ExecAction;
connect(config: ConnectConfig): this {
this.connectConfig = config;
queueMicrotask(() => {
if (this.connectAction) this.connectAction(config, this);
else this.emit("ready");
});
return this;
}
exec(command: string, callback: (error: Error | undefined, channel: ClientChannel) => void): void {
this.command = command;
if (this.execAction) {
this.execAction(command, callback);
return;
}
const channel = new FakeChannel();
this.channel = channel;
callback(undefined, channel as ClientChannel);
queueMicrotask(() => {
channel.emit("data", Buffer.from("stdout\n"));
channel.stderr.emit("data", Buffer.from("stderr\n"));
channel.emit("close", 7);
});
}
sftp(callback: (error: Error | undefined, sftp: SFTPWrapper) => void): void {
this.sftpCalls += 1;
if (!this.sftpValue) callback(new Error("SFTP unavailable"), undefined as unknown as SFTPWrapper);
else callback(undefined, this.sftpValue);
}
end(): this {
this.ended = true;
this.emit("close");
return this;
}
destroy(): this {
this.destroyed = true;
return this;
}
}
class FakeSftp {
readonly files = new Map<string, Buffer>();
readonly directories = new Set<string>(["/"]);
readonly openCalls: Array<{ path: string; flags: string }> = [];
readonly writes: string[] = [];
readonly renames: Array<{ from: string; to: string; atomic: boolean }> = [];
readonly unlinks: string[] = [];
failStandardRename = false;
ext_openssh_rename?: (from: string, to: string, callback: (error?: Error) => void) => void;
constructor(atomicRename = true) {
if (atomicRename) {
this.ext_openssh_rename = (from, to, callback) => {
this.renames.push({ from, to, atomic: true });
const value = this.files.get(from);
if (!value) { callback(new Error("source missing")); return; }
this.files.set(to, value);
this.files.delete(from);
callback();
};
}
}
readFile(path: string, callback: (error: Error | undefined, data: Buffer) => void): void {
const value = this.files.get(path);
if (!value) callback(new Error(`missing file: ${path}`), Buffer.alloc(0));
else callback(undefined, Buffer.from(value));
}
writeFile(path: string, data: Buffer, callback: (error?: Error) => void): void {
this.writes.push(path);
this.files.set(path, Buffer.from(data));
callback();
}
open(path: string, flags: string, callback: (error: Error | undefined, handle: Buffer) => void): void {
this.openCalls.push({ path, flags });
if (!this.files.has(path)) callback(new Error(`cannot open: ${path}`), Buffer.alloc(0));
else callback(undefined, Buffer.from(path));
}
close(_handle: Buffer, callback: (error?: Error) => void): void { callback(); }
mkdir(path: string, callback: (error?: Error) => void): void {
if (this.directories.has(path)) { callback(new Error("already exists")); return; }
this.directories.add(path);
callback();
}
stat(path: string, callback: (error: Error | undefined, stats: object) => void): void {
if (this.directories.has(path)) callback(undefined, {});
else callback(new Error(`missing directory: ${path}`), {});
}
rename(from: string, to: string, callback: (error?: Error) => void): void {
this.renames.push({ from, to, atomic: false });
if (this.failStandardRename) { callback(new Error("replace unsupported")); return; }
const value = this.files.get(from);
if (!value) { callback(new Error("source missing")); return; }
this.files.set(to, value);
this.files.delete(from);
callback();
}
unlink(path: string, callback: (error?: Error) => void): void {
this.unlinks.push(path);
this.files.delete(path);
callback();
}
}
function passwordHost(method: "auto" | "password" | "keyboard-interactive" = "auto"): SshHostConfig {
return {
hostName: "host.example.test",
user: "builder",
port: 2222,
auth: { type: "password", password: "secret", method },
hostKey: fingerprintHostKey(fixtureKey()),
};
}
async function connectedTransport(
sftp?: FakeSftp,
host: SshHostConfig = passwordHost(),
): Promise<{ fake: FakeClient; transport: Ssh2Transport }> {
const fake = new FakeClient();
if (sftp) fake.sftpValue = sftp as unknown as SFTPWrapper;
const transport = new Ssh2Transport(host, fake as unknown as Client);
await transport.connect();
return { fake, transport };
}
test("connects with password auth, pins the host key, and streams exec output", async () => {
const { fake, transport } = await connectedTransport();
assert.equal(fake.connectConfig?.host, "host.example.test");
assert.equal(fake.connectConfig?.username, "builder");
assert.equal(fake.connectConfig?.password, "secret");
assert.equal(fake.connectConfig?.tryKeyboard, true);
assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey()), true);
assert.equal(fake.connectConfig?.hostVerifier?.(fixtureKey("wrong")), false);
const output: Buffer[] = [];
const result = await transport.exec("printf ok", "/srv/build", {
onData: (data) => output.push(data),
timeout: 5,
});
assert.equal(result.exitCode, 7);
assert.equal(Buffer.concat(output).toString("utf8"), "stdout\nstderr\n");
assert.match(fake.command ?? "", /^cd -- '\/srv\/build' && bash -lc 'printf ok' <\/dev\/null$/);
await transport.dispose();
assert.equal(fake.ended, true);
});
test("supports password-only and keyboard-interactive authentication", async () => {
const passwordOnly = await connectedTransport(undefined, passwordHost("password"));
assert.equal(passwordOnly.fake.connectConfig?.tryKeyboard, false);
await passwordOnly.transport.dispose();
const interactive = await connectedTransport(undefined, passwordHost("keyboard-interactive"));
let answers: string[] = [];
interactive.fake.emit(
"keyboard-interactive",
"name",
"instructions",
"",
[{ prompt: "Password:" }, { prompt: "Again:" }],
(values: string[]) => { answers = values; },
);
assert.deepEqual(answers, ["secret", "secret"]);
await interactive.transport.dispose();
});
test("loads private-key authentication and forwards the passphrase", async () => {
const root = mkdtempSync(join(tmpdir(), "pi-ssh-key-"));
try {
const identityFile = join(root, "id_test");
writeFileSync(identityFile, "PRIVATE KEY FIXTURE", { mode: 0o600 });
const host: SshHostConfig = {
...passwordHost(),
auth: { type: "private-key", identityFile, passphrase: "key-secret" },
};
const { fake, transport } = await connectedTransport(undefined, host);
assert.equal(Buffer.from(fake.connectConfig?.privateKey ?? "").toString("utf8"), "PRIVATE KEY FIXTURE");
assert.equal(fake.connectConfig?.passphrase, "key-secret");
assert.equal(fake.connectConfig?.password, undefined);
assert.equal(fake.connectConfig?.tryKeyboard, undefined);
await transport.dispose();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("reads files, checks access modes, detects images, and reuses SFTP", async () => {
const sftp = new FakeSftp();
sftp.files.set("/srv/app/a.txt", Buffer.from("hello"));
sftp.files.set("/srv/app/image.png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
const { fake, transport } = await connectedTransport(sftp);
assert.equal((await transport.readFile("/srv/app/a.txt")).toString("utf8"), "hello");
await transport.ensureReadable("/srv/app/a.txt");
await transport.ensureReadableWritable("/srv/app/a.txt");
assert.deepEqual(sftp.openCalls, [
{ path: "/srv/app/a.txt", flags: "r" },
{ path: "/srv/app/a.txt", flags: "r+" },
]);
assert.equal(await transport.detectImageMimeType("/srv/app/image.png"), "image/png");
assert.equal(await transport.detectImageMimeType("/srv/app/a.txt"), null);
assert.equal(fake.sftpCalls, 1);
await transport.dispose();
});
test("creates parent directories and atomically renames remote writes", async () => {
const sftp = new FakeSftp(true);
const { transport } = await connectedTransport(sftp);
await transport.writeFile("/srv/app/output.txt", Buffer.from("new content"));
assert.equal(sftp.directories.has("/srv"), true);
assert.equal(sftp.directories.has("/srv/app"), true);
assert.equal(sftp.files.get("/srv/app/output.txt")?.toString("utf8"), "new content");
assert.equal(sftp.renames.length, 1);
assert.equal(sftp.renames[0]?.atomic, true);
assert.equal([...sftp.files.keys()].some((path) => path.includes(".pi-ssh-")), false);
await transport.dispose();
});
test("falls back to direct overwrite when SFTP v3 rename cannot replace", async () => {
const sftp = new FakeSftp(false);
sftp.directories.add("/srv");
sftp.directories.add("/srv/app");
sftp.files.set("/srv/app/output.txt", Buffer.from("old"));
sftp.failStandardRename = true;
const { transport } = await connectedTransport(sftp);
await transport.writeFile("/srv/app/output.txt", Buffer.from("replacement"));
assert.equal(sftp.files.get("/srv/app/output.txt")?.toString("utf8"), "replacement");
assert.equal(sftp.renames.length, 1);
assert.equal(sftp.renames[0]?.atomic, false);
assert.equal(sftp.unlinks.length, 1);
assert.equal([...sftp.files.keys()].some((path) => path.includes(".pi-ssh-")), false);
await transport.dispose();
});
test("aborts and times out commands by closing the active channel", async () => {
const abortClient = new FakeClient();
const abortChannel = new FakeChannel();
abortClient.execAction = (_command, callback) => {
abortClient.channel = abortChannel;
callback(undefined, abortChannel as ClientChannel);
};
const abortTransport = new Ssh2Transport(passwordHost(), abortClient as unknown as Client);
await abortTransport.connect();
const controller = new AbortController();
const aborted = abortTransport.exec("sleep 10", "/srv", { onData() {}, signal: controller.signal, timeout: 0 });
await new Promise<void>((resolve) => setImmediate(resolve));
controller.abort();
await assert.rejects(aborted, /SSH command aborted/);
assert.deepEqual(abortChannel.signals, ["KILL"]);
assert.equal(abortChannel.closeCalls, 1);
await abortTransport.dispose();
const timeoutClient = new FakeClient();
const timeoutChannel = new FakeChannel();
timeoutClient.execAction = (_command, callback) => {
timeoutClient.channel = timeoutChannel;
callback(undefined, timeoutChannel as ClientChannel);
};
const timeoutTransport = new Ssh2Transport(passwordHost(), timeoutClient as unknown as Client);
await timeoutTransport.connect();
await assert.rejects(
timeoutTransport.exec("sleep 10", "/srv", { onData() {}, timeout: 0.01 }),
/timed out after 0.01s/,
);
assert.deepEqual(timeoutChannel.signals, ["KILL"]);
assert.equal(timeoutChannel.closeCalls, 1);
await timeoutTransport.dispose();
});
test("fails closed after the SSH connection closes", async () => {
const sftp = new FakeSftp();
sftp.files.set("/srv/a.txt", Buffer.from("a"));
const { fake, transport } = await connectedTransport(sftp);
fake.emit("close");
await assert.rejects(transport.readFile("/srv/a.txt"), /SSH2 connection closed unexpectedly/);
await transport.dispose();
});
test("probes and fingerprints a host key without authenticating", async () => {
const fake = new FakeClient();
const key = fixtureKey("probe");
fake.connectAction = (config, client) => {
assert.equal(config.host, "probe.example.test");
assert.equal(config.port, 2200);
assert.equal(config.username, "probe-user");
assert.equal(config.hostVerifier?.(key), false);
client.emit("close");
};
const result = await probeHostKey("probe.example.test", 2200, "probe-user", fake as unknown as Client);
assert.deepEqual(result, fingerprintHostKey(key));
});
test("rejects host-key probing errors before a key is observed", async () => {
const fake = new FakeClient();
fake.connectAction = (_config, client) => client.emit("error", new Error("network unavailable"));
await assert.rejects(
probeHostKey("probe.example.test", 22, "probe-user", fake as unknown as Client),
/network unavailable/,
);
assert.equal(fake.destroyed, true);
});
test("times out host-key probing when the server never responds", async () => {
const fake = new FakeClient();
fake.connectAction = () => {};
await assert.rejects(
probeHostKey("probe.example.test", 22, "probe-user", fake as unknown as Client, 5),
/timed out while obtaining SSH host key/,
);
assert.equal(fake.destroyed, true);
assert.equal(fake.connectConfig?.readyTimeout, 5);
});
+81
View File
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { mkdtempSync } from "node:fs";
import type { PiSshConfig, VaultPaths } from "../src/config.ts";
import { decryptConfig, encryptConfig, loadVault, rotateVaultKey, saveVault } from "../src/vault.ts";
function fixture(): PiSshConfig {
return {
version: 1,
hosts: {
packaging: {
label: "Packaging",
hostName: "192.0.2.10",
user: "builder",
port: 22,
auth: { type: "password", password: "server-secret", method: "auto" },
hostKey: { algorithm: "ssh-ed25519", fingerprint: "SHA256:fixture" },
},
},
};
}
function paths(): VaultPaths {
const directory = mkdtempSync(join(tmpdir(), "pi-ssh-vault-"));
return {
directory,
encryptedConfig: join(directory, "hosts.enc"),
key: join(directory, "vault.key"),
};
}
test("encrypts and authenticates the complete configuration", () => {
const key = randomBytes(32);
const encrypted = encryptConfig(fixture(), key);
assert.doesNotMatch(encrypted, /server-secret|192\.0\.2\.10|builder/);
assert.deepEqual(decryptConfig(encrypted, key), fixture());
const envelope = JSON.parse(encrypted);
envelope.ciphertext = `${envelope.ciphertext.slice(0, -2)}AA`;
assert.throws(() => decryptConfig(JSON.stringify(envelope), key), /integrity verification/);
assert.throws(() => decryptConfig(encrypted, randomBytes(32)), /integrity verification/);
});
test("writes an owner-only key and encrypted config without plaintext", () => {
const target = paths();
saveVault(fixture(), target);
assert.deepEqual(loadVault(target), fixture());
assert.doesNotMatch(readFileSync(target.encryptedConfig, "utf8"), /server-secret/);
assert.equal(Buffer.from(readFileSync(target.key, "utf8").trim(), "base64").length, 32);
if (process.platform !== "win32") {
assert.equal(statSync(target.directory).mode & 0o777, 0o700);
assert.equal(statSync(target.encryptedConfig).mode & 0o777, 0o600);
assert.equal(statSync(target.key).mode & 0o777, 0o600);
}
});
test("fails closed when vault files are missing or too broadly readable", () => {
const target = paths();
assert.throws(() => loadVault(target), /not configured/);
mkdirSync(target.directory, { recursive: true });
writeFileSync(target.key, `${randomBytes(32).toString("base64")}\n`, { mode: 0o600 });
assert.throws(() => loadVault(target), /not configured/);
saveVault(fixture(), target);
if (process.platform !== "win32") {
chmodSync(target.encryptedConfig, 0o644);
assert.throws(() => loadVault(target), /permissions/);
}
});
test("rotates the adjacent key while preserving the encrypted configuration", () => {
const target = paths();
saveVault(fixture(), target);
const oldKey = readFileSync(target.key, "utf8");
rotateVaultKey(target);
assert.notEqual(readFileSync(target.key, "utf8"), oldKey);
assert.deepEqual(loadVault(target), fixture());
});