From 2cc9ddfb590dc60557bba3ed76a6c4639df5f596 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 25 Jun 2026 21:45:58 +0900 Subject: feat(ssh): discover computers from ~/.ssh/known_hosts + remote system-prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two improvements to the SSH support feature: 1. KNOWN_HOSTS DISCOVERY (packages/ssh): Computers are now auto-discovered from ~/.ssh/known_hosts (every hostname you've ever connected to) in ADDITION to ~/.ssh/config (explicit Host aliases). Config entries take precedence (full params); known_hosts entries get defaulted params (User=defaultUser, IdentityFile=null→pool probes default keys, Port from [host]:port or 22, knownHost=true). Zero-config — no ~/.ssh/config file needed; hosts just appear. Reject list: dispatch.toml [ssh].reject = [...] (glob patterns like github.com, *.ts.net) filters noise from the catalog. Read from both the global ~/.config/dispatch/dispatch.toml and the project dispatch.toml. Parsed with Bun.TOML.parse (zero deps). Only filters discovery (catalog); specific lookups (getComputer/getStatus/test/connect) ignore the reject list (it's a visibility filter, not access control). New pure functions: parseKnownHosts(), isRejected(), globMatch(). +26 tests. tsc EXIT 0, biome clean, 1756 tests pass. 2. REMOTE SYSTEM-PROMPT AWARENESS (packages/system-prompt): When a conversation has a computerId set (remote turn), the system prompt now resolves system:os, system:hostname, git:branch/git:status, and file: reads against the REMOTE machine — not the local host. Previously the prompt always said 'Arch Linux (WSL)' + local hostname even when the agent was connected to a remote Artix Linux machine. The ResolverAdapters' hostname()/platform() are now async (so a remote adapter can run 'hostname'/'uname -s' over SSH). The system-prompt extension builds remote adapters from the ExecBackend (readFile→SFTP, spawn→SSH exec). Cache invalidation now checks computerId (switching computers rebuilds the prompt). The compaction path also threads computerId. @dispatch/system-prompt now depends on @dispatch/exec-backend. --- packages/ssh/src/extension.ts | 88 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 14 deletions(-) (limited to 'packages/ssh/src/extension.ts') diff --git a/packages/ssh/src/extension.ts b/packages/ssh/src/extension.ts index f63a84f..3294908 100644 --- a/packages/ssh/src/extension.ts +++ b/packages/ssh/src/extension.ts @@ -21,7 +21,10 @@ 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 { + resolveComputer as resolveComputerFromConfig, + type SshConfigResolveEnv, +} from "./config.js"; import { createSshService, type SshServiceDeps } from "./service.js"; export const manifest: Manifest = { @@ -67,6 +70,47 @@ export function makeSshExtension(deps: SshServiceDeps): Extension { // ─── 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 @@ -82,6 +126,28 @@ export function createSshServiceDeps(hostLogger: Logger): SshServiceDeps { 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(), @@ -89,6 +155,7 @@ export function createSshServiceDeps(hostLogger: Logger): SshServiceDeps { knownHostsPath, readConfigText, readFileText, + readRejectPatterns, pathExists: async (path: string) => access(path) .then(() => true) @@ -96,20 +163,13 @@ export function createSshServiceDeps(hostLogger: Logger): SshServiceDeps { 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. + // 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 [configText, knownHostsText] = await Promise.all([ - readConfigText().catch(async () => ""), - readFileText(knownHostsPath).catch(async () => ""), - ]); - return resolveComputerFromConfig(alias, { - configText, - knownHostsText, - defaultUser, - homeDir: homedir(), - }); + const env = await readEnv(); + return resolveComputerFromConfig(alias, env); }, }; } -- cgit v1.2.3