Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
180 changes: 180 additions & 0 deletions docs/remote-acp.md
Original file line number Diff line number Diff line change
@@ -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.
90 changes: 64 additions & 26 deletions server/drivers/acp/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<C extends AcpConfig = AcpConfig> {
driverKind: string;
displayName: string;
/** Omit for subscription CLIs (the default). Custom-only CLIs sit below
Expand All @@ -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<string, string | undefined>): ModelCatalog | Promise<ModelCatalog>;
/** 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<string, unknown>, 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<string, string | undefined>, config: C): ModelCatalog | Promise<ModelCatalog>;
/** 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
Expand All @@ -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<string, string | undefined>, config: AcpConfig): void;
transformEnv?(env: Record<string, string | undefined>, 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<string, string | undefined>, config: AcpConfig): boolean | Promise<boolean>;
isAuthenticated(env: Record<string, string | undefined>, config: C): boolean | Promise<boolean>;
/** Classify provider-native failures without coupling the core to messages. */
classifyError?(error: unknown): ProviderErrorCode | undefined;
/** Compose the session/prompt text. Default prepends the persona. */
Expand All @@ -110,11 +129,18 @@ export interface AcpSupport {
configureSession?(ctx: {
request: (method: string, params: unknown, timeoutMs?: number) => Promise<any>;
sessionId: string;
config: AcpConfig;
config: C;
turn: SendTurnInput;
}): Promise<void>;
}

/** 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;
Expand All @@ -131,21 +157,26 @@ const PROVIDER_CREDENTIAL_ENV = [
"XAI_API_KEY",
] as const;

function decodeAcpConfig(defaultCli: string) {
return (raw: unknown): AcpConfig => {
function decodeAcpConfig<C extends AcpConfig>(support: AcpSupport<C>) {
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<string, unknown>;
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<AcpConfig> {
export function createAcpDriver<C extends AcpConfig = AcpConfig>(support: AcpSupport<C>): ProviderDriver<C> {
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.";

Expand All @@ -161,8 +192,12 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver<AcpConfig>
decodeConfig,
defaultConfig: () => decodeConfig({}),

async create(input: DriverCreateInput<AcpConfig>): Promise<ProviderInstance> {
async create(input: DriverCreateInput<C>): Promise<ProviderInstance> {
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<string, string | undefined> = {
...process.env,
Expand All @@ -180,7 +215,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver<AcpConfig>
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.
Expand Down Expand Up @@ -215,11 +250,14 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver<AcpConfig>
const servers: Array<{ name: string; command: string; args: string[]; env: Array<{ name: string; value: string }> }> = [];
const acpEnv = (env: Record<string, string>) =>
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",
Expand All @@ -231,15 +269,15 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver<AcpConfig>
// 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",
command: process.execPath,
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",
Expand Down Expand Up @@ -513,7 +551,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver<AcpConfig>
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);
Expand Down Expand Up @@ -671,9 +709,9 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver<AcpConfig>
provider: DRIVER_KIND,
capabilities: {
sessionModelSwitch: "unsupported",
agentsMcp: true,
computerMcp: true,
composioMcp: true,
agentsMcp: mountsAgents,
computerMcp: mountsComputer,
composioMcp: mountsComposio,
effortLevels: support.effortLevels,
},
sendTurn,
Expand Down
Loading