summaryrefslogtreecommitdiffhomepage
path: root/packages/ssh/src/extension.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/extension.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/extension.ts')
-rw-r--r--packages/ssh/src/extension.ts88
1 files changed, 74 insertions, 14 deletions
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);
},
};
}