summaryrefslogtreecommitdiffhomepage
path: root/packages/ssh/src/config.test.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.test.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.test.ts')
-rw-r--r--packages/ssh/src/config.test.ts254
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);
+ });
+});