/** * 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, type SshConfigResolveEnv, } 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) | 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) ───────────────────── /** Path candidates for `dispatch.toml` (global + project-local). */ function dispatchTomlPaths(): readonly string[] { const paths = [ join(homedir(), ".config", "dispatch", "dispatch.toml"), // global join(process.cwd(), "dispatch.toml"), // project-local ]; return paths; } /** * Read `[ssh].reject` glob patterns from `dispatch.toml` (global + project). * Merges both lists (deduped). Returns `[]` when no file or no `[ssh]` section. * Uses `Bun.TOML.parse` (Bun's built-in TOML parser — zero deps). */ async function readRejectPatternsImpl(): Promise { const patterns: string[] = []; const seen = new Set(); for (const path of dispatchTomlPaths()) { try { const text = await readFile(path, "utf8"); const parsed = Bun.TOML.parse(text) as { ssh?: { reject?: readonly string[] }; }; const list = parsed.ssh?.reject; if (list !== undefined) { for (const p of list) { if (typeof p === "string" && !seen.has(p)) { seen.add(p); patterns.push(p); } } } } catch { // File missing or parse error → skip silently. } } return patterns; } /** * 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 => readFile(configPath, "utf8"); const readFileText = async (path: string): Promise => readFile(path, "utf8"); const defaultUser = process.env.USER ?? homedir().split("/").pop() ?? "root"; /** Read the reject list fresh from `dispatch.toml` on each call. */ const readRejectPatterns = async (): Promise => readRejectPatternsImpl(); /** * Build the resolve env (config + known_hosts + reject patterns) — shared by * the service methods and the pool's resolveComputer dep. */ async function readEnv(): Promise { const [configText, knownHostsText, rejectPatterns] = await Promise.all([ readConfigText().catch(async () => ""), readFileText(knownHostsPath).catch(async () => ""), readRejectPatterns(), ]); const base: SshConfigResolveEnv = { configText, knownHostsText, defaultUser, homeDir: homedir(), }; return rejectPatterns.length > 0 ? { ...base, rejectPatterns } : base; } return { logger: hostLogger, homeDir: homedir(), defaultUser, knownHostsPath, readConfigText, readFileText, readRejectPatterns, 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 + // known_hosts. Reads fresh on each call (a Host block or known_hosts // entry added between turns is picked up). Does NOT apply the reject // list — the pool needs to connect even to hosts hidden from the catalog. resolveComputer: async (alias: string) => { const env = await readEnv(); return resolveComputerFromConfig(alias, env); }, }; } /** Production extension: real `node:fs` + real `ssh2`. */ export const extension: Extension = { manifest, activate(host: HostAPI) { const deps = createSshServiceDeps(host.logger); makeSshExtension(deps).activate(host); }, };