diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 21:45:58 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 21:45:58 +0900 |
| commit | 2cc9ddfb590dc60557bba3ed76a6c4639df5f596 (patch) | |
| tree | b0ced1ecb5f899e6a2b835d41603c4040a49bbce /packages/ssh/src/extension.ts | |
| parent | 087ce142247637bb10351ab7815144b720836153 (diff) | |
| download | dispatch-2cc9ddfb590dc60557bba3ed76a6c4639df5f596.tar.gz dispatch-2cc9ddfb590dc60557bba3ed76a6c4639df5f596.zip | |
feat(ssh): discover computers from ~/.ssh/known_hosts + remote system-prompt
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.
Diffstat (limited to 'packages/ssh/src/extension.ts')
| -rw-r--r-- | packages/ssh/src/extension.ts | 88 |
1 files changed, 74 insertions, 14 deletions
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<readonly string[]> { + const patterns: string[] = []; + const seen = new Set<string>(); + + 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<string> => 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<readonly string[]> => 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<SshConfigResolveEnv> { + 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); }, }; } |
