summaryrefslogtreecommitdiffhomepage
path: root/packages/ssh/src/backend.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 16:19:58 +0900
committerAdam Malczewski <[email protected]>2026-06-25 16:19:58 +0900
commit652010b6c054b69d813e8a2c724d6db039242119 (patch)
treeb70e4dd1591381a6c017c0c02f9502474b1d612d /packages/ssh/src/backend.ts
parent350b9b8e247bb1c24f49a884fdade18e44b115eb (diff)
downloaddispatch-652010b6c054b69d813e8a2c724d6db039242119.tar.gz
dispatch-652010b6c054b69d813e8a2c724d6db039242119.zip
feat(ssh): wave 5b — the ssh package (remote ExecBackend over ssh2)
Wave 5b of transparent SSH support. NEW standard extension @dispatch/ssh makes remote execution actually work over SSH, transparently. ssh2 verified to run under Bun (load-bearing decision #1 confirmed: connects to local sshd :22 + execs). - config.ts: ~/.ssh/config reader via ssh-config -> Computer[]/ComputerEntry[] (read-only discovery; resolves hostName/port/user/identityFile/knownHost). - hostkey.ts: known_hosts auto-trust-and-pin (present->verify/reject-on-mismatch, absent->accept+append; the accept-new analog). - errors.ts: pure ssh2/SFTP -> node:fs-style .code error mapping (so tools' existing ENOENT branches work unchanged). - pool.ts: SshConnectionPool (per-alias ssh2.Client, lazy connect, keep-alive, idle reap ~15m); key-only auth from ~/.ssh (config IdentityFile or default id_ed25519/id_rsa); no agent-forwarding, no PTY. - backend.ts: SshExecBackend implements ExecBackend (spawn via client.exec with shell-quoted cwd; fs via SFTP). - service.ts + extension.ts: activate provides BOTH handles the other units consume — remoteExecBackendFactoryHandle (exec-backend: computerId->SshExecBackend) AND computerServiceHandle (transport-http: listComputers/getComputer/getStatus/test). - orchestrator: added packages/ssh to root tsconfig.json refs + bun install. Tests: 45 pass + 6 sshd-integration skipped (it.skipIf(!process.env.SSH_TEST_HOST)). Verified: tsc -b EXIT 0, biome clean, 1690 vitest pass (was 1641, +49). CRs for wave 5c: host-bin registration; CR-5 transport-http barrel re-export; CR-6 usageCount wiring (deferred-ok, defaults to 0). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
Diffstat (limited to 'packages/ssh/src/backend.ts')
-rw-r--r--packages/ssh/src/backend.ts200
1 files changed, 200 insertions, 0 deletions
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, "'\\''")}'`;
+}