diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 21:45:58 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 21:45:58 +0900 |
| commit | 2cc9ddfb590dc60557bba3ed76a6c4639df5f596 (patch) | |
| tree | b0ced1ecb5f899e6a2b835d41603c4040a49bbce /packages | |
| parent | 087ce142247637bb10351ab7815144b720836153 (diff) | |
| download | dispatch-2cc9ddfb590dc60557bba3ed76a6c4639df5f596.tar.gz dispatch-2cc9ddfb590dc60557bba3ed76a6c4639df5f596.zip | |
feat(ssh): discover computers from ~/.ssh/known_hosts + remote system-prompt
Two improvements to the SSH support feature:
1. KNOWN_HOSTS DISCOVERY (packages/ssh):
Computers are now auto-discovered from ~/.ssh/known_hosts (every hostname
you've ever connected to) in ADDITION to ~/.ssh/config (explicit Host
aliases). Config entries take precedence (full params); known_hosts entries
get defaulted params (User=defaultUser, IdentityFile=null→pool probes
default keys, Port from [host]:port or 22, knownHost=true). Zero-config —
no ~/.ssh/config file needed; hosts just appear.
Reject list: dispatch.toml [ssh].reject = [...] (glob patterns like
github.com, *.ts.net) filters noise from the catalog. Read from both
the global ~/.config/dispatch/dispatch.toml and the project dispatch.toml.
Parsed with Bun.TOML.parse (zero deps). Only filters discovery (catalog);
specific lookups (getComputer/getStatus/test/connect) ignore the reject
list (it's a visibility filter, not access control).
New pure functions: parseKnownHosts(), isRejected(), globMatch().
+26 tests. tsc EXIT 0, biome clean, 1756 tests pass.
2. REMOTE SYSTEM-PROMPT AWARENESS (packages/system-prompt):
When a conversation has a computerId set (remote turn), the system prompt
now resolves system:os, system:hostname, git:branch/git:status, and
file: reads against the REMOTE machine — not the local host. Previously
the prompt always said 'Arch Linux (WSL)' + local hostname even when the
agent was connected to a remote Artix Linux machine.
The ResolverAdapters' hostname()/platform() are now async (so a remote
adapter can run 'hostname'/'uname -s' over SSH). The system-prompt
extension builds remote adapters from the ExecBackend (readFile→SFTP,
spawn→SSH exec). Cache invalidation now checks computerId (switching
computers rebuilds the prompt). The compaction path also threads
computerId. @dispatch/system-prompt now depends on @dispatch/exec-backend.
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 19 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 13 | ||||
| -rw-r--r-- | packages/ssh/src/config.test.ts | 254 | ||||
| -rw-r--r-- | packages/ssh/src/config.ts | 240 | ||||
| -rw-r--r-- | packages/ssh/src/extension.ts | 88 | ||||
| -rw-r--r-- | packages/ssh/src/service.ts | 23 | ||||
| -rw-r--r-- | packages/system-prompt/package.json | 1 | ||||
| -rw-r--r-- | packages/system-prompt/src/extension.ts | 101 | ||||
| -rw-r--r-- | packages/system-prompt/src/resolver.test.ts | 18 | ||||
| -rw-r--r-- | packages/system-prompt/src/resolver.ts | 18 | ||||
| -rw-r--r-- | packages/system-prompt/src/service.test.ts | 6 | ||||
| -rw-r--r-- | packages/system-prompt/src/service.ts | 33 | ||||
| -rw-r--r-- | packages/system-prompt/src/types.ts | 26 |
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>; |
