diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 01:09:39 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 01:09:39 +0900 |
| commit | 61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch) | |
| tree | 2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/ssh/src | |
| parent | 63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff) | |
| parent | 727c98c9dae516a2070eb950410314380a20c974 (diff) | |
| download | dispatch-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.ts | 292 | ||||
| -rw-r--r-- | packages/ssh/src/config.test.ts | 650 | ||||
| -rw-r--r-- | packages/ssh/src/config.ts | 394 | ||||
| -rw-r--r-- | packages/ssh/src/errors.test.ts | 134 | ||||
| -rw-r--r-- | packages/ssh/src/errors.ts | 106 | ||||
| -rw-r--r-- | packages/ssh/src/extension.ts | 240 | ||||
| -rw-r--r-- | packages/ssh/src/hostkey.test.ts | 152 | ||||
| -rw-r--r-- | packages/ssh/src/hostkey.ts | 142 | ||||
| -rw-r--r-- | packages/ssh/src/index.ts | 28 | ||||
| -rw-r--r-- | packages/ssh/src/integration.test.ts | 272 | ||||
| -rw-r--r-- | packages/ssh/src/pool.ts | 650 | ||||
| -rw-r--r-- | packages/ssh/src/service.ts | 340 |
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", () => {}); + }); + }); } |
