summaryrefslogtreecommitdiffhomepage
path: root/packages/ssh/src/config.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 21:45:58 +0900
committerAdam Malczewski <[email protected]>2026-06-25 21:45:58 +0900
commit2cc9ddfb590dc60557bba3ed76a6c4639df5f596 (patch)
treeb0ced1ecb5f899e6a2b835d41603c4040a49bbce /packages/ssh/src/config.ts
parent087ce142247637bb10351ab7815144b720836153 (diff)
downloaddispatch-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.ts240
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;
+}