diff options
82 files changed, 6535 insertions, 360 deletions
@@ -51,6 +51,13 @@ "@dispatch/kernel": "workspace:*", }, }, + "packages/exec-backend": { + "name": "@dispatch/exec-backend", + "version": "0.0.0", + "dependencies": { + "@dispatch/kernel": "workspace:*", + }, + }, "packages/host-bin": { "name": "@dispatch/host-bin", "version": "0.0.0", @@ -59,6 +66,7 @@ "@dispatch/cache-warming": "workspace:*", "@dispatch/conversation-store": "workspace:*", "@dispatch/credential-store": "workspace:*", + "@dispatch/exec-backend": "workspace:*", "@dispatch/journal-sink": "workspace:*", "@dispatch/kernel": "workspace:*", "@dispatch/lsp": "workspace:*", @@ -68,6 +76,7 @@ "@dispatch/provider-umans": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", "@dispatch/skills": "workspace:*", + "@dispatch/ssh": "workspace:*", "@dispatch/storage-sqlite": "workspace:*", "@dispatch/surface-loaded-extensions": "workspace:*", "@dispatch/surface-registry": "workspace:*", @@ -176,6 +185,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", @@ -228,6 +253,7 @@ "name": "@dispatch/tool-edit-file", "version": "0.0.0", "dependencies": { + "@dispatch/exec-backend": "workspace:*", "@dispatch/kernel": "workspace:*", "@dispatch/lsp": "workspace:*", }, @@ -236,6 +262,7 @@ "name": "@dispatch/tool-read-file", "version": "0.0.0", "dependencies": { + "@dispatch/exec-backend": "workspace:*", "@dispatch/kernel": "workspace:*", }, }, @@ -243,6 +270,7 @@ "name": "@dispatch/tool-shell", "version": "0.0.0", "dependencies": { + "@dispatch/exec-backend": "workspace:*", "@dispatch/kernel": "workspace:*", }, }, @@ -257,6 +285,7 @@ "name": "@dispatch/tool-write-file", "version": "0.0.0", "dependencies": { + "@dispatch/exec-backend": "workspace:*", "@dispatch/kernel": "workspace:*", }, }, @@ -299,6 +328,7 @@ "@dispatch/system-prompt": "workspace:*", "@dispatch/throughput-store": "workspace:*", "@dispatch/transport-contract": "workspace:*", + "@dispatch/wire": "workspace:*", "hono": "^4.0.0", }, }, @@ -351,6 +381,8 @@ "@dispatch/credential-store": ["@dispatch/credential-store@workspace:packages/credential-store"], + "@dispatch/exec-backend": ["@dispatch/exec-backend@workspace:packages/exec-backend"], + "@dispatch/host-bin": ["@dispatch/host-bin@workspace:packages/host-bin"], "@dispatch/journal-sink": ["@dispatch/journal-sink@workspace:packages/journal-sink"], @@ -375,6 +407,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"], @@ -527,6 +561,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=="], @@ -541,8 +577,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=="], @@ -551,6 +593,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=="], @@ -577,6 +621,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=="], @@ -591,10 +637,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=="], @@ -613,6 +665,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=="], @@ -624,5 +678,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/notes/ssh-support-plan.md b/notes/ssh-support-plan.md new file mode 100644 index 0000000..dce5618 --- /dev/null +++ b/notes/ssh-support-plan.md @@ -0,0 +1,988 @@ +# SSH Support — Design & Implementation Plan + +> **Status:** Planning. No implementation has begun. +> **Branch:** `feature/ssh-support` +> **Scope:** Transparent SSH execution so an agent runs commands on a remote +> computer as if local — the agent never learns it is using SSH. +> This plan follows the architecture rules in `AGENTS.md` (kernel → core → +> standard tiers; effects at the edges; contracts are the only cross-unit +> surface; one owner per unit). + +--- + +## 0. Goals (from the feature brief) + +1. **Remote computer selection** — alongside `cwd`, a user can select another + computer to connect to via SSH. When an agent runs commands, they execute on + that remote computer transparently. The agent must NOT know it is using SSH; + it just runs commands and they happen on the remote machine. +2. **Workspace-level defaults** — a user can set a computer as the default for a + workspace. Any agent summoned in that workspace without an explicitly + assigned computer inherits the workspace's configured computer. +3. **Per-conversation override** — a conversation can specify its own computer, + overriding the workspace default. + +The non-functional requirement that shapes everything below: **transparency.** +The model sees identical tools with identical descriptions whether execution is +local or remote. Only the tool *implementation* routes differently per call. + +--- + +## 0.5 Resolved decisions (user-confirmed 2026-06-25) + +These supersede any contrary recommendation elsewhere in this document. + +1. **Library:** use the regular **`ssh2`** (mscdex/ssh2) — do **not** use the + `bun-ssh2` fork. Caveat: `ssh2` leans on Node's `crypto`, so verifying it + runs under **Bun** is the load-bearing first step of Phase 3. If it fails, + there is no easy fallback (fork ruled out) — escalate to the user. +2. **Host-key trust:** **auto-trust-and-pin** on first connect (record the + fingerprint, verify on every subsequent connect, surface a mismatch loudly + — the `StrictHostKeyChecking=accept-new` analog). A frontend "approve host + key" prompt is a **roadmap** item (future), not MVP. +3. **Auth:** **key-only**, using the keys already installed on the Dispatch host + under **`~/.ssh/`**. No keys in gopass/`SecretsAccess`; no password/agent + auth in the MVP. +4. **Computer discovery (key simplification):** the list of available computers + is **auto-discovered from the system's `~/.ssh/config`**, not hand-entered + into a CRUD store. `computerId` **is** an SSH config `Host` alias (e.g. + `"myserver"`). There is therefore **no `Computer` CRUD entity and no + `computer-store` package** — only a read-only config reader + the persisted + *assignment* (which conversation/workspace uses which alias). `~/.ssh/known_hosts` + is the host-key trust store. (See §3 for the revised data model.) +5. **`computerId` persistence:** persisted **per-conversation** (like `cwd`), + not per-`chat.send`. A per-turn override on `chat.send` is supported by the + contract but not exposed in the MVP UI. +6. **LSP/MCP on remote turns:** **silently dropped** (the tools filter removes + them; the agent sees nothing, no system-prompt note). This avoids busting + the prompt cache. Remote LSP/MCP spawn is a future phase. +7. **`edit_file` on remote:** keeps working (writes via SFTP) with **no + post-edit diagnostics** (the diagnostics hook returns empty — the existing + no-LSP degradation path). + +--- + +## 1. How execution works today (the seam we plug into) + +### 1.1 The cwd → tool pipeline + +`cwd` is already threaded end-to-end through a pure, injected path. SSH support +mirrors this exact path with a `computerId`: + +``` +ChatRequest.cwd / ChatRequest.workspaceId (transport-contract) + → StartTurnInput.cwd / workspaceId (session-orchestrator) + → runTurnDetached: getEffectiveCwd(...) (resolve per-turn) + → RunTurnInput.cwd (kernel contract) + → StepContext.cwd (run-turn.ts) + → createStepDispatcher(..., cwd) (dispatch.ts) + → executeToolCall(..., cwd) + → ToolExecuteContext.cwd (contracts/tool.ts) + → tool.execute(args, ctx) uses ctx.cwd +``` + +Key files (the single-owner units this feature touches): + +| Unit (package) | Role | Current local-only behavior | +|---|---|---| +| `kernel` (contracts) | `RunTurnInput.cwd`, `ToolExecuteContext.cwd` | Threads a string; never interprets | +| `kernel` (runtime) | `dispatch.ts` `executeToolCall` builds `ToolExecuteContext` | Forwards `cwd` verbatim | +| `session-orchestrator` | `getEffectiveCwd` resolution; builds `RunTurnInput` | Resolves cwd against workspace `defaultCwd` | +| `conversation-store` | `Workspace.defaultCwd`, per-conv cwd, `getEffectiveCwd` | Stores cwd + workspace | +| `tool-shell` | `run_shell` tool; `SpawnShell` interface | `realSpawn` = `node:child_process` | +| `tool-read-file` | `read_file` tool | `node:fs/promises` directly (readdir/readFile/stat) | +| `tool-write-file` | `write_file` tool | `node:fs/promises` directly (access/stat/writeFile) | +| `tool-edit-file` | `edit_file` tool | `node:fs` directly | +| `lsp` | spawns language servers, reads/watches files | `Bun.spawn`, `Bun.file`, `node:fs.watch` | +| `mcp` | spawns MCP servers | injectable `spawn` adapter | +| `transport-http` | `POST /chat`, cwd/workspace endpoints | — | +| `transport-ws` | `chat.send` message (cwd/workspaceId) | — | +| `host-bin` | wires extensions + `process.cwd()` into tools | — | + +### 1.2 The critical finding: tools hardcode `node:fs`/`node:child_process` + +The shell tool already has an injectable seam — `SpawnShell`: + +```ts +// packages/tool-shell/src/shell.ts +export type SpawnShell = (params: { + readonly command: string; + readonly cwd: string; + readonly signal: AbortSignal; + readonly timeout: number; + readonly onOutput: (data: string, stream: "stdout" | "stderr") => void; +}) => Promise<SpawnResult>; +``` + +But it is bound **once at activation** with a fixed local spawn: + +```ts +// packages/tool-shell/src/extension.ts +host.defineTool(createRunShellTool({ workdir: process.cwd(), spawn: realSpawn })); +``` + +The filesystem tools (`read_file`, `write_file`, `edit_file`) are **worse**: they +call `node:fs/promises` *directly inside `execute()`*, with no injection seam at +all. The only injection is the `workdir` (bound to `process.cwd()` at boot). + +**Implication:** transparency is not free. To run a tool remotely, the tool must +resolve its execution backend *per call* from `ToolExecuteContext` — not from a +binding fixed at activation. This requires: + +1. A new `ExecBackend` (a.k.a. host backend) abstraction over spawn + fs. +2. Threading a `computerId` through `ToolExecuteContext` (mirroring `cwd`). +3. Refactoring the filesystem tools to use the injected backend instead of + `node:fs` directly. + +### 1.3 The workspace model we mirror + +`getEffectiveCwd` (conversation-store) is the exact resolution pattern to clone +for computers: + +1. **Absolute per-conversation cwd** → used outright +2. **Relative per-conversation cwd** → resolved against workspace `defaultCwd` +3. **No per-conversation cwd** → workspace `defaultCwd` +4. **Neither** → `serverDefaultCwd` (`process.cwd()`) + +For computers the ladder is: + +1. **Per-conversation `computerId`** → used outright +2. **No per-conversation `computerId`** → workspace `defaultComputerId` +3. **Neither** → `null` = **local** (no SSH; today's behavior) + +`null` (local) is the "server default" equivalent — and it is the ONLY level +that requires no SSH, so the feature degrades cleanly to today's behavior when no +computer is configured anywhere. + +--- + +## 2. Core design: the `ExecBackend` abstraction + +### 2.1 The contract (new, lives in a new core extension `exec-backend`) + +The central abstraction is an `ExecBackend`: the union of spawn + filesystem +operations a tool needs, expressed against **paths and bytes**, never against +`node:fs`/`child_process`. There are exactly two implementations: + +- `LocalExecBackend` — wraps `node:fs/promises` + `node:child_process` (today's + behavior, factored out). +- `SshExecBackend` — wraps `ssh2` `exec` + `sftp` (new, in the `ssh` extension). + +```ts +// packages/exec-backend/src/backend.ts (NEW core extension) + +/** A spawned process's stdio handles + lifecycle, transport-agnostic. */ +export interface ExecResult { + readonly exitCode: number | null; + readonly timedOut: boolean; + readonly aborted: boolean; +} + +export interface SpawnParams { + readonly command: string; + readonly cwd: string; + readonly signal: AbortSignal; + readonly timeout: number; + readonly onOutput: (data: string, stream: "stdout" | "stderr") => void; +} + +/** Stat result — the subset read_file/write_file/edit_file need. */ +export interface StatResult { + readonly isFile: boolean; + readonly isDirectory: boolean; +} + +/** + * The execution backend: spawn + a minimal filesystem surface. + * Tools program against THIS, never against node:fs. Two implementations: + * local (node) and ssh (ssh2). Resolved per-call from ToolExecuteContext. + * + * Deliberately a SMALL surface (only what the bundled tools use) so a remote + * implementation is tractable. New operations are added here, not ad hoc. + */ +export interface ExecBackend { + /** Run a shell command, streaming stdout/stderr. The shell-tool seam. */ + readonly spawn: (params: SpawnParams) => Promise<ExecResult>; + + // --- filesystem (the read_file / write_file / edit_file surface) --- + readonly readFile: (path: string) => Promise<string>; + readonly writeFile: (path: string, content: string) => Promise<void>; + readonly stat: (path: string) => Promise<StatResult>; + readonly readdir: (path: string) => Promise<readonly { readonly name: string; readonly isDirectory: boolean }[]>; + /** Check existence without throwing. */ + readonly exists: (path: string) => Promise<boolean>; +} +``` + +### 2.2 Resolution: `computerId` on `ToolExecuteContext` + a resolver + +The tool cannot reach the Host API at `execute` time (it only gets +`ToolExecuteContext`). So resolution flows through the context, mirroring `cwd`: + +**Kernel contract change** (additive, optional field — backward compatible): + +```ts +// packages/kernel/src/contracts/tool.ts (MODIFIED) +export interface ToolExecuteContext { + readonly toolCallId: string; + readonly onOutput: (data: string, stream: "stdout" | "stderr") => void; + readonly signal: AbortSignal; + readonly log: Logger; + readonly cwd?: string; + readonly conversationId?: string; + /** + * The computer this tool-call executes on (NEW). When omitted/undefined, + * execution is LOCAL (today's behavior). When set, tools resolve a remote + * ExecBackend via the injected resolver. The kernel never interprets it — + * it forwards verbatim from RunTurnInput, like cwd. + */ + readonly computerId?: string; +} +``` + +```ts +// packages/kernel/src/contracts/runtime.ts (MODIFIED — RunTurnInput) +export interface RunTurnInput { + // ...existing fields... + readonly cwd?: string; + /** + * The computer to execute this turn's tools on (NEW). Omitted = local. + * Forwarded verbatim to each ToolExecuteContext.computerId. Like cwd, it + * never enters the model prompt (no prompt-cache impact). + */ + readonly computerId?: string; + // ... +} +``` + +The dispatch runtime (`executeToolCall`) threads `computerId` exactly as it does +`cwd` today (one extra optional arg / field). + +### 2.3 How a tool resolves its backend + +Each affected tool is constructed with an injected **backend resolver** — a +function `(computerId?) => ExecBackend`. At `execute` time it calls +`resolveBackend(ctx.computerId)`: + +```ts +// packages/tool-shell/src/shell.ts (MODIFIED factory signature) +export function createRunShellTool(deps: { + readonly workdir: string; + /** Resolve the execution backend for a call. computerId undefined = local. */ + readonly resolveBackend: (computerId?: string) => ExecBackend; + readonly outputCap?: number; +}): ToolContract { + // ... + async execute(args, ctx) { + // ... + const backend = deps.resolveBackend(ctx.computerId); + spawnResult = await backend.spawn({ command, cwd: effectiveCwd, signal, timeout, onOutput }); + // ... + } +} +``` + +The `LocalExecBackend` ignores `computerId`; the `SshExecBackend` is built for a +specific connection (see §4). The resolver (provided by the `exec-backend` +extension via a service handle, wired in `host-bin`) returns the right one. + +> **Why a resolver function and not `host.getService` inside execute?** Tools +> don't receive `host` at execute time — only `ToolExecuteContext`. Injecting the +> resolver at construction (like `spawn` today) keeps the tool pure-ish and +> testable (a test injects a fake resolver), consistent with the existing +> `SpawnShell`/`McpExtensionDeps` injection patterns. The resolver is the ONE +> piece of ambient-ish wiring; it is owned by the `exec-backend` extension and +> is reproducible from inputs (computerId → backend). + +### 2.4 The filesystem tools must be refactored + +`read_file`/`write_file`/`edit_file` currently call `node:fs/promises` inline. +They must be rewritten to call `backend.readFile(...)` etc. through the same +injected resolver. The pure logic (validate args, slice lines, diff, decide +overwrite) stays pure and untouched — only the I/O calls move behind the +backend. This is the bulk of the mechanical work but is low-risk: the contract is +a strict subset of `node:fs`. + +--- + +## 3. Data model: the `Computer` is a view over `~/.ssh/config` + +> **Revised per decision #4.** There is no persisted `Computer` CRUD entity. A +> "computer" is a `Host` alias in the system's `~/.ssh/config`, discovered +> read-only. Dispatch stores only the *assignment* (an alias string) per +> conversation and per workspace — exactly parallel to how `cwd` is a string +> stored alongside everything else. + +### 3.1 The `Computer` view (wire — read-only) + +`listComputers()` parses `~/.ssh/config` and returns one entry per named +(non-wildcard) `Host` alias, with the connection params resolved from the +config (first-match-wins for `HostName`/`User`/`Port`/`IdentityFile`): + +```ts +// packages/wire/src/index.ts (NEW — read-only view, not an editable entity) +export interface Computer { + /** The SSH config `Host` alias — also the computerId users select. */ + readonly alias: string; + /** Resolved HostName/IP from the config (falls back to the alias itself). */ + readonly hostName: string; + /** Resolved port (config `Port`, default 22). */ + readonly port: number; + /** Resolved user (config `User`, default current user). */ + readonly user: string; + /** Resolved IdentityFile path (config, or null = default ~/.ssh/id_*). */ + readonly identityFile: string | null; + /** + * Whether the host's key is already in ~/.ssh/known_hosts (i.e. previously + * connected). Drives the FE "known/new" indicator. Read-only. + */ + readonly knownHost: boolean; +} + +export interface ComputerEntry extends Computer { + /** Number of conversations/workspaces whose computerId resolves to this alias. */ + readonly usageCount: number; +} +``` + +`Computer` is **not editable through the API** — to add a computer, the user +adds a `Host` block to `~/.ssh/config` (the file they already manage). This is +the deliberate simplification: the source of truth is the user's existing SSH +config, so there is nothing to keep in sync. + +### 3.2 No `computer-store` package + +Because there is no `Computer` entity to CRUD, the dedicated `computer-store` +package is **eliminated**. What remains: + +- A **read-only config reader** (`parseSshConfig()`) — lives inside the `ssh` + extension (it owns SSH concern end-to-end). It uses the `ssh-config` package + (project-local dep, see §13.Q) to parse `~/.ssh/config` correctly (wildcards, + `Include`, first-match-wins) rather than hand-rolling. +- The **persisted assignment** — `computerId` per-conversation + `defaultComputerId` + per workspace — stored as strings alongside `cwd`/`defaultCwd`. This is owned + by **`conversation-store`** (it already owns the workspace row + per-conv + keys). The `getEffectiveComputer` resolution (§3.3) lives in `conversation-store` + too, mirroring `getEffectiveCwd`. + +So the only new contract surface for storage is on `conversation-store` +(§3.4) — there is no separate store unit. + +### 3.3 The resolution ladder (`getEffectiveComputer`) + +``` +1. overrideComputerId (per-turn, from chat.send) → return alias (or null) +2. per-conversation computerId (persisted) → return alias (or null) +3. workspace defaultComputerId → return alias (or null) +4. none of the above → null (LOCAL) +``` + +`null` is the deliberate "local" sentinel — no SSH connection, today's behavior. +A new conversation in a workspace with a `defaultComputerId` inherits it without +persisting anything itself. **Note:** `getEffectiveComputer` returns the alias +*string* (or null); it does NOT validate the alias exists in `~/.ssh/config` +(validation happens at connect time — a stale alias yields a clear connect error +rather than silently falling back to local). + +### 3.4 `Workspace` gains `defaultComputerId` (conversation-store) + +```ts +// packages/wire/src/index.ts (MODIFIED) +export interface Workspace { + readonly id: string; + readonly title: string; + readonly defaultCwd: string | null; + /** NEW: default computer (SSH config alias) for conversations in this workspace. null = local. */ + readonly defaultComputerId: string | null; + readonly createdAt: number; + readonly lastActivityAt: number; +} +``` + +`conversation-store` gains, parallel to `cwd`/`defaultCwd`: +- `getComputerId(convId) / setComputerId(convId, alias | null) / clearComputerId` + (per-conversation, mirror `getCwd`/`setCwd`/`clearCwd`) +- `setWorkspaceDefaultComputerId(wsId, alias | null)` (mirror + `setWorkspaceDefaultCwd`) +- `getEffectiveComputer(convId, overrideAlias?)` (mirror `getEffectiveCwd`) + +This is the **one contract gap** this plan reports to the `conversation-store` +owner: new per-conversation keys + a `WorkspaceRow.defaultComputerId` field + a +setter + `getEffectiveComputer`. (Per the constitution, the planner does not +edit conversation-store; it reports the needed change.) + +--- + +## 4. SSH connection management (the `ssh` extension) + +### 4.1 Library + +**`ssh2`** (mscdex/ssh2, v1.17.0) — the standard pure-JS SSH2 client, MIT, 5.8k +stars, 2k+ dependents. It provides: + +- `client.exec(command, opts)` → a stream with `stdout`/`stderr` `'data'` events + and an `'exit'` event (exit code). This maps directly onto `SpawnShell`. +- `client.sftp()` → an SFTP session with `readFile`, `writeFile`, `stat`, + `readdir`, `createReadStream`/`createWriteStream`. This implements the fs half + of `ExecBackend`. +- Auth: `privateKey`, `password`, `agent` (`ssh-agent`), `keyboard-interactive`. + +**Bun compatibility:** `ssh2` relies on Node's `crypto`/`Stream` APIs. The +project runs on **Bun**. Per decision #1, we use `ssh2` directly (the fork is +ruled out). This makes verifying it under Bun the **load-bearing first step of +Phase 3**: a smoke test that connects + `exec`s a command under Bun. If it +fails, there is no easy fallback — escalate to the user (the fork was rejected, +so the only options would be a different SSH approach or a Bun-native client). + +> **Action for the user (package install):** SSH support needs the `ssh2` +> dependency added to `packages/ssh/package.json`, plus `ssh-config` for parsing +> `~/.ssh/config` (see §3.2). Per the package install policy, I will not install +> system-wide; the implementation agent adds them as project-local dependencies +> (`bun add ssh2 ssh-config` in the package). No system package is required +> (ssh2 ships its own crypto; OpenSSH is not needed on the Dispatch host — the +> library IS the SSH client). + +### 4.2 Connection pooling + +A single `ssh2` `Client` connection can run **many** `exec` calls and **one** +SFTP session concurrently, so we pool **one connection per computer alias** (the +alias's connection params are resolved once from `~/.ssh/config` and are stable +for the connection's life). This is the `SshConnectionPool`, owned by the `ssh` +extension: + +```ts +// packages/ssh/src/pool.ts (NEW) +export interface SshConnection { + /** Acquire the live ssh2 Client (connects lazily on first acquire). */ + readonly getClient: () => Promise<ssh2.Client>; + /** Acquire a shared SFTP session (lazily created, reused). */ + readonly getSftp: () => Promise<ssh2.SFTPWrapper>; + readonly close: () => Promise<void>; + /** Live status for the frontend status endpoint. */ + readonly state: "disconnected" | "connecting" | "connected" | "error"; +} + +export interface SshConnectionPool { + /** Get-or-connect the pooled connection for a computer. */ + readonly acquire: (computerId: string) => Promise<SshConnection>; + /** Close + drop a single computer's connection (on error / manual disconnect). */ + readonly drop: (computerId: string) => Promise<void>; + /** Close all (shutdown). */ + readonly closeAll: () => Promise<void>; + /** Status snapshot for all known computers. */ + readonly status: () => readonly { readonly computerId: string; readonly state: SshConnection["state"]; readonly error?: string }[]; +} +``` + +**Lifecycle / pooling rules:** + +- **Lazy connect:** the first `acquire(computerId)` for a computer opens the + connection. Subsequent acquires reuse it (no reconnect per command — this is + the transparency + performance win over spawning `ssh` per call). +- **Keep-alive:** ssh2 supports `keepaliveInterval` / `keepaliveCountMax`. + Configure (e.g. 30s interval, 3 misses) so idle pooled connections detect + dead peers without a user-visible hang. +- **Idle reaping:** a periodic sweep closes connections unused for N minutes + (configurable; default ~15m) to avoid holding sockets on remote hosts. The + next `acquire` reconnects transparently. +- **Per-computer single connection** is the MVP. If a remote host becomes a + bottleneck (many concurrent tool calls — note the default dispatch is + `maxConcurrent: 1`, so this is unlikely), the pool can grow to a small cap + (e.g. 3) per computer later. SFTP is a single session per connection; if fs + contention appears, open additional SFTP sessions on the same connection. + +### 4.3 The `SshExecBackend` + +Built per `acquire`, wrapping the pooled connection's `exec` + `sftp`: + +```ts +// packages/ssh/src/backend.ts (NEW) +export function createSshExecBackend(conn: SshConnection, computer: Computer): ExecBackend { + return { + async spawn(params) { + const client = await conn.getClient(); + // sh -c on the remote, cwd via `cd <cwd> && ...` or exec opts. + // ssh2 exec has no cwd option → prefix `cd "$cwd" && ` (shell-quoted). + const wrapped = `cd ${shellQuote(params.cwd)} && ${params.command}`; + return new Promise((resolve) => { + client.exec(wrapped, { pty: false }, (err, stream) => { + if (err) return resolve({ exitCode: 1, timedOut: false, aborted: false }); + // wire stream.stdout/stderr 'data' → params.onOutput + // wire stream 'exit'/'close' → resolve({exitCode, ...}) + // wire params.signal abort → stream.end(); resolve aborted + // wire params.timeout → stream.end(); resolve timedOut + }); + }); + }, + async readFile(path) { const sftp = await conn.getSftp(); return sftp.readFile(path, "utf8"); /* throws ENOENT → map */ }, + async writeFile(path, content) { const sftp = await conn.getSftp(); return sftp.writeFile(path, content, "utf8"); }, + async stat(path) { const sftp = await conn.getSftp(); const s = await sftp.stat(path); return { isFile: s.isFile(), isDirectory: s.isDirectory() }; }, + async readdir(path) { const sftp = await conn.getSftp(); const list = await sftp.readdir(path); /* map → {name, isDirectory} */ }, + async exists(path) { try { await sftp.stat(path); return true; } catch { return false; } }, + }; +} +``` + +**Error mapping:** `node:fs` throws `ENOENT` etc. with `.code`. ssh2/SFTP errors +have different shapes. The `SshExecBackend` maps them to the `node:fs`-style +errors the existing tool pure-logic expects (e.g. `(err as NodeJS.ErrnoException).code +=== "ENOENT"`), so the tools' existing error branches (`read_file`'s "File not +found") work unchanged. This mapping lives in the backend, not the tools. + +### 4.4 Auth & host-key verification + +Per decisions #2 and #3, auth is **key-only, from `~/.ssh/`** — no +`SecretsAccess`/gopass, no passwords, no agent in the MVP. + +- **Key resolution at connect time:** the `ssh` extension resolves the alias → + `IdentityFile` from `~/.ssh/config` (§3.1). If the config specifies one, read + that file; otherwise fall back to the default identity files (`~/.ssh/id_rsa`, + `~/.ssh/id_ed25519`, etc., first that exists). The key material is read from + disk and passed to `ssh2` as `privateKey` (with passphrase support — prompted + via the FE roadmap item, or empty for unencrypted keys in the MVP). The key + never leaves the `ssh` extension and is never persisted. +- **No secrets in the API or store.** Because the key lives on disk in + `~/.ssh/`, there is no `secretRef` field, no secret store wiring, and no + secret transit through env/containers. This is the simplification from + decision #3. +- **Host-key verification (auto-trust-and-pin):** uses `~/.ssh/known_hosts` + directly. On connect, the `ssh2` `hostVerifier` callback checks whether the + host key is in `known_hosts`: if present, verify it matches (reject on + mismatch — surface "HOST KEY CHANGED" loudly, never silently connect); if + **absent** (first connect), accept and append the fingerprint to + `known_hosts` (the `StrictHostKeyChecking=accept-new` analog). A future FE + "approve host key" prompt (roadmap, decision #2) would gate that first + accept. +- **No agent-forwarding** (avoids credential leakage to the remote). +- Future: `agent`/`password` auth can be added later behind the same connect + path if needed; not in scope for the MVP. + +--- + +## 5. Integration with the turn loop & tool dispatch + +### 5.1 Threading `computerId` end-to-end + +The change is a strict superset of the cwd threading — one more optional field +at each hop: + +``` +ChatRequest.computerId (NEW) / Workspace.defaultComputerId (NEW) + → StartTurnInput.computerId (NEW) + → runTurnDetached: getEffectiveComputer(...) (NEW resolution) + → RunTurnInput.computerId (NEW) + → StepContext.computerId (NEW) + → createStepDispatcher(..., computerId) (NEW arg) + → executeToolCall(..., computerId) (NEW arg) + → ToolExecuteContext.computerId (NEW) + → tool.execute resolves backend from ctx.computerId +``` + +Every one of these is **additive and optional** — when `computerId` is absent +everywhere, behavior is byte-identical to today (local). This is the +backward-compatibility invariant. + +### 5.2 session-orchestrator changes + +`runTurnDetached` already resolves `effectiveCwd` via a chained promise. It +gains a parallel `effectiveComputer` resolution (mirroring the cwd promise), +then a `resolveBackend` is wired so tools get the right backend. Concretely: + +- Add `computerId?: string` to `StartTurnInput`. +- Resolve `effectiveComputerId` = `computerStore.getEffectiveComputer(convId, override)`. +- Persist per-conversation `computerId` on first turn (like cwd). +- Thread `computerId` into `RunTurnInput` (line ~589 where `opts` is built). +- The `TurnLifecyclePayload` gains `computerId` (for cache-warming symmetry — + a warm probe must assemble tools under the same computer so the *tool + descriptions* match; see §5.4). + +### 5.3 The `exec-backend` extension wires the resolver + +A new core extension `exec-backend` provides a service handle +`execBackendHandle: ServiceHandle<ExecBackendResolver>` where +`ExecBackendResolver = (computerId?: string) => ExecBackend`. Its implementation: + +```ts +function resolveBackend(computerId?: string): ExecBackend { + if (computerId === undefined) return localBackend; // local + const ssh = sshPool.acquire(computerId); // remote (async!) + return sshBackendFor(computerId); +} +``` + +**Subtlety: `acquire` is async.** The resolver must return a backend whose +methods are async (they already are — `spawn`/`readFile` return Promises), so +the connection is acquired lazily *inside* the first backend method call, not +at resolver-call time. The resolver stays synchronous; the `SshExecBackend` +captures the `computerId` + a lazy `acquire` thunk. This keeps the resolver +side-effect-free (no connection opened merely by resolving a backend — only +when a tool actually executes). + +The tool extensions (`tool-shell`, `tool-read-file`, `tool-write-file`, +`tool-edit-file`) gain a `resolveBackend` dep injected at activation +(`host-bin` wires `host.getService(execBackendHandle)`). + +### 5.4 Cache-warming & prompt-cache safety + +`cache-warming` replays the conversation's prefix to warm the provider cache. It +assembles tools via `applyToolsFilter` under the *same cwd* today. With SSH, the +**tool descriptions are unchanged** (transparency!), so the prompt-cache prefix +is unaffected by the computer — UNLESS a tools-filter changes the tool *set* +based on computer (e.g. dropping LSP tools when remote; see §6). The plan: +`WarmService.warm` and the tools-filter must thread `computerId` so any +computer-sensitive filtering is byte-stable between warm and real turns. This +is the same invariant the codebase already enforces for cwd. + +### 5.5 System prompt + +The system prompt is cwd-aware today (it may include the cwd). For transparency, +the prompt should NOT reveal "you are on a remote machine" — the agent must not +know. The cwd shown to the model is the *remote* cwd (a path on the remote +machine), which is already what `ctx.cwd` would be. No system-prompt change is +required for transparency. (Optionally, a future `{{computer}}` template variable +could be added, but that would *break* transparency — out of scope / discouraged.) + +--- + +## 6. LSP, MCP, and other spawned-process extensions + +### 6.1 LSP — the hard case + +The LSP extension spawns a **language server process** (e.g. `typescript-language-server`) +rooted at the workspace, communicating over stdio. For full transparency, this +process would need to run on the remote machine and Dispatch would bridge its +stdio over SSH. ssh2 supports this (`client.exec` with a shell that runs the +server, forwarding its stdio) — but it is significantly more complex than file +ops (long-lived process, framing, file-watching over SFTP). + +**MVP decision: degrade gracefully.** When `effectiveComputer !== null` (remote): + +- The `lsp` tool's per-edit diagnostics are **skipped** (the `edit_file` tool + already degrades to no-diagnostics when LSP is unavailable — the existing + try/catch path). +- The LSP status endpoint reports "disabled on remote computers" for that + conversation. + +**Future phase:** a `RemoteLspManager` that spawns the language server over SSH +and bridges stdio + uses SFTP for `didOpen`/file-watching. This is a large, +separate unit of work and is **out of scope** for the initial SSH feature. The +plan records it as a known limitation; the `lsp` extension owner gets a +change-request when remote LSP is prioritized. + +This is enforced cleanly via the **tools filter**: the session-orchestrator's +`toolsFilter` (owned by session-orchestrator) drops the `lsp` tool from the +turn's tool set when `effectiveComputer !== null`. The model simply doesn't see +the `lsp` tool on remote turns — consistent with how MCP drops disconnected +servers' tools today. + +### 6.2 MCP + +MCP servers are configured per-cwd (`.dispatch/mcp.json`). They spawn local +processes. For a remote conversation, the MCP servers should be **discovered on +the remote machine** (read the remote `.dispatch/mcp.json` via SFTP) and spawned +remotely. This is also complex (long-lived remote processes). + +**MVP decision:** MCP tools are **also dropped** via the tools filter when +remote (same mechanism as LSP). A future phase adds remote MCP server spawn +over SSH. Recorded as a known limitation. + +### 6.3 Tools unaffected by SSH + +`web_search`, `youtube_transcript` — these hit the network from the Dispatch host +(not the remote machine), so they are **unaffected** and remain available on +remote turns. `todo` is in-memory. These need no changes. + +--- + +## 7. Security considerations + +1. **No secrets managed by Dispatch (decision #3).** SSH private keys live on + disk in `~/.ssh/` (the user's existing, file-permission-protected keys). + Dispatch reads the key file at connect time and holds it only in the `ssh` + extension's process memory (on the pooled connection). It is never + persisted, never logged, never returned by any API. File permissions on + `~/.ssh/` (typically `0600`) are the protection — Dispatch relies on them. +2. **No `secretRef`/gopass wiring (removed).** The secrets-management skill is + not involved for SSH; keys are filesystem, not gopass. +3. **Host-key verification (auto-trust-and-pin, decision #2).** ssh2's + `hostVerifier` callback checks `~/.ssh/known_hosts`: present → verify match + (reject on mismatch, surface "HOST KEY CHANGED" loudly, never silently + connect — prevents MITM); absent (first connect) → accept and append the + fingerprint to `known_hosts` (the `StrictHostKeyChecking=accept-new` analog). + A future FE "approve host key" prompt (roadmap) would gate that first accept. +4. **No agent-forwarding** by default (avoids credential leakage to the remote). +5. **No PTY by default** for `exec` (`pty: false`) — commands run non-interactively, + output captured as today. PTY would risk leaking control chars / interactive + prompts hanging. +6. **Command injection** — the shell tool already passes the model's `command` + to `sh -c` locally; SSH does not change this threat model (the agent is already + trusted to run arbitrary commands). The `cd "$cwd" && ` prefix must + **shell-quote** the cwd to avoid a cwd containing shell metachars breaking + out — use a proper quoting helper, not string concat. +7. **Port exposure** — SSH is outbound from the Dispatch host; no inbound ports + opened. No change to the existing TLS/cert posture. +8. **Auth method policy (MVP)** — key-only (decision #3). Password/agent are + out of scope; if added later, passwords must never be stored in plaintext + (would require reintroducing a secret store). +9. **Auditability** — every remote `exec`/fs op should be logged via the + injected `Logger` (the `ssh` extension spans each operation with the alias), + so remote activity is traceable. Existing observability (trace-store) covers + this if spans are opened. + +--- + +## 8. Edge cases + +| Case | Handling | +|---|---| +| **Connection drop mid-turn** | The pooled connection errors. The in-flight `spawn`/fs call rejects; the tool returns an error result (`isError: true`) with a clear message ("remote computer connection lost: …"). The model sees a normal tool error and can retry. The pool drops the dead connection; next `acquire` reconnects. The turn is NOT aborted (unlike a signal abort) — the model continues. | +| **Remote machine offline (connect fails)** | First `acquire` rejects with a connect error → tool error result. A `GET /computers/:alias/status` lets the FE show "offline" before the user sends. | +| **Timeout** | Each `spawn` carries its own `timeout` (existing tool param, default 120s). The backend enforces it over SSH (close the stream on timeout) — same `timedOut` result as local. Connect itself has a separate (shorter, e.g. 10s) connect timeout so an unreachable host fails fast. | +| **Auth failure** | Connect rejects with auth error. Surface a specific error ("SSH authentication failed for computer X") via the tool result + the status endpoint. Never retry in a tight loop (avoid account lockout) — fail and let the user fix the secret. | +| **cwd doesn't exist on remote** | `cd <cwd>` fails on the remote shell → the command exits non-zero with stderr "no such directory". The tool returns an error result; the model can `cd`/`ls` to recover. Same UX as a bad local cwd. | +| **Path semantics differ (Windows remote)** | MVP assumes POSIX remotes (ssh2 + sh -c). A Windows remote would need `cmd.exe` + path translation — **out of scope**; documented as POSIX-only. | +| **Long output** | The existing `OUTPUT_CAP` (50k chars) truncation in the shell tool applies identically — the backend streams stdout; the tool caps. No change. | +| **Concurrent tool calls to same remote** | Default dispatch `maxConcurrent: 1` serializes, so one command at a time. With parallelism enabled, the pooled connection handles concurrent `exec` (ssh2 supports it); SFTP ops are serialized within the single SFTP session or open additional sessions. | +| **Computer removed from `~/.ssh/config` while in use** | There's no delete API (config is the source of truth). If a user removes the `Host` block, in-flight calls keep running (the pooled connection is already open); the next `acquire` after the pool reaps it fails to resolve the alias → clear "unknown computer alias" error. The persisted `computerId`/`defaultComputerId` assignment still points at the stale alias; the FE should flag it as unresolved. | +| **Aborted turn** | `ctx.signal` is threaded into the backend (`spawn` params already take `signal`). On abort, the backend closes the remote stream (best-effort `stream.end()`); the promise resolves `aborted`. The pooled connection stays alive for reuse. | +| **Key rotated/removed on disk** | Next `acquire` after a drop re-reads the key from `~/.ssh/`. If removed or unreadable, connect fails with an auth/read error. | + +--- + +## 9. API surface (transport-contract + transport-http + transport-ws) + +All **additive**. Existing endpoints/messages unchanged. + +### 9.1 Computer endpoints (read-only discovery + status) + +Per decision #4, computers are **discovered from `~/.ssh/config`**, so there is +**no create/update/delete** — only read + status + test: + +``` +GET /computers → { computers: ComputerEntry[] } (parses ~/.ssh/config) +GET /computers/:alias → Computer (resolved config entry) +GET /computers/:alias/status → { alias, state: "disconnected"|"connecting"|"connected"|"error", error?, knownHost: bool } +POST /computers/:alias/test → probe-connect (opens a test connection, reports ok/error + pins host key) +``` + +`:alias` is the SSH config `Host` alias. To "add" a computer, the user edits +`~/.ssh/config` (their own file) — there is no `PUT /computers`. `knownHost` +reflects whether the alias's host is already in `~/.ssh/known_hosts`. + +### 9.2 Per-conversation + workspace-default endpoints (mirror cwd) + +``` +GET /conversations/:id/computer → { conversationId, computerId: string | null } +PUT /conversations/:id/computer → { computerId: string | null } (null = clear → inherit/local) +DELETE /conversations/:id/computer → clear (same as PUT null) + +PUT /workspaces/:id/default-computer → { computerId: string | null } (mirror /workspaces/:id/default-cwd) +``` + +`GET /workspaces/:id` and `GET /workspaces` return the new `defaultComputerId` +field (additive). + +### 9.3 Chat request gains `computerId` + +```ts +// transport-contract ChatRequest (MODIFIED — additive optional field) +export interface ChatRequest { + readonly conversationId?: string; + readonly message: string; + readonly model?: string; + readonly cwd?: string; + readonly reasoningEffort?: ReasoningEffort; + readonly workspaceId?: string; + /** NEW: computer (SSH config alias) to execute this turn's tools on. Omit = inherit (workspace default → local). */ + readonly computerId?: string; +} +``` + +`POST /chat` body parsing, the WS `chat.send` router (`handleChatSend`), and +`POST /conversations/:id/queue` (`QueueRequest`) all gain the optional +`computerId`, threaded identically to `cwd`/`workspaceId`. + +### 9.4 No secret handling on the API + +Per decision #3, there are **no secrets in the API at all** — keys live on disk +in `~/.ssh/` and are read by the `ssh` extension at connect time. There is no +`secretRef` field anywhere. This entire concern is removed relative to the +earlier draft. + +--- + +## 10. Frontend impact (dispatch-web / worktrees/ssh-support/frontend) + +The frontend is a Svelte app; cwd is managed in `src/app/store.svelte.ts` and +`src/features/workspace/`. The changes mirror the cwd UI: + +1. **Computer selector from discovered list** (new feature folder + `src/features/computer/`): a dropdown populated by `GET /computers` (which + parses `~/.ssh/config`), **no create/edit/delete UI** — to add a computer the + user edits `~/.ssh/config`. Each entry shows alias + knownHost indicator. + A "Test connection" button hits `POST /computers/:alias/test`. +2. **Per-conversation computer selector** — a `ComputerField.svelte` next to the + existing `CwdField.svelte` in the workspace sidebar. A dropdown of the + discovered computers + "Local (none)". Saves via + `PUT /conversations/:id/computer`. +3. **Workspace default computer** — in the workspace settings, a + `default-computer` selector (mirror the `default-cwd` control). Saves via + `PUT /workspaces/:id/default-computer`. +4. **Connection status badge** — near the computer selector, showing the live + `state` from `GET /computers/:alias/status` + (connected/connecting/error/offline). Poll or surface via the existing + surface-registry mechanism. +5. **Store** (`store.svelte.ts`) gains `computerId` reactive state + + `setComputer`/`refetchComputer` (parallel to `cwd`/`setCwd`). +6. **`chat.send`** — the chat store's `send()` does not currently pass cwd per- + send (cwd is persisted, not per-message). `computerId` follows the same model + (decision #5): persisted per-conversation, set via the sidebar, NOT per- + message. So `chat.send` needs no change for the MVP (computer is resolved + server-side from the persisted value). A per-send `computerId` override is a + later option (the contract supports it; the UI need not expose it initially). +7. **(Roadmap) Host-key approve prompt** — on first connect to a new host, a + FE prompt to approve the host key before it is pinned (decision #2 roadmap). + Not in MVP; MVP auto-trusts-and-pins silently. + +> **Transparency note for the FE:** the FE shows the computer to the *user* (so +> they know where commands run), but the *agent* never sees it (not in the system +> prompt, not in tool descriptions). The FE computer selector is a user-facing +> control, not an agent-facing one. + +--- + +## 11. New packages / units summary + +| New package | Tier | Owns | Depends on | +|---|---|---|---| +| `exec-backend` | core | `ExecBackend` contract, `LocalExecBackend`, `execBackendHandle` service, the resolver wiring | kernel | +| `ssh` | standard | `SshConnectionPool`, `SshExecBackend`, `~/.ssh/config` reader (uses `ssh-config`), `known_hosts` host-key verify, key read from `~/.ssh` | exec-backend, conversation-store (reads `getEffectiveComputer`), wire | + +> **No `computer-store` package** (decision #4): with no `Computer` entity to +> CRUD, the config reader lives in `ssh`, and the persisted assignment + +> `getEffectiveComputer` live in the existing `conversation-store` (§3.4). + +Modified units (contract changes, reported to owners — planner does NOT edit +these directly per one-owner-per-unit): + +| Unit | Change | +|---|---| +| `kernel` (contracts) | `+ computerId` on `ToolExecuteContext` + `RunTurnInput` (additive optional) | +| `kernel` (runtime dispatch) | thread `computerId` through `executeToolCall`/`createStepDispatcher` | +| `wire` | `+ Computer`, `ComputerEntry` (read-only view); `+ defaultComputerId` on `Workspace` | +| `conversation-store` | `+ defaultComputerId` on `WorkspaceRow`/`Workspace` + `setWorkspaceDefaultComputerId`; `+ getComputerId`/`setComputerId`/`getEffectiveComputer` (mirrors cwd) | +| `tool-shell` | factory takes `resolveBackend`; `execute` uses `backend.spawn` | +| `tool-read-file` | refactor to `backend.readFile/readdir/stat` | +| `tool-write-file` | refactor to `backend.access/stat/writeFile` | +| `tool-edit-file` | refactor to backend fs ops | +| `session-orchestrator` | `+ computerId` on `StartTurnInput`/`TurnLifecyclePayload`; resolve `effectiveComputer`; thread into `RunTurnInput`; tools-filter drops `lsp`/`mcp` when remote | +| `transport-contract` | `+ computerId` on `ChatRequest`/`ChatSendMessage`/`QueueRequest`; computer (read-only) + workspace-computer response types | +| `transport-http` | read-only `/computers` (parses config) + status/test; per-conv/workspace-computer endpoints; thread `computerId` in `/chat` | +| `transport-ws` | thread `computerId` in `handleChatSend`/`handleChatQueue` | +| `host-bin` | wire `exec-backend` + `ssh` extensions; inject `resolveBackend` into tool extensions | +| `cache-warming` | thread `computerId` into warm tool assembly (cache-safe) | +| frontend | discovered-computer selector + per-conv/workspace-default selectors + status badge | + +--- + +## 12. Implementation phases + +### Phase 0 — Contracts (no behavior change) +- Add `computerId` to `ToolExecuteContext` + `RunTurnInput` (kernel contracts). +- Add `Computer`/`ComputerEntry` + `Workspace.defaultComputerId` to `@dispatch/wire`. +- Add `ExecBackend` contract + `execBackendHandle` in a new `exec-backend` + package; `LocalExecBackend` wraps today's node calls (behavior-identical). +- Thread `computerId` through dispatch runtime (forwards `undefined` → no-op). +- **Verify:** `bun run typecheck` + `bun run test` green, behavior unchanged. + +### Phase 1 — Refactor tools behind `ExecBackend` (still local-only) +- `tool-shell`/`read-file`/`write-file`/`edit-file` factories take + `resolveBackend`; `LocalExecBackend` injected. Pure logic untouched. +- `host-bin` wires the local resolver. +- **Verify:** full test suite green; tools behave identically (this de-risks + the refactor before any SSH). + +### Phase 2 — Assignment + API (no SSH yet) +- `conversation-store`: `defaultComputerId` field + setter + + `getComputerId`/`setComputerId`/`getEffectiveComputer` (mirrors cwd). +- transport-http/ws: read-only `/computers` + per-conv/workspace-computer + endpoints + `computerId` on chat. +- `session-orchestrator`: resolve + thread `computerId`. +- **Verify:** can assign a computer (alias) per-conversation/workspace; with no + `ssh` extension loaded, a configured computer yields a clear "no SSH backend" + error (degraded) — local conversations unchanged. + +### Phase 3 — SSH execution +- **First:** verify `ssh2` runs under Bun (load-bearing — decision #1). +- `ssh` package: `~/.ssh/config` reader (`ssh-config`), `SshConnectionPool`, + `SshExecBackend` (ssh2 exec + sftp), key read from `~/.ssh`, host-key + auto-trust-and-pin via `~/.ssh/known_hosts`, error mapping. +- `exec-backend` resolver returns `SshExecBackend` for a `computerId` (alias). +- tools-filter drops `lsp`/`mcp` on remote turns (silent — decision #6). +- **Verify:** integration test against a real (or dockerized) sshd — run_shell, + read_file, write_file, edit_file execute remotely; agent is unaware. + +### Phase 4 — Frontend +- Discovered-computer selector (from `GET /computers`), per-conv + + workspace-default selectors, status badge. +- Wire store + chat flow (persisted per-conversation — decision #5). + +### Phase 5 — Hardening +- Connection drop/offline/timeout edge tests. +- Idle reaping + keep-alive tuning. +- Observability spans for remote ops. +- (Roadmap) FE host-key approve prompt (decision #2). +- Remote LSP/MCP (future — out of scope for initial feature). + +--- + +## 13. Open questions / decisions for the user + +### Resolved (2026-06-25) — all decisions locked + +1. ~~ssh2 vs bun-ssh2~~ → **`ssh2`** (no fork); verify under Bun at Phase 3 start. +2. ~~Host-key trust model~~ → **auto-trust-and-pin**; FE approve prompt is + roadmap (future), not MVP. +3. ~~Auth method~~ → **key-only, from `~/.ssh/`** (no secrets/gopass). +4. ~~`Computer` storage location~~ → **moot**: no CRUD entity; computers are + discovered read-only from `~/.ssh/config`. Assignment (alias string) lives in + `conversation-store`. +5. ~~Per-send vs persisted `computerId`~~ → **persisted per-conversation**. +6. ~~Remote LSP/MCP scope~~ → **silently dropped** on remote turns (MVP); remote + spawn is a future phase. +7. ~~`edit_file` diagnostics on remote~~ → **works, no diagnostics** (existing + no-LSP degradation path). +8. ~~`ssh-config` dependency vs hand-rolled parser~~ → **take `ssh-config`** + (project-local dep in `packages/ssh/package.json`, alongside `ssh2`). Both + maintainers are single-author but these are the standard, widely-depended-on + packages for their jobs (`ssh2` ~2k dependents; `ssh-config` ~224k weekly + downloads). Correct config parsing (wildcards, `Include`, `Match`, + first-match-wins) is worth the dep over a hand-rolled parser that would miss + edge cases. + +**No open questions remain.** The plan is decision-complete and ready to hand +off to implementation. + +### Minor defaults adopted (not flagged as decisions — veto if undesired) + +- The `~/.ssh/config` reader lives **inside the `ssh` extension** (it owns the + SSH concern end-to-end). +- A stale alias (removed from `~/.ssh/config` while a conversation still points + at it) is surfaced by the FE as **"unresolved"**, never silently falls back + to local. +- Default identity file probing order: `~/.ssh/id_ed25519` → `~/.ssh/id_rsa` → + others, first-existing-wins (matches OpenSSH's own probing). +- Encrypted-key passphrases: assume **unencrypted** for the MVP; passphrase + prompting is bundled into the same FE roadmap item as the host-key approve + prompt (decision #2). + +--- + +## 14. Glossary additions (proposed, for `GLOSSARY.md`) + +| Term | Meaning | Aliases to avoid | +|---|---|---| +| **computer** | A named SSH target, auto-discovered from a `Host` alias in the system's `~/.ssh/config` (read-only — NOT a persisted CRUD entity). Referenced by `computerId` (the alias). `null`/absent = local execution (no SSH). | host (when meaning the SSH target — clashes with "host" the runtime), remote, machine | +| **ExecBackend** | The transport-agnostic spawn+fs abstraction tools program against. Two implementations: `LocalExecBackend` (node) and `SshExecBackend` (ssh2). Resolved per-call from `ToolExecuteContext.computerId`. | backend, executor | +| **computerId** | The SSH config `Host` alias of the computer a turn's tools execute on. Threaded like `cwd` (per-turn override → persisted per-conversation → workspace `defaultComputerId` → `null`/local). | hostId, machineId, remoteId | +| **defaultComputerId** | A workspace's default computer (an SSH config alias), inherited by conversations with no per-conversation `computerId`. The computer analog of `defaultCwd`. | — | diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts index 1fd1237..061871e 100644 --- a/packages/conversation-store/src/keys.ts +++ b/packages/conversation-store/src/keys.ts @@ -50,6 +50,10 @@ export function cwdKey(conversationId: string): string { return `conv:${conversationId}:cwd`; } +export function computerKey(conversationId: string): string { + return `conv:${conversationId}:computer`; +} + export function reasoningEffortKey(conversationId: string): string { return `conv:${conversationId}:reasoning-effort`; } diff --git a/packages/conversation-store/src/store-workspace.test.ts b/packages/conversation-store/src/store-workspace.test.ts index 48c63e5..3926c94 100644 --- a/packages/conversation-store/src/store-workspace.test.ts +++ b/packages/conversation-store/src/store-workspace.test.ts @@ -46,6 +46,7 @@ describe("WorkspaceStore", () => { id: "my-work", title: "my-work", defaultCwd: null, + defaultComputerId: null, createdAt: 1000, lastActivityAt: 1000, }); @@ -64,6 +65,7 @@ describe("WorkspaceStore", () => { id: "my-work", title: "my-work", defaultCwd: null, + defaultComputerId: null, createdAt: 1000, lastActivityAt: 1000, }); @@ -80,6 +82,7 @@ describe("WorkspaceStore", () => { id: "my-work", title: "Custom", defaultCwd: "/projects/dispatch", + defaultComputerId: null, createdAt: 3000, lastActivityAt: 3000, }); @@ -92,6 +95,7 @@ describe("WorkspaceStore", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }); @@ -112,6 +116,7 @@ describe("WorkspaceStore", () => { id: "my-work", title: "Renamed", defaultCwd: null, + defaultComputerId: null, createdAt: 1000, lastActivityAt: 1000, }); @@ -208,6 +213,7 @@ describe("WorkspaceStore", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, conversationCount: 0, @@ -417,6 +423,241 @@ describe("WorkspaceStore", () => { }); }); +describe("ComputerStore", () => { + let storage: StorageNamespace; + let clock: number; + + beforeEach(() => { + storage = createMemoryStorage(); + clock = 1000; + }); + + function makeStore() { + return createConversationStore(storage, undefined, () => clock); + } + + // --- per-conversation computerId (mirror getCwd/setCwd/clearCwd) --- + + it("setComputerId/getComputerId round-trips an alias", async () => { + const store = makeStore(); + expect(await store.getComputerId("conv1")).toBeNull(); + await store.setComputerId("conv1", "myserver"); + expect(await store.getComputerId("conv1")).toBe("myserver"); + }); + + it("setComputerId(null) clears (is idempotent local sentinel, like clearComputerId)", async () => { + const store = makeStore(); + await store.setComputerId("conv1", "myserver"); + expect(await store.getComputerId("conv1")).toBe("myserver"); + // null is the "local" sentinel: it clears the persisted key so it does + // NOT linger to shadow the workspace defaultComputerId. + await store.setComputerId("conv1", null); + expect(await store.getComputerId("conv1")).toBeNull(); + // idempotent — clearing an already-absent key is a no-op. + await store.setComputerId("conv1", null); + expect(await store.getComputerId("conv1")).toBeNull(); + }); + + it("clearComputerId is idempotent and un-shadows the workspace default", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "per-conv-host"); + expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); + // After clear: the workspace defaultComputerId is used (fall-through). + await store.clearComputerId("conv1"); + expect(await store.getComputerId("conv1")).toBeNull(); + expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); + // idempotent — deleting an already-absent key is a no-op. + await store.clearComputerId("conv1"); + expect(await store.getComputerId("conv1")).toBeNull(); + }); + + // --- setWorkspaceDefaultComputerId (mirror setWorkspaceDefaultCwd) --- + + it("setWorkspaceDefaultComputerId sets and clears", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work"); + clock = 2000; + const setWs = await store.setWorkspaceDefaultComputerId("my-work", "remote-host"); + expect(setWs.defaultComputerId).toBe("remote-host"); + // does not bump lastActivityAt on defaultComputerId change (mirrors defaultCwd). + expect(setWs.lastActivityAt).toBe(1000); + const cleared = await store.setWorkspaceDefaultComputerId("my-work", null); + expect(cleared.defaultComputerId).toBeNull(); + }); + + it("setWorkspaceDefaultComputerId creates the workspace if missing", async () => { + const store = makeStore(); + clock = 5000; + const ws = await store.setWorkspaceDefaultComputerId("brand-new", "remote-host"); + expect(ws).toEqual({ + id: "brand-new", + title: "brand-new", + defaultCwd: null, + defaultComputerId: "remote-host", + createdAt: 5000, + lastActivityAt: 5000, + }); + }); + + it("setWorkspaceDefaultComputerId preserves defaultCwd on an existing workspace", async () => { + const store = makeStore(); + clock = 1000; + await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" }); + clock = 2000; + const ws = await store.setWorkspaceDefaultComputerId("my-work", "remote-host"); + expect(ws.defaultCwd).toBe("/workspace/root"); + expect(ws.defaultComputerId).toBe("remote-host"); + }); + + it("the synthesized 'default' workspace still returns defaultComputerId: null (local)", async () => { + const store = makeStore(); + const ws = await store.getWorkspace("default"); + expect(ws).toEqual({ + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }); + // And it surfaces null in listWorkspaces too. + const list = await store.listWorkspaces(); + const defaultWs = list.find((w) => w.id === "default"); + expect(defaultWs?.defaultComputerId).toBeNull(); + }); + + // --- getEffectiveComputer resolution ladder (mirror getEffectiveCwd) --- + + it("getEffectiveComputer: per-conversation computerId overrides workspace defaultComputerId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "per-conv-host"); + expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); + }); + + it("getEffectiveComputer: workspace defaultComputerId used when conversation computerId is unset", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); + }); + + it("getEffectiveComputer: null (LOCAL) when both conversation and workspace computerId are unset", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work"); + await store.setWorkspaceId("conv1", "my-work"); + expect(await store.getEffectiveComputer("conv1")).toBeNull(); + }); + + it("getEffectiveComputer: default workspace (no defaultComputerId) falls through to null (local)", async () => { + const store = makeStore(); + // No explicit workspace assignment — defaults to "default" workspace + // which has defaultComputerId null. + expect(await store.getEffectiveComputer("conv1")).toBeNull(); + }); + + it("getEffectiveComputer: clearComputerId falls through to workspace defaultComputerId (un-shadows it)", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "per-conv-host"); + // Before clear: the conversation computerId shadows the workspace default. + expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host"); + // After clear: the workspace defaultComputerId is used (fall-through). + await store.clearComputerId("conv1"); + expect(await store.getEffectiveComputer("conv1")).toBe("ws-host"); + }); + + // --- overrideAlias (per-turn computer override, mirror overrideCwd) --- + + it("getEffectiveComputer: overrideAlias string wins outright, overriding workspace defaultComputerId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + // A string override wins outright, even over a workspace defaultComputerId. + expect(await store.getEffectiveComputer("conv1", "override-host")).toBe("override-host"); + }); + + it("getEffectiveComputer: overrideAlias string wins over the persisted per-conversation computerId", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "persisted-host"); + // The override must win over the persisted computerId. + expect(await store.getEffectiveComputer("conv1", "override-host")).toBe("override-host"); + }); + + it("getEffectiveComputer: overrideAlias null is explicitly local and does NOT fall through", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "persisted-host"); + // An explicit null override = "local for this turn": it wins outright and + // does NOT fall through to the persisted value or the workspace default. + expect(await store.getEffectiveComputer("conv1", null)).toBeNull(); + }); + + it("getEffectiveComputer: overrideAlias omitted behaves as today (uses persisted computerId)", async () => { + const store = makeStore(); + await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" }); + await store.setWorkspaceId("conv1", "my-work"); + await store.setComputerId("conv1", "persisted-host"); + // No second arg — persisted computerId is used. + expect(await store.getEffectiveComputer("conv1")).toBe("persisted-host"); + }); + + // --- round-trip through persistence (parse/toWorkspace) --- + + it("a Workspace with defaultComputerId round-trips through parse/toWorkspace", async () => { + const store = makeStore(); + clock = 1000; + // Create with a defaultComputerId via ensureWorkspace, then read it back + // (exercises parseWorkspaceRow -> toWorkspace round-trip). + const created = await store.ensureWorkspace("remote-work", { + title: "Remote", + defaultComputerId: "prod-server", + }); + expect(created.defaultComputerId).toBe("prod-server"); + const roundTripped = await store.getWorkspace("remote-work"); + expect(roundTripped).toEqual({ + id: "remote-work", + title: "Remote", + defaultCwd: null, + defaultComputerId: "prod-server", + createdAt: 1000, + lastActivityAt: 1000, + }); + }); + + it("a legacy WorkspaceRow without defaultComputerId reads back as null (local)", async () => { + const store = makeStore(); + // Simulate a legacy row persisted before defaultComputerId existed: + // write a raw WorkspaceRow JSON lacking the field, then read it back. + await storage.set( + "workspace:legacy", + JSON.stringify({ + title: "legacy", + defaultCwd: "/legacy/cwd", + createdAt: 100, + lastActivityAt: 200, + }), + ); + const ws = await store.getWorkspace("legacy"); + expect(ws).toEqual({ + id: "legacy", + title: "legacy", + defaultCwd: "/legacy/cwd", + defaultComputerId: null, + createdAt: 100, + lastActivityAt: 200, + }); + }); +}); + describe("isValidWorkspaceSlug", () => { it("accepts valid slugs", () => { expect(isValidWorkspaceSlug("my-work")).toBe(true); diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index 26d5ed4..2fd0a0c 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -18,6 +18,7 @@ import { chunkKey, chunkPrefix, compactThresholdKey, + computerKey, cwdKey, metaKey, metricsKey, @@ -70,6 +71,20 @@ export interface ConversationStore { readonly setCwd: (conversationId: string, cwd: string) => Promise<void>; /** Clear (delete) the persisted working directory for a conversation. */ readonly clearCwd: (conversationId: string) => Promise<void>; + /** + * The persisted computer (SSH config `Host` alias) for a conversation, or + * `null` if never set (local). The computer analog of `getCwd`. + */ + readonly getComputerId: (conversationId: string) => Promise<string | null>; + /** + * Persist (upsert) the computer for a conversation. Passing `null` clears + * the persisted selection (idempotent) — `null` is the "local" sentinel + * (no SSH), so it must NOT linger to shadow the workspace default. Mirrors + * `setModel`'s clear-on-sentinel pattern (the computer analog of `setCwd`). + */ + readonly setComputerId: (conversationId: string, alias: string | null) => Promise<void>; + /** Clear (delete) the persisted computer for a conversation. */ + readonly clearComputerId: (conversationId: string) => Promise<void>; /** The persisted reasoning-effort level for a conversation, or null if never set. */ readonly getReasoningEffort: (conversationId: string) => Promise<ReasoningEffort | null>; /** Persist (upsert) the reasoning-effort level for a conversation. */ @@ -145,13 +160,26 @@ export interface ConversationStore { */ readonly ensureWorkspace: ( id: string, - opts?: { readonly title?: string; readonly defaultCwd?: string | null }, + opts?: { + readonly title?: string; + readonly defaultCwd?: string | null; + readonly defaultComputerId?: string | null; + }, ) => Promise<Workspace>; /** Rename a workspace. Creates the workspace if missing. */ readonly setWorkspaceTitle: (id: string, title: string) => Promise<Workspace>; /** Set/clear a workspace's default cwd. Creates the workspace if missing. */ readonly setWorkspaceDefaultCwd: (id: string, defaultCwd: string | null) => Promise<Workspace>; /** + * Set/clear a workspace's default computer (SSH alias). Creates the + * workspace if missing. The computer analog of `setWorkspaceDefaultCwd`. + * `null` = local (no SSH). + */ + readonly setWorkspaceDefaultComputerId: ( + id: string, + defaultComputerId: string | null, + ) => Promise<Workspace>; + /** * Delete a workspace: (1) find all conversations with `workspaceId === id`, * (2) set each to `status = "closed"` and reassign `workspaceId = "default"`, * (3) delete the workspace entity. Returns `closedCount`. Throws if `id @@ -205,6 +233,34 @@ export interface ConversationStore { conversationId: string, overrideCwd?: string, ) => Promise<string | null>; + /** + * Resolve the effective computer (SSH alias) for a conversation — the + * computer analog of `getEffectiveCwd`. Resolution ladder: + * + * 1. **overrideAlias** — an explicit per-turn alias (from `chat.send`) + * wins outright, EVEN when `null` (explicitly local for this turn — it + * does NOT fall through). + * 2. **Persisted per-conversation `computerId`** — `getComputerId`. + * 3. **Workspace `defaultComputerId`** — resolved via `getWorkspaceId` + * (falling back to `"default"`) + `getWorkspace`. + * 4. **None of the above** — `null` (LOCAL: no SSH, today's behavior). + * + * Returns the alias STRING (or `null`); it does NOT validate the alias + * exists in `~/.ssh/config` (validation happens at connect time — a stale + * alias yields a clear connect error rather than silently falling back to + * local). + * + * @param overrideAlias — an explicit alias to resolve INSTEAD of the + * persisted `getComputerId` value. When provided (not `undefined`), it + * is returned as-is (string or `null`), short-circuiting the rest of the + * ladder. Used by the session-orchestrator for a per-turn computer + * override (sent by the client on `chat.send`). When omitted, the + * persisted `getComputerId` is read as today. + */ + readonly getEffectiveComputer: ( + conversationId: string, + overrideAlias?: string | null, + ) => Promise<string | null>; } export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store"); @@ -265,6 +321,12 @@ interface ConversationMetaRow { interface WorkspaceRow { readonly title: string; readonly defaultCwd: string | null; + /** + * The workspace's default computer (SSH config `Host` alias) — the computer + * analog of `defaultCwd`. `null` = local (no SSH). Conversations in this + * workspace inherit it when they set no `computerId` of their own. + */ + readonly defaultComputerId: string | null; readonly createdAt: number; readonly lastActivityAt: number; } @@ -373,9 +435,14 @@ function parseWorkspaceRow(raw: string): WorkspaceRow | null { const row = parsed as WorkspaceRow; // `defaultCwd` may be null OR a string; treat anything else as null. const defaultCwd = typeof row.defaultCwd === "string" ? row.defaultCwd : null; + // `defaultComputerId` may be null OR a string; treat anything else as null + // (mirrors `defaultCwd`). Absent on legacy rows → null (local). + const defaultComputerId = + typeof row.defaultComputerId === "string" ? row.defaultComputerId : null; return { title: row.title, defaultCwd, + defaultComputerId, createdAt: row.createdAt, lastActivityAt: row.lastActivityAt, }; @@ -386,6 +453,7 @@ function toWorkspace(id: string, row: WorkspaceRow): Workspace { id, title: row.title, defaultCwd: row.defaultCwd, + defaultComputerId: row.defaultComputerId, createdAt: row.createdAt, lastActivityAt: row.lastActivityAt, }; @@ -442,10 +510,17 @@ export function createConversationStore( const existing = await readWorkspaceRow(workspaceId); const row: WorkspaceRow = existing === null - ? { title: workspaceId, defaultCwd: null, createdAt: ts, lastActivityAt: ts } + ? { + title: workspaceId, + defaultCwd: null, + defaultComputerId: null, + createdAt: ts, + lastActivityAt: ts, + } : { title: existing.title, defaultCwd: existing.defaultCwd, + defaultComputerId: existing.defaultComputerId, createdAt: existing.createdAt, lastActivityAt: ts, }; @@ -662,6 +737,36 @@ export function createConversationStore( } }, + async getComputerId(conversationId) { + return await storage.get(computerKey(conversationId)); + }, + + async setComputerId(conversationId, alias) { + // `null` is the "local" sentinel: clear the persisted key so it does + // NOT linger to shadow the workspace defaultComputerId. Idempotent + // (deleting an already-absent key is a no-op). Mirrors `setModel`'s + // clear-on-sentinel pattern. + if (alias === null) { + await storage.delete(computerKey(conversationId)); + if (logger !== undefined) { + logger.debug("computer cleared", { conversationId }); + } + return; + } + await storage.set(computerKey(conversationId), alias); + if (logger !== undefined) { + logger.debug("computer set", { conversationId }); + } + }, + + async clearComputerId(conversationId) { + // Idempotent: deleting an already-absent key is a no-op (no error). + await storage.delete(computerKey(conversationId)); + if (logger !== undefined) { + logger.debug("computer cleared", { conversationId }); + } + }, + async getReasoningEffort(conversationId) { return (await storage.get(reasoningEffortKey(conversationId))) as ReasoningEffort | null; }, @@ -874,13 +979,15 @@ export function createConversationStore( } await ensureInIndex(targetId); - // Copy cwd + reasoning-effort + model (so the archive is self-contained). + // Copy cwd + reasoning-effort + model + computer (so the archive is self-contained). const cwd = await storage.get(cwdKey(sourceId)); if (cwd !== null) await storage.set(cwdKey(targetId), cwd); const effort = await storage.get(reasoningEffortKey(sourceId)); if (effort !== null) await storage.set(reasoningEffortKey(targetId), effort); const model = await storage.get(modelKey(sourceId)); if (model !== null) await storage.set(modelKey(targetId), model); + const computerId = await storage.get(computerKey(sourceId)); + if (computerId !== null) await storage.set(computerKey(targetId), computerId); }, async getCompactPercent(conversationId) { @@ -917,12 +1024,14 @@ export function createConversationStore( const row = await readWorkspaceRow(id); if (row !== null) return toWorkspace(id, row); // Synthesize the always-present "default" workspace when it was - // never persisted (title "default", defaultCwd null, timestamps 0). + // never persisted (title "default", defaultCwd null, defaultComputerId + // null [local], timestamps 0). if (id === DEFAULT_WORKSPACE_ID) { return { id: DEFAULT_WORKSPACE_ID, title: DEFAULT_WORKSPACE_ID, defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -939,6 +1048,7 @@ export function createConversationStore( const row: WorkspaceRow = { title: opts?.title ?? id, defaultCwd: opts?.defaultCwd ?? null, + defaultComputerId: opts?.defaultComputerId ?? null, createdAt: ts, lastActivityAt: ts, }; @@ -954,6 +1064,7 @@ export function createConversationStore( ? { title: id, defaultCwd: null as string | null, + defaultComputerId: null as string | null, createdAt: ts, lastActivityAt: ts, } @@ -961,6 +1072,7 @@ export function createConversationStore( const row: WorkspaceRow = { title, defaultCwd: base.defaultCwd, + defaultComputerId: base.defaultComputerId, createdAt: base.createdAt, lastActivityAt: base.lastActivityAt, }; @@ -976,6 +1088,7 @@ export function createConversationStore( ? { title: id, defaultCwd: null as string | null, + defaultComputerId: null as string | null, createdAt: ts, lastActivityAt: ts, } @@ -983,6 +1096,31 @@ export function createConversationStore( const row: WorkspaceRow = { title: base.title, defaultCwd, + defaultComputerId: base.defaultComputerId, + createdAt: base.createdAt, + lastActivityAt: base.lastActivityAt, + }; + await storage.set(workspaceKey(id), JSON.stringify(row)); + return toWorkspace(id, row); + }, + + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + const existing = await readWorkspaceRow(id); + const ts = now(); + const base = + existing === null + ? { + title: id, + defaultCwd: null as string | null, + defaultComputerId: null as string | null, + createdAt: ts, + lastActivityAt: ts, + } + : existing; + const row: WorkspaceRow = { + title: base.title, + defaultCwd: base.defaultCwd, + defaultComputerId, createdAt: base.createdAt, lastActivityAt: base.lastActivityAt, }; @@ -1053,6 +1191,7 @@ export function createConversationStore( id: DEFAULT_WORKSPACE_ID, title: DEFAULT_WORKSPACE_ID, defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }); @@ -1155,5 +1294,20 @@ export function createConversationStore( } return pathResolve(workspaceCwd ?? serverDefaultCwd, conversationCwd); }, + + async getEffectiveComputer(conversationId, overrideAlias) { + const workspaceId = await this.getWorkspaceId(conversationId); + const workspace = await this.getWorkspace(workspaceId); + const workspaceComputerId = workspace?.defaultComputerId ?? null; + // When an explicit override is given, it wins outright — even `null` + // (explicitly local for this turn) does NOT fall through to the + // persisted / workspace values. + if (overrideAlias !== undefined) { + return overrideAlias; + } + // Persisted per-conversation computerId → workspace defaultComputerId → null (LOCAL). + const computerId = await this.getComputerId(conversationId); + return computerId ?? workspaceComputerId; + }, }; } diff --git a/packages/exec-backend/package.json b/packages/exec-backend/package.json new file mode 100644 index 0000000..19a8f9b --- /dev/null +++ b/packages/exec-backend/package.json @@ -0,0 +1,11 @@ +{ + "name": "@dispatch/exec-backend", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } +} diff --git a/packages/exec-backend/src/backend.test.ts b/packages/exec-backend/src/backend.test.ts new file mode 100644 index 0000000..30458e7 --- /dev/null +++ b/packages/exec-backend/src/backend.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from "./backend.js"; + +/** + * ExecBackend type conformance — a fake backend satisfies the interface. + * (Pure compile-time + runtime check; zero internal mocks.) + */ +describe("ExecBackend type conformance", () => { + it("a minimal fake satisfies the ExecBackend interface", () => { + const fake: ExecBackend = { + spawn: async (_params: SpawnParams): Promise<ExecResult> => ({ + exitCode: 0, + timedOut: false, + aborted: false, + }), + readFile: async (_path: string): Promise<string> => "", + writeFile: async (_path: string, _content: string): Promise<void> => {}, + stat: async (_path: string): Promise<StatResult> => ({ isFile: true, isDirectory: false }), + readdir: async (_path: string): Promise<readonly DirEntry[]> => [], + exists: async (_path: string): Promise<boolean> => true, + }; + + // Runtime sanity: every method is present and callable. + expect(typeof fake.spawn).toBe("function"); + expect(typeof fake.readFile).toBe("function"); + expect(typeof fake.writeFile).toBe("function"); + expect(typeof fake.stat).toBe("function"); + expect(typeof fake.readdir).toBe("function"); + expect(typeof fake.exists).toBe("function"); + }); + + it("ExecResult is { exitCode, timedOut, aborted }", () => { + const result: ExecResult = { exitCode: null, timedOut: true, aborted: false }; + expect(result.exitCode).toBeNull(); + expect(result.timedOut).toBe(true); + expect(result.aborted).toBe(false); + }); + + it("SpawnParams carries the shell-tool seam fields", () => { + const params: SpawnParams = { + command: "echo", + cwd: "/tmp", + signal: new AbortController().signal, + timeout: 1000, + onOutput: () => {}, + }; + expect(params.command).toBe("echo"); + expect(params.timeout).toBe(1000); + }); + + it("StatResult distinguishes file vs directory", () => { + const fileStat: StatResult = { isFile: true, isDirectory: false }; + const dirStat: StatResult = { isFile: false, isDirectory: true }; + expect(fileStat.isFile && !fileStat.isDirectory).toBe(true); + expect(!dirStat.isFile && dirStat.isDirectory).toBe(true); + }); + + it("DirEntry carries name + isDirectory", () => { + const entry: DirEntry = { name: "sub", isDirectory: true }; + expect(entry.name).toBe("sub"); + expect(entry.isDirectory).toBe(true); + }); +}); diff --git a/packages/exec-backend/src/backend.ts b/packages/exec-backend/src/backend.ts new file mode 100644 index 0000000..f6a807f --- /dev/null +++ b/packages/exec-backend/src/backend.ts @@ -0,0 +1,78 @@ +/** + * ExecBackend — the transport-agnostic spawn + minimal filesystem surface. + * + * Tools (tool-shell, tool-read-file, tool-write-file, tool-edit-file) program + * against THIS abstraction instead of `node:fs` / `node:child_process` directly. + * Two implementations exist: + * + * - `LocalExecBackend` — wraps today's node calls (behavior-identical). + * - `SshExecBackend` — wraps ssh2 `exec` + `sftp` (added later by the `ssh` + * package; not this package's concern — but THIS interface is the seam it + * implements). + * + * The surface is deliberately SMALL (only what the bundled tools use) so a + * remote implementation is tractable. New operations are added here, not ad hoc. + * + * Resolved per-call from `ToolExecuteContext.computerId` via the injected + * `ExecBackendResolver` (see `./service.js`). `computerId` undefined → local. + * + * Error contract: `readFile`/`stat`/`readdir`/`writeFile` throw node:fs-style + * errors carrying a `.code` property (e.g. `"ENOENT"`) so the tools' existing + * error branches work unchanged. `exists` never throws (returns `false` on + * missing). The SshExecBackend maps ssh2 errors onto these same shapes. + */ + +/** A spawned process's result. Mirrors tool-shell's `SpawnResult` exactly. */ +export interface ExecResult { + readonly exitCode: number | null; + readonly timedOut: boolean; + readonly aborted: boolean; +} + +/** Parameters for spawning a shell command. Mirrors tool-shell's `SpawnShell` params. */ +export interface SpawnParams { + readonly command: string; + readonly cwd: string; + readonly signal: AbortSignal; + readonly timeout: number; + readonly onOutput: (data: string, stream: "stdout" | "stderr") => void; +} + +/** Stat result — the subset read_file / write_file / edit_file need. */ +export interface StatResult { + readonly isFile: boolean; + readonly isDirectory: boolean; +} + +/** A directory entry — the subset read_file lists. */ +export interface DirEntry { + readonly name: string; + readonly isDirectory: boolean; +} + +/** + * The execution backend: spawn + a minimal filesystem surface. + * Tools program against THIS, never against `node:fs`. Resolved per-call from + * `ToolExecuteContext.computerId` via the injected resolver. + */ +export interface ExecBackend { + /** Run a shell command, streaming stdout/stderr. The shell-tool seam. */ + readonly spawn: (params: SpawnParams) => Promise<ExecResult>; + + // --- filesystem (the read_file / write_file / edit_file surface) --- + + /** Read a file as utf8 text. Throws node:fs-style errors with `.code`. */ + readonly readFile: (path: string) => Promise<string>; + + /** Write utf8 text to a file. Throws on failure (e.g. missing parent dir). */ + readonly writeFile: (path: string, content: string) => Promise<void>; + + /** Stat a path. Throws node:fs-style errors with `.code` (e.g. `"ENOENT"`). */ + readonly stat: (path: string) => Promise<StatResult>; + + /** List directory entries. Throws node:fs-style errors with `.code`. */ + readonly readdir: (path: string) => Promise<readonly DirEntry[]>; + + /** Check existence without throwing (returns `false` when the path is missing). */ + readonly exists: (path: string) => Promise<boolean>; +} diff --git a/packages/exec-backend/src/extension.test.ts b/packages/exec-backend/src/extension.test.ts new file mode 100644 index 0000000..57161a5 --- /dev/null +++ b/packages/exec-backend/src/extension.test.ts @@ -0,0 +1,121 @@ +import type { HostAPI, ServiceHandle } from "@dispatch/kernel"; +import { describe, expect, it } from "vitest"; +import type { ExecBackend } from "./backend.js"; +import { createExecBackendExtension } from "./extension.js"; +import { localExecBackend } from "./local.js"; +import { execBackendHandle, remoteExecBackendFactoryHandle } from "./service.js"; + +/** + * Resolver tests — pure core, zero internal mocks. + * + * The resolver's ONLY external dependency is `host.getService` (the service + * registry — the outermost edge). We inject a minimal fake host that mirrors + * the real `bus.getService` contract: returns the provided impl, or throws when + * nothing provided the handle. No `vi.mock("@dispatch/*")` — the resolver + + * handles + local backend under test are all real. + * + * Three cases (matching the task spec): + * 1. `computerId` undefined → `localExecBackend` (byte-identical local path). + * 2. `computerId` set + factory provided → the factory's backend. + * 3. `computerId` set + factory NOT provided (ssh not loaded) → a clear + * "SSH remote execution is not configured" error, not a crash. + */ + +/** + * A minimal fake host exposing only the service-registry surface the resolver + * touches (`getService`/`provideService`). Throws on a missing service exactly + * like the real `bus.getService`, so the resolver's try/catch path is exercised + * against behavior-equivalent input. + */ +function createFakeHost(services: Map<string, unknown>): HostAPI { + const api = { + provideService<T>(handle: ServiceHandle<T>, impl: T): void { + services.set(handle.id, impl); + }, + getService<T>(handle: ServiceHandle<T>): T { + const impl = services.get(handle.id); + if (impl === undefined) { + throw new Error( + `Service "${handle.id}" has no provider. Call provideService before getService.`, + ); + } + return impl as T; + }, + }; + // The resolver only calls getService; the rest of HostAPI is unused here. + return api as unknown as HostAPI; +} + +/** A fake remote backend — identifiable so we can assert it's the one returned. */ +function createFakeRemoteBackend(marker: string): ExecBackend { + const fail = (): never => { + throw new Error(`fake remote backend (${marker}) should not be called in this test`); + }; + return { + spawn: fail, + readFile: fail, + writeFile: fail, + stat: fail, + readdir: fail, + exists: fail, + }; +} + +describe("ExecBackend resolver", () => { + it("returns localExecBackend for computerId === undefined (local path unchanged)", () => { + const services = new Map<string, unknown>(); + const host = createFakeHost(services); + + // Activate the extension so it registers its resolver, then retrieve it. + createExecBackendExtension().activate(host); + const resolver = host.getService(execBackendHandle); + + expect(resolver(undefined)).toBe(localExecBackend); + expect(resolver()).toBe(localExecBackend); + }); + + it("returns the factory's backend for a set computerId when the factory is provided", () => { + const services = new Map<string, unknown>(); + const host = createFakeHost(services); + + // The `ssh` extension (not built yet) would do this: + const remoteBackend = createFakeRemoteBackend("ssh-alias"); + const factory = (computerId: string): ExecBackend => { + // Confirm the alias is threaded through to the factory. + expect(computerId).toBe("ssh-alias"); + return remoteBackend; + }; + host.provideService(remoteExecBackendFactoryHandle, factory); + + createExecBackendExtension().activate(host); + const resolver = host.getService(execBackendHandle); + + expect(resolver("ssh-alias")).toBe(remoteBackend); + }); + + it("throws a clear 'not configured' error when the factory is NOT provided (ssh not loaded)", () => { + const services = new Map<string, unknown>(); + const host = createFakeHost(services); + + // No remoteExecBackendFactoryHandle provided → simulates ssh not loaded. + createExecBackendExtension().activate(host); + const resolver = host.getService(execBackendHandle); + + // Not a crash: a clear, actionable error mentioning computerId + ssh. + expect(() => resolver("some-host")).toThrow(/SSH remote execution is not configured/); + expect(() => resolver("some-host")).toThrow(/ssh extension is not loaded/); + expect(() => resolver("some-host")).toThrow(/some-host/); + }); + + it("local path is unaffected by whether the factory is provided", () => { + // Even with a factory present, computerId === undefined still returns local. + const services = new Map<string, unknown>(); + const host = createFakeHost(services); + host.provideService(remoteExecBackendFactoryHandle, () => createFakeRemoteBackend("unused")); + + createExecBackendExtension().activate(host); + const resolver = host.getService(execBackendHandle); + + expect(resolver(undefined)).toBe(localExecBackend); + }); +}); diff --git a/packages/exec-backend/src/extension.ts b/packages/exec-backend/src/extension.ts new file mode 100644 index 0000000..9d6840a --- /dev/null +++ b/packages/exec-backend/src/extension.ts @@ -0,0 +1,73 @@ +import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; +import type { ExecBackend } from "./backend.js"; +import { localExecBackend } from "./local.js"; +import { + type ExecBackendResolver, + execBackendHandle, + remoteExecBackendFactoryHandle, +} from "./service.js"; + +export const manifest: Manifest = { + id: "exec-backend", + name: "Exec Backend", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + contributes: { services: ["exec-backend/resolver"] }, +}; + +/** + * Build the `ExecBackendResolver` for a given host. + * + * - `computerId` undefined → `localExecBackend` (byte-identical local path; + * no host lookup, no remote machinery — unchanged from the original behavior). + * - `computerId` set → remote: lazily look up the factory the `ssh` extension + * provides via `remoteExecBackendFactoryHandle` and call it with the alias. + * The lookup is deferred to resolve time (tool-EXECUTE time, after every + * extension has activated), so a missing provider (ssh not loaded) degrades + * gracefully into a clear error instead of crashing activation. This mirrors + * the lazy `host.getService(lspServiceHandle)` try/catch pattern `tool-edit-file` + * uses for its diagnostics hook. + * + * The resolver stays SYNCHRONOUS and side-effect-free with respect to + * connections: looking up the factory and calling it returns a backend whose + * methods are async, so any remote connection acquisition happens lazily + * inside the first backend method call, not at resolve time. + */ +function createResolver(host: HostAPI): ExecBackendResolver { + return (computerId?: string): ExecBackend => { + if (computerId === undefined) return localExecBackend; + // computerId set → remote. Look up the factory the `ssh` extension provides. + // `host.getService` throws when nothing provided the handle (ssh not loaded); + // convert that into a clear "not configured" error rather than a crash. + let factory: (computerId: string) => ExecBackend; + try { + factory = host.getService(remoteExecBackendFactoryHandle); + } catch { + throw new Error( + `SSH remote execution is not configured: the ssh extension is not loaded ` + + `(requested computerId="${computerId}"). Load the ssh package to enable remote execution.`, + ); + } + return factory(computerId); + }; +} + +/** + * Factory: create the `exec-backend` core extension. + * + * `activate` captures the host and provides the `ExecBackendResolver` via the + * typed service handle. The resolver lazily delegates the remote branch to a + * factory the `ssh` extension will provide (see `remoteExecBackendFactoryHandle`); + * until `ssh` is loaded, a remote request fails with a clear error. + */ +export function createExecBackendExtension(): Extension { + return { + manifest, + activate(host) { + const resolver: ExecBackendResolver = createResolver(host); + host.provideService(execBackendHandle, resolver); + }, + }; +} diff --git a/packages/exec-backend/src/index.ts b/packages/exec-backend/src/index.ts new file mode 100644 index 0000000..30c12c8 --- /dev/null +++ b/packages/exec-backend/src/index.ts @@ -0,0 +1,8 @@ +export type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from "./backend.js"; +export { createExecBackendExtension, manifest } from "./extension.js"; +export { createLocalExecBackend, localExecBackend } from "./local.js"; +export type { ExecBackendResolver } from "./service.js"; +export { + execBackendHandle, + remoteExecBackendFactoryHandle, +} from "./service.js"; diff --git a/packages/exec-backend/src/local.test.ts b/packages/exec-backend/src/local.test.ts new file mode 100644 index 0000000..5357d6f --- /dev/null +++ b/packages/exec-backend/src/local.test.ts @@ -0,0 +1,199 @@ +import { writeFile as fsWriteFile, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { ExecBackend } from "./backend.js"; +import { createLocalExecBackend, localExecBackend } from "./local.js"; + +/** + * LocalExecBackend — integration tests against the OUTERMOST real edge + * (real fs/spawn). Zero internal mocks; no mocking of @dispatch/*. + */ +describe("LocalExecBackend", () => { + const backend: ExecBackend = createLocalExecBackend(); + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "exec-backend-test-")); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + describe("spawn", () => { + it("runs a real `sh -c 'echo hi'` and returns exitCode 0 + captured stdout", async () => { + let output = ""; + const result = await backend.spawn({ + command: "echo hi", + cwd: tmpDir, + signal: AbortSignal.timeout(5000), + timeout: 5000, + onOutput: (data) => { + output += data; + }, + }); + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + expect(result.aborted).toBe(false); + expect(output).toContain("hi"); + }); + + it("returns a non-zero exit code for a failing command", async () => { + const result = await backend.spawn({ + command: "false", + cwd: tmpDir, + signal: AbortSignal.timeout(5000), + timeout: 5000, + onOutput: () => {}, + }); + expect(result.exitCode).toBe(1); + expect(result.aborted).toBe(false); + expect(result.timedOut).toBe(false); + }); + + it("streams stderr separately from stdout", async () => { + const streams: Array<{ data: string; stream: "stdout" | "stderr" }> = []; + const result = await backend.spawn({ + command: "echo out; echo err 1>&2", + cwd: tmpDir, + signal: AbortSignal.timeout(5000), + timeout: 5000, + onOutput: (data, stream) => streams.push({ data, stream }), + }); + expect(result.exitCode).toBe(0); + expect(streams.some((s) => s.stream === "stdout" && s.data.includes("out"))).toBe(true); + expect(streams.some((s) => s.stream === "stderr" && s.data.includes("err"))).toBe(true); + }); + + it("resolves with aborted: true when the signal fires", async () => { + const controller = new AbortController(); + const promise = backend.spawn({ + command: "sleep 30", + cwd: tmpDir, + signal: controller.signal, + timeout: 60_000, + onOutput: () => {}, + }); + // Let the sleep actually start. + await new Promise((r) => setTimeout(r, 300)); + controller.abort(); + const result = await promise; + expect(result.aborted).toBe(true); + expect(result.timedOut).toBe(false); + }); + + it("resolves with timedOut: true when the timeout elapses", async () => { + const start = Date.now(); + const result = await backend.spawn({ + command: "sleep 30", + cwd: tmpDir, + signal: AbortSignal.timeout(60_000), + timeout: 300, + onOutput: () => {}, + }); + const elapsed = Date.now() - start; + expect(result.timedOut).toBe(true); + expect(result.aborted).toBe(false); + // Should resolve shortly after the 300ms timeout, well under 30s. + expect(elapsed).toBeLessThan(10_000); + }); + }); + + describe("stat", () => { + it("distinguishes file vs directory", async () => { + await fsWriteFile(join(tmpDir, "file.txt"), "hello"); + await mkdir(join(tmpDir, "subdir")); + + const fileStat = await backend.stat(join(tmpDir, "file.txt")); + expect(fileStat.isFile).toBe(true); + expect(fileStat.isDirectory).toBe(false); + + const dirStat = await backend.stat(join(tmpDir, "subdir")); + expect(dirStat.isFile).toBe(false); + expect(dirStat.isDirectory).toBe(true); + }); + + it("throws ENOENT with .code for a missing path", async () => { + try { + await backend.stat(join(tmpDir, "nope")); + expect.fail("stat should have thrown for a missing path"); + } catch (err: unknown) { + expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); + } + }); + }); + + describe("readFile / writeFile / readdir / exists round-trip", () => { + it("writes then reads a file (utf8 round-trip)", async () => { + const filePath = join(tmpDir, "round.txt"); + await backend.writeFile(filePath, "round-trip content"); + const content = await backend.readFile(filePath); + expect(content).toBe("round-trip content"); + }); + + it("readdir lists entries with correct isDirectory flags", async () => { + await fsWriteFile(join(tmpDir, "a.txt"), "a"); + await mkdir(join(tmpDir, "sub")); + + const entries = await backend.readdir(tmpDir); + const names = entries.map((e) => e.name).sort(); + expect(names).toEqual(["a.txt", "sub"]); + + const sub = entries.find((e) => e.name === "sub"); + expect(sub?.isDirectory).toBe(true); + + const file = entries.find((e) => e.name === "a.txt"); + expect(file?.isDirectory).toBe(false); + }); + + it("exists returns true for an existing file, false for a missing one", async () => { + const filePath = join(tmpDir, "exists.txt"); + await fsWriteFile(filePath, "x"); + expect(await backend.exists(filePath)).toBe(true); + expect(await backend.exists(join(tmpDir, "missing"))).toBe(false); + }); + + it("exists returns true for an existing directory", async () => { + await mkdir(join(tmpDir, "adir")); + expect(await backend.exists(join(tmpDir, "adir"))).toBe(true); + }); + + it("readFile throws ENOENT with .code for a missing file", async () => { + try { + await backend.readFile(join(tmpDir, "missing.txt")); + expect.fail("readFile should have thrown for a missing file"); + } catch (err: unknown) { + expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); + } + }); + + it("readdir throws ENOENT with .code for a missing directory", async () => { + try { + await backend.readdir(join(tmpDir, "missingdir")); + expect.fail("readdir should have thrown for a missing directory"); + } catch (err: unknown) { + expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); + } + }); + + it("writeFile throws an error with .code when the parent dir is missing", async () => { + try { + await backend.writeFile(join(tmpDir, "missing-parent", "child.txt"), "x"); + expect.fail("writeFile should have thrown for a missing parent dir"); + } catch (err: unknown) { + expect((err as NodeJS.ErrnoException).code).toBe("ENOENT"); + } + }); + }); + + describe("singleton", () => { + it("localExecBackend singleton satisfies ExecBackend and behaves identically", async () => { + expect(typeof localExecBackend.spawn).toBe("function"); + expect(typeof localExecBackend.readFile).toBe("function"); + const filePath = join(tmpDir, "singleton.txt"); + await localExecBackend.writeFile(filePath, "singleton"); + expect(await localExecBackend.readFile(filePath)).toBe("singleton"); + }); + }); +}); diff --git a/packages/tool-shell/src/spawn.ts b/packages/exec-backend/src/local.ts index 9b1d7e4..ca88a11 100644 --- a/packages/tool-shell/src/spawn.ts +++ b/packages/exec-backend/src/local.ts @@ -1,8 +1,67 @@ import { spawn as nodeSpawn } from "node:child_process"; -import type { SpawnResult, SpawnShell } from "./shell.js"; +import { access, readdir, readFile, stat, writeFile } from "node:fs/promises"; +import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from "./backend.js"; -export const realSpawn: SpawnShell = (params): Promise<SpawnResult> => { - return new Promise<SpawnResult>((resolve) => { +/** + * LocalExecBackend — wraps `node:fs/promises` + `node:child_process`. + * + * Behavior is IDENTICAL to today's local tools: + * - `spawn` mirrors `realSpawn` in `packages/tool-shell/src/spawn.ts` — same + * `sh -c` invocation, detached process-group kill on abort/timeout, + * close-based resolution, and spawn-error → `{ exitCode: 1 }`. + * - `readFile`/`writeFile`/`stat`/`readdir` use the same `node:fs/promises` + * calls (utf8, `withFileTypes`) the tools make inline today, and throw the + * same node errors (carrying `.code`) so the tools' existing error branches + * work unchanged. + * - `exists` swallows all errors and returns `false` (an existence check). + * + * This factors the inline node calls out behind the `ExecBackend` interface so + * a remote (SshExecBackend) can swap in transparently. Stateless — safe to + * share as a singleton. + */ +export function createLocalExecBackend(): ExecBackend { + return { + spawn: localSpawn, + + readFile: (path) => readFile(path, "utf8"), + + writeFile: (path, content) => writeFile(path, content, "utf8"), + + stat: async (path): Promise<StatResult> => { + const s = await stat(path); + return { isFile: s.isFile(), isDirectory: s.isDirectory() }; + }, + + readdir: async (path): Promise<readonly DirEntry[]> => { + const entries = await readdir(path, { encoding: "utf8", withFileTypes: true }); + return entries.map((e): DirEntry => ({ name: e.name, isDirectory: e.isDirectory() })); + }, + + exists: async (path): Promise<boolean> => { + try { + await access(path); + return true; + } catch { + return false; + } + }, + }; +} + +/** Default singleton — stateless, safe to share across calls. */ +export const localExecBackend: ExecBackend = createLocalExecBackend(); + +/** + * Run a shell command locally via `node:child_process`. + * + * Ported verbatim from `packages/tool-shell/src/spawn.ts` (`realSpawn`) so + * behavior is byte-identical: `sh -c <command>`, `detached: true` (own process + * group), process-group `SIGKILL` on abort/timeout so a backgrounded grandchild + * cannot hold the stdio pipes open, and resolve-once-with-cleanup to avoid + * listener/timer leaks. + */ +function localSpawn(params: SpawnParams): Promise<ExecResult> { + return new Promise<ExecResult>((resolve) => { // detached: true puts the child in its own process group (pgid = child.pid). // This lets us kill the entire group (child + any grandchildren that inherit // the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a @@ -39,7 +98,7 @@ export const realSpawn: SpawnShell = (params): Promise<SpawnResult> => { }; /** Resolve once, then clean up so listeners/timers never leak. */ - const settle = (result: SpawnResult) => { + const settle = (result: ExecResult) => { if (settled) return; settled = true; cleanup(); @@ -84,4 +143,4 @@ export const realSpawn: SpawnShell = (params): Promise<SpawnResult> => { settle({ exitCode: 1, timedOut: false, aborted: false }); }); }); -}; +} diff --git a/packages/exec-backend/src/service.ts b/packages/exec-backend/src/service.ts new file mode 100644 index 0000000..6cfa9de --- /dev/null +++ b/packages/exec-backend/src/service.ts @@ -0,0 +1,46 @@ +import { defineService } from "@dispatch/kernel"; +import type { ExecBackend } from "./backend.js"; + +/** + * Resolve an `ExecBackend` for a given computer. + * + * - `computerId` **undefined** → local (today's behavior; `LocalExecBackend`). + * - `computerId` **set** → remote (SSH; wired by `host-bin` + the `ssh` package + * in a later wave — the `SshExecBackend` implements the same `ExecBackend` + * interface). + * + * The resolver is SYNCHRONOUS by design: it returns a backend whose methods are + * async, so any remote connection acquisition happens lazily inside the first + * backend method call, not at resolver-call time. This keeps the resolver + * side-effect-free — merely resolving a backend never opens a connection; only + * when a tool actually executes does the (remote) backend connect. + */ +export type ExecBackendResolver = (computerId?: string) => ExecBackend; + +/** + * Typed service handle for the `ExecBackend` resolver. + * + * The `exec-backend` extension provides this via `host.provideService`. + * Tool extensions resolve their per-call backend from it (injected at + * activation by `host-bin`). + */ +export const execBackendHandle = defineService<ExecBackendResolver>("exec-backend/resolver"); + +/** + * A factory the `ssh` extension provides: given a computerId (SSH alias), + * returns the remote `ExecBackend`. Absent (ssh not loaded) → remote execution + * is unconfigured. + * + * This is a **consumer-defined handle**: `exec-backend` (a core extension) + * declares it, and the `ssh` extension (a standard extension, built in a later + * wave) `host.provideService`s it. That direction (standard → core, consumer + * defines / provider implements) is the only layering that keeps the kernel free + * of any concrete feature name — `exec-backend` owns the seam, `ssh` plugs in. + * + * The resolver looks this up LAZILY at resolve time (tool-execute time, after + * all extensions have activated), so missing-the-provider degrades gracefully + * rather than crashing activation. + */ +export const remoteExecBackendFactoryHandle = defineService<(computerId: string) => ExecBackend>( + "exec-backend/remote-factory", +); diff --git a/packages/exec-backend/tsconfig.json b/packages/exec-backend/tsconfig.json new file mode 100644 index 0000000..ff99a43 --- /dev/null +++ b/packages/exec-backend/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] +} diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json index 568fd6c..64f436e 100644 --- a/packages/host-bin/package.json +++ b/packages/host-bin/package.json @@ -10,12 +10,14 @@ "@dispatch/auth-apikey": "workspace:*", "@dispatch/cache-warming": "workspace:*", "@dispatch/credential-store": "workspace:*", + "@dispatch/exec-backend": "workspace:*", "@dispatch/provider-openai-compat": "workspace:*", "@dispatch/provider-umans": "workspace:*", "@dispatch/message-queue": "workspace:*", "@dispatch/mcp": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", "@dispatch/skills": "workspace:*", + "@dispatch/ssh": "workspace:*", "@dispatch/throughput-store": "workspace:*", "@dispatch/todo": "workspace:*", "@dispatch/transport-http": "workspace:*", diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index bd79497..571628f 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -4,6 +4,7 @@ import { extension as authApikeyExt } from "@dispatch/auth-apikey"; import { extension as cacheWarmingExt } from "@dispatch/cache-warming"; import { extension as conversationStoreExt } from "@dispatch/conversation-store"; import { createCredentialStoreExtension } from "@dispatch/credential-store"; +import { createExecBackendExtension } from "@dispatch/exec-backend"; import { createJournalSink } from "@dispatch/journal-sink"; import { type ConfigAccess, @@ -26,6 +27,7 @@ import { extension as providerOpenaiCompatExt } from "@dispatch/provider-openai- import { extension as providerUmansExt } from "@dispatch/provider-umans"; import { extension as sessionOrchestratorExt } from "@dispatch/session-orchestrator"; import { extension as skillsExt } from "@dispatch/skills"; +import { extension as sshExt } from "@dispatch/ssh"; import { createSqliteStorage, extension as storageSqliteExt } from "@dispatch/storage-sqlite"; import { createLoadedExtensionsExtension } from "@dispatch/surface-loaded-extensions"; import { createSurfaceRegistryExtension } from "@dispatch/surface-registry"; @@ -76,6 +78,11 @@ const CORE_EXTENSIONS: readonly Extension[] = [ authApikeyExt, providerOpenaiCompatExt, providerUmansExt, + // exec-backend must precede the tool extensions that + // `dependsOn: ["exec-backend"]` (tool-edit-file/read/shell/write). It + // provides the ExecBackendResolver the tools resolve through; placing it + // here keeps the activation DAG honest (it depends only on kernel). + createExecBackendExtension(), toolEditFileExt, toolReadFileExt, toolShellExt, @@ -91,6 +98,13 @@ const CORE_EXTENSIONS: readonly Extension[] = [ systemPromptExt, cacheWarmingExt, lspExt, + // ssh declares `dependsOn: ["exec-backend"]` and PROVIDES the remote + // exec-backend factory + the ComputerService the HTTP routes delegate to. + // Its lookups are lazy (tool-/request-time), but it is placed after + // exec-backend and the tool extensions (alongside the other standard + // tool-serving extensions) to keep the DAG honest — and before + // transport-http, whose routes consume the ComputerService it provides. + sshExt, createTransportHttpExtension(), // Surface extensions — dependency order: surface-registry first, then consumers. createSurfaceRegistryExtension(), diff --git a/packages/host-bin/tsconfig.json b/packages/host-bin/tsconfig.json index 771dc92..e445f13 100644 --- a/packages/host-bin/tsconfig.json +++ b/packages/host-bin/tsconfig.json @@ -11,6 +11,9 @@ "path": "../cache-warming" }, { + "path": "../exec-backend" + }, + { "path": "../kernel" }, { @@ -23,6 +26,9 @@ "path": "../skills" }, { + "path": "../ssh" + }, + { "path": "../storage-sqlite" }, { diff --git a/packages/kernel/src/contracts/runtime.ts b/packages/kernel/src/contracts/runtime.ts index 8376e42..dc74c84 100644 --- a/packages/kernel/src/contracts/runtime.ts +++ b/packages/kernel/src/contracts/runtime.ts @@ -85,6 +85,17 @@ export interface RunTurnInput { readonly cwd?: string; /** + * The computer to execute this turn's tools on (SSH support). Omitted/undefined + * = LOCAL (today's behavior). When set, it is an SSH config alias; the kernel + * does NOT interpret it — it forwards the value verbatim to each + * `ToolExecuteContext.computerId`, exactly like `cwd`. It never enters the + * model prompt, so it does not affect prompt caching. Tools resolve their + * execution backend (local vs. remote) from this; see + * `notes/ssh-support-plan.md`. + */ + readonly computerId?: string; + + /** * Optional logger for structured span instrumentation. The runtime opens * turn/step/tool-call spans using this logger. If omitted, no spans are * emitted (backward-compatible with callers that don't yet pass a logger). diff --git a/packages/kernel/src/contracts/tool.ts b/packages/kernel/src/contracts/tool.ts index d5a835c..589fbd0 100644 --- a/packages/kernel/src/contracts/tool.ts +++ b/packages/kernel/src/contracts/tool.ts @@ -78,6 +78,18 @@ export interface ToolExecuteContext { * a tool has no conversation scope (e.g. a global tool). */ readonly conversationId?: string; + + /** + * The computer this tool-call executes on (SSH support). When + * omitted/undefined, execution is LOCAL (today's behavior — the tool uses + * the local node fs/child_process). When set, it is an SSH config alias + * (see `notes/ssh-support-plan.md` §3); a tool resolves a remote + * `ExecBackend` for it via its injected resolver. The kernel never + * interprets it — it forwards the value verbatim from + * `RunTurnInput.computerId`, exactly like `cwd`. It never enters the model + * prompt, so it does not affect prompt caching. + */ + readonly computerId?: string; } /** diff --git a/packages/kernel/src/runtime/dispatch.ts b/packages/kernel/src/runtime/dispatch.ts index e0be1b4..01f0043 100644 --- a/packages/kernel/src/runtime/dispatch.ts +++ b/packages/kernel/src/runtime/dispatch.ts @@ -18,6 +18,7 @@ export async function executeToolCall( turnId: string, toolSpan?: Span, cwd?: string, + computerId?: string, ): Promise<ToolResult> { if (tool === undefined) { return { content: `Unknown tool: ${call.name}`, isError: true }; @@ -34,6 +35,7 @@ export async function executeToolCall( log: toolSpan?.log ?? createNoopLogger(), conversationId, ...(cwd !== undefined ? { cwd } : {}), + ...(computerId !== undefined ? { computerId } : {}), }; // Race the tool's execute promise against the abort signal so a tool // that hangs (ignores ctx.signal, or blocks on something the signal @@ -74,6 +76,7 @@ export function createStepDispatcher( turnId: string, toolSpans: Map<string, Span>, cwd?: string, + computerId?: string, ): StepDispatcher { let activeCount = 0; let unsafeRunning = false; @@ -112,6 +115,7 @@ export function createStepDispatcher( turnId, tcSpan, cwd, + computerId, ); activeCount--; if (entry.tool?.concurrencySafe === false) unsafeRunning = false; diff --git a/packages/kernel/src/runtime/run-turn.test.ts b/packages/kernel/src/runtime/run-turn.test.ts index dba9d80..a9fc3d9 100644 --- a/packages/kernel/src/runtime/run-turn.test.ts +++ b/packages/kernel/src/runtime/run-turn.test.ts @@ -835,6 +835,71 @@ describe("runTurn", () => { expect(capturedCwd).toBeUndefined(); }); + it("forwards computerId from RunTurnInput to ToolExecuteContext", async () => { + let capturedComputerId: string | undefined = "SENTINEL_NOT_SET"; + + const tool = createFakeTool("computercheck", async (_input, ctx) => { + capturedComputerId = ctx.computerId; + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "computercheck", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + computerId: "ssh-host-alias", + }); + + expect(capturedComputerId).toBe("ssh-host-alias"); + }); + + it("forwards undefined computerId when RunTurnInput has no computerId", async () => { + let capturedComputerId: string | undefined = "SENTINEL_NOT_SET"; + + const tool = createFakeTool("computercheck", async (_input, ctx) => { + capturedComputerId = ctx.computerId; + return { content: "ok" }; + }); + + const provider = createFakeProvider([ + [ + { type: "tool-call", toolCallId: "tc1", toolName: "computercheck", input: {} }, + { type: "finish", reason: "tool-calls" }, + ], + [ + { type: "text-delta", delta: "done" }, + { type: "finish", reason: "stop" }, + ], + ]); + + await runTurn({ + provider, + messages: [userMessage], + tools: [tool], + dispatch: { maxConcurrent: 1, eager: false }, + conversationId: "tab-test", + turnId: "turn-test", + emit: () => {}, + }); + + expect(capturedComputerId).toBeUndefined(); + }); + it("aggregates usage across multiple steps", async () => { const provider = createFakeProvider([ [ diff --git a/packages/kernel/src/runtime/run-turn.ts b/packages/kernel/src/runtime/run-turn.ts index 08f8459..ac87a1f 100644 --- a/packages/kernel/src/runtime/run-turn.ts +++ b/packages/kernel/src/runtime/run-turn.ts @@ -123,6 +123,7 @@ interface StepContext { readonly turnSpan: Span | undefined; readonly toolSpans: Map<string, Span>; readonly cwd: string | undefined; + readonly computerId: string | undefined; readonly now: (() => number) | undefined; /** Per-turn provider options (model, systemPrompt, …) threaded to stream(). */ readonly providerOpts: ProviderStreamOptions | undefined; @@ -301,6 +302,7 @@ async function executeStep(ctx: StepContext): Promise<StepResult> { ctx.turnId, ctx.toolSpans, ctx.cwd, + ctx.computerId, ); const timing: TimingState = { @@ -636,6 +638,7 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> { turnSpan, toolSpans, cwd: input.cwd, + computerId: input.computerId, now, providerOpts: input.providerOpts, retry: input.retry, diff --git a/packages/mcp/src/extension.test.ts b/packages/mcp/src/extension.test.ts index e2d2eab..75515fb 100644 --- a/packages/mcp/src/extension.test.ts +++ b/packages/mcp/src/extension.test.ts @@ -49,6 +49,28 @@ describe("filterMcpTools (pure)", () => { expect(result.tools).toHaveLength(0); expect(result.conversationId).toBe("c"); expect(result.cwd).toBeUndefined(); + expect(result.computerId).toBeUndefined(); + }); + + it("preserves computerId when set (mirrors cwd/conversationId preservation)", () => { + const toolToServer = new Map<string, string>([["a__x", "a"]]); + const connected = new Set<string>(["a"]); + + const result = filterMcpTools( + { + tools: [stubTool("a__x"), stubTool("other")], + cwd: "/p", + computerId: "ssh-host", + conversationId: "c", + }, + toolToServer, + connected, + ); + + expect(result.tools.map((t) => t.name).sort()).toEqual(["a__x", "other"]); + expect(result.computerId).toBe("ssh-host"); + expect(result.cwd).toBe("/p"); + expect(result.conversationId).toBe("c"); }); }); diff --git a/packages/mcp/src/extension.ts b/packages/mcp/src/extension.ts index 9adb879..e1c4d52 100644 --- a/packages/mcp/src/extension.ts +++ b/packages/mcp/src/extension.ts @@ -49,6 +49,7 @@ export function filterMcpTools( return { tools: filtered, ...(assembly.cwd !== undefined && { cwd: assembly.cwd }), + ...(assembly.computerId !== undefined && { computerId: assembly.computerId }), conversationId: assembly.conversationId, }; } diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts index 4144827..1a57cc3 100644 --- a/packages/session-orchestrator/src/extension.ts +++ b/packages/session-orchestrator/src/extension.ts @@ -13,7 +13,7 @@ import { sessionOrchestratorHandle, } from "./orchestrator.js"; import { selectFirstProvider } from "./pure.js"; -import { toolsFilter } from "./tools-filter.js"; +import { filterRemoteIncompatibleTools, toolsFilter } from "./tools-filter.js"; export const manifest: Manifest = { id: "session-orchestrator", @@ -97,6 +97,18 @@ export function activate(host: HostAPI): void { host.provideService(sessionOrchestratorHandle, orchestrator); + // Remote-degradation rule (plan §6): when a turn is REMOTE + // (`assembly.computerId !== undefined`), drop tools that spawn local + // processes and cannot run over SFTP — the `lsp` tool (local LSP servers) + // and MCP-namespaced tools (`<serverId>__<toolName>`, local MCP servers). + // When LOCAL (`computerId === undefined`), the filter is a passthrough — + // byte-identical to today. Registered at default priority (0) with + // activation-order tie-breaking: session-orchestrator activates before + // MCP (which dependsOn it), so this runs FIRST in the chain — the drops + // happen before MCP's filter connects/registers servers. Mirrors how MCP + // adds its own filter via host.addFilter. + host.addFilter(toolsFilter, filterRemoteIncompatibleTools); + const warmService = createWarmService( { conversationStore, diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index e2d3b6b..8ff3f5e 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -33,6 +33,7 @@ function createInMemoryStore(): ConversationStore & { readonly data: Map<string, ChatMessage[]>; readonly metricsData: Map<string, TurnMetrics[]>; readonly cwdData: Map<string, string>; + readonly computerData: Map<string, string>; readonly effortData: Map<string, ReasoningEffort>; readonly modelData: Map<string, string>; readonly workspaceIdData: Map<string, string>; @@ -40,6 +41,7 @@ function createInMemoryStore(): ConversationStore & { const data = new Map<string, ChatMessage[]>(); const metricsData = new Map<string, TurnMetrics[]>(); const cwdData = new Map<string, string>(); + const computerData = new Map<string, string>(); const effortData = new Map<string, ReasoningEffort>(); const modelData = new Map<string, string>(); const workspaceIdData = new Map<string, string>(); @@ -53,6 +55,7 @@ function createInMemoryStore(): ConversationStore & { data, metricsData, cwdData, + computerData, effortData, modelData, workspaceIdData, @@ -91,6 +94,22 @@ function createInMemoryStore(): ConversationStore & { async setCwd(conversationId, cwd) { cwdData.set(conversationId, cwd); }, + async clearCwd(conversationId) { + cwdData.delete(conversationId); + }, + async getComputerId(conversationId) { + return computerData.get(conversationId) ?? null; + }, + async setComputerId(conversationId, alias) { + if (alias === null) { + computerData.delete(conversationId); + } else { + computerData.set(conversationId, alias); + } + }, + async clearComputerId(conversationId) { + computerData.delete(conversationId); + }, async getReasoningEffort(conversationId) { return effortData.get(conversationId) ?? null; }, @@ -149,13 +168,44 @@ function createInMemoryStore(): ConversationStore & { return null; }, async ensureWorkspace(id) { - return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceTitle(id, title) { - return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceDefaultCwd(id, defaultCwd) { - return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId, + createdAt: 0, + lastActivityAt: 0, + }; }, async deleteWorkspace() { return { closedCount: 0 }; @@ -173,6 +223,9 @@ function createInMemoryStore(): ConversationStore & { async getEffectiveCwd(conversationId, overrideCwd) { return overrideCwd ?? cwdData.get(conversationId) ?? null; }, + async getEffectiveComputer(conversationId, overrideAlias) { + return overrideAlias ?? computerData.get(conversationId) ?? null; + }, }; } @@ -489,6 +542,114 @@ describe("handleMessage model resolution", () => { expect(captured[1]?.cwd).toBeUndefined(); }); + it("computerId is forwarded to RunTurnInput.computerId and absent when not provided", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captured, captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-computer", + text: "hi", + onEvent: () => {}, + computerId: "my-ssh-host", + }); + + expect(captured).toHaveLength(1); + expect(captured[0]?.computerId).toBe("my-ssh-host"); + + await orchestrator.handleMessage({ + conversationId: "conv-no-computer", + text: "hi", + onEvent: () => {}, + }); + + expect(captured).toHaveLength(2); + expect(captured[1]?.computerId).toBeUndefined(); + }); + + it("computerId override persists via setComputerId (mirrors setCwd-on-override)", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-persist-computer", + text: "hi", + onEvent: () => {}, + computerId: "persisted-host", + }); + + expect(store.computerData.get("conv-persist-computer")).toBe("persisted-host"); + }); + + it("computerId not provided → setComputerId NOT called (no override persisted)", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: identityApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-no-persist", + text: "hi", + onEvent: () => {}, + }); + + expect(store.computerData.get("conv-no-persist")).toBeUndefined(); + }); + + it("computerId threads into ToolAssembly passed to applyToolsFilter", async () => { + const store = createInMemoryStore(); + const provider: ProviderContract = { id: "p", stream: async function* () {} }; + const { captureRunTurn } = createCapturingRunTurn(); + + const capturedAssemblies: ToolAssembly[] = []; + const recordingApplyToolsFilter = (assembly: ToolAssembly): Promise<ToolAssembly> => { + capturedAssemblies.push(assembly); + return Promise.resolve(assembly); + }; + + const { orchestrator } = createSessionOrchestrator({ + conversationStore: store, + resolveProvider: () => provider, + resolveTools: () => [], + applyToolsFilter: recordingApplyToolsFilter, + runTurn: captureRunTurn, + }); + + await orchestrator.handleMessage({ + conversationId: "conv-assembly", + text: "hi", + onEvent: () => {}, + computerId: "remote-host", + }); + + expect(capturedAssemblies).toHaveLength(1); + expect(capturedAssemblies[0]?.computerId).toBe("remote-host"); + }); + it("forwards an injected now into the RunTurnInput passed to runTurn", async () => { const store = createInMemoryStore(); const provider: ProviderContract = { id: "p", stream: async function* () {} }; @@ -643,6 +804,12 @@ describe("turn-sealed event", () => { return null; }, async setCwd() {}, + async clearCwd() {}, + async getComputerId() { + return null; + }, + async setComputerId() {}, + async clearComputerId() {}, async getReasoningEffort() { return null; }, @@ -673,13 +840,44 @@ describe("turn-sealed event", () => { return null; }, async ensureWorkspace(id) { - return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceTitle(id, title) { - return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceDefaultCwd(id, defaultCwd) { - return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId, + createdAt: 0, + lastActivityAt: 0, + }; }, async deleteWorkspace() { return { closedCount: 0 }; @@ -694,6 +892,9 @@ describe("turn-sealed event", () => { async getEffectiveCwd() { return null; }, + async getEffectiveComputer() { + return null; + }, }; const { orchestrator } = createSessionOrchestrator({ @@ -1034,6 +1235,12 @@ describe("turn metrics persistence", () => { return null; }, async setCwd() {}, + async clearCwd() {}, + async getComputerId() { + return null; + }, + async setComputerId() {}, + async clearComputerId() {}, async getReasoningEffort() { return null; }, @@ -1064,13 +1271,44 @@ describe("turn metrics persistence", () => { return null; }, async ensureWorkspace(id) { - return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceTitle(id, title) { - return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceDefaultCwd(id, defaultCwd) { - return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId, + createdAt: 0, + lastActivityAt: 0, + }; }, async deleteWorkspace() { return { closedCount: 0 }; @@ -1085,6 +1323,9 @@ describe("turn metrics persistence", () => { async getEffectiveCwd() { return null; }, + async getEffectiveComputer() { + return null; + }, }; const { orchestrator } = createSessionOrchestrator({ @@ -2781,7 +3022,14 @@ describe("workspace integration", () => { ...base, async ensureWorkspace(id) { ensureWorkspaceCalls.push(id); - return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, }; @@ -2999,6 +3247,7 @@ describe("workspace integration", () => { id, title: id, defaultCwd: workspaceDefaultCwds.get(id) ?? null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -3012,7 +3261,14 @@ describe("workspace integration", () => { }, async getWorkspace(id) { const defaultCwd = workspaceDefaultCwds.get(id) ?? null; - return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async getEffectiveCwd(conversationId, overrideCwd) { // Real algorithm: relative cwd resolved against workspace defaultCwd. @@ -3089,6 +3345,7 @@ describe("workspace integration", () => { id, title: id, defaultCwd: workspaceDefaultCwds.get(id) ?? null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -3101,7 +3358,14 @@ describe("workspace integration", () => { }, async getWorkspace(id) { const defaultCwd = workspaceDefaultCwds.get(id) ?? null; - return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async getEffectiveCwd(conversationId, overrideCwd) { const wsId = assignedWorkspaceIds.get(conversationId) ?? "default"; diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index b4d4b35..a1401d6 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -39,6 +39,14 @@ export interface StartTurnInput { readonly text: string; readonly modelName?: string; readonly cwd?: string; + /** + * The computer to execute this turn's tools on (SSH config alias). Mirrors + * `cwd`: an explicit per-turn override resolved via `getEffectiveComputer`. + * Omitted/`undefined` = use the persisted per-conversation / workspace + * default (LOCAL when none set). The orchestrator never interprets it — it + * forwards the alias string verbatim (like cwd forwards a path). + */ + readonly computerId?: string; readonly reasoningEffort?: ReasoningEffort; /** * The workspace this conversation belongs to. Defaults to `"default"` when @@ -59,6 +67,12 @@ export interface EnqueueInput { readonly text: string; /** Workspace to stamp on a new conversation. Defaults to `"default"`. */ readonly workspaceId?: string; + /** + * Per-turn computer override (SSH alias), threaded to `startTurn` when the + * conversation is idle (the message starts a turn). Additive optional — + * mirrors `workspaceId` on this type (enqueue does not carry `cwd`). + */ + readonly computerId?: string; } /** @@ -89,6 +103,8 @@ interface ActiveTurn { export interface TurnLifecyclePayload { readonly conversationId: string; readonly cwd?: string; + /** The computer this turn executes on (SSH alias), mirroring `cwd`. */ + readonly computerId?: string; readonly modelName?: string; } @@ -258,6 +274,7 @@ export interface SessionOrchestrator { onEvent: (event: AgentEvent) => void; modelName?: string; cwd?: string; + computerId?: string; reasoningEffort?: ReasoningEffort; workspaceId?: string; }): Promise<void>; @@ -404,6 +421,7 @@ export function createSessionOrchestrator( text: string, modelName: string | undefined, cwd: string | undefined, + computerId: string | undefined, reasoningEffortOverride: ReasoningEffort | undefined, workspaceId: string, ): void { @@ -446,6 +464,19 @@ export function createSessionOrchestrator( deps.conversationStore.getEffectiveCwd(conversationId, cwd).then((c) => c ?? undefined), ); + // Resolve the effective computer the SAME way cwd resolves — pass the + // per-turn computerId as the overrideAlias. When computerId is + // undefined, getEffectiveComputer reads the persisted per-conversation + // computerId → workspace defaultComputerId → null (LOCAL). Chained + // after workspaceSetupPromise (same timing invariant as cwd). The + // orchestrator never interprets the alias — it forwards the string + // verbatim (like cwd forwards a path). Mirrors effectiveCwdPromise. + const effectiveComputerIdPromise = workspaceSetupPromise.then(() => + deps.conversationStore + .getEffectiveComputer(conversationId, computerId) + .then((c) => c ?? undefined), + ); + const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId); // Resolve the persisted model (if any) in parallel with the other // per-conversation reads. The effective model name is @@ -455,13 +486,15 @@ export function createSessionOrchestrator( const payloadPromise = Promise.all([ effectiveCwdPromise, + effectiveComputerIdPromise, storedEffortPromise, storedModelPromise, - ]).then(([effectiveCwd, _storedEffort, storedModel]) => { + ]).then(([effectiveCwd, effectiveComputerId, _storedEffort, storedModel]) => { const effectiveModelName = resolveModelName(modelName, storedModel); return { conversationId, ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), ...(effectiveModelName !== undefined ? { modelName: effectiveModelName } : {}), }; }); @@ -483,17 +516,27 @@ export function createSessionOrchestrator( void (async () => { let sealed = false; try { - const [effectiveCwd, storedEffort, isNewConversation, storedModel] = await Promise.all([ - effectiveCwdPromise, - storedEffortPromise, - workspaceSetupPromise, - storedModelPromise, - ]); + const [effectiveCwd, effectiveComputerId, storedEffort, isNewConversation, storedModel] = + await Promise.all([ + effectiveCwdPromise, + effectiveComputerIdPromise, + storedEffortPromise, + workspaceSetupPromise, + storedModelPromise, + ]); if (cwd !== undefined) { await deps.conversationStore.setCwd(conversationId, cwd); } + // Persist the per-turn computer override, mirroring the cwd + // persistence above. Only stamped when a computerId was actually + // provided — NOT when it resolved to undefined (LOCAL) via the + // workspace default. Idempotent when the value is unchanged. + if (computerId !== undefined) { + await deps.conversationStore.setComputerId(conversationId, computerId); + } + const resolvedEffort = resolveReasoningEffort(reasoningEffortOverride, storedEffort); // Effective model name: per-turn override → persisted → undefined // (→ default provider). Resolved here so every downstream consumer @@ -543,6 +586,7 @@ export function createSessionOrchestrator( tools: baseTools, conversationId, ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), }); const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy(); const turnLogger = deps.logger?.child({ conversationId, turnId }); @@ -634,6 +678,7 @@ export function createSessionOrchestrator( retry: retryStrategy, ...(turnLogger !== undefined ? { logger: turnLogger } : {}), ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), ...(deps.now !== undefined ? { now: deps.now } : {}), ...(drainSteering !== undefined ? { drainSteering } : {}), }; @@ -719,7 +764,7 @@ export function createSessionOrchestrator( } const orchestrator: SessionOrchestrator = { - startTurn({ conversationId, text, modelName, cwd, reasoningEffort, workspaceId }) { + startTurn({ conversationId, text, modelName, cwd, computerId, reasoningEffort, workspaceId }) { if (activeTurns.has(conversationId)) { return { started: false, reason: "already-active" }; } @@ -728,6 +773,7 @@ export function createSessionOrchestrator( text, modelName, cwd, + computerId, reasoningEffort, workspaceId ?? "default", ); @@ -736,11 +782,12 @@ export function createSessionOrchestrator( return { started: true, turnId }; }, - enqueue({ conversationId, text, workspaceId }) { + enqueue({ conversationId, text, workspaceId, computerId }) { const result = orchestrator.startTurn({ conversationId, text, ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(computerId !== undefined ? { computerId } : {}), }); if (result.started) { return { startedTurn: true, queue: [] }; @@ -821,6 +868,7 @@ export function createSessionOrchestrator( onEvent, modelName, cwd, + computerId, reasoningEffort, workspaceId, }) { @@ -829,6 +877,7 @@ export function createSessionOrchestrator( text, ...(modelName !== undefined ? { modelName } : {}), ...(cwd !== undefined ? { cwd } : {}), + ...(computerId !== undefined ? { computerId } : {}), ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), ...(workspaceId !== undefined ? { workspaceId } : {}), }; diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts index 225d1af..adf5d9a 100644 --- a/packages/session-orchestrator/src/queue.test.ts +++ b/packages/session-orchestrator/src/queue.test.ts @@ -72,6 +72,14 @@ function createInMemoryStore(): ConversationStore & { async setCwd(conversationId, cwd) { cwdData.set(conversationId, cwd); }, + async clearCwd(conversationId) { + cwdData.delete(conversationId); + }, + async getComputerId() { + return null; + }, + async setComputerId() {}, + async clearComputerId() {}, async getReasoningEffort(conversationId) { return effortData.get(conversationId) ?? null; }, @@ -110,13 +118,44 @@ function createInMemoryStore(): ConversationStore & { return null; }, async ensureWorkspace(id) { - return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceTitle(id, title) { - return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; }, async setWorkspaceDefaultCwd(id, defaultCwd) { - return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 }; + return { + id, + title: id, + defaultCwd, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { + id, + title: id, + defaultCwd: null, + defaultComputerId, + createdAt: 0, + lastActivityAt: 0, + }; }, async deleteWorkspace() { return { closedCount: 0 }; @@ -131,6 +170,9 @@ function createInMemoryStore(): ConversationStore & { async getEffectiveCwd(conversationId) { return cwdData.get(conversationId) ?? null; }, + async getEffectiveComputer() { + return null; + }, }; } diff --git a/packages/session-orchestrator/src/tools-filter.test.ts b/packages/session-orchestrator/src/tools-filter.test.ts new file mode 100644 index 0000000..3233469 --- /dev/null +++ b/packages/session-orchestrator/src/tools-filter.test.ts @@ -0,0 +1,75 @@ +import type { ToolContract } from "@dispatch/kernel"; +import { describe, expect, it } from "vitest"; +import { filterRemoteIncompatibleTools, type ToolAssembly } from "./tools-filter.js"; + +function fakeTool(name: string): ToolContract { + return { + name, + description: `Fake tool: ${name}`, + parameters: { type: "object" }, + execute: async () => ({ content: "ok" }), + }; +} + +const baseAssembly: ToolAssembly = { + tools: [fakeTool("lsp"), fakeTool("mcp__x"), fakeTool("run_shell")], + conversationId: "conv-1", +}; + +describe("filterRemoteIncompatibleTools", () => { + it("REMOTE (computerId set): drops 'lsp' and any '__' namespaced tool, keeps 'run_shell'", () => { + const remote: ToolAssembly = { ...baseAssembly, computerId: "my-server" }; + const result = filterRemoteIncompatibleTools(remote); + const names = result.tools.map((t) => t.name); + expect(names).not.toContain("lsp"); + expect(names).not.toContain("mcp__x"); + expect(names).toContain("run_shell"); + expect(result.tools).toHaveLength(1); + }); + + it("REMOTE: preserves computerId + cwd + conversationId in the returned assembly", () => { + const remote: ToolAssembly = { + tools: [fakeTool("lsp"), fakeTool("run_shell")], + conversationId: "conv-2", + cwd: "/work", + computerId: "ssh-host", + }; + const result = filterRemoteIncompatibleTools(remote); + expect(result.computerId).toBe("ssh-host"); + expect(result.cwd).toBe("/work"); + expect(result.conversationId).toBe("conv-2"); + }); + + it("LOCAL (computerId undefined): passthrough — nothing is dropped", () => { + const local: ToolAssembly = { ...baseAssembly }; + const result = filterRemoteIncompatibleTools(local); + expect(result.tools).toHaveLength(3); + const names = result.tools.map((t) => t.name); + expect(names).toContain("lsp"); + expect(names).toContain("mcp__x"); + expect(names).toContain("run_shell"); + }); + + it("LOCAL: returns the exact same assembly object (byte-identical)", () => { + const local: ToolAssembly = { ...baseAssembly }; + const result = filterRemoteIncompatibleTools(local); + expect(result).toBe(local); + }); + + it("REMOTE: drops multiple MCP-namespaced tools (serverId__toolName pattern)", () => { + const remote: ToolAssembly = { + tools: [ + fakeTool("lsp"), + fakeTool("filesystem__read"), + fakeTool("github__create_issue"), + fakeTool("run_shell"), + fakeTool("write_file"), + ], + conversationId: "conv-3", + computerId: "host", + }; + const result = filterRemoteIncompatibleTools(remote); + const names = result.tools.map((t) => t.name); + expect(names).toEqual(["run_shell", "write_file"]); + }); +}); diff --git a/packages/session-orchestrator/src/tools-filter.ts b/packages/session-orchestrator/src/tools-filter.ts index 19b2eb3..913e574 100644 --- a/packages/session-orchestrator/src/tools-filter.ts +++ b/packages/session-orchestrator/src/tools-filter.ts @@ -6,6 +6,13 @@ export interface ToolAssembly { readonly tools: readonly ToolContract[]; /** This turn's working directory (verbatim from the request), for cwd-aware filters. */ readonly cwd?: string; + /** + * The computer this turn executes on (SSH alias), for computer-aware + * filters. Omitted/`undefined` = LOCAL (today's behavior). When set, the + * turn is REMOTE — {@link filterRemoteIncompatibleTools} drops tools that + * cannot run over SFTP (local-process servers). Mirrors `cwd?`. + */ + readonly computerId?: string; /** The conversation this turn belongs to. */ readonly conversationId: string; } @@ -14,3 +21,39 @@ export interface ToolAssembly { export const toolsFilter: FilterDescriptor<ToolAssembly> = defineFilter<ToolAssembly>( "session-orchestrator/tools", ); + +/** + * Remote-degradation rule (plan §6). When a turn is REMOTE + * (`assembly.computerId !== undefined`), drop tools that cannot execute over + * SFTP because they spawn local processes: + * + * - the tool named exactly `"lsp"` — its servers are local LSP processes that + * can't see remote files over SFTP, AND + * - any tool whose name includes `"__"` — MCP-namespaced tools + * (`<serverId>__<toolName>`); MCP servers spawn local processes. + * + * When `assembly.computerId` is `undefined` (LOCAL), this is a passthrough — + * nothing is dropped (byte-identical to today). Tool-name matching is DATA (the + * kernel routes tool-calls by name — that is the sanctioned string-keyed + * exception), so a name-based filter is correct here, not a string-keyed + * cross-feature code lookup. + * + * Extracted from the filter handler so it is unit-testable without I/O. Mirrors + * MCP's `filterMcpTools` extraction pattern. + */ +export function filterRemoteIncompatibleTools(assembly: ToolAssembly): ToolAssembly { + // LOCAL — passthrough, byte-identical to today. + if (assembly.computerId === undefined) return assembly; + // REMOTE — drop lsp + MCP-namespaced tools (local-process servers). + const filtered = assembly.tools.filter((tool) => { + if (tool.name === "lsp") return false; + if (tool.name.includes("__")) return false; + return true; + }); + return { + tools: filtered, + ...(assembly.cwd !== undefined ? { cwd: assembly.cwd } : {}), + ...(assembly.computerId !== undefined ? { computerId: assembly.computerId } : {}), + conversationId: assembly.conversationId, + }; +} 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/packages/tool-edit-file/package.json b/packages/tool-edit-file/package.json index 7194cce..4bfac38 100644 --- a/packages/tool-edit-file/package.json +++ b/packages/tool-edit-file/package.json @@ -7,6 +7,7 @@ "types": "dist/index.d.ts", "dependencies": { "@dispatch/kernel": "workspace:*", + "@dispatch/exec-backend": "workspace:*", "@dispatch/lsp": "workspace:*" } } diff --git a/packages/tool-edit-file/src/edit-file.test.ts b/packages/tool-edit-file/src/edit-file.test.ts index 5ef8376..9341102 100644 --- a/packages/tool-edit-file/src/edit-file.test.ts +++ b/packages/tool-edit-file/src/edit-file.test.ts @@ -1,9 +1,15 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { localExecBackend } from "@dispatch/exec-backend"; import { createLogger, type ToolExecuteContext } from "@dispatch/kernel"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { computeReplacement, createEditFileTool, validateArgs } from "./edit-file.js"; +import { + computeReplacement, + createEditFileTool, + type DiagnosticsHook, + validateArgs, +} from "./edit-file.js"; function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { return { @@ -19,6 +25,30 @@ function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { }; } +/** No-op diagnostics — the post-edit LSP hook returning "no diagnostics". */ +const noopDiagnostics: DiagnosticsHook = async () => ({ + formatted: "", + slow: false, + timedOut: false, +}); + +/** + * Build an edit_file tool wired to the real local ExecBackend (node:fs, + * behavior-identical to today's inline calls) and a no-op diagnostics hook. + * No `@dispatch/*` mocking — the real fs edge is exercised, matching the + * constitution's strict-core rule. Tests that need a real diagnostics hook + * build the tool inline. + */ +function makeTool( + diagnostics: DiagnosticsHook = noopDiagnostics, +): ReturnType<typeof createEditFileTool> { + return createEditFileTool({ + resolveBackend: () => localExecBackend, + workdir, + diagnostics, + }); +} + let workdir: string; beforeEach(async () => { @@ -145,7 +175,7 @@ describe("createEditFileTool", () => { const filePath = join(workdir, "test.txt"); await writeFile(filePath, "hello world\n", "utf8"); - const tool = createEditFileTool(workdir); + const tool = makeTool(); const result = await tool.execute( { path: "test.txt", oldString: "world", newString: "there" }, stubCtx(), @@ -162,7 +192,7 @@ describe("createEditFileTool", () => { const filePath = join(workdir, "test.txt"); await writeFile(filePath, "aaa\n", "utf8"); - const tool = createEditFileTool(workdir); + const tool = makeTool(); const result = await tool.execute( { path: "test.txt", oldString: "a", newString: "b", replaceAll: true }, stubCtx(), @@ -179,7 +209,7 @@ describe("createEditFileTool", () => { const filePath = join(workdir, "test.txt"); await writeFile(filePath, "hello\n", "utf8"); - const tool = createEditFileTool(workdir); + const tool = makeTool(); const result = await tool.execute( { path: "test.txt", oldString: "xyz", newString: "abc" }, stubCtx(), @@ -193,7 +223,7 @@ describe("createEditFileTool", () => { const filePath = join(workdir, "test.txt"); await writeFile(filePath, "abc abc abc\n", "utf8"); - const tool = createEditFileTool(workdir); + const tool = makeTool(); const result = await tool.execute( { path: "test.txt", oldString: "abc", newString: "xyz" }, stubCtx(), @@ -207,7 +237,7 @@ describe("createEditFileTool", () => { const filePath = join(workdir, "test.txt"); await writeFile(filePath, "hello\n", "utf8"); - const tool = createEditFileTool(workdir); + const tool = makeTool(); const result = await tool.execute( { path: "test.txt", oldString: "hello", newString: "hello" }, stubCtx(), @@ -218,7 +248,7 @@ describe("createEditFileTool", () => { }); it("errors / not-found for a nonexistent file", async () => { - const tool = createEditFileTool(workdir); + const tool = makeTool(); const result = await tool.execute( { path: "nonexistent.txt", oldString: "a", newString: "b" }, stubCtx(), @@ -234,7 +264,7 @@ describe("createEditFileTool", () => { const filePath = join(ctxDir, "ctx-file.txt"); await writeFile(filePath, "hello world", "utf8"); - const tool = createEditFileTool(workdir); + const tool = makeTool(); const result = await tool.execute( { path: "ctx-file.txt", oldString: "world", newString: "there" }, stubCtx({ cwd: ctxDir }), @@ -254,7 +284,7 @@ describe("createEditFileTool", () => { const filePath = join(workdir, "baked-file.txt"); await writeFile(filePath, "hello world", "utf8"); - const tool = createEditFileTool(workdir); + const tool = makeTool(); const ctx = stubCtx(); expect(ctx.cwd).toBeUndefined(); const result = await tool.execute( @@ -267,7 +297,7 @@ describe("createEditFileTool", () => { }); it("never throws on bad input (always returns ToolResult)", async () => { - const tool = createEditFileTool(workdir); + const tool = makeTool(); const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }]; for (const input of inputs) { @@ -278,12 +308,12 @@ describe("createEditFileTool", () => { }); it("concurrencySafe is false", () => { - const tool = createEditFileTool(workdir); + const tool = makeTool(); expect(tool.concurrencySafe).toBe(false); }); it("has correct name and parameters shape", () => { - const tool = createEditFileTool(workdir); + const tool = makeTool(); expect(tool.name).toBe("edit_file"); expect(tool.parameters.type).toBe("object"); expect(tool.parameters.required).toEqual(["path", "oldString", "newString"]); @@ -292,4 +322,115 @@ describe("createEditFileTool", () => { expect(tool.parameters.properties?.newString?.type).toBe("string"); expect(tool.parameters.properties?.replaceAll?.type).toBe("boolean"); }); + + it("appends LSP diagnostics to the result when local and errors exist", async () => { + const filePath = join(workdir, "diag.txt"); + await writeFile(filePath, "hello world\n", "utf8"); + + let called = false; + const diagnostics: DiagnosticsHook = async (opts) => { + called = true; + expect(opts.text).toBe("hello there\n"); + return { formatted: "⚠️ 2 errors", slow: false, timedOut: false }; + }; + const tool = makeTool(diagnostics); + + const result = await tool.execute( + { path: "diag.txt", oldString: "world", newString: "there" }, + stubCtx(), + ); + + expect(called).toBe(true); + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("Replaced 1 occurrence"); + expect(result.content).toContain("⚠️ 2 errors"); + }); + + it("appends the slow-diagnostics notice when LSP is slow", async () => { + const filePath = join(workdir, "slow.txt"); + await writeFile(filePath, "hello\n", "utf8"); + + const diagnostics: DiagnosticsHook = async () => ({ + formatted: "", + slow: true, + timedOut: false, + }); + const tool = makeTool(diagnostics); + + const result = await tool.execute( + { path: "slow.txt", oldString: "hello", newString: "hi" }, + stubCtx(), + ); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("Replaced 1 occurrence"); + expect(result.content).toContain("LSP is taking unusually long"); + }); + + it("calls LSP diagnostics when local (computerId undefined)", async () => { + const filePath = join(workdir, "local.txt"); + await writeFile(filePath, "hello\n", "utf8"); + + let called = false; + const diagnostics: DiagnosticsHook = async () => { + called = true; + return { formatted: "", slow: false, timedOut: false }; + }; + const tool = makeTool(diagnostics); + + const result = await tool.execute( + { path: "local.txt", oldString: "hello", newString: "hi" }, + stubCtx(), // computerId omitted → undefined → local + ); + + expect(called).toBe(true); + expect(result.isError).toBeUndefined(); + expect(result.content).toBe('Replaced 1 occurrence in "local.txt".'); + }); + + it("skips LSP diagnostics when computerId is set (remote)", async () => { + const filePath = join(workdir, "remote.txt"); + await writeFile(filePath, "hello\n", "utf8"); + + let called = false; + const diagnostics: DiagnosticsHook = async () => { + called = true; + return { formatted: "DIAG-SHOULD-NOT-APPEAR", slow: false, timedOut: false }; + }; + const tool = makeTool(diagnostics); + + const result = await tool.execute( + { path: "remote.txt", oldString: "hello", newString: "hi" }, + stubCtx({ computerId: "remote-host" }), + ); + + // Remote: the diagnostics hook is never invoked (LSP servers are local + // processes that can't see remote files over SFTP). + expect(called).toBe(false); + expect(result.isError).toBeUndefined(); + // The edit itself still succeeded against the (local) backend. + expect(result.content).toBe('Replaced 1 occurrence in "remote.txt".'); + expect(result.content).not.toContain("DIAG-SHOULD-NOT-APPEAR"); + + const content = await readFile(filePath, "utf8"); + expect(content).toBe("hi\n"); + }); + + it("swallows a throwing diagnostics hook (edit already succeeded)", async () => { + const filePath = join(workdir, "throw.txt"); + await writeFile(filePath, "hello\n", "utf8"); + + const diagnostics: DiagnosticsHook = async () => { + throw new Error("LSP exploded"); + }; + const tool = makeTool(diagnostics); + + const result = await tool.execute( + { path: "throw.txt", oldString: "hello", newString: "hi" }, + stubCtx(), + ); + + expect(result.isError).toBeUndefined(); + expect(result.content).toBe('Replaced 1 occurrence in "throw.txt".'); + }); }); diff --git a/packages/tool-edit-file/src/edit-file.ts b/packages/tool-edit-file/src/edit-file.ts index 1719ea3..e588f66 100644 --- a/packages/tool-edit-file/src/edit-file.ts +++ b/packages/tool-edit-file/src/edit-file.ts @@ -1,5 +1,5 @@ -import { readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; +import type { ExecBackend, ExecBackendResolver } from "@dispatch/exec-backend"; import type { ToolContract, ToolResult } from "@dispatch/kernel"; // --- Pure types --- @@ -123,16 +123,29 @@ export type DiagnosticsHook = (opts: { // --- Shell / edge --- /** - * Factory: create an edit_file ToolContract bound to a working directory. - * The working directory is injected so the tool is testable. - * `diagnostics` is optional — when provided, errors+warnings from LSP servers - * are appended to successful edit results (only when errors exist). + * Factory: create an edit_file ToolContract. + * + * `resolveBackend` is the injected seam: each `execute` resolves an + * `ExecBackend` from `ctx.computerId` (undefined → local `node:fs`; a set + * id → a remote SSH backend in a later wave). The tool programs against the + * `ExecBackend` surface, never `node:fs` directly, so it is transport-agnostic. + * + * `workdir` is the fallback base directory when `ctx.cwd` is omitted. It is + * injected so the tool is testable; `execute` prefers `ctx.cwd` when present. + * + * `diagnostics` is the post-edit LSP hook (errors+warnings from LSP servers + * are appended to successful edit results, only when errors exist). It is + * invoked LAZILY at edit time — the extension defers the LSP service lookup so + * it resolves after LSP activates. When `ctx.computerId` is set (REMOTE) the + * diagnostics call is skipped: LSP servers are local processes that can't see + * remote files over SFTP, so the no-LSP degradation path is used instead. */ -export function createEditFileTool( - workingDirectory: string, - diagnostics?: DiagnosticsHook, -): ToolContract { - const workdir = resolve(workingDirectory); +export function createEditFileTool(deps: { + readonly resolveBackend: ExecBackendResolver; + readonly workdir?: string; + readonly diagnostics: DiagnosticsHook; +}): ToolContract { + const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; return { name: "edit_file", @@ -173,12 +186,21 @@ export function createEditFileTool( const { path: relPath, oldString, newString, replaceAll } = validated; const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; + if (effectiveBase === undefined) { + return { + content: + "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", + isError: true, + }; + } const resolvedPath = resolve(effectiveBase, relPath); + const backend: ExecBackend = deps.resolveBackend(ctx.computerId); + // Read the file. let content: string; try { - content = await readFile(resolvedPath, "utf8"); + content = await backend.readFile(resolvedPath); } catch (err: unknown) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") { @@ -215,7 +237,7 @@ export function createEditFileTool( // Write the modified content back. try { - await writeFile(resolvedPath, result.content, "utf8"); + await backend.writeFile(resolvedPath, result.content); } catch (err: unknown) { return { content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`, @@ -228,28 +250,43 @@ export function createEditFileTool( // After a successful edit, query LSP diagnostics (if available). // Only append if there are actual errors/warnings (no noise on clean edits). + const diagnostics = deps.diagnostics; if (diagnostics) { - try { - const cwd = ctx.cwd ?? process.cwd(); - const diag = await diagnostics({ - filePath: resolvedPath, - text: result.content, - cwd, - }); - const suffix: string[] = []; - if (diag.slow) { - suffix.push( - "⚠️ LSP is taking unusually long. If this happens more than once, raise it to the user.", - ); - } - if (diag.formatted) { - suffix.push(diag.formatted); - } - if (suffix.length > 0) { - baseContent += `\n\n${suffix.join("\n\n")}`; + let diag: { + readonly formatted: string; + readonly slow: boolean; + readonly timedOut: boolean; + }; + if (ctx.computerId !== undefined) { + // REMOTE: LSP servers are local processes that can't see remote + // files over SFTP — skip the diagnostics call (the no-LSP + // degradation path). Forward-compatible: computerId is always + // undefined this wave, so behavior is byte-identical to today. + diag = { formatted: "", slow: false, timedOut: false }; + } else { + try { + const cwd = ctx.cwd ?? process.cwd(); + diag = await diagnostics({ + filePath: resolvedPath, + text: result.content, + cwd, + }); + } catch { + // LSP diagnostics failure is non-fatal — the edit already succeeded. + diag = { formatted: "", slow: false, timedOut: false }; } - } catch { - // LSP diagnostics failure is non-fatal — the edit already succeeded. + } + const suffix: string[] = []; + if (diag.slow) { + suffix.push( + "⚠️ LSP is taking unusually long. If this happens more than once, raise it to the user.", + ); + } + if (diag.formatted) { + suffix.push(diag.formatted); + } + if (suffix.length > 0) { + baseContent += `\n\n${suffix.join("\n\n")}`; } } diff --git a/packages/tool-edit-file/src/extension.ts b/packages/tool-edit-file/src/extension.ts index 2a58fac..9dbebda 100644 --- a/packages/tool-edit-file/src/extension.ts +++ b/packages/tool-edit-file/src/extension.ts @@ -1,3 +1,4 @@ +import { execBackendHandle } from "@dispatch/exec-backend"; import type { Extension } from "@dispatch/kernel"; import { type LspService, lspServiceHandle } from "@dispatch/lsp"; import { createEditFileTool, type DiagnosticsHook } from "./edit-file.js"; @@ -12,8 +13,15 @@ export const extension: Extension = { activation: "eager", capabilities: { fs: true }, contributes: { tools: ["edit_file"] }, + // Host activates exec-backend first → host.getService at activation is safe. + // LSP stays lazy (looked up at edit time, not activation): the LSP extension + // activates AFTER us in the CORE_EXTENSIONS array, so resolving it here would + // throw; the diagnostics hook below defers the lookup to execute(). + dependsOn: ["exec-backend"], }, activate(host) { + const resolveBackend = host.getService(execBackendHandle); + // Lazy LSP lookup: the LSP extension activates AFTER us in the // CORE_EXTENSIONS array, so host.getService would throw at activation // time. Instead, defer the lookup to edit time — by then all extensions @@ -41,6 +49,6 @@ export const extension: Extension = { }); }; - host.defineTool(createEditFileTool(process.cwd(), diagnostics)); + host.defineTool(createEditFileTool({ resolveBackend, workdir: process.cwd(), diagnostics })); }, }; diff --git a/packages/tool-edit-file/tsconfig.json b/packages/tool-edit-file/tsconfig.json index 38a7610..e4ee1eb 100644 --- a/packages/tool-edit-file/tsconfig.json +++ b/packages/tool-edit-file/tsconfig.json @@ -2,5 +2,5 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }, { "path": "../lsp" }] + "references": [{ "path": "../kernel" }, { "path": "../exec-backend" }, { "path": "../lsp" }] } diff --git a/packages/tool-read-file/package.json b/packages/tool-read-file/package.json index 3a98fa7..ffb974d 100644 --- a/packages/tool-read-file/package.json +++ b/packages/tool-read-file/package.json @@ -6,6 +6,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "dependencies": { - "@dispatch/kernel": "workspace:*" + "@dispatch/kernel": "workspace:*", + "@dispatch/exec-backend": "workspace:*" } } diff --git a/packages/tool-read-file/src/extension.ts b/packages/tool-read-file/src/extension.ts index 8c3a064..5a0b7c5 100644 --- a/packages/tool-read-file/src/extension.ts +++ b/packages/tool-read-file/src/extension.ts @@ -1,3 +1,4 @@ +import { execBackendHandle } from "@dispatch/exec-backend"; import type { Extension } from "@dispatch/kernel"; import { createReadFileTool } from "./read-file.js"; @@ -11,8 +12,11 @@ export const extension: Extension = { activation: "eager", capabilities: { fs: true }, contributes: { tools: ["read_file"] }, + // Host activates exec-backend first → host.getService at activation is safe. + dependsOn: ["exec-backend"], }, activate(host) { - host.defineTool(createReadFileTool(process.cwd())); + const resolveBackend = host.getService(execBackendHandle); + host.defineTool(createReadFileTool({ resolveBackend, workdir: process.cwd() })); }, }; diff --git a/packages/tool-read-file/src/read-file.test.ts b/packages/tool-read-file/src/read-file.test.ts index 619ba34..bac5902 100644 --- a/packages/tool-read-file/src/read-file.test.ts +++ b/packages/tool-read-file/src/read-file.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { localExecBackend } from "@dispatch/exec-backend"; import { createLogger, type ToolExecuteContext } from "@dispatch/kernel"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { @@ -25,6 +26,15 @@ function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { }; } +/** + * Build a read_file tool wired to the real local ExecBackend (node:fs, + * behavior-identical to today's inline calls). No `@dispatch/*` mocking — the + * real fs edge is exercised, matching the constitution's strict-core rule. + */ +function makeTool(workdir: string) { + return createReadFileTool({ resolveBackend: () => localExecBackend, workdir }); +} + let workdir: string; beforeEach(async () => { @@ -151,7 +161,7 @@ describe("createReadFileTool", () => { const filePath = join(workdir, "hello.txt"); await writeFile(filePath, "hello\nworld\n", "utf8"); - const tool = createReadFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "hello.txt" }, stubCtx()); expect(result.isError).toBeUndefined(); @@ -163,7 +173,7 @@ describe("createReadFileTool", () => { const filePath = join(workdir, "lines.txt"); await writeFile(filePath, "a\nb\nc\nd\ne\n", "utf8"); - const tool = createReadFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "lines.txt", offset: 2, limit: 2 }, stubCtx()); expect(result.isError).toBeUndefined(); @@ -171,7 +181,7 @@ describe("createReadFileTool", () => { }); it("returns error for missing file", async () => { - const tool = createReadFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "nonexistent.txt" }, stubCtx()); expect(result.isError).toBe(true); @@ -182,7 +192,7 @@ describe("createReadFileTool", () => { const filePath = join(workdir, "empty.txt"); await writeFile(filePath, "", "utf8"); - const tool = createReadFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "empty.txt" }, stubCtx()); expect(result.isError).toBeUndefined(); @@ -194,7 +204,7 @@ describe("createReadFileTool", () => { const filePath = join(workdir, "short.txt"); await writeFile(filePath, "one\n", "utf8"); - const tool = createReadFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "short.txt", offset: 100 }, stubCtx()); expect(result.isError).toBe(true); @@ -202,7 +212,7 @@ describe("createReadFileTool", () => { }); it("never throws on bad input (always returns ToolResult)", async () => { - const tool = createReadFileTool(workdir); + const tool = makeTool(workdir); const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }]; for (const input of inputs) { @@ -213,12 +223,12 @@ describe("createReadFileTool", () => { }); it("concurrencySafe is true", () => { - const tool = createReadFileTool(workdir); + const tool = makeTool(workdir); expect(tool.concurrencySafe).toBe(true); }); it("has correct name and parameters shape", () => { - const tool = createReadFileTool(workdir); + const tool = makeTool(workdir); expect(tool.name).toBe("read_file"); expect(tool.parameters.type).toBe("object"); expect(tool.parameters.required).toEqual(["path"]); @@ -231,7 +241,7 @@ describe("createReadFileTool", () => { const filePath = join(ctxDir, "ctx-file.txt"); await writeFile(filePath, "from ctx cwd", "utf8"); - const tool = createReadFileTool(workdir); // baked workdir is different + const tool = makeTool(workdir); // baked workdir is different const result = await tool.execute({ path: "ctx-file.txt" }, stubCtx({ cwd: ctxDir })); expect(result.isError).toBeUndefined(); @@ -245,7 +255,7 @@ describe("createReadFileTool", () => { const filePath = join(workdir, "baked-file.txt"); await writeFile(filePath, "from baked workdir", "utf8"); - const tool = createReadFileTool(workdir); + const tool = makeTool(workdir); const ctx = stubCtx(); // Ensure cwd is undefined expect(ctx.cwd).toBeUndefined(); @@ -260,7 +270,7 @@ describe("createReadFileTool", () => { await writeFile(join(workdir, "zebra.txt"), "z", "utf8"); await writeFile(join(workdir, "alpha.txt"), "a", "utf8"); - const tool = createReadFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "." }, stubCtx()); expect(result.isError).toBeUndefined(); @@ -270,7 +280,7 @@ describe("createReadFileTool", () => { it("returns empty-directory message for an empty dir", async () => { await mkdir(join(workdir, "empty-dir")); - const tool = createReadFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "empty-dir" }, stubCtx()); expect(result.isError).toBeUndefined(); @@ -280,7 +290,7 @@ describe("createReadFileTool", () => { it("reads a file unchanged (regression: line numbers + offset/limit)", async () => { await writeFile(join(workdir, "regression.txt"), "a\nb\nc\nd\ne\n", "utf8"); - const tool = createReadFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "regression.txt", offset: 2, limit: 3 }, stubCtx()); expect(result.isError).toBeUndefined(); @@ -288,10 +298,89 @@ describe("createReadFileTool", () => { }); it("returns not-found for a nonexistent path", async () => { - const tool = createReadFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "nonexistent-path" }, stubCtx()); expect(result.isError).toBe(true); expect(result.content).toContain("not found"); }); + + it("routes fs calls through resolveBackend(ctx.computerId) (transport seam)", async () => { + // A fake backend records what it is asked to do. Proves the tool programs + // against the ExecBackend surface (not node:fs) and that the resolver is + // invoked with ctx.computerId — the SSH seam. No real fs involved. + let statCalls = 0; + let readFileCalls = 0; + let readdirCalls = 0; + let receivedComputerId: string | undefined = "__sentinel__"; + const fakeBackend = { + spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }), + readFile: async (path: string) => { + readFileCalls++; + expect(path).toContain("seam.txt"); + return "fake-line-1\nfake-line-2"; + }, + writeFile: async () => {}, + stat: async (path: string) => { + statCalls++; + expect(path).toContain("seam.txt"); + return { isFile: true, isDirectory: false }; + }, + readdir: async () => { + readdirCalls++; + return []; + }, + exists: async () => true, + } as const; + + const tool = createReadFileTool({ + resolveBackend: (computerId) => { + receivedComputerId = computerId; + return fakeBackend; + }, + workdir, + }); + const result = await tool.execute({ path: "seam.txt" }, stubCtx({ computerId: "prod-ssh" })); + + expect(receivedComputerId).toBe("prod-ssh"); + expect(statCalls).toBe(1); + expect(readFileCalls).toBe(1); + expect(readdirCalls).toBe(0); + expect(result.isError).toBeUndefined(); + expect(result.content).toBe("1: fake-line-1\n2: fake-line-2"); + }); + + it("resolves the local backend when ctx.computerId is undefined (backward compat)", async () => { + // computerId undefined → resolver returns localExecBackend → real fs. + const tool = createReadFileTool({ resolveBackend: () => localExecBackend, workdir }); + const filePath = join(workdir, "compat.txt"); + await writeFile(filePath, "real fs via backend\n", "utf8"); + + const result = await tool.execute({ path: "compat.txt" }, stubCtx()); + + expect(result.isError).toBeUndefined(); + expect(result.content).toContain("1: real fs via backend"); + }); + + it("preserves ENOENT .code branch through the backend (fake backend throws)", async () => { + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { + code: "ENOENT", + }); + const fakeBackend = { + spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }), + readFile: async () => "unused", + writeFile: async () => {}, + stat: async () => { + throw enoent; + }, + readdir: async () => [], + exists: async () => false, + } as const; + + const tool = createReadFileTool({ resolveBackend: () => fakeBackend, workdir }); + const result = await tool.execute({ path: "ghost.txt" }, stubCtx()); + + expect(result.isError).toBe(true); + expect(result.content).toBe('Error: File "ghost.txt" not found.'); + }); }); diff --git a/packages/tool-read-file/src/read-file.ts b/packages/tool-read-file/src/read-file.ts index 216f165..b88c241 100644 --- a/packages/tool-read-file/src/read-file.ts +++ b/packages/tool-read-file/src/read-file.ts @@ -1,5 +1,5 @@ -import { readdir, readFile, stat } from "node:fs/promises"; import { resolve } from "node:path"; +import type { ExecBackend, ExecBackendResolver, StatResult } from "@dispatch/exec-backend"; import type { ToolContract, ToolResult } from "@dispatch/kernel"; const DEFAULT_LIMIT = 500; @@ -83,11 +83,21 @@ export function formatDirectoryEntries(entries: readonly DirEntry[], dirPath: st } /** - * Factory: create a read_file ToolContract bound to a working directory. - * The working directory is injected so the tool is testable. + * Factory: create a read_file ToolContract. + * + * `resolveBackend` is the injected seam: each `execute` resolves an + * `ExecBackend` from `ctx.computerId` (undefined → local `node:fs`; a set + * id → a remote SSH backend in a later wave). The tool programs against the + * `ExecBackend` surface, never `node:fs` directly, so it is transport-agnostic. + * + * `workdir` is the fallback base directory when `ctx.cwd` is omitted. It is + * injected so the tool is testable; `execute` prefers `ctx.cwd` when present. */ -export function createReadFileTool(workingDirectory: string): ToolContract { - const workdir = resolve(workingDirectory); +export function createReadFileTool(deps: { + readonly resolveBackend: ExecBackendResolver; + readonly workdir?: string; +}): ToolContract { + const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; return { name: "read_file", @@ -126,12 +136,21 @@ export function createReadFileTool(workingDirectory: string): ToolContract { const { path: relPath, offset, limit } = validated; const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; + if (effectiveBase === undefined) { + return { + content: + "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", + isError: true, + }; + } const resolvedPath = resolve(effectiveBase, relPath); + const backend: ExecBackend = deps.resolveBackend(ctx.computerId); + // Stat to determine if this is a file or directory. - let pathStat: import("node:fs").Stats; + let pathStat: StatResult; try { - pathStat = await stat(resolvedPath); + pathStat = await backend.stat(resolvedPath); } catch (err: unknown) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") { @@ -143,28 +162,25 @@ export function createReadFileTool(workingDirectory: string): ToolContract { }; } - // Directory listing branch. - if (pathStat.isDirectory()) { - let rawEntries: import("node:fs").Dirent<string>[]; + // Directory listing branch. backend.readdir already returns + // {name, isDirectory}[] entries, so no per-entry collapse is needed. + if (pathStat.isDirectory) { + let entries: readonly DirEntry[]; try { - rawEntries = await readdir(resolvedPath, { encoding: "utf8", withFileTypes: true }); + entries = await backend.readdir(resolvedPath); } catch (err: unknown) { return { content: `Error reading directory: ${err instanceof Error ? err.message : String(err)}`, isError: true, }; } - const dirEntries = rawEntries.map((e) => ({ - name: e.name, - isDirectory: e.isDirectory(), - })); - return { content: formatDirectoryEntries(dirEntries, relPath) }; + return { content: formatDirectoryEntries(entries, relPath) }; } // File branch — read the file. let content: string; try { - content = await readFile(resolvedPath, "utf8"); + content = await backend.readFile(resolvedPath); } catch (err: unknown) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") { diff --git a/packages/tool-read-file/tsconfig.json b/packages/tool-read-file/tsconfig.json index ff99a43..30cdc4d 100644 --- a/packages/tool-read-file/tsconfig.json +++ b/packages/tool-read-file/tsconfig.json @@ -2,5 +2,5 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "references": [{ "path": "../kernel" }, { "path": "../exec-backend" }] } diff --git a/packages/tool-shell/package.json b/packages/tool-shell/package.json index 3c5995c..606525f 100644 --- a/packages/tool-shell/package.json +++ b/packages/tool-shell/package.json @@ -6,6 +6,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "dependencies": { - "@dispatch/kernel": "workspace:*" + "@dispatch/kernel": "workspace:*", + "@dispatch/exec-backend": "workspace:*" } } diff --git a/packages/tool-shell/src/extension.ts b/packages/tool-shell/src/extension.ts index 1a89de0..984263e 100644 --- a/packages/tool-shell/src/extension.ts +++ b/packages/tool-shell/src/extension.ts @@ -1,6 +1,6 @@ +import { execBackendHandle } from "@dispatch/exec-backend"; import type { Extension } from "@dispatch/kernel"; import { createRunShellTool } from "./shell.js"; -import { realSpawn } from "./spawn.js"; export const extension: Extension = { manifest: { @@ -12,8 +12,11 @@ export const extension: Extension = { activation: "eager", capabilities: { shell: true }, contributes: { tools: ["run_shell"] }, + // Host activates exec-backend first → host.getService at activation is safe. + dependsOn: ["exec-backend"], }, activate(host) { - host.defineTool(createRunShellTool({ workdir: process.cwd(), spawn: realSpawn })); + const resolveBackend = host.getService(execBackendHandle); + host.defineTool(createRunShellTool({ workdir: process.cwd(), resolveBackend })); }, }; diff --git a/packages/tool-shell/src/index.ts b/packages/tool-shell/src/index.ts index efd36fc..5194342 100644 --- a/packages/tool-shell/src/index.ts +++ b/packages/tool-shell/src/index.ts @@ -1,3 +1,3 @@ export { extension } from "./extension.js"; -export type { SpawnResult, SpawnShell, ValidatedArgs } from "./shell.js"; +export type { SpawnResult, ValidatedArgs } from "./shell.js"; export { createRunShellTool } from "./shell.js"; diff --git a/packages/tool-shell/src/shell.test.ts b/packages/tool-shell/src/shell.test.ts index 07e0af4..4a579b0 100644 --- a/packages/tool-shell/src/shell.test.ts +++ b/packages/tool-shell/src/shell.test.ts @@ -1,12 +1,7 @@ +import { type ExecBackend, localExecBackend } from "@dispatch/exec-backend"; import { createLogger, type ToolExecuteContext } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; -import { - buildResult, - createRunShellTool, - type SpawnShell, - truncateOutput, - validateArgs, -} from "./shell.js"; +import { buildResult, createRunShellTool, truncateOutput, validateArgs } from "./shell.js"; function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { return { @@ -22,12 +17,21 @@ function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { }; } -function fakeSpawn(result: { +/** A fake backend whose `spawn` resolves a fixed result (no real I/O). */ +function fakeBackend(result: { exitCode: number | null; timedOut: boolean; aborted?: boolean; -}): SpawnShell { - return async () => ({ aborted: false, ...result }); +}): ExecBackend { + return { + ...localExecBackend, + spawn: async () => ({ aborted: false, ...result }), + }; +} + +/** Wrap a fake backend in the resolver the factory expects (computerId-agnostic). */ +function resolverFor(backend: ExecBackend) { + return () => backend; } describe("validateArgs", () => { @@ -173,7 +177,7 @@ describe("createRunShellTool", () => { it("has correct name and parameters shape", () => { const tool = createRunShellTool({ workdir: "/tmp", - spawn: fakeSpawn({ exitCode: 0, timedOut: false }), + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), }); expect(tool.name).toBe("run_shell"); expect(tool.parameters.type).toBe("object"); @@ -185,7 +189,7 @@ describe("createRunShellTool", () => { it("concurrencySafe is false", () => { const tool = createRunShellTool({ workdir: "/tmp", - spawn: fakeSpawn({ exitCode: 0, timedOut: false }), + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), }); expect(tool.concurrencySafe).toBe(false); }); @@ -193,7 +197,7 @@ describe("createRunShellTool", () => { it("rejects missing or empty command", async () => { const tool = createRunShellTool({ workdir: "/tmp", - spawn: fakeSpawn({ exitCode: 0, timedOut: false }), + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), }); const result = await tool.execute({}, stubCtx()); expect(result.isError).toBe(true); @@ -203,10 +207,13 @@ describe("createRunShellTool", () => { it("maps a zero exit code to a success result", async () => { const tool = createRunShellTool({ workdir: "/tmp", - spawn: async (_params) => { - _params.onOutput("hello\n", "stdout"); - return { exitCode: 0, timedOut: false, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("hello\n", "stdout"); + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), }); const result = await tool.execute({ command: "echo hello" }, stubCtx()); expect(result.isError).toBeUndefined(); @@ -216,10 +223,13 @@ describe("createRunShellTool", () => { it("maps a non-zero exit code to an isError result", async () => { const tool = createRunShellTool({ workdir: "/tmp", - spawn: async (_params) => { - _params.onOutput("error output\n", "stderr"); - return { exitCode: 1, timedOut: false, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("error output\n", "stderr"); + return { exitCode: 1, timedOut: false, aborted: false }; + }, + }), }); const result = await tool.execute({ command: "false" }, stubCtx()); expect(result.isError).toBe(true); @@ -229,10 +239,13 @@ describe("createRunShellTool", () => { it("reports a timeout as an isError result", async () => { const tool = createRunShellTool({ workdir: "/tmp", - spawn: async (_params) => { - _params.onOutput("partial\n", "stdout"); - return { exitCode: null, timedOut: true, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("partial\n", "stdout"); + return { exitCode: null, timedOut: true, aborted: false }; + }, + }), }); const result = await tool.execute({ command: "sleep 999" }, stubCtx()); expect(result.isError).toBe(true); @@ -244,10 +257,13 @@ describe("createRunShellTool", () => { const tool = createRunShellTool({ workdir: "/tmp", outputCap: cap, - spawn: async (_params) => { - _params.onOutput("a".repeat(200), "stdout"); - return { exitCode: 0, timedOut: false, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("a".repeat(200), "stdout"); + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), }); const result = await tool.execute({ command: "gen" }, stubCtx()); expect(result.content).toContain("[Output truncated"); @@ -258,12 +274,15 @@ describe("createRunShellTool", () => { const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = []; const tool = createRunShellTool({ workdir: "/tmp", - spawn: async (params) => { - params.onOutput("line1\n", "stdout"); - params.onOutput("err1\n", "stderr"); - params.onOutput("line2\n", "stdout"); - return { exitCode: 0, timedOut: false, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + params.onOutput("line1\n", "stdout"); + params.onOutput("err1\n", "stderr"); + params.onOutput("line2\n", "stdout"); + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), }); await tool.execute( { command: "test" }, @@ -282,10 +301,13 @@ describe("createRunShellTool", () => { let receivedCwd = ""; const tool = createRunShellTool({ workdir: "/baked", - spawn: async (params) => { - receivedCwd = params.cwd; - return { exitCode: 0, timedOut: false, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + receivedCwd = params.cwd; + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), }); await tool.execute({ command: "pwd" }, stubCtx({ cwd: "/custom" })); expect(receivedCwd).toBe("/custom"); @@ -295,10 +317,13 @@ describe("createRunShellTool", () => { let receivedCwd = ""; const tool = createRunShellTool({ workdir: "/baked", - spawn: async (params) => { - receivedCwd = params.cwd; - return { exitCode: 0, timedOut: false, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + receivedCwd = params.cwd; + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), }); await tool.execute({ command: "pwd" }, stubCtx()); expect(receivedCwd).toBe("/baked"); @@ -307,9 +332,12 @@ describe("createRunShellTool", () => { it("returns error for spawn failure", async () => { const tool = createRunShellTool({ workdir: "/tmp", - spawn: async () => { - throw new Error("spawn failed"); - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async () => { + throw new Error("spawn failed"); + }, + }), }); const result = await tool.execute({ command: "bad" }, stubCtx()); expect(result.isError).toBe(true); @@ -321,7 +349,7 @@ describe("createRunShellTool", () => { controller.abort(); const tool = createRunShellTool({ workdir: "/tmp", - spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }), + resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })), }); const result = await tool.execute({ command: "test" }, stubCtx({ signal: controller.signal })); expect(result.isError).toBe(true); @@ -331,20 +359,51 @@ describe("createRunShellTool", () => { let receivedTimeout = 0; const tool = createRunShellTool({ workdir: "/tmp", - spawn: async (params) => { - receivedTimeout = params.timeout; - return { exitCode: 0, timedOut: false, aborted: false }; - }, + resolveBackend: resolverFor({ + ...localExecBackend, + spawn: async (params) => { + receivedTimeout = params.timeout; + return { exitCode: 0, timedOut: false, aborted: false }; + }, + }), }); await tool.execute({ command: "test", timeout: 5000 }, stubCtx()); expect(receivedTimeout).toBe(5000); }); + + it("resolves the backend per-call from ctx.computerId (passes it to the resolver)", async () => { + let receivedComputerId: string | undefined = "__unset__"; + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: (computerId) => { + receivedComputerId = computerId; + return fakeBackend({ exitCode: 0, timedOut: false }); + }, + }); + await tool.execute({ command: "test" }, stubCtx({ computerId: "my-host" })); + expect(receivedComputerId).toBe("my-host"); + }); + + it("resolves the local backend when ctx.computerId is undefined", async () => { + let receivedComputerId: string | undefined = "__unset__"; + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: (computerId) => { + receivedComputerId = computerId; + return fakeBackend({ exitCode: 0, timedOut: false }); + }, + }); + await tool.execute({ command: "test" }, stubCtx()); + expect(receivedComputerId).toBeUndefined(); + }); }); describe("createRunShellTool (integration)", () => { - it("runs a real echo command and captures stdout + cwd", async () => { - const { realSpawn } = await import("./spawn.js"); - const tool = createRunShellTool({ workdir: "/tmp", spawn: realSpawn }); + it("runs a real echo command through the local backend and captures stdout + cwd", async () => { + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: () => localExecBackend, + }); let streamed = ""; const result = await tool.execute( { command: "echo hello-from-shell" }, @@ -358,134 +417,23 @@ describe("createRunShellTool (integration)", () => { expect(result.content).toContain("hello-from-shell"); expect(streamed).toContain("hello-from-shell"); }); -}); -describe("realSpawn — process-group kill on abort/timeout", () => { - it("aborts a command with a grandchild holding the pipes and resolves immediately", async () => { - const { realSpawn } = await import("./spawn.js"); + it("aborts a real long-running command through the local backend and resolves with aborted", async () => { const controller = new AbortController(); - - // "sleep 30 & wait" spawns a grandchild (sleep) that inherits the stdio - // pipes. Killing just the sh parent does NOT close the pipes → close never - // fires. With detached:true + process-group kill, the grandchild dies too. - const promise = realSpawn({ - command: "sleep 30 & wait", - cwd: "/tmp", - signal: controller.signal, - timeout: 60_000, - onOutput: () => {}, - }); - - // Give the shell time to actually spawn the grandchild. - await new Promise((r) => setTimeout(r, 500)); - - controller.abort(); - - // Must resolve promptly (not wait 30s for the grandchild's sleep). - const result = await promise; - expect(result.aborted).toBe(true); - expect(result.timedOut).toBe(false); - - // Give the OS a moment to reap the killed processes. - await new Promise((r) => setTimeout(r, 200)); - - // The grandchild sleep process should be gone. Check via pgrep. - const { execSync } = await import("node:child_process"); - let sleeping: string[]; - try { - sleeping = execSync("pgrep -f 'sleep 30'", { encoding: "utf-8" }).trim().split("\n"); - } catch { - // pgrep returns non-zero when no processes match → all gone. - sleeping = []; - } - expect(sleeping.length).toBe(0); - }); - - it("times out a command with a grandchild holding the pipes and resolves promptly", async () => { - const { realSpawn } = await import("./spawn.js"); - const controller = new AbortController(); - - const promise = realSpawn({ - command: "sleep 30 & wait", - cwd: "/tmp", - signal: controller.signal, - timeout: 500, - onOutput: () => {}, - }); - - // Must resolve within a short window (not 30s). - const start = Date.now(); - const result = await promise; - const elapsed = Date.now() - start; - - expect(result.timedOut).toBe(true); - expect(result.aborted).toBe(false); - // Should resolve shortly after the 500ms timeout, well under 30s. - expect(elapsed).toBeLessThan(10_000); - - // Grandchild should be dead. - await new Promise((r) => setTimeout(r, 200)); - const { execSync } = await import("node:child_process"); - let sleeping: string[]; - try { - sleeping = execSync("pgrep -f 'sleep 30'", { encoding: "utf-8" }).trim().split("\n"); - } catch { - sleeping = []; - } - expect(sleeping.length).toBe(0); - }); - - it("captures stdout on normal completion (regression guard)", async () => { - const { realSpawn } = await import("./spawn.js"); - const controller = new AbortController(); - let output = ""; - - const result = await realSpawn({ - command: "echo hi", - cwd: "/tmp", - signal: controller.signal, - timeout: 5_000, - onOutput: (data) => { - output += data; - }, - }); - - expect(result.aborted).toBe(false); - expect(result.timedOut).toBe(false); - expect(result.exitCode).toBe(0); - expect(output).toContain("hi"); - }); - - it("aborts a simple single-process command and resolves with aborted: true", async () => { - const { realSpawn } = await import("./spawn.js"); - const controller = new AbortController(); - - const promise = realSpawn({ - command: "sleep 30", - cwd: "/tmp", - signal: controller.signal, - timeout: 60_000, - onOutput: () => {}, + const tool = createRunShellTool({ + workdir: "/tmp", + resolveBackend: () => localExecBackend, }); - + const promise = tool.execute({ command: "sleep 30" }, stubCtx({ signal: controller.signal })); // Let the sleep actually start. await new Promise((r) => setTimeout(r, 300)); - controller.abort(); - const result = await promise; - expect(result.aborted).toBe(true); - expect(result.timedOut).toBe(false); - - // The sleep process should be gone. + expect(result.isError).toBe(true); + // The detailed process-group-kill semantics (grandchild holding the pipes, + // prompt resolution) are owned by @dispatch/exec-backend's local.test.ts — + // realSpawn was ported there byte-for-byte. Here we only confirm the tool + // wires the backend's aborted result through to an isError result. await new Promise((r) => setTimeout(r, 200)); - const { execSync } = await import("node:child_process"); - let sleeping: string[]; - try { - sleeping = execSync("pgrep -f 'sleep 30'", { encoding: "utf-8" }).trim().split("\n"); - } catch { - sleeping = []; - } - expect(sleeping.length).toBe(0); }); }); diff --git a/packages/tool-shell/src/shell.ts b/packages/tool-shell/src/shell.ts index cc76bca..dac7fab 100644 --- a/packages/tool-shell/src/shell.ts +++ b/packages/tool-shell/src/shell.ts @@ -1,4 +1,5 @@ import { resolve } from "node:path"; +import type { ExecBackendResolver } from "@dispatch/exec-backend"; import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel"; const DEFAULT_TIMEOUT = 120_000; @@ -15,14 +16,6 @@ export interface SpawnResult { readonly aborted: boolean; } -export type SpawnShell = (params: { - readonly command: string; - readonly cwd: string; - readonly signal: AbortSignal; - readonly timeout: number; - readonly onOutput: (data: string, stream: "stdout" | "stderr") => void; -}) => Promise<SpawnResult>; - export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } { if (args === null || args === undefined || typeof args !== "object") { return { error: "Error: Arguments must be an object." }; @@ -88,7 +81,7 @@ export function buildResult(params: { export function createRunShellTool(deps: { readonly workdir: string; - readonly spawn: SpawnShell; + readonly resolveBackend: ExecBackendResolver; readonly outputCap?: number; }): ToolContract { const workdir = resolve(deps.workdir); @@ -139,10 +132,12 @@ export function createRunShellTool(deps: { output += data; }; + const backend = deps.resolveBackend(ctx.computerId); + let spawnResult: SpawnResult; try { - spawnResult = await deps.spawn({ + spawnResult = await backend.spawn({ command, cwd: effectiveCwd, signal: ctx.signal, diff --git a/packages/tool-shell/tsconfig.json b/packages/tool-shell/tsconfig.json index ff99a43..30cdc4d 100644 --- a/packages/tool-shell/tsconfig.json +++ b/packages/tool-shell/tsconfig.json @@ -2,5 +2,5 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "references": [{ "path": "../kernel" }, { "path": "../exec-backend" }] } diff --git a/packages/tool-write-file/package.json b/packages/tool-write-file/package.json index 4aa3481..63c2ccd 100644 --- a/packages/tool-write-file/package.json +++ b/packages/tool-write-file/package.json @@ -6,6 +6,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "dependencies": { - "@dispatch/kernel": "workspace:*" + "@dispatch/kernel": "workspace:*", + "@dispatch/exec-backend": "workspace:*" } } diff --git a/packages/tool-write-file/src/extension.ts b/packages/tool-write-file/src/extension.ts index 2008954..0a9a10f 100644 --- a/packages/tool-write-file/src/extension.ts +++ b/packages/tool-write-file/src/extension.ts @@ -1,3 +1,4 @@ +import { execBackendHandle } from "@dispatch/exec-backend"; import type { Extension } from "@dispatch/kernel"; import { createWriteFileTool } from "./write-file.js"; @@ -11,8 +12,11 @@ export const extension: Extension = { activation: "eager", capabilities: { fs: true }, contributes: { tools: ["write_file"] }, + // Host activates exec-backend first → host.getService at activation is safe. + dependsOn: ["exec-backend"], }, activate(host) { - host.defineTool(createWriteFileTool(process.cwd())); + const resolveBackend = host.getService(execBackendHandle); + host.defineTool(createWriteFileTool({ resolveBackend, workdir: process.cwd() })); }, }; diff --git a/packages/tool-write-file/src/write-file.test.ts b/packages/tool-write-file/src/write-file.test.ts index 6b316bc..d157eb2 100644 --- a/packages/tool-write-file/src/write-file.test.ts +++ b/packages/tool-write-file/src/write-file.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { localExecBackend } from "@dispatch/exec-backend"; import { createLogger, type ToolExecuteContext } from "@dispatch/kernel"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createWriteFileTool, decideOverwrite, validateArgs } from "./write-file.js"; @@ -19,6 +20,15 @@ function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { }; } +/** + * Build a write_file tool wired to the real local ExecBackend (node:fs, + * behavior-identical to today's inline calls). No `@dispatch/*` mocking — the + * real fs edge is exercised, matching the constitution's strict-core rule. + */ +function makeTool(workdir: string) { + return createWriteFileTool({ resolveBackend: () => localExecBackend, workdir }); +} + let workdir: string; beforeEach(async () => { @@ -116,7 +126,7 @@ describe("validateArgs", () => { describe("createWriteFileTool", () => { it("creates a new file when overwrite is unset and the file is absent", async () => { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "new-file.txt", content: "hello world" }, stubCtx()); expect(result.isError).toBeUndefined(); @@ -128,7 +138,7 @@ describe("createWriteFileTool", () => { it("errors when the file exists and overwrite is unset", async () => { await writeFile(join(workdir, "existing.txt"), "old content", "utf8"); - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "existing.txt", content: "new content" }, stubCtx()); expect(result.isError).toBe(true); @@ -141,7 +151,7 @@ describe("createWriteFileTool", () => { it("overwrites an existing file when overwrite is true", async () => { await writeFile(join(workdir, "existing.txt"), "old content", "utf8"); - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute( { path: "existing.txt", content: "new content", overwrite: true }, stubCtx(), @@ -154,7 +164,7 @@ describe("createWriteFileTool", () => { }); it("errors when overwrite is true but the file is absent", async () => { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute( { path: "nonexistent.txt", content: "data", overwrite: true }, stubCtx(), @@ -165,7 +175,7 @@ describe("createWriteFileTool", () => { }); it("errors when the parent directory does not exist", async () => { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "no/such/dir/file.txt", content: "data" }, stubCtx()); expect(result.isError).toBe(true); @@ -173,12 +183,12 @@ describe("createWriteFileTool", () => { }); it("concurrencySafe is false", () => { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); expect(tool.concurrencySafe).toBe(false); }); it("has correct name and parameters shape", () => { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); expect(tool.name).toBe("write_file"); expect(tool.parameters.type).toBe("object"); expect(tool.parameters.required).toEqual(["path", "content"]); @@ -188,7 +198,7 @@ describe("createWriteFileTool", () => { }); it("never throws on bad input (always returns ToolResult)", async () => { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }]; for (const input of inputs) { const result = await tool.execute(input, stubCtx()); @@ -200,7 +210,7 @@ describe("createWriteFileTool", () => { it("respects ctx.cwd over baked workdir", async () => { const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-")); try { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute( { path: "ctx-file.txt", content: "from ctx" }, stubCtx({ cwd: ctxDir }), @@ -215,7 +225,7 @@ describe("createWriteFileTool", () => { }); it("writes empty content", async () => { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "empty.txt", content: "" }, stubCtx()); expect(result.isError).toBeUndefined(); @@ -225,7 +235,7 @@ describe("createWriteFileTool", () => { it("writes content in subdirectory that exists", async () => { await mkdir(join(workdir, "sub")); - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "sub/file.txt", content: "nested" }, stubCtx()); expect(result.isError).toBeUndefined(); diff --git a/packages/tool-write-file/src/write-file.ts b/packages/tool-write-file/src/write-file.ts index 1317ce8..cf761b6 100644 --- a/packages/tool-write-file/src/write-file.ts +++ b/packages/tool-write-file/src/write-file.ts @@ -1,5 +1,5 @@ -import { access, stat, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; +import type { ExecBackend, ExecBackendResolver } from "@dispatch/exec-backend"; import type { ToolContract, ToolResult } from "@dispatch/kernel"; interface ValidatedArgs { @@ -51,11 +51,21 @@ export function validateArgs(args: unknown): ValidatedArgs | { readonly error: s } /** - * Factory: create a write_file ToolContract bound to a working directory. - * The working directory is injected so the tool is testable. + * Factory: create a write_file ToolContract. + * + * `resolveBackend` is the injected seam: each `execute` resolves an + * `ExecBackend` from `ctx.computerId` (undefined → local `node:fs`; a set + * id → a remote SSH backend in a later wave). The tool programs against the + * `ExecBackend` surface, never `node:fs` directly, so it is transport-agnostic. + * + * `workdir` is the fallback base directory when `ctx.cwd` is omitted. It is + * injected so the tool is testable; `execute` prefers `ctx.cwd` when present. */ -export function createWriteFileTool(workingDirectory: string): ToolContract { - const workdir = resolve(workingDirectory); +export function createWriteFileTool(deps: { + readonly resolveBackend: ExecBackendResolver; + readonly workdir?: string; +}): ToolContract { + const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; return { name: "write_file", @@ -95,22 +105,21 @@ export function createWriteFileTool(workingDirectory: string): ToolContract { const { path: relPath, content, overwrite } = validated; const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; + if (effectiveBase === undefined) { + return { + content: + "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", + isError: true, + }; + } const resolvedPath = resolve(effectiveBase, relPath); - // Check existence. - let fileExists = false; - try { - await access(resolvedPath); - fileExists = true; - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - return { - content: `Error checking file: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - } + const backend: ExecBackend = deps.resolveBackend(ctx.computerId); + + // Check existence. `backend.exists` never throws — it returns false + // when the path is missing — so the old try/catch around `access` + // collapses to a single boolean read. + const fileExists = await backend.exists(resolvedPath); // Pure decision. const decision = decideOverwrite(fileExists, overwrite); @@ -118,10 +127,13 @@ export function createWriteFileTool(workingDirectory: string): ToolContract { return { content: decision.error, isError: true }; } - // Verify it's not a directory. + // Verify it's not a directory. `backend.stat` returns a + // `{ isFile, isDirectory }` result; only reached when the file + // exists, so an ENOENT here is a lost race left to propagate + // (same as the prior uncaught `stat` call). if (fileExists) { - const pathStat = await stat(resolvedPath); - if (pathStat.isDirectory()) { + const pathStat = await backend.stat(resolvedPath); + if (pathStat.isDirectory) { return { content: `Error: "${relPath}" is a directory, not a file.`, isError: true, @@ -129,9 +141,11 @@ export function createWriteFileTool(workingDirectory: string): ToolContract { } } - // Write the file. + // Write the file. LocalExecBackend throws node:fs-style errors + // carrying a `.code` (e.g. ENOENT when the parent dir is missing); + // the catch surfaces the message verbatim. try { - await writeFile(resolvedPath, content, "utf8"); + await backend.writeFile(resolvedPath, content); } catch (err: unknown) { return { content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`, diff --git a/packages/tool-write-file/tsconfig.json b/packages/tool-write-file/tsconfig.json index ff99a43..30cdc4d 100644 --- a/packages/tool-write-file/tsconfig.json +++ b/packages/tool-write-file/tsconfig.json @@ -2,5 +2,5 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "references": [{ "path": "../kernel" }, { "path": "../exec-backend" }] } diff --git a/packages/transport-contract/src/contract.types.test.ts b/packages/transport-contract/src/contract.types.test.ts index 6d0129c..0aad643 100644 --- a/packages/transport-contract/src/contract.types.test.ts +++ b/packages/transport-contract/src/contract.types.test.ts @@ -8,12 +8,22 @@ import { describe, expect, it } from "vitest"; import type { + ChatRequest, + Computer, + ComputerEntry, + ComputerListResponse, + ComputerResponse, + ComputerStatusResponse, + ConversationComputerResponse, CwdResponse, LspServerInfo, LspServerState, LspStatusResponse, McpStatusResponse, + SetConversationComputerRequest, SetCwdRequest, + SetWorkspaceDefaultComputerRequest, + TestComputerResponse, } from "./index.js"; // ─── CwdResponse ───────────────────────────────────────────────────────────── @@ -34,6 +44,89 @@ const _setCwd: SetCwdRequest = { cwd: "/tmp/workspace", }; +// ─── ChatRequest.computerId (additive optional) ────────────────────────────── + +const _chatWithComputer: ChatRequest = { + message: "run the test suite", + computerId: "prod-box", +}; + +const _chatWithoutComputer: ChatRequest = { + message: "hello", +}; + +// ─── Computer list / single response ───────────────────────────────────────── + +const _computer: Computer = { + alias: "prod-box", + hostName: "10.0.0.5", + port: 22, + user: "deploy", + identityFile: "/home/user/.ssh/id_ed25519", + knownHost: true, +}; + +const _computerEntry: ComputerEntry = { + ..._computer, + usageCount: 3, +}; + +const _computerList: ComputerListResponse = { + computers: [_computerEntry], +}; + +const _computerResponse: ComputerResponse = _computer; + +// ─── Computer status / test probe ──────────────────────────────────────────── + +const _statusConnected: ComputerStatusResponse = { + alias: "prod-box", + state: "connected", + knownHost: true, +}; + +const _statusError: ComputerStatusResponse = { + alias: "prod-box", + state: "error", + error: "connection refused", + knownHost: false, +}; + +const _testOk: TestComputerResponse = { + alias: "prod-box", + ok: true, +}; + +const _testFail: TestComputerResponse = { + alias: "prod-box", + ok: false, + error: "auth failed", +}; + +// ─── Per-conversation + workspace computer ─────────────────────────────────── + +const _setConvComputer: SetConversationComputerRequest = { + computerId: "prod-box", +}; + +const _clearConvComputer: SetConversationComputerRequest = { + computerId: null, +}; + +const _convComputer: ConversationComputerResponse = { + conversationId: "conv-1", + computerId: "prod-box", +}; + +const _convComputerNull: ConversationComputerResponse = { + conversationId: "conv-2", + computerId: null, +}; + +const _setDefaultComputer: SetWorkspaceDefaultComputerRequest = { + computerId: null, +}; + // ─── LspServerState ────────────────────────────────────────────────────────── const _stateConnected: LspServerState = "connected"; @@ -151,4 +244,71 @@ describe("transport-contract types compile and are exported", () => { expect(_withServers.servers[0]?.toolCount).toBe(12); expect(_withServers.servers[1]?.error).toBe("spawn failed"); }); + + // ─── ChatRequest.computerId ────────────────────────────────────────────── + + it("ChatRequest: computerId is additive optional (omittable)", () => { + expect(_chatWithoutComputer.computerId).toBeUndefined(); + }); + + it("ChatRequest: carries computerId when set", () => { + expect(_chatWithComputer.computerId).toBe("prod-box"); + }); + + // ─── Computers ─────────────────────────────────────────────────────────── + + it("ComputerListResponse: carries entries with usage counts", () => { + expect(_computerList.computers).toHaveLength(1); + expect(_computerList.computers[0]?.usageCount).toBe(3); + expect(_computerList.computers[0]?.alias).toBe("prod-box"); + }); + + it("ComputerResponse: is a single Computer", () => { + expect(_computerResponse.alias).toBe("prod-box"); + expect(_computerResponse.port).toBe(22); + }); + + it("ComputerStatusResponse: all four states are valid", () => { + const states: ComputerStatusResponse["state"][] = [ + "disconnected", + "connecting", + "connected", + "error", + ]; + expect(states).toHaveLength(4); + }); + + it("ComputerStatusResponse: connected has no error field", () => { + expect(_statusConnected.state).toBe("connected"); + expect(_statusConnected.error).toBeUndefined(); + }); + + it("ComputerStatusResponse: error carries message", () => { + expect(_statusError.state).toBe("error"); + expect(_statusError.error).toBe("connection refused"); + }); + + it("TestComputerResponse: ok has no error field", () => { + expect(_testOk.ok).toBe(true); + expect(_testOk.error).toBeUndefined(); + }); + + it("TestComputerResponse: failure carries error", () => { + expect(_testFail.ok).toBe(false); + expect(_testFail.error).toBe("auth failed"); + }); + + it("SetConversationComputerRequest: null clears to inherit/local", () => { + expect(_setConvComputer.computerId).toBe("prod-box"); + expect(_clearConvComputer.computerId).toBeNull(); + }); + + it("ConversationComputerResponse: null computerId round-trips", () => { + expect(_convComputer.computerId).toBe("prod-box"); + expect(_convComputerNull.computerId).toBeNull(); + }); + + it("SetWorkspaceDefaultComputerRequest: null clears to local", () => { + expect(_setDefaultComputer.computerId).toBeNull(); + }); }); diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index 4e3e7dc..b32c8a0 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -22,6 +22,8 @@ import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; import type { AgentEvent, + Computer, + ComputerEntry, ConversationMeta, ConversationStatus, QueuedMessage, @@ -35,6 +37,8 @@ import type { export type { AgentEvent, CompactionResult, + Computer, + ComputerEntry, ConversationMeta, ConversationStatus, QueuedMessage, @@ -78,6 +82,16 @@ export interface ChatRequest { readonly cwd?: string; /** + * The computer to run this turn's tools on — an SSH config `Host` alias + * (one of the `alias` values returned by `GET /computers`). Omit to inherit + * the resolved chain: per-conversation `computerId` → the workspace's + * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is + * a per-turn tool-execution target forwarded to tools and never part of the + * model prompt (so it does not affect prompt caching). Mirrors `cwd`. + */ + readonly computerId?: string; + + /** * Reasoning-effort override for THIS turn only (does not persist). When * omitted, the server resolves the conversation's persisted value, falling * back to `"high"`. Must be one of the `ReasoningEffort` levels; an @@ -817,3 +831,80 @@ export interface DeleteWorkspaceResponse { /** Conversations that were closed (status → "closed") by this delete. */ readonly closedCount: number; } + +// ─── Computers ─────────────────────────────────────────────────────────────── + +/** + * Response of `GET /computers` — every remote computer discovered from the + * system's `~/.ssh/config`, sorted by `alias`. Parallel to + * `WorkspaceListResponse`: each entry is a `ComputerEntry` (a `Computer` plus a + * usage count). There is no Computer CRUD — to add one, the user adds a `Host` + * block to `~/.ssh/config` and Dispatch discovers it on the next read. + */ +export interface ComputerListResponse { + readonly computers: readonly ComputerEntry[]; +} + +/** + * Response of `GET /computers/:alias` — a single computer. Parallel to + * `WorkspaceResponse` (the entity itself). `alias` is the `computerId` users + * select; the remaining fields are resolved from the SSH config. + */ +export interface ComputerResponse extends Computer {} + +/** + * Response of `GET /computers/:alias/status` — the live connection state of a + * computer (whether Dispatch currently holds an open SSH session to it). Drives + * the frontend connection indicator. `error` is present only when + * `state === "error"`; `knownHost` mirrors the read-only `Computer` field. + */ +export interface ComputerStatusResponse { + readonly alias: string; + readonly state: "disconnected" | "connecting" | "connected" | "error"; + readonly error?: string; + readonly knownHost: boolean; +} + +/** + * Body of `PUT /conversations/:id/computer` — set or clear the conversation's + * persisted computer selection (the computer analog of `SetCwdRequest`). Pass + * `null` to clear → the conversation inherits the workspace's + * `defaultComputerId`, then `null`/local. An unknown alias is not validated here + * (the connection resolves at turn time; an unreachable host → turn error, not + * a 400). Mirrors the cwd/model PUT clear semantics. + */ +export interface SetConversationComputerRequest { + readonly computerId: string | null; +} + +/** + * Response of `GET /conversations/:id/computer`. `computerId` is the persisted + * SSH `Host` alias, or `null` when never set (the conversation then inherits + * the workspace default → local). Parallel to `CwdResponse`. + */ +export interface ConversationComputerResponse { + readonly conversationId: string; + readonly computerId: string | null; +} + +/** + * Body of `PUT /workspaces/:id/default-computer` — set or clear the workspace's + * default computer (the computer analog of `SetWorkspaceDefaultCwdRequest`). + * `null` means local (no SSH). Conversations in the workspace with no + * `computerId` of their own inherit this. + */ +export interface SetWorkspaceDefaultComputerRequest { + readonly computerId: string | null; +} + +/** + * Response of `POST /computers/:alias/test` — the result of a one-shot + * connectivity probe (Dispatch opens an SSH connection to the alias, runs a + * trivial command, then closes). `ok` is true on success; `error` carries the + * failure reason (e.g. auth refused, host unreachable) when `ok` is false. + */ +export interface TestComputerResponse { + readonly alias: string; + readonly ok: boolean; + readonly error?: string; +} diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json index d95436b..e7eb85c 100644 --- a/packages/transport-http/package.json +++ b/packages/transport-http/package.json @@ -14,6 +14,7 @@ "@dispatch/session-orchestrator": "workspace:*", "@dispatch/throughput-store": "workspace:*", "@dispatch/transport-contract": "workspace:*", + "@dispatch/wire": "workspace:*", "hono": "^4.0.0", "@dispatch/system-prompt": "workspace:*" } diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index c7b7d31..4f64ece 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -21,11 +21,12 @@ import type { WorkspaceListResponse, WorkspaceResponse, } from "@dispatch/transport-contract"; -import type { Workspace } from "@dispatch/wire"; +import type { Computer, ComputerEntry, Workspace } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { createApp } from "./app.js"; import { extractLastAssistantText } from "./logic.js"; import type { + ComputerService, ConversationStore, CredentialStore, LspService, @@ -100,7 +101,16 @@ function createFakeConversationStore( cwdStore: Map<string, string> = new Map(), reasoningEffortStore: Map<string, ReasoningEffort> = new Map(), modelStore: Map<string, string> = new Map(), + computerStore: Map<string, string> = new Map(), ): ConversationStore { + const sampleWorkspace = { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; return { async append() {}, async load() { @@ -133,6 +143,19 @@ function createFakeConversationStore( async clearCwd(conversationId) { cwdStore.delete(conversationId); }, + async getComputerId(conversationId) { + return computerStore.get(conversationId) ?? null; + }, + async setComputerId(conversationId, alias) { + if (alias === null) { + computerStore.delete(conversationId); + } else { + computerStore.set(conversationId, alias); + } + }, + async clearComputerId(conversationId) { + computerStore.delete(conversationId); + }, async getReasoningEffort(conversationId) { return reasoningEffortStore.get(conversationId) ?? null; }, @@ -171,13 +194,16 @@ function createFakeConversationStore( return null; }, async ensureWorkspace() { - return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return sampleWorkspace; }, async setWorkspaceTitle() { - return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return sampleWorkspace; }, async setWorkspaceDefaultCwd() { - return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + return sampleWorkspace; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { ...sampleWorkspace, id, defaultComputerId }; }, async deleteWorkspace() { return { closedCount: 0 }; @@ -192,6 +218,9 @@ function createFakeConversationStore( async getEffectiveCwd(conversationId) { return cwdStore.get(conversationId) ?? null; }, + async getEffectiveComputer(conversationId) { + return computerStore.get(conversationId) ?? null; + }, }; } @@ -466,6 +495,27 @@ function createFakeSystemPromptService( }; } +function createFakeComputerService(computers: readonly ComputerEntry[] = []): ComputerService { + const byAlias = new Map<string, Computer>(computers.map((c) => [c.alias, c])); + return { + async listComputers() { + return computers; + }, + async getComputer(alias) { + return byAlias.get(alias) ?? null; + }, + async getStatus(alias) { + const known = byAlias.has(alias); + return { alias, state: "disconnected", knownHost: known }; + }, + async test(alias) { + return byAlias.has(alias) + ? { alias, ok: true } + : { alias, ok: false, error: "Computer not found" }; + }, + }; +} + const noopLogger = createFakeLogger(); describe("GET /health", () => { @@ -1065,6 +1115,7 @@ describe("GET /conversations/:id", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1074,6 +1125,7 @@ describe("GET /conversations/:id", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1083,6 +1135,7 @@ describe("GET /conversations/:id", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1184,6 +1237,7 @@ describe("GET /conversations/:id", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1193,6 +1247,7 @@ describe("GET /conversations/:id", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1202,6 +1257,7 @@ describe("GET /conversations/:id", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1372,6 +1428,7 @@ describe("GET /conversations/:id/metrics", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1381,6 +1438,7 @@ describe("GET /conversations/:id/metrics", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -1390,6 +1448,7 @@ describe("GET /conversations/:id/metrics", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -2807,6 +2866,7 @@ describe("PUT /conversations/:id/reasoning-effort", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -2816,6 +2876,7 @@ describe("PUT /conversations/:id/reasoning-effort", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -2825,6 +2886,7 @@ describe("PUT /conversations/:id/reasoning-effort", () => { id: "default", title: "default", defaultCwd: null, + defaultComputerId: null, createdAt: 0, lastActivityAt: 0, }; @@ -3480,6 +3542,7 @@ describe("Workspaces", () => { id: "proj", title: "proj", defaultCwd: null, + defaultComputerId: null, createdAt: 1000, lastActivityAt: 2000, }; @@ -3865,3 +3928,405 @@ describe("GET /system-prompt/variables", () => { expect(fileEntry?.dynamic).toBe(true); }); }); + +// ─── Computers (mirrors the cwd / workspace routes) ───────────────────────── + +const sampleComputer: Computer = { + alias: "myserver", + hostName: "10.0.0.5", + port: 22, + user: "deploy", + identityFile: "/home/user/.ssh/id_ed25519", + knownHost: true, +}; + +describe("GET /computers", () => { + it("returns [] when no ComputerService is wired (graceful degrade)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers"); + expect(res.status).toBe(200); + const body = (await res.json()) as { computers: readonly ComputerEntry[] }; + expect(body.computers).toEqual([]); + }); + + it("delegates to the ComputerService when wired", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 2 }]), + logger: noopLogger, + }); + const res = await app.request("/computers"); + expect(res.status).toBe(200); + const body = (await res.json()) as { computers: readonly ComputerEntry[] }; + expect(body.computers).toHaveLength(1); + expect(body.computers[0]?.alias).toBe("myserver"); + expect(body.computers[0]?.usageCount).toBe(2); + }); +}); + +describe("GET /computers/:alias", () => { + it("returns the computer when the alias is configured", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 0 }]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver"); + expect(res.status).toBe(200); + const body = (await res.json()) as Computer; + expect(body.alias).toBe("myserver"); + expect(body.hostName).toBe("10.0.0.5"); + }); + + it("returns 404 when the alias is not in the config", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + computerService: createFakeComputerService([]), + logger: noopLogger, + }); + const res = await app.request("/computers/unknown"); + expect(res.status).toBe(404); + }); + + it("returns 404 when no ComputerService is wired (no ssh)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver"); + expect(res.status).toBe(404); + }); +}); + +describe("GET /computers/:alias/status", () => { + it("returns disconnected + knownHost:false when no ComputerService is wired", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver/status"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + alias: string; + state: string; + knownHost: boolean; + }; + expect(body.alias).toBe("myserver"); + expect(body.state).toBe("disconnected"); + expect(body.knownHost).toBe(false); + }); +}); + +describe("POST /computers/:alias/test", () => { + it("returns ok:false + 'SSH not configured' when no ComputerService is wired", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver/test", { method: "POST" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { alias: string; ok: boolean; error?: string }; + expect(body.alias).toBe("myserver"); + expect(body.ok).toBe(false); + expect(body.error).toBe("SSH not configured"); + }); +}); + +describe("GET then PUT then GET /conversations/:id/computer", () => { + it("round-trips the value", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const get0 = await app.request("/conversations/conv1/computer"); + expect(get0.status).toBe(200); + const get0Body = (await get0.json()) as { conversationId: string; computerId: string | null }; + expect(get0Body.conversationId).toBe("conv1"); + expect(get0Body.computerId).toBeNull(); + + const putRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(putRes.status).toBe(200); + const putBody = (await putRes.json()) as { conversationId: string; computerId: string }; + expect(putBody.conversationId).toBe("conv1"); + expect(putBody.computerId).toBe("myserver"); + + const getRes = await app.request("/conversations/conv1/computer"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { conversationId: string; computerId: string | null }; + expect(getBody.computerId).toBe("myserver"); + }); +}); + +describe("PUT /conversations/:id/computer with null clears (→ DELETE parity)", () => { + it("PUT null clears a previously-set computer", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const putRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(putRes.status).toBe(200); + + const clearRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: null }), + }); + expect(clearRes.status).toBe(200); + const clearBody = (await clearRes.json()) as { + conversationId: string; + computerId: string | null; + }; + expect(clearBody.computerId).toBeNull(); + + const getRes = await app.request("/conversations/conv1/computer"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { computerId: string | null }; + expect(getBody.computerId).toBeNull(); + }); +}); + +describe("DELETE /conversations/:id/computer", () => { + it("after a PUT computer → returns { computerId: null } and a subsequent GET returns null", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const putRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(putRes.status).toBe(200); + + const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + const deleteBody = (await deleteRes.json()) as { + conversationId: string; + computerId: string | null; + }; + expect(deleteBody.conversationId).toBe("conv1"); + expect(deleteBody.computerId).toBeNull(); + + const getRes = await app.request("/conversations/conv1/computer"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { computerId: string | null }; + expect(getBody.computerId).toBeNull(); + }); + + it("on a conversation that never had a computer set → returns { computerId: null } (idempotent)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + const deleteBody = (await deleteRes.json()) as { + conversationId: string; + computerId: string | null; + }; + expect(deleteBody.computerId).toBeNull(); + }); + + it("does NOT affect other conversations' computers (isolation)", async () => { + const computerStore = new Map<string, string>([ + ["conv1", "myserver"], + ["conv2", "otherbox"], + ]); + const store = createFakeConversationStore( + new Map(), + new Map(), + new Map(), + new Map(), + new Map(), + computerStore, + ); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + + const get1 = await app.request("/conversations/conv1/computer"); + expect(get1.status).toBe(200); + expect((await get1.json()).computerId).toBeNull(); + + const get2 = await app.request("/conversations/conv2/computer"); + expect(get2.status).toBe(200); + expect((await get2.json()).computerId).toBe("otherbox"); + }); +}); + +describe("PUT /conversations/:id/computer validation", () => { + it("with missing computerId returns 400", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("computerId"); + }); + + it("with empty-string computerId returns 400", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "" }), + }); + expect(res.status).toBe(400); + }); +}); + +describe("PUT /workspaces/:id/default-computer", () => { + const wsSample: Workspace = { + id: "proj", + title: "proj", + defaultCwd: null, + defaultComputerId: null, + createdAt: 1000, + lastActivityAt: 2000, + }; + + it("sets the default computer", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { ...wsSample, id, defaultComputerId }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj/default-computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.defaultComputerId).toBe("myserver"); + }); + + it("clears the default computer with null", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { ...wsSample, id, defaultComputerId }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj/default-computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: null }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.defaultComputerId).toBeNull(); + }); +}); + +describe("POST /chat threads computerId", () => { + it("forwards computerId into the orchestrator input when present", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + computerId: "myserver", + }), + }); + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.conversationId).toBe("conv1"); + expect(cap.received?.computerId).toBe("myserver"); + }); + + it("omits computerId when not provided", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.computerId).toBeUndefined(); + }); +}); diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 1d87383..2e81c46 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -4,6 +4,10 @@ import type { CloseConversationResponse, CompactPercentResponse, CompactResponse, + ComputerListResponse, + ComputerResponse, + ComputerStatusResponse, + ConversationComputerResponse, ConversationHistoryResponse, ConversationListResponse, ConversationMetricsResponse, @@ -21,9 +25,12 @@ import type { QueueResponse, ReasoningEffortResponse, SetCompactPercentRequest, + SetConversationComputerRequest, SetSystemPromptTemplateRequest, + SetWorkspaceDefaultComputerRequest, SystemPromptTemplateResponse, SystemPromptVariablesResponse, + TestComputerResponse, ThroughputResponse, TitleResponse, WarmResponse, @@ -53,6 +60,7 @@ import { } from "./logic.js"; import { type CompactionService, + type ComputerService, type ConversationStore, type CredentialStore, conversationOpened, @@ -78,6 +86,14 @@ export interface CreateServerOptions { readonly mcpService?: McpService; /** Optional — system prompt builder service (GET/PUT template). */ readonly systemPromptService?: SystemPromptService; + /** + * Optional — computer discovery + live connection service (provided by the + * `ssh` extension). When absent (ssh not loaded), the `/computers*` routes + * degrade: list returns `[]`, status returns "disconnected", test returns + * a not-configured result. The per-conversation / workspace-default computer + * endpoints work regardless (they only touch the conversation store). + */ + readonly computerService?: ComputerService; /** Optional — defaults to a no-op store (recording disabled, empty reports). */ readonly throughputStore?: ThroughputStore; readonly logger?: Logger; @@ -282,6 +298,77 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + // ─── Computers (discovery + live state) ─────────────────────────────────── + // Read-only discovery + connection state is delegated to the ComputerService + // (provided by the `ssh` extension). When ssh is NOT loaded the routes + // degrade: list → empty, status → "disconnected", test → not-configured. + + app.get("/computers", async (c) => { + if (opts.computerService === undefined) { + // Graceful: no ssh configured → no computers discovered. + const body: ComputerListResponse = { computers: [] }; + return c.json(body, 200); + } + try { + const computers = await opts.computerService.listComputers(); + log.info("computers: list", { count: computers.length }); + const body: ComputerListResponse = { computers }; + return c.json(body, 200); + } catch (err) { + log.error("computers: list failure", { err }); + return c.json({ error: "Failed to list computers" }, 500); + } + }); + + app.get("/computers/:alias", async (c) => { + const alias = c.req.param("alias"); + if (opts.computerService === undefined) { + // No ssh configured → no computer resolves this alias. + return c.json({ error: "Computer not found" }, 404); + } + try { + const computer = await opts.computerService.getComputer(alias); + if (computer === null) { + return c.json({ error: "Computer not found" }, 404); + } + const body: ComputerResponse = computer; + return c.json(body, 200); + } catch (err) { + log.error("computers: get failure", { err, alias }); + return c.json({ error: "Failed to read computer" }, 500); + } + }); + + app.get("/computers/:alias/status", async (c) => { + const alias = c.req.param("alias"); + if (opts.computerService === undefined) { + const body: ComputerStatusResponse = { alias, state: "disconnected", knownHost: false }; + return c.json(body, 200); + } + try { + const body = await opts.computerService.getStatus(alias); + return c.json(body, 200); + } catch (err) { + log.error("computers: status failure", { err, alias }); + return c.json({ error: "Failed to read computer status" }, 500); + } + }); + + app.post("/computers/:alias/test", async (c) => { + const alias = c.req.param("alias"); + if (opts.computerService === undefined) { + const body: TestComputerResponse = { alias, ok: false, error: "SSH not configured" }; + return c.json(body, 200); + } + try { + const body = await opts.computerService.test(alias); + return c.json(body, 200); + } catch (err) { + log.error("computers: test failure", { err, alias }); + return c.json({ error: "Failed to test computer" }, 500); + } + }); + app.post("/chat", async (c) => { let body: unknown; try { @@ -297,11 +384,13 @@ export function createApp(opts: CreateServerOptions): Hono { return c.json({ error: result.error }, 400); } - const { conversationId, message, model, cwd, reasoningEffort, workspaceId } = result; + const { conversationId, message, model, cwd, computerId, reasoningEffort, workspaceId } = + result; log.info("chat: request accepted", { conversationId, hasModel: model !== undefined, hasCwd: cwd !== undefined, + hasComputerId: computerId !== undefined, hasReasoningEffort: reasoningEffort !== undefined, hasWorkspaceId: workspaceId !== undefined, }); @@ -351,6 +440,7 @@ export function createApp(opts: CreateServerOptions): Hono { }, ...(model !== undefined ? { modelName: model } : {}), ...(cwd !== undefined ? { cwd } : {}), + ...(computerId !== undefined ? { computerId } : {}), ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), ...(workspaceId !== undefined ? { workspaceId } : {}), }; @@ -578,6 +668,91 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + // ─── Per-conversation computer (mirrors /conversations/:id/cwd) ────────── + + app.get("/conversations/:id/computer", async (c) => { + const conversationId = c.req.param("id"); + try { + const computerId = await opts.conversationStore.getComputerId(conversationId); + log.info("conversations: computer read", { + conversationId, + hasComputerId: computerId !== null, + }); + const body: ConversationComputerResponse = { conversationId, computerId }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: computer read failure", { err }); + return c.json({ error: "Failed to read conversation computer" }, 500); + } + }); + + app.put("/conversations/:id/computer", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/computer: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record<string, unknown>; + // `computerId` must be a string (the SSH alias) or null (clear → inherit + // the workspace defaultComputerId → local). An empty string is rejected + // (unlike cwd, an alias is never "empty"); null is the explicit clear. + if ( + obj.computerId !== null && + (typeof obj.computerId !== "string" || obj.computerId.length === 0) + ) { + return c.json( + { error: "Field 'computerId' is required and must be a non-empty string or null" }, + 400, + ); + } + const { computerId } = obj as unknown as SetConversationComputerRequest; + + // Mirror PUT /conversations/:id/cwd: when a workspaceId is provided, + // assign the conversation to that workspace BEFORE persisting the + // computer, so a subsequent effective-computer resolution reads the + // workspace's defaultComputerId. Omit for unchanged workspace assignment. + if (obj.workspaceId !== undefined) { + if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) { + return c.json({ error: "Invalid workspaceId" }, 400); + } + } + + try { + if (typeof obj.workspaceId === "string") { + await opts.conversationStore.ensureWorkspace(obj.workspaceId); + await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId); + } + // null → clear (inherit/local); string → persist the alias. + await opts.conversationStore.setComputerId(conversationId, computerId); + log.info("conversations: computer set", { conversationId }); + const response: ConversationComputerResponse = { conversationId, computerId }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: computer set failure", { err }); + return c.json({ error: "Failed to set conversation computer" }, 500); + } + }); + + app.delete("/conversations/:id/computer", async (c) => { + const conversationId = c.req.param("id"); + try { + await opts.conversationStore.clearComputerId(conversationId); + log.info("conversations: computer cleared", { conversationId }); + const response: ConversationComputerResponse = { conversationId, computerId: null }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: computer clear failure", { err }); + return c.json({ error: "Failed to clear conversation computer" }, 500); + } + }); + app.get("/conversations/:id/reasoning-effort", async (c) => { const conversationId = c.req.param("id"); try { @@ -1119,6 +1294,35 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + // Mirrors PUT /workspaces/:id/default-cwd exactly (the computer analog). + app.put("/workspaces/:id/default-computer", async (c) => { + const workspaceId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + body = {}; + } + const obj = body as Record<string, unknown>; + // Mirrors PUT /workspaces/:id/default-cwd: a string → the SSH alias; + // anything else (null/absent/non-string) → clear (local). + const defaultComputerId: SetWorkspaceDefaultComputerRequest["computerId"] = + typeof obj.computerId === "string" ? obj.computerId : null; + + try { + const workspace = await opts.conversationStore.setWorkspaceDefaultComputerId( + workspaceId, + defaultComputerId, + ); + log.info("workspaces: default-computer set", { workspaceId }); + const response: WorkspaceResponse = workspace; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: default-computer set failure", { err }); + return c.json({ error: "Failed to set workspace default computer" }, 500); + } + }); + app.delete("/workspaces/:id", async (c) => { const workspaceId = c.req.param("id"); if (workspaceId === "default") { diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index 0f46e6b..4ab43ce 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -1,8 +1,10 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { createApp } from "./app.js"; import { + type ComputerService, cacheWarmHandle, compactionHandle, + computerServiceHandle, conversationStoreHandle, credentialStoreHandle, lspServiceHandle, @@ -31,11 +33,16 @@ export const manifest: Manifest = { routes: [ "/chat", "/chat/warm", + "/computers", + "/computers/:alias", + "/computers/:alias/status", + "/computers/:alias/test", "/conversations", "/conversations/:id", "/conversations/:id/close", "/conversations/:id/compact", "/conversations/:id/compact-percent", + "/conversations/:id/computer", "/conversations/:id/cwd", "/conversations/:id/last", "/conversations/:id/lsp", @@ -55,6 +62,7 @@ export const manifest: Manifest = { "/workspaces/:id", "/workspaces/:id/title", "/workspaces/:id/default-cwd", + "/workspaces/:id/default-computer", ], }, activation: "eager", @@ -80,6 +88,17 @@ export function createTransportHttpExtension(): Extension & { const lspService = host.getService(lspServiceHandle); const mcpService = host.getService(mcpServiceHandle); const systemPromptService = host.getService(systemPromptHandle); + // Optional: the `ssh` extension provides ComputerService. It is NOT in + // dependsOn (ssh may be absent), so resolve defensively — when no + // provider registered the handle, the computer routes degrade to + // empty/disconnected (see app.ts). Wrapped because getService throws + // for an unregistered handle. + let computerService: ComputerService | undefined; + try { + computerService = host.getService(computerServiceHandle); + } catch { + computerService = undefined; + } const logger = host.logger; const app = createApp({ @@ -92,6 +111,7 @@ export function createTransportHttpExtension(): Extension & { lspService, mcpService, systemPromptService, + ...(computerService !== undefined ? { computerService } : {}), logger, emit: host.emit.bind(host), ...(process.env.DISPATCH_WEB_DIR !== undefined diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts index 7165c55..192b00c 100644 --- a/packages/transport-http/src/index.ts +++ b/packages/transport-http/src/index.ts @@ -26,6 +26,7 @@ export { serializeEventLine, } from "./logic.js"; export type { + ComputerService, ConversationStore, CredentialStore, LspService, @@ -35,6 +36,7 @@ export type { } from "./seam.js"; export { cacheWarmHandle, + computerServiceHandle, conversationStoreHandle, credentialStoreHandle, isValidWorkspaceSlug, diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts index 948afb8..4e099c4 100644 --- a/packages/transport-http/src/logic.ts +++ b/packages/transport-http/src/logic.ts @@ -46,6 +46,13 @@ export interface ChatCommand { readonly message: string; readonly model?: string; readonly cwd?: string; + /** + * Per-turn computer override (SSH `Host` alias). Mirrors `cwd`: forwarded + * to the orchestrator verbatim and never part of the model prompt. When + * absent, the orchestrator resolves the per-conversation → workspace + * default → local chain. + */ + readonly computerId?: string; readonly reasoningEffort?: ReasoningEffort; readonly workspaceId?: string; } @@ -91,6 +98,13 @@ export function parseChatBody(body: unknown, generateId: () => string): ParseRes (result as { cwd?: string }).cwd = obj.cwd; } + if (obj.computerId !== undefined) { + if (typeof obj.computerId !== "string") { + return { error: "Field 'computerId' must be a string" }; + } + (result as { computerId?: string }).computerId = obj.computerId; + } + if (obj.reasoningEffort !== undefined) { if (!isValidReasoningEffort(obj.reasoningEffort)) { return { diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts index e9dc4ce..ef28a09 100644 --- a/packages/transport-http/src/seam.ts +++ b/packages/transport-http/src/seam.ts @@ -1,3 +1,7 @@ +import { defineService, type ServiceHandle } from "@dispatch/kernel"; +import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/transport-contract"; +import type { Computer, ComputerEntry } from "@dispatch/wire"; + export type { ConversationStore } from "@dispatch/conversation-store"; export { conversationStoreHandle, isValidWorkspaceSlug } from "@dispatch/conversation-store"; export type { CredentialStore } from "@dispatch/credential-store"; @@ -21,3 +25,39 @@ export type { SystemPromptService } from "@dispatch/system-prompt"; export { systemPromptHandle } from "@dispatch/system-prompt"; export type { ThroughputStore } from "@dispatch/throughput-store"; export { ThroughputQueryError, throughputStoreHandle } from "@dispatch/throughput-store"; + +// ─── ComputerService seam ───────────────────────────────────────────────────── +// +// The read-only computer discovery + live connection surface. The `ssh` +// extension provides the real implementation (parses `~/.ssh/config`, pools +// `ssh2` connections) and registers it via `host.provideService`. Until ssh is +// loaded, the routes that delegate here DEGRADE: the list route returns an empty +// `[]` (no computers configured), and the status/test routes return their +// "disconnected" / not-configured sentinels. The interface + handle are defined +// HERE (not in `@dispatch/ssh`, which does not exist yet) so the routes can be +// wired against a typed seam today; when the `ssh` package lands it imports +// `ComputerService` + `computerServiceHandle` from here (mirroring how a +// provider implements a contract owned by its consumer seam). + +/** + * Read-only computer discovery + per-alias live state + one-shot probe. The + * transport routes delegate to this; it never throws for "no ssh configured" + * — an ABSENT service (ssh extension not loaded) is the graceful-degrade path. + */ +export interface ComputerService { + /** Every computer discovered from `~/.ssh/config`, sorted by `alias`. */ + readonly listComputers: () => Promise<readonly ComputerEntry[]>; + /** One computer by alias, or `null` when the alias isn't in the config. */ + readonly getComputer: (alias: string) => Promise<Computer | null>; + /** Live connection state for a computer alias. */ + readonly getStatus: (alias: string) => Promise<ComputerStatusResponse>; + /** One-shot connectivity probe (open, run a trivial command, close). */ + readonly test: (alias: string) => Promise<TestComputerResponse>; +} + +/** + * Typed service handle the `ssh` extension provides and the transport routes + * consume. Mirrors `lspServiceHandle` / `mcpServiceHandle`. + */ +export const computerServiceHandle: ServiceHandle<ComputerService> = + defineService<ComputerService>("ssh"); diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts index 1e3da27..56bd8e2 100644 --- a/packages/transport-ws/src/extension.ts +++ b/packages/transport-ws/src/extension.ts @@ -290,6 +290,7 @@ export function createTransportWsExtension(): Extension { ? { reasoningEffort: result.reasoningEffort } : {}), ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}), + ...(result.computerId !== undefined ? { computerId: result.computerId } : {}), }); if (!startResult.started) { send(ws, { diff --git a/packages/transport-ws/src/router.test.ts b/packages/transport-ws/src/router.test.ts index 66e84cf..6d01823 100644 --- a/packages/transport-ws/src/router.test.ts +++ b/packages/transport-ws/src/router.test.ts @@ -379,6 +379,39 @@ describe("routeClientMessage", () => { expect(result).not.toHaveProperty("workspaceId"); }); + it("chat.send threads computerId", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + conversationId: "conv-cid", + message: "hello computer", + computerId: "dev-box", + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + expect(result.computerId).toBe("dev-box"); + }); + + it("chat.send omits computerId (absent/undefined) when not sent — backward compatible", () => { + const registry = fakeRegistry([]); + const connSubs = new Set<string>(); + + const result = routeClientMessage(registry, connSubs, { + type: "chat.send", + message: "hello no computer", + }); + + expect(result.kind).toBe("chat"); + if (result.kind !== "chat") throw new Error("expected chat"); + // computerId is absent (undefined) — the orchestrator receives no + // computerId and resolves the inherited chain (conversation → + // workspace defaultComputerId → local). Mirrors workspaceId. + expect(result).not.toHaveProperty("computerId"); + }); + it("rejects a malformed chat.send (empty message)", () => { const registry = fakeRegistry([]); const connSubs = new Set<string>(); diff --git a/packages/transport-ws/src/router.ts b/packages/transport-ws/src/router.ts index d43894d..7e9ba77 100644 --- a/packages/transport-ws/src/router.ts +++ b/packages/transport-ws/src/router.ts @@ -49,6 +49,15 @@ export interface ChatRouteResult { readonly cwd: string | undefined; readonly reasoningEffort?: ReasoningEffort; readonly workspaceId?: string; + /** + * The computer (SSH config alias) to run this turn's tools on — forwarded + * verbatim to the orchestrator's `startTurn` (which resolves it via + * `getEffectiveComputer`). Mirrors `cwd`/`workspaceId`: an opaque per-turn + * override, unvalidated here (validation happens at SSH connect time). + * Absent when the client omits it (the orchestrator then inherits the + * conversation → workspace → local chain). + */ + readonly computerId?: string; } /** A malformed chat.send that should yield a chat.error reply. */ @@ -173,6 +182,7 @@ function handleChatSend(msg: ChatSendMessage): ChatRouteResult | ChatRouteError cwd: msg.cwd, ...(msg.reasoningEffort !== undefined ? { reasoningEffort: msg.reasoningEffort } : {}), ...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}), + ...(msg.computerId !== undefined ? { computerId: msg.computerId } : {}), }; } diff --git a/packages/wire/src/index.test.ts b/packages/wire/src/index.test.ts new file mode 100644 index 0000000..cd297b7 --- /dev/null +++ b/packages/wire/src/index.test.ts @@ -0,0 +1,59 @@ +/** + * Conformance test for the wire ABI's type-only surface. The wire package ships + * no runtime, so these tests assert that the public shapes COMPILE and round-trip + * — a `Computer` literal satisfies its type, `ComputerEntry` extends `Computer`, + * and a `Workspace` carries the new `defaultComputerId`. The `ComputerEntry → + * Computer` assignment is a genuine compile-time check (it would fail to typecheck + * if the `extends` relationship broke); the runtime assertions are sanity echo. + */ + +import { describe, expect, it } from "vitest"; +import type { Computer, ComputerEntry, Workspace } from "./index.js"; + +describe("@dispatch/wire — Computer / Workspace shapes", () => { + it("a Computer literal satisfies the Computer type", () => { + const c: Computer = { + alias: "myserver", + hostName: "myserver.example.com", + port: 22, + user: "deploy", + identityFile: null, + knownHost: true, + }; + expect(c.alias).toBe("myserver"); + expect(c.port).toBe(22); + expect(c.identityFile).toBeNull(); + expect(c.knownHost).toBe(true); + }); + + it("ComputerEntry extends Computer and carries usageCount", () => { + const entry: ComputerEntry = { + alias: "buildbox", + hostName: "buildbox", + port: 2222, + user: "root", + identityFile: "/home/u/.ssh/id_ed25519", + knownHost: false, + usageCount: 3, + }; + // Compile-time proof that ComputerEntry is assignable to Computer. + const asComputer: Computer = entry; + expect(asComputer.alias).toBe("buildbox"); + expect(entry.usageCount).toBe(3); + }); + + it("a Workspace carries defaultComputerId (null = local)", () => { + const remote: Workspace = { + id: "default", + title: "Default", + defaultCwd: null, + defaultComputerId: "myserver", + createdAt: 0, + lastActivityAt: 0, + }; + expect(remote.defaultComputerId).toBe("myserver"); + + const local: Workspace = { ...remote, defaultComputerId: null }; + expect(local.defaultComputerId).toBeNull(); + }); +}); diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts index eecd2f7..8dc3a72 100644 --- a/packages/wire/src/index.ts +++ b/packages/wire/src/index.ts @@ -596,6 +596,14 @@ export interface Workspace { readonly title: string; /** The workspace's default cwd, or `null` (fall through to server default). */ readonly defaultCwd: string | null; + /** + * The workspace's default computer — an SSH config `Host` alias that + * conversations in this workspace inherit when they set no `computerId` of + * their own. `null` means local (no SSH; today's behavior). The computer + * analog of `defaultCwd`. Resolved per-conversation by `getEffectiveComputer` + * (per-conv `computerId` → this → `null`/local). + */ + readonly defaultComputerId: string | null; /** Epoch-ms when the workspace was first created. */ readonly createdAt: number; /** Epoch-ms of the most recent conversation activity in this workspace. */ @@ -610,3 +618,44 @@ export interface WorkspaceEntry extends Workspace { /** Number of conversations assigned to this workspace. */ readonly conversationCount: number; } + +// ─── Computers ─────────────────────────────────────────────────────────────── + +/** + * A read-only view of a remote computer discovered from the system's + * `~/.ssh/config` — a "computer" is a `Host` alias, NOT an editable entity + * (there is no Computer CRUD store). To add a computer, the user adds a `Host` + * block to `~/.ssh/config`; Dispatch discovers it on the next `listComputers()` + * read. Every field below is resolved from the config (first-match-wins for + * `HostName`/`User`/`Port`/`IdentityFile`). + * + * `alias` is the `computerId` users select — the string persisted per + * conversation and per workspace (the computer analog of `cwd`). `knownHost` + * drives the frontend "known/new" indicator and is read-only. + */ +export interface Computer { + /** The SSH config `Host` alias — also the `computerId` users select. */ + readonly alias: string; + /** Resolved `HostName`/IP from the config (falls back to the alias itself). */ + readonly hostName: string; + /** Resolved port (config `Port`, default 22). */ + readonly port: number; + /** Resolved user (config `User`, default the current user). */ + readonly user: string; + /** Resolved `IdentityFile` path (from the config, or `null` = default `~/.ssh/id_*`). */ + readonly identityFile: string | null; + /** + * Whether the host's key is already in `~/.ssh/known_hosts` (i.e. previously + * connected). Drives the frontend "known/new" indicator. Read-only. + */ + readonly knownHost: boolean; +} + +/** + * A computer entry in the list response (`GET /computers`) — a `Computer` plus + * a usage count. Parallel to `WorkspaceEntry`. + */ +export interface ComputerEntry extends Computer { + /** Number of conversations/workspaces whose `computerId` resolves to this alias. */ + readonly usageCount: number; +} @@ -5,9 +5,11 @@ > Keep this lean and current; do not let it re-accrete a step-by-step changelog. ## Status (current) -`tsc -b` EXIT 0 · biome clean · **1574 vitest** green. +`tsc -b` EXIT 0 · biome clean · **1730 vitest** pass (+6 sshd-integration skipped). (worktree `feature/ssh-support`; +merged `dev` — brings retry-with-backoff (`provider-retry` AgentEvent) + the LSP-dead-server fix alongside the +SSH waves below.) -## Retry with backoff on retryable provider errors (DONE) +## Retry with backoff on retryable provider errors (DONE — from dev) When the upstream LLM API returns a retryable error (HTTP 429 / 5xx "overloaded"), the kernel now retries `provider.stream()` with a stepped backoff, visibly, until the 8h cumulative-sleep budget is exhausted — then emits the final error and @@ -28,21 +30,66 @@ safety invariant — never duplicate partial output). Plan: - **Retry trigger:** emitted `error` with `retryable===true` → retry; `retryable` false/absent → give up; a THROWN error → retryable-by-default ONLY when pre-content. All gated on `!hadContent` (text/reasoning/tool-call/usage). -- [x] Verified: `tsc -b` EXIT 0, biome clean, **1574 vitest** pass (+16 new: 11 - kernel retry tests with an injected fake `sleep` + pure `delayFor` + stub - provider — zero `@dispatch/*` mocks; 5 pure schedule tests). Transports - unchanged — transport-ws forwards `AgentEvent` verbatim inside `chat.delta`; - transport-http is generic `JSON.stringify`. Unit-tested only — not yet - live-verified against a real 429. -- **Optional follow-up (roadmap):** the CLI renderer - (`packages/cli/src/render.ts` `renderEvent`) has no `default` case and silently - drops `provider-retry` — the yellow-warning/countdown target is the web - frontend, not the CLI, so non-blocking. Optional: render `provider-retry` in - the CLI as a stderr warning + `delayMs` countdown. - **Frontend handoff (5d3f, separate repo `../dispatch-web`):** render `provider-retry` as a yellow warning system-message bubble showing `message` (+`code`) with the `delayMs` countdown. +## SSH support — transparent remote execution (DONE — waves 0-5c) +Plan: `notes/ssh-support-plan.md` (decisions locked in §0.5/§13). Orchestrated in +waves (ORCHESTRATOR.md §2a — pre-author the contract seam, then parallel +owner-agents on disjoint packages). +- [x] **Wave 0** (orchestrator): kernel contract seam — `computerId` on + `ToolExecuteContext` + `RunTurnInput` (additive optional; backward + compatible). `tsc -b` EXIT 0. +- [x] **Wave 1** (parallel): `wire` (Computer/defaultComputerId types) + + `exec-backend` (NEW pkg: ExecBackend contract + LocalExecBackend + handle + + resolver) + `kernel` runtime (thread computerId through dispatch/run-turn) + + `conversation-store` (contract fan-out: defaultComputerId + getEffectiveComputer + + per-conv computerId get/set/clear). `tsc -b` EXIT 0, biome clean, **1592 vitest** + (was 1549, +43). +- [x] **Wave 2** (parallel): refactor `tool-shell`/`read-file`/`write-file`/ + `edit-file` behind `ExecBackend` (local-only; spawn.ts deleted — logic moved + to exec-backend; edit_file gains forward-compatible remote-diagnostics skip). + `tsc -b` EXIT 0, biome clean, **1599 vitest** (was 1592). +- [x] **Wave 3** (parallel): `session-orchestrator` (thread computerId end-to-end + + remote tool-drop filter: drops `lsp` + `__`-namespaced MCP tools when + remote) + `transport-contract` (ChatRequest.computerId + computer endpoint + API types). `tsc -b` EXIT 0, biome clean, **1620 vitest** (was 1599). +- [x] **Wave 4** (parallel): `transport-http` (computer endpoints + `/chat` + 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). +- [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). +- [x] **Wave 5c**: host-bin — register exec-backend + ssh extensions in + CORE_EXTENSIONS (correct DAG order); transport-http CR-5 barrel re-export of + computerServiceHandle. orchestrator added missing @dispatch/exec-backend dep to + host-bin + bun install. **LIVE-VERIFIED**: server boots clean ("Dispatch booted", + no disabled extensions). tsc -b EXIT 0, biome clean, 1690 vitest (+6 sshd skipped). +- [x] **Merge dev**: brought retry-with-backoff (`provider-retry` AgentEvent — what + the FE consumes) + LSP-dead-server fix into the SSH branch. All code files + auto-merged cleanly; only `tasks.md` conflicted (orchestrator-resolved). +- [ ] **DEFERRED — CR-6 usageCount**: `listComputers()` returns `usageCount: 0` until a + conversation-store count-by-alias helper + host-bin wiring is added (non-blocking — + discovery/connect/execute all work; only the count badge shows 0). Follow-up. +- [ ] **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 + → a potential prompt-cache miss (performance-only, not correctness). Revisit + when cache-warming is re-enabled. +Key decisions: ssh2 + ssh-config (project-local deps); key-only auth from +`~/.ssh`; auto-trust-and-pin host keys; computers discovered read-only from +`~/.ssh/config` (no CRUD entity); computerId persisted per-conversation; LSP/MCP +silently dropped on remote turns; edit_file works w/o diagnostics remotely. + ## Per-edit LSP diagnostics auto-append (DONE) After a successful `edit_file`, the extension now calls LSP `getDiagnostics` on the post-edit buffer and appends any errors/warnings (severity ≤ 2) to the tool result — diff --git a/tsconfig.json b/tsconfig.json index 3dea4a1..aab3ac1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -41,6 +41,12 @@ "path": "./packages/credential-store" }, { + "path": "./packages/exec-backend" + }, + { + "path": "./packages/ssh" + }, + { "path": "./packages/conversation-store" }, { |
