summaryrefslogtreecommitdiffhomepage
path: root/packages/ssh/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ssh/src')
-rw-r--r--packages/ssh/src/config.test.ts254
-rw-r--r--packages/ssh/src/config.ts240
-rw-r--r--packages/ssh/src/extension.ts88
-rw-r--r--packages/ssh/src/service.ts23
4 files changed, 567 insertions, 38 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);
+ });
+});
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;
+}
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);
},
};
}
diff --git a/packages/ssh/src/service.ts b/packages/ssh/src/service.ts
index 6a809c6..629c9f8 100644
--- a/packages/ssh/src/service.ts
+++ b/packages/ssh/src/service.ts
@@ -21,7 +21,7 @@ import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/tra
import type { ComputerService } from "@dispatch/transport-http/dist/seam.js";
import type { Computer, ComputerEntry } from "@dispatch/wire";
import { createSshExecBackend } from "./backend.js";
-import { resolveComputer, resolveComputers } from "./config.js";
+import { resolveComputer, resolveComputers, type SshConfigResolveEnv } from "./config.js";
import { createSshConnectionPool, type SshConnectionPool, type SshPoolDeps } from "./pool.js";
/**
@@ -42,6 +42,12 @@ export interface SshServiceDeps extends SshPoolDeps {
* host-bin wires this from conversation-store; absent → every count is 0.
*/
readonly getUsageCounts?: () => Promise<ReadonlyMap<string, number>>;
+ /**
+ * Optional: glob patterns to exclude from the computer catalog (e.g.
+ * `github.com`, `*.ts.net`). Sourced from `dispatch.toml` `[ssh].reject`.
+ * Absent → no filtering.
+ */
+ readonly readRejectPatterns?: () => Promise<readonly string[]>;
}
/** Build the `ComputerService` + the remote-`ExecBackend` factory. */
@@ -53,12 +59,21 @@ export function createSshService(deps: SshServiceDeps): {
} {
const pool = createSshConnectionPool(deps);
- async function readEnv() {
- const [configText, knownHostsText] = await Promise.all([
+ async function readEnv(): Promise<SshConfigResolveEnv> {
+ const [configText, knownHostsText, rejectPatterns] = await Promise.all([
deps.readConfigText().catch(async () => ""),
deps.readFileText(deps.knownHostsPath).catch(async () => ""),
+ deps.readRejectPatterns !== undefined
+ ? deps.readRejectPatterns().catch(async () => [] as readonly string[])
+ : Promise.resolve([] as readonly string[]),
]);
- return { configText, knownHostsText, defaultUser: deps.defaultUser, homeDir: deps.homeDir };
+ const base: SshConfigResolveEnv = {
+ configText,
+ knownHostsText,
+ defaultUser: deps.defaultUser,
+ homeDir: deps.homeDir,
+ };
+ return rejectPatterns.length > 0 ? { ...base, rejectPatterns } : base;
}
const service: ComputerService = {