summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 16:19:58 +0900
committerAdam Malczewski <[email protected]>2026-06-25 16:19:58 +0900
commit652010b6c054b69d813e8a2c724d6db039242119 (patch)
treeb70e4dd1591381a6c017c0c02f9502474b1d612d
parent350b9b8e247bb1c24f49a884fdade18e44b115eb (diff)
downloaddispatch-652010b6c054b69d813e8a2c724d6db039242119.tar.gz
dispatch-652010b6c054b69d813e8a2c724d6db039242119.zip
feat(ssh): wave 5b — the ssh package (remote ExecBackend over ssh2)
Wave 5b of transparent SSH support. NEW standard extension @dispatch/ssh makes remote execution actually work over SSH, transparently. ssh2 verified to run under Bun (load-bearing decision #1 confirmed: connects to local sshd :22 + execs). - config.ts: ~/.ssh/config reader via ssh-config -> Computer[]/ComputerEntry[] (read-only discovery; resolves hostName/port/user/identityFile/knownHost). - hostkey.ts: known_hosts auto-trust-and-pin (present->verify/reject-on-mismatch, absent->accept+append; the accept-new analog). - errors.ts: pure ssh2/SFTP -> node:fs-style .code error mapping (so tools' existing ENOENT branches work unchanged). - pool.ts: SshConnectionPool (per-alias ssh2.Client, lazy connect, keep-alive, idle reap ~15m); key-only auth from ~/.ssh (config IdentityFile or default id_ed25519/id_rsa); no agent-forwarding, no PTY. - backend.ts: SshExecBackend implements ExecBackend (spawn via client.exec with shell-quoted cwd; fs via SFTP). - service.ts + extension.ts: activate provides BOTH handles the other units consume — remoteExecBackendFactoryHandle (exec-backend: computerId->SshExecBackend) AND computerServiceHandle (transport-http: listComputers/getComputer/getStatus/test). - orchestrator: added packages/ssh to root tsconfig.json refs + bun install. Tests: 45 pass + 6 sshd-integration skipped (it.skipIf(!process.env.SSH_TEST_HOST)). Verified: tsc -b EXIT 0, biome clean, 1690 vitest pass (was 1641, +49). CRs for wave 5c: host-bin registration; CR-5 transport-http barrel re-export; CR-6 usageCount wiring (deferred-ok, defaults to 0). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
-rw-r--r--bun.lock42
-rw-r--r--packages/ssh/package.json20
-rw-r--r--packages/ssh/src/backend.ts200
-rw-r--r--packages/ssh/src/config.test.ts162
-rw-r--r--packages/ssh/src/config.ts164
-rw-r--r--packages/ssh/src/errors.test.ts90
-rw-r--r--packages/ssh/src/errors.ts111
-rw-r--r--packages/ssh/src/extension.ts124
-rw-r--r--packages/ssh/src/hostkey.test.ts105
-rw-r--r--packages/ssh/src/hostkey.ts148
-rw-r--r--packages/ssh/src/index.ts24
-rw-r--r--packages/ssh/src/integration.test.ts184
-rw-r--r--packages/ssh/src/pool.ts458
-rw-r--r--packages/ssh/src/service.ts164
-rw-r--r--packages/ssh/tsconfig.json12
-rw-r--r--tasks.md15
-rw-r--r--tsconfig.json3
17 files changed, 2024 insertions, 2 deletions
diff --git a/bun.lock b/bun.lock
index 6b3e667..fbd64a4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -183,6 +183,22 @@
"@dispatch/session-orchestrator": "workspace:*",
},
},
+ "packages/ssh": {
+ "name": "@dispatch/ssh",
+ "version": "0.0.0",
+ "dependencies": {
+ "@dispatch/exec-backend": "workspace:*",
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/transport-contract": "workspace:*",
+ "@dispatch/transport-http": "workspace:*",
+ "@dispatch/wire": "workspace:*",
+ "ssh-config": "^5.1.0",
+ "ssh2": "^1.17.0",
+ },
+ "devDependencies": {
+ "@types/ssh2": "^1.15.5",
+ },
+ },
"packages/storage-sqlite": {
"name": "@dispatch/storage-sqlite",
"version": "0.0.0",
@@ -389,6 +405,8 @@
"@dispatch/skills": ["@dispatch/skills@workspace:packages/skills"],
+ "@dispatch/ssh": ["@dispatch/ssh@workspace:packages/ssh"],
+
"@dispatch/storage-sqlite": ["@dispatch/storage-sqlite@workspace:packages/storage-sqlite"],
"@dispatch/surface-loaded-extensions": ["@dispatch/surface-loaded-extensions@workspace:packages/surface-loaded-extensions"],
@@ -541,6 +559,8 @@
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
+ "@types/ssh2": ["@types/[email protected]", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
+
"@vitest/expect": ["@vitest/[email protected]", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ=="],
"@vitest/mocker": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw=="],
@@ -555,8 +575,14 @@
"@vitest/utils": ["@vitest/[email protected]", "", { "dependencies": { "@vitest/pretty-format": "3.2.6", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg=="],
+ "asn1": ["[email protected]", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
+
"assertion-error": ["[email protected]", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+ "bcrypt-pbkdf": ["[email protected]", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
+
+ "buildcheck": ["[email protected]", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
+
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"cac": ["[email protected]", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
@@ -565,6 +591,8 @@
"check-error": ["[email protected]", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
+ "cpu-features": ["[email protected]", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
+
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-eql": ["[email protected]", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
@@ -591,6 +619,8 @@
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "nan": ["[email protected]", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="],
+
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"pathe": ["[email protected]", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -605,10 +635,16 @@
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.61.1", "@rollup/rollup-android-arm64": "4.61.1", "@rollup/rollup-darwin-arm64": "4.61.1", "@rollup/rollup-darwin-x64": "4.61.1", "@rollup/rollup-freebsd-arm64": "4.61.1", "@rollup/rollup-freebsd-x64": "4.61.1", "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", "@rollup/rollup-linux-arm-musleabihf": "4.61.1", "@rollup/rollup-linux-arm64-gnu": "4.61.1", "@rollup/rollup-linux-arm64-musl": "4.61.1", "@rollup/rollup-linux-loong64-gnu": "4.61.1", "@rollup/rollup-linux-loong64-musl": "4.61.1", "@rollup/rollup-linux-ppc64-gnu": "4.61.1", "@rollup/rollup-linux-ppc64-musl": "4.61.1", "@rollup/rollup-linux-riscv64-gnu": "4.61.1", "@rollup/rollup-linux-riscv64-musl": "4.61.1", "@rollup/rollup-linux-s390x-gnu": "4.61.1", "@rollup/rollup-linux-x64-gnu": "4.61.1", "@rollup/rollup-linux-x64-musl": "4.61.1", "@rollup/rollup-openbsd-x64": "4.61.1", "@rollup/rollup-openharmony-arm64": "4.61.1", "@rollup/rollup-win32-arm64-msvc": "4.61.1", "@rollup/rollup-win32-ia32-msvc": "4.61.1", "@rollup/rollup-win32-x64-gnu": "4.61.1", "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA=="],
+ "safer-buffer": ["[email protected]", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+
"siginfo": ["[email protected]", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+ "ssh-config": ["[email protected]", "", {}, "sha512-z4fFE4MgCja706ajwYOg6uptS3BIu0TWSUj08UWLuwNB/awVktEA5LyOgIAmgazyDjyTULhlL2GaBv37k4zoxQ=="],
+
+ "ssh2": ["[email protected]", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
+
"stackback": ["[email protected]", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["[email protected]", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
@@ -627,6 +663,8 @@
"tinyspy": ["[email protected]", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
+ "tweetnacl": ["[email protected]", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
+
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["[email protected]", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
@@ -638,5 +676,9 @@
"vitest": ["[email protected]", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.6", "@vitest/mocker": "3.2.6", "@vitest/pretty-format": "^3.2.6", "@vitest/runner": "3.2.6", "@vitest/snapshot": "3.2.6", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.6", "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw=="],
"why-is-node-running": ["[email protected]", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
+
+ "@types/ssh2/@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
+
+ "@types/ssh2/@types/node/undici-types": ["[email protected]", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
}
}
diff --git a/packages/ssh/package.json b/packages/ssh/package.json
new file mode 100644
index 0000000..8f0d025
--- /dev/null
+++ b/packages/ssh/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@dispatch/ssh",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/exec-backend": "workspace:*",
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/transport-contract": "workspace:*",
+ "@dispatch/transport-http": "workspace:*",
+ "@dispatch/wire": "workspace:*",
+ "ssh-config": "^5.1.0",
+ "ssh2": "^1.17.0"
+ },
+ "devDependencies": {
+ "@types/ssh2": "^1.15.5"
+ }
+}
diff --git a/packages/ssh/src/backend.ts b/packages/ssh/src/backend.ts
new file mode 100644
index 0000000..6531b8f
--- /dev/null
+++ b/packages/ssh/src/backend.ts
@@ -0,0 +1,200 @@
+/**
+ * SshExecBackend — implements `ExecBackend` over a pooled SSH connection.
+ *
+ * `spawn` runs a command on the remote via `client.exec` (shell-quoting the cwd
+ * into `cd "<cwd>" && <command>`; ssh2 exec has no cwd option). `readFile`/
+ * `writeFile`/`stat`/`readdir`/`exists` use SFTP. Every ssh2/SFTP error is
+ * routed through `errors.ts` so it lands as a node:fs-style `.code` error — the
+ * bundled tools' existing error branches (e.g. `read_file`'s "File not found"
+ * on `ENOENT`) work unchanged (plan §4.3).
+ *
+ * Built per `acquire`: captures the alias + a lazy `acquire` thunk so merely
+ * RESOLVING a backend never opens a connection — only the first actual method
+ * call connects (the resolver stays side-effect-free; see exec-backend service).
+ */
+
+import type {
+ DirEntry,
+ ExecBackend,
+ ExecResult,
+ SpawnParams,
+ StatResult,
+} from "@dispatch/exec-backend";
+import type { Client, ClientChannel } from "ssh2";
+import { mapSshError } from "./errors.js";
+import type { SshConnection } from "./pool.js";
+
+/** Acquire the pooled connection for an alias (lazy — the backend is built
+ * before any connection exists; acquire runs on first method call). */
+export type AcquireConnection = (alias: string) => Promise<SshConnection>;
+
+/**
+ * Build a remote `ExecBackend` for `alias`. The connection is acquired lazily
+ * inside each method (so resolving a backend in the resolver is free — opening
+ * a connection is deferred to the first actual tool call). Only the alias is
+ * needed here: the pool re-resolves the real `Computer` (hostName/port/user/key)
+ * 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));
+ });
+ },
+ };
+}
+
+// ─── spawn core ─────────────────────────────────────────────────────────────
+
+/**
+ * Run one `client.exec`, wiring stdout/stderr → `params.onOutput`, exit code,
+ * abort (`stream.end()`), and timeout. Mirrors `localSpawn`'s settle-once +
+ * 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);
+ });
+ });
+}
+
+// ─── shell quoting ─────────────────────────────────────────────────────────
+
+/**
+ * Shell-quote a path for the `cd "<cwd>" && ...` prefix so a cwd containing
+ * shell metacharacters cannot break out (plan §7.6). Single-quotes wrap the
+ * value and any embedded single-quote is escaped (`'\''`).
+ */
+export function shellQuote(value: string): string {
+ return `'${value.replace(/'/g, "'\\''")}'`;
+}
diff --git a/packages/ssh/src/config.test.ts b/packages/ssh/src/config.test.ts
new file mode 100644
index 0000000..e1ae05b
--- /dev/null
+++ b/packages/ssh/src/config.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it } from "vitest";
+import {
+ knownHostToken,
+ resolveComputer,
+ resolveComputers,
+ type SshConfigResolveEnv,
+} from "./config.js";
+
+const env = (overrides: Partial<SshConfigResolveEnv> = {}): SshConfigResolveEnv => ({
+ configText: "",
+ knownHostsText: "",
+ defaultUser: "fallback-user",
+ homeDir: "/home/test",
+ ...overrides,
+});
+
+const FIXTURE = `
+# top-level comment
+Host *
+ ServerAliveInterval 60
+
+Host myserver
+ HostName 10.0.0.5
+ Port 2222
+ User deploy
+ IdentityFile ~/.ssh/deploy_key
+
+Host web *.example.com
+ HostName web.internal
+ User webuser
+
+Host barehost
+ # no HostName → falls back to alias
+
+Host github.com
+ HostName github.com
+ User git
+ IdentityFile ~/.ssh/github_key
+`;
+
+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 = `
+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);
+ });
+});
+
+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 = `
+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");
+ });
+});
+
+describe("knownHostToken", () => {
+ 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");
+ });
+});
diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts
new file mode 100644
index 0000000..6116125
--- /dev/null
+++ b/packages/ssh/src/config.ts
@@ -0,0 +1,164 @@
+/**
+ * ~/.ssh/config reader — pure discovery of `Computer`s from an SSH config.
+ *
+ * Per decision #4: computers are DISCOVERED read-only (no CRUD). A "computer"
+ * is a named (non-wildcard) `Host` alias in the system's `~/.ssh/config`. This
+ * module is the PURE half: it takes the config TEXT + known_hosts TEXT (the I/O
+ * of reading the files lives in the shell) and resolves each alias to a
+ * `Computer`. Uses the `ssh-config` package for correct parsing (wildcards,
+ * `Include`, first-match-wins) rather than a hand-rolled parser (decision #8).
+ *
+ * Pure: zero I/O, zero mocks — a test feeds fixture strings. The shell
+ * (`service.ts`) injects the file contents.
+ */
+
+import type { Computer } from "@dispatch/wire";
+import SSHConfig, { type Directive, type Section } from "ssh-config";
+import { isKnownHost } from "./hostkey.js";
+
+/** Injected environment for the pure resolver (no ambient process access). */
+export interface SshConfigResolveEnv {
+ /** The raw `~/.ssh/config` text. */
+ readonly configText: string;
+ /** The raw `~/.ssh/known_hosts` text (drives `knownHost`). */
+ 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;
+}
+
+/**
+ * Parse `~/.ssh/config` and return one `Computer` per named (non-wildcard)
+ * `Host` alias, with resolved `hostName`/`port`/`user`/`identityFile`/
+ * `knownHost`. Wildcard hosts (`*`, `?.example.com`) are NOT computers (they
+ * are patterns, not selectable targets) — skipped. Sorted by `alias`.
+ *
+ * `knownHost` reflects whether the resolved HostName appears in
+ * `~/.ssh/known_hosts` (drives the FE "known/new" indicator).
+ *
+ * Pure: `SshConfigResolveEnv` → `readonly Computer[]`.
+ */
+export function resolveComputers(env: SshConfigResolveEnv): readonly Computer[] {
+ const config = SSHConfig.parse(env.configText);
+ const computers: Computer[] = [];
+
+ 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);
+ }
+ }
+
+ // De-dup by alias (a host may be listed in multiple `Host` lines; first wins
+ // per OpenSSH), 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;
+ });
+ unique.sort((a, b) => (a.alias < b.alias ? -1 : a.alias > b.alias ? 1 : 0));
+ return unique;
+}
+
+/**
+ * Resolve a single alias to a `Computer` (or `null` when the alias isn't a
+ * named host). Pure. `compute()` applies OpenSSH first-match-wins + wildcards.
+ */
+export function resolveComputer(alias: string, env: SshConfigResolveEnv): Computer | null {
+ const config = SSHConfig.parse(env.configText);
+ if (!aliasExistsAsNamedHost(config, alias)) return null;
+ return resolveOne(config, alias, env);
+}
+
+/** 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);
+
+ // `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 };
+}
+
+// ─── ssh-config line helpers ──────────────────────────────────────────────
+
+function isHostSection(line: SSHConfig[number]): line is Section {
+ 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 [];
+}
+
+/** A `Host` alias is a selectable computer only if it contains no wildcard chars. */
+function isWildcardAlias(alias: string): boolean {
+ 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;
+}
+
+// ─── 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;
+}
+
+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;
+}
+
+function identityFileValue(
+ 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);
+}
+
+/** 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;
+}
+
+/**
+ * The token used to key `known_hosts` for a host:port. Mirrors OpenSSH — a
+ * non-default port is recorded as `[host]:port`; the default port (22) is just
+ * `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}`;
+}
diff --git a/packages/ssh/src/errors.test.ts b/packages/ssh/src/errors.test.ts
new file mode 100644
index 0000000..234fa49
--- /dev/null
+++ b/packages/ssh/src/errors.test.ts
@@ -0,0 +1,90 @@
+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_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_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();
+ });
+});
+
+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");
+ });
+});
+
+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 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("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("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("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");
+ });
+});
diff --git a/packages/ssh/src/errors.ts b/packages/ssh/src/errors.ts
new file mode 100644
index 0000000..fab9d32
--- /dev/null
+++ b/packages/ssh/src/errors.ts
@@ -0,0 +1,111 @@
+/**
+ * Error mapping — translate ssh2/SFTP errors onto node:fs-style errors.
+ *
+ * The bundled tools (`read_file`/`write_file`/`edit_file`) branch on
+ * `(err as NodeJS.ErrnoException).code` (e.g. `"ENOENT"`). SFTP/ssh2 errors do
+ * NOT carry that shape, so the `SshExecBackend` routes every throw through this
+ * mapping first. Pure: input → output, no I/O, zero mocks (plan §4.3).
+ *
+ * ssh2 SFTP status codes (RFC 4254 §9.1) → node:fs errno mapping, mirroring how
+ * OpenSSH's own sftp client and node:fs classify the same conditions. Only the
+ * cases the tools actually react to are mapped; everything else becomes
+ * `EIO`-ish (a generic I/O error) so the tool's generic catch still works.
+ */
+
+/** A node:fs-style error carrying a `.code` errno string. */
+export interface FsError extends Error {
+ 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;
+}
+
+/**
+ * Map a numeric SFTP status code (SSH_FXP_*) onto a node:fs errno string.
+ * Returns `undefined` when the code has no meaningful errno analog (caller
+ * falls back to a generic I/O error). Pure.
+ *
+ * @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;
+}
+
+/**
+ * Normalize a ssh2/SFTP error into a node:fs-style `FsError`. Inspects the
+ * ssh2 error's `code` (an SSH_FXP_* status string like `"SFTP_NO_SUCH_FILE"`
+ * or a numeric `.code`/`.desc`) and maps it. Anything unrecognized → `EIO`.
+ *
+ * ssh2 surfaces SFTP failures two ways depending on the operation:
+ * - callback `err` whose `.code` is an `"SFTP_*"` status string, OR a numeric
+ * code on the error object;
+ * - `sftp.exists(cb)` which gives no error — handled separately by the caller.
+ *
+ * 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);
+
+ // 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}`);
+ }
+
+ // 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}`);
+}
+
+/**
+ * Map an ssh2 `"SFTP_*"` status-code string (e.g. `"SFTP_STATUS_NO_SUCH_FILE"`,
+ * `"NO_SUCH_FILE"`) onto a node:fs errno. ssh2's exact string spelling varies
+ * across versions, so match case-insensitively on the stable fragment.
+ * 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;
+}
diff --git a/packages/ssh/src/extension.ts b/packages/ssh/src/extension.ts
new file mode 100644
index 0000000..f63a84f
--- /dev/null
+++ b/packages/ssh/src/extension.ts
@@ -0,0 +1,124 @@
+/**
+ * ssh extension — manifest + `activate(host)`.
+ *
+ * Provides TWO typed service handles (the seams other units already declared):
+ * 1. `remoteExecBackendFactoryHandle` (@dispatch/exec-backend) — `(alias) =>
+ * ExecBackend`; this is what makes `resolveBackend(computerId)` return a
+ * remote backend (exec-backend lazy-looks-it-up at tool-execute time).
+ * 2. `computerServiceHandle` (@dispatch/transport-http) — the `ComputerService`
+ * the HTTP routes delegate to (list/get/status/test).
+ *
+ * `activate` builds the service with REAL edges (`node:fs` + real `ssh2.Client`)
+ * and registers both. The injected-deps seam (`SshServiceDeps`) lets the
+ * integration test drive the same real ssh2 against a live sshd (mirrors how
+ * `packages/mcp` injects its spawn/read adapters — no `@dispatch/*` mocking).
+ */
+
+import { access, appendFile, readFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { remoteExecBackendFactoryHandle } from "@dispatch/exec-backend";
+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 } 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"] },
+};
+
+/**
+ * Build the ssh extension with injectable edges. The production `extension`
+ * passes real `node:fs` + real `ssh2`; a test passes the same real edges
+ * 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;
+ },
+ };
+}
+
+// ─── real node:fs + ssh2 adapters (production wiring) ─────────────────────
+
+/**
+ * Resolve the real `SshServiceDeps` against the live filesystem + ssh2. The
+ * `resolveComputer` dep is wired from the pure config reader using the same
+ * live `readConfigText`/`readFileText` edges, so the pool connects with params
+ * 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";
+
+ return {
+ logger: hostLogger,
+ homeDir: homedir(),
+ defaultUser,
+ knownHostsPath,
+ readConfigText,
+ readFileText,
+ 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. Reads
+ // fresh on each call (the config is the source of truth; a Host block added
+ // between turns is picked up). Returns null for an unknown/stale alias.
+ resolveComputer: async (alias: string) => {
+ const [configText, knownHostsText] = await Promise.all([
+ readConfigText().catch(async () => ""),
+ readFileText(knownHostsPath).catch(async () => ""),
+ ]);
+ return resolveComputerFromConfig(alias, {
+ configText,
+ knownHostsText,
+ defaultUser,
+ homeDir: homedir(),
+ });
+ },
+ };
+}
+
+/** Production extension: real `node:fs` + real `ssh2`. */
+export const extension: Extension = {
+ 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
new file mode 100644
index 0000000..1975777
--- /dev/null
+++ b/packages/ssh/src/hostkey.test.ts
@@ -0,0 +1,105 @@
+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",
+});
+
+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("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);
+ });
+});
+
+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("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("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");
+ });
+});
+
+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");
+ });
+});
+
+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 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 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);
+ });
+});
diff --git a/packages/ssh/src/hostkey.ts b/packages/ssh/src/hostkey.ts
new file mode 100644
index 0000000..626b060
--- /dev/null
+++ b/packages/ssh/src/hostkey.ts
@@ -0,0 +1,148 @@
+/**
+ * Host-key trust decision — the `accept-new` (auto-trust-and-pin) analog.
+ *
+ * Per decisions #2/#3: on connect, the ssh2 `hostVerifier` checks whether the
+ * host's key is in `~/.ssh/known_hosts`. If present → verify match (reject on
+ * mismatch, surface "HOST KEY CHANGED" loudly). If absent (first connect) →
+ * accept + append the fingerprint to `known_hosts` (the pin). A future FE
+ * "approve host key" prompt (roadmap) would gate that first accept.
+ *
+ * The DECISION is PURE (input → output, no I/O, zero mocks): given the current
+ * known_hosts text + the host + its key fingerprint, decide accept/reject and
+ * (if pinning) produce the line to append. The I/O — reading/append-writing the
+ * real `~/.ssh/known_hosts` file — lives in the `SshConnectionPool` shell, which
+ * injects the text + applies the append. This keeps the policy unit-testable
+ * against fixture strings (plan §4.4).
+ */
+
+/** 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;
+}
+
+/**
+ * The host-key verification fingerprint. ssh2 hands the verifier the raw host
+ * key Buffer; the shell computes a fingerprint string (e.g. an OpenSSH-style
+ * `SHA256:...` hex) + the key type + the line OpenSSH would write (the `host`
+ * token + base64 key) so the decision is string-comparable against known_hosts.
+ *
+ * Carrying the exact line OpenSSH itself writes keeps `~/.ssh/known_hosts`
+ * interchangeable with the real ssh client (decision #2 — the file is the
+ * 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;
+}
+
+/**
+ * Decide whether to accept a host key, given the current `known_hosts` text.
+ *
+ * The matching mirrors OpenSSH's `known_hosts` semantics at the granularity the
+ * MVP needs: a line whose FIRST field equals `fingerprint.knownHostToken` is a
+ * match for that host. (OpenSSH also supports comma-host + hash + wildcard
+ * tokens; the MVP pins one explicit token per host — the shell writes exactly
+ * the token ssh2 supplied — so a plain first-field compare is correct and
+ * sufficient. A full known_hosts parser is a roadmap item.)
+ *
+ * - **present + key matches** → accept, no append (already pinned).
+ * - **present + key differs** → REJECT ("HOST KEY CHANGED" — never silently
+ * connect; this is the MITM guard).
+ * - **absent (first connect)** → accept + append the pin line.
+ *
+ * Pure: `knownHostsText` + `fingerprint` → `HostKeyDecision`.
+ */
+export function decideHostKey(
+ knownHostsText: string,
+ fingerprint: HostKeyFingerprint,
+): HostKeyDecision {
+ 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);
+
+ 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 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;
+}
+
+/** 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(" ");
+}
+
+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;
+}
+
+/**
+ * Read whether a host appears in `known_hosts` at all (for the read-only
+ * `Computer.knownHost` view surfaced by `GET /computers`). Pure. Uses the same
+ * first-field matching as `decideHostKey`.
+ */
+export function isKnownHost(knownHostsText: string, token: string): boolean {
+ return findHostLine(knownHostsText, token) !== undefined;
+}
diff --git a/packages/ssh/src/index.ts b/packages/ssh/src/index.ts
new file mode 100644
index 0000000..2d4fb2a
--- /dev/null
+++ b/packages/ssh/src/index.ts
@@ -0,0 +1,24 @@
+export { type AcquireConnection, createSshExecBackend, shellQuote } from "./backend.js";
+export {
+ 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,
+} from "./hostkey.js";
+export {
+ 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
new file mode 100644
index 0000000..7b05be2
--- /dev/null
+++ b/packages/ssh/src/integration.test.ts
@@ -0,0 +1,184 @@
+/**
+ * Integration test against a REAL sshd (the outermost edge). NOT mocked:
+ * - no `vi.mock` of `@dispatch/*` (forbidden by the constitution);
+ * - no mock of `ssh2` itself (that would defeat the purpose — the smoke test
+ * from the load-bearing first step IS the real-edge proof).
+ *
+ * Skipped unless `SSH_TEST_HOST` is set, so CI without an sshd stays green. The
+ * orchestrator live-verifies by exporting `SSH_TEST_HOST=localhost` (with the
+ * user's own key + an sshd on :22). The test exercises the full path: config
+ * reader → pool connect (key-only auth + host-key pin) → SshExecBackend spawn +
+ * SFTP fs ops, all over the real ssh2-under-Bun edge.
+ */
+
+import { access, mkdir, mkdtemp, readFile } from "node:fs/promises";
+import { homedir, tmpdir } from "node:os";
+import { join } from "node:path";
+import type { Logger } from "@dispatch/kernel";
+import { Client } from "ssh2";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { createSshExecBackend } from "./backend.js";
+import { resolveComputer } from "./config.js";
+import { createSshConnectionPool } from "./pool.js";
+
+const HOST = process.env.SSH_TEST_HOST;
+const PORT = process.env.SSH_TEST_PORT ? Number.parseInt(process.env.SSH_TEST_PORT, 10) : 22;
+const USER = process.env.SSH_TEST_USER ?? process.env.USER ?? "";
+
+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`;
+}
+
+const sshDir = join(homedir(), ".ssh");
+
+// Build real pool deps (real node:fs + real ssh2) for the test sshd.
+/**
+ * A self-referential `Logger` stub: every method is a no-op, and `child()`
+ * 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;
+}
+
+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(),
+ }),
+ };
+}
+
+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);
+ });
+});
diff --git a/packages/ssh/src/pool.ts b/packages/ssh/src/pool.ts
new file mode 100644
index 0000000..5b380d8
--- /dev/null
+++ b/packages/ssh/src/pool.ts
@@ -0,0 +1,458 @@
+/**
+ * SshConnectionPool — one pooled `ssh2.Client` per computer alias.
+ *
+ * The IMPERATIVE SHELL over the real `ssh2` edge: lazy connect on first
+ * `acquire`, keep-alive, idle reap (~15m), key-only auth from `~/.ssh`, and
+ * host-key auto-trust-and-pin via `~/.ssh/known_hosts` (decisions #2/#3). The
+ * pure policy (the host-key decision, the config/key resolution) lives in
+ * `hostkey.ts` / `config.ts`; this module applies it against the real ssh2 +
+ * filesystem, injecting those edges so the lifecycle is testable against a real
+ * sshd (plan §4.2/§4.4).
+ *
+ * @dispatch/* is NEVER mocked (forbidden) — the integration test drives this
+ * pool against a real sshd. Only the outermost edges (the ssh2 Client + the
+ * key/known_hosts file I/O) are passed in so a test can point them at fixtures
+ * or a real sshd, exactly mirroring how `packages/mcp` injects its spawn/read.
+ */
+
+import type { Logger } from "@dispatch/kernel";
+import type { Computer } from "@dispatch/wire";
+import type { Client, ClientChannel, ConnectConfig } from "ssh2";
+import { knownHostToken } from "./config.js";
+import { decideHostKey, type HostKeyFingerprint } from "./hostkey.js";
+
+/** The idle-reap interval: close a connection unused for this long (ms). */
+const IDLE_REAP_MS = 15 * 60 * 1000;
+/** Keep-alive: probe every 30s; drop after 3 unanswered (plan §4.2). */
+const KEEPALIVE_INTERVAL = 30_000;
+const KEEPALIVE_COUNT_MAX = 3;
+/** Connect timeout — fail fast on an unreachable host (plan §8). */
+const CONNECT_TIMEOUT_MS = 10_000;
+
+export type SshConnectionState = "disconnected" | "connecting" | "connected" | "error";
+
+/**
+ * A pooled connection for one alias. Lazy: `getClient`/`getSftp` connect on
+ * first use; the same `ssh2.Client` backs every subsequent call (one connection
+ * 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;
+}
+
+/**
+ * The outermost edges the pool drives. Injected so a test points them at a real
+ * 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;
+}
+
+export interface SshConnectionPool {
+ 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;
+}
+
+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;
+}
+
+/**
+ * Create the pool. The returned object owns one `ssh2.Client` per alias; the
+ * caller wires it into the `SshExecBackend` (exec-backend factory) + the
+ * `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 } : {}),
+ }));
+ },
+ };
+}
+
+// ─── connect: auth + host-key ──────────────────────────────────────────────
+
+/**
+ * Drive a single `client.connect`: resolve the key, verify/pin the host key,
+ * and await `ready`. Throws a clear error on auth failure, host-key mismatch,
+ * 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 },
+): 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);
+ });
+}
+
+/** Resolve the private key bytes for a computer (key-only auth, decision #3). */
+async function resolvePrivateKey(
+ 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(", ")})`,
+ };
+}
+
+/**
+ * The IdentityFile candidates: the config's `IdentityFile` (resolved absolute
+ * by the config reader), else the default probe order (`~/.ssh/id_ed25519` →
+ * `~/.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 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");
+}
+
+/** 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);
+ });
+ });
+}
+
+// ─── host-key fingerprint → pure decision input ────────────────────────────
+
+/**
+ * Build the `HostKeyFingerprint` from the raw host public-key blob ssh2's
+ * verifier supplies (a Buffer — see `ConnectConfig.hostVerifier`, used WITHOUT
+ * `hostHash` so the blob is passed verbatim). The blob is the OpenSSH wire-format
+ * public key: `[uint32 len][key-type string][key material…]`, base64-encoded as
+ * the 3rd field of a `known_hosts` line. We parse the type string from the blob
+ * (rather than guessing) so the pinned line is byte-identical to what OpenSSH
+ * 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),
+ };
+}
+
+/**
+ * Read the key-type label (e.g. `ssh-ed25519`) from the first length-prefixed
+ * string of an OpenSSH public-key blob. Falls back to `ssh-ed25519` (the most
+ * common host key) if the blob is too short to parse — the base64 blob itself
+ * 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");
+}
+
+// ─── idle reaping ───────────────────────────────────────────────────────────
+
+function startReaper(
+ 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);
+}
+
+function stopReaper(entry: PooledEntry): void {
+ if (entry.reaper !== null) {
+ clearInterval(entry.reaper);
+ entry.reaper = null;
+ }
+}
+
+/** Ssh2 exec stream type alias (the channel backing spawn). */
+export type { ClientChannel };
diff --git a/packages/ssh/src/service.ts b/packages/ssh/src/service.ts
new file mode 100644
index 0000000..6a809c6
--- /dev/null
+++ b/packages/ssh/src/service.ts
@@ -0,0 +1,164 @@
+/**
+ * ComputerService — the read-only computer discovery + live-state surface the
+ * transport-http routes delegate to (`computerServiceHandle`), plus the remote
+ * `ExecBackend` factory exec-backend consumes (`remoteExecBackendFactoryHandle`).
+ *
+ * This is the IMPERATIVE SHELL that wires the pure config reader (`config.ts`)
+ * to the real filesystem + the `SshConnectionPool`. It reads `~/.ssh/config` +
+ * `~/.ssh/known_hosts` (read-only — decision #4: computers are discovered, not
+ * CRUD'd), resolves aliases, and delegates connect/test/status to the pool.
+ *
+ * `usageCount` (on `ComputerEntry`) is INJECTED, not owned here: the ssh package
+ * discovers computers; how many conversations/workspaces reference an alias is
+ * conversation-store data. host-bin wires `getUsageCounts` from conversation-store
+ * later (a CR — conversation-store needs a count-by-alias helper); until then it
+ * defaults to 0 so the feature is fully functional (discovery + connect).
+ */
+
+import type { ExecBackend } from "@dispatch/exec-backend";
+import type { Logger } from "@dispatch/kernel";
+import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/transport-contract";
+import type { ComputerService } from "@dispatch/transport-http/dist/seam.js";
+import type { Computer, ComputerEntry } from "@dispatch/wire";
+import { createSshExecBackend } from "./backend.js";
+import { resolveComputer, resolveComputers } from "./config.js";
+import { createSshConnectionPool, type SshConnectionPool, type SshPoolDeps } from "./pool.js";
+
+/**
+ * Edges the service drives (mirrors mcp's injected deps). The real wiring
+ * (extension.ts) passes `node:fs` + real ssh2; the integration test passes the
+ * 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>>;
+}
+
+/** 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;
+} {
+ const pool = createSshConnectionPool(deps);
+
+ async function readEnv() {
+ const [configText, knownHostsText] = await Promise.all([
+ deps.readConfigText().catch(async () => ""),
+ deps.readFileText(deps.knownHostsPath).catch(async () => ""),
+ ]);
+ return { configText, knownHostsText, defaultUser: deps.defaultUser, homeDir: deps.homeDir };
+ }
+
+ 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).
+ try {
+ const conn = await pool.acquire(alias);
+ const client = await conn.getClient();
+ const ok = await runProbe(client);
+ if (ok) {
+ // Successful connect pins the host key (the accept-new analog);
+ // a fresh known_hosts read reflects the new pin.
+ deps.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" };
+ } 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 };
+}
+
+/** Run `true` over SSH as a connectivity probe; resolve ok=true on exit 0. */
+function runProbe(client: import("ssh2").Client): Promise<boolean> {
+ return new Promise<boolean>((resolve) => {
+ client.exec("true", { pty: false }, (err, stream) => {
+ if (err !== null && err !== undefined) {
+ resolve(false);
+ return;
+ }
+ let exitCode: number | null = null;
+ stream.on("exit", (code: number | null) => {
+ exitCode = code;
+ });
+ stream.on("close", () => {
+ resolve(exitCode === 0);
+ });
+ });
+ });
+}
diff --git a/packages/ssh/tsconfig.json b/packages/ssh/tsconfig.json
new file mode 100644
index 0000000..79e6972
--- /dev/null
+++ b/packages/ssh/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [
+ { "path": "../exec-backend" },
+ { "path": "../kernel" },
+ { "path": "../transport-contract" },
+ { "path": "../transport-http" },
+ { "path": "../wire" }
+ ]
+}
diff --git a/tasks.md b/tasks.md
index 0916af9..27d7b75 100644
--- a/tasks.md
+++ b/tasks.md
@@ -35,8 +35,19 @@ owner-agents on disjoint packages).
threading + the `ComputerService` seam the ssh package will provide) +
`transport-ws` (computerId through chat.send/queue) + `mcp` (CR-1: preserve
computerId in filter). `tsc -b` EXIT 0, biome clean, **1641 vitest** (was 1620).
-- [ ] **Wave 5**: `host-bin` wiring + `ssh` package (SshConnectionPool,
- SshExecBackend, ~/.ssh/config reader via ssh-config, known_hosts pinning).
+- [x] **Wave 5a**: `exec-backend` — remote-backend factory handle (lazy lookup;
+ computerId set -> SshExecBackend via factory; absent -> clear error). +24 tests.
+- [x] **Wave 5b**: `ssh` package (NEW) — SshConnectionPool (per-alias ssh2.Client,
+ lazy connect, keep-alive, idle reap), SshExecBackend (ssh2 exec+sftp, node:fs
+ .code error mapping), ~/.ssh/config reader (ssh-config), known_hosts
+ auto-trust-and-pin, key-only auth from ~/.ssh. LOAD-BEARING: ssh2 verified
+ under Bun (connected to local sshd :22, exec OK) — decision #1 confirmed.
+ Provides remoteExecBackendFactoryHandle + computerServiceHandle. +45 tests
+ (6 sshd integration tests skipped). tsc -b EXIT 0, biome clean, **1690 vitest**
+ (was 1641).
+- [ ] **Wave 5c**: host-bin — register exec-backend + ssh extensions; CR-5
+ transport-http barrel re-export of computerServiceHandle; CR-6
+ usageCount wiring (deferred-ok).
- [ ] **DEFERRED — cache-warming**: computerId threading intentionally NOT done
(user-deferred — cache-warming is not needed right now). Known limitation:
a warm probe on a remote turn assembles the tool set WITHOUT the remote-drop
diff --git a/tsconfig.json b/tsconfig.json
index 7fda111..aab3ac1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -44,6 +44,9 @@
"path": "./packages/exec-backend"
},
{
+ "path": "./packages/ssh"
+ },
+ {
"path": "./packages/conversation-store"
},
{