From 301a71b38224ce421d45ce560155fd968773ce22 Mon Sep 17 00:00:00 2001 From: Jake Gaylor Date: Wed, 19 Aug 2026 00:37:45 -0400 Subject: [PATCH 1/2] Add a Fountain engine: `fountain acp` as an ACP driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fountain (BinaryBourbon/fountain) runs agents in sandboxes on a hosted instance; its CLI's `fountain acp` speaks the Agent Client Protocol on stdio, so it slots into the existing ACP driver core as one more support (server/drivers/acp/fountain.ts). Three things are different from every other ACP harness, and the support encodes each: - The model picker chooses a Fountain *agent*: an agent already carries its model, runtime, skills, MCP servers and environment. The catalog is `fountain agent list --json` filtered to ACP-capable runtimes, and the pick becomes `fountain acp --agent `. A failed listing keeps the last catalog instead of emptying the picker. - The ACP session id is the Fountain conversation id, so the resume cursor core.ts already stores survives restarts and machines. - `fountain acp` ignores mcpServers/cwd (the agent runs elsewhere), so the support declares no MCP integrations. That needed a small core hook — AcpSupport.mcp — so a bot is never told it has a computer or peers its driver cannot mount. spawnArgs also now receives the child env, so per-instance knobs (FOUNTAIN_ACP_VAULT / _ENVIRONMENT → --vault / --environment) can become flags. Credentials stay the CLI's: sign-in is probed with `fountain auth whoami` (or trusted from FOUNTAIN_API_KEY), and no authenticate RPC is sent — the adapter advertises none when the CLI is signed in. Registered in builtIn.ts and the default fleet as a custom-only engine beside qwen/hermes, with a provider mark, docs/fountain.md, and a test file that runs the driver against the shared fake ACP CLI (argv, env plumbing, catalog, sign-in probe, canonical event sequence). Verified live against a real instance: 24-agent catalog, one turn streamed to turn.completed, the conversation visible in `fountain conv list`. Co-Authored-By: Claude Fable 5 --- docs/fountain.md | 97 ++++++++ server/config.test.ts | 4 +- server/config.ts | 2 + server/drivers/acp/core.ts | 21 +- server/drivers/acp/fountain.test.ts | 349 ++++++++++++++++++++++++++++ server/drivers/acp/fountain.ts | 165 +++++++++++++ server/drivers/builtIn.ts | 2 + server/testing/fake-acp-cli.ts | 2 + server/testing/live-fountain.ts | 39 ++++ src/components/FountainMark.tsx | 33 +++ src/components/ProviderIcons.tsx | 5 +- 11 files changed, 711 insertions(+), 8 deletions(-) create mode 100644 docs/fountain.md create mode 100644 server/drivers/acp/fountain.test.ts create mode 100644 server/drivers/acp/fountain.ts create mode 100644 server/testing/live-fountain.ts create mode 100644 src/components/FountainMark.tsx diff --git a/docs/fountain.md b/docs/fountain.md new file mode 100644 index 000000000..5a27cdd76 --- /dev/null +++ b/docs/fountain.md @@ -0,0 +1,97 @@ +# Fountain + +[Fountain](https://github.com/BinaryBourbon/fountain) is an optional +OpenMausBot engine, and the one engine whose agents do **not** run on your +machine. OpenMausBot spawns `fountain acp` — the Fountain CLI's +[Agent Client Protocol](https://agentclientprotocol.com) adapter — which +opens a conversation in a sandbox on your Fountain instance and streams it +back. Streaming, resume, and cancellation ride the same ACP runtime as the +other engines; what differs is where the agent lives. + +## Setup + +1. Install the CLI: `brew install BinaryBourbon/tap/fountain` (or a release + binary from the + [releases page](https://github.com/BinaryBourbon/fountain/releases)). +2. Sign in: `fountain auth login`. Point it at your own instance first with + `FOUNTAIN_BASE_URL=https://your-fountain.example fountain auth login`. +3. Restart OpenMausBot (or refresh Settings → Engines). Fountain appears in + the picker rail below the divider, like the other custom-only engines. + +Credentials are the CLI's, never OpenMausBot's: the driver runs `fountain auth +whoami` to decide whether the engine is signed in, and the child process reads +the saved profile itself. To bypass the saved profile, put `FOUNTAIN_API_KEY` +and `FOUNTAIN_BASE_URL` in the instance's `environment` in `config.json`; +`FOUNTAIN_PROFILE` selects a non-default profile the same way. + +## The model picker chooses an agent + +A Fountain *agent* already carries its model, runtime (claude, codex, +opencode, …), skills, MCP servers and environment, so there is nothing left +for a model picker to choose. The picker therefore lists **your Fountain +agents** — `fountain agent list --json`, filtered to runtimes that speak ACP +— and the pick becomes `fountain acp --agent `. Switching a bot's "model" +switches which Fountain agent answers. + +The catalog is refreshed like any live catalog: a failed listing (signed out, +instance down) keeps the last one rather than emptying the picker. + +## Threads survive everything + +The ACP session id **is** the Fountain conversation id, and that is what +OpenMausBot stores as the thread's resume cursor. Quitting the app, rebooting, +or moving to another machine changes nothing: the next message reopens the +same conversation with `session/load`, and its transcript is replayed from the +server. The same conversation is visible in Fountain's web UI and +`fountain conv`. + +## Per-instance vault and environment + +`fountain acp` takes `--vault` (secrets layered over the agent's environment +— an identity the agent posts under, for instance) and `--environment` +(provision from a different environment than the agent's own). Both are +per-*instance* knobs in OpenMausBot, set in the instance's `environment` in +`config.json`: + +```json +{ + "instances": { + "fountain": { "driver": "fountainAgent" }, + "fountain-nostr": { + "driver": "fountainAgent", + "displayName": "Fountain (nostr identity)", + "environment": { "FOUNTAIN_ACP_VAULT": "nostr-identity" } + } + } +} +``` + +`FOUNTAIN_ACP_VAULT` and `FOUNTAIN_ACP_ENVIRONMENT` accept a name or an id. +Two instances pointing at the same agent with different vaults stay separate +engines in the rail — one entry per identity, exactly how the Fountain docs +frame it. + +## What does not apply + +- **No computer, no connected apps, no peer comms.** `fountain acp` ignores + the session's `mcpServers` and `cwd` — the sandbox has its own checkout and + the agent its own MCP configuration — so the driver declares none of + OpenMausBot's MCP integrations. A Fountain bot is never told it has a + computer it cannot reach. +- **No approval cards yet.** Permission requests are not forwarded by + `fountain acp` (sandboxed runtimes run under their own permission mode, + [fountain#643](https://github.com/BinaryBourbon/fountain/issues/643)). + "Auto mode" changes nothing for this engine. +- **No reasoning-effort control.** The agent's model and settings belong to + Fountain. +- **A `gemini`-runtime agent** does not speak ACP and is left out of the + picker ([fountain#659](https://github.com/BinaryBourbon/fountain/issues/659)). + +## Testing + +`server/drivers/acp/fountain.test.ts` runs the driver against the shared fake +ACP CLI and needs no Fountain instance. For a live check with your own +credentials, `node --experimental-strip-types server/testing/live-fountain.ts +` runs one turn through the real driver and prints the canonical +events; `fountain acp --agent ` by hand proves the CLI starts and finds +its credentials. diff --git a/server/config.test.ts b/server/config.test.ts index 3da54f2ff..d2dee7de5 100644 --- a/server/config.test.ts +++ b/server/config.test.ts @@ -30,10 +30,11 @@ describe("configuration boundaries", () => { }); describe("default fleet", () => { - it("ships Qwen and Hermes as custom-only engines", () => { + it("ships Qwen, Hermes and Fountain as custom-only engines", () => { const map = instanceConfigs({}); expect(map.qwen).toEqual({ driver: "qwenAgent", environment: {} }); expect(map.hermes).toEqual({ driver: "hermesAgent", environment: {} }); + expect(map.fountain).toEqual({ driver: "fountainAgent", environment: {} }); }); it("adds missing custom-only engines onto an existing product fleet", () => { @@ -41,6 +42,7 @@ describe("default fleet", () => { expect(map.claude.driver).toBe("claudeAgent"); expect(map.qwen?.driver).toBe("qwenAgent"); expect(map.hermes?.driver).toBe("hermesAgent"); + expect(map.fountain?.driver).toBe("fountainAgent"); }); it("does not expand a one-off shadow fleet", () => { diff --git a/server/config.ts b/server/config.ts index c21d5ace4..48b7c28d1 100644 --- a/server/config.ts +++ b/server/config.ts @@ -219,10 +219,12 @@ export function instanceConfigs(cfg: AppConfig): InstanceConfigMap { computer: { driver: "boxAgent" }, qwen: { driver: "qwenAgent" }, hermes: { driver: "hermesAgent" }, + fountain: { driver: "fountainAgent" }, }; const CUSTOM_ONLY = { qwen: { driver: "qwenAgent" }, hermes: { driver: "hermesAgent" }, + fountain: { driver: "fountainAgent" }, } as const; const configured = cfg.instances && Object.keys(cfg.instances).length ? cfg.instances : null; const map: InstanceConfigMap = configured ? { ...configured } : { ...DEFAULT_FLEET }; diff --git a/server/drivers/acp/core.ts b/server/drivers/acp/core.ts index 0bbc999ea..f4d018326 100644 --- a/server/drivers/acp/core.ts +++ b/server/drivers/acp/core.ts @@ -69,10 +69,19 @@ export interface AcpSupport { 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 (fountain acp: the + * agent runs in a sandbox elsewhere and ignores mcpServers) declares + * false, so a bot is never told it has a computer or peers its driver + * cannot hand it. */ + mcp?: { agents?: boolean; computer?: boolean; composio?: boolean }; /** 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[]; + /** CLI argv AFTER the binary name to enter ACP stdio mode. `env` is the + * child's environment (process env + instance environment, after + * transformEnv), for harnesses whose per-instance knobs become flags. */ + spawnArgs(config: AcpConfig, turn: SendTurnInput, env: Record): 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 @@ -264,7 +273,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver : turn; const mcpServers = acpMcpServers(turn); - const child = spawnCli(config.cli, support.spawnArgs(config, cliTurn), { + const child = spawnCli(config.cli, support.spawnArgs(config, cliTurn, env), { cwd, env, stdio: ["pipe", "pipe", "pipe"], @@ -671,9 +680,9 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver provider: DRIVER_KIND, capabilities: { sessionModelSwitch: "unsupported", - agentsMcp: true, - computerMcp: true, - composioMcp: true, + agentsMcp: support.mcp?.agents ?? true, + computerMcp: support.mcp?.computer ?? true, + composioMcp: support.mcp?.composio ?? true, effortLevels: support.effortLevels, }, sendTurn, diff --git a/server/drivers/acp/fountain.test.ts b/server/drivers/acp/fountain.test.ts new file mode 100644 index 000000000..d0ec5c749 --- /dev/null +++ b/server/drivers/acp/fountain.test.ts @@ -0,0 +1,349 @@ +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 { removeTempDir } from "../../testing/cleanup.ts"; +import { recordEvents, type EventRecorder } from "../../testing/events.ts"; +import { + classifyFountainError, + createFountainAgentDriver, + FountainAgentDriver, + fountainSpawnArgs, + parseFountainAgentCatalog, + type FountainCliRunner, +} from "./fountain.ts"; + +const FAKE_CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "testing", "fake-acp-cli.ts"); + +// Two rows as `fountain agent list --json` prints them (fields the catalog +// ignores trimmed): a claude agent, a gemini agent that cannot speak ACP. +const AGENT_LIST = JSON.stringify([ + { + acp: true, + id: "a42e20f6-45e8-4d89-b24a-c428c8cc853c", + name: "homelab-builder", + runtime: "claude", + model: "anthropic/claude-opus-4-7", + conversation_count: 3, + }, + { acp: false, id: "0d3f0f2e-0000-4000-8000-000000000001", name: "gem", runtime: "gemini", model: "gemini-2.5-pro" }, + { acp: true, id: "5c1a0000-0000-4000-8000-000000000002", name: "reviewer", runtime: "codex", model: "gpt-5" }, +]); + +describe("Fountain agent catalog", () => { + it("lists ACP-capable agents by id, labelled with runtime and model", () => { + const catalog = parseFountainAgentCatalog(AGENT_LIST); + expect(catalog.default).toBe("a42e20f6-45e8-4d89-b24a-c428c8cc853c"); + expect(catalog.options).toEqual([ + { id: "a42e20f6-45e8-4d89-b24a-c428c8cc853c", label: "homelab-builder (claude · anthropic/claude-opus-4-7)" }, + { id: "5c1a0000-0000-4000-8000-000000000002", label: "reviewer (codex · gpt-5)" }, + ]); + }); + + it("drops agents whose runtime does not speak ACP", () => { + const ids = parseFountainAgentCatalog(AGENT_LIST).options.map((o) => o.id); + expect(ids).not.toContain("0d3f0f2e-0000-4000-8000-000000000001"); + }); + + it("is empty for garbage, a non-array, and rows without an id", () => { + expect(parseFountainAgentCatalog("not json")).toEqual({ default: "", options: [] }); + expect(parseFountainAgentCatalog(JSON.stringify({ agents: [] }))).toEqual({ default: "", options: [] }); + expect(parseFountainAgentCatalog(JSON.stringify([{ name: "no-id" }, null, 7]))).toEqual({ + default: "", + options: [], + }); + }); + + it("falls back to the id as the label when the row has no name or detail", () => { + const catalog = parseFountainAgentCatalog(JSON.stringify([{ id: "abc" }])); + expect(catalog.options).toEqual([{ id: "abc", label: "abc" }]); + }); +}); + +describe("Fountain spawn args", () => { + it("names the picked agent and nothing else by default", () => { + expect(fountainSpawnArgs("homelab-builder", {})).toEqual(["acp", "--agent", "homelab-builder"]); + }); + + it("passes an empty model through as no --agent so the adapter reports it", () => { + expect(fountainSpawnArgs(undefined, {})).toEqual(["acp"]); + expect(fountainSpawnArgs("", {})).toEqual(["acp"]); + }); + + it("turns the instance's vault and environment knobs into flags", () => { + expect( + fountainSpawnArgs("a1", { FOUNTAIN_ACP_VAULT: "buzz-identity", FOUNTAIN_ACP_ENVIRONMENT: "staging" }), + ).toEqual(["acp", "--agent", "a1", "--vault", "buzz-identity", "--environment", "staging"]); + }); + + it("leaves credentials and profile to the CLI's own environment lookup", () => { + expect( + fountainSpawnArgs("a1", { FOUNTAIN_API_KEY: "k", FOUNTAIN_BASE_URL: "https://x", FOUNTAIN_PROFILE: "p" }), + ).toEqual(["acp", "--agent", "a1"]); + }); +}); + +describe("Fountain error classification", () => { + it("treats rejected credentials as a sign-in problem, not a retry", () => { + expect(classifyFountainError(new Error("credentials for https://f.example were rejected"))).toBe( + "invalid_credentials", + ); + expect(classifyFountainError(new Error("HTTP 401 Unauthorized"))).toBe("invalid_credentials"); + }); + + it("leaves everything else unclassified", () => { + expect(classifyFountainError(new Error('could not resolve agent "x" on https://f.example'))).toBeUndefined(); + expect(classifyFountainError(new Error("the sandbox never started: quota"))).toBeUndefined(); + expect(classifyFountainError(undefined)).toBeUndefined(); + }); +}); + +describe("Fountain driver", () => { + it("is a custom-only ACP engine over the fountain CLI", () => { + expect(FountainAgentDriver.driverKind).toBe("fountainAgent"); + expect(FountainAgentDriver.metadata).toMatchObject({ displayName: "Fountain", access: "custom" }); + expect(FountainAgentDriver.decodeConfig(undefined)).toEqual({ cli: "fountain", fullAuto: false, workspace: undefined }); + expect(FountainAgentDriver.install?.signInCommand).toBe("fountain auth login"); + expect(FountainAgentDriver.install?.command?.darwin).toContain("brew install"); + }); + + it("declares no MCP integrations: the agent runs in a sandbox that ignores mcpServers", async () => { + const instance = await createFountainAgentDriver(async () => ({ ok: false, stdout: "" })).create({ + instanceId: "fountain-caps", + displayName: "Fountain", + environment: {}, + enabled: true, + config: FountainAgentDriver.defaultConfig(), + }); + expect(instance.adapter.capabilities).toMatchObject({ + sessionModelSwitch: "unsupported", + agentsMcp: false, + computerMcp: false, + composioMcp: false, + }); + expect(instance.adapter.capabilities.effortLevels).toBeUndefined(); + await instance.dispose(); + }); + + it("builds the picker from `fountain agent list --json` and keeps the last catalog on failure", async () => { + const calls: string[][] = []; + let listing = AGENT_LIST; + let ok = true; + const run: FountainCliRunner = async (args) => { + calls.push(args); + return { ok, stdout: listing }; + }; + const instance = await createFountainAgentDriver(run).create({ + instanceId: "fountain-catalog", + displayName: "Fountain", + environment: {}, + enabled: true, + config: FountainAgentDriver.defaultConfig(), + }); + expect(calls).toContainEqual(["agent", "list", "--json"]); + expect(instance.models.options.map((o) => o.id)).toEqual([ + "a42e20f6-45e8-4d89-b24a-c428c8cc853c", + "5c1a0000-0000-4000-8000-000000000002", + ]); + + // the CLI failing (signed out, instance 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([{ id: "new-1", name: "fresh", runtime: "claude", acp: true }]); + await instance.refreshModels?.(); + expect(instance.models.options.map((o) => o.id)).toEqual(["new-1"]); + await instance.dispose(); + }); + + it("gives the catalog runner the instance environment (base URL, key, profile)", async () => { + let seenEnv: Record = {}; + const run: FountainCliRunner = async (_args, env) => { + seenEnv = env; + return { ok: true, stdout: "[]" }; + }; + const instance = await createFountainAgentDriver(run).create({ + instanceId: "fountain-env", + displayName: "Fountain", + environment: { FOUNTAIN_BASE_URL: "https://fountain.example", FOUNTAIN_PROFILE: "work" }, + enabled: true, + config: FountainAgentDriver.defaultConfig(), + }); + expect(seenEnv.FOUNTAIN_BASE_URL).toBe("https://fountain.example"); + expect(seenEnv.FOUNTAIN_PROFILE).toBe("work"); + await instance.dispose(); + }); + + describe("sign-in probe", () => { + it("trusts FOUNTAIN_API_KEY without asking the CLI", async () => { + const calls: string[][] = []; + const run: FountainCliRunner = async (args) => { + calls.push(args); + return { ok: true, stdout: "[]" }; + }; + const instance = await createFountainAgentDriver(run).create({ + instanceId: "fountain-key", + displayName: "Fountain", + environment: { FOUNTAIN_API_KEY: "fk_test" }, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + chmodSync(FAKE_CLI, 0o755); + const snap = await instance.snapshot(); + expect(snap).toMatchObject({ state: "available", authenticated: true }); + expect(calls.some((a) => a[0] === "auth")).toBe(false); + await instance.dispose(); + }); + + it("otherwise asks `fountain auth whoami` and reports its verdict", async () => { + const calls: string[][] = []; + let signedIn = false; + const run: FountainCliRunner = async (args) => { + calls.push(args); + if (args[0] === "auth") return { ok: signedIn, stdout: "" }; + return { ok: true, stdout: "[]" }; + }; + const instance = await createFountainAgentDriver(run).create({ + instanceId: "fountain-whoami", + displayName: "Fountain", + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + chmodSync(FAKE_CLI, 0o755); + 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 CLI is not installed", async () => { + const instance = await createFountainAgentDriver(async () => ({ ok: false, stdout: "" })).create({ + instanceId: "fountain-missing", + displayName: "Fountain", + environment: {}, + enabled: true, + config: { cli: "fountain-cli-that-does-not-exist", fullAuto: false }, + }); + 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-fountain-test-")); + }); + + afterEach(async () => { + delete process.env.FAKE_ACP_MODE; + delete process.env.FAKE_ACP_DUMP; + recorder?.stop(); + await instance?.dispose(); + await removeTempDir(scratch); + }); + + const create = async (environment: Record = {}) => { + instance = await createFountainAgentDriver(async () => ({ ok: true, stdout: AGENT_LIST })).create({ + instanceId: "fountain-e2e", + displayName: "Fountain", + environment, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + recorder = recordEvents(instance.adapter); + }; + + it("runs a turn on the picked agent and normalizes the canonical event sequence", async () => { + await create({ FOUNTAIN_API_KEY: "fk_test", FOUNTAIN_BASE_URL: "https://fountain.example" }); + const dump = join(scratch, "dump.json"); + process.env.FAKE_ACP_DUMP = dump; + + const { turnId } = await instance.adapter.sendTurn({ + threadId: "t-fountain", + 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 === "fountainAgent")).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 CLI reads its own credentials from the child environment + expect(seen.env.FOUNTAIN_API_KEY).toBe("fk_test"); + expect(seen.env.FOUNTAIN_BASE_URL).toBe("https://fountain.example"); + }); + + it("hands the instance's vault and environment overrides to `fountain acp`", async () => { + await create({ FOUNTAIN_ACP_VAULT: "nostr-identity", FOUNTAIN_ACP_ENVIRONMENT: "prod-env" }); + const dump = join(scratch, "dump.json"); + process.env.FAKE_ACP_DUMP = dump; + + await instance.adapter.sendTurn({ threadId: "t-vault", text: "hi", model: "reviewer" }); + await recorder.until((e) => e.type === "turn.completed"); + + const seen = JSON.parse(readFileSync(dump, "utf8")); + expect(seen.argv).toEqual([ + "acp", + "--agent", + "reviewer", + "--vault", + "nostr-identity", + "--environment", + "prod-env", + ]); + }); + + it("keeps the conversation id as the resume cursor: session.started carries the ACP session id", async () => { + await create(); + 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 (the CLI holds the credentials)", async () => { + await create(); + const rpcDump = join(scratch, "rpc.json"); + process.env.FAKE_ACP_RPC_DUMP = rpcDump; + try { + await instance.adapter.sendTurn({ threadId: "t-auth", text: "hi", model: "reviewer" }); + await recorder.until((e) => e.type === "turn.completed"); + const methods = JSON.parse(readFileSync(rpcDump, "utf8")) as string[]; + expect(methods).not.toContain("authenticate"); + expect(methods).toContain("session/new"); + expect(methods).toContain("session/prompt"); + } finally { + delete process.env.FAKE_ACP_RPC_DUMP; + } + }); + }); +}); diff --git a/server/drivers/acp/fountain.ts b/server/drivers/acp/fountain.ts new file mode 100644 index 000000000..159220de9 --- /dev/null +++ b/server/drivers/acp/fountain.ts @@ -0,0 +1,165 @@ +// Fountain — `fountain acp`, the ACP stdio adapter of the Fountain CLI +// (https://github.com/BinaryBourbon/fountain). Unlike every other ACP +// harness here the agent does NOT run on this machine: `fountain acp` is a +// window onto a conversation running in a sandbox on your Fountain instance. +// That changes three things about the driver: +// +// • The "model" picker chooses a Fountain *agent*, not a model. A Fountain +// agent already carries its model, runtime (claude/codex/opencode/…), +// skills, MCP servers and environment; ACP has no field for it, so it +// goes on the command line (`--agent`). The catalog is `fountain agent +// list --json`, filtered to agents whose runtime speaks ACP. +// • The session id IS the Fountain conversation id (ADR 0015), so the +// resume cursor core.ts already keeps survives restarts, machines and +// days: `session/load` replays the transcript from the server. +// • `cwd` and `mcpServers` are ignored by the adapter — the sandbox has +// its own checkout and the agent its own MCP config — so this support +// declares no MCP integrations. A bot must never be told it has a +// computer whose tools its driver cannot mount. +// +// Credentials are the CLI's (`fountain auth login`, or FOUNTAIN_API_KEY + +// FOUNTAIN_BASE_URL in the instance environment). Permission requests are +// not forwarded by `fountain acp` yet (fountain#643): sandboxed runtimes +// run under their own permission mode, so no approval cards appear. +import { execCli } from "../../procs.ts"; + +import type { ModelCatalog, ProviderErrorCode } from "../../contracts.ts"; +import { createAcpDriver, type AcpSupport } from "./core.ts"; + +const EMPTY: ModelCatalog = { default: "", options: [] }; +const CLI_TIMEOUT = 15_000; + +/** One row of `fountain agent list --json`, the fields the catalog reads. */ +interface FountainAgentRow { + id?: unknown; + name?: unknown; + runtime?: unknown; + model?: unknown; + /** false when the agent's runtime does not speak ACP (gemini, fountain#659) */ + acp?: unknown; +} + +/** Turn the CLI's agent listing into a picker catalog. Non-ACP agents are + * dropped: `fountain acp` refuses them at session/new, and a picker entry + * that can never run a turn is worse than none. The id is the agent's UUID + * (stable across renames — `--agent` takes either); the label is the name + * plus the runtime/model so two agents on the same model still read apart. */ +export function parseFountainAgentCatalog(json: string): ModelCatalog { + let rows: unknown; + try { + rows = JSON.parse(json); + } catch { + return EMPTY; + } + if (!Array.isArray(rows)) return EMPTY; + const options: ModelCatalog["options"] = []; + for (const raw of rows as FountainAgentRow[]) { + if (!raw || typeof raw !== "object") continue; + if (typeof raw.id !== "string" || !raw.id) continue; + if (raw.acp === false) continue; + const name = typeof raw.name === "string" && raw.name ? raw.name : raw.id; + const runtime = typeof raw.runtime === "string" ? raw.runtime : ""; + const model = typeof raw.model === "string" ? raw.model : ""; + const detail = [runtime, model].filter(Boolean).join(" · "); + options.push({ id: raw.id, label: detail ? `${name} (${detail})` : name }); + } + return { default: options[0]?.id ?? "", options }; +} + +/** How the catalog reaches the CLI; injectable so tests need no binary. */ +export type FountainCliRunner = ( + args: string[], + env: Record, +) => Promise<{ ok: boolean; stdout: string }>; + +const runFountainCli: FountainCliRunner = (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(cliName(env), args, { timeout: CLI_TIMEOUT, env: env as NodeJS.ProcessEnv }, (err, stdout) => + resolve({ ok: !err, stdout }), + ); + }); + +/** The catalog and the sign-in probe run before any session exists, so + * they only see the instance environment, not the decoded config. A user + * who set a custom `cli` in the instance config can mirror it here. */ +function cliName(env: Record): string { + return env.FOUNTAIN_CLI || "fountain"; +} + +/** Sign-in and agent-resolution failures, in the adapter's own words (see + * docs/integrations/acp.md "When something goes wrong"). Both are user + * actions, not retries. */ +export function classifyFountainError(error: unknown): ProviderErrorCode | undefined { + const message = error instanceof Error ? error.message : String(error ?? ""); + if (/credentials .* were rejected|not signed in|not authenticated|401|unauthorized/i.test(message)) { + return "invalid_credentials"; + } + return undefined; +} + +/** `fountain acp` argv: the agent from the picker, plus the optional vault + * and environment overrides. Those two are per-instance knobs (one Fountain + * engine entry per identity or environment, exactly how the CLI docs frame + * `--vault`/`--environment`), so they ride the instance environment rather + * than the model id. An empty model is passed through as no `--agent`: the + * adapter answers "no Fountain agent configured", which surfaces as the + * turn's runtime.error instead of a guess. */ +export function fountainSpawnArgs(model: string | undefined, env: Record): string[] { + const args = ["acp"]; + if (model) args.push("--agent", model); + if (env.FOUNTAIN_ACP_VAULT) args.push("--vault", env.FOUNTAIN_ACP_VAULT); + if (env.FOUNTAIN_ACP_ENVIRONMENT) args.push("--environment", env.FOUNTAIN_ACP_ENVIRONMENT); + // FOUNTAIN_PROFILE / FOUNTAIN_API_KEY / FOUNTAIN_BASE_URL are read by the + // CLI itself from the environment, so they need no flag here. + return args; +} + +export function createFountainAgentDriver(run: FountainCliRunner = runFountainCli) { + const support: AcpSupport = { + driverKind: "fountainAgent", + displayName: "Fountain", + // No first-party cloud catalog: the picker lists YOUR agents on YOUR + // instance, which is what "custom" means to the picker rail. + access: "custom", + models: EMPTY, + resolveModels: async (env) => { + const { ok, stdout } = await run(["agent", "list", "--json"], env); + if (!ok) return EMPTY; + return parseFountainAgentCatalog(stdout); + }, + defaultCli: "fountain", + nativeSource: "fountain.acp", + loginNote: "Fountain CLI is not signed in — run `fountain auth login`", + install: { + command: { + darwin: "brew install BinaryBourbon/tap/fountain", + linux: "brew install BinaryBourbon/tap/fountain", + }, + docsUrl: "https://github.com/BinaryBourbon/fountain/blob/main/docs/cli.md", + signInCommand: "fountain auth login", + }, + mcp: { agents: false, computer: false, composio: false }, + spawnArgs: (_config, turn, env) => fountainSpawnArgs(turn.model, env), + // `fountain acp` advertises `authenticate` only when the CLI holds no + // credentials, and its one method just says "run fountain auth login" — + // there is nothing to authenticate over the wire. Skip it and let a + // missing login fail session/new with a classified error instead. + pickAuthMethod: () => null, + authFailure: "continue", + isAuthenticated: async (env) => { + if (env.FOUNTAIN_API_KEY) return true; + const { ok } = await run(["auth", "whoami"], env); + return ok; + }, + classifyError: classifyFountainError, + // The Fountain agent carries its own system prompt; the bot persona + // (name/title/description) is prepended like every other ACP harness so + // "who am I to you" still lands. + buildPromptText: (turn) => (turn.system ? `${turn.system}\n\n${turn.text}` : turn.text), + }; + return createAcpDriver(support); +} + +export const FountainAgentDriver = createFountainAgentDriver(); diff --git a/server/drivers/builtIn.ts b/server/drivers/builtIn.ts index adbb7fe18..d1c24d5db 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 { FountainAgentDriver } from "./acp/fountain.ts"; export const BUILT_IN_DRIVERS: readonly AnyProviderDriver[] = [ GrokDriver, @@ -23,6 +24,7 @@ export const BUILT_IN_DRIVERS: readonly AnyProviderDriver[] = [ OpenCodeGoDriver, QwenAgentDriver, HermesAgentDriver, + FountainAgentDriver, ClaudeDriver, CodexDriver, AntigravityDriver, diff --git a/server/testing/fake-acp-cli.ts b/server/testing/fake-acp-cli.ts index b845093b4..2263d7d70 100755 --- a/server/testing/fake-acp-cli.ts +++ b/server/testing/fake-acp-cli.ts @@ -71,6 +71,8 @@ if (process.env.FAKE_ACP_DUMP) { "ANTHROPIC_API_KEY", "XAI_API_KEY", "UNSLOTH_STUDIO_AUTH_TOKEN", + "FOUNTAIN_API_KEY", + "FOUNTAIN_BASE_URL", ].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/server/testing/live-fountain.ts b/server/testing/live-fountain.ts new file mode 100644 index 000000000..6be8ff305 --- /dev/null +++ b/server/testing/live-fountain.ts @@ -0,0 +1,39 @@ +// Live smoke for the Fountain driver against the REAL fountain CLI + instance. +// Not a test: run by hand. `node --experimental-strip-types server/testing/live-fountain.ts ` +import { ensureDirs } from "../config.ts"; +import { FountainAgentDriver } from "../drivers/acp/fountain.ts"; + +ensureDirs(); +const agent = process.argv[2]; +const instance = await FountainAgentDriver.create({ + instanceId: "fountain-live", + displayName: "Fountain", + environment: {}, + enabled: true, + config: FountainAgentDriver.defaultConfig(), +}); +console.log("snapshot", await instance.snapshot()); +console.log("catalog", instance.models.options.length, "agents; default", instance.models.default); +const t0 = Date.now(); +instance.adapter.onEvent((e) => { + const { type } = e; + const extra = + type === "content.delta" ? JSON.stringify((e as any).delta) : + type === "session.started" ? (e as any).sessionId : + type === "turn.completed" ? JSON.stringify({ ok: (e as any).ok, stopReason: (e as any).stopReason }) : + type === "runtime.error" ? (e as any).message : + type === "item.started" ? (e as any).title : ""; + console.log(`+${((Date.now() - t0) / 1000).toFixed(1)}s`, type, extra); +}); +await instance.adapter.sendTurn({ + threadId: "live-thread", + text: "Reply with exactly the word PONG and nothing else.", + model: agent, + system: "You are Maus, a bot in OpenMausBot.", +}); +await new Promise((resolve) => { + const off = instance.adapter.onEvent((e) => { + if (e.type === "turn.completed") { off(); resolve(); } + }); +}); +await instance.dispose(); diff --git a/src/components/FountainMark.tsx b/src/components/FountainMark.tsx new file mode 100644 index 000000000..ca12723fb --- /dev/null +++ b/src/components/FountainMark.tsx @@ -0,0 +1,33 @@ +// Fountain (BinaryBourbon/fountain) — the project ships a raster app icon and +// no vector mark, so this is a plain glyph: a basin with three jets of water. +import { cn } from "@/lib/cn"; + +interface IconProps { + size?: number; + className?: string; +} + +export function FountainMark({ size = 16, className }: IconProps) { + return ( + + {/* jets */} + + + + + {/* basin */} + + + ); +} diff --git a/src/components/ProviderIcons.tsx b/src/components/ProviderIcons.tsx index 49c6f5131..15b665d1a 100644 --- a/src/components/ProviderIcons.tsx +++ b/src/components/ProviderIcons.tsx @@ -2,8 +2,9 @@ import { Monitor } from "lucide-react"; import { cn } from "@/lib/cn"; import { HermesMark } from "./HermesMark"; +import { FountainMark } from "./FountainMark"; -export { HermesMark }; +export { HermesMark, FountainMark }; export interface IconProps { size?: number; @@ -129,6 +130,8 @@ export function ProviderMark({ driverKind, size, className }: IconProps & { driv return ; case "hermesAgent": return ; + case "fountainAgent": + return ; case "boxAgent": return ; default: From f7f5bada94855dada638161179dcaa82305ddb4f Mon Sep 17 00:00:00 2001 From: Jake Gaylor Date: Wed, 19 Aug 2026 00:44:32 -0400 Subject: [PATCH 2/2] Fountain sits in the Cloud rail, not the local-models pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit access: "custom" routes the picker to the local-models (inject) pane, where a catalog of hosted agents never lists — the picker showed "No local models found". The agents run on a hosted instance and the catalog is a real one, so the engine is subscription/cloud-rail like claude and codex. Co-Authored-By: Claude Fable 5 --- docs/fountain.md | 2 +- server/config.test.ts | 2 +- server/config.ts | 2 ++ server/drivers/acp/fountain.test.ts | 4 ++-- server/drivers/acp/fountain.ts | 7 ++++--- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/fountain.md b/docs/fountain.md index 5a27cdd76..5cfa35d47 100644 --- a/docs/fountain.md +++ b/docs/fountain.md @@ -16,7 +16,7 @@ other engines; what differs is where the agent lives. 2. Sign in: `fountain auth login`. Point it at your own instance first with `FOUNTAIN_BASE_URL=https://your-fountain.example fountain auth login`. 3. Restart OpenMausBot (or refresh Settings → Engines). Fountain appears in - the picker rail below the divider, like the other custom-only engines. + the picker's Cloud rail — the agents run on your instance, not here. Credentials are the CLI's, never OpenMausBot's: the driver runs `fountain auth whoami` to decide whether the engine is signed in, and the child process reads diff --git a/server/config.test.ts b/server/config.test.ts index d2dee7de5..16919a54c 100644 --- a/server/config.test.ts +++ b/server/config.test.ts @@ -30,7 +30,7 @@ describe("configuration boundaries", () => { }); describe("default fleet", () => { - it("ships Qwen, Hermes and Fountain as custom-only engines", () => { + it("ships Qwen and Hermes as custom-only engines, and Fountain", () => { const map = instanceConfigs({}); expect(map.qwen).toEqual({ driver: "qwenAgent", environment: {} }); expect(map.hermes).toEqual({ driver: "hermesAgent", environment: {} }); diff --git a/server/config.ts b/server/config.ts index 48b7c28d1..54fd1441c 100644 --- a/server/config.ts +++ b/server/config.ts @@ -221,6 +221,8 @@ export function instanceConfigs(cfg: AppConfig): InstanceConfigMap { hermes: { driver: "hermesAgent" }, fountain: { driver: "fountainAgent" }, }; + // Engines added after a user's fleet was first written; fountain is + // cloud-rail, not custom-only, but joins an existing fleet the same way. const CUSTOM_ONLY = { qwen: { driver: "qwenAgent" }, hermes: { driver: "hermesAgent" }, diff --git a/server/drivers/acp/fountain.test.ts b/server/drivers/acp/fountain.test.ts index d0ec5c749..62e90ce60 100644 --- a/server/drivers/acp/fountain.test.ts +++ b/server/drivers/acp/fountain.test.ts @@ -103,9 +103,9 @@ describe("Fountain error classification", () => { }); describe("Fountain driver", () => { - it("is a custom-only ACP engine over the fountain CLI", () => { + it("is a cloud-rail ACP engine over the fountain CLI", () => { expect(FountainAgentDriver.driverKind).toBe("fountainAgent"); - expect(FountainAgentDriver.metadata).toMatchObject({ displayName: "Fountain", access: "custom" }); + expect(FountainAgentDriver.metadata).toMatchObject({ displayName: "Fountain", access: "subscription" }); expect(FountainAgentDriver.decodeConfig(undefined)).toEqual({ cli: "fountain", fullAuto: false, workspace: undefined }); expect(FountainAgentDriver.install?.signInCommand).toBe("fountain auth login"); expect(FountainAgentDriver.install?.command?.darwin).toContain("brew install"); diff --git a/server/drivers/acp/fountain.ts b/server/drivers/acp/fountain.ts index 159220de9..86f9b8a35 100644 --- a/server/drivers/acp/fountain.ts +++ b/server/drivers/acp/fountain.ts @@ -120,9 +120,10 @@ export function createFountainAgentDriver(run: FountainCliRunner = runFountainCl const support: AcpSupport = { driverKind: "fountainAgent", displayName: "Fountain", - // No first-party cloud catalog: the picker lists YOUR agents on YOUR - // instance, which is what "custom" means to the picker rail. - access: "custom", + // Cloud rail: the agents run on a hosted instance and the catalog is a + // real catalog (your agents), not a local-model inject — "custom" would + // send the picker to the local-models pane, where nothing would list. + access: "subscription", models: EMPTY, resolveModels: async (env) => { const { ok, stdout } = await run(["agent", "list", "--json"], env);