summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--bun.lock58
-rw-r--r--notes/ssh-support-plan.md988
-rw-r--r--packages/conversation-store/src/keys.ts4
-rw-r--r--packages/conversation-store/src/store-workspace.test.ts241
-rw-r--r--packages/conversation-store/src/store.ts162
-rw-r--r--packages/exec-backend/package.json11
-rw-r--r--packages/exec-backend/src/backend.test.ts63
-rw-r--r--packages/exec-backend/src/backend.ts78
-rw-r--r--packages/exec-backend/src/extension.test.ts121
-rw-r--r--packages/exec-backend/src/extension.ts73
-rw-r--r--packages/exec-backend/src/index.ts8
-rw-r--r--packages/exec-backend/src/local.test.ts199
-rw-r--r--packages/exec-backend/src/local.ts (renamed from packages/tool-shell/src/spawn.ts)69
-rw-r--r--packages/exec-backend/src/service.ts46
-rw-r--r--packages/exec-backend/tsconfig.json6
-rw-r--r--packages/host-bin/package.json2
-rw-r--r--packages/host-bin/src/main.ts14
-rw-r--r--packages/host-bin/tsconfig.json6
-rw-r--r--packages/kernel/src/contracts/runtime.ts11
-rw-r--r--packages/kernel/src/contracts/tool.ts12
-rw-r--r--packages/kernel/src/runtime/dispatch.ts4
-rw-r--r--packages/kernel/src/runtime/run-turn.test.ts65
-rw-r--r--packages/kernel/src/runtime/run-turn.ts3
-rw-r--r--packages/mcp/src/extension.test.ts22
-rw-r--r--packages/mcp/src/extension.ts1
-rw-r--r--packages/session-orchestrator/src/extension.ts14
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts288
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts67
-rw-r--r--packages/session-orchestrator/src/queue.test.ts48
-rw-r--r--packages/session-orchestrator/src/tools-filter.test.ts75
-rw-r--r--packages/session-orchestrator/src/tools-filter.ts43
-rw-r--r--packages/ssh/package.json20
-rw-r--r--packages/ssh/src/backend.ts200
-rw-r--r--packages/ssh/src/config.test.ts162
-rw-r--r--packages/ssh/src/config.ts164
-rw-r--r--packages/ssh/src/errors.test.ts90
-rw-r--r--packages/ssh/src/errors.ts111
-rw-r--r--packages/ssh/src/extension.ts124
-rw-r--r--packages/ssh/src/hostkey.test.ts105
-rw-r--r--packages/ssh/src/hostkey.ts148
-rw-r--r--packages/ssh/src/index.ts24
-rw-r--r--packages/ssh/src/integration.test.ts184
-rw-r--r--packages/ssh/src/pool.ts458
-rw-r--r--packages/ssh/src/service.ts164
-rw-r--r--packages/ssh/tsconfig.json12
-rw-r--r--packages/tool-edit-file/package.json1
-rw-r--r--packages/tool-edit-file/src/edit-file.test.ts165
-rw-r--r--packages/tool-edit-file/src/edit-file.ts101
-rw-r--r--packages/tool-edit-file/src/extension.ts10
-rw-r--r--packages/tool-edit-file/tsconfig.json2
-rw-r--r--packages/tool-read-file/package.json3
-rw-r--r--packages/tool-read-file/src/extension.ts6
-rw-r--r--packages/tool-read-file/src/read-file.test.ts117
-rw-r--r--packages/tool-read-file/src/read-file.ts50
-rw-r--r--packages/tool-read-file/tsconfig.json2
-rw-r--r--packages/tool-shell/package.json3
-rw-r--r--packages/tool-shell/src/extension.ts7
-rw-r--r--packages/tool-shell/src/index.ts2
-rw-r--r--packages/tool-shell/src/shell.test.ts298
-rw-r--r--packages/tool-shell/src/shell.ts15
-rw-r--r--packages/tool-shell/tsconfig.json2
-rw-r--r--packages/tool-write-file/package.json3
-rw-r--r--packages/tool-write-file/src/extension.ts6
-rw-r--r--packages/tool-write-file/src/write-file.test.ts32
-rw-r--r--packages/tool-write-file/src/write-file.ts62
-rw-r--r--packages/tool-write-file/tsconfig.json2
-rw-r--r--packages/transport-contract/src/contract.types.test.ts160
-rw-r--r--packages/transport-contract/src/index.ts91
-rw-r--r--packages/transport-http/package.json1
-rw-r--r--packages/transport-http/src/app.test.ts473
-rw-r--r--packages/transport-http/src/app.ts206
-rw-r--r--packages/transport-http/src/extension.ts20
-rw-r--r--packages/transport-http/src/index.ts2
-rw-r--r--packages/transport-http/src/logic.ts14
-rw-r--r--packages/transport-http/src/seam.ts40
-rw-r--r--packages/transport-ws/src/extension.ts1
-rw-r--r--packages/transport-ws/src/router.test.ts33
-rw-r--r--packages/transport-ws/src/router.ts10
-rw-r--r--packages/wire/src/index.test.ts59
-rw-r--r--packages/wire/src/index.ts49
-rw-r--r--tasks.md73
-rw-r--r--tsconfig.json6
82 files changed, 6535 insertions, 360 deletions
diff --git a/bun.lock b/bun.lock
index f7c0418..b669d88 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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;
+}
diff --git a/tasks.md b/tasks.md
index 0a1c747..d1d55e7 100644
--- a/tasks.md
+++ b/tasks.md
@@ -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"
},
{