diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 16:19:58 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 16:19:58 +0900 |
| commit | 652010b6c054b69d813e8a2c724d6db039242119 (patch) | |
| tree | b70e4dd1591381a6c017c0c02f9502474b1d612d | |
| parent | 350b9b8e247bb1c24f49a884fdade18e44b115eb (diff) | |
| download | dispatch-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.lock | 42 | ||||
| -rw-r--r-- | packages/ssh/package.json | 20 | ||||
| -rw-r--r-- | packages/ssh/src/backend.ts | 200 | ||||
| -rw-r--r-- | packages/ssh/src/config.test.ts | 162 | ||||
| -rw-r--r-- | packages/ssh/src/config.ts | 164 | ||||
| -rw-r--r-- | packages/ssh/src/errors.test.ts | 90 | ||||
| -rw-r--r-- | packages/ssh/src/errors.ts | 111 | ||||
| -rw-r--r-- | packages/ssh/src/extension.ts | 124 | ||||
| -rw-r--r-- | packages/ssh/src/hostkey.test.ts | 105 | ||||
| -rw-r--r-- | packages/ssh/src/hostkey.ts | 148 | ||||
| -rw-r--r-- | packages/ssh/src/index.ts | 24 | ||||
| -rw-r--r-- | packages/ssh/src/integration.test.ts | 184 | ||||
| -rw-r--r-- | packages/ssh/src/pool.ts | 458 | ||||
| -rw-r--r-- | packages/ssh/src/service.ts | 164 | ||||
| -rw-r--r-- | packages/ssh/tsconfig.json | 12 | ||||
| -rw-r--r-- | tasks.md | 15 | ||||
| -rw-r--r-- | tsconfig.json | 3 |
17 files changed, 2024 insertions, 2 deletions
@@ -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" } + ] +} @@ -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" }, { |
