summaryrefslogtreecommitdiffhomepage
path: root/packages
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
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')
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts19
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts13
-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
-rw-r--r--packages/system-prompt/package.json1
-rw-r--r--packages/system-prompt/src/extension.ts101
-rw-r--r--packages/system-prompt/src/resolver.test.ts18
-rw-r--r--packages/system-prompt/src/resolver.ts18
-rw-r--r--packages/system-prompt/src/service.test.ts6
-rw-r--r--packages/system-prompt/src/service.ts33
-rw-r--r--packages/system-prompt/src/types.ts26
13 files changed, 759 insertions, 81 deletions
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts
index 8ff3f5e..654c0c3 100644
--- a/packages/session-orchestrator/src/orchestrator.test.ts
+++ b/packages/session-orchestrator/src/orchestrator.test.ts
@@ -3611,12 +3611,13 @@ function createFakeSystemPromptService(
constructImpl: (
conversationId: string,
cwd: string,
- context?: { readonly model?: string },
+ context?: { readonly model?: string; readonly computerId?: string },
) => Promise<string>,
- getWithMetaImpl: (
- conversationId: string,
- ) => Promise<{ readonly prompt: string | null; readonly cwd: string | null }> = () =>
- Promise.resolve({ prompt: null, cwd: null }),
+ getWithMetaImpl: (conversationId: string) => Promise<{
+ readonly prompt: string | null;
+ readonly cwd: string | null;
+ readonly computerId: string | null;
+ }> = () => Promise.resolve({ prompt: null, cwd: null, computerId: null }),
): SystemPromptService {
return {
construct: constructImpl,
@@ -3663,7 +3664,7 @@ describe("system prompt: regular turn flow", () => {
},
async (conversationId) => {
getCalls.push(conversationId);
- return null;
+ return { prompt: null, cwd: null, computerId: null };
},
),
});
@@ -3714,7 +3715,7 @@ describe("system prompt: regular turn flow", () => {
},
async (conversationId) => {
getWithMetaCalls.push(conversationId);
- return { prompt: "PERSISTED_PROMPT", cwd: "/work/dir" };
+ return { prompt: "PERSISTED_PROMPT", cwd: "/work/dir", computerId: null };
},
),
});
@@ -3762,7 +3763,7 @@ describe("system prompt: regular turn flow", () => {
constructCalls.push({ conversationId, cwd, model: context?.model });
return "RECONSTRUCTED_PROMPT";
},
- async () => ({ prompt: null, cwd: null }),
+ async () => ({ prompt: null, cwd: null, computerId: null }),
),
});
@@ -3815,7 +3816,7 @@ describe("system prompt: regular turn flow", () => {
async (conversationId) => {
getWithMetaCalls.push(conversationId);
// Stored prompt was built against an OLD cwd.
- return { prompt: "STALE_PROMPT", cwd: "/old/dir" };
+ return { prompt: "STALE_PROMPT", cwd: "/old/dir", computerId: null };
},
),
});
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index a1401d6..4aa77f7 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -619,17 +619,26 @@ export function createSessionOrchestrator(
{
...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
},
);
} else {
const meta = await systemPromptService.getWithMeta(conversationId);
const currentCwd = effectiveCwd ?? process.cwd();
- if (meta.prompt !== null && meta.cwd === currentCwd) {
+ const currentComputerId = effectiveComputerId ?? null;
+ // Invalidate when cwd OR computerId changed (switching computers
+ // must rebuild the prompt against the remote OS/hostname).
+ if (
+ meta.prompt !== null &&
+ meta.cwd === currentCwd &&
+ meta.computerId === currentComputerId
+ ) {
systemPrompt = meta.prompt;
} else {
systemPrompt = await systemPromptService.construct(conversationId, currentCwd, {
...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
});
}
}
@@ -1122,9 +1131,11 @@ export function createCompactionService(
if (systemPromptService !== undefined) {
const cwd = (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? process.cwd();
const workspaceId = await deps.conversationStore.getWorkspaceId(conversationId);
+ const computerId = await deps.conversationStore.getEffectiveComputer(conversationId);
const constructed = await systemPromptService.construct(conversationId, cwd, {
...(opts?.modelName !== undefined ? { model: opts.modelName } : {}),
workspaceId,
+ ...(computerId !== null ? { computerId } : {}),
});
compactionSystemPrompt = `${constructed}\n\n${COMPACTION_SYSTEM_PROMPT}`;
} else {
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 = {
diff --git a/packages/system-prompt/package.json b/packages/system-prompt/package.json
index 70ce940..9414297 100644
--- a/packages/system-prompt/package.json
+++ b/packages/system-prompt/package.json
@@ -6,6 +6,7 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"dependencies": {
+ "@dispatch/exec-backend": "workspace:*",
"@dispatch/kernel": "workspace:*",
"@dispatch/transport-contract": "workspace:*"
}
diff --git a/packages/system-prompt/src/extension.ts b/packages/system-prompt/src/extension.ts
index 7b6575d..5cb9125 100644
--- a/packages/system-prompt/src/extension.ts
+++ b/packages/system-prompt/src/extension.ts
@@ -4,7 +4,13 @@
* Builds the service with real Bun-backed adapters (Bun.file for fs,
* Bun.spawn for git), persists the template + resolved prompts via a namespaced
* storage, and provides the service through `systemPromptHandle`.
+ *
+ * When a conversation has a `computerId` set (remote turn), the service calls
+ * `resolveRemoteAdapters` to obtain ExecBackend-backed adapters that read
+ * files / run commands on the REMOTE machine (via SSH), so the system prompt
+ * reflects the remote OS/hostname/cwd — not the local host's.
*/
+import { type ExecBackend, execBackendHandle } from "@dispatch/exec-backend";
import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import type { GitSpawnResult, ResolverAdapters } from "./resolver.js";
import { createSystemPromptService } from "./service.js";
@@ -17,7 +23,10 @@ export const manifest: Manifest = {
apiVersion: "^0.1.0",
trust: "bundled",
activation: "eager",
- dependsOn: [],
+ // exec-backend provides the resolver used to obtain a remote ExecBackend
+ // when computerId is set. The lookup is lazy (at construct time, not
+ // activation), but declaring the dep keeps the DAG honest.
+ dependsOn: ["exec-backend"],
capabilities: { fs: true, spawn: true },
contributes: { services: ["system-prompt"] },
};
@@ -47,11 +56,97 @@ function realFs() {
};
}
-const adapters: ResolverAdapters = { spawn: realSpawn, fs: realFs() };
+const localAdapters: ResolverAdapters = { spawn: realSpawn, fs: realFs() };
+
+/**
+ * Run a single command on a remote ExecBackend and capture stdout. Returns
+ * `null` on any error (the resolver treats null as "unavailable").
+ */
+async function remoteCommand(
+ backend: ExecBackend,
+ command: string,
+ cwd: string,
+): Promise<string | null> {
+ let stdout = "";
+ try {
+ const result = await backend.spawn({
+ command,
+ cwd,
+ signal: new AbortController().signal,
+ timeout: 10_000,
+ onOutput: (data: string, stream: "stdout" | "stderr") => {
+ if (stream === "stdout") stdout += data;
+ },
+ });
+ return result.exitCode === 0 ? stdout.trim() : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Build `ResolverAdapters` backed by a remote `ExecBackend` (SSH). File reads
+ * go through SFTP; git/hostname/uname run over SSH exec. This makes the system
+ * prompt reflect the REMOTE machine's OS, hostname, and git state.
+ */
+function buildRemoteAdapters(backend: ExecBackend, cwd: string): ResolverAdapters {
+ return {
+ spawn: async (command, opts) => {
+ let stdout = "";
+ let stderr = "";
+ const result = await backend.spawn({
+ command: command.join(" "),
+ cwd: opts.cwd,
+ signal: new AbortController().signal,
+ timeout: 10_000,
+ onOutput: (data: string, stream: "stdout" | "stderr") => {
+ if (stream === "stdout") stdout += data;
+ else stderr += data;
+ },
+ });
+ return { stdout, stderr, exitCode: result.exitCode };
+ },
+ fs: {
+ readText: async (path: string): Promise<string> => backend.readFile(path),
+ exists: async (path: string): Promise<boolean> => backend.exists(path),
+ },
+ // Run hostname/uname on the REMOTE machine. These are resolved once per
+ // construct call (cached by the service's cwd+computerId cache). If the
+ // remote command fails, fall back to a generic value (the resolver will
+ // still read /etc/os-release via SFTP for the distro name).
+ hostname: async () => (await remoteCommand(backend, "hostname", cwd)) ?? "remote",
+ platform: async () => (await remoteCommand(backend, "uname -s", cwd)) ?? "linux",
+ };
+}
export function activate(host: HostAPI): void {
const storage = host.storage("system-prompt");
- const service = createSystemPromptService({ storage, adapters });
+
+ /**
+ * Resolve remote-backed adapters for a given computerId. Looks up the
+ * ExecBackendResolver (provided by exec-backend, which delegates to ssh's
+ * remote factory when computerId is set) and wraps it in ResolverAdapters.
+ * Falls back to local adapters if the resolver or backend is unavailable.
+ */
+ const resolveRemoteAdapters = async (
+ computerId: string,
+ cwd: string,
+ ): Promise<ResolverAdapters> => {
+ try {
+ const resolver = host.getService(execBackendHandle);
+ const backend = resolver(computerId);
+ return buildRemoteAdapters(backend, cwd);
+ } catch {
+ // exec-backend not loaded or resolver unavailable → local.
+ return localAdapters;
+ }
+ };
+
+ const service = createSystemPromptService({
+ storage,
+ adapters: localAdapters,
+ resolveRemoteAdapters,
+ });
host.provideService(systemPromptHandle, service);
host.logger.info("system-prompt: activated");
}
diff --git a/packages/system-prompt/src/resolver.test.ts b/packages/system-prompt/src/resolver.test.ts
index 474c79a..d55af07 100644
--- a/packages/system-prompt/src/resolver.test.ts
+++ b/packages/system-prompt/src/resolver.test.ts
@@ -37,8 +37,8 @@ describe("resolver", () => {
spawn: failSpawn(),
fs: fakeFs(new Map()),
now: () => fixedNow,
- platform: () => "linux",
- hostname: () => "myhost",
+ platform: async () => "linux",
+ hostname: async () => "myhost",
});
expect(map.get("system:time")).toBe("2024-06-15T12:30:00.000Z");
@@ -83,7 +83,7 @@ describe("resolver", () => {
const map = await resolveVariables("/proj", {
spawn: failSpawn(),
fs: fakeFs(files),
- platform: () => "linux",
+ platform: async () => "linux",
});
expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS");
});
@@ -95,7 +95,7 @@ describe("resolver", () => {
const map = await resolveVariables("/proj", {
spawn: failSpawn(),
fs: fakeFs(files),
- platform: () => "linux",
+ platform: async () => "linux",
});
expect(map.get("system:os")).toBe("Debian 12");
});
@@ -108,7 +108,7 @@ describe("resolver", () => {
const map = await resolveVariables("/proj", {
spawn: failSpawn(),
fs: fakeFs(files),
- platform: () => "linux",
+ platform: async () => "linux",
});
expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)");
});
@@ -121,7 +121,7 @@ describe("resolver", () => {
const map = await resolveVariables("/proj", {
spawn: failSpawn(),
fs: fakeFs(files),
- platform: () => "linux",
+ platform: async () => "linux",
});
expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)");
});
@@ -131,7 +131,7 @@ describe("resolver", () => {
const map = await resolveVariables("/proj", {
spawn: failSpawn(),
fs: fakeFs(files),
- platform: () => "linux",
+ platform: async () => "linux",
});
expect(map.get("system:os")).toBe("Linux (WSL)");
});
@@ -140,7 +140,7 @@ describe("resolver", () => {
const map = await resolveVariables("/proj", {
spawn: failSpawn(),
fs: fakeFs(new Map()),
- platform: () => "linux",
+ platform: async () => "linux",
});
expect(map.get("system:os")).toBe("linux");
});
@@ -149,7 +149,7 @@ describe("resolver", () => {
const map = await resolveVariables("/proj", {
spawn: failSpawn(),
fs: fakeFs(new Map()),
- platform: () => "darwin",
+ platform: async () => "darwin",
});
expect(map.get("system:os")).toBe("darwin");
});
diff --git a/packages/system-prompt/src/resolver.ts b/packages/system-prompt/src/resolver.ts
index f271f47..e864554 100644
--- a/packages/system-prompt/src/resolver.ts
+++ b/packages/system-prompt/src/resolver.ts
@@ -45,10 +45,16 @@ export interface ResolverAdapters {
readonly fs: ResolverFs;
/** Override the current time (defaults to `new Date()`). */
readonly now?: () => Date;
- /** Override `process.platform` (defaults to the real platform). */
- readonly platform?: () => string;
- /** Override the hostname (defaults to `os.hostname()`). */
- readonly hostname?: () => string;
+ /**
+ * Override `process.platform` (defaults to the real platform). Async so a
+ * remote adapter can run `uname -s` over SSH.
+ */
+ readonly platform?: () => Promise<string>;
+ /**
+ * Override the hostname (defaults to `os.hostname()`). Async so a remote
+ * adapter can run `hostname` over SSH.
+ */
+ readonly hostname?: () => Promise<string>;
}
/** Per-construction context forwarded by the session-orchestrator. */
@@ -157,9 +163,9 @@ export async function resolveVariables(
// ── system:* ────────────────────────────────────────────────────────────
vars.set("system:time", now.toISOString());
vars.set("system:date", now.toISOString().slice(0, 10));
- const platform = adapters.platform?.() ?? process.platform;
+ const platform = (await adapters.platform?.()) ?? process.platform;
vars.set("system:os", await resolveOs(platform, adapters.fs));
- vars.set("system:hostname", adapters.hostname?.() ?? osHostname());
+ vars.set("system:hostname", (await adapters.hostname?.()) ?? osHostname());
// ── prompt:* ────────────────────────────────────────────────────────────
vars.set("prompt:cwd", cwd);
diff --git a/packages/system-prompt/src/service.test.ts b/packages/system-prompt/src/service.test.ts
index cd850e3..37c1c0d 100644
--- a/packages/system-prompt/src/service.test.ts
+++ b/packages/system-prompt/src/service.test.ts
@@ -157,7 +157,7 @@ describe("system-prompt service", () => {
});
const meta = await service.getWithMeta("never-constructed");
- expect(meta).toEqual({ prompt: null, cwd: null });
+ expect(meta).toEqual({ prompt: null, cwd: null, computerId: null });
});
it("getWithMeta after construct returns the resolved prompt and the exact cwd", async () => {
@@ -217,11 +217,11 @@ describe("system-prompt service", () => {
const first = await service.construct("conv-second", "/dir-a");
const firstMeta = await service.getWithMeta("conv-second");
- expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a" });
+ expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a", computerId: null });
const second = await service.construct("conv-second", "/dir-b");
const secondMeta = await service.getWithMeta("conv-second");
- expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b" });
+ expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b", computerId: null });
expect(secondMeta.cwd).not.toBe("/dir-a");
});
});
diff --git a/packages/system-prompt/src/service.ts b/packages/system-prompt/src/service.ts
index 60977bf..8d6ede5 100644
--- a/packages/system-prompt/src/service.ts
+++ b/packages/system-prompt/src/service.ts
@@ -28,12 +28,21 @@ The current working directory is [prompt:cwd].
const TEMPLATE_KEY = "template";
const resolvedKey = (conversationId: string): string => `resolved:${conversationId}`;
const resolvedCwdKey = (conversationId: string): string => `resolved-cwd:${conversationId}`;
+const resolvedComputerIdKey = (conversationId: string): string =>
+ `resolved-computer:${conversationId}`;
export interface SystemPromptServiceDeps {
/** Namespaced KV (`host.storage("system-prompt")`). */
readonly storage: StorageNamespace;
- /** Injected effects for variable resolution. */
+ /** Injected effects for variable resolution (local). */
readonly adapters: ResolverAdapters;
+ /**
+ * Optional: build remote-backed adapters for a given computerId. When
+ * `construct` is called with a `computerId`, this is invoked to obtain
+ * adapters that read/run commands on the REMOTE machine (via the
+ * ExecBackend/SSH). Absent → falls back to the local `adapters`.
+ */
+ readonly resolveRemoteAdapters?: (computerId: string, cwd: string) => Promise<ResolverAdapters>;
}
/**
@@ -52,7 +61,17 @@ export function createSystemPromptService(deps: SystemPromptServiceDeps): System
...(context?.model !== undefined ? { model: context.model } : {}),
...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}),
};
- const vars = await resolveVariables(cwd, deps.adapters, {
+
+ // Select adapters: when computerId is set, use remote-backed adapters
+ // (read files / run commands on the REMOTE machine via SSH). Otherwise
+ // use the local adapters.
+ const computerId = context?.computerId;
+ const adapters =
+ computerId !== undefined && deps.resolveRemoteAdapters !== undefined
+ ? await deps.resolveRemoteAdapters(computerId, cwd)
+ : deps.adapters;
+
+ const vars = await resolveVariables(cwd, adapters, {
context: resolverContext,
referencedKeys,
});
@@ -60,6 +79,9 @@ export function createSystemPromptService(deps: SystemPromptServiceDeps): System
await deps.storage.set(resolvedKey(conversationId), result);
await deps.storage.set(resolvedCwdKey(conversationId), cwd);
+ // Store the computerId (or empty string for local) so the cache can be
+ // invalidated when the computer changes.
+ await deps.storage.set(resolvedComputerIdKey(conversationId), computerId ?? "");
return result;
},
@@ -68,11 +90,14 @@ export function createSystemPromptService(deps: SystemPromptServiceDeps): System
},
async getWithMeta(conversationId) {
- const [prompt, cwd] = await Promise.all([
+ const [prompt, cwd, computerIdStored] = await Promise.all([
deps.storage.get(resolvedKey(conversationId)),
deps.storage.get(resolvedCwdKey(conversationId)),
+ deps.storage.get(resolvedComputerIdKey(conversationId)),
]);
- return { prompt, cwd };
+ // Empty string → null (local, no computerId). Non-empty → the alias.
+ const computerId = computerIdStored === null ? null : computerIdStored || null;
+ return { prompt, cwd, computerId };
},
async getTemplate() {
diff --git a/packages/system-prompt/src/types.ts b/packages/system-prompt/src/types.ts
index 4e1db0b..a9fe3ca 100644
--- a/packages/system-prompt/src/types.ts
+++ b/packages/system-prompt/src/types.ts
@@ -20,25 +20,35 @@ export interface SystemPromptService {
* result under `resolved:<conversationId>`. Returns the resolved string.
* When no template is stored, the built-in default template is used. An
* empty template yields an empty string.
+ *
+ * When `context.computerId` is set, the resolver uses remote-backed adapters
+ * (reading the remote's `/etc/os-release`, `hostname`, `uname`, `git` via
+ * the ExecBackend/SSH) so the system prompt reflects the REMOTE machine.
*/
construct(
conversationId: string,
cwd: string,
- context?: { readonly model?: string; readonly workspaceId?: string },
+ context?: {
+ readonly model?: string;
+ readonly workspaceId?: string;
+ readonly computerId?: string;
+ },
): Promise<string>;
/** Read the persisted resolved system prompt, or `null` if never constructed. */
get(conversationId: string): Promise<string | null>;
/**
- * Read the persisted resolved system prompt AND the cwd it was built
- * against. Returns `{ prompt: null, cwd: null }` if never constructed.
- * Consumers use this to detect whether the cached prompt is stale
- * relative to the current effective cwd.
+ * Read the persisted resolved system prompt AND the cwd + computerId it was
+ * built against. Returns `{ prompt: null, cwd: null, computerId: null }` if
+ * never constructed. Consumers use this to detect whether the cached prompt
+ * is stale relative to the current effective cwd or computerId.
*/
- getWithMeta(
- conversationId: string,
- ): Promise<{ readonly prompt: string | null; readonly cwd: string | null }>;
+ getWithMeta(conversationId: string): Promise<{
+ readonly prompt: string | null;
+ readonly cwd: string | null;
+ readonly computerId: string | null;
+ }>;
/** Read the global template (or `DEFAULT_TEMPLATE` when none is stored). */
getTemplate(): Promise<string>;