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/system-prompt/src | |
| 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/system-prompt/src')
| -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 |
6 files changed, 169 insertions, 33 deletions
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>; |
