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.test.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.test.ts')
| -rw-r--r-- | packages/ssh/src/config.test.ts | 254 |
1 files changed, 254 insertions, 0 deletions
diff --git a/packages/ssh/src/config.test.ts b/packages/ssh/src/config.test.ts index e1ae05b..04ccdc5 100644 --- a/packages/ssh/src/config.test.ts +++ b/packages/ssh/src/config.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + isRejected, knownHostToken, + parseKnownHosts, resolveComputer, resolveComputers, type SshConfigResolveEnv, @@ -160,3 +162,255 @@ describe("knownHostToken", () => { expect(knownHostToken("host.example", 2222)).toBe("[host.example]:2222"); }); }); + +// ─── known_hosts discovery ───────────────────────────────────────────────── + +const KNOWN_HOSTS_FIXTURE = [ + "# comment line", + "arch-razer ssh-ed25519 AAAA1", + "xenifse1 ssh-ed25519 AAAA2", + "xenifse1 ssh-rsa AAAA3", + "xenifse1 ecdsa-sha2-nistp256 AAAA4", + "[xenifse1]:2222 ssh-ed25519 AAAA5", + "github.com ssh-ed25519 AAAA6", + "|1|+W4/jC7U8rJwiEC2m0KvG2A7uAA=|rWwV8p5J1FfR3k= ssh-ed25519 AAAA7", + "localhost ssh-ed25519 AAAA8", + "[localhost]:2222 ssh-ed25519 AAAA9", +].join("\n"); + +describe("parseKnownHosts", () => { + it("extracts hostnames with default port 22 from bare entries", () => { + const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE); + const arch = entries.find((e) => e.hostname === "arch-razer"); + expect(arch).toEqual({ hostname: "arch-razer", port: 22 }); + }); + + it("extracts hostname + port from [host]:port notation", () => { + const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE); + // xenifse1 has entries on port 22 (first) and port 2222 — first wins. + const xen = entries.find((e) => e.hostname === "xenifse1"); + expect(xen?.port).toBe(22); + // localhost appears as port 22 (first) and [localhost]:2222 — first wins. + const local = entries.find((e) => e.hostname === "localhost"); + expect(local?.port).toBe(22); + }); + + it("deduplicates by hostname (first port wins)", () => { + const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE); + const xenCount = entries.filter((e) => e.hostname === "xenifse1").length; + expect(xenCount).toBe(1); + // Multiple key types (ed25519, rsa, ecdsa) for the same host:port = 1 entry. + expect(entries).toHaveLength(4); // arch-razer, xenifse1, github.com, localhost + }); + + it("skips hashed entries (|1|...)", () => { + const entries = parseKnownHosts(KNOWN_HOSTS_FIXTURE); + expect(entries.find((e) => e.hostname.startsWith("|"))).toBeUndefined(); + }); + + it("skips comment lines and empty lines", () => { + const entries = parseKnownHosts("\n# comment\n\n \nhost1 ssh-ed25519 AAA\n"); + expect(entries).toHaveLength(1); + expect(entries[0]?.hostname).toBe("host1"); + }); + + it("returns empty for empty text", () => { + expect(parseKnownHosts("")).toEqual([]); + expect(parseKnownHosts(" \n\n ")).toEqual([]); + }); + + it("handles comma-separated host markers", () => { + const entries = parseKnownHosts("hostA,hostB ssh-ed25519 AAA\n"); + expect(entries.map((e) => e.hostname)).toEqual(["hostA", "hostB"]); + }); + + it("preserves non-default port when the host only has [host]:port entries", () => { + const entries = parseKnownHosts("[specialhost]:9999 ssh-ed25519 AAA\n"); + expect(entries[0]).toEqual({ hostname: "specialhost", port: 9999 }); + }); +}); + +// ─── known_hosts discovery merged into resolveComputers ─────────────────── + +describe("resolveComputers with known_hosts discovery", () => { + it("discovers computers from known_hosts when no ~/.ssh/config exists", () => { + const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); + const aliases = computers.map((c) => c.alias); + expect(aliases).toContain("arch-razer"); + expect(aliases).toContain("xenifse1"); + expect(aliases).toContain("github.com"); + expect(aliases).toContain("localhost"); + }); + + it("defaulted params for known_hosts-discovered computers", () => { + const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); + const arch = computers.find((c) => c.alias === "arch-razer"); + expect(arch).toEqual({ + alias: "arch-razer", + hostName: "arch-razer", + port: 22, + user: "fallback-user", + identityFile: null, + knownHost: true, + }); + }); + + it("config entries take precedence over known_hosts (no duplication)", () => { + const cfg = ` +Host arch-razer + HostName 192.168.1.100 + User root + Port 2222 +`; + const computers = resolveComputers( + env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }), + ); + const arch = computers.filter((c) => c.alias === "arch-razer"); + expect(arch).toHaveLength(1); + // Config params win, not the known_hosts defaults. + expect(arch[0]?.hostName).toBe("192.168.1.100"); + expect(arch[0]?.user).toBe("root"); + expect(arch[0]?.port).toBe(2222); + }); + + it("merges config + known_hosts entries (union, sorted)", () => { + const cfg = ` +Host server-a + HostName 10.0.0.1 +`; + const computers = resolveComputers( + env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }), + ); + const aliases = computers.map((c) => c.alias); + // config entry + known_hosts entries, all unique, sorted. + expect(aliases).toEqual(["arch-razer", "github.com", "localhost", "server-a", "xenifse1"]); + }); +}); + +// ─── reject-list filtering ──────────────────────────────────────────────── + +describe("resolveComputers with rejectPatterns", () => { + it("filters out exact-match rejected hostnames", () => { + const computers = resolveComputers( + env({ + knownHostsText: KNOWN_HOSTS_FIXTURE, + rejectPatterns: ["github.com"], + }), + ); + expect(computers.find((c) => c.alias === "github.com")).toBeUndefined(); + expect(computers.find((c) => c.alias === "arch-razer")).toBeDefined(); + }); + + it("filters out glob patterns (* matches any sequence)", () => { + const computers = resolveComputers( + env({ + knownHostsText: KNOWN_HOSTS_FIXTURE, + rejectPatterns: ["*.com"], + }), + ); + expect(computers.find((c) => c.alias === "github.com")).toBeUndefined(); + expect(computers.find((c) => c.alias === "arch-razer")).toBeDefined(); + }); + + it("filters multiple patterns", () => { + const computers = resolveComputers( + env({ + knownHostsText: KNOWN_HOSTS_FIXTURE, + rejectPatterns: ["github.com", "localhost"], + }), + ); + const aliases = computers.map((c) => c.alias); + expect(aliases).toEqual(["arch-razer", "xenifse1"]); + }); + + it("does NOT filter resolveComputer (single alias lookup ignores reject list)", () => { + // resolveComputer is for specific lookups (status, test, connect) — + // the reject list is a discovery/catalog filter, not access control. + const c = resolveComputer( + "github.com", + env({ knownHostsText: KNOWN_HOSTS_FIXTURE, rejectPatterns: ["github.com"] }), + ); + expect(c).not.toBeNull(); + expect(c?.alias).toBe("github.com"); + }); + + it("empty rejectPatterns does not filter anything", () => { + const computers = resolveComputers( + env({ knownHostsText: KNOWN_HOSTS_FIXTURE, rejectPatterns: [] }), + ); + expect(computers).toHaveLength(4); + }); + + it("absent rejectPatterns does not filter anything", () => { + const computers = resolveComputers(env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); + expect(computers).toHaveLength(4); + }); +}); + +// ─── resolveComputer known_hosts fallback ───────────────────────────────── + +describe("resolveComputer with known_hosts fallback", () => { + it("resolves a host from known_hosts when not in config", () => { + const c = resolveComputer("arch-razer", env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); + expect(c).toEqual({ + alias: "arch-razer", + hostName: "arch-razer", + port: 22, + user: "fallback-user", + identityFile: null, + knownHost: true, + }); + }); + + it("returns null for a host in neither config nor known_hosts", () => { + const c = resolveComputer("nonexistent", env({ knownHostsText: KNOWN_HOSTS_FIXTURE })); + expect(c).toBeNull(); + }); + + it("config takes precedence over known_hosts for resolveComputer", () => { + const cfg = ` +Host arch-razer + HostName 192.168.1.100 + User root +`; + const c = resolveComputer( + "arch-razer", + env({ configText: cfg, knownHostsText: KNOWN_HOSTS_FIXTURE }), + ); + expect(c?.hostName).toBe("192.168.1.100"); + expect(c?.user).toBe("root"); + }); +}); + +// ─── isRejected glob matching ────────────────────────────────────────────── + +describe("isRejected", () => { + it("matches exact hostname", () => { + expect(isRejected("github.com", ["github.com"])).toBe(true); + expect(isRejected("arch-razer", ["github.com"])).toBe(false); + }); + + it("matches * glob (any sequence)", () => { + expect(isRejected("foo.ts.net", ["*.ts.net"])).toBe(true); + // * matches zero chars, but the remaining ".ts.net" (with literal dot) + // still doesn't match "ts.net" — the dot is required. + expect(isRejected("ts.net", ["*.ts.net"])).toBe(false); + expect(isRejected("foo.other.net", ["*.ts.net"])).toBe(false); + }); + + it("matches ? glob (single char)", () => { + expect(isRejected("host1", ["host?"])).toBe(true); + expect(isRejected("host12", ["host?"])).toBe(false); // ? = exactly one char + }); + + it("is case-insensitive", () => { + expect(isRejected("GitHub.Com", ["github.com"])).toBe(true); + expect(isRejected("FOO.TS.NET", ["*.ts.net"])).toBe(true); + }); + + it("returns false for absent or empty patterns", () => { + expect(isRejected("anything")).toBe(false); + expect(isRejected("anything", [])).toBe(false); + expect(isRejected("anything", undefined)).toBe(false); + }); +}); |
