From 97c21866aedbd836056a2bfdbe97a7779cda3905 Mon Sep 17 00:00:00 2001 From: Jake Gaylor Date: Wed, 19 Aug 2026 09:05:53 -0400 Subject: [PATCH 1/2] Add a provider-neutral Remote ACP engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `remoteAcp` driver for any command that speaks ACP on stdio for an agent that executes somewhere else — a hosted sandbox service's CLI, an agent over ssh, an agent in a container. The instance's `config` says how to start the bridge (`args`, with `{model}` for the pick), how to list what the picker can choose (`catalog` → JSON rows, or static `models`), how to tell whether it is signed in (`authCheck`), and which local MCP mounts to forward (`mcp`, none by default). Core gains three additive hooks every existing support ignores: `decodeConfig` to widen the instance config, `mcp` to declare which integrations a support mounts (also gated inside acpMcpServers, so a bridge that ignores mcpServers is never handed a computer token), and the decoded config on `resolveModels` / `pickAuthMethod`. Nothing provider-specific ships; docs/remote-acp.md uses Fountain as the worked example alongside ssh and docker shapes. Co-Authored-By: Claude Fable 5 --- README.md | 3 +- docs/remote-acp.md | 180 +++++++++++++ server/drivers/acp/core.ts | 90 +++++-- server/drivers/acp/remote.test.ts | 435 ++++++++++++++++++++++++++++++ server/drivers/acp/remote.ts | 275 +++++++++++++++++++ server/drivers/builtIn.ts | 2 + server/testing/fake-acp-cli.ts | 1 + src/components/ProviderIcons.tsx | 9 +- 8 files changed, 967 insertions(+), 28 deletions(-) create mode 100644 docs/remote-acp.md create mode 100644 server/drivers/acp/remote.test.ts create mode 100644 server/drivers/acp/remote.ts diff --git a/README.md b/README.md index cff4d0c0b4..6b01d7b746 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,8 @@ pnpm dev:desktop # Electron shell; keep the two commands above running Requirements: **macOS, Windows, or Ubuntu 24.04 x64**, **Node 24+**, **pnpm**, and at least one agent CLI — [`claude`](https://claude.com/claude-code), [`codex`](https://github.com/openai/codex), or [`grok`](https://x.ai/cli) — installed and logged in. They appear -in the model picker automatically. +in the model picker automatically. Agents that run somewhere else — a hosted sandbox service, another machine, +a container — plug in through the provider-neutral [Remote ACP engine](docs/remote-acp.md). Package the desktop application: diff --git a/docs/remote-acp.md b/docs/remote-acp.md new file mode 100644 index 0000000000..0eaae891d3 --- /dev/null +++ b/docs/remote-acp.md @@ -0,0 +1,180 @@ +# Remote ACP engine + +The **Remote ACP** engine (`driver: "remoteAcp"`) runs a bot on any command that +speaks the [Agent Client Protocol](https://agentclientprotocol.com) on stdio for +an agent that **executes somewhere else** — a hosted sandbox service's CLI, an +agent on another machine over `ssh`, an agent inside a container. Nothing about +the provider is built in: the instance's config says how to start the bridge, +how to list what the picker can choose, and how to tell whether it is signed in. + +It rides the same ACP core as the local engines (Grok Build, Gemini CLI, Kimi, +Droid, OpenCode, Qwen, Hermes): streaming, tool events, resume, cancellation and +permission cards all work the same way when the bridge implements them. + +## Setup + +Remote ACP has no default instance — there is no command it could run without +being told which one. Add an entry to `~/.openmausbot/config.json` under +`instances` and restart the app. The minimum is the binary: + +```json +{ + "instances": { + "remote-agent": { + "driver": "remoteAcp", + "displayName": "My remote agent", + "config": { "cli": "my-agent-cli", "args": ["acp"] } + } + } +} +``` + +The instance shows up in the Cloud rail of the model picker under its +`displayName`. Like every engine, the binary path can also be overridden later +from **Settings → Engines** (that writes `config.cli`). + +### Config reference + +All keys sit under the instance's `config`. Every argv field is an array of +strings **after** the binary, never a shell string, so paths with spaces and +arguments with quotes need no escaping. + +| Key | Type | Meaning | +|---|---|---| +| `cli` | string | The bridge binary (name on PATH or absolute path). | +| `args` | string[] | Arguments that enter ACP stdio mode. `{model}` is replaced with the picker's choice; see [Picking](#picking-an-agent-model-or-profile). Default `[]`. | +| `catalog` | string[] | Arguments that print the picker catalog as JSON; see [Catalog](#the-catalog-command). Omit for a bridge with nothing to list. | +| `models` | `(string \| {id, label})[]` | Static picker entries, listed ahead of whatever `catalog` returns. | +| `authCheck` | string[] | Arguments whose exit status answers "signed in?" (0 = yes). Omit to trust the bridge. | +| `authMethod` | string | ACP `authenticate` method id to call when the agent advertises it. Omit to never call `authenticate` — most bridges hold their own credentials. | +| `mcp` | `{agents?, computer?, composio?}` | Which local MCP integrations to forward into the session. **All `false` unless set**; see [What does not apply](#what-does-not-apply-and-why). | +| `fullAuto` | boolean | Approve every permission request the bridge forwards, instead of showing a card. | +| `workspace` | string | `cwd` handed to the bridge and to `session/new`. Most remote agents ignore it. | + +Environment variables for the bridge (API keys, base URLs, profiles) go in the +instance's `environment`, exactly like the other engines: + +```json +"environment": { "FOUNTAIN_API_KEY": "fk_…", "FOUNTAIN_BASE_URL": "https://fountain.example" } +``` + +A malformed entry (a string where an array is expected, a model row without an +`id`) does not run on a guess: the instance appears as unavailable with the +offending key named in the reason. + +## Picking an agent, model, or profile + +ACP has no field for "which agent" — each bridge takes that on its command line. +Put `{model}` in `args` where the pick belongs, as its own argument or inside one: + +```json +"args": ["acp", "--agent", "{model}"] +"args": ["acp", "--agent={model}"] +"args": ["-T", "devbox", "gemini", "--experimental-acp", "-m", "{model}"] +``` + +When nothing is picked (an empty catalog, or the picker left blank) the +`{model}` argument is dropped, and so is a directly preceding option +(`--agent`, `-m`) that would otherwise dangle — the bridge then runs on its own +default, or says it has none, in its own words. + +## The catalog command + +`catalog` runs at startup and on picker refresh with the instance environment, +and must print JSON: an array, or an object whose `data`, `models`, `agents` or +`items` is one, of rows with a string `id` and optionally a `label` or `name`: + +```json +[{ "id": "a42e…", "name": "homelab-builder", "runtime": "claude" }, { "id": "gpt-5" }] +``` + +The id is what lands in `{model}`; the label is `label`, else `name`, else the +id. Extra fields are ignored. A row marked `"acp": false` — the remote side's +way of saying this entry cannot be driven over the protocol — is left out, so +the picker never offers something that fails at `session/new`. A failing +command (signed out, remote down) keeps the last catalog instead of emptying the +picker. + +## Worked example: Fountain + +[Fountain](https://github.com/BinaryBourbon/fountain) runs agents in sandboxes +on a hosted or self-hosted instance; its CLI's `fountain acp` speaks ACP on +stdio and a Fountain *agent* (model + runtime + skills + MCP servers + +environment) is the unit the picker chooses. One instance per identity or +environment, as Fountain's own `--vault`/`--environment` flags frame it: + +```json +{ + "instances": { + "fountain": { + "driver": "remoteAcp", + "displayName": "Fountain", + "config": { + "cli": "fountain", + "args": ["acp", "--agent", "{model}"], + "catalog": ["agent", "list", "--json"], + "authCheck": ["auth", "whoami"] + } + }, + "fountain-staging": { + "driver": "remoteAcp", + "displayName": "Fountain (staging)", + "config": { + "cli": "fountain", + "args": ["acp", "--agent", "{model}", "--environment", "staging"], + "catalog": ["agent", "list", "--json"] + }, + "environment": { "FOUNTAIN_API_KEY": "fk_…", "FOUNTAIN_BASE_URL": "https://staging.fountain.example" } + } + } +} +``` + +Install with `brew install BinaryBourbon/tap/fountain`, sign in with +`fountain auth login` (or set `FOUNTAIN_API_KEY` in `environment`), and the +agents of that instance list in the picker. Fountain's ACP session id is its +conversation id, so a thread resumes on the server after a restart — even from +another machine. See Fountain's +[`fountain acp` reference](https://github.com/BinaryBourbon/fountain/blob/main/docs/integrations/acp.md) +for what the adapter does and does not forward. + +## Other shapes + +An ACP agent on another machine, over ssh (the agent's own credentials live +there; `-T` keeps stdio clean): + +```json +"config": { "cli": "ssh", "args": ["-T", "devbox", "gemini", "--experimental-acp", "-m", "{model}"], "models": ["gemini-2.5-pro", "gemini-2.5-flash"] } +``` + +An agent in a running container: + +```json +"config": { "cli": "docker", "args": ["exec", "-i", "agent-box", "opencode", "acp"] } +``` + +A bridge whose catalog needs reshaping — wrap it in a script that prints the +contract above; `catalog` is just argv. + +## What does not apply, and why + +- **No local MCP by default.** The agent runs elsewhere and never sees this + machine, so the bot is not told it has a computer, a Composio connection or + peer bots its driver cannot hand it — and no tokens for those are ever sent + to the bridge. A bridge that *does* forward `mcpServers` to where the agent + runs (an ssh box that can reach your services) can opt back in per mount: + `"mcp": { "agents": true }`. +- **Permission cards** appear only if the bridge forwards + `session/request_permission`. Sandboxed runtimes usually run under their own + permission mode instead. +- **No effort control, no in-session model switch.** The pick is made on the + command line when the bridge starts. +- **Install/sign-in buttons** in Settings → Engines know nothing about your + bridge; install and sign it in yourself. A configured `authCheck` is what + makes the picker say "not signed in" rather than failing the first turn. + +## Testing + +`server/drivers/acp/remote.test.ts` covers config decoding, the catalog +contract, argv substitution, the sign-in probe, MCP gating, and a full turn +through the shared fake ACP CLI — no remote service or credential is needed. diff --git a/server/drivers/acp/core.ts b/server/drivers/acp/core.ts index 0bbc999eae..981dfd1edb 100644 --- a/server/drivers/acp/core.ts +++ b/server/drivers/acp/core.ts @@ -48,8 +48,11 @@ export interface AcpConfig { workspace?: string; } -/** Per-harness specifics — everything that differs between Grok, Gemini, … */ -export interface AcpSupport { +/** Per-harness specifics — everything that differs between Grok, Gemini, … + * `C` is the decoded instance config: the shared AcpConfig for a CLI that + * takes no extra settings, or a wider record for a support that reads more + * out of the instance's `config` envelope (see `decodeConfig`). */ +export interface AcpSupport { driverKind: string; displayName: string; /** Omit for subscription CLIs (the default). Custom-only CLIs sit below @@ -63,16 +66,31 @@ export interface AcpSupport { effortLevels?: readonly EffortLevel[]; /** Default CLI binary name if the instance config doesn't override it. */ defaultCli: string; - /** Optional live model catalog. A failed lookup keeps the last usable catalog. */ - resolveModels?(environment: Record): ModelCatalog | Promise; + /** Widen the decoded config: `base` is the shared cli/fullAuto/workspace + * triple already read from `raw`. Throw to reject an instance (it + * becomes a shadow entry carrying the message) rather than run it on a + * guess. Omit when the harness has no settings of its own. */ + decodeConfig?(raw: Record, base: AcpConfig): C; + /** Optional live model catalog. A failed lookup keeps the last usable + * catalog. Receives the decoded instance config for supports whose + * catalog source is configured per instance. */ + resolveModels?(environment: Record, config: C): ModelCatalog | Promise; /** Native-protocol log label, e.g. "grok.acp". */ nativeSource: string; /** Message shown when the CLI is present but not signed in. */ loginNote: string; + /** Which of the harness's MCP integrations this agent actually mounts. + * Default: all of them — an ACP CLI runs on this machine and takes the + * session's mcpServers. A remote-execution harness (the agent runs in a + * sandbox or on another host and never sees this machine's mcpServers) + * declares false, so a bot is never told it has a computer or peers its + * driver cannot hand it. May be a function of the decoded config for + * supports where that is a per-instance fact. */ + mcp?: AcpMcpMounts | ((config: C) => AcpMcpMounts); /** How a user installs this harness's CLI; surfaced by the setup UI. */ install?: EngineInstall; /** CLI argv AFTER the binary name to enter ACP stdio mode. */ - spawnArgs(config: AcpConfig, turn: SendTurnInput): string[]; + spawnArgs(config: C, turn: SendTurnInput): string[]; /** Provider credential variables this ACP child is allowed to inherit. */ credentialEnv?: readonly string[]; /** Select the model through a session config option instead of argv, for @@ -82,16 +100,17 @@ export interface AcpSupport { selectModel?: { configId: string }; /** Mutate the child env in place: strip a key, inject a policy. Receives the * instance config so a support can vary with fullAuto. */ - transformEnv?(env: Record, config: AcpConfig): void; + transformEnv?(env: Record, config: C): void; /** Pick the ACP authenticate methodId from initialize's advertised - * authMethods; return null to skip the authenticate step. */ - pickAuthMethod(authMethods: Array<{ id?: string }>): string | null; + * authMethods; return null to skip the authenticate step. Receives the + * decoded instance config for supports where the method is configured. */ + pickAuthMethod(authMethods: Array<{ id?: string }>, config: C): string | null; /** "fail": abort the turn if auth is missing/errors (subscription CLIs). * "continue": proceed anyway (CLIs that work off an ambient login). */ authFailure: "fail" | "continue"; /** snapshot(): can this harness actually run a turn? (env already carries the * merged config). May be async for harnesses that have to ask the CLI. */ - isAuthenticated(env: Record, config: AcpConfig): boolean | Promise; + isAuthenticated(env: Record, config: C): boolean | Promise; /** Classify provider-native failures without coupling the core to messages. */ classifyError?(error: unknown): ProviderErrorCode | undefined; /** Compose the session/prompt text. Default prepends the persona. */ @@ -110,11 +129,18 @@ export interface AcpSupport { configureSession?(ctx: { request: (method: string, params: unknown, timeoutMs?: number) => Promise; sessionId: string; - config: AcpConfig; + config: C; turn: SendTurnInput; }): Promise; } +/** Which MCP integrations an ACP support mounts into the session. */ +export interface AcpMcpMounts { + agents?: boolean; + computer?: boolean; + composio?: boolean; +} + const INIT_TIMEOUT = 20_000; const SESSION_CONFIG_TIMEOUT = 20_000; // configureSession's per-request default const NEW_SESSION_TIMEOUT = 30_000; @@ -131,21 +157,26 @@ const PROVIDER_CREDENTIAL_ENV = [ "XAI_API_KEY", ] as const; -function decodeAcpConfig(defaultCli: string) { - return (raw: unknown): AcpConfig => { +function decodeAcpConfig(support: AcpSupport) { + return (raw: unknown): C => { + // SAFETY: the config envelope is opaque JSON; each key is checked below + // (and by a support's own decodeConfig) before use. const o = (raw ?? {}) as Record; - return { - cli: typeof o.cli === "string" ? o.cli : defaultCli, + const base: AcpConfig = { + cli: typeof o.cli === "string" ? o.cli : support.defaultCli, fullAuto: o.fullAuto === true, workspace: typeof o.workspace === "string" ? o.workspace : undefined, }; + // SAFETY: without a widening hook C is AcpConfig itself (the default + // type argument), so base already has the right shape. + return support.decodeConfig ? support.decodeConfig(o, base) : (base as C); }; } -export function createAcpDriver(support: AcpSupport): ProviderDriver { +export function createAcpDriver(support: AcpSupport): ProviderDriver { const DRIVER_KIND = support.driverKind; const SOURCE = support.nativeSource; - const decodeConfig = decodeAcpConfig(support.defaultCli); + const decodeConfig = decodeAcpConfig(support); const DENY_TIMEOUT_NOTE = "OpenMausBot: nobody answered this permission request in time. Skip this action and finish what you can without it."; @@ -161,8 +192,12 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver decodeConfig, defaultConfig: () => decodeConfig({}), - async create(input: DriverCreateInput): Promise { + async create(input: DriverCreateInput): Promise { const { instanceId, config } = input; + const mounts: AcpMcpMounts = typeof support.mcp === "function" ? support.mcp(config) : support.mcp ?? {}; + const mountsAgents = mounts.agents ?? true; + const mountsComputer = mounts.computer ?? true; + const mountsComposio = mounts.composio ?? true; const childEnv = () => { const env: Record = { ...process.env, @@ -180,7 +215,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver const refreshModels = async () => { if (!support.resolveModels) return; try { - const resolved = await support.resolveModels(childEnv()); + const resolved = await support.resolveModels(childEnv(), config); if (resolved.options.length) models = resolved; } catch { // Keep the last usable catalog when an optional discovery source is down. @@ -215,11 +250,14 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver const servers: Array<{ name: string; command: string; args: string[]; env: Array<{ name: string; value: string }> }> = []; const acpEnv = (env: Record) => Object.entries(env).map(([name, value]) => ({ name, value: String(value) })); - const agents = turn.integrations?.agents; + // Gated on the support's declared mounts as well as the caller: a + // server that ignores mcpServers must not be handed credentials + // (the computer token, peer tokens) it would never use. + const agents = mountsAgents ? turn.integrations?.agents : undefined; if (agents) { servers.push({ name: "agents", command: agents.command, args: agents.args, env: acpEnv(agents.env) }); } - const composio = turn.integrations?.composio; + const composio = mountsComposio ? turn.integrations?.composio : undefined; if (composio) { servers.push({ name: "composio", @@ -231,7 +269,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver // The bot's computer, mounted exactly like the Claude driver does. // Cloud boxes use the REST adapter; host and sandbox Cua connections // expose Cua Driver's official MCP server directly. - const computer = turn.integrations?.computer; + const computer = mountsComputer ? turn.integrations?.computer : undefined; if (computer) { servers.push({ name: "computer", @@ -239,7 +277,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver args: [COMPUTER_PROXY_PATH], env: acpEnv({ ELECTRON_RUN_AS_NODE: "1", ...computerProxyEnv(computer) }), }); - } else if (turn.integrations?.localComputer) { + } else if (mountsComputer && turn.integrations?.localComputer) { const local = turn.integrations.localComputer; servers.push({ name: "computer", @@ -513,7 +551,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver INIT_TIMEOUT, ); const methods: Array<{ id?: string }> = Array.isArray(init?.authMethods) ? init.authMethods : []; - const methodId = support.pickAuthMethod(methods); + const methodId = support.pickAuthMethod(methods, config); if (methodId) { try { await request("authenticate", { methodId }, INIT_TIMEOUT); @@ -671,9 +709,9 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver provider: DRIVER_KIND, capabilities: { sessionModelSwitch: "unsupported", - agentsMcp: true, - computerMcp: true, - composioMcp: true, + agentsMcp: mountsAgents, + computerMcp: mountsComputer, + composioMcp: mountsComposio, effortLevels: support.effortLevels, }, sendTurn, diff --git a/server/drivers/acp/remote.test.ts b/server/drivers/acp/remote.test.ts new file mode 100644 index 0000000000..72540c1d03 --- /dev/null +++ b/server/drivers/acp/remote.test.ts @@ -0,0 +1,435 @@ +import { chmodSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ensureDirs } from "../../config.ts"; +import type { ProviderInstance } from "../../contracts.ts"; +import { BUILT_IN_DRIVERS } from "../builtIn.ts"; +import { removeTempDir } from "../../testing/cleanup.ts"; +import { recordEvents, type EventRecorder } from "../../testing/events.ts"; +import { + classifyRemoteAcpError, + createRemoteAcpDriver, + decodeRemoteAcpConfig, + mergeRemoteAcpCatalog, + parseRemoteAcpCatalog, + RemoteAcpDriver, + remoteAcpSpawnArgs, + type RemoteAcpRunner, +} from "./remote.ts"; + +const FAKE_CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "testing", "fake-acp-cli.ts"); +const BASE = { cli: "acp", fullAuto: false, workspace: undefined }; + +// A hosted-agent listing as one such CLI prints it: rows carry more than the +// contract reads, and one of them cannot be driven over ACP. +const AGENT_LIST = JSON.stringify([ + { acp: true, id: "a42e20f6-45e8-4d89-b24a-c428c8cc853c", name: "homelab-builder", runtime: "claude", extra: 3 }, + { acp: false, id: "0d3f0f2e-0000-4000-8000-000000000001", name: "gem", runtime: "gemini" }, + { acp: true, id: "5c1a0000-0000-4000-8000-000000000002", name: "reviewer", runtime: "codex" }, +]); + +describe("Remote ACP config", () => { + it("defaults to running the binary bare with no catalog, no auth probe, and no local MCP", () => { + expect(RemoteAcpDriver.decodeConfig(undefined)).toEqual({ + ...BASE, + args: [], + catalog: undefined, + models: [], + authCheck: undefined, + authMethod: undefined, + mcp: {}, + }); + expect(RemoteAcpDriver.defaultConfig()).toEqual(RemoteAcpDriver.decodeConfig({})); + }); + + it("reads the bridge command, catalog, auth probe, static models, and MCP opt-ins", () => { + expect( + decodeRemoteAcpConfig( + { + cli: "fountain", + args: ["acp", "--agent", "{model}"], + catalog: ["agent", "list", "--json"], + authCheck: ["auth", "whoami"], + authMethod: "cached_token", + models: ["plain-id", { id: "x1", label: "Labelled" }, { id: "x2" }], + mcp: { computer: true }, + fullAuto: true, + }, + { cli: "fountain", fullAuto: true, workspace: undefined }, + ), + ).toEqual({ + cli: "fountain", + fullAuto: true, + workspace: undefined, + args: ["acp", "--agent", "{model}"], + catalog: ["agent", "list", "--json"], + authCheck: ["auth", "whoami"], + authMethod: "cached_token", + models: [ + { id: "plain-id", label: "plain-id" }, + { id: "x1", label: "Labelled" }, + { id: "x2", label: "x2" }, + ], + mcp: { computer: true }, + }); + }); + + it("rejects a malformed entry with the field named, so the shadow row says why", () => { + expect(() => decodeRemoteAcpConfig({ args: "acp --agent x" }, BASE)).toThrow(/config\.args/); + expect(() => decodeRemoteAcpConfig({ catalog: [1] }, BASE)).toThrow(/config\.catalog/); + expect(() => decodeRemoteAcpConfig({ authCheck: {} }, BASE)).toThrow(/config\.authCheck/); + expect(() => decodeRemoteAcpConfig({ models: [{ label: "no id" }] }, BASE)).toThrow(/config\.models/); + expect(() => decodeRemoteAcpConfig({ mcp: { computer: "yes" } }, BASE)).toThrow(/config\.mcp\.computer/); + expect(() => decodeRemoteAcpConfig({ mcp: [] }, BASE)).toThrow(/config\.mcp/); + expect(() => decodeRemoteAcpConfig({ authMethod: 7 }, BASE)).toThrow(/config\.authMethod/); + }); +}); + +describe("Remote ACP catalog", () => { + it("reads an array of {id, name} rows and skips ones marked acp:false", () => { + expect(parseRemoteAcpCatalog(AGENT_LIST)).toEqual([ + { id: "a42e20f6-45e8-4d89-b24a-c428c8cc853c", label: "homelab-builder" }, + { id: "5c1a0000-0000-4000-8000-000000000002", label: "reviewer" }, + ]); + }); + + it("prefers label over name over id", () => { + expect(parseRemoteAcpCatalog(JSON.stringify([{ id: "a", label: "L", name: "N" }, { id: "b", name: "N" }, { id: "c" }]))).toEqual([ + { id: "a", label: "L" }, + { id: "b", label: "N" }, + { id: "c", label: "c" }, + ]); + }); + + it("unwraps the common object envelopes", () => { + for (const key of ["data", "models", "agents", "items"]) { + expect(parseRemoteAcpCatalog(JSON.stringify({ [key]: [{ id: "m1" }] }))).toEqual([{ id: "m1", label: "m1" }]); + } + }); + + it("is empty for garbage, scalars, and rows without a string id", () => { + expect(parseRemoteAcpCatalog("not json")).toEqual([]); + expect(parseRemoteAcpCatalog("42")).toEqual([]); + expect(parseRemoteAcpCatalog(JSON.stringify({ other: [{ id: "x" }] }))).toEqual([]); + expect(parseRemoteAcpCatalog(JSON.stringify([{ name: "no-id" }, null, 7, { id: 9 }, { id: "" }]))).toEqual([]); + }); + + it("merges static entries ahead of listed ones, first id wins, first entry is the default", () => { + expect( + mergeRemoteAcpCatalog( + [{ id: "pinned", label: "Pinned" }, { id: "dup", label: "Static dup" }], + [{ id: "dup", label: "Listed dup" }, { id: "listed", label: "Listed" }], + ), + ).toEqual({ + default: "pinned", + options: [ + { id: "pinned", label: "Pinned" }, + { id: "dup", label: "Static dup" }, + { id: "listed", label: "Listed" }, + ], + }); + expect(mergeRemoteAcpCatalog([], [])).toEqual({ default: "", options: [] }); + }); +}); + +describe("Remote ACP spawn args", () => { + it("substitutes the pick wherever {model} appears", () => { + expect(remoteAcpSpawnArgs(["acp", "--agent", "{model}"], "reviewer")).toEqual(["acp", "--agent", "reviewer"]); + expect(remoteAcpSpawnArgs(["acp", "--agent={model}"], "reviewer")).toEqual(["acp", "--agent=reviewer"]); + expect(remoteAcpSpawnArgs(["run", "{model}", "--tag", "{model}-x"], "m")).toEqual(["run", "m", "--tag", "m-x"]); + }); + + it("passes a template without the placeholder through untouched", () => { + expect(remoteAcpSpawnArgs(["acp", "--log-level", "debug"], "reviewer")).toEqual(["acp", "--log-level", "debug"]); + expect(remoteAcpSpawnArgs([], undefined)).toEqual([]); + }); + + it("drops the placeholder and its flag when nothing is picked, so the bridge runs on its own default", () => { + expect(remoteAcpSpawnArgs(["acp", "--agent", "{model}"], undefined)).toEqual(["acp"]); + expect(remoteAcpSpawnArgs(["acp", "--agent", "{model}"], "")).toEqual(["acp"]); + expect(remoteAcpSpawnArgs(["acp", "--agent={model}"], undefined)).toEqual(["acp"]); + // a positional before the placeholder is not a flag and stays + expect(remoteAcpSpawnArgs(["run", "{model}"], undefined)).toEqual(["run"]); + expect(remoteAcpSpawnArgs(["run", "{model}", "--json"], undefined)).toEqual(["run", "--json"]); + }); +}); + +describe("Remote ACP error classification", () => { + it("treats the bridge's sign-in complaints as a setup problem, not a retry", () => { + for (const text of [ + "credentials for https://f.example were rejected", + "HTTP 401 Unauthorized", + "not signed in — run `fountain auth login`", + "not logged in", + "request unauthenticated", + ]) { + expect(classifyRemoteAcpError(new Error(text))).toBe("invalid_credentials"); + } + }); + + it("leaves everything else unclassified", () => { + expect(classifyRemoteAcpError(new Error('could not resolve agent "x"'))).toBeUndefined(); + expect(classifyRemoteAcpError(new Error("the sandbox never started: quota"))).toBeUndefined(); + expect(classifyRemoteAcpError(new Error("port 4010 in use"))).toBeUndefined(); + expect(classifyRemoteAcpError(undefined)).toBeUndefined(); + }); +}); + +describe("Remote ACP driver", () => { + type RawConfig = Parameters[0]; + const create = (driver: ReturnType, config: RawConfig, environment: Record = {}) => + driver.create({ + instanceId: "remote-test", + displayName: "Remote", + environment, + enabled: true, + config: driver.decodeConfig(config), + }); + + it("is a registered cloud-rail engine with no install recipe (the bridge is yours)", () => { + expect(RemoteAcpDriver.driverKind).toBe("remoteAcp"); + expect(RemoteAcpDriver.metadata).toMatchObject({ displayName: "Remote ACP", access: "subscription" }); + expect(RemoteAcpDriver.install).toBeUndefined(); + expect(BUILT_IN_DRIVERS.some((d) => d.driverKind === "remoteAcp")).toBe(true); + }); + + it("mounts no local MCP integration unless the instance opts in", async () => { + const driver = createRemoteAcpDriver(async () => ({ ok: true, stdout: "[]" })); + const closed = await create(driver, {}); + expect(closed.adapter.capabilities).toMatchObject({ agentsMcp: false, computerMcp: false, composioMcp: false }); + await closed.dispose(); + + const open = await create(driver, { mcp: { computer: true, agents: true } }); + expect(open.adapter.capabilities).toMatchObject({ agentsMcp: true, computerMcp: true, composioMcp: false }); + await open.dispose(); + }); + + it("builds the picker from the catalog command, with the instance's cli and environment", async () => { + const calls: Array<{ cli: string; args: string[]; env: Record }> = []; + let listing = AGENT_LIST; + let ok = true; + const run: RemoteAcpRunner = async (cli, args, env) => { + calls.push({ cli, args, env }); + return { ok, stdout: listing }; + }; + const instance = await create( + createRemoteAcpDriver(run), + { cli: "/opt/fountain/bin/fountain", catalog: ["agent", "list", "--json"] }, + { FOUNTAIN_BASE_URL: "https://fountain.example" }, + ); + expect(calls[0]).toMatchObject({ cli: "/opt/fountain/bin/fountain", args: ["agent", "list", "--json"] }); + expect(calls[0]!.env.FOUNTAIN_BASE_URL).toBe("https://fountain.example"); + expect(instance.models.default).toBe("a42e20f6-45e8-4d89-b24a-c428c8cc853c"); + expect(instance.models.options.map((o) => o.id)).toEqual([ + "a42e20f6-45e8-4d89-b24a-c428c8cc853c", + "5c1a0000-0000-4000-8000-000000000002", + ]); + + // the command failing (signed out, remote down) must not wipe the picker + ok = false; + listing = ""; + await instance.refreshModels?.(); + expect(instance.models.options).toHaveLength(2); + + // a changed listing replaces it + ok = true; + listing = JSON.stringify({ data: [{ id: "new-1", name: "fresh" }] }); + await instance.refreshModels?.(); + expect(instance.models.options).toEqual([{ id: "new-1", label: "fresh" }]); + await instance.dispose(); + }); + + it("lists static models without any catalog command, and ahead of a listed one", async () => { + const calls: string[][] = []; + const run: RemoteAcpRunner = async (_cli, args) => { + calls.push(args); + return { ok: true, stdout: JSON.stringify([{ id: "listed" }]) }; + }; + const statics = await create(createRemoteAcpDriver(run), { models: [{ id: "only", label: "Only" }] }); + expect(calls).toEqual([]); + expect(statics.models).toEqual({ default: "only", options: [{ id: "only", label: "Only" }] }); + await statics.dispose(); + + const both = await create(createRemoteAcpDriver(run), { models: ["pinned"], catalog: ["list"] }); + expect(both.models.options.map((o) => o.id)).toEqual(["pinned", "listed"]); + await both.dispose(); + }); + + describe("sign-in probe", () => { + beforeEach(() => chmodSync(FAKE_CLI, 0o755)); + + it("trusts the bridge when no authCheck is configured", async () => { + const calls: string[][] = []; + const instance = await create( + createRemoteAcpDriver(async (_cli, args) => (calls.push(args), { ok: false, stdout: "" })), + { cli: FAKE_CLI }, + ); + expect(await instance.snapshot()).toMatchObject({ state: "available", authenticated: true }); + expect(calls).toEqual([]); + await instance.dispose(); + }); + + it("otherwise runs authCheck and reports its exit status", async () => { + const calls: string[][] = []; + let signedIn = false; + const run: RemoteAcpRunner = async (_cli, args) => { + calls.push(args); + return { ok: signedIn, stdout: "" }; + }; + const instance = await create(createRemoteAcpDriver(run), { cli: FAKE_CLI, authCheck: ["auth", "whoami"] }); + expect(await instance.snapshot()).toMatchObject({ state: "available", authenticated: false }); + signedIn = true; + expect(await instance.snapshot()).toMatchObject({ state: "available", authenticated: true }); + expect(calls).toContainEqual(["auth", "whoami"]); + await instance.dispose(); + }); + + it("is unavailable when the bridge binary is not installed", async () => { + const instance = await create(createRemoteAcpDriver(async () => ({ ok: true, stdout: "" })), { + cli: "remote-acp-bridge-that-does-not-exist", + }); + expect(await instance.snapshot()).toMatchObject({ state: "unavailable" }); + await instance.dispose(); + }); + }); + + describe("turns through the fake ACP CLI", () => { + let instance: ProviderInstance; + let recorder: EventRecorder; + let scratch: string; + + beforeEach(() => { + ensureDirs(); + chmodSync(FAKE_CLI, 0o755); + scratch = mkdtempSync(join(tmpdir(), "omb-remote-acp-test-")); + }); + + afterEach(async () => { + delete process.env.FAKE_ACP_MODE; + delete process.env.FAKE_ACP_DUMP; + delete process.env.FAKE_ACP_RPC_DUMP; + recorder?.stop(); + await instance?.dispose(); + await removeTempDir(scratch); + }); + + const start = async (config: RawConfig = {}, environment: Record = {}) => { + instance = await create( + createRemoteAcpDriver(async () => ({ ok: true, stdout: AGENT_LIST })), + { cli: FAKE_CLI, args: ["acp", "--agent", "{model}"], catalog: ["agent", "list", "--json"], ...config }, + environment, + ); + recorder = recordEvents(instance.adapter); + }; + + it("runs a turn on the picked entry and normalizes the canonical event sequence", async () => { + await start({}, { OMB_TEST_REMOTE_TOKEN: "rt_test" }); + const dump = join(scratch, "dump.json"); + process.env.FAKE_ACP_DUMP = dump; + + const { turnId } = await instance.adapter.sendTurn({ + threadId: "t-remote", + text: "hi", + model: "a42e20f6-45e8-4d89-b24a-c428c8cc853c", + system: "You are Maus.", + }); + await recorder.until((e) => e.type === "turn.completed"); + + expect(recorder.events.map((e) => e.type)).toEqual([ + "turn.started", + "session.started", + "content.delta", + "item.started", + "item.completed", + "thread.token-usage.updated", + "item.completed", + "turn.completed", + ]); + expect(recorder.events.every((e) => e.turnId === turnId && e.provider === "remoteAcp")).toBe(true); + expect(recorder.events.at(-1)).toMatchObject({ type: "turn.completed", ok: true }); + + const seen = JSON.parse(readFileSync(dump, "utf8")); + expect(seen.argv).toEqual(["acp", "--agent", "a42e20f6-45e8-4d89-b24a-c428c8cc853c"]); + // the bridge reads its own credentials from the child environment + expect(seen.env.OMB_TEST_REMOTE_TOKEN).toBe("rt_test"); + }); + + it("records the ACP session id as the resume cursor", async () => { + await start(); + await instance.adapter.sendTurn({ threadId: "t-cursor", text: "hi", model: "reviewer" }); + const started = await recorder.until((e) => e.type === "session.started"); + if (started.type !== "session.started") throw new Error("expected session.started"); + expect(started.sessionId).toBeTruthy(); + }); + + it("skips the wire authenticate step by default", async () => { + await start(); + const rpcDump = join(scratch, "rpc.json"); + process.env.FAKE_ACP_RPC_DUMP = rpcDump; + await instance.adapter.sendTurn({ threadId: "t-auth", text: "hi", model: "reviewer" }); + await recorder.until((e) => e.type === "turn.completed"); + // SAFETY: FAKE_ACP_RPC_DUMP is the fake's own JSON array of method names. + const methods = JSON.parse(readFileSync(rpcDump, "utf8")) as string[]; + expect(methods).not.toContain("authenticate"); + expect(methods).toContain("session/new"); + expect(methods).toContain("session/prompt"); + }); + + it("authenticates with the configured method when the agent advertises it", async () => { + await start({ authMethod: "cached_token" }); + const rpcDump = join(scratch, "rpc.json"); + process.env.FAKE_ACP_RPC_DUMP = rpcDump; + await instance.adapter.sendTurn({ threadId: "t-auth-on", text: "hi", model: "reviewer" }); + await recorder.until((e) => e.type === "turn.completed"); + expect(JSON.parse(readFileSync(rpcDump, "utf8"))).toContain("authenticate"); + }); + + it("does not send authenticate for a method the agent never offered", async () => { + await start({ authMethod: "something-else" }); + const rpcDump = join(scratch, "rpc.json"); + process.env.FAKE_ACP_RPC_DUMP = rpcDump; + await instance.adapter.sendTurn({ threadId: "t-auth-off", text: "hi", model: "reviewer" }); + await recorder.until((e) => e.type === "turn.completed"); + expect(JSON.parse(readFileSync(rpcDump, "utf8"))).not.toContain("authenticate"); + }); + + it("hands the bridge no mcpServers by default — a remote agent never sees this machine's tools", async () => { + await start(); + const dump = join(scratch, "dump.json"); + process.env.FAKE_ACP_DUMP = dump; + await instance.adapter.sendTurn({ + threadId: "t-mcp-off", + text: "hi", + model: "reviewer", + integrations: { + localComputer: { command: "node", args: ["-e", "0"], env: { SECRET: "never-sent" } }, + agents: { command: "node", args: ["-e", "0"], env: {} }, + }, + }); + await recorder.until((e) => e.type === "turn.completed"); + expect(JSON.parse(readFileSync(`${dump}.mcp.json`, "utf8"))).toEqual([]); + }); + + it("forwards exactly the mounts the instance opted into", async () => { + await start({ mcp: { computer: true } }); + const dump = join(scratch, "dump.json"); + process.env.FAKE_ACP_DUMP = dump; + await instance.adapter.sendTurn({ + threadId: "t-mcp-on", + text: "hi", + model: "reviewer", + integrations: { + localComputer: { command: "node", args: ["-e", "0"], env: {} }, + agents: { command: "node", args: ["-e", "0"], env: {} }, + }, + }); + await recorder.until((e) => e.type === "turn.completed"); + // SAFETY: the fake writes session/new's mcpServers array verbatim, each entry named. + const servers = JSON.parse(readFileSync(`${dump}.mcp.json`, "utf8")) as Array<{ name: string }>; + expect(servers.map((s) => s.name)).toEqual(["computer"]); + }); + }); +}); diff --git a/server/drivers/acp/remote.ts b/server/drivers/acp/remote.ts new file mode 100644 index 0000000000..d8fade003a --- /dev/null +++ b/server/drivers/acp/remote.ts @@ -0,0 +1,275 @@ +// Remote ACP — a provider-neutral engine for any command that speaks the +// Agent Client Protocol on stdio for an agent that EXECUTES SOMEWHERE ELSE: +// a hosted sandbox service's CLI (`fountain acp --agent x`), an agent on +// another machine (`ssh box -- codex acp`), a container (`docker exec -i …`). +// Nothing about the provider is hard-coded; the instance's `config` says +// how to start the bridge, how to list what the picker can choose, and how +// to tell whether it is signed in. +// +// { +// "instances": { +// "fountain": { +// "driver": "remoteAcp", +// "displayName": "Fountain", +// "config": { +// "cli": "fountain", +// "args": ["acp", "--agent", "{model}"], +// "catalog": ["agent", "list", "--json"], +// "authCheck": ["auth", "whoami"] +// }, +// "environment": { "FOUNTAIN_API_KEY": "…" } +// } +// } +// } +// +// What "remote" changes, and what this support therefore encodes: +// +// • The picker chooses whatever the catalog command lists — a model, an +// agent, a profile, an environment: the remote side's unit of choice — +// and the pick lands in the command line where `{model}` appears. ACP +// has no field for it; the bridge's argv is the only channel. +// • The agent does not run on this machine, so by default no local MCP +// integration is mounted: the bot is never told it has a computer, a +// Composio connection, or peers that its driver cannot hand it. A bridge +// that DOES forward mcpServers (ssh to a box that runs them there) can +// opt back in per mount with `config.mcp`. +// • Credentials are the bridge's own. No `authenticate` RPC is sent +// unless `config.authMethod` names one; sign-in is probed with +// `config.authCheck` (exit 0 = signed in) and trusted otherwise. +// • The ACP session id is whatever the remote side uses for a +// conversation, so the resume cursor core.ts already stores survives +// restarts and machines when the bridge implements session/load. +// +// The catalog contract is deliberately small: print JSON — an array, or an +// object whose `data`/`models`/`agents`/`items` is one — of rows with a +// string `id` and optionally `label` or `name`. A row with `acp: false` +// (the remote side's way of saying this entry cannot be driven over the +// protocol) is left out. Anything else is ignored. Static `models` work +// too, for bridges with nothing to list, and merge ahead of the command's. +import { z } from "zod"; + +import { execCli } from "../../procs.ts"; +import { parseJson, schemaIssue } from "../../schema.ts"; + +import type { ModelCatalog, ProviderErrorCode } from "../../contracts.ts"; +import { createAcpDriver, type AcpConfig, type AcpMcpMounts, type AcpSupport } from "./core.ts"; + +const EMPTY: ModelCatalog = { default: "", options: [] }; +const CLI_TIMEOUT = 20_000; +/** Where the picked catalog id lands in `args`. */ +export const MODEL_PLACEHOLDER = "{model}"; + +export interface RemoteAcpConfig extends AcpConfig { + /** argv after the binary that enters ACP stdio mode. `{model}` is replaced + * by the picker's choice; see {@link remoteAcpSpawnArgs} for the empty case. */ + args: string[]; + /** argv after the binary that prints the picker catalog as JSON. */ + catalog?: string[]; + /** Static picker entries, listed ahead of whatever `catalog` returns. */ + models: Array<{ id: string; label: string }>; + /** argv after the binary whose exit status answers "signed in?". Omitted: + * the bridge is trusted to hold its own credentials. */ + authCheck?: string[]; + /** ACP authenticate methodId to use when the agent advertises it. Omitted: + * never call authenticate — the bridge already holds the credentials. */ + authMethod?: string; + /** Which local MCP integrations the bridge forwards to the remote agent. + * All false unless set: a remote agent cannot reach this machine. */ + mcp: AcpMcpMounts; +} + +const argvSchema = z.array(z.string()); +const modelRowSchema = z.union([ + z.string().min(1).transform((id) => ({ id, label: id })), + z.object({ id: z.string().min(1), label: z.string().optional() }).transform((row) => ({ + id: row.id, + label: row.label || row.id, + })), +]); +const mountsSchema = z.object({ + agents: z.boolean().optional(), + computer: z.boolean().optional(), + composio: z.boolean().optional(), +}); +/** The remote-specific keys of the instance `config` envelope; the shared + * cli/fullAuto/workspace triple is read by core before this runs. */ +const remoteConfigSchema = z.object({ + args: argvSchema.optional(), + catalog: argvSchema.optional(), + models: z.array(modelRowSchema).optional(), + authCheck: argvSchema.optional(), + authMethod: z.string().optional(), + mcp: mountsSchema.optional(), +}); + +/** Read the remote-specific settings out of the instance's `config`. Throws on + * a malformed entry so the registry shows WHY the engine is a shadow instead + * of running a command that was never what the user meant. */ +export function decodeRemoteAcpConfig(raw: Record, base: AcpConfig): RemoteAcpConfig { + const parsed = remoteConfigSchema.safeParse(raw); + if (!parsed.success) throw new Error(`remoteAcp config.${schemaIssue(parsed.error, "is not valid")}`); + const { args, catalog, models, authCheck, authMethod, mcp } = parsed.data; + return { + ...base, + args: args ?? [], + catalog, + models: models ?? [], + authCheck, + authMethod: authMethod || undefined, + mcp: mcp ?? {}, + }; +} + +/** One catalog row as the contract reads it; anything else is ignored. */ +const catalogRowSchema = z.object({ + id: z.string().min(1), + label: z.string().optional(), + name: z.string().optional(), + acp: z.boolean().optional(), +}); +const rowsSchema = z.array(z.unknown()); +/** An array, or the common object envelopes around one. */ +const catalogSchema = z.union([ + rowsSchema, + z + .object({ + data: rowsSchema.optional(), + models: rowsSchema.optional(), + agents: rowsSchema.optional(), + items: rowsSchema.optional(), + }) + .transform((o) => o.data ?? o.models ?? o.agents ?? o.items ?? []), +]); + +/** Turn the catalog command's JSON into picker options. See the contract in + * the header: rows with a string `id`; `label` else `name` else the id; a + * row marked `acp: false` is skipped. */ +export function parseRemoteAcpCatalog(json: string): ModelCatalog["options"] { + let parsed: unknown; + try { + parsed = parseJson(json); + } catch { + return []; + } + const rows = catalogSchema.safeParse(parsed); + if (!rows.success) return []; + const options: ModelCatalog["options"] = []; + for (const raw of rows.data) { + const row = catalogRowSchema.safeParse(raw); + if (!row.success || row.data.acp === false) continue; + options.push({ id: row.data.id, label: row.data.label || row.data.name || row.data.id }); + } + return options; +} + +/** Static entries first, then the command's, first occurrence of an id wins. */ +export function mergeRemoteAcpCatalog( + statics: ModelCatalog["options"], + listed: ModelCatalog["options"], +): ModelCatalog { + const seen = new Set(); + const options: ModelCatalog["options"] = []; + for (const option of [...statics, ...listed]) { + if (seen.has(option.id)) continue; + seen.add(option.id); + options.push(option); + } + return { default: options[0]?.id ?? "", options }; +} + +/** Substitute the pick into the argv template. With nothing picked (empty + * catalog, or the picker left blank) the `{model}` argument is dropped, and + * so is a directly preceding option (`--agent`, `-m`) that would otherwise + * be left dangling; the bridge then runs on its own default, or reports + * that it has none — its error, in its words, not ours. */ +export function remoteAcpSpawnArgs(template: readonly string[], model: string | undefined): string[] { + const out: string[] = []; + for (const arg of template) { + if (!arg.includes(MODEL_PLACEHOLDER)) { + out.push(arg); + continue; + } + if (model) { + out.push(arg.split(MODEL_PLACEHOLDER).join(model)); + continue; + } + if (arg === MODEL_PLACEHOLDER && out.length && /^-/.test(out[out.length - 1]!)) out.pop(); + } + return out; +} + +/** The bridge's own words for "you are not signed in", as far as any one + * pattern can be provider-neutral. Sign-in is a user action, not a retry. */ +export function classifyRemoteAcpError(error: unknown): ProviderErrorCode | undefined { + const message = error instanceof Error ? error.message : String(error ?? ""); + if (/not (signed|logged) in|not authenticated|unauthenticated|credentials .*rejected|\b401\b|unauthori[sz]ed/i.test(message)) { + return "invalid_credentials"; + } + return undefined; +} + +/** How the catalog and sign-in probes reach the bridge; injectable so tests + * need no binary. */ +export type RemoteAcpRunner = ( + cli: string, + args: string[], + env: Record, +) => Promise<{ ok: boolean; stdout: string }>; + +const runCli: RemoteAcpRunner = (cli, args, env) => + new Promise((resolve) => { + // SAFETY: the env map is process.env + instance environment (string or + // undefined values), which is exactly NodeJS.ProcessEnv's shape. + execCli(cli, args, { timeout: CLI_TIMEOUT, env: env as NodeJS.ProcessEnv }, (err, stdout) => + resolve({ ok: !err, stdout }), + ); + }); + +export function createRemoteAcpDriver(run: RemoteAcpRunner = runCli) { + const support: AcpSupport = { + driverKind: "remoteAcp", + displayName: "Remote ACP", + // Cloud rail: whatever the bridge lists IS the catalog (agents on a + // hosted service, models on another box). "custom" would send the + // picker to the local-models pane, where a remote agent has nothing. + access: "subscription", + models: EMPTY, + defaultCli: "acp", + decodeConfig: decodeRemoteAcpConfig, + resolveModels: async (env, config) => { + let listed: ModelCatalog["options"] = []; + if (config.catalog) { + const { ok, stdout } = await run(config.cli, config.catalog, env); + // a failed listing throws so core keeps the last usable catalog + // instead of emptying the picker while the remote side is down + if (!ok) throw new Error("catalog command failed"); + listed = parseRemoteAcpCatalog(stdout); + } + return mergeRemoteAcpCatalog(config.models, listed); + }, + nativeSource: "remote.acp", + loginNote: "the remote ACP command is not signed in", + mcp: (config) => ({ + agents: config.mcp.agents ?? false, + computer: config.mcp.computer ?? false, + composio: config.mcp.composio ?? false, + }), + spawnArgs: (config, turn) => remoteAcpSpawnArgs(config.args, turn.model), + // The wire authenticate step is opt-in by method id: most bridges hold + // their credentials themselves and advertise `authenticate` only to say + // "go sign in". A missing login then fails session/new with an error + // the classifier below turns into a setup prompt. + pickAuthMethod: (methods, config) => + config.authMethod && methods.some((m) => m.id === config.authMethod) ? config.authMethod : null, + authFailure: "continue", + isAuthenticated: async (env, config) => { + if (!config.authCheck) return true; + const { ok } = await run(config.cli, config.authCheck, env); + return ok; + }, + classifyError: classifyRemoteAcpError, + }; + return createAcpDriver(support); +} + +export const RemoteAcpDriver = createRemoteAcpDriver(); diff --git a/server/drivers/builtIn.ts b/server/drivers/builtIn.ts index adbb7fe188..befde82ca0 100644 --- a/server/drivers/builtIn.ts +++ b/server/drivers/builtIn.ts @@ -13,6 +13,7 @@ import { DroidAgentDriver } from "./acp/droid.ts"; import { OpenCodeGoDriver } from "./acp/opencode-go.ts"; import { QwenAgentDriver } from "./acp/qwen.ts"; import { HermesAgentDriver } from "./acp/hermes.ts"; +import { RemoteAcpDriver } from "./acp/remote.ts"; export const BUILT_IN_DRIVERS: readonly AnyProviderDriver[] = [ GrokDriver, @@ -23,6 +24,7 @@ export const BUILT_IN_DRIVERS: readonly AnyProviderDriver[] = [ OpenCodeGoDriver, QwenAgentDriver, HermesAgentDriver, + RemoteAcpDriver, ClaudeDriver, CodexDriver, AntigravityDriver, diff --git a/server/testing/fake-acp-cli.ts b/server/testing/fake-acp-cli.ts index b845093b4f..897ef93aa3 100755 --- a/server/testing/fake-acp-cli.ts +++ b/server/testing/fake-acp-cli.ts @@ -71,6 +71,7 @@ if (process.env.FAKE_ACP_DUMP) { "ANTHROPIC_API_KEY", "XAI_API_KEY", "UNSLOTH_STUDIO_AUTH_TOKEN", + "OMB_TEST_REMOTE_TOKEN", ].flatMap((key) => (process.env[key] === undefined ? [] : [[key, process.env[key]]] as const)), ); writeFileSync(process.env.FAKE_ACP_DUMP, JSON.stringify({ argv, env: dumpEnv }, null, 2)); diff --git a/src/components/ProviderIcons.tsx b/src/components/ProviderIcons.tsx index 49c6f51312..e4a53c7e76 100644 --- a/src/components/ProviderIcons.tsx +++ b/src/components/ProviderIcons.tsx @@ -1,5 +1,5 @@ // Provider brand marks, keyed by driver kind. Official logos only. -import { Monitor } from "lucide-react"; +import { Monitor, Radio } from "lucide-react"; import { cn } from "@/lib/cn"; import { HermesMark } from "./HermesMark"; @@ -39,6 +39,11 @@ export function ComputerMark({ size = 16, className }: IconProps) { return ; } +/** Remote ACP: an agent that executes somewhere else, driven over the protocol. */ +export function RemoteAcpMark({ size = 16, className }: IconProps) { + return ; +} + /** Official Kimi mark (Moonshot). */ export function KimiMark({ size = 16, className }: IconProps) { return ( @@ -131,6 +136,8 @@ export function ProviderMark({ driverKind, size, className }: IconProps & { driv return ; case "boxAgent": return ; + case "remoteAcp": + return ; default: return ( From e5cb300fdfb989664fbf155bc4c801c7c8459395 Mon Sep 17 00:00:00 2001 From: Jake Gaylor Date: Wed, 19 Aug 2026 09:13:20 -0400 Subject: [PATCH 2/2] Drop the dangling flag for embedded {model} too; await turn.completed in the cursor test Co-Authored-By: Claude Fable 5 --- server/drivers/acp/remote.test.ts | 3 +++ server/drivers/acp/remote.ts | 10 +++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/server/drivers/acp/remote.test.ts b/server/drivers/acp/remote.test.ts index 72540c1d03..a7df387a22 100644 --- a/server/drivers/acp/remote.test.ts +++ b/server/drivers/acp/remote.test.ts @@ -151,6 +151,8 @@ describe("Remote ACP spawn args", () => { expect(remoteAcpSpawnArgs(["acp", "--agent", "{model}"], undefined)).toEqual(["acp"]); expect(remoteAcpSpawnArgs(["acp", "--agent", "{model}"], "")).toEqual(["acp"]); expect(remoteAcpSpawnArgs(["acp", "--agent={model}"], undefined)).toEqual(["acp"]); + // an embedded placeholder is still the flag's value + expect(remoteAcpSpawnArgs(["acp", "--agent", "profile-{model}"], undefined)).toEqual(["acp"]); // a positional before the placeholder is not a flag and stays expect(remoteAcpSpawnArgs(["run", "{model}"], undefined)).toEqual(["run"]); expect(remoteAcpSpawnArgs(["run", "{model}", "--json"], undefined)).toEqual(["run", "--json"]); @@ -363,6 +365,7 @@ describe("Remote ACP driver", () => { const started = await recorder.until((e) => e.type === "session.started"); if (started.type !== "session.started") throw new Error("expected session.started"); expect(started.sessionId).toBeTruthy(); + await recorder.until((e) => e.type === "turn.completed"); }); it("skips the wire authenticate step by default", async () => { diff --git a/server/drivers/acp/remote.ts b/server/drivers/acp/remote.ts index d8fade003a..37109e4c03 100644 --- a/server/drivers/acp/remote.ts +++ b/server/drivers/acp/remote.ts @@ -178,10 +178,10 @@ export function mergeRemoteAcpCatalog( } /** Substitute the pick into the argv template. With nothing picked (empty - * catalog, or the picker left blank) the `{model}` argument is dropped, and - * so is a directly preceding option (`--agent`, `-m`) that would otherwise - * be left dangling; the bridge then runs on its own default, or reports - * that it has none — its error, in its words, not ours. */ + * catalog, or the picker left blank) every argument carrying `{model}` is + * dropped, and so is a directly preceding option (`--agent`, `-m`) that + * would otherwise be left dangling; the bridge then runs on its own + * default, or reports that it has none — its error, in its words, not ours. */ export function remoteAcpSpawnArgs(template: readonly string[], model: string | undefined): string[] { const out: string[] = []; for (const arg of template) { @@ -193,7 +193,7 @@ export function remoteAcpSpawnArgs(template: readonly string[], model: string | out.push(arg.split(MODEL_PLACEHOLDER).join(model)); continue; } - if (arg === MODEL_PLACEHOLDER && out.length && /^-/.test(out[out.length - 1]!)) out.pop(); + if (out.length && /^-/.test(out[out.length - 1]!)) out.pop(); } return out; }