summaryrefslogtreecommitdiffhomepage
path: root/packages/ssh/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
commit61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch)
tree2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/ssh/src
parent63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff)
parent727c98c9dae516a2070eb950410314380a20c974 (diff)
downloaddispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.tar.gz
dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.zip
Merge branch 'feature/indent-change' into dev
Diffstat (limited to 'packages/ssh/src')
-rw-r--r--packages/ssh/src/backend.ts292
-rw-r--r--packages/ssh/src/config.test.ts650
-rw-r--r--packages/ssh/src/config.ts394
-rw-r--r--packages/ssh/src/errors.test.ts134
-rw-r--r--packages/ssh/src/errors.ts106
-rw-r--r--packages/ssh/src/extension.ts240
-rw-r--r--packages/ssh/src/hostkey.test.ts152
-rw-r--r--packages/ssh/src/hostkey.ts142
-rw-r--r--packages/ssh/src/index.ts28
-rw-r--r--packages/ssh/src/integration.test.ts272
-rw-r--r--packages/ssh/src/pool.ts650
-rw-r--r--packages/ssh/src/service.ts340
12 files changed, 1700 insertions, 1700 deletions
diff --git a/packages/ssh/src/backend.ts b/packages/ssh/src/backend.ts
index 6531b8f..3e7f536 100644
--- a/packages/ssh/src/backend.ts
+++ b/packages/ssh/src/backend.ts
@@ -14,11 +14,11 @@
*/
import type {
- DirEntry,
- ExecBackend,
- ExecResult,
- SpawnParams,
- StatResult,
+ DirEntry,
+ ExecBackend,
+ ExecResult,
+ SpawnParams,
+ StatResult,
} from "@dispatch/exec-backend";
import type { Client, ClientChannel } from "ssh2";
import { mapSshError } from "./errors.js";
@@ -36,77 +36,77 @@ export type AcquireConnection = (alias: string) => Promise<SshConnection>;
* from `~/.ssh/config` at connect time, so the backend carries no stale params.
*/
export function createSshExecBackend(alias: string, acquire: AcquireConnection): ExecBackend {
- const getConn = (): Promise<SshConnection> => acquire(alias);
-
- return {
- async spawn(params: SpawnParams): Promise<ExecResult> {
- const conn = await getConn();
- const client = await conn.getClient();
- // ssh2 exec has no cwd option → prefix `cd "<cwd>" && <command>`.
- // Shell-quote the cwd so a path with metachars can't break out (plan §7.6).
- const wrapped = `cd ${shellQuote(params.cwd)} && ${params.command}`;
-
- return runExec(client, wrapped, params);
- },
-
- async readFile(path: string): Promise<string> {
- const conn = await getConn();
- const sftp = await conn.getSftp();
- return new Promise<string>((resolve, reject) => {
- sftp.readFile(path, "utf8", (err, data) => {
- if (err !== null && err !== undefined) reject(mapSshError(err, `readFile ${path}`));
- else resolve(data.toString("utf8"));
- });
- });
- },
-
- async writeFile(path: string, content: string): Promise<void> {
- const conn = await getConn();
- const sftp = await conn.getSftp();
- return new Promise<void>((resolve, reject) => {
- sftp.writeFile(path, content, "utf8", (err) => {
- if (err !== null && err !== undefined) reject(mapSshError(err, `writeFile ${path}`));
- else resolve();
- });
- });
- },
-
- async stat(path: string): Promise<StatResult> {
- const conn = await getConn();
- const sftp = await conn.getSftp();
- return new Promise<StatResult>((resolve, reject) => {
- sftp.stat(path, (err, stats) => {
- if (err !== null && err !== undefined) reject(mapSshError(err, `stat ${path}`));
- else resolve({ isFile: stats.isFile(), isDirectory: stats.isDirectory() });
- });
- });
- },
-
- async readdir(path: string): Promise<readonly DirEntry[]> {
- const conn = await getConn();
- const sftp = await conn.getSftp();
- return new Promise<readonly DirEntry[]>((resolve, reject) => {
- sftp.readdir(path, (err, list) => {
- if (err !== null && err !== undefined) reject(mapSshError(err, `readdir ${path}`));
- else
- resolve(
- list.map((e): DirEntry => ({ name: e.filename, isDirectory: e.attrs.isDirectory() })),
- );
- });
- });
- },
-
- async exists(path: string): Promise<boolean> {
- const conn = await getConn();
- const sftp = await conn.getSftp();
- // ssh2's `sftp.exists` invokes the callback with a boolean that is TRUE
- // when the path exists and FALSE when missing (verified empirically).
- // Never throws — a missing path resolves `false`.
- return new Promise<boolean>((resolve) => {
- sftp.exists(path, (exists: boolean) => resolve(exists));
- });
- },
- };
+ const getConn = (): Promise<SshConnection> => acquire(alias);
+
+ return {
+ async spawn(params: SpawnParams): Promise<ExecResult> {
+ const conn = await getConn();
+ const client = await conn.getClient();
+ // ssh2 exec has no cwd option → prefix `cd "<cwd>" && <command>`.
+ // Shell-quote the cwd so a path with metachars can't break out (plan §7.6).
+ const wrapped = `cd ${shellQuote(params.cwd)} && ${params.command}`;
+
+ return runExec(client, wrapped, params);
+ },
+
+ async readFile(path: string): Promise<string> {
+ const conn = await getConn();
+ const sftp = await conn.getSftp();
+ return new Promise<string>((resolve, reject) => {
+ sftp.readFile(path, "utf8", (err, data) => {
+ if (err !== null && err !== undefined) reject(mapSshError(err, `readFile ${path}`));
+ else resolve(data.toString("utf8"));
+ });
+ });
+ },
+
+ async writeFile(path: string, content: string): Promise<void> {
+ const conn = await getConn();
+ const sftp = await conn.getSftp();
+ return new Promise<void>((resolve, reject) => {
+ sftp.writeFile(path, content, "utf8", (err) => {
+ if (err !== null && err !== undefined) reject(mapSshError(err, `writeFile ${path}`));
+ else resolve();
+ });
+ });
+ },
+
+ async stat(path: string): Promise<StatResult> {
+ const conn = await getConn();
+ const sftp = await conn.getSftp();
+ return new Promise<StatResult>((resolve, reject) => {
+ sftp.stat(path, (err, stats) => {
+ if (err !== null && err !== undefined) reject(mapSshError(err, `stat ${path}`));
+ else resolve({ isFile: stats.isFile(), isDirectory: stats.isDirectory() });
+ });
+ });
+ },
+
+ async readdir(path: string): Promise<readonly DirEntry[]> {
+ const conn = await getConn();
+ const sftp = await conn.getSftp();
+ return new Promise<readonly DirEntry[]>((resolve, reject) => {
+ sftp.readdir(path, (err, list) => {
+ if (err !== null && err !== undefined) reject(mapSshError(err, `readdir ${path}`));
+ else
+ resolve(
+ list.map((e): DirEntry => ({ name: e.filename, isDirectory: e.attrs.isDirectory() })),
+ );
+ });
+ });
+ },
+
+ async exists(path: string): Promise<boolean> {
+ const conn = await getConn();
+ const sftp = await conn.getSftp();
+ // ssh2's `sftp.exists` invokes the callback with a boolean that is TRUE
+ // when the path exists and FALSE when missing (verified empirically).
+ // Never throws — a missing path resolves `false`.
+ return new Promise<boolean>((resolve) => {
+ sftp.exists(path, (exists: boolean) => resolve(exists));
+ });
+ },
+ };
}
// ─── spawn core ─────────────────────────────────────────────────────────────
@@ -117,75 +117,75 @@ export function createSshExecBackend(alias: string, acquire: AcquireConnection):
* cleanup semantics so the tool sees the same `ExecResult` shape (plan §4.3/§8).
*/
function runExec(client: Client, command: string, params: SpawnParams): Promise<ExecResult> {
- return new Promise<ExecResult>((resolve) => {
- let settled = false;
- let timedOut = false;
- let timer: ReturnType<typeof setTimeout> | undefined;
- let exitCode: number | null = null;
-
- const settle = (result: ExecResult): void => {
- if (settled) return;
- settled = true;
- if (timer !== undefined) clearTimeout(timer);
- params.signal.removeEventListener("abort", onAbort);
- client.removeListener("error", onClientError);
- resolve(result);
- };
-
- const onAbort = (): void => {
- if (settled) return;
- try {
- stream?.end();
- } catch {
- // best-effort — the remote channel may already be gone
- }
- settle({ exitCode: null, timedOut: false, aborted: true });
- };
-
- // If the client errors mid-exec, surface as a non-zero exit (the turn is
- // NOT aborted — the model sees a normal tool error and can retry; §8).
- const onClientError = (): void => {
- if (!settled) settle({ exitCode: 1, timedOut: false, aborted: false });
- };
- client.on("error", onClientError);
-
- let stream: ClientChannel | undefined;
-
- client.exec(command, { pty: false }, (err, channel) => {
- if (err !== null && err !== undefined) {
- // Spawn error → non-zero exit, like localSpawn's error path.
- settle({ exitCode: 1, timedOut: false, aborted: false });
- return;
- }
- stream = channel;
-
- // stdout: ssh2 channel IS its stdout stream (this.stdin = this.stdout = this).
- channel.on("data", (data: Buffer) => {
- params.onOutput(data.toString(), "stdout");
- });
- channel.stderr.on("data", (data: Buffer) => {
- params.onOutput(data.toString(), "stderr");
- });
- channel.on("exit", (code: number | null) => {
- exitCode = code;
- });
- channel.on("close", () => {
- settle({ exitCode, timedOut, aborted: false });
- });
-
- params.signal.addEventListener("abort", onAbort, { once: true });
- timer = setTimeout(() => {
- if (settled) return;
- timedOut = true;
- try {
- channel.end();
- } catch {
- // best-effort
- }
- settle({ exitCode: null, timedOut: true, aborted: false });
- }, params.timeout);
- });
- });
+ return new Promise<ExecResult>((resolve) => {
+ let settled = false;
+ let timedOut = false;
+ let timer: ReturnType<typeof setTimeout> | undefined;
+ let exitCode: number | null = null;
+
+ const settle = (result: ExecResult): void => {
+ if (settled) return;
+ settled = true;
+ if (timer !== undefined) clearTimeout(timer);
+ params.signal.removeEventListener("abort", onAbort);
+ client.removeListener("error", onClientError);
+ resolve(result);
+ };
+
+ const onAbort = (): void => {
+ if (settled) return;
+ try {
+ stream?.end();
+ } catch {
+ // best-effort — the remote channel may already be gone
+ }
+ settle({ exitCode: null, timedOut: false, aborted: true });
+ };
+
+ // If the client errors mid-exec, surface as a non-zero exit (the turn is
+ // NOT aborted — the model sees a normal tool error and can retry; §8).
+ const onClientError = (): void => {
+ if (!settled) settle({ exitCode: 1, timedOut: false, aborted: false });
+ };
+ client.on("error", onClientError);
+
+ let stream: ClientChannel | undefined;
+
+ client.exec(command, { pty: false }, (err, channel) => {
+ if (err !== null && err !== undefined) {
+ // Spawn error → non-zero exit, like localSpawn's error path.
+ settle({ exitCode: 1, timedOut: false, aborted: false });
+ return;
+ }
+ stream = channel;
+
+ // stdout: ssh2 channel IS its stdout stream (this.stdin = this.stdout = this).
+ channel.on("data", (data: Buffer) => {
+ params.onOutput(data.toString(), "stdout");
+ });
+ channel.stderr.on("data", (data: Buffer) => {
+ params.onOutput(data.toString(), "stderr");
+ });
+ channel.on("exit", (code: number | null) => {
+ exitCode = code;
+ });
+ channel.on("close", () => {
+ settle({ exitCode, timedOut, aborted: false });
+ });
+
+ params.signal.addEventListener("abort", onAbort, { once: true });
+ timer = setTimeout(() => {
+ if (settled) return;
+ timedOut = true;
+ try {
+ channel.end();
+ } catch {
+ // best-effort
+ }
+ settle({ exitCode: null, timedOut: true, aborted: false });
+ }, params.timeout);
+ });
+ });
}
// ─── shell quoting ─────────────────────────────────────────────────────────
@@ -196,5 +196,5 @@ function runExec(client: Client, command: string, params: SpawnParams): Promise<
* value and any embedded single-quote is escaped (`'\''`).
*/
export function shellQuote(value: string): string {
- return `'${value.replace(/'/g, "'\\''")}'`;
+ return `'${value.replace(/'/g, "'\\''")}'`;
}
diff --git a/packages/ssh/src/config.test.ts b/packages/ssh/src/config.test.ts
index 04ccdc5..a54f8d6 100644
--- a/packages/ssh/src/config.test.ts
+++ b/packages/ssh/src/config.test.ts
@@ -1,19 +1,19 @@
import { describe, expect, it } from "vitest";
import {
- isRejected,
- knownHostToken,
- parseKnownHosts,
- resolveComputer,
- resolveComputers,
- type SshConfigResolveEnv,
+ isRejected,
+ knownHostToken,
+ parseKnownHosts,
+ resolveComputer,
+ resolveComputers,
+ type SshConfigResolveEnv,
} from "./config.js";
const env = (overrides: Partial<SshConfigResolveEnv> = {}): SshConfigResolveEnv => ({
- configText: "",
- knownHostsText: "",
- defaultUser: "fallback-user",
- homeDir: "/home/test",
- ...overrides,
+ configText: "",
+ knownHostsText: "",
+ defaultUser: "fallback-user",
+ homeDir: "/home/test",
+ ...overrides,
});
const FIXTURE = `
@@ -41,376 +41,376 @@ Host github.com
`;
describe("resolveComputers", () => {
- it("returns one Computer per named (non-wildcard) Host alias, sorted", () => {
- const computers = resolveComputers(env({ configText: FIXTURE }));
- expect(computers.map((c) => c.alias)).toEqual(["barehost", "github.com", "myserver", "web"]);
- });
-
- it("skips wildcard-only Host patterns (* and ?)", () => {
- const computers = resolveComputers(env({ configText: FIXTURE }));
- // The bare `*` host is a pattern, not a computer — excluded.
- expect(computers.find((c) => c.alias === "*")).toBeUndefined();
- });
-
- it("skips wildcard aliases within a multi-alias Host line (*.example.com)", () => {
- const computers = resolveComputers(env({ configText: FIXTURE }));
- expect(computers.find((c) => c.alias === "*.example.com")).toBeUndefined();
- // but the named alias on the SAME line (web) is included.
- expect(computers.find((c) => c.alias === "web")).toBeDefined();
- });
-
- it("resolves HostName/Port/User/IdentityFile from the config (first-match-wins)", () => {
- const computers = resolveComputers(env({ configText: FIXTURE }));
- const my = computers.find((c) => c.alias === "myserver");
- expect(my).toEqual({
- alias: "myserver",
- hostName: "10.0.0.5",
- port: 2222,
- user: "deploy",
- identityFile: "/home/test/.ssh/deploy_key",
- knownHost: false,
- });
- });
-
- it("falls back HostName → alias when no HostName is set", () => {
- const computers = resolveComputers(env({ configText: FIXTURE }));
- const bare = computers.find((c) => c.alias === "barehost");
- expect(bare?.hostName).toBe("barehost");
- expect(bare?.port).toBe(22);
- expect(bare?.user).toBe("fallback-user");
- expect(bare?.identityFile).toBeNull();
- });
-
- it("expands ~ in IdentityFile to homeDir", () => {
- const computers = resolveComputers(env({ configText: FIXTURE }));
- const gh = computers.find((c) => c.alias === "github.com");
- expect(gh?.identityFile).toBe("/home/test/.ssh/github_key");
- });
-
- it("resolves a Host block whose first alias is a wildcard but later alias is named", () => {
- const computers = resolveComputers(env({ configText: FIXTURE }));
- const web = computers.find((c) => c.alias === "web");
- expect(web?.hostName).toBe("web.internal");
- expect(web?.user).toBe("webuser");
- });
-
- it("de-dups aliases listed in multiple Host lines (first wins)", () => {
- const dup = `
+ it("returns one Computer per named (non-wildcard) Host alias, sorted", () => {
+ const computers = resolveComputers(env({ configText: FIXTURE }));
+ expect(computers.map((c) => c.alias)).toEqual(["barehost", "github.com", "myserver", "web"]);
+ });
+
+ it("skips wildcard-only Host patterns (* and ?)", () => {
+ const computers = resolveComputers(env({ configText: FIXTURE }));
+ // The bare `*` host is a pattern, not a computer — excluded.
+ expect(computers.find((c) => c.alias === "*")).toBeUndefined();
+ });
+
+ it("skips wildcard aliases within a multi-alias Host line (*.example.com)", () => {
+ const computers = resolveComputers(env({ configText: FIXTURE }));
+ expect(computers.find((c) => c.alias === "*.example.com")).toBeUndefined();
+ // but the named alias on the SAME line (web) is included.
+ expect(computers.find((c) => c.alias === "web")).toBeDefined();
+ });
+
+ it("resolves HostName/Port/User/IdentityFile from the config (first-match-wins)", () => {
+ const computers = resolveComputers(env({ configText: FIXTURE }));
+ const my = computers.find((c) => c.alias === "myserver");
+ expect(my).toEqual({
+ alias: "myserver",
+ hostName: "10.0.0.5",
+ port: 2222,
+ user: "deploy",
+ identityFile: "/home/test/.ssh/deploy_key",
+ knownHost: false,
+ });
+ });
+
+ it("falls back HostName → alias when no HostName is set", () => {
+ const computers = resolveComputers(env({ configText: FIXTURE }));
+ const bare = computers.find((c) => c.alias === "barehost");
+ expect(bare?.hostName).toBe("barehost");
+ expect(bare?.port).toBe(22);
+ expect(bare?.user).toBe("fallback-user");
+ expect(bare?.identityFile).toBeNull();
+ });
+
+ it("expands ~ in IdentityFile to homeDir", () => {
+ const computers = resolveComputers(env({ configText: FIXTURE }));
+ const gh = computers.find((c) => c.alias === "github.com");
+ expect(gh?.identityFile).toBe("/home/test/.ssh/github_key");
+ });
+
+ it("resolves a Host block whose first alias is a wildcard but later alias is named", () => {
+ const computers = resolveComputers(env({ configText: FIXTURE }));
+ const web = computers.find((c) => c.alias === "web");
+ expect(web?.hostName).toBe("web.internal");
+ expect(web?.user).toBe("webuser");
+ });
+
+ it("de-dups aliases listed in multiple Host lines (first wins)", () => {
+ const dup = `
Host dup
HostName first.example
Host dup
HostName second.example
`;
- const computers = resolveComputers(env({ configText: dup }));
- expect(computers).toHaveLength(1);
- expect(computers[0]?.hostName).toBe("first.example");
- });
-
- it("knownHost=true when the resolved HostName:port token is in known_hosts", () => {
- // myserver is port 2222 → token is [10.0.0.5]:2222; web is port 22 → bare host.
- const known = "[10.0.0.5]:2222 ssh-ed25519 AAA\nweb.internal ssh-ed25519 BBB\n";
- const computers = resolveComputers(env({ configText: FIXTURE, knownHostsText: known }));
- expect(computers.find((c) => c.alias === "myserver")?.knownHost).toBe(true);
- // default port 22 → token is just the hostName (no bracket).
- expect(computers.find((c) => c.alias === "web")?.knownHost).toBe(true);
- expect(computers.find((c) => c.alias === "barehost")?.knownHost).toBe(false);
- });
-
- it("knownHost keys a non-default port as [host]:port", () => {
- const known = "[10.0.0.5]:2222 ssh-ed25519 AAA\n";
- const computers = resolveComputers(env({ configText: FIXTURE, knownHostsText: known }));
- expect(computers.find((c) => c.alias === "myserver")?.knownHost).toBe(true);
- });
+ const computers = resolveComputers(env({ configText: dup }));
+ expect(computers).toHaveLength(1);
+ expect(computers[0]?.hostName).toBe("first.example");
+ });
+
+ it("knownHost=true when the resolved HostName:port token is in known_hosts", () => {
+ // myserver is port 2222 → token is [10.0.0.5]:2222; web is port 22 → bare host.
+ const known = "[10.0.0.5]:2222 ssh-ed25519 AAA\nweb.internal ssh-ed25519 BBB\n";
+ const computers = resolveComputers(env({ configText: FIXTURE, knownHostsText: known }));
+ expect(computers.find((c) => c.alias === "myserver")?.knownHost).toBe(true);
+ // default port 22 → token is just the hostName (no bracket).
+ expect(computers.find((c) => c.alias === "web")?.knownHost).toBe(true);
+ expect(computers.find((c) => c.alias === "barehost")?.knownHost).toBe(false);
+ });
+
+ it("knownHost keys a non-default port as [host]:port", () => {
+ const known = "[10.0.0.5]:2222 ssh-ed25519 AAA\n";
+ const computers = resolveComputers(env({ configText: FIXTURE, knownHostsText: known }));
+ expect(computers.find((c) => c.alias === "myserver")?.knownHost).toBe(true);
+ });
});
describe("resolveComputer (single alias)", () => {
- it("resolves a named alias", () => {
- const c = resolveComputer("myserver", env({ configText: FIXTURE }));
- expect(c?.hostName).toBe("10.0.0.5");
- expect(c?.port).toBe(2222);
- });
-
- it("returns null for an unknown alias", () => {
- expect(resolveComputer("nope", env({ configText: FIXTURE }))).toBeNull();
- });
-
- it("returns null for a wildcard alias (not a selectable computer)", () => {
- expect(resolveComputer("*.example.com", env({ configText: FIXTURE }))).toBeNull();
- });
-
- it("applies top-level wildcard defaults to a named host (first-match-wins)", () => {
- const cfg = `
+ it("resolves a named alias", () => {
+ const c = resolveComputer("myserver", env({ configText: FIXTURE }));
+ expect(c?.hostName).toBe("10.0.0.5");
+ expect(c?.port).toBe(2222);
+ });
+
+ it("returns null for an unknown alias", () => {
+ expect(resolveComputer("nope", env({ configText: FIXTURE }))).toBeNull();
+ });
+
+ it("returns null for a wildcard alias (not a selectable computer)", () => {
+ expect(resolveComputer("*.example.com", env({ configText: FIXTURE }))).toBeNull();
+ });
+
+ it("applies top-level wildcard defaults to a named host (first-match-wins)", () => {
+ const cfg = `
Host *
ServerAliveInterval 60
User stardefault
Host named
HostName named.example
`;
- const c = resolveComputer("named", env({ configText: cfg }));
- // User inherited from the `Host *` block via first-match-wins.
- expect(c?.user).toBe("stardefault");
- expect(c?.hostName).toBe("named.example");
- });
+ const c = resolveComputer("named", env({ configText: cfg }));
+ // User inherited from the `Host *` block via first-match-wins.
+ expect(c?.user).toBe("stardefault");
+ expect(c?.hostName).toBe("named.example");
+ });
});
describe("knownHostToken", () => {
- it("returns the bare host for the default port (22)", () => {
- expect(knownHostToken("host.example", 22)).toBe("host.example");
- });
+ it("returns the bare host for the default port (22)", () => {
+ expect(knownHostToken("host.example", 22)).toBe("host.example");
+ });
- it("returns [host]:port for a non-default port", () => {
- expect(knownHostToken("host.example", 2222)).toBe("[host.example]:2222");
- });
+ it("returns [host]:port for a non-default port", () => {
+ expect(knownHostToken("host.example", 2222)).toBe("[host.example]:2222");
+ });
});
// ─── known_hosts discovery ─────────────────────────────────────────────────
const KNOWN_HOSTS_FIXTURE = [
- "# comment line",
- "arch-razer ssh-ed25519 AAAA1",
- "xenifse1 ssh-ed25519 AAAA2",
- "xenifse1 ssh-rsa AAAA3",
- "xenifse1 ecdsa-sha2-nistp256 AAAA4",
- "[xenifse1]:2222 ssh-ed25519 AAAA5",
- "github.com ssh-ed25519 AAAA6",
- "|1|+W4/jC7U8rJwiEC2m0KvG2A7uAA=|rWwV8p5J1FfR3k= ssh-ed25519 AAAA7",
- "localhost ssh-ed25519 AAAA8",
- "[localhost]:2222 ssh-ed25519 AAAA9",
+ "# comment line",
+ "arch-razer ssh-ed25519 AAAA1",
+ "xenifse1 ssh-ed25519 AAAA2",
+ "xenifse1 ssh-rsa AAAA3",
+ "xenifse1 ecdsa-sha2-nistp256 AAAA4",
+ "[xenifse1]:2222 ssh-ed25519 AAAA5",
+ "github.com ssh-ed25519 AAAA6",
+ "|1|+W4/jC7U8rJwiEC2m0KvG2A7uAA=|rWwV8p5J1FfR3k= ssh-ed25519 AAAA7",
+ "localhost ssh-ed25519 AAAA8",
+ "[localhost]:2222 ssh-ed25519 AAAA9",
].join("\n");
describe("parseKnownHosts", () => {
- it("extracts hostnames with default port 22 from bare entries", () => {
- const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE);
- const arch = entries.find((e) => e.hostname === "arch-razer");
- expect(arch).toEqual({ hostname: "arch-razer", port: 22 });
- });
-
- it("extracts hostname + port from [host]:port notation", () => {
- const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE);
- // xenifse1 has entries on port 22 (first) and port 2222 — first wins.
- const xen = entries.find((e) => e.hostname === "xenifse1");
- expect(xen?.port).toBe(22);
- // localhost appears as port 22 (first) and [localhost]:2222 — first wins.
- const local = entries.find((e) => e.hostname === "localhost");
- expect(local?.port).toBe(22);
- });
-
- it("deduplicates by hostname (first port wins)", () => {
- const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE);
- const xenCount = entries.filter((e) => e.hostname === "xenifse1").length;
- expect(xenCount).toBe(1);
- // Multiple key types (ed25519, rsa, ecdsa) for the same host:port = 1 entry.
- expect(entries).toHaveLength(4); // arch-razer, xenifse1, github.com, localhost
- });
-
- it("skips hashed entries (|1|...)", () => {
- const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE);
- expect(entries.find((e) => e.hostname.startsWith("|"))).toBeUndefined();
- });
-
- it("skips comment lines and empty lines", () => {
- const entries = parseKnownHosts("\n# comment\n\n \nhost1 ssh-ed25519 AAA\n");
- expect(entries).toHaveLength(1);
- expect(entries[0]?.hostname).toBe("host1");
- });
-
- it("returns empty for empty text", () => {
- expect(parseKnownHosts("")).toEqual([]);
- expect(parseKnownHosts(" \n\n ")).toEqual([]);
- });
-
- it("handles comma-separated host markers", () => {
- const entries = parseKnownHosts("hostA,hostB ssh-ed25519 AAA\n");
- expect(entries.map((e) => e.hostname)).toEqual(["hostA", "hostB"]);
- });
-
- it("preserves non-default port when the host only has [host]:port entries", () => {
- const entries = parseKnownHosts("[specialhost]:9999 ssh-ed25519 AAA\n");
- expect(entries[0]).toEqual({ hostname: "specialhost", port: 9999 });
- });
+ it("extracts hostnames with default port 22 from bare entries", () => {
+ const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE);
+ const arch = entries.find((e) => e.hostname === "arch-razer");
+ expect(arch).toEqual({ hostname: "arch-razer", port: 22 });
+ });
+
+ it("extracts hostname + port from [host]:port notation", () => {
+ const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE);
+ // xenifse1 has entries on port 22 (first) and port 2222 — first wins.
+ const xen = entries.find((e) => e.hostname === "xenifse1");
+ expect(xen?.port).toBe(22);
+ // localhost appears as port 22 (first) and [localhost]:2222 — first wins.
+ const local = entries.find((e) => e.hostname === "localhost");
+ expect(local?.port).toBe(22);
+ });
+
+ it("deduplicates by hostname (first port wins)", () => {
+ const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE);
+ const xenCount = entries.filter((e) => e.hostname === "xenifse1").length;
+ expect(xenCount).toBe(1);
+ // Multiple key types (ed25519, rsa, ecdsa) for the same host:port = 1 entry.
+ expect(entries).toHaveLength(4); // arch-razer, xenifse1, github.com, localhost
+ });
+
+ it("skips hashed entries (|1|...)", () => {
+ const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE);
+ expect(entries.find((e) => e.hostname.startsWith("|"))).toBeUndefined();
+ });
+
+ it("skips comment lines and empty lines", () => {
+ const entries = parseKnownHosts("\n# comment\n\n \nhost1 ssh-ed25519 AAA\n");
+ expect(entries).toHaveLength(1);
+ expect(entries[0]?.hostname).toBe("host1");
+ });
+
+ it("returns empty for empty text", () => {
+ expect(parseKnownHosts("")).toEqual([]);
+ expect(parseKnownHosts(" \n\n ")).toEqual([]);
+ });
+
+ it("handles comma-separated host markers", () => {
+ const entries = parseKnownHosts("hostA,hostB ssh-ed25519 AAA\n");
+ expect(entries.map((e) => e.hostname)).toEqual(["hostA", "hostB"]);
+ });
+
+ it("preserves non-default port when the host only has [host]:port entries", () => {
+ const entries = parseKnownHosts("[specialhost]:9999 ssh-ed25519 AAA\n");
+ expect(entries[0]).toEqual({ hostname: "specialhost", port: 9999 });
+ });
});
// ─── known_hosts discovery merged into resolveComputers ───────────────────
describe("resolveComputers with known_hosts discovery", () => {
- it("discovers computers from known_hosts when no ~/.ssh/config exists", () => {
- const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE }));
- const aliases = computers.map((c) => c.alias);
- expect(aliases).toContain("arch-razer");
- expect(aliases).toContain("xenifse1");
- expect(aliases).toContain("github.com");
- expect(aliases).toContain("localhost");
- });
-
- it("defaulted params for known_hosts-discovered computers", () => {
- const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE }));
- const arch = computers.find((c) => c.alias === "arch-razer");
- expect(arch).toEqual({
- alias: "arch-razer",
- hostName: "arch-razer",
- port: 22,
- user: "fallback-user",
- identityFile: null,
- knownHost: true,
- });
- });
-
- it("config entries take precedence over known_hosts (no duplication)", () => {
- const cfg = `
+ it("discovers computers from known_hosts when no ~/.ssh/config exists", () => {
+ const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE }));
+ const aliases = computers.map((c) => c.alias);
+ expect(aliases).toContain("arch-razer");
+ expect(aliases).toContain("xenifse1");
+ expect(aliases).toContain("github.com");
+ expect(aliases).toContain("localhost");
+ });
+
+ it("defaulted params for known_hosts-discovered computers", () => {
+ const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE }));
+ const arch = computers.find((c) => c.alias === "arch-razer");
+ expect(arch).toEqual({
+ alias: "arch-razer",
+ hostName: "arch-razer",
+ port: 22,
+ user: "fallback-user",
+ identityFile: null,
+ knownHost: true,
+ });
+ });
+
+ it("config entries take precedence over known_hosts (no duplication)", () => {
+ const cfg = `
Host arch-razer
HostName 192.168.1.100
User root
Port 2222
`;
- const computers = resolveComputers(
- env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }),
- );
- const arch = computers.filter((c) => c.alias === "arch-razer");
- expect(arch).toHaveLength(1);
- // Config params win, not the known_hosts defaults.
- expect(arch[0]?.hostName).toBe("192.168.1.100");
- expect(arch[0]?.user).toBe("root");
- expect(arch[0]?.port).toBe(2222);
- });
-
- it("merges config + known_hosts entries (union, sorted)", () => {
- const cfg = `
+ const computers = resolveComputers(
+ env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }),
+ );
+ const arch = computers.filter((c) => c.alias === "arch-razer");
+ expect(arch).toHaveLength(1);
+ // Config params win, not the known_hosts defaults.
+ expect(arch[0]?.hostName).toBe("192.168.1.100");
+ expect(arch[0]?.user).toBe("root");
+ expect(arch[0]?.port).toBe(2222);
+ });
+
+ it("merges config + known_hosts entries (union, sorted)", () => {
+ const cfg = `
Host server-a
HostName 10.0.0.1
`;
- const computers = resolveComputers(
- env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }),
- );
- const aliases = computers.map((c) => c.alias);
- // config entry + known_hosts entries, all unique, sorted.
- expect(aliases).toEqual(["arch-razer", "github.com", "localhost", "server-a", "xenifse1"]);
- });
+ const computers = resolveComputers(
+ env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }),
+ );
+ const aliases = computers.map((c) => c.alias);
+ // config entry + known_hosts entries, all unique, sorted.
+ expect(aliases).toEqual(["arch-razer", "github.com", "localhost", "server-a", "xenifse1"]);
+ });
});
// ─── reject-list filtering ────────────────────────────────────────────────
describe("resolveComputers with rejectPatterns", () => {
- it("filters out exact-match rejected hostnames", () => {
- const computers = resolveComputers(
- env({
- knownHostsText: KNOWN_HOSTS_FIXTURE,
- rejectPatterns: ["github.com"],
- }),
- );
- expect(computers.find((c) => c.alias === "github.com")).toBeUndefined();
- expect(computers.find((c) => c.alias === "arch-razer")).toBeDefined();
- });
-
- it("filters out glob patterns (* matches any sequence)", () => {
- const computers = resolveComputers(
- env({
- knownHostsText: KNOWN_HOSTS_FIXTURE,
- rejectPatterns: ["*.com"],
- }),
- );
- expect(computers.find((c) => c.alias === "github.com")).toBeUndefined();
- expect(computers.find((c) => c.alias === "arch-razer")).toBeDefined();
- });
-
- it("filters multiple patterns", () => {
- const computers = resolveComputers(
- env({
- knownHostsText: KNOWN_HOSTS_FIXTURE,
- rejectPatterns: ["github.com", "localhost"],
- }),
- );
- const aliases = computers.map((c) => c.alias);
- expect(aliases).toEqual(["arch-razer", "xenifse1"]);
- });
-
- it("does NOT filter resolveComputer (single alias lookup ignores reject list)", () => {
- // resolveComputer is for specific lookups (status, test, connect) —
- // the reject list is a discovery/catalog filter, not access control.
- const c = resolveComputer(
- "github.com",
- env({ knownHostsText: KNOWN_HOSTS_FIXTURE, rejectPatterns: ["github.com"] }),
- );
- expect(c).not.toBeNull();
- expect(c?.alias).toBe("github.com");
- });
-
- it("empty rejectPatterns does not filter anything", () => {
- const computers = resolveComputers(
- env({ knownHostsText: KNOWN_HOSTS_FIXTURE, rejectPatterns: [] }),
- );
- expect(computers).toHaveLength(4);
- });
-
- it("absent rejectPatterns does not filter anything", () => {
- const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE }));
- expect(computers).toHaveLength(4);
- });
+ it("filters out exact-match rejected hostnames", () => {
+ const computers = resolveComputers(
+ env({
+ knownHostsText: KNOWN_HOSTS_FIXTURE,
+ rejectPatterns: ["github.com"],
+ }),
+ );
+ expect(computers.find((c) => c.alias === "github.com")).toBeUndefined();
+ expect(computers.find((c) => c.alias === "arch-razer")).toBeDefined();
+ });
+
+ it("filters out glob patterns (* matches any sequence)", () => {
+ const computers = resolveComputers(
+ env({
+ knownHostsText: KNOWN_HOSTS_FIXTURE,
+ rejectPatterns: ["*.com"],
+ }),
+ );
+ expect(computers.find((c) => c.alias === "github.com")).toBeUndefined();
+ expect(computers.find((c) => c.alias === "arch-razer")).toBeDefined();
+ });
+
+ it("filters multiple patterns", () => {
+ const computers = resolveComputers(
+ env({
+ knownHostsText: KNOWN_HOSTS_FIXTURE,
+ rejectPatterns: ["github.com", "localhost"],
+ }),
+ );
+ const aliases = computers.map((c) => c.alias);
+ expect(aliases).toEqual(["arch-razer", "xenifse1"]);
+ });
+
+ it("does NOT filter resolveComputer (single alias lookup ignores reject list)", () => {
+ // resolveComputer is for specific lookups (status, test, connect) —
+ // the reject list is a discovery/catalog filter, not access control.
+ const c = resolveComputer(
+ "github.com",
+ env({ knownHostsText: KNOWN_HOSTS_FIXTURE, rejectPatterns: ["github.com"] }),
+ );
+ expect(c).not.toBeNull();
+ expect(c?.alias).toBe("github.com");
+ });
+
+ it("empty rejectPatterns does not filter anything", () => {
+ const computers = resolveComputers(
+ env({ knownHostsText: KNOWN_HOSTS_FIXTURE, rejectPatterns: [] }),
+ );
+ expect(computers).toHaveLength(4);
+ });
+
+ it("absent rejectPatterns does not filter anything", () => {
+ const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE }));
+ expect(computers).toHaveLength(4);
+ });
});
// ─── resolveComputer known_hosts fallback ─────────────────────────────────
describe("resolveComputer with known_hosts fallback", () => {
- it("resolves a host from known_hosts when not in config", () => {
- const c = resolveComputer("arch-razer", env({ knownHostsText: KNOWN_HOSTS_FIXTURE }));
- expect(c).toEqual({
- alias: "arch-razer",
- hostName: "arch-razer",
- port: 22,
- user: "fallback-user",
- identityFile: null,
- knownHost: true,
- });
- });
-
- it("returns null for a host in neither config nor known_hosts", () => {
- const c = resolveComputer("nonexistent", env({ knownHostsText: KNOWN_HOSTS_FIXTURE }));
- expect(c).toBeNull();
- });
-
- it("config takes precedence over known_hosts for resolveComputer", () => {
- const cfg = `
+ it("resolves a host from known_hosts when not in config", () => {
+ const c = resolveComputer("arch-razer", env({ knownHostsText: KNOWN_HOSTS_FIXTURE }));
+ expect(c).toEqual({
+ alias: "arch-razer",
+ hostName: "arch-razer",
+ port: 22,
+ user: "fallback-user",
+ identityFile: null,
+ knownHost: true,
+ });
+ });
+
+ it("returns null for a host in neither config nor known_hosts", () => {
+ const c = resolveComputer("nonexistent", env({ knownHostsText: KNOWN_HOSTS_FIXTURE }));
+ expect(c).toBeNull();
+ });
+
+ it("config takes precedence over known_hosts for resolveComputer", () => {
+ const cfg = `
Host arch-razer
HostName 192.168.1.100
User root
`;
- const c = resolveComputer(
- "arch-razer",
- env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }),
- );
- expect(c?.hostName).toBe("192.168.1.100");
- expect(c?.user).toBe("root");
- });
+ const c = resolveComputer(
+ "arch-razer",
+ env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }),
+ );
+ expect(c?.hostName).toBe("192.168.1.100");
+ expect(c?.user).toBe("root");
+ });
});
// ─── isRejected glob matching ──────────────────────────────────────────────
describe("isRejected", () => {
- it("matches exact hostname", () => {
- expect(isRejected("github.com", ["github.com"])).toBe(true);
- expect(isRejected("arch-razer", ["github.com"])).toBe(false);
- });
-
- it("matches * glob (any sequence)", () => {
- expect(isRejected("foo.ts.net", ["*.ts.net"])).toBe(true);
- // * matches zero chars, but the remaining ".ts.net" (with literal dot)
- // still doesn't match "ts.net" — the dot is required.
- expect(isRejected("ts.net", ["*.ts.net"])).toBe(false);
- expect(isRejected("foo.other.net", ["*.ts.net"])).toBe(false);
- });
-
- it("matches ? glob (single char)", () => {
- expect(isRejected("host1", ["host?"])).toBe(true);
- expect(isRejected("host12", ["host?"])).toBe(false); // ? = exactly one char
- });
-
- it("is case-insensitive", () => {
- expect(isRejected("GitHub.Com", ["github.com"])).toBe(true);
- expect(isRejected("FOO.TS.NET", ["*.ts.net"])).toBe(true);
- });
-
- it("returns false for absent or empty patterns", () => {
- expect(isRejected("anything")).toBe(false);
- expect(isRejected("anything", [])).toBe(false);
- expect(isRejected("anything", undefined)).toBe(false);
- });
+ it("matches exact hostname", () => {
+ expect(isRejected("github.com", ["github.com"])).toBe(true);
+ expect(isRejected("arch-razer", ["github.com"])).toBe(false);
+ });
+
+ it("matches * glob (any sequence)", () => {
+ expect(isRejected("foo.ts.net", ["*.ts.net"])).toBe(true);
+ // * matches zero chars, but the remaining ".ts.net" (with literal dot)
+ // still doesn't match "ts.net" — the dot is required.
+ expect(isRejected("ts.net", ["*.ts.net"])).toBe(false);
+ expect(isRejected("foo.other.net", ["*.ts.net"])).toBe(false);
+ });
+
+ it("matches ? glob (single char)", () => {
+ expect(isRejected("host1", ["host?"])).toBe(true);
+ expect(isRejected("host12", ["host?"])).toBe(false); // ? = exactly one char
+ });
+
+ it("is case-insensitive", () => {
+ expect(isRejected("GitHub.Com", ["github.com"])).toBe(true);
+ expect(isRejected("FOO.TS.NET", ["*.ts.net"])).toBe(true);
+ });
+
+ it("returns false for absent or empty patterns", () => {
+ expect(isRejected("anything")).toBe(false);
+ expect(isRejected("anything", [])).toBe(false);
+ expect(isRejected("anything", undefined)).toBe(false);
+ });
});
diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts
index 7c4daa3..b01f860 100644
--- a/packages/ssh/src/config.ts
+++ b/packages/ssh/src/config.ts
@@ -34,20 +34,20 @@ import { isKnownHost } from "./hostkey.js";
/** Injected environment for the pure resolver (no ambient process access). */
export interface SshConfigResolveEnv {
- /** The raw `~/.ssh/config` text (may be empty — no file). */
- readonly configText: string;
- /** The raw `~/.ssh/known_hosts` text (drives `knownHost` + discovery). */
- readonly knownHostsText: string;
- /** Fallback user when the config sets none (the current OS user). */
- readonly defaultUser: string;
- /** Home dir, for resolving `~` in `IdentityFile` (already-expanded by caller). */
- readonly homeDir: string;
- /**
- * Glob patterns (e.g. `github.com`, `*.ts.net`) to exclude from the
- * computer catalog. Sourced from `dispatch.toml` `[ssh].reject`. Absent
- * or empty → no filtering.
- */
- readonly rejectPatterns?: readonly string[];
+ /** The raw `~/.ssh/config` text (may be empty — no file). */
+ readonly configText: string;
+ /** The raw `~/.ssh/known_hosts` text (drives `knownHost` + discovery). */
+ readonly knownHostsText: string;
+ /** Fallback user when the config sets none (the current OS user). */
+ readonly defaultUser: string;
+ /** Home dir, for resolving `~` in `IdentityFile` (already-expanded by caller). */
+ readonly homeDir: string;
+ /**
+ * Glob patterns (e.g. `github.com`, `*.ts.net`) to exclude from the
+ * computer catalog. Sourced from `dispatch.toml` `[ssh].reject`. Absent
+ * or empty → no filtering.
+ */
+ readonly rejectPatterns?: readonly string[];
}
/**
@@ -70,50 +70,50 @@ export interface SshConfigResolveEnv {
* Pure: `SshConfigResolveEnv` → `readonly Computer[]`.
*/
export function resolveComputers(env: SshConfigResolveEnv): readonly Computer[] {
- const config = SSHConfig.parse(env.configText);
- const computers: Computer[] = [];
-
- // Source 1: ~/.ssh/config — full-param aliases.
- for (const line of config) {
- // Only `Host` sections define aliases; `Match`/standalone directives aren't
- // selectable computers.
- if (!isHostSection(line)) continue;
- const aliases = readAliasValues(line);
- for (const alias of aliases) {
- if (isWildcardAlias(alias)) continue; // patterns, not targets
- const computer = resolveOne(config, alias, env);
- if (computer !== null) computers.push(computer);
- }
- }
-
- const configAliases = new Set(computers.map((c) => c.alias));
-
- // Source 2: ~/.ssh/known_hosts — discovered hostnames not already in config.
- for (const { hostname, port } of parseKnownHosts(env.knownHostsText)) {
- if (configAliases.has(hostname)) continue; // config takes precedence
- computers.push({
- alias: hostname,
- hostName: hostname,
- port,
- user: env.defaultUser,
- identityFile: null, // pool probes default keys (~/.ssh/id_ed25519, etc.)
- knownHost: true, // it's in known_hosts by definition
- });
- }
-
- // De-dup by alias (a host may be listed in multiple `Host` lines or appear
- // in both config + known_hosts; first wins), then sort for stable FE ordering.
- const seen = new Set<string>();
- const unique = computers.filter((c) => {
- if (seen.has(c.alias)) return false;
- seen.add(c.alias);
- return true;
- });
-
- // Filter out rejected hostnames (glob patterns from dispatch.toml).
- const filtered = unique.filter((c) => !isRejected(c.alias, env.rejectPatterns));
- filtered.sort((a, b) => (a.alias < b.alias ? -1 : a.alias > b.alias ? 1 : 0));
- return filtered;
+ const config = SSHConfig.parse(env.configText);
+ const computers: Computer[] = [];
+
+ // Source 1: ~/.ssh/config — full-param aliases.
+ for (const line of config) {
+ // Only `Host` sections define aliases; `Match`/standalone directives aren't
+ // selectable computers.
+ if (!isHostSection(line)) continue;
+ const aliases = readAliasValues(line);
+ for (const alias of aliases) {
+ if (isWildcardAlias(alias)) continue; // patterns, not targets
+ const computer = resolveOne(config, alias, env);
+ if (computer !== null) computers.push(computer);
+ }
+ }
+
+ const configAliases = new Set(computers.map((c) => c.alias));
+
+ // Source 2: ~/.ssh/known_hosts — discovered hostnames not already in config.
+ for (const { hostname, port } of parseKnownHosts(env.knownHostsText)) {
+ if (configAliases.has(hostname)) continue; // config takes precedence
+ computers.push({
+ alias: hostname,
+ hostName: hostname,
+ port,
+ user: env.defaultUser,
+ identityFile: null, // pool probes default keys (~/.ssh/id_ed25519, etc.)
+ knownHost: true, // it's in known_hosts by definition
+ });
+ }
+
+ // De-dup by alias (a host may be listed in multiple `Host` lines or appear
+ // in both config + known_hosts; first wins), then sort for stable FE ordering.
+ const seen = new Set<string>();
+ const unique = computers.filter((c) => {
+ if (seen.has(c.alias)) return false;
+ seen.add(c.alias);
+ return true;
+ });
+
+ // Filter out rejected hostnames (glob patterns from dispatch.toml).
+ const filtered = unique.filter((c) => !isRejected(c.alias, env.rejectPatterns));
+ filtered.sort((a, b) => (a.alias < b.alias ? -1 : a.alias > b.alias ? 1 : 0));
+ return filtered;
}
/**
@@ -125,103 +125,103 @@ export function resolveComputers(env: SshConfigResolveEnv): readonly Computer[]
* Pure. `compute()` applies OpenSSH first-match-wins + wildcards.
*/
export function resolveComputer(alias: string, env: SshConfigResolveEnv): Computer | null {
- // Source 1: ~/.ssh/config.
- const config = SSHConfig.parse(env.configText);
- if (aliasExistsAsNamedHost(config, alias)) {
- return resolveOne(config, alias, env);
- }
-
- // Source 2: ~/.ssh/known_hosts (defaulted params).
- const knownHosts = parseKnownHosts(env.knownHostsText);
- const entry = knownHosts.find((h) => h.hostname === alias);
- if (entry !== undefined) {
- return {
- alias: entry.hostname,
- hostName: entry.hostname,
- port: entry.port,
- user: env.defaultUser,
- identityFile: null,
- knownHost: true,
- };
- }
-
- return null;
+ // Source 1: ~/.ssh/config.
+ const config = SSHConfig.parse(env.configText);
+ if (aliasExistsAsNamedHost(config, alias)) {
+ return resolveOne(config, alias, env);
+ }
+
+ // Source 2: ~/.ssh/known_hosts (defaulted params).
+ const knownHosts = parseKnownHosts(env.knownHostsText);
+ const entry = knownHosts.find((h) => h.hostname === alias);
+ if (entry !== undefined) {
+ return {
+ alias: entry.hostname,
+ hostName: entry.hostname,
+ port: entry.port,
+ user: env.defaultUser,
+ identityFile: null,
+ knownHost: true,
+ };
+ }
+
+ return null;
}
/** Resolve one alias using a parsed config. Pure. */
function resolveOne(config: SSHConfig, alias: string, env: SshConfigResolveEnv): Computer | null {
- const computed = config.compute(alias);
- const hostName = stringValue(computed.HostName) ?? alias; // falls back to alias
- const port = numberValue(computed.Port) ?? 22;
- const user = stringValue(computed.User) ?? env.defaultUser;
- const identityFile = identityFileValue(computed.IdentityFile, env);
+ const computed = config.compute(alias);
+ const hostName = stringValue(computed.HostName) ?? alias; // falls back to alias
+ const port = numberValue(computed.Port) ?? 22;
+ const user = stringValue(computed.User) ?? env.defaultUser;
+ const identityFile = identityFileValue(computed.IdentityFile, env);
- // `knownHost` is keyed by the HostName (the actual connect target) — that is
- // what ssh2 connects to and what OpenSSH records in known_hosts.
- const knownHost = isKnownHost(env.knownHostsText, knownHostToken(hostName, port));
+ // `knownHost` is keyed by the HostName (the actual connect target) — that is
+ // what ssh2 connects to and what OpenSSH records in known_hosts.
+ const knownHost = isKnownHost(env.knownHostsText, knownHostToken(hostName, port));
- return { alias, hostName, port, user, identityFile, knownHost };
+ return { alias, hostName, port, user, identityFile, knownHost };
}
// ─── ssh-config line helpers ──────────────────────────────────────────────
function isHostSection(line: SSHConfig[number]): line is Section {
- return "param" in line && (line as Directive).param.toLowerCase() === "host";
+ return "param" in line && (line as Directive).param.toLowerCase() === "host";
}
/** The alias values declared on a `Host` line (space-separated, may be quoted). */
function readAliasValues(section: Section): string[] {
- const value = section.value;
- if (typeof value === "string") return value.split(/\s+/).filter((s) => s.length > 0);
- // Quoted/structured value: array of { val } objects.
- if (Array.isArray(value)) {
- return value.map((v) => (typeof v === "string" ? v : v.val)).filter((s) => s.length > 0);
- }
- return [];
+ const value = section.value;
+ if (typeof value === "string") return value.split(/\s+/).filter((s) => s.length > 0);
+ // Quoted/structured value: array of { val } objects.
+ if (Array.isArray(value)) {
+ return value.map((v) => (typeof v === "string" ? v : v.val)).filter((s) => s.length > 0);
+ }
+ return [];
}
/** A `Host` alias is a selectable computer only if it contains no wildcard chars. */
function isWildcardAlias(alias: string): boolean {
- return alias.includes("*") || alias.includes("?");
+ return alias.includes("*") || alias.includes("?");
}
function aliasExistsAsNamedHost(config: SSHConfig, alias: string): boolean {
- for (const line of config) {
- if (!isHostSection(line)) continue;
- const aliases = readAliasValues(line);
- if (aliases.includes(alias) && !aliases.some(isWildcardAlias)) return true;
- }
- return false;
+ for (const line of config) {
+ if (!isHostSection(line)) continue;
+ const aliases = readAliasValues(line);
+ if (aliases.includes(alias) && !aliases.some(isWildcardAlias)) return true;
+ }
+ return false;
}
// ─── value coercion (ssh-config returns string | string[]) ────────────────
function stringValue(v: string | string[] | undefined): string | undefined {
- if (v === undefined) return undefined;
- return Array.isArray(v) ? v[0] : v;
+ if (v === undefined) return undefined;
+ return Array.isArray(v) ? v[0] : v;
}
function numberValue(v: string | string[] | undefined): number | undefined {
- const s = stringValue(v);
- if (s === undefined) return undefined;
- const n = Number.parseInt(s, 10);
- return Number.isNaN(n) ? undefined : n;
+ const s = stringValue(v);
+ if (s === undefined) return undefined;
+ const n = Number.parseInt(s, 10);
+ return Number.isNaN(n) ? undefined : n;
}
function identityFileValue(
- v: string | string[] | undefined,
- env: SshConfigResolveEnv,
+ v: string | string[] | undefined,
+ env: SshConfigResolveEnv,
): string | null {
- const raw = stringValue(v);
- if (raw === undefined) return null; // caller falls back to default probing
- return expandPath(raw, env.homeDir);
+ const raw = stringValue(v);
+ if (raw === undefined) return null; // caller falls back to default probing
+ return expandPath(raw, env.homeDir);
}
/** Expand a leading `~` to the home dir. (Other $VARs left to the shell.) */
function expandPath(p: string, homeDir: string): string {
- if (p === "~") return homeDir;
- if (p.startsWith("~/")) return `${homeDir}/${p.slice(2)}`;
- return p;
+ if (p === "~") return homeDir;
+ if (p.startsWith("~/")) return `${homeDir}/${p.slice(2)}`;
+ return p;
}
/**
@@ -230,25 +230,25 @@ function expandPath(p: string, homeDir: string): string {
* `host`. Used both for the `knownHost` view and by the pool's host-verifier.
*/
export function knownHostToken(hostName: string, port: number): string {
- if (port === 22) return hostName;
- return `[${hostName}]:${port}`;
+ if (port === 22) return hostName;
+ return `[${hostName}]:${port}`;
}
// ─── known_hosts discovery ─────────────────────────────────────────────────
/** Find the index of the first space or tab, or -1 if none. */
function findSpace(line: string): number {
- for (let i = 0; i < line.length; i++) {
- const ch = line.charCodeAt(i);
- if (ch === 32 || ch === 9) return i; // space or tab
- }
- return -1;
+ for (let i = 0; i < line.length; i++) {
+ const ch = line.charCodeAt(i);
+ if (ch === 32 || ch === 9) return i; // space or tab
+ }
+ return -1;
}
/** A hostname + port extracted from a `~/.ssh/known_hosts` line. */
export interface KnownHostEntry {
- readonly hostname: string;
- readonly port: number;
+ readonly hostname: string;
+ readonly port: number;
}
/**
@@ -267,47 +267,47 @@ export interface KnownHostEntry {
* Pure: `knownHostsText` → `readonly KnownHostEntry[]`.
*/
export function parseKnownHosts(knownHostsText: string): readonly KnownHostEntry[] {
- const entries: KnownHostEntry[] = [];
- const seen = new Set<string>();
-
- for (const raw of knownHostsText.split("\n")) {
- const line = raw.trim();
- if (line === "" || line.startsWith("#")) continue;
-
- // First whitespace-delimited field is the host markers (comma-list).
- const firstSpace = findSpace(line);
- const firstField = firstSpace === -1 ? line : line.slice(0, firstSpace);
-
- for (const marker of firstField.split(",")) {
- const trimmed = marker.trim();
- if (trimmed === "" || trimmed.startsWith("|")) continue; // skip hashed
-
- let hostname: string;
- let port = 22;
-
- if (trimmed.startsWith("[")) {
- // [hostname]:port or [hostname]
- const bracketEnd = trimmed.indexOf("]");
- if (bracketEnd === -1) continue; // malformed
- hostname = trimmed.slice(1, bracketEnd);
- const afterBracket = trimmed.slice(bracketEnd + 1);
- if (afterBracket.startsWith(":")) {
- const n = Number.parseInt(afterBracket.slice(1), 10);
- if (Number.isFinite(n) && n > 0) port = n;
- }
- } else {
- hostname = trimmed;
- }
-
- // Dedup by hostname — first port wins (a host with entries on
- // multiple ports gets one computer; use config for a specific port).
- if (seen.has(hostname)) continue;
- seen.add(hostname);
- entries.push({ hostname, port });
- }
- }
-
- return entries;
+ const entries: KnownHostEntry[] = [];
+ const seen = new Set<string>();
+
+ for (const raw of knownHostsText.split("\n")) {
+ const line = raw.trim();
+ if (line === "" || line.startsWith("#")) continue;
+
+ // First whitespace-delimited field is the host markers (comma-list).
+ const firstSpace = findSpace(line);
+ const firstField = firstSpace === -1 ? line : line.slice(0, firstSpace);
+
+ for (const marker of firstField.split(",")) {
+ const trimmed = marker.trim();
+ if (trimmed === "" || trimmed.startsWith("|")) continue; // skip hashed
+
+ let hostname: string;
+ let port = 22;
+
+ if (trimmed.startsWith("[")) {
+ // [hostname]:port or [hostname]
+ const bracketEnd = trimmed.indexOf("]");
+ if (bracketEnd === -1) continue; // malformed
+ hostname = trimmed.slice(1, bracketEnd);
+ const afterBracket = trimmed.slice(bracketEnd + 1);
+ if (afterBracket.startsWith(":")) {
+ const n = Number.parseInt(afterBracket.slice(1), 10);
+ if (Number.isFinite(n) && n > 0) port = n;
+ }
+ } else {
+ hostname = trimmed;
+ }
+
+ // Dedup by hostname — first port wins (a host with entries on
+ // multiple ports gets one computer; use config for a specific port).
+ if (seen.has(hostname)) continue;
+ seen.add(hostname);
+ entries.push({ hostname, port });
+ }
+ }
+
+ return entries;
}
// ─── reject-list glob matching ─────────────────────────────────────────────
@@ -320,8 +320,8 @@ export function parseKnownHosts(knownHostsText: string): readonly KnownHostEntry
* Pure: `alias` + `patterns` → `boolean`.
*/
export function isRejected(alias: string, patterns?: readonly string[]): boolean {
- if (patterns === undefined || patterns.length === 0) return false;
- return patterns.some((p) => globMatch(p, alias));
+ if (patterns === undefined || patterns.length === 0) return false;
+ return patterns.some((p) => globMatch(p, alias));
}
/**
@@ -330,35 +330,35 @@ export function isRejected(alias: string, patterns?: readonly string[]): boolean
* characters match literally.
*/
function globMatch(pattern: string, input: string): boolean {
- const p = pattern.toLowerCase();
- const s = input.toLowerCase();
- return globMatchImpl(p, 0, s, 0);
+ const p = pattern.toLowerCase();
+ const s = input.toLowerCase();
+ return globMatchImpl(p, 0, s, 0);
}
function globMatchImpl(p: string, pi: number, s: string, si: number): boolean {
- while (pi < p.length) {
- const pc = p[pi];
- if (pc === "*") {
- // Skip consecutive * (they're equivalent to one).
- while (pi + 1 < p.length && p[pi + 1] === "*") pi++;
- // If * is the last char, match everything remaining.
- if (pi + 1 === p.length) return true;
- // Try to match the rest of the pattern at every position in s.
- for (let i = si; i <= s.length; i++) {
- if (globMatchImpl(p, pi + 1, s, i)) return true;
- }
- return false;
- }
- if (pc === "?") {
- if (si >= s.length) return false;
- pi++;
- si++;
- continue;
- }
- // Literal char.
- if (si >= s.length || p[pi] !== s[si]) return false;
- pi++;
- si++;
- }
- return si === s.length;
+ while (pi < p.length) {
+ const pc = p[pi];
+ if (pc === "*") {
+ // Skip consecutive * (they're equivalent to one).
+ while (pi + 1 < p.length && p[pi + 1] === "*") pi++;
+ // If * is the last char, match everything remaining.
+ if (pi + 1 === p.length) return true;
+ // Try to match the rest of the pattern at every position in s.
+ for (let i = si; i <= s.length; i++) {
+ if (globMatchImpl(p, pi + 1, s, i)) return true;
+ }
+ return false;
+ }
+ if (pc === "?") {
+ if (si >= s.length) return false;
+ pi++;
+ si++;
+ continue;
+ }
+ // Literal char.
+ if (si >= s.length || p[pi] !== s[si]) return false;
+ pi++;
+ si++;
+ }
+ return si === s.length;
}
diff --git a/packages/ssh/src/errors.test.ts b/packages/ssh/src/errors.test.ts
index 234fa49..189b704 100644
--- a/packages/ssh/src/errors.test.ts
+++ b/packages/ssh/src/errors.test.ts
@@ -2,89 +2,89 @@ import { describe, expect, it } from "vitest";
import { type FsError, fsError, mapSshError, sftpStatusToErrno } from "./errors.js";
describe("sftpStatusToErrno", () => {
- it("maps SSH_FX_NO_SUCH_FILE (3) → ENOENT", () => {
- expect(sftpStatusToErrno(3)).toBe("ENOENT");
- });
+ it("maps SSH_FX_NO_SUCH_FILE (3) → ENOENT", () => {
+ expect(sftpStatusToErrno(3)).toBe("ENOENT");
+ });
- it("maps SSH_FX_PERMISSION_DENIED (4) → EACCES", () => {
- expect(sftpStatusToErrno(4)).toBe("EACCES");
- });
+ it("maps SSH_FX_PERMISSION_DENIED (4) → EACCES", () => {
+ expect(sftpStatusToErrno(4)).toBe("EACCES");
+ });
- it("maps SSH_FX_FILE_ALREADY_EXISTS (11) → EEXIST", () => {
- expect(sftpStatusToErrno(11)).toBe("EEXIST");
- });
+ it("maps SSH_FX_FILE_ALREADY_EXISTS (11) → EEXIST", () => {
+ expect(sftpStatusToErrno(11)).toBe("EEXIST");
+ });
- it("maps SSH_FX_NOT_A_DIRECTORY (20) → ENOTDIR", () => {
- expect(sftpStatusToErrno(20)).toBe("ENOTDIR");
- });
+ it("maps SSH_FX_NOT_A_DIRECTORY (20) → ENOTDIR", () => {
+ expect(sftpStatusToErrno(20)).toBe("ENOTDIR");
+ });
- it("returns undefined for codes with no errno analog", () => {
- expect(sftpStatusToErrno(1)).toBeUndefined(); // SSH_FX_EOF
- expect(sftpStatusToErrno(999)).toBeUndefined();
- });
+ it("returns undefined for codes with no errno analog", () => {
+ expect(sftpStatusToErrno(1)).toBeUndefined(); // SSH_FX_EOF
+ expect(sftpStatusToErrno(999)).toBeUndefined();
+ });
});
describe("fsError", () => {
- it("builds an Error carrying a .code string", () => {
- const err: FsError = fsError("ENOENT", "no such file: /x");
- expect(err).toBeInstanceOf(Error);
- expect(err.code).toBe("ENOENT");
- expect(err.message).toBe("no such file: /x");
- });
+ it("builds an Error carrying a .code string", () => {
+ const err: FsError = fsError("ENOENT", "no such file: /x");
+ expect(err).toBeInstanceOf(Error);
+ expect(err.code).toBe("ENOENT");
+ expect(err.message).toBe("no such file: /x");
+ });
});
describe("mapSshError", () => {
- it("maps a numeric SFTP status code on .code → ENOENT", () => {
- const err = mapSshError(Object.assign(new Error("fail"), { code: 3 }), "readFile /x");
- expect(err.code).toBe("ENOENT");
- expect(err.message).toContain("readFile /x");
- expect(err.message).toContain("fail");
- });
+ it("maps a numeric SFTP status code on .code → ENOENT", () => {
+ const err = mapSshError(Object.assign(new Error("fail"), { code: 3 }), "readFile /x");
+ expect(err.code).toBe("ENOENT");
+ expect(err.message).toContain("readFile /x");
+ expect(err.message).toContain("fail");
+ });
- it("maps an SFTP_* string code → ENOENT", () => {
- const err = mapSshError(
- Object.assign(new Error("nope"), { code: "SFTP_STATUS_NO_SUCH_FILE" }),
- "stat /y",
- );
- expect(err.code).toBe("ENOENT");
- });
+ it("maps an SFTP_* string code → ENOENT", () => {
+ const err = mapSshError(
+ Object.assign(new Error("nope"), { code: "SFTP_STATUS_NO_SUCH_FILE" }),
+ "stat /y",
+ );
+ expect(err.code).toBe("ENOENT");
+ });
- it("maps an SFTP permission-denied string → EACCES", () => {
- const err = mapSshError(
- Object.assign(new Error("denied"), { code: "SFTP_STATUS_PERMISSION_DENIED" }),
- "readFile /y",
- );
- expect(err.code).toBe("EACCES");
- });
+ it("maps an SFTP permission-denied string → EACCES", () => {
+ const err = mapSshError(
+ Object.assign(new Error("denied"), { code: "SFTP_STATUS_PERMISSION_DENIED" }),
+ "readFile /y",
+ );
+ expect(err.code).toBe("EACCES");
+ });
- it("falls back to message-text sniffing when .code is absent (No such file)", () => {
- const err = mapSshError(new Error("No such file or directory"), "readFile /z");
- expect(err.code).toBe("ENOENT");
- });
+ it("falls back to message-text sniffing when .code is absent (No such file)", () => {
+ const err = mapSshError(new Error("No such file or directory"), "readFile /z");
+ expect(err.code).toBe("ENOENT");
+ });
- it("falls back to message-text sniffing for permission denied", () => {
- const err = mapSshError(new Error("Permission denied"), "writeFile /z");
- expect(err.code).toBe("EACCES");
- });
+ it("falls back to message-text sniffing for permission denied", () => {
+ const err = mapSshError(new Error("Permission denied"), "writeFile /z");
+ expect(err.code).toBe("EACCES");
+ });
- it("surfaces HOST KEY CHANGED as EHOSTUNREACH", () => {
- const err = mapSshError(new Error("HOST KEY CHANGED for localhost"), "connect");
- expect(err.code).toBe("EHOSTUNREACH");
- });
+ it("surfaces HOST KEY CHANGED as EHOSTUNREACH", () => {
+ const err = mapSshError(new Error("HOST KEY CHANGED for localhost"), "connect");
+ expect(err.code).toBe("EHOSTUNREACH");
+ });
- it("defaults unrecognized errors to EIO", () => {
- const err = mapSshError(new Error("something weird happened"), "readdir /a");
- expect(err.code).toBe("EIO");
- });
+ it("defaults unrecognized errors to EIO", () => {
+ const err = mapSshError(new Error("something weird happened"), "readdir /a");
+ expect(err.code).toBe("EIO");
+ });
- it("never throws — maps a non-Error value", () => {
- const err = mapSshError("just a string", "readdir /a");
- expect(err.code).toBe("EIO");
- expect(err.message).toContain("just a string");
- });
+ it("never throws — maps a non-Error value", () => {
+ const err = mapSshError("just a string", "readdir /a");
+ expect(err.code).toBe("EIO");
+ expect(err.message).toContain("just a string");
+ });
- it("includes the context prefix in the message", () => {
- const err = mapSshError(new Error("boom"), "writeFile /path/file");
- expect(err.message).toContain("writeFile /path/file");
- });
+ it("includes the context prefix in the message", () => {
+ const err = mapSshError(new Error("boom"), "writeFile /path/file");
+ expect(err.message).toContain("writeFile /path/file");
+ });
});
diff --git a/packages/ssh/src/errors.ts b/packages/ssh/src/errors.ts
index fab9d32..4fea51e 100644
--- a/packages/ssh/src/errors.ts
+++ b/packages/ssh/src/errors.ts
@@ -14,14 +14,14 @@
/** A node:fs-style error carrying a `.code` errno string. */
export interface FsError extends Error {
- readonly code: string;
+ readonly code: string;
}
/** Build a node:fs-style error with a `.code`. Pure. */
export function fsError(code: string, message: string): FsError {
- const err = new Error(message) as FsError;
- (err as { code: string }).code = code;
- return err;
+ const err = new Error(message) as FsError;
+ (err as { code: string }).code = code;
+ return err;
}
/**
@@ -32,15 +32,15 @@ export function fsError(code: string, message: string): FsError {
* @see https://datatracker.ietf.org/doc/html/rfc4254#section-9.1
*/
export function sftpStatusToErrno(status: number): string | undefined {
- // SSH_FX_NO_SUCH_FILE (3) → ENOENT — the common case (missing path).
- if (status === 3) return "ENOENT";
- // SSH_FX_PERMISSION_DENIED (4) → EACCES — read/parse this path.
- if (status === 4) return "EACCES";
- // SSH_FX_FILE_ALREADY_EXISTS (11) → EEXIST.
- if (status === 11) return "EEXIST";
- // SSH_FX_NOT_A_DIRECTORY (20) → ENOTDIR.
- if (status === 20) return "ENOTDIR";
- return undefined;
+ // SSH_FX_NO_SUCH_FILE (3) → ENOENT — the common case (missing path).
+ if (status === 3) return "ENOENT";
+ // SSH_FX_PERMISSION_DENIED (4) → EACCES — read/parse this path.
+ if (status === 4) return "EACCES";
+ // SSH_FX_FILE_ALREADY_EXISTS (11) → EEXIST.
+ if (status === 11) return "EEXIST";
+ // SSH_FX_NOT_A_DIRECTORY (20) → ENOTDIR.
+ if (status === 20) return "ENOTDIR";
+ return undefined;
}
/**
@@ -56,38 +56,38 @@ export function sftpStatusToErrno(status: number): string | undefined {
* Pure: takes the thrown value, returns an `FsError`. Never throws.
*/
export function mapSshError(err: unknown, context: string): FsError {
- const message = err instanceof Error ? err.message : String(err);
+ const message = err instanceof Error ? err.message : String(err);
- // ssh2 SFTP errors often carry a `.code` that is an SSH_FXP_* string.
- const code = (err as { code?: unknown } | null)?.code;
- if (typeof code === "string") {
- const mapped = sshCodeStringToErrno(code);
- if (mapped !== undefined) return fsError(mapped, `${context}: ${message}`);
- // ssh2 also surfaces raw numeric SFTP status on `.code`.
- }
- if (typeof code === "number") {
- const mapped = sftpStatusToErrno(code);
- if (mapped !== undefined) return fsError(mapped, `${context}: ${message}`);
- }
+ // ssh2 SFTP errors often carry a `.code` that is an SSH_FXP_* string.
+ const code = (err as { code?: unknown } | null)?.code;
+ if (typeof code === "string") {
+ const mapped = sshCodeStringToErrno(code);
+ if (mapped !== undefined) return fsError(mapped, `${context}: ${message}`);
+ // ssh2 also surfaces raw numeric SFTP status on `.code`.
+ }
+ if (typeof code === "number") {
+ const mapped = sftpStatusToErrno(code);
+ if (mapped !== undefined) return fsError(mapped, `${context}: ${message}`);
+ }
- // Some ssh2 errors embed the SFTP status code as `.desc`/message text; sniff
- // the human-readable text for the common markers as a last resort.
- if (message.includes("No such file") || message.includes("ENOENT")) {
- return fsError("ENOENT", `${context}: ${message}`);
- }
- if (message.includes("Permission denied") || message.includes("EACCES")) {
- return fsError("EACCES", `${context}: ${message}`);
- }
- if (message.includes("not a directory") || message.includes("ENOTDIR")) {
- return fsError("ENOTDIR", `${context}: ${message}`);
- }
+ // Some ssh2 errors embed the SFTP status code as `.desc`/message text; sniff
+ // the human-readable text for the common markers as a last resort.
+ if (message.includes("No such file") || message.includes("ENOENT")) {
+ return fsError("ENOENT", `${context}: ${message}`);
+ }
+ if (message.includes("Permission denied") || message.includes("EACCES")) {
+ return fsError("EACCES", `${context}: ${message}`);
+ }
+ if (message.includes("not a directory") || message.includes("ENOTDIR")) {
+ return fsError("ENOTDIR", `${context}: ${message}`);
+ }
- // Host-key / connect failures are surfaced as ECONNREFUSED-ish so the tool's
- // generic error path still renders them clearly. Default: generic I/O error.
- if (message.includes("HOST KEY CHANGED") || message.includes("host key")) {
- return fsError("EHOSTUNREACH", `${context}: ${message}`);
- }
- return fsError("EIO", `${context}: ${message}`);
+ // Host-key / connect failures are surfaced as ECONNREFUSED-ish so the tool's
+ // generic error path still renders them clearly. Default: generic I/O error.
+ if (message.includes("HOST KEY CHANGED") || message.includes("host key")) {
+ return fsError("EHOSTUNREACH", `${context}: ${message}`);
+ }
+ return fsError("EIO", `${context}: ${message}`);
}
/**
@@ -97,15 +97,15 @@ export function mapSshError(err: unknown, context: string): FsError {
* Returns `undefined` when no analog. Pure.
*/
function sshCodeStringToErrno(code: string): string | undefined {
- const c = code.toUpperCase();
- if (c.includes("NO_SUCH_FILE")) return "ENOENT";
- if (c.includes("PERMISSION_DENIED")) return "EACCES";
- if (
- c.includes("FILE_ALREADY_EXISTS") ||
- (c.includes("FAILURE") === false && c.includes("EXIST"))
- ) {
- return "EEXIST";
- }
- if (c.includes("NOT_A_DIRECTORY")) return "ENOTDIR";
- return undefined;
+ const c = code.toUpperCase();
+ if (c.includes("NO_SUCH_FILE")) return "ENOENT";
+ if (c.includes("PERMISSION_DENIED")) return "EACCES";
+ if (
+ c.includes("FILE_ALREADY_EXISTS") ||
+ (c.includes("FAILURE") === false && c.includes("EXIST"))
+ ) {
+ return "EEXIST";
+ }
+ if (c.includes("NOT_A_DIRECTORY")) return "ENOTDIR";
+ return undefined;
}
diff --git a/packages/ssh/src/extension.ts b/packages/ssh/src/extension.ts
index 3294908..c098ba0 100644
--- a/packages/ssh/src/extension.ts
+++ b/packages/ssh/src/extension.ts
@@ -22,24 +22,24 @@ import type { Extension, HostAPI, Logger, Manifest } from "@dispatch/kernel";
import { computerServiceHandle } from "@dispatch/transport-http/dist/seam.js";
import { Client } from "ssh2";
import {
- resolveComputer as resolveComputerFromConfig,
- type SshConfigResolveEnv,
+ resolveComputer as resolveComputerFromConfig,
+ type SshConfigResolveEnv,
} from "./config.js";
import { createSshService, type SshServiceDeps } from "./service.js";
export const manifest: Manifest = {
- id: "ssh",
- name: "SSH Remote Execution",
- version: "0.0.0",
- apiVersion: "^0.1.0",
- trust: "bundled",
- activation: "eager",
- // exec-backend owns the resolver; ssh provides the remote factory it looks up
- // at runtime (lazy, post-activation). Declaring dependsOn keeps the DAG honest
- // even though the lookup itself is deferred to tool-execute time.
- dependsOn: ["exec-backend"],
- capabilities: { fs: true, network: true },
- contributes: { services: ["ssh", "exec-backend/remote-factory"] },
+ id: "ssh",
+ name: "SSH Remote Execution",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ // exec-backend owns the resolver; ssh provides the remote factory it looks up
+ // at runtime (lazy, post-activation). Declaring dependsOn keeps the DAG honest
+ // even though the lookup itself is deferred to tool-execute time.
+ dependsOn: ["exec-backend"],
+ capabilities: { fs: true, network: true },
+ contributes: { services: ["ssh", "exec-backend/remote-factory"] },
};
/**
@@ -48,35 +48,35 @@ export const manifest: Manifest = {
* against a live sshd (the integration test — no `@dispatch/*` mocking).
*/
export function makeSshExtension(deps: SshServiceDeps): Extension {
- const store: { close: (() => Promise<void>) | null } = { close: null };
-
- return {
- manifest,
- activate(host: HostAPI) {
- const { service, pool, remoteFactory } = createSshService(deps);
- store.close = () => pool.closeAll();
-
- host.provideService(remoteExecBackendFactoryHandle, remoteFactory);
- host.provideService(computerServiceHandle, service);
-
- host.logger.info("ssh extension activated");
- },
- async deactivate() {
- await store.close?.();
- store.close = null;
- },
- };
+ const store: { close: (() => Promise<void>) | null } = { close: null };
+
+ return {
+ manifest,
+ activate(host: HostAPI) {
+ const { service, pool, remoteFactory } = createSshService(deps);
+ store.close = () => pool.closeAll();
+
+ host.provideService(remoteExecBackendFactoryHandle, remoteFactory);
+ host.provideService(computerServiceHandle, service);
+
+ host.logger.info("ssh extension activated");
+ },
+ async deactivate() {
+ await store.close?.();
+ store.close = null;
+ },
+ };
}
// ─── real node:fs + ssh2 adapters (production wiring) ─────────────────────
/** Path candidates for `dispatch.toml` (global + project-local). */
function dispatchTomlPaths(): readonly string[] {
- const paths = [
- join(homedir(), ".config", "dispatch", "dispatch.toml"), // global
- join(process.cwd(), "dispatch.toml"), // project-local
- ];
- return paths;
+ const paths = [
+ join(homedir(), ".config", "dispatch", "dispatch.toml"), // global
+ join(process.cwd(), "dispatch.toml"), // project-local
+ ];
+ return paths;
}
/**
@@ -85,30 +85,30 @@ function dispatchTomlPaths(): readonly string[] {
* Uses `Bun.TOML.parse` (Bun's built-in TOML parser — zero deps).
*/
async function readRejectPatternsImpl(): Promise<readonly string[]> {
- const patterns: string[] = [];
- const seen = new Set<string>();
-
- for (const path of dispatchTomlPaths()) {
- try {
- const text = await readFile(path, "utf8");
- const parsed = Bun.TOML.parse(text) as {
- ssh?: { reject?: readonly string[] };
- };
- const list = parsed.ssh?.reject;
- if (list !== undefined) {
- for (const p of list) {
- if (typeof p === "string" && !seen.has(p)) {
- seen.add(p);
- patterns.push(p);
- }
- }
- }
- } catch {
- // File missing or parse error → skip silently.
- }
- }
-
- return patterns;
+ const patterns: string[] = [];
+ const seen = new Set<string>();
+
+ for (const path of dispatchTomlPaths()) {
+ try {
+ const text = await readFile(path, "utf8");
+ const parsed = Bun.TOML.parse(text) as {
+ ssh?: { reject?: readonly string[] };
+ };
+ const list = parsed.ssh?.reject;
+ if (list !== undefined) {
+ for (const p of list) {
+ if (typeof p === "string" && !seen.has(p)) {
+ seen.add(p);
+ patterns.push(p);
+ }
+ }
+ }
+ } catch {
+ // File missing or parse error → skip silently.
+ }
+ }
+
+ return patterns;
}
/**
@@ -118,67 +118,67 @@ async function readRejectPatternsImpl(): Promise<readonly string[]> {
* resolved fresh from `~/.ssh/config` on each acquire (decision #4).
*/
export function createSshServiceDeps(hostLogger: Logger): SshServiceDeps {
- const sshDir = join(homedir(), ".ssh");
- const configPath = join(sshDir, "config");
- const knownHostsPath = join(sshDir, "known_hosts");
-
- const readConfigText = async (): Promise<string> => readFile(configPath, "utf8");
- const readFileText = async (path: string): Promise<string> => readFile(path, "utf8");
- const defaultUser = process.env.USER ?? homedir().split("/").pop() ?? "root";
-
- /** Read the reject list fresh from `dispatch.toml` on each call. */
- const readRejectPatterns = async (): Promise<readonly string[]> => readRejectPatternsImpl();
-
- /**
- * Build the resolve env (config + known_hosts + reject patterns) — shared by
- * the service methods and the pool's resolveComputer dep.
- */
- async function readEnv(): Promise<SshConfigResolveEnv> {
- const [configText, knownHostsText, rejectPatterns] = await Promise.all([
- readConfigText().catch(async () => ""),
- readFileText(knownHostsPath).catch(async () => ""),
- readRejectPatterns(),
- ]);
- const base: SshConfigResolveEnv = {
- configText,
- knownHostsText,
- defaultUser,
- homeDir: homedir(),
- };
- return rejectPatterns.length > 0 ? { ...base, rejectPatterns } : base;
- }
-
- return {
- logger: hostLogger,
- homeDir: homedir(),
- defaultUser,
- knownHostsPath,
- readConfigText,
- readFileText,
- readRejectPatterns,
- pathExists: async (path: string) =>
- access(path)
- .then(() => true)
- .catch(() => false),
- appendKnownHosts: async (path: string, line: string) =>
- appendFile(path, `${line}\n`, { encoding: "utf8" }),
- newClient: () => new Client(),
- // Resolve a computer alias → `Computer` by reading the live config +
- // known_hosts. Reads fresh on each call (a Host block or known_hosts
- // entry added between turns is picked up). Does NOT apply the reject
- // list — the pool needs to connect even to hosts hidden from the catalog.
- resolveComputer: async (alias: string) => {
- const env = await readEnv();
- return resolveComputerFromConfig(alias, env);
- },
- };
+ const sshDir = join(homedir(), ".ssh");
+ const configPath = join(sshDir, "config");
+ const knownHostsPath = join(sshDir, "known_hosts");
+
+ const readConfigText = async (): Promise<string> => readFile(configPath, "utf8");
+ const readFileText = async (path: string): Promise<string> => readFile(path, "utf8");
+ const defaultUser = process.env.USER ?? homedir().split("/").pop() ?? "root";
+
+ /** Read the reject list fresh from `dispatch.toml` on each call. */
+ const readRejectPatterns = async (): Promise<readonly string[]> => readRejectPatternsImpl();
+
+ /**
+ * Build the resolve env (config + known_hosts + reject patterns) — shared by
+ * the service methods and the pool's resolveComputer dep.
+ */
+ async function readEnv(): Promise<SshConfigResolveEnv> {
+ const [configText, knownHostsText, rejectPatterns] = await Promise.all([
+ readConfigText().catch(async () => ""),
+ readFileText(knownHostsPath).catch(async () => ""),
+ readRejectPatterns(),
+ ]);
+ const base: SshConfigResolveEnv = {
+ configText,
+ knownHostsText,
+ defaultUser,
+ homeDir: homedir(),
+ };
+ return rejectPatterns.length > 0 ? { ...base, rejectPatterns } : base;
+ }
+
+ return {
+ logger: hostLogger,
+ homeDir: homedir(),
+ defaultUser,
+ knownHostsPath,
+ readConfigText,
+ readFileText,
+ readRejectPatterns,
+ pathExists: async (path: string) =>
+ access(path)
+ .then(() => true)
+ .catch(() => false),
+ appendKnownHosts: async (path: string, line: string) =>
+ appendFile(path, `${line}\n`, { encoding: "utf8" }),
+ newClient: () => new Client(),
+ // Resolve a computer alias → `Computer` by reading the live config +
+ // known_hosts. Reads fresh on each call (a Host block or known_hosts
+ // entry added between turns is picked up). Does NOT apply the reject
+ // list — the pool needs to connect even to hosts hidden from the catalog.
+ resolveComputer: async (alias: string) => {
+ const env = await readEnv();
+ return resolveComputerFromConfig(alias, env);
+ },
+ };
}
/** Production extension: real `node:fs` + real `ssh2`. */
export const extension: Extension = {
- manifest,
- activate(host: HostAPI) {
- const deps = createSshServiceDeps(host.logger);
- makeSshExtension(deps).activate(host);
- },
+ manifest,
+ activate(host: HostAPI) {
+ const deps = createSshServiceDeps(host.logger);
+ makeSshExtension(deps).activate(host);
+ },
};
diff --git a/packages/ssh/src/hostkey.test.ts b/packages/ssh/src/hostkey.test.ts
index 1975777..1870de0 100644
--- a/packages/ssh/src/hostkey.test.ts
+++ b/packages/ssh/src/hostkey.test.ts
@@ -2,104 +2,104 @@ import { describe, expect, it } from "vitest";
import { decideHostKey, type HostKeyFingerprint, isKnownHost } from "./hostkey.js";
const fp = (token: string, key = "AAA"): HostKeyFingerprint => ({
- knownHostToken: token,
- keyBase64: key,
- keyType: "ssh-ed25519",
+ knownHostToken: token,
+ keyBase64: key,
+ keyType: "ssh-ed25519",
});
describe("decideHostKey — present + match → accept, no append", () => {
- it("accepts when the pinned key matches exactly", () => {
- const known = "myhost ssh-ed25519 AAA\n";
- const d = decideHostKey(known, fp("myhost", "AAA"));
- expect(d.accept).toBe(true);
- expect(d.append).toBeUndefined();
- expect(d.reason).toContain("matches");
- });
+ it("accepts when the pinned key matches exactly", () => {
+ const known = "myhost ssh-ed25519 AAA\n";
+ const d = decideHostKey(known, fp("myhost", "AAA"));
+ expect(d.accept).toBe(true);
+ expect(d.append).toBeUndefined();
+ expect(d.reason).toContain("matches");
+ });
- it("matches ignoring leading/trailing whitespace differences", () => {
- const known = "myhost ssh-ed25519 AAA\n";
- const d = decideHostKey(known, fp("myhost", "AAA"));
- expect(d.accept).toBe(true);
- expect(d.append).toBeUndefined();
- });
+ it("matches ignoring leading/trailing whitespace differences", () => {
+ const known = "myhost ssh-ed25519 AAA\n";
+ const d = decideHostKey(known, fp("myhost", "AAA"));
+ expect(d.accept).toBe(true);
+ expect(d.append).toBeUndefined();
+ });
- it("matches a comma-host token list containing the alias", () => {
- const known = "hostA,myhost,hostB ssh-ed25519 AAA\n";
- const d = decideHostKey(known, fp("myhost", "AAA"));
- expect(d.accept).toBe(true);
- });
+ it("matches a comma-host token list containing the alias", () => {
+ const known = "hostA,myhost,hostB ssh-ed25519 AAA\n";
+ const d = decideHostKey(known, fp("myhost", "AAA"));
+ expect(d.accept).toBe(true);
+ });
});
describe("decideHostKey — present + mismatch → REJECT, no append", () => {
- it("rejects loudly when the pinned key differs", () => {
- const known = "myhost ssh-ed25519 AAA\n";
- const d = decideHostKey(known, fp("myhost", "BBB"));
- expect(d.accept).toBe(false);
- expect(d.append).toBeUndefined(); // never pin a mismatched key
- expect(d.reason).toContain("HOST KEY CHANGED");
- expect(d.reason).toContain("myhost");
- });
+ it("rejects loudly when the pinned key differs", () => {
+ const known = "myhost ssh-ed25519 AAA\n";
+ const d = decideHostKey(known, fp("myhost", "BBB"));
+ expect(d.accept).toBe(false);
+ expect(d.append).toBeUndefined(); // never pin a mismatched key
+ expect(d.reason).toContain("HOST KEY CHANGED");
+ expect(d.reason).toContain("myhost");
+ });
- it("does not pin on mismatch (the user must clear the stale line)", () => {
- const known = "myhost ssh-ed25519 AAA\n";
- const d = decideHostKey(known, fp("myhost", "DIFFERENT"));
- expect(d.append).toBeUndefined();
- });
+ it("does not pin on mismatch (the user must clear the stale line)", () => {
+ const known = "myhost ssh-ed25519 AAA\n";
+ const d = decideHostKey(known, fp("myhost", "DIFFERENT"));
+ expect(d.append).toBeUndefined();
+ });
});
describe("decideHostKey — absent (first connect) → accept + pin", () => {
- it("accepts and produces the pin line to append", () => {
- const d = decideHostKey("", fp("newhost", "AAA"));
- expect(d.accept).toBe(true);
- expect(d.append).toBe("newhost ssh-ed25519 AAA");
- expect(d.reason).toContain("first connect");
- expect(d.reason).toContain("newhost");
- });
+ it("accepts and produces the pin line to append", () => {
+ const d = decideHostKey("", fp("newhost", "AAA"));
+ expect(d.accept).toBe(true);
+ expect(d.append).toBe("newhost ssh-ed25519 AAA");
+ expect(d.reason).toContain("first connect");
+ expect(d.reason).toContain("newhost");
+ });
- it("ignores comment + empty lines when searching", () => {
- const known = "# a comment\n\n \notherhost ssh-ed25519 ZZZ\n";
- const d = decideHostKey(known, fp("newhost", "AAA"));
- expect(d.accept).toBe(true);
- expect(d.append).toBe("newhost ssh-ed25519 AAA");
- });
+ it("ignores comment + empty lines when searching", () => {
+ const known = "# a comment\n\n \notherhost ssh-ed25519 ZZZ\n";
+ const d = decideHostKey(known, fp("newhost", "AAA"));
+ expect(d.accept).toBe(true);
+ expect(d.append).toBe("newhost ssh-ed25519 AAA");
+ });
- it("pins a bracketed token for a non-default port", () => {
- const d = decideHostKey("", fp("[localhost]:2222", "AAA"));
- expect(d.accept).toBe(true);
- expect(d.append).toBe("[localhost]:2222 ssh-ed25519 AAA");
- });
+ it("pins a bracketed token for a non-default port", () => {
+ const d = decideHostKey("", fp("[localhost]:2222", "AAA"));
+ expect(d.accept).toBe(true);
+ expect(d.append).toBe("[localhost]:2222 ssh-ed25519 AAA");
+ });
});
describe("decideHostKey — first field must match the token", () => {
- it("does not match a host that appears only as a substring of another token", () => {
- const known = "myhost-extra ssh-ed25519 AAA\n";
- const d = decideHostKey(known, fp("myhost", "AAA"));
- // "myhost" is not an exact first-field (nor comma element) → absent → pin.
- expect(d.accept).toBe(true);
- expect(d.append).toBe("myhost ssh-ed25519 AAA");
- });
+ it("does not match a host that appears only as a substring of another token", () => {
+ const known = "myhost-extra ssh-ed25519 AAA\n";
+ const d = decideHostKey(known, fp("myhost", "AAA"));
+ // "myhost" is not an exact first-field (nor comma element) → absent → pin.
+ expect(d.accept).toBe(true);
+ expect(d.append).toBe("myhost ssh-ed25519 AAA");
+ });
});
describe("isKnownHost", () => {
- it("returns true when the token is a known_hosts first field", () => {
- expect(isKnownHost("a.example ssh-ed25519 AAA\n", "a.example")).toBe(true);
- });
+ it("returns true when the token is a known_hosts first field", () => {
+ expect(isKnownHost("a.example ssh-ed25519 AAA\n", "a.example")).toBe(true);
+ });
- it("returns true for a comma-list token", () => {
- expect(isKnownHost("a,b,c ssh-ed25519 AAA\n", "b")).toBe(true);
- });
+ it("returns true for a comma-list token", () => {
+ expect(isKnownHost("a,b,c ssh-ed25519 AAA\n", "b")).toBe(true);
+ });
- it("returns false when the token is absent", () => {
- expect(isKnownHost("a.example ssh-ed25519 AAA\n", "b.example")).toBe(false);
- });
+ it("returns false when the token is absent", () => {
+ expect(isKnownHost("a.example ssh-ed25519 AAA\n", "b.example")).toBe(false);
+ });
- it("returns false for an empty known_hosts", () => {
- expect(isKnownHost("", "anything")).toBe(false);
- });
+ it("returns false for an empty known_hosts", () => {
+ expect(isKnownHost("", "anything")).toBe(false);
+ });
- it("ignores comments and blanks", () => {
- const known = "# comment\n\nfoo ssh-ed25519 AAA\n";
- expect(isKnownHost(known, "foo")).toBe(true);
- expect(isKnownHost(known, "bar")).toBe(false);
- });
+ it("ignores comments and blanks", () => {
+ const known = "# comment\n\nfoo ssh-ed25519 AAA\n";
+ expect(isKnownHost(known, "foo")).toBe(true);
+ expect(isKnownHost(known, "bar")).toBe(false);
+ });
});
diff --git a/packages/ssh/src/hostkey.ts b/packages/ssh/src/hostkey.ts
index 626b060..f1c8a07 100644
--- a/packages/ssh/src/hostkey.ts
+++ b/packages/ssh/src/hostkey.ts
@@ -17,16 +17,16 @@
/** Outcome of a host-key check. The shell acts on `accept` + `append`. */
export interface HostKeyDecision {
- /** Accept the connection (true) or reject it loudly (false). */
- readonly accept: boolean;
- /**
- * When the host is unseen (first connect), the line to append to
- * `known_hosts` to pin the key. `undefined` when the host is already known
- * (no write needed) or when rejecting (do not pin a mismatched key).
- */
- readonly append: string | undefined;
- /** Human-readable reason for logging/the rejection error. */
- readonly reason: string;
+ /** Accept the connection (true) or reject it loudly (false). */
+ readonly accept: boolean;
+ /**
+ * When the host is unseen (first connect), the line to append to
+ * `known_hosts` to pin the key. `undefined` when the host is already known
+ * (no write needed) or when rejecting (do not pin a mismatched key).
+ */
+ readonly append: string | undefined;
+ /** Human-readable reason for logging/the rejection error. */
+ readonly reason: string;
}
/**
@@ -40,12 +40,12 @@ export interface HostKeyDecision {
* shared trust store).
*/
export interface HostKeyFingerprint {
- /** The OpenSSH `known_hosts` line token, e.g. `[localhost]:2222` or `myhost`. */
- readonly knownHostToken: string;
- /** The base64-encoded public key (the 2nd field of a known_hosts line). */
- readonly keyBase64: string;
- /** Key type label, e.g. `ssh-ed25519` (the 1st field). */
- readonly keyType: string;
+ /** The OpenSSH `known_hosts` line token, e.g. `[localhost]:2222` or `myhost`. */
+ readonly knownHostToken: string;
+ /** The base64-encoded public key (the 2nd field of a known_hosts line). */
+ readonly keyBase64: string;
+ /** Key type label, e.g. `ssh-ed25519` (the 1st field). */
+ readonly keyType: string;
}
/**
@@ -66,76 +66,76 @@ export interface HostKeyFingerprint {
* Pure: `knownHostsText` + `fingerprint` → `HostKeyDecision`.
*/
export function decideHostKey(
- knownHostsText: string,
- fingerprint: HostKeyFingerprint,
+ knownHostsText: string,
+ fingerprint: HostKeyFingerprint,
): HostKeyDecision {
- const { knownHostToken, keyBase64, keyType } = fingerprint;
- const expectedLine = `${knownHostToken} ${keyType} ${keyBase64}`;
+ const { knownHostToken, keyBase64, keyType } = fingerprint;
+ const expectedLine = `${knownHostToken} ${keyType} ${keyBase64}`;
- // A line matches THIS host when its first field is the knownHostToken.
- const existing = findHostLine(knownHostsText, knownHostToken);
+ // A line matches THIS host when its first field is the knownHostToken.
+ const existing = findHostLine(knownHostsText, knownHostToken);
- if (existing === undefined) {
- // Absent → first connect → accept + pin (the accept-new analog).
- return {
- accept: true,
- append: expectedLine,
- reason: `first connect to "${knownHostToken}": pinning host key`,
- };
- }
+ if (existing === undefined) {
+ // Absent → first connect → accept + pin (the accept-new analog).
+ return {
+ accept: true,
+ append: expectedLine,
+ reason: `first connect to "${knownHostToken}": pinning host key`,
+ };
+ }
- // Present → compare the key material (fields 2+3). Ignore leading/trailing
- // whitespace differences (OpenSSH tolerates these).
- const normalizedExisting = normalizeLine(existing);
- if (normalizedExisting === normalizeLine(expectedLine)) {
- return { accept: true, append: undefined, reason: `host key for "${knownHostToken}" matches` };
- }
+ // Present → compare the key material (fields 2+3). Ignore leading/trailing
+ // whitespace differences (OpenSSH tolerates these).
+ const normalizedExisting = normalizeLine(existing);
+ if (normalizedExisting === normalizeLine(expectedLine)) {
+ return { accept: true, append: undefined, reason: `host key for "${knownHostToken}" matches` };
+ }
- // Present but DIFFERENT → reject loudly. Do NOT pin (the key changed →
- // possible MITM; the user must clear the stale line manually).
- return {
- accept: false,
- append: undefined,
- reason:
- `HOST KEY CHANGED for "${knownHostToken}" — refusing to connect ` +
- `(remove the stale entry from ~/.ssh/known_hosts if this change is expected)`,
- };
+ // Present but DIFFERENT → reject loudly. Do NOT pin (the key changed →
+ // possible MITM; the user must clear the stale line manually).
+ return {
+ accept: false,
+ append: undefined,
+ reason:
+ `HOST KEY CHANGED for "${knownHostToken}" — refusing to connect ` +
+ `(remove the stale entry from ~/.ssh/known_hosts if this change is expected)`,
+ };
}
/** Find the first known_hosts line whose first field is `token`. Pure. */
function findHostLine(text: string, token: string): string | undefined {
- for (const raw of text.split("\n")) {
- const line = raw.trim();
- if (line === "" || line.startsWith("#")) continue;
- // First whitespace-delimited field is the host token (possibly comma-list).
- const firstSpace = findFirstSpace(line);
- const firstField = firstSpace === -1 ? line : line.slice(0, firstSpace);
- // A token may be a comma-separated host list; accept if any element matches.
- if (
- firstField
- .split(",")
- .map((h) => h.trim())
- .includes(token)
- ) {
- return line;
- }
- }
- return undefined;
+ for (const raw of text.split("\n")) {
+ const line = raw.trim();
+ if (line === "" || line.startsWith("#")) continue;
+ // First whitespace-delimited field is the host token (possibly comma-list).
+ const firstSpace = findFirstSpace(line);
+ const firstField = firstSpace === -1 ? line : line.slice(0, firstSpace);
+ // A token may be a comma-separated host list; accept if any element matches.
+ if (
+ firstField
+ .split(",")
+ .map((h) => h.trim())
+ .includes(token)
+ ) {
+ return line;
+ }
+ }
+ return undefined;
}
/** Normalize a known_hosts line for key-material comparison (host-independent). */
function normalizeLine(line: string): string {
- const parts = line.split(/\s+/).filter((p) => p.length > 0);
- // Drop the first field (host token); compare key-type + base64 key.
- return parts.slice(1).join(" ");
+ const parts = line.split(/\s+/).filter((p) => p.length > 0);
+ // Drop the first field (host token); compare key-type + base64 key.
+ return parts.slice(1).join(" ");
}
function findFirstSpace(line: string): number {
- for (let i = 0; i < line.length; i++) {
- const ch = line.charCodeAt(i);
- if (ch === 32 || ch === 9) return i; // space or tab
- }
- return -1;
+ for (let i = 0; i < line.length; i++) {
+ const ch = line.charCodeAt(i);
+ if (ch === 32 || ch === 9) return i; // space or tab
+ }
+ return -1;
}
/**
@@ -144,5 +144,5 @@ function findFirstSpace(line: string): number {
* first-field matching as `decideHostKey`.
*/
export function isKnownHost(knownHostsText: string, token: string): boolean {
- return findHostLine(knownHostsText, token) !== undefined;
+ return findHostLine(knownHostsText, token) !== undefined;
}
diff --git a/packages/ssh/src/index.ts b/packages/ssh/src/index.ts
index 2d4fb2a..f4f7bba 100644
--- a/packages/ssh/src/index.ts
+++ b/packages/ssh/src/index.ts
@@ -1,24 +1,24 @@
export { type AcquireConnection, createSshExecBackend, shellQuote } from "./backend.js";
export {
- knownHostToken,
- resolveComputer,
- resolveComputers,
- type SshConfigResolveEnv,
+ knownHostToken,
+ resolveComputer,
+ resolveComputers,
+ type SshConfigResolveEnv,
} from "./config.js";
export { type FsError, fsError, mapSshError, sftpStatusToErrno } from "./errors.js";
export { createSshServiceDeps, extension, makeSshExtension, manifest } from "./extension.js";
export {
- decideHostKey,
- type HostKeyDecision,
- type HostKeyFingerprint,
- isKnownHost,
+ decideHostKey,
+ type HostKeyDecision,
+ type HostKeyFingerprint,
+ isKnownHost,
} from "./hostkey.js";
export {
- createSshConnectionPool,
- type SshConnection,
- type SshConnectionPool,
- type SshConnectionState,
- type SshPoolDeps,
- type SshPoolStatusEntry,
+ createSshConnectionPool,
+ type SshConnection,
+ type SshConnectionPool,
+ type SshConnectionState,
+ type SshPoolDeps,
+ type SshPoolStatusEntry,
} from "./pool.js";
export { createSshService, type SshServiceDeps } from "./service.js";
diff --git a/packages/ssh/src/integration.test.ts b/packages/ssh/src/integration.test.ts
index 7b05be2..bfbecd8 100644
--- a/packages/ssh/src/integration.test.ts
+++ b/packages/ssh/src/integration.test.ts
@@ -29,8 +29,8 @@ const testEnv = HOST === undefined ? null : { host: HOST, port: PORT, user: USER
// Build a real config env fixture pointing at the test sshd.
function configText(): string {
- if (testEnv === null) return "";
- return `Host testremote\n HostName ${testEnv.host}\n Port ${testEnv.port}\n User ${testEnv.user}\n`;
+ if (testEnv === null) return "";
+ return `Host testremote\n HostName ${testEnv.host}\n Port ${testEnv.port}\n User ${testEnv.user}\n`;
}
const sshDir = join(homedir(), ".ssh");
@@ -41,144 +41,144 @@ const sshDir = join(homedir(), ".ssh");
* returns itself so the type is complete (the integration test logs nothing).
*/
function noopLogger(): Logger {
- const log: Logger = {
- debug: () => undefined,
- info: () => undefined,
- warn: () => undefined,
- error: () => undefined,
- child: () => log,
- span: (name: string) => ({
- id: name,
- log,
- setAttributes: () => undefined,
- addLink: () => undefined,
- child: (n: string) =>
- ({
- id: n,
- log,
- setAttributes: () => undefined,
- addLink: () => undefined,
- child: () => ({ id: n, log }) as never,
- end: () => undefined,
- }) as never,
- end: () => undefined,
- }),
- };
- return log;
+ const log: Logger = {
+ debug: () => undefined,
+ info: () => undefined,
+ warn: () => undefined,
+ error: () => undefined,
+ child: () => log,
+ span: (name: string) => ({
+ id: name,
+ log,
+ setAttributes: () => undefined,
+ addLink: () => undefined,
+ child: (n: string) =>
+ ({
+ id: n,
+ log,
+ setAttributes: () => undefined,
+ addLink: () => undefined,
+ child: () => ({ id: n, log }) as never,
+ end: () => undefined,
+ }) as never,
+ end: () => undefined,
+ }),
+ };
+ return log;
}
function realDeps() {
- return {
- logger: noopLogger(),
- homeDir: homedir(),
- knownHostsPath: join(sshDir, "known_hosts"),
- readFileText: (p: string) => readFile(p, "utf8"),
- appendKnownHosts: async () => undefined, // don't mutate the real known_hosts in a test
- pathExists: (p: string) =>
- access(p)
- .then(() => true)
- .catch(() => false),
- newClient: () => new Client(),
- resolveComputer: async (alias: string) =>
- resolveComputer(alias, {
- configText: configText(),
- knownHostsText: "",
- defaultUser: USER,
- homeDir: homedir(),
- }),
- };
+ return {
+ logger: noopLogger(),
+ homeDir: homedir(),
+ knownHostsPath: join(sshDir, "known_hosts"),
+ readFileText: (p: string) => readFile(p, "utf8"),
+ appendKnownHosts: async () => undefined, // don't mutate the real known_hosts in a test
+ pathExists: (p: string) =>
+ access(p)
+ .then(() => true)
+ .catch(() => false),
+ newClient: () => new Client(),
+ resolveComputer: async (alias: string) =>
+ resolveComputer(alias, {
+ configText: configText(),
+ knownHostsText: "",
+ defaultUser: USER,
+ homeDir: homedir(),
+ }),
+ };
}
describe.skipIf(testEnv === null)("SshExecBackend against a real sshd", () => {
- let pool: ReturnType<typeof createSshConnectionPool>;
- let tmpRemoteDir: string;
-
- beforeEach(async () => {
- pool = createSshConnectionPool(realDeps());
- // Create a remote temp dir to run cwd-scoped commands in.
- tmpRemoteDir = await mkdtemp(join(tmpdir(), "ssh-int-"));
- });
-
- afterEach(async () => {
- await pool.closeAll();
- // best-effort cleanup of the remote temp dir.
- const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
- try {
- await backend.spawn({
- command: `rm -rf ${tmpRemoteDir}`,
- cwd: "/",
- signal: new AbortController().signal,
- timeout: 5000,
- onOutput: () => undefined,
- });
- } catch {
- // ignore
- }
- });
-
- it("connects + execs a command, returning stdout + exit code", async () => {
- const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
- let stdout = "";
- const res = await backend.spawn({
- command: "echo integration_ok; exit 7",
- cwd: tmpRemoteDir,
- signal: new AbortController().signal,
- timeout: 10000,
- onOutput: (data, stream) => {
- if (stream === "stdout") stdout += data;
- },
- });
- expect(stdout.trim()).toBe("integration_ok");
- expect(res.exitCode).toBe(7);
- expect(res.timedOut).toBe(false);
- expect(res.aborted).toBe(false);
- });
-
- it("writes a file over SFTP then reads it back", async () => {
- const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
- const path = join(tmpRemoteDir, "sftp-probe.txt");
- await backend.writeFile(path, "hello-sftp");
- const content = await backend.readFile(path);
- expect(content).toBe("hello-sftp");
- });
-
- it("stat reports isFile/isDirectory correctly", async () => {
- const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
- const path = join(tmpRemoteDir, "stat-probe.txt").replace(/\\/g, "/");
- await backend.writeFile(path, "x");
- const s = await backend.stat(path);
- expect(s.isFile).toBe(true);
- expect(s.isDirectory).toBe(false);
- // A directory stat reports the inverse.
- const dirStat = await backend.stat(tmpRemoteDir);
- expect(dirStat.isDirectory).toBe(true);
- expect(dirStat.isFile).toBe(false);
- });
-
- it("readdir lists entries with isDirectory flags", async () => {
- const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
- await backend.writeFile(join(tmpRemoteDir, "a.txt").replace(/\\/g, "/"), "a");
- await mkdir(join(tmpRemoteDir, "subdir").replace(/\\/g, "/")).catch(() => undefined);
- const entries = await backend.readdir(tmpRemoteDir);
- const names = entries.map((e) => e.name);
- expect(names).toContain("a.txt");
- expect(entries.find((e) => e.name === "a.txt")?.isDirectory).toBe(false);
- });
-
- it("readFile on a missing path throws an ENOENT .code error", async () => {
- const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
- await expect(
- backend.readFile(join(tmpRemoteDir, "nope.txt").replace(/\\/g, "/")),
- ).rejects.toMatchObject({
- code: "ENOENT",
- });
- });
-
- it("exists returns false for a missing path and true for an existing one", async () => {
- const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
- const path = join(tmpRemoteDir, "exists-probe.txt").replace(/\\/g, "/");
- await backend.writeFile(path, "x");
- expect(await backend.exists(path)).toBe(true);
- expect(await backend.exists(join(tmpRemoteDir, "missing.txt").replace(/\\/g, "/"))).toBe(false);
- });
+ let pool: ReturnType<typeof createSshConnectionPool>;
+ let tmpRemoteDir: string;
+
+ beforeEach(async () => {
+ pool = createSshConnectionPool(realDeps());
+ // Create a remote temp dir to run cwd-scoped commands in.
+ tmpRemoteDir = await mkdtemp(join(tmpdir(), "ssh-int-"));
+ });
+
+ afterEach(async () => {
+ await pool.closeAll();
+ // best-effort cleanup of the remote temp dir.
+ const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
+ try {
+ await backend.spawn({
+ command: `rm -rf ${tmpRemoteDir}`,
+ cwd: "/",
+ signal: new AbortController().signal,
+ timeout: 5000,
+ onOutput: () => undefined,
+ });
+ } catch {
+ // ignore
+ }
+ });
+
+ it("connects + execs a command, returning stdout + exit code", async () => {
+ const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
+ let stdout = "";
+ const res = await backend.spawn({
+ command: "echo integration_ok; exit 7",
+ cwd: tmpRemoteDir,
+ signal: new AbortController().signal,
+ timeout: 10000,
+ onOutput: (data, stream) => {
+ if (stream === "stdout") stdout += data;
+ },
+ });
+ expect(stdout.trim()).toBe("integration_ok");
+ expect(res.exitCode).toBe(7);
+ expect(res.timedOut).toBe(false);
+ expect(res.aborted).toBe(false);
+ });
+
+ it("writes a file over SFTP then reads it back", async () => {
+ const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
+ const path = join(tmpRemoteDir, "sftp-probe.txt");
+ await backend.writeFile(path, "hello-sftp");
+ const content = await backend.readFile(path);
+ expect(content).toBe("hello-sftp");
+ });
+
+ it("stat reports isFile/isDirectory correctly", async () => {
+ const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
+ const path = join(tmpRemoteDir, "stat-probe.txt").replace(/\\/g, "/");
+ await backend.writeFile(path, "x");
+ const s = await backend.stat(path);
+ expect(s.isFile).toBe(true);
+ expect(s.isDirectory).toBe(false);
+ // A directory stat reports the inverse.
+ const dirStat = await backend.stat(tmpRemoteDir);
+ expect(dirStat.isDirectory).toBe(true);
+ expect(dirStat.isFile).toBe(false);
+ });
+
+ it("readdir lists entries with isDirectory flags", async () => {
+ const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
+ await backend.writeFile(join(tmpRemoteDir, "a.txt").replace(/\\/g, "/"), "a");
+ await mkdir(join(tmpRemoteDir, "subdir").replace(/\\/g, "/")).catch(() => undefined);
+ const entries = await backend.readdir(tmpRemoteDir);
+ const names = entries.map((e) => e.name);
+ expect(names).toContain("a.txt");
+ expect(entries.find((e) => e.name === "a.txt")?.isDirectory).toBe(false);
+ });
+
+ it("readFile on a missing path throws an ENOENT .code error", async () => {
+ const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
+ await expect(
+ backend.readFile(join(tmpRemoteDir, "nope.txt").replace(/\\/g, "/")),
+ ).rejects.toMatchObject({
+ code: "ENOENT",
+ });
+ });
+
+ it("exists returns false for a missing path and true for an existing one", async () => {
+ const backend = createSshExecBackend("testremote", async (a) => pool.acquire(a));
+ const path = join(tmpRemoteDir, "exists-probe.txt").replace(/\\/g, "/");
+ await backend.writeFile(path, "x");
+ expect(await backend.exists(path)).toBe(true);
+ expect(await backend.exists(join(tmpRemoteDir, "missing.txt").replace(/\\/g, "/"))).toBe(false);
+ });
});
diff --git a/packages/ssh/src/pool.ts b/packages/ssh/src/pool.ts
index 5b380d8..9acae0e 100644
--- a/packages/ssh/src/pool.ts
+++ b/packages/ssh/src/pool.ts
@@ -37,12 +37,12 @@ export type SshConnectionState = "disconnected" | "connecting" | "connected" | "
* per computer — the transparency + perf win over spawning `ssh` per call).
*/
export interface SshConnection {
- readonly getClient: () => Promise<Client>;
- readonly getSftp: () => Promise<import("ssh2").SFTPWrapper>;
- readonly close: () => Promise<void>;
- readonly state: SshConnectionState;
- /** Last error message when `state === "error"`; `undefined` otherwise. */
- readonly error: string | undefined;
+ readonly getClient: () => Promise<Client>;
+ readonly getSftp: () => Promise<import("ssh2").SFTPWrapper>;
+ readonly close: () => Promise<void>;
+ readonly state: SshConnectionState;
+ /** Last error message when `state === "error"`; `undefined` otherwise. */
+ readonly error: string | undefined;
}
/**
@@ -50,44 +50,44 @@ export interface SshConnection {
* sshd (or fixture files) — never at a `@dispatch/*` mock.
*/
export interface SshPoolDeps {
- readonly logger: Logger;
- /** Read a file as utf8 text (key files, known_hosts, ssh config). */
- readonly readFileText: (path: string) => Promise<string>;
- /** Append a line to `~/.ssh/known_hosts` (the host-key pin). */
- readonly appendKnownHosts: (path: string, line: string) => Promise<void>;
- /** Check a path exists (for default-identity-file probing). */
- readonly pathExists: (path: string) => Promise<boolean>;
- /** Factory for a fresh ssh2 Client (the real edge). */
- readonly newClient: () => Client;
- /** Resolve a computer alias → its `Computer` (connection params). */
- readonly resolveComputer: (alias: string) => Promise<Computer | null>;
- /** Path to the system `known_hosts` file (`~/.ssh/known_hosts`). */
- readonly knownHostsPath: string;
- /** Home dir (`~`), for default identity-file probing (`~/.ssh/id_*`). */
- readonly homeDir: string;
+ readonly logger: Logger;
+ /** Read a file as utf8 text (key files, known_hosts, ssh config). */
+ readonly readFileText: (path: string) => Promise<string>;
+ /** Append a line to `~/.ssh/known_hosts` (the host-key pin). */
+ readonly appendKnownHosts: (path: string, line: string) => Promise<void>;
+ /** Check a path exists (for default-identity-file probing). */
+ readonly pathExists: (path: string) => Promise<boolean>;
+ /** Factory for a fresh ssh2 Client (the real edge). */
+ readonly newClient: () => Client;
+ /** Resolve a computer alias → its `Computer` (connection params). */
+ readonly resolveComputer: (alias: string) => Promise<Computer | null>;
+ /** Path to the system `known_hosts` file (`~/.ssh/known_hosts`). */
+ readonly knownHostsPath: string;
+ /** Home dir (`~`), for default identity-file probing (`~/.ssh/id_*`). */
+ readonly homeDir: string;
}
export interface SshConnectionPool {
- readonly acquire: (computerId: string) => Promise<SshConnection>;
- readonly drop: (computerId: string) => Promise<void>;
- readonly closeAll: () => Promise<void>;
- readonly status: () => readonly SshPoolStatusEntry[];
+ readonly acquire: (computerId: string) => Promise<SshConnection>;
+ readonly drop: (computerId: string) => Promise<void>;
+ readonly closeAll: () => Promise<void>;
+ readonly status: () => readonly SshPoolStatusEntry[];
}
export interface SshPoolStatusEntry {
- readonly computerId: string;
- readonly state: SshConnectionState;
- readonly error?: string;
+ readonly computerId: string;
+ readonly state: SshConnectionState;
+ readonly error?: string;
}
interface PooledEntry {
- readonly alias: string;
- conn: SshConnection;
- /** Wall-clock of the last `acquire`/use — for idle reaping. */
- lastUsedAt: number;
- /** Pending connect (so concurrent first-acquires share one connect). */
- readonly pending: Promise<void> | null;
- reaper: ReturnType<typeof setInterval> | null;
+ readonly alias: string;
+ conn: SshConnection;
+ /** Wall-clock of the last `acquire`/use — for idle reaping. */
+ lastUsedAt: number;
+ /** Pending connect (so concurrent first-acquires share one connect). */
+ readonly pending: Promise<void> | null;
+ reaper: ReturnType<typeof setInterval> | null;
}
/**
@@ -96,127 +96,127 @@ interface PooledEntry {
* `ComputerService` status/test routes.
*/
export function createSshConnectionPool(deps: SshPoolDeps): SshConnectionPool {
- const entries = new Map<string, PooledEntry>();
-
- async function buildConnection(alias: string): Promise<SshConnection> {
- const computer = await deps.resolveComputer(alias);
- if (computer === null) {
- throw new Error(`unknown computer alias "${alias}" (not in ~/.ssh/config)`);
- }
-
- const state: { value: SshConnectionState; error: string | undefined } = {
- value: "disconnected",
- error: undefined,
- };
- const client = deps.newClient();
- let sftp: import("ssh2").SFTPWrapper | null = null;
- let connectPromise: Promise<void> | null = null;
-
- const touch = (): void => {
- const e = entries.get(alias);
- if (e !== undefined) e.lastUsedAt = Date.now();
- };
-
- const connect = (): Promise<void> => {
- if (state.value === "connected") return Promise.resolve();
- if (connectPromise !== null) return connectPromise; // share one connect
- state.value = "connecting";
- connectPromise = doConnect(client, computer, deps, state)
- .then(() => {
- state.value = "connected";
- state.error = undefined;
- // Stale pins → re-evaluate on each connect via hostVerifier already.
- })
- .catch((err: unknown) => {
- state.value = "error";
- state.error = err instanceof Error ? err.message : String(err);
- connectPromise = null; // allow retry on next acquire
- throw err;
- });
- return connectPromise;
- };
-
- const conn: SshConnection = {
- get state() {
- return state.value;
- },
- get error() {
- return state.error;
- },
- async getClient() {
- await connect();
- touch();
- return client;
- },
- async getSftp() {
- await connect();
- if (sftp === null) {
- sftp = await openSftp(client);
- }
- touch();
- return sftp;
- },
- async close() {
- try {
- sftp?.end();
- } catch {
- // best-effort
- }
- try {
- client.end();
- } catch {
- // best-effort
- }
- sftp = null;
- state.value = "disconnected";
- },
- };
- return conn;
- }
-
- return {
- async acquire(computerId: string): Promise<SshConnection> {
- let entry = entries.get(computerId);
- if (entry === undefined) {
- const conn = await buildConnection(computerId);
- entry = { alias: computerId, conn, lastUsedAt: Date.now(), pending: null, reaper: null };
- entries.set(computerId, entry);
- startReaper(entries, computerId, deps);
- }
- // Eagerly verify connectivity (reconnect if the peer died/reaped).
- await entry.conn.getClient().then(
- () => undefined,
- () => {
- // getClient throws on a dead connection — drop + retry once.
- },
- );
- entry.lastUsedAt = Date.now();
- return entry.conn;
- },
-
- async drop(computerId: string): Promise<void> {
- const entry = entries.get(computerId);
- if (entry === undefined) return;
- stopReaper(entry);
- await entry.conn.close();
- entries.delete(computerId);
- },
-
- async closeAll(): Promise<void> {
- const all = [...entries.values()];
- for (const entry of all) stopReaper(entry);
- await Promise.all(all.map((e) => e.conn.close()));
- entries.clear();
- },
-
- status(): readonly SshPoolStatusEntry[] {
- return [...entries.values()].map((e) => ({
- computerId: e.alias,
- state: e.conn.state,
- ...(e.conn.error !== undefined ? { error: e.conn.error } : {}),
- }));
- },
- };
+ const entries = new Map<string, PooledEntry>();
+
+ async function buildConnection(alias: string): Promise<SshConnection> {
+ const computer = await deps.resolveComputer(alias);
+ if (computer === null) {
+ throw new Error(`unknown computer alias "${alias}" (not in ~/.ssh/config)`);
+ }
+
+ const state: { value: SshConnectionState; error: string | undefined } = {
+ value: "disconnected",
+ error: undefined,
+ };
+ const client = deps.newClient();
+ let sftp: import("ssh2").SFTPWrapper | null = null;
+ let connectPromise: Promise<void> | null = null;
+
+ const touch = (): void => {
+ const e = entries.get(alias);
+ if (e !== undefined) e.lastUsedAt = Date.now();
+ };
+
+ const connect = (): Promise<void> => {
+ if (state.value === "connected") return Promise.resolve();
+ if (connectPromise !== null) return connectPromise; // share one connect
+ state.value = "connecting";
+ connectPromise = doConnect(client, computer, deps, state)
+ .then(() => {
+ state.value = "connected";
+ state.error = undefined;
+ // Stale pins → re-evaluate on each connect via hostVerifier already.
+ })
+ .catch((err: unknown) => {
+ state.value = "error";
+ state.error = err instanceof Error ? err.message : String(err);
+ connectPromise = null; // allow retry on next acquire
+ throw err;
+ });
+ return connectPromise;
+ };
+
+ const conn: SshConnection = {
+ get state() {
+ return state.value;
+ },
+ get error() {
+ return state.error;
+ },
+ async getClient() {
+ await connect();
+ touch();
+ return client;
+ },
+ async getSftp() {
+ await connect();
+ if (sftp === null) {
+ sftp = await openSftp(client);
+ }
+ touch();
+ return sftp;
+ },
+ async close() {
+ try {
+ sftp?.end();
+ } catch {
+ // best-effort
+ }
+ try {
+ client.end();
+ } catch {
+ // best-effort
+ }
+ sftp = null;
+ state.value = "disconnected";
+ },
+ };
+ return conn;
+ }
+
+ return {
+ async acquire(computerId: string): Promise<SshConnection> {
+ let entry = entries.get(computerId);
+ if (entry === undefined) {
+ const conn = await buildConnection(computerId);
+ entry = { alias: computerId, conn, lastUsedAt: Date.now(), pending: null, reaper: null };
+ entries.set(computerId, entry);
+ startReaper(entries, computerId, deps);
+ }
+ // Eagerly verify connectivity (reconnect if the peer died/reaped).
+ await entry.conn.getClient().then(
+ () => undefined,
+ () => {
+ // getClient throws on a dead connection — drop + retry once.
+ },
+ );
+ entry.lastUsedAt = Date.now();
+ return entry.conn;
+ },
+
+ async drop(computerId: string): Promise<void> {
+ const entry = entries.get(computerId);
+ if (entry === undefined) return;
+ stopReaper(entry);
+ await entry.conn.close();
+ entries.delete(computerId);
+ },
+
+ async closeAll(): Promise<void> {
+ const all = [...entries.values()];
+ for (const entry of all) stopReaper(entry);
+ await Promise.all(all.map((e) => e.conn.close()));
+ entries.clear();
+ },
+
+ status(): readonly SshPoolStatusEntry[] {
+ return [...entries.values()].map((e) => ({
+ computerId: e.alias,
+ state: e.conn.state,
+ ...(e.conn.error !== undefined ? { error: e.conn.error } : {}),
+ }));
+ },
+ };
}
// ─── connect: auth + host-key ──────────────────────────────────────────────
@@ -227,127 +227,127 @@ export function createSshConnectionPool(deps: SshPoolDeps): SshConnectionPool {
* or connect timeout (never silently connects — plan §4.4/§8).
*/
async function doConnect(
- client: Client,
- computer: Computer,
- deps: SshPoolDeps,
- state: { value: SshConnectionState; error: string | undefined },
+ client: Client,
+ computer: Computer,
+ deps: SshPoolDeps,
+ state: { value: SshConnectionState; error: string | undefined },
): Promise<void> {
- const { privateKey, passphraseError } = await resolvePrivateKey(computer, deps);
- if (passphraseError !== null) throw new Error(passphraseError);
-
- // Read known_hosts once for the host-key decision (present/absent + verify).
- let knownHostsText = "";
- try {
- knownHostsText = await deps.readFileText(deps.knownHostsPath);
- } catch {
- // Missing known_hosts → treat as empty (first connect pins the first line).
- knownHostsText = "";
- }
- const token = knownHostToken(computer.hostName, computer.port);
- const decisionArmed = { decided: false };
-
- await new Promise<void>((resolve, reject) => {
- const onReady = (): void => {
- cleanup();
- resolve();
- };
- const onError = (err: Error): void => {
- cleanup();
- reject(err);
- };
- const timer = setTimeout(() => {
- cleanup();
- reject(new Error(`connect timeout to ${computer.hostName}:${computer.port}`));
- }, CONNECT_TIMEOUT_MS);
-
- function cleanup(): void {
- clearTimeout(timer);
- client.removeListener("ready", onReady);
- client.removeListener("error", onError);
- }
-
- client.on("ready", onReady);
- client.on("error", onError);
-
- const connectConfig: ConnectConfig = {
- host: computer.hostName,
- port: computer.port,
- username: computer.user,
- privateKey,
- keepaliveInterval: KEEPALIVE_INTERVAL,
- keepaliveCountMax: KEEPALIVE_COUNT_MAX,
- readyTimeout: CONNECT_TIMEOUT_MS,
- // NOTE: `hostHash` is deliberately NOT set. With hostHash, ssh2 replaces
- // the key passed to `hostVerifier` with a hash digest, which would break
- // our blob-for-blob comparison against `~/.ssh/known_hosts` (whose 3rd
- // field is the base64 of the raw public-key blob). We compare the raw
- // blob directly, exactly as OpenSSH records it (decision #2 — the file
- // is the shared trust store, so the comparison must be byte-identical).
- hostVerifier: (key: Buffer | string): boolean => {
- if (decisionArmed.decided) return true; // already accepted this handshake
- const fingerprint = toFingerprint(token, key);
- const decision = decideHostKey(knownHostsText, fingerprint);
- decisionArmed.decided = true;
- if (!decision.accept) {
- state.error = decision.reason;
- // Reject the handshake; the emitted 'error' → onError (reject).
- process.nextTick(() => client.emit("error", new Error(decision.reason)));
- return false;
- }
- // Accept. Pin on first connect (append is async + best-effort —
- // the connection proceeds; a failed append only means the next
- // connect re-pins).
- if (decision.append !== undefined) {
- void deps
- .appendKnownHosts(deps.knownHostsPath, decision.append)
- .then(() => {
- deps.logger.info("pinned host key", { alias: computer.alias, token });
- })
- .catch((e: unknown) => {
- deps.logger.warn("failed to pin host key", {
- alias: computer.alias,
- error: e instanceof Error ? e.message : String(e),
- });
- });
- }
- return true;
- },
- };
-
- client.connect(connectConfig);
- });
+ const { privateKey, passphraseError } = await resolvePrivateKey(computer, deps);
+ if (passphraseError !== null) throw new Error(passphraseError);
+
+ // Read known_hosts once for the host-key decision (present/absent + verify).
+ let knownHostsText = "";
+ try {
+ knownHostsText = await deps.readFileText(deps.knownHostsPath);
+ } catch {
+ // Missing known_hosts → treat as empty (first connect pins the first line).
+ knownHostsText = "";
+ }
+ const token = knownHostToken(computer.hostName, computer.port);
+ const decisionArmed = { decided: false };
+
+ await new Promise<void>((resolve, reject) => {
+ const onReady = (): void => {
+ cleanup();
+ resolve();
+ };
+ const onError = (err: Error): void => {
+ cleanup();
+ reject(err);
+ };
+ const timer = setTimeout(() => {
+ cleanup();
+ reject(new Error(`connect timeout to ${computer.hostName}:${computer.port}`));
+ }, CONNECT_TIMEOUT_MS);
+
+ function cleanup(): void {
+ clearTimeout(timer);
+ client.removeListener("ready", onReady);
+ client.removeListener("error", onError);
+ }
+
+ client.on("ready", onReady);
+ client.on("error", onError);
+
+ const connectConfig: ConnectConfig = {
+ host: computer.hostName,
+ port: computer.port,
+ username: computer.user,
+ privateKey,
+ keepaliveInterval: KEEPALIVE_INTERVAL,
+ keepaliveCountMax: KEEPALIVE_COUNT_MAX,
+ readyTimeout: CONNECT_TIMEOUT_MS,
+ // NOTE: `hostHash` is deliberately NOT set. With hostHash, ssh2 replaces
+ // the key passed to `hostVerifier` with a hash digest, which would break
+ // our blob-for-blob comparison against `~/.ssh/known_hosts` (whose 3rd
+ // field is the base64 of the raw public-key blob). We compare the raw
+ // blob directly, exactly as OpenSSH records it (decision #2 — the file
+ // is the shared trust store, so the comparison must be byte-identical).
+ hostVerifier: (key: Buffer | string): boolean => {
+ if (decisionArmed.decided) return true; // already accepted this handshake
+ const fingerprint = toFingerprint(token, key);
+ const decision = decideHostKey(knownHostsText, fingerprint);
+ decisionArmed.decided = true;
+ if (!decision.accept) {
+ state.error = decision.reason;
+ // Reject the handshake; the emitted 'error' → onError (reject).
+ process.nextTick(() => client.emit("error", new Error(decision.reason)));
+ return false;
+ }
+ // Accept. Pin on first connect (append is async + best-effort —
+ // the connection proceeds; a failed append only means the next
+ // connect re-pins).
+ if (decision.append !== undefined) {
+ void deps
+ .appendKnownHosts(deps.knownHostsPath, decision.append)
+ .then(() => {
+ deps.logger.info("pinned host key", { alias: computer.alias, token });
+ })
+ .catch((e: unknown) => {
+ deps.logger.warn("failed to pin host key", {
+ alias: computer.alias,
+ error: e instanceof Error ? e.message : String(e),
+ });
+ });
+ }
+ return true;
+ },
+ };
+
+ client.connect(connectConfig);
+ });
}
/** Resolve the private key bytes for a computer (key-only auth, decision #3). */
async function resolvePrivateKey(
- computer: Computer,
- deps: SshPoolDeps,
+ computer: Computer,
+ deps: SshPoolDeps,
): Promise<{ privateKey: Buffer; passphraseError: string | null }> {
- const candidates = await identityCandidates(computer, deps);
- for (const path of candidates) {
- try {
- const text = await deps.readFileText(path);
- if (looksEncrypted(text)) {
- // MVP: no passphrase prompt (roadmap). Fail with a clear error.
- return {
- privateKey: Buffer.from(text),
- passphraseError:
- `SSH key "${path}" is encrypted — passphrase prompting is not ` +
- `supported in the MVP (use an unencrypted key for computer ` +
- `"${computer.alias}", or set IdentityFile to an unencrypted key).`,
- };
- }
- return { privateKey: Buffer.from(text), passphraseError: null };
- } catch {
- // missing/unreadable → try the next candidate
- }
- }
- return {
- privateKey: Buffer.alloc(0),
- passphraseError:
- `no readable SSH key for computer "${computer.alias}" ` +
- `(checked: ${candidates.join(", ")})`,
- };
+ const candidates = await identityCandidates(computer, deps);
+ for (const path of candidates) {
+ try {
+ const text = await deps.readFileText(path);
+ if (looksEncrypted(text)) {
+ // MVP: no passphrase prompt (roadmap). Fail with a clear error.
+ return {
+ privateKey: Buffer.from(text),
+ passphraseError:
+ `SSH key "${path}" is encrypted — passphrase prompting is not ` +
+ `supported in the MVP (use an unencrypted key for computer ` +
+ `"${computer.alias}", or set IdentityFile to an unencrypted key).`,
+ };
+ }
+ return { privateKey: Buffer.from(text), passphraseError: null };
+ } catch {
+ // missing/unreadable → try the next candidate
+ }
+ }
+ return {
+ privateKey: Buffer.alloc(0),
+ passphraseError:
+ `no readable SSH key for computer "${computer.alias}" ` +
+ `(checked: ${candidates.join(", ")})`,
+ };
}
/**
@@ -356,39 +356,39 @@ async function resolvePrivateKey(
* `~/.ssh/id_rsa`, first-existing-wins — matches OpenSSH's own probing).
*/
async function identityCandidates(computer: Computer, deps: SshPoolDeps): Promise<string[]> {
- const candidates: string[] = [];
- if (computer.identityFile !== null) candidates.push(computer.identityFile);
- for (const name of DEFAULT_IDENTITY_FILES) {
- candidates.push(`${deps.homeDir}/.ssh/${name}`);
- }
- // De-dup + filter to existing, preserving order.
- const existing: string[] = [];
- const seen = new Set<string>();
- for (const c of candidates) {
- if (seen.has(c)) continue;
- seen.add(c);
- if (await deps.pathExists(c)) existing.push(c);
- }
- if (existing.length > 0) return existing;
- // Fall back to the raw candidate list (so resolvePrivateKey reports it).
- return [...new Set(candidates)];
+ const candidates: string[] = [];
+ if (computer.identityFile !== null) candidates.push(computer.identityFile);
+ for (const name of DEFAULT_IDENTITY_FILES) {
+ candidates.push(`${deps.homeDir}/.ssh/${name}`);
+ }
+ // De-dup + filter to existing, preserving order.
+ const existing: string[] = [];
+ const seen = new Set<string>();
+ for (const c of candidates) {
+ if (seen.has(c)) continue;
+ seen.add(c);
+ if (await deps.pathExists(c)) existing.push(c);
+ }
+ if (existing.length > 0) return existing;
+ // Fall back to the raw candidate list (so resolvePrivateKey reports it).
+ return [...new Set(candidates)];
}
const DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_rsa"];
/** OpenSSH encrypts keys with a `ENCRYPTED` header — detect it (no passphrase MVP). */
function looksEncrypted(keyText: string): boolean {
- return keyText.includes("ENCRYPTED");
+ return keyText.includes("ENCRYPTED");
}
/** Open an SFTP session on a connected client (promisified). */
function openSftp(client: Client): Promise<import("ssh2").SFTPWrapper> {
- return new Promise((resolve, reject) => {
- client.sftp((err, sftp) => {
- if (err !== null && err !== undefined) reject(err);
- else resolve(sftp);
- });
- });
+ return new Promise((resolve, reject) => {
+ client.sftp((err, sftp) => {
+ if (err !== null && err !== undefined) reject(err);
+ else resolve(sftp);
+ });
+ });
}
// ─── host-key fingerprint → pure decision input ────────────────────────────
@@ -403,12 +403,12 @@ function openSftp(client: Client): Promise<import("ssh2").SFTPWrapper> {
* itself writes — the file is the shared trust store (decision #2).
*/
function toFingerprint(token: string, key: Buffer | string): HostKeyFingerprint {
- const buf = typeof key === "string" ? Buffer.from(key, "utf8") : key;
- return {
- knownHostToken: token,
- keyBase64: buf.toString("base64"),
- keyType: parseKeyType(buf),
- };
+ const buf = typeof key === "string" ? Buffer.from(key, "utf8") : key;
+ return {
+ knownHostToken: token,
+ keyBase64: buf.toString("base64"),
+ keyType: parseKeyType(buf),
+ };
}
/**
@@ -418,40 +418,40 @@ function toFingerprint(token: string, key: Buffer | string): HostKeyFingerprint
* is the authoritative identity for `decideHostKey`'s comparison.
*/
function parseKeyType(buf: Buffer): string {
- if (buf.length < 4) return "ssh-ed25519";
- const len = buf.readUInt32BE(0);
- if (len <= 0 || buf.length < 4 + len) return "ssh-ed25519";
- return buf.subarray(4, 4 + len).toString("ascii");
+ if (buf.length < 4) return "ssh-ed25519";
+ const len = buf.readUInt32BE(0);
+ if (len <= 0 || buf.length < 4 + len) return "ssh-ed25519";
+ return buf.subarray(4, 4 + len).toString("ascii");
}
// ─── idle reaping ───────────────────────────────────────────────────────────
function startReaper(
- entries: Map<string, PooledEntry>,
- computerId: string,
- deps: SshPoolDeps,
+ entries: Map<string, PooledEntry>,
+ computerId: string,
+ deps: SshPoolDeps,
): void {
- const entry = entries.get(computerId);
- if (entry === undefined) return;
- entry.reaper = setInterval(() => {
- const e = entries.get(computerId);
- if (e === undefined) return;
- const idle = Date.now() - e.lastUsedAt;
- if (idle >= IDLE_REAP_MS) {
- deps.logger.info("reaping idle ssh connection", { alias: computerId, idleMs: idle });
- void e.conn.close().then(() => {
- stopReaper(e);
- entries.delete(computerId);
- });
- }
- }, 60_000);
+ const entry = entries.get(computerId);
+ if (entry === undefined) return;
+ entry.reaper = setInterval(() => {
+ const e = entries.get(computerId);
+ if (e === undefined) return;
+ const idle = Date.now() - e.lastUsedAt;
+ if (idle >= IDLE_REAP_MS) {
+ deps.logger.info("reaping idle ssh connection", { alias: computerId, idleMs: idle });
+ void e.conn.close().then(() => {
+ stopReaper(e);
+ entries.delete(computerId);
+ });
+ }
+ }, 60_000);
}
function stopReaper(entry: PooledEntry): void {
- if (entry.reaper !== null) {
- clearInterval(entry.reaper);
- entry.reaper = null;
- }
+ if (entry.reaper !== null) {
+ clearInterval(entry.reaper);
+ entry.reaper = null;
+ }
}
/** Ssh2 exec stream type alias (the channel backing spawn). */
diff --git a/packages/ssh/src/service.ts b/packages/ssh/src/service.ts
index cebe2ef..c47a81e 100644
--- a/packages/ssh/src/service.ts
+++ b/packages/ssh/src/service.ts
@@ -30,132 +30,132 @@ import { createSshConnectionPool, type SshConnectionPool, type SshPoolDeps } fro
* same real edges against a real sshd.
*/
export interface SshServiceDeps extends SshPoolDeps {
- readonly logger: Logger;
- /** Read `~/.ssh/config` text (the source of truth — decision #4). */
- readonly readConfigText: () => Promise<string>;
- /** The current OS user (fallback when the config sets no `User`). */
- readonly defaultUser: string;
- /** Home dir, for resolving `~` in `IdentityFile`/default key probing. */
- readonly homeDir: string;
- /**
- * Optional: alias → usage count (conversations/workspaces referencing it).
- * host-bin wires this from conversation-store; absent → every count is 0.
- */
- readonly getUsageCounts?: () => Promise<ReadonlyMap<string, number>>;
- /**
- * Optional: glob patterns to exclude from the computer catalog (e.g.
- * `github.com`, `*.ts.net`). Sourced from `dispatch.toml` `[ssh].reject`.
- * Absent → no filtering.
- */
- readonly readRejectPatterns?: () => Promise<readonly string[]>;
+ readonly logger: Logger;
+ /** Read `~/.ssh/config` text (the source of truth — decision #4). */
+ readonly readConfigText: () => Promise<string>;
+ /** The current OS user (fallback when the config sets no `User`). */
+ readonly defaultUser: string;
+ /** Home dir, for resolving `~` in `IdentityFile`/default key probing. */
+ readonly homeDir: string;
+ /**
+ * Optional: alias → usage count (conversations/workspaces referencing it).
+ * host-bin wires this from conversation-store; absent → every count is 0.
+ */
+ readonly getUsageCounts?: () => Promise<ReadonlyMap<string, number>>;
+ /**
+ * Optional: glob patterns to exclude from the computer catalog (e.g.
+ * `github.com`, `*.ts.net`). Sourced from `dispatch.toml` `[ssh].reject`.
+ * Absent → no filtering.
+ */
+ readonly readRejectPatterns?: () => Promise<readonly string[]>;
}
/** Build the `ComputerService` + the remote-`ExecBackend` factory. */
export function createSshService(deps: SshServiceDeps): {
- readonly service: ComputerService;
- readonly pool: SshConnectionPool;
- /** `(computerId) => ExecBackend` — provided via remoteExecBackendFactoryHandle. */
- readonly remoteFactory: (computerId: string) => ExecBackend;
+ readonly service: ComputerService;
+ readonly pool: SshConnectionPool;
+ /** `(computerId) => ExecBackend` — provided via remoteExecBackendFactoryHandle. */
+ readonly remoteFactory: (computerId: string) => ExecBackend;
} {
- const pool = createSshConnectionPool(deps);
-
- async function readEnv(): Promise<SshConfigResolveEnv> {
- const [configText, knownHostsText, rejectPatterns] = await Promise.all([
- deps.readConfigText().catch(async () => ""),
- deps.readFileText(deps.knownHostsPath).catch(async () => ""),
- deps.readRejectPatterns !== undefined
- ? deps.readRejectPatterns().catch(async () => [] as readonly string[])
- : Promise.resolve([] as readonly string[]),
- ]);
- const base: SshConfigResolveEnv = {
- configText,
- knownHostsText,
- defaultUser: deps.defaultUser,
- homeDir: deps.homeDir,
- };
- return rejectPatterns.length > 0 ? { ...base, rejectPatterns } : base;
- }
-
- const service: ComputerService = {
- async listComputers(): Promise<readonly ComputerEntry[]> {
- const env = await readEnv();
- const computers = resolveComputers(env);
- const counts = deps.getUsageCounts !== undefined ? await deps.getUsageCounts() : new Map();
- return computers.map(
- (c): ComputerEntry => ({
- ...c,
- usageCount: counts.get(c.alias) ?? 0,
- }),
- );
- },
-
- async getComputer(alias: string): Promise<Computer | null> {
- const env = await readEnv();
- return resolveComputer(alias, env);
- },
-
- async getStatus(alias: string): Promise<ComputerStatusResponse> {
- const env = await readEnv();
- const computer = resolveComputer(alias, env);
- if (computer === null) {
- return {
- alias,
- state: "disconnected",
- knownHost: false,
- };
- }
- // Surface the pool's live state for this alias (disconnected if never
- // acquired; connecting/connected/error once a connect is attempted).
- const entry = pool.status().find((s) => s.computerId === alias);
- if (entry === undefined) {
- return { alias, state: "disconnected", knownHost: computer.knownHost };
- }
- if (entry.error !== undefined) {
- return { alias, state: "error", error: entry.error, knownHost: computer.knownHost };
- }
- return { alias, state: entry.state, knownHost: computer.knownHost };
- },
-
- async test(alias: string): Promise<TestComputerResponse> {
- const env = await readEnv();
- const computer = resolveComputer(alias, env);
- if (computer === null) {
- return { alias, ok: false, error: `unknown computer alias "${alias}"` };
- }
- // One-shot probe: acquire (connects), run a trivial command, then drop
- // the connection so a test never holds a pooled socket open (plan §9.1).
- // Wrapped in a timeout safety net so the endpoint ALWAYS responds —
- // even if the SSH connect/exec/drop hangs (the probe is non-interactive).
- const PROBE_TOTAL_TIMEOUT_MS = 30_000;
- try {
- const result = await Promise.race<TestComputerResponse>([
- runTestProbe(pool, alias, deps.logger),
- timeoutAfter<TestComputerResponse>(
- PROBE_TOTAL_TIMEOUT_MS,
- `test timed out after ${PROBE_TOTAL_TIMEOUT_MS / 1000}s`,
- ),
- ]);
- return result;
- } catch (err: unknown) {
- await pool.drop(alias).catch(() => undefined);
- const message = err instanceof Error ? err.message : String(err);
- deps.logger.warn("computer test failed", { alias, error: message });
- return { alias, ok: false, error: message };
- }
- },
- };
-
- /**
- * The factory exec-backend consumes: given a computerId (alias), return a
- * remote `ExecBackend`. The backend acquires lazily — merely building it
- * (in the resolver) opens NO connection; the first method call connects.
- * Only the alias is captured; the pool re-resolves connection params from
- * `~/.ssh/config` at connect time, so no stale snapshot is held here.
- */
- const remoteFactory = (computerId: string): ExecBackend =>
- createSshExecBackend(computerId, async (alias) => pool.acquire(alias));
-
- return { service, pool, remoteFactory };
+ const pool = createSshConnectionPool(deps);
+
+ async function readEnv(): Promise<SshConfigResolveEnv> {
+ const [configText, knownHostsText, rejectPatterns] = await Promise.all([
+ deps.readConfigText().catch(async () => ""),
+ deps.readFileText(deps.knownHostsPath).catch(async () => ""),
+ deps.readRejectPatterns !== undefined
+ ? deps.readRejectPatterns().catch(async () => [] as readonly string[])
+ : Promise.resolve([] as readonly string[]),
+ ]);
+ const base: SshConfigResolveEnv = {
+ configText,
+ knownHostsText,
+ defaultUser: deps.defaultUser,
+ homeDir: deps.homeDir,
+ };
+ return rejectPatterns.length > 0 ? { ...base, rejectPatterns } : base;
+ }
+
+ const service: ComputerService = {
+ async listComputers(): Promise<readonly ComputerEntry[]> {
+ const env = await readEnv();
+ const computers = resolveComputers(env);
+ const counts = deps.getUsageCounts !== undefined ? await deps.getUsageCounts() : new Map();
+ return computers.map(
+ (c): ComputerEntry => ({
+ ...c,
+ usageCount: counts.get(c.alias) ?? 0,
+ }),
+ );
+ },
+
+ async getComputer(alias: string): Promise<Computer | null> {
+ const env = await readEnv();
+ return resolveComputer(alias, env);
+ },
+
+ async getStatus(alias: string): Promise<ComputerStatusResponse> {
+ const env = await readEnv();
+ const computer = resolveComputer(alias, env);
+ if (computer === null) {
+ return {
+ alias,
+ state: "disconnected",
+ knownHost: false,
+ };
+ }
+ // Surface the pool's live state for this alias (disconnected if never
+ // acquired; connecting/connected/error once a connect is attempted).
+ const entry = pool.status().find((s) => s.computerId === alias);
+ if (entry === undefined) {
+ return { alias, state: "disconnected", knownHost: computer.knownHost };
+ }
+ if (entry.error !== undefined) {
+ return { alias, state: "error", error: entry.error, knownHost: computer.knownHost };
+ }
+ return { alias, state: entry.state, knownHost: computer.knownHost };
+ },
+
+ async test(alias: string): Promise<TestComputerResponse> {
+ const env = await readEnv();
+ const computer = resolveComputer(alias, env);
+ if (computer === null) {
+ return { alias, ok: false, error: `unknown computer alias "${alias}"` };
+ }
+ // One-shot probe: acquire (connects), run a trivial command, then drop
+ // the connection so a test never holds a pooled socket open (plan §9.1).
+ // Wrapped in a timeout safety net so the endpoint ALWAYS responds —
+ // even if the SSH connect/exec/drop hangs (the probe is non-interactive).
+ const PROBE_TOTAL_TIMEOUT_MS = 30_000;
+ try {
+ const result = await Promise.race<TestComputerResponse>([
+ runTestProbe(pool, alias, deps.logger),
+ timeoutAfter<TestComputerResponse>(
+ PROBE_TOTAL_TIMEOUT_MS,
+ `test timed out after ${PROBE_TOTAL_TIMEOUT_MS / 1000}s`,
+ ),
+ ]);
+ return result;
+ } catch (err: unknown) {
+ await pool.drop(alias).catch(() => undefined);
+ const message = err instanceof Error ? err.message : String(err);
+ deps.logger.warn("computer test failed", { alias, error: message });
+ return { alias, ok: false, error: message };
+ }
+ },
+ };
+
+ /**
+ * The factory exec-backend consumes: given a computerId (alias), return a
+ * remote `ExecBackend`. The backend acquires lazily — merely building it
+ * (in the resolver) opens NO connection; the first method call connects.
+ * Only the alias is captured; the pool re-resolves connection params from
+ * `~/.ssh/config` at connect time, so no stale snapshot is held here.
+ */
+ const remoteFactory = (computerId: string): ExecBackend =>
+ createSshExecBackend(computerId, async (alias) => pool.acquire(alias));
+
+ return { service, pool, remoteFactory };
}
/**
@@ -163,27 +163,27 @@ export function createSshService(deps: SshServiceDeps): {
* be raced against a timeout. Always drops the connection (even on success).
*/
async function runTestProbe(
- pool: SshConnectionPool,
- alias: string,
- logger: Logger,
+ pool: SshConnectionPool,
+ alias: string,
+ logger: Logger,
): Promise<TestComputerResponse> {
- const conn = await pool.acquire(alias);
- const client = await conn.getClient();
- const ok = await runProbe(client);
- if (ok) {
- logger.info("computer test ok", { alias });
- }
- await pool.drop(alias);
- return ok
- ? { alias, ok: true }
- : { alias, ok: false, error: "remote command returned no exit code" };
+ const conn = await pool.acquire(alias);
+ const client = await conn.getClient();
+ const ok = await runProbe(client);
+ if (ok) {
+ logger.info("computer test ok", { alias });
+ }
+ await pool.drop(alias);
+ return ok
+ ? { alias, ok: true }
+ : { alias, ok: false, error: "remote command returned no exit code" };
}
/** Reject with `message` after `ms`. Used to race against a hanging probe. */
function timeoutAfter<T>(ms: number, message: string): Promise<T> {
- return new Promise<T>((_resolve, reject) => {
- setTimeout(() => reject(new Error(message)), ms);
- });
+ return new Promise<T>((_resolve, reject) => {
+ setTimeout(() => reject(new Error(message)), ms);
+ });
}
/** Probe timeout — the `true` command exits instantly; 15s is generous. */
@@ -198,37 +198,37 @@ const PROBE_TIMEOUT_MS = 15_000;
* the `exit` event never fires (e.g. the server requires a pty for exec).
*/
function runProbe(client: import("ssh2").Client): Promise<boolean> {
- return new Promise<boolean>((resolve) => {
- let settled = false;
- const done = (result: boolean): void => {
- if (!settled) {
- settled = true;
- clearTimeout(timer);
- resolve(result);
- }
- };
-
- const timer = setTimeout(() => {
- done(false);
- }, PROBE_TIMEOUT_MS);
-
- client.exec("true", { pty: false }, (err, stream) => {
- if (err !== null && err !== undefined) {
- done(false);
- return;
- }
- // Resolve on `exit` — the command has finished. Don't wait for
- // `close` (some servers never emit it for exec channels).
- stream.on("exit", (code: number | null) => {
- done(code === 0);
- });
- // Safety net: if `exit` never fires, `close` might.
- stream.on("close", () => {
- done(false);
- });
- // Drain any output so the stream doesn't deadlock.
- stream.on("data", () => {});
- stream.stderr.on("data", () => {});
- });
- });
+ return new Promise<boolean>((resolve) => {
+ let settled = false;
+ const done = (result: boolean): void => {
+ if (!settled) {
+ settled = true;
+ clearTimeout(timer);
+ resolve(result);
+ }
+ };
+
+ const timer = setTimeout(() => {
+ done(false);
+ }, PROBE_TIMEOUT_MS);
+
+ client.exec("true", { pty: false }, (err, stream) => {
+ if (err !== null && err !== undefined) {
+ done(false);
+ return;
+ }
+ // Resolve on `exit` — the command has finished. Don't wait for
+ // `close` (some servers never emit it for exec channels).
+ stream.on("exit", (code: number | null) => {
+ done(code === 0);
+ });
+ // Safety net: if `exit` never fires, `close` might.
+ stream.on("close", () => {
+ done(false);
+ });
+ // Drain any output so the stream doesn't deadlock.
+ stream.on("data", () => {});
+ stream.stderr.on("data", () => {});
+ });
+ });
}