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/config.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/config.ts')
| -rw-r--r-- | packages/ssh/src/config.ts | 240 |
1 files changed, 220 insertions, 20 deletions
diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts index 6116125..7c4daa3 100644 --- a/packages/ssh/src/config.ts +++ b/packages/ssh/src/config.ts @@ -1,12 +1,28 @@ /** - * ~/.ssh/config reader — pure discovery of `Computer`s from an SSH config. + * Computer discovery — pure resolution of `Computer`s from SSH config + + * `known_hosts`. * - * 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). + * Per decision #4: computers are DISCOVERED read-only (no CRUD). Discovery + * has TWO sources, in precedence order: + * + * 1. **`~/.ssh/config`** — named (non-wildcard) `Host` aliases with full + * connection params (HostName, Port, User, IdentityFile). These are the + * "explicitly configured" computers. + * 2. **`~/.ssh/known_hosts`** — every hostname you've ever connected to. These + * are the "discovered" computers, added with defaulted params (User= + * defaultUser, IdentityFile=null → the pool probes default keys, Port + * parsed from `[host]:port` notation or 22). A hostname already present in + * config is NOT duplicated (config takes precedence). + * + * A **reject list** (glob patterns from `dispatch.toml` `[ssh].reject`) + * filters the final list — e.g. `github.com`, `*.ts.net`, raw IPs — so noise + * from `known_hosts` doesn't pollute the computer catalog. + * + * This module is the PURE half: it takes the config TEXT + known_hosts TEXT + + * reject patterns (the I/O of reading the files lives in the shell) and + * resolves each 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. @@ -18,21 +34,35 @@ import { isKnownHost } from "./hostkey.js"; /** Injected environment for the pure resolver (no ambient process access). */ export interface SshConfigResolveEnv { - /** The raw `~/.ssh/config` text. */ + /** The raw `~/.ssh/config` text (may be empty — no file). */ readonly configText: string; - /** The raw `~/.ssh/known_hosts` text (drives `knownHost`). */ + /** The raw `~/.ssh/known_hosts` text (drives `knownHost` + discovery). */ 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; + /** + * Glob patterns (e.g. `github.com`, `*.ts.net`) to exclude from the + * computer catalog. Sourced from `dispatch.toml` `[ssh].reject`. Absent + * or empty → no filtering. + */ + readonly rejectPatterns?: readonly 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`. + * Discover `Computer`s from `~/.ssh/config` + `~/.ssh/known_hosts`, returning + * one per unique hostname (config aliases first, then known_hosts entries not + * already in config), filtered by the reject list. + * + * **Sources, in precedence order:** + * 1. `~/.ssh/config` — named (non-wildcard) `Host` aliases with full params. + * 2. `~/.ssh/known_hosts` — hostnames you've connected to before, with + * defaulted params (User=defaultUser, IdentityFile=null, Port from + * `[host]:port` or 22). Not duplicated when already in config. + * + * Wildcard hosts (`*`, `?.example.com`) are NOT computers. The reject list + * (glob patterns) filters the final set. Sorted by `alias`. * * `knownHost` reflects whether the resolved HostName appears in * `~/.ssh/known_hosts` (drives the FE "known/new" indicator). @@ -43,6 +73,7 @@ export function resolveComputers(env: SshConfigResolveEnv): readonly Computer[] const config = SSHConfig.parse(env.configText); const computers: Computer[] = []; + // Source 1: ~/.ssh/config — full-param aliases. for (const line of config) { // Only `Host` sections define aliases; `Match`/standalone directives aren't // selectable computers. @@ -55,26 +86,66 @@ export function resolveComputers(env: SshConfigResolveEnv): readonly 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 configAliases = new Set(computers.map((c) => c.alias)); + + // Source 2: ~/.ssh/known_hosts — discovered hostnames not already in config. + for (const { hostname, port } of parseKnownHosts(env.knownHostsText)) { + if (configAliases.has(hostname)) continue; // config takes precedence + computers.push({ + alias: hostname, + hostName: hostname, + port, + user: env.defaultUser, + identityFile: null, // pool probes default keys (~/.ssh/id_ed25519, etc.) + knownHost: true, // it's in known_hosts by definition + }); + } + + // De-dup by alias (a host may be listed in multiple `Host` lines or appear + // in both config + known_hosts; first wins), 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; + + // Filter out rejected hostnames (glob patterns from dispatch.toml). + const filtered = unique.filter((c) => !isRejected(c.alias, env.rejectPatterns)); + filtered.sort((a, b) => (a.alias < b.alias ? -1 : a.alias > b.alias ? 1 : 0)); + return filtered; } /** * 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. + * known computer). Checks `~/.ssh/config` first (full params), then + * `~/.ssh/known_hosts` (defaulted params). Does NOT apply the reject list — + * a specific lookup always resolves (reject is a discovery/catalog filter, + * not access control). + * Pure. `compute()` applies OpenSSH first-match-wins + wildcards. */ export function resolveComputer(alias: string, env: SshConfigResolveEnv): Computer | null { + // Source 1: ~/.ssh/config. const config = SSHConfig.parse(env.configText); - if (!aliasExistsAsNamedHost(config, alias)) return null; - return resolveOne(config, alias, env); + if (aliasExistsAsNamedHost(config, alias)) { + return resolveOne(config, alias, env); + } + + // Source 2: ~/.ssh/known_hosts (defaulted params). + const knownHosts = parseKnownHosts(env.knownHostsText); + const entry = knownHosts.find((h) => h.hostname === alias); + if (entry !== undefined) { + return { + alias: entry.hostname, + hostName: entry.hostname, + port: entry.port, + user: env.defaultUser, + identityFile: null, + knownHost: true, + }; + } + + return null; } /** Resolve one alias using a parsed config. Pure. */ @@ -162,3 +233,132 @@ export function knownHostToken(hostName: string, port: number): string { if (port === 22) return hostName; return `[${hostName}]:${port}`; } + +// ─── known_hosts discovery ───────────────────────────────────────────────── + +/** Find the index of the first space or tab, or -1 if none. */ +function findSpace(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; +} + +/** A hostname + port extracted from a `~/.ssh/known_hosts` line. */ +export interface KnownHostEntry { + readonly hostname: string; + readonly port: number; +} + +/** + * Parse `~/.ssh/known_hosts` and return one entry per unique hostname with + * its port. Skips hashed entries (`|1|...` — can't reverse the hash), comment + * lines, and entries with no parseable hostname. Deduplicates by hostname + * (first port wins — so a host with both `host` and `[host]:2222` entries + * keeps whichever appears first). + * + * Each known_hosts line is: `hostmarkers keytype key [comment]` + * where `hostmarkers` is comma-separated, each marker being: + * - `hostname` (e.g. `myserver`) → port 22 + * - `[hostname]:port` (e.g. `[myserver]:2222`) + * - `|1|hash|hash` (hashed — skipped) + * + * Pure: `knownHostsText` → `readonly KnownHostEntry[]`. + */ +export function parseKnownHosts(knownHostsText: string): readonly KnownHostEntry[] { + const entries: KnownHostEntry[] = []; + const seen = new Set<string>(); + + for (const raw of knownHostsText.split("\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + + // First whitespace-delimited field is the host markers (comma-list). + const firstSpace = findSpace(line); + const firstField = firstSpace === -1 ? line : line.slice(0, firstSpace); + + for (const marker of firstField.split(",")) { + const trimmed = marker.trim(); + if (trimmed === "" || trimmed.startsWith("|")) continue; // skip hashed + + let hostname: string; + let port = 22; + + if (trimmed.startsWith("[")) { + // [hostname]:port or [hostname] + const bracketEnd = trimmed.indexOf("]"); + if (bracketEnd === -1) continue; // malformed + hostname = trimmed.slice(1, bracketEnd); + const afterBracket = trimmed.slice(bracketEnd + 1); + if (afterBracket.startsWith(":")) { + const n = Number.parseInt(afterBracket.slice(1), 10); + if (Number.isFinite(n) && n > 0) port = n; + } + } else { + hostname = trimmed; + } + + // Dedup by hostname — first port wins (a host with entries on + // multiple ports gets one computer; use config for a specific port). + if (seen.has(hostname)) continue; + seen.add(hostname); + entries.push({ hostname, port }); + } + } + + return entries; +} + +// ─── reject-list glob matching ───────────────────────────────────────────── + +/** + * Test whether a hostname should be rejected (hidden from the catalog). + * Patterns support `*` (any chars) and `?` (single char), matching SSH's + * own wildcard semantics. A bare hostname pattern matches exactly. + * + * Pure: `alias` + `patterns` → `boolean`. + */ +export function isRejected(alias: string, patterns?: readonly string[]): boolean { + if (patterns === undefined || patterns.length === 0) return false; + return patterns.some((p) => globMatch(p, alias)); +} + +/** + * Minimal glob matcher: `*` matches any sequence (including empty), `?` + * matches a single character. Case-insensitive (hostnames are). All other + * characters match literally. + */ +function globMatch(pattern: string, input: string): boolean { + const p = pattern.toLowerCase(); + const s = input.toLowerCase(); + return globMatchImpl(p, 0, s, 0); +} + +function globMatchImpl(p: string, pi: number, s: string, si: number): boolean { + while (pi < p.length) { + const pc = p[pi]; + if (pc === "*") { + // Skip consecutive * (they're equivalent to one). + while (pi + 1 < p.length && p[pi + 1] === "*") pi++; + // If * is the last char, match everything remaining. + if (pi + 1 === p.length) return true; + // Try to match the rest of the pattern at every position in s. + for (let i = si; i <= s.length; i++) { + if (globMatchImpl(p, pi + 1, s, i)) return true; + } + return false; + } + if (pc === "?") { + if (si >= s.length) return false; + pi++; + si++; + continue; + } + // Literal char. + if (si >= s.length || p[pi] !== s[si]) return false; + pi++; + si++; + } + return si === s.length; +} |
