diff --git a/.gitignore b/.gitignore index b95d6d689c..9c5ebde022 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +.worktrees/ dist dist-electron dist-native diff --git a/docs/opencode-go.md b/docs/opencode-go.md new file mode 100644 index 0000000000..733e687f8b --- /dev/null +++ b/docs/opencode-go.md @@ -0,0 +1,44 @@ +# OpenCode Go + +OpenCode Go is an optional OpenMausBot engine. OpenMausBot runs the maintained +OpenCode CLI through its ACP stdio interface, so sessions, streaming, coding +tools, permission requests, MCP integrations, resume, and cancellation use the +same runtime as the other ACP engines. + +## Setup + +1. Install the official CLI using the + [OpenCode installation guide](https://opencode.ai/docs/). +2. Create or obtain an OpenCode Go API key according to the live + [OpenCode Go documentation](https://opencode.ai/docs/go/). +3. Open OpenMausBot Settings → Connections and save the key under **OpenCode + Go API key**. + +The key is stored locally as write-only configuration. OpenMausBot reports only +whether it is configured, never the value. A key saved in OpenMausBot is +injected as `OPENCODE_API_KEY` only into the OpenCode child process; it is not +sent to the renderer, logs, analytics, snapshots, error messages, or command +arguments. + +OpenCode Go remains unavailable until both the `opencode` executable and the +credential are present. It is never selected as a runnable default while either +requirement is missing. Users may instead manage OpenCode's own login flow with +`opencode auth login`; OpenMausBot does not edit OpenCode auth/config files. + +## Models + +The model picker refreshes the public catalog from +`https://opencode.ai/zen/go/v1/models`. IDs are normalized to the full +`opencode-go/` form required by ACP. If the catalog is unavailable, +the last successful catalog is used, followed by a small static fallback. The +catalog is mutable; current names, pricing, limits, and retention terms remain +defined by the live OpenCode documentation. + +Before every prompt, ACP receives `session/set_config_option` with +`configId: "model"` and the exact selected provider-qualified model ID. + +## Testing + +Normal unit and ACP protocol tests do not require a subscription. Live tests, +if added, must be explicitly enabled and must never print credentials or upload +native protocol logs from a credentialed run. diff --git a/docs/plans/opencode-go-integration.md b/docs/plans/opencode-go-integration.md new file mode 100644 index 0000000000..a8febf5174 --- /dev/null +++ b/docs/plans/opencode-go-integration.md @@ -0,0 +1,312 @@ +# OpenCode Go integration plan + +- Status: proposed +- Last upstream review: 2026-08-14 +- Tracking artifact: the pull request that adds this document + +## Summary + +Add OpenCode Go as a first-class OpenMausBot engine by running the maintained +OpenCode CLI over its official Agent Client Protocol (ACP) interface and selecting +models from the `opencode-go/*` provider. + +The first implementation should reuse OpenMausBot's ACP runtime rather than call +the model endpoints directly. That keeps OpenCode's coding tools, sessions, MCP +support, and permission requests intact. A direct model API driver would only +provide inference; OpenMausBot does not currently have a provider-independent +agent/tool loop to replace the functionality the OpenCode CLI supplies. + +This plan uses **OpenCode Go** to mean the current subscription/API product +documented at [opencode.ai/docs/go](https://opencode.ai/docs/go/). It does not +target the archived Go-language repository at `opencode-ai/opencode`, which moved +to a different project and is no longer the current OpenCode implementation. + +## Upstream facts to preserve + +- OpenCode Go is an optional subscription which exposes a changing set of coding + models through provider id `opencode-go`. +- The public model catalog is available from + `https://opencode.ai/zen/go/v1/models`. +- Model inference is split across OpenAI Responses, OpenAI-compatible chat + completions, and Anthropic Messages endpoints. Treating every model as one + chat-completions API would be incorrect. +- The maintained OpenCode CLI supports `opencode acp`, a JSON-RPC process over + stdio. Its ACP implementation includes sessions, streamed messages, tool + activity, permission requests, cancellation, MCP servers, and model selection. +- In ACP, OpenCode exposes model selection as the `model` session config option. + The selected value is the full `opencode-go/` string and is set with + `session/set_config_option` before prompting. +- OpenCode accepts an `OPENCODE_API_KEY` environment variable. OpenMausBot should + inject it only into the OpenCode child process. +- The official cross-platform CLI package is `opencode-ai`; `opencode auth login` + remains an alternative user-managed credential flow. +- Go's lineup, limits, prices, providers, and retention terms can change. The app + must link to the live OpenCode documentation rather than duplicate mutable + commercial details in code. + +Primary references: + +- [OpenCode Go documentation](https://opencode.ai/docs/go/) +- [OpenCode ACP documentation](https://opencode.ai/docs/acp/) +- [OpenCode CLI documentation](https://opencode.ai/docs/cli/) +- [OpenCode server documentation](https://opencode.ai/docs/server/) +- [Maintained OpenCode repository](https://github.com/anomalyco/opencode) + +## Proposed architecture + +### Runtime: a small OpenCode ACP shim + +Add `server/drivers/acp/opencode-go.ts` as an `AcpSupport` definition over the +existing `createAcpDriver` core: + +- driver kind: `opencodeGoAgent` +- display name: `OpenCode Go` +- executable: `opencode` +- argv: `opencode acp` +- model ids: full `opencode-go/` values +- credential: `OPENCODE_API_KEY`, scoped to this child +- install commands: `npm install -g opencode-ai@latest` on macOS, Linux, and + Windows, with the official install/docs page linked as the fallback +- setup link: the OpenCode Go subscription and API-key instructions + +Do not fork the OpenCode CLI, vendor its SDK, or start a network-listening server +for the initial integration. ACP already provides a local stdio boundary and fits +the process lifecycle OpenMausBot uses for Grok and Gemini. + +### Generic ACP changes + +Extend the ACP support object with an opt-in model-selection hook. After +`session/new` or `session/load`, but before `session/prompt`, the core should: + +1. inspect the returned `configOptions`; +2. confirm a `model` option exists and contains the requested full model id; +3. call `session/set_config_option` with `{ configId: "model", value: modelId }`; +4. use the returned current value in `session.started`; and +5. fail with an actionable error rather than silently running a different model. + +The hook must be opt-in so the existing Grok and Gemini ACP drivers keep their +current CLI-argument model selection until they are deliberately migrated. + +OpenCode's ACP authentication method only starts/acknowledges its own login flow; +it does not prove that a Go subscription is active. Availability should therefore +distinguish these states: + +- CLI missing: unavailable, show the driver install path from PR #97. +- key missing: unavailable, deep-link to the credential row. +- key present: available enough to attempt a turn. +- invalid key, inactive subscription, quota, or region failure: normalize the + upstream response without exposing the key; authentication/setup failures + should carry `setup: true`, while quota and transient provider failures should + remain normal runtime errors. + +### Credentials + +Add a write-only OpenCode Go credential alongside the existing app credentials: + +```json +{ + "opencodeGo": { + "key": "..." + } +} +``` + +`OPENCODE_API_KEY` remains a supported environment fallback. The config API may +return only `opencodeGo.configured: boolean`; it must never return the key. + +Only the OpenCode Go instance receives the resolved key. Other CLI children, +native logs, error text, analytics, and renderer payloads must not receive it. +Clearing or replacing the key should use the existing atomic config write and +provider reload path. + +The Connections screen should provide: + +- a password input for the API key; +- a link to subscribe/manage OpenCode Go and copy a key; +- a clear notice that this is an optional paid third-party service; +- save, replace, and clear behavior consistent with other credentials; and +- a setup state that can represent both "install the CLI" and "add the key". + +The driver may additionally document `opencode auth login` for users who prefer +OpenCode-owned credential storage, but the first implementation should not depend +on reading or editing OpenCode's private auth file. + +### Model catalog + +Fetch the current catalog from the documented `/zen/go/v1/models` endpoint. Store +the full provider-qualified id in `ModelSelection`, while presenting a clean +label in the picker. + +Catalog behavior must be fail-safe: + +- use a short timeout and do not block application startup indefinitely; +- validate the response shape and accept only string ids; +- never make a paid inference request during discovery or health checks; +- cache the last good catalog for the process lifetime; +- fall back to a small pinned catalog when offline; and +- refresh on the existing instance re-probe path so upstream additions do not + require restarting OpenMausBot. + +If the current synchronous `ProviderInstance.models` contract prevents honest +refresh, add a small asynchronous catalog method to the driver/registry contract +instead of mutating shared objects behind the registry. + +### Sessions, tools, and permissions + +Use the existing ACP mapping as the baseline: + +- save the OpenCode session id as the resume cursor; +- load that session on the next OpenMausBot turn; +- stream assistant text and reasoning once, without replay duplication; +- translate tool start/completion updates into canonical runtime events; +- translate `session/request_permission` into the existing approval cards; +- deny or cancel when no matching upstream permission option exists; +- forward user allow/deny choices by option id, never by array position; +- send `session/cancel` on interruption and kill the child tree after the grace + period; and +- surface usage only when OpenCode reports it; do not invent cost values. + +The first slice should support the same agent and computer stdio MCP integrations +as the current generic ACP core. HTTP/SSE MCP additions, including a direct +Composio transport, should be a separate follow-up after their ACP schemas and +permission behavior are covered by tests. + +## Expected code areas + +- `server/drivers/acp/opencode-go.ts`: OpenCode-specific ACP support. +- `server/drivers/acp/core.ts`: opt-in session config/model selection and any + OpenCode event-shape compatibility found by the protocol spike. +- `server/drivers/builtIn.ts`: registration. +- `server/contracts.ts` and `server/harness/registry.ts`: asynchronous catalog or + setup metadata only if required by the spike. +- `server/config.ts` and `server/index.ts`: write-only key persistence, environment + fallback, status reporting, and provider reload. +- `src/components/ApiKeys.tsx`, settings components, and `src/state/store.tsx`: + credential UI and configured-only state. +- `src/components/ProviderIcons.tsx`, onboarding, and the model picker: provider + presentation and setup entry points. +- `server/testing/fake-acp-cli.ts` plus ACP/config tests: deterministic protocol, + credential, catalog, and lifecycle coverage. + +Keep unrelated engine setup, styling, and provider refactors out of the +implementation PRs. + +## Delivery as small PRs + +### PR 1: protocol spike and generic ACP model selection + +- Capture one sanitized `opencode acp` session against a test configuration. +- Add fake-ACP coverage for returned session config options. +- Add the opt-in `session/set_config_option` hook. +- Prove requested-model selection, session resume, cancellation, and permission + behavior without adding a visible engine. + +Exit condition: a fake OpenCode ACP process cannot prompt until the requested +`opencode-go/*` model has been acknowledged. + +### PR 2: driver, catalog, and credential plumbing + +- Add the OpenCode Go driver and built-in registration. +- Add write-only key storage and `OPENCODE_API_KEY` fallback. +- Add model discovery with timeout, validation, cache, and offline fallback. +- Add install/setup metadata and server-side tests. +- Keep the driver opt-in through explicit instance configuration until the live + smoke test is complete. + +Exit condition: `/api/instances` reports the engine honestly for missing CLI, +missing key, ready, and offline-catalog states without leaking the credential. + +### PR 3: product setup and picker integration + +- Add the Connections credential row and OpenCode Go provider presentation. +- Reuse the PR #97 focus/picker re-probe so install and key changes appear without + restarting the app. +- Add the engine to onboarding/default fleet only after macOS, Linux, and Windows + setup paths are verified. +- Link mutable pricing, usage, privacy, and model information to OpenCode's live + docs. + +Exit condition: a new user can go from unavailable to a selectable Go model with +no config-file editing and no false "ready" state. + +### PR 4: live smoke coverage and user documentation + +- Run an opt-in real-CLI smoke test for one streamed turn, one permission request, + session continuation, model switch, and cancellation. +- Verify process cleanup and executable discovery on all supported desktop OSes. +- Document setup, key removal, quota/auth errors, and the upstream data-policy + link. +- Decide whether the engine is ready for the default fleet. + +Exit condition: the integration meets the definition of done below and has a +recorded minimum supported OpenCode CLI version. + +## Test matrix + +Automated tests must cover: + +- config decoding and default executable; +- missing executable and refreshed PATH discovery on macOS, Linux, and Windows; +- key absent, environment fallback, saved key, replacement, and clearing; +- configured-only API responses and log/error redaction; +- catalog success, malformed JSON, timeout, HTTP failure, empty result, cache, and + fallback behavior; +- full provider-qualified model ids and rejection of a model not advertised by + the ACP session; +- ordering: initialize -> authenticate -> new/load -> set model -> prompt; +- streamed text without duplicate final content; +- tool lifecycle and permission allow, deny, timeout, and missing-option behavior; +- session load, missing-session fallback, and model switching after resume; +- cancellation before and after session creation, early process exit, malformed + JSON-RPC, and process-tree cleanup; and +- concurrent OpenMausBot threads without shared ACP or credential state. + +Live tests must be opt-in because they require a private subscription key and may +consume quota. CI must never print the key or upload native protocol logs from a +credentialed run. + +## Definition of done + +- OpenCode Go is shown as unavailable until both its CLI and credential are + present; OpenMausBot never selects it as a runnable default otherwise. +- The setup flow works without restarting OpenMausBot and never silently executes + an installer. +- A user can select every currently advertised `opencode-go/*` model, and the ACP + session confirms that exact model before the prompt starts. +- A bot can stream a reply, use coding tools, request approval, continue its + session, switch models, and cancel cleanly. +- Agent/computer MCP integrations behave like the other ACP engines or are + explicitly shown as unsupported. +- Missing key, invalid key, inactive subscription, quota exhaustion, region + restriction, upstream outage, and catalog outage have distinct, useful errors. +- Credentials are write-only and absent from renderer payloads, child arguments, + logs, analytics, test snapshots, and error messages. +- macOS, Linux, and Windows setup and runtime paths are verified. +- The integration documents a minimum supported OpenCode CLI version and links to + the live Go pricing, usage, model, and privacy terms. + +## Deferred work + +- A direct no-CLI OpenCode Go API driver. This requires a generic agent/tool loop, + tool-call execution, approval brokering, and three upstream wire formats. +- OpenCode's HTTP server/SDK integration. It adds port allocation, authentication, + readiness, and server lifecycle concerns without an initial advantage over ACP. +- Automatic subscription purchase, billing management, or usage top-ups. +- Persisting or modifying OpenCode's own auth/config files. +- Making Composio available over ACP HTTP/SSE transports. +- Supporting the archived Go-language OpenCode CLI. + +## Questions to close in PR 1 + +1. What minimum OpenCode CLI version has stable `opencode acp` config options and + permission event shapes for this integration? +2. Does the CLI expose a reliable non-billable credential/subscription readiness + check, or should readiness mean only "CLI and key present" until the first turn? +3. Does `session/load` return the current `model` config option for all supported + versions, and can it be switched before every prompt? +4. Which OpenCode Go errors identify invalid credentials, inactive subscription, + quota, and region restrictions without matching mutable English strings? +5. Should catalog fallback be bundled metadata or the last successful catalog + persisted on disk? +6. Can the existing ACP usage metadata distinguish cumulative session totals from + per-turn usage so OpenMausBot does not double-count it? diff --git a/docs/superpowers/plans/2026-08-15-opencode-go-integration.md b/docs/superpowers/plans/2026-08-15-opencode-go-integration.md new file mode 100644 index 0000000000..875c06df17 --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-opencode-go-integration.md @@ -0,0 +1,93 @@ +# OpenCode Go Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add OpenCode Go as an optional ACP-backed OpenMausBot engine with dynamic models, write-only credentials, setup UX, and comprehensive tests. + +**Architecture:** Extend the generic ACP support SPI with an optional asynchronous model resolver and keep OpenCode-specific behavior in `server/drivers/acp/opencode-go.ts`. Load the API key from config/environment into the child process only, register the driver in the normal fleet, and reuse the existing registry, `/api/instances`, setup UI, and ACP event machinery. + +**Tech Stack:** TypeScript, Node 24, Vitest, React, Vite, pnpm, JSON-RPC ACP over stdio. + +--- + +## File map + +- Create `server/drivers/acp/opencode-go.ts`: OpenCode Go executable, catalog, credential and ACP support definition. +- Create `server/drivers/acp/opencode-go.test.ts`: resolver, config, environment, catalog and driver contract tests. +- Modify `server/drivers/acp/core.ts`: optional async model resolution without changing existing harness behavior. +- Modify `server/drivers/builtIn.ts`: register the driver. +- Modify `server/config.ts`: persist and inject `opencodeGo.apiKey`, while preserving write-only semantics. +- Modify `server/index.ts`: expose only configured status and accept the new credential patch. +- Modify `src/components/ApiKeys.tsx` and `src/components/SettingsModal.tsx`: add the optional OpenCode Go credential row. +- Modify `src/components/Onboarding.tsx`: show OpenCode Go in the engine check. +- Modify `src/components/ProviderIcons.tsx`: add a stable OpenCode mark/fallback. +- Modify `server/testing/fake-acp-cli.ts` and `server/drivers/acp/acp.test.ts`: exercise exact ACP ordering/model selection with a fake CLI. +- Modify `docs/opencode-go.md`: document prerequisites, live links, privacy boundary, and opt-in behavior. + +### Task 1: Extend ACP model resolution + +**Files:** `server/drivers/acp/core.ts`, `server/drivers/acp/acp.test.ts` + +- [ ] Add a failing test proving an ACP support can provide `resolveModels(environment)` and that the created instance exposes the resolved catalog while existing static supports remain unchanged. +- [ ] Run `pnpm vitest run server/drivers/acp/acp.test.ts`; expect the new test to fail because `AcpSupport` has no resolver. +- [ ] Add `resolveModels?: (environment) => Promise` to `AcpSupport`; resolve it at creation and expose `refreshModels()` so `/api/instances` can replace the existing instance catalog without recreating it, falling back to `support.models` when it rejects. +- [ ] Run the focused test and then the existing ACP suite; expect all tests to pass. +- [ ] Commit `feat(acp): support dynamic model catalogs`. + +### Task 2: Implement OpenCode Go support and catalog + +**Files:** `server/drivers/acp/opencode-go.ts`, `server/drivers/acp/opencode-go.test.ts` + +- [ ] Write failing tests for default config `{ cli: "opencode", fullAuto: false, workspace: undefined }`, `opencode acp` arguments, deletion of unrelated provider API-key variables, and full `opencode-go/` model IDs. +- [ ] Write failing catalog tests using injected `fetch`: accept only valid model records, preserve deterministic labels, reject malformed/empty payloads, and return the last successful cache/static fallback on timeout or HTTP failure. +- [ ] Run the focused tests and verify they fail for missing exports/driver. +- [ ] Implement `fetchOpenCodeGoModels(fetcher)` with a module cache, 8-second timeout via `AbortController`, validation of `data`/array payloads, and a static fallback containing the documented Go model ids. Do not log response bodies or credentials. +- [ ] Implement `OpenCodeGoDriver = createAcpDriver({ driverKind: "opencodeGo", displayName: "OpenCode Go", defaultCli: "opencode", nativeSource: "opencode-go.acp", spawnArgs: () => ["acp"], transformEnv: stripForeignProviderKeys, pickAuthMethod: () => null, authFailure: "continue", isAuthenticated: env => Boolean(env.OPENCODE_API_KEY), resolveModels: () => fetchOpenCodeGoModels() })`; the environment transform must remove known unrelated provider keys and leave the intentionally injected OpenCode key. +- [ ] Add cross-platform setup commands, docs URL, and a login note that never asks OpenMausBot to edit OpenCode auth files. +- [ ] Run the focused tests and commit `feat(acp): add OpenCode Go driver`. + +### Task 3: Wire credential storage and registration + +**Files:** `server/config.ts`, `server/index.ts`, `server/drivers/builtIn.ts`, `server/index.test.ts`, `server/config.test.ts` if needed + +- [ ] Add failing tests proving `opencodeGo.apiKey` is loaded from `OPENCODE_API_KEY`, saved to disk, passed only in the configured instance environment, and never appears in `configStatus()` or GET `/api/config`. +- [ ] Run the focused tests and confirm failure before implementation. +- [ ] Add `opencodeGo?: { apiKey?: string }` to `AppConfig`, load the environment fallback with disk override, merge the key only into `entry.environment` for `opencodeGo` instances, include `opencodeGo` in accepted config patches, and expose `{ configured: boolean }` in status. +- [ ] Add `OpenCodeGoDriver` to `BUILT_IN_DRIVERS`; keep the default fleet optional by adding `opencodeGo: { driver: "opencodeGo" }` only when the user explicitly configures it or when the default fleet policy is updated to show unavailable optional engines without selecting them. +- [ ] Run config/index tests and inspect serialized bodies to verify the secret is absent. +- [ ] Commit `feat(config): wire OpenCode Go credentials`. + +### Task 4: Add settings, onboarding, and icon integration + +**Files:** `src/components/ApiKeys.tsx`, `src/components/SettingsModal.tsx`, `src/components/Onboarding.tsx`, `src/components/ProviderIcons.tsx` + +- [ ] Add component tests or existing test-style coverage for the credential row payload `{ opencodeGo: { apiKey } }` and the onboarding engine label `OpenCode Go`. +- [ ] Run the new focused tests and verify the missing row/label failure. +- [ ] Add `opencodeGo` to `ConfigSection`, `SECTIONS`, and `CREDENTIALS` with optional copy, live documentation link, and no value rendering; add the row to Connections. +- [ ] Add the engine row using `driverKind === "opencodeGo"` and add the provider mark without changing unavailable-engine behavior. +- [ ] Run typecheck and frontend tests; commit `feat(ui): add OpenCode Go setup surfaces`. + +### Task 5: Verify ACP runtime behavior end to end + +**Files:** `server/testing/fake-acp-cli.ts`, `server/drivers/acp/opencode-go.test.ts` + +- [ ] Add a fake-CLI mode that records received JSON-RPC methods/model config in a test-only temporary file and emits initialize, session/new, session/set_config_option, session/update, permission, and prompt result messages. +- [ ] Add failing integration tests asserting order `initialize -> session/new/load -> session/set_config_option(model=opencode-go/...) -> session/prompt`, no duplicate final text, permission allow/deny, resume fallback, cancellation, malformed JSON-RPC, and process cleanup. +- [ ] Run the focused tests and verify failures are behavior failures rather than import/type errors. +- [ ] Implement only the smallest driver/core changes needed to pass these tests; preserve existing ACP semantics for Grok, Gemini, and Kimi. +- [ ] Run all ACP tests and commit `test(acp): cover OpenCode Go protocol flow`. + +### Task 6: Documentation and final verification + +**Files:** `docs/opencode-go.md`, `README.md` only if the repository’s engine documentation index requires it + +- [ ] Document CLI installation via the official CLI guide, `opencode auth login` as a user-managed alternative, API-key scope, model catalog behavior, supported platforms, and the fact that live tests are opt-in. +- [ ] Run `pnpm test`, `pnpm typecheck`, `pnpm build`, `pnpm check:electron`, and `git diff --check` from the worktree. +- [ ] Search the diff and test output for `OPENCODE_API_KEY` values or serialized secrets; only variable names and configured booleans may remain. +- [ ] Commit `docs: document OpenCode Go setup`. + +## Self-review + +- The plan covers every scope item: ACP driver, dynamic catalog, credential boundary, registry/setup/model picker, session/tool/permission/cancellation tests, documentation, and cross-platform checks. +- No runtime implementation is permitted before its focused failing test; each implementation task has an explicit RED/GREEN sequence. +- The only unresolved product choice is optional-fleet visibility. The implementation will follow the repository’s existing policy: configured instances are shown, and OpenCode Go is never selected as a runnable default without CLI plus credential readiness. diff --git a/docs/superpowers/specs/2026-08-15-opencode-go-integration-design.md b/docs/superpowers/specs/2026-08-15-opencode-go-integration-design.md new file mode 100644 index 0000000000..af294f1436 --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-opencode-go-integration-design.md @@ -0,0 +1,49 @@ +# OpenCode Go Integration Design + +## Goal + +Add OpenCode Go as an optional first-class OpenMausBot engine by launching the maintained OpenCode CLI through its ACP stdio interface. The integration must preserve the existing session, streaming, tool, permission, cancellation, MCP, credential, and model-selection contracts. + +## Scope + +- Add an ACP support definition and OpenCode Go driver using the existing ACP runtime. +- Detect the `opencode` executable through the repository's existing cross-platform PATH discovery. +- Expose advertised `opencode-go/*` models from the public catalog, with safe fallback behavior when the catalog is unavailable. +- Store the API key write-only and inject it only into the OpenCode child process as `OPENCODE_API_KEY`. +- Integrate availability, setup, onboarding, and model selection without making OpenCode Go a default engine. +- Cover protocol ordering, streaming, tools, permissions, resume, model switching, cancellation, process cleanup, catalog failures, and credential redaction with automated tests. +- Keep live subscription tests opt-in and prevent credentials or native protocol logs from appearing in CI output. + +## Out of scope + +- A direct OpenCode Go HTTP/API driver. +- OpenCode's HTTP server/SDK lifecycle. +- Automatic installation, subscription purchase, billing, usage top-ups, or modification of OpenCode's own auth files. +- Support for the archived Go-language OpenCode repository. + +## Architecture + +`server/drivers/acp/opencode-go.ts` will define the engine-specific executable, environment construction, model catalog metadata, and ACP session behavior on top of `server/drivers/acp/core.ts`. It will use the same registry and contracts as the existing ACP engines, so the engine remains isolated from provider-specific model APIs. + +The catalog layer will fetch `https://opencode.ai/zen/go/v1/models`, normalize only valid provider-qualified model IDs, cache the last successful result in memory, and fall back to a small static availability response when the endpoint is unavailable. The UI will consume the existing engine/model payloads and will not receive the credential. + +Credential handling will follow existing write-only secret conventions. The key may come from configured storage or an environment fallback, but renderer payloads, logs, errors, snapshots, child arguments, and analytics must never contain it. Only the spawned OpenCode process receives `OPENCODE_API_KEY`. + +## Runtime flow + +1. Registry discovers the OpenCode executable and checks configuration/credential presence. +2. Setup or onboarding can configure the engine without installing a CLI or restarting the app. +3. A session starts through ACP, authenticates, creates or loads a session, and sets the full `opencode-go/` value with `session/set_config_option` before the first prompt. +4. ACP events stream text and tool activity through existing OpenMausBot event handling. +5. Permission requests are brokered through the existing permission proxy; allow, deny, timeout, and missing-option paths remain explicit. +6. Cancellation terminates the session/process cleanly, including early exit and malformed JSON-RPC cases. + +## Error handling + +Errors must distinguish missing CLI, missing/invalid credential, inactive subscription, quota/region restrictions, upstream outage, and model-catalog outage. Mutable upstream English messages must not be used as the sole classifier. Catalog failure must not make unrelated engines unavailable, and OpenCode Go must never be selected as runnable unless its executable and credential prerequisites are satisfied. + +## Testing and acceptance + +Unit tests will use the existing fake ACP CLI/test harness where possible. They must verify config defaults, refreshed PATH discovery, write-only credentials, catalog success/failure/cache/fallback, exact model IDs, protocol ordering, streaming without duplicate final content, tool/permission lifecycle, resume and model switching, cancellation/process cleanup, and concurrent sessions without shared state. A live test suite is opt-in only. + +The implementation is complete when all advertised models can be selected and confirmed by ACP, sessions can stream/tool/continue/switch/cancel, setup works on macOS/Linux/Windows paths, secrets remain absent from all observable app surfaces, and the normal repository typecheck/build/test commands pass. diff --git a/server/config.test.ts b/server/config.test.ts new file mode 100644 index 0000000000..0fc8fb8145 --- /dev/null +++ b/server/config.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { instanceConfigs, type AppConfig } from "./config.ts"; + +describe("OpenCode Go configuration", () => { + it("injects the key only into OpenCode Go instances", () => { + const cfg: AppConfig = { + opencodeGo: { apiKey: "secret-value" }, + instances: { + opencode: { driver: "opencodeGo" }, + grok: { driver: "grokAgent" }, + }, + }; + + const instances = instanceConfigs(cfg); + expect(instances.opencode.environment).toEqual({ OPENCODE_API_KEY: "secret-value" }); + expect(instances.grok.environment).toEqual({}); + }); +}); diff --git a/server/config.ts b/server/config.ts index 147bb48569..a265db90aa 100644 --- a/server/config.ts +++ b/server/config.ts @@ -15,6 +15,8 @@ export interface AppConfig { * catalog with official logos in the plugins marketplace. */ composio?: { key?: string; apiKey?: string; url?: string }; box?: { token?: string }; + /** OpenCode Go key; persisted write-only and passed only to its child. */ + opencodeGo?: { apiKey?: string }; /** Voice (ElevenLabs). `key` is the credential and is never echoed back; * `voice` is the chosen voice id, which is a setting, not a secret. */ tts?: { key?: string; voice?: string }; @@ -53,6 +55,7 @@ export function loadConfig(): AppConfig { cfg.xai = { key: process.env.XAI_API_KEY, ...cfg.xai }; cfg.composio = { key: process.env.COMPOSIO_KEY, ...cfg.composio }; cfg.box = { token: process.env.BOX_TOKEN, ...cfg.box }; + cfg.opencodeGo = { apiKey: process.env.OPENCODE_API_KEY, ...cfg.opencodeGo }; cfg.tts = { key: process.env.OMB_TTS_KEY, ...cfg.tts }; return cfg; } @@ -67,7 +70,7 @@ export function saveConfig(patch: Partial): void { } catch { /* first write */ } - for (const key of ["xai", "composio", "box", "tts", "profile"] as const) { + for (const key of ["xai", "composio", "box", "opencodeGo", "tts", "profile"] as const) { if (patch[key] && typeof patch[key] === "object") { disk[key] = { ...(disk[key] as object), ...patch[key] }; } @@ -104,12 +107,16 @@ export function instanceConfigs(cfg: AppConfig): InstanceConfigMap { claude: { driver: "claudeAgent" }, codex: { driver: "codex" }, antigravity: { driver: "antigravityAgent" }, + opencodeGo: { driver: "opencodeGo" }, computer: { driver: "boxAgent" }, }; for (const entry of Object.values(map)) { entry.environment = { ...(cfg.xai?.key ? { XAI_API_KEY: cfg.xai.key } : {}), ...(cfg.box?.token ? { BOX_TOKEN: cfg.box.token } : {}), + ...(entry.driver === "opencodeGo" && cfg.opencodeGo?.apiKey + ? { OPENCODE_API_KEY: cfg.opencodeGo.apiKey } + : {}), ...entry.environment, }; } diff --git a/server/contracts.ts b/server/contracts.ts index 2a49efb533..cfecbe8b33 100644 --- a/server/contracts.ts +++ b/server/contracts.ts @@ -10,6 +10,24 @@ export type InstanceId = string; export type ThreadId = string; export type TurnId = string; +export type ProviderErrorCode = + | "missing_cli" + | "invalid_credentials" + | "inactive_subscription" + | "quota_or_region_restriction" + | "upstream_outage" + | "model_catalog_outage"; + +export class ProviderError extends Error { + readonly code: ProviderErrorCode; + + constructor(code: ProviderErrorCode, message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "ProviderError"; + this.code = code; + } +} + // ── model selection ──────────────────────────────────────────────────── // "Which model" is a data value carried on the request, never a service // binding (upstream ModelSelectionWire). instanceId is the routing key. @@ -199,6 +217,8 @@ export interface ProviderInstance { readonly displayName: string | undefined; readonly enabled: boolean; readonly models: ModelCatalog; + /** Refresh a live catalog without recreating the provider instance. */ + readonly refreshModels?: () => Promise; readonly adapter: ProviderAdapter; snapshot(): Promise; /** Cheap one-shot text call (upstream TextGeneration) — titles, summaries. */ diff --git a/server/drivers/acp/acp.test.ts b/server/drivers/acp/acp.test.ts index 2d22931026..29525a7d9d 100644 --- a/server/drivers/acp/acp.test.ts +++ b/server/drivers/acp/acp.test.ts @@ -61,7 +61,48 @@ const AsyncAuthDriver = createAcpDriver({ isAuthenticated: async () => true, }); +const ClassifiedErrorDriver = createAcpDriver({ + ...SELECT_MODEL_SUPPORT, + driverKind: "classifiedErrorTest", + selectModel: undefined, + classifyError: (error) => + error && typeof error === "object" && (error as { code?: unknown }).code === -32000 + ? "invalid_credentials" + : undefined, +}); + describe("ACP decodeConfig", () => { + it("resolves a dynamic model catalog when a support provides one", async () => { + const support: AcpSupport = { + driverKind: "dynamic-test", + displayName: "Dynamic Test", + models: { default: "fallback", options: [{ id: "fallback", label: "Fallback" }] }, + defaultCli: FAKE_CLI, + nativeSource: "dynamic-test.acp", + loginNote: "not authenticated", + spawnArgs: () => [], + pickAuthMethod: () => null, + authFailure: "continue", + isAuthenticated: () => true, + resolveModels: async () => ({ + default: "dynamic-model", + options: [{ id: "dynamic-model", label: "Dynamic model" }], + }), + }; + const driver = createAcpDriver(support); + const instance = await driver.create({ + instanceId: "dynamic-test", + displayName: "Dynamic Test", + environment: {}, + enabled: true, + config: driver.defaultConfig(), + }); + expect(instance.models).toEqual({ + default: "dynamic-model", + options: [{ id: "dynamic-model", label: "Dynamic model" }], + }); + await instance.dispose(); + }); it("grok defaults to the grok binary", () => { expect(GrokAgentDriver.decodeConfig({})).toEqual({ cli: "grok", fullAuto: false, workspace: undefined }); }); @@ -119,6 +160,7 @@ describe("ACP turns (fake CLI)", () => { delete process.env.FAKE_ACP_MODE; delete process.env.FAKE_ACP_DUMP; delete process.env.XAI_API_KEY; + delete process.env.OPENCODE_API_KEY; delete process.env.FAKE_ACP_MODELS; delete process.env.FAKE_ACP_MODEL_STICKS; delete process.env.FAKE_ACP_USAGE_ROOT; @@ -163,11 +205,12 @@ describe("ACP turns (fake CLI)", () => { expect(usage).toMatchObject({ input: 10, output: 5 }); }); - it("passes ACP stdio flags and strips XAI_API_KEY from the child env", async () => { + it("passes ACP stdio flags and strips foreign provider keys from the child env", async () => { await create(); const dump = join(scratch, "dump.json"); process.env.FAKE_ACP_DUMP = dump; process.env.XAI_API_KEY = "xai-should-not-leak"; + process.env.OPENCODE_API_KEY = "opencode-should-not-leak"; await instance.adapter.sendTurn({ threadId: "t-hygiene", text: "go" }); await recorder.until((e) => e.type === "turn.completed"); @@ -177,6 +220,7 @@ describe("ACP turns (fake CLI)", () => { expect(seen.argv).toContain("stdio"); expect(seen.argv).toContain("--permission-mode"); expect(seen.env.XAI_API_KEY).toBeUndefined(); + expect(seen.env.OPENCODE_API_KEY).toBeUndefined(); }); // this driver has no Composio mount, so it must not claim the @@ -322,6 +366,15 @@ describe("ACP turns (fake CLI)", () => { expect(recorder.events.some((e) => e.type === "runtime.error")).toBe(true); }); + it("preserves ACP error codes for provider setup classification", async () => { + await create(ClassifiedErrorDriver, "auth-required"); + await instance.adapter.sendTurn({ threadId: "t-auth-required", text: "go" }); + const done = await recorder.until((e) => e.type === "turn.completed"); + + expect(done).toMatchObject({ ok: false, stopReason: "auth_required" }); + expect(recorder.events.find((e) => e.type === "runtime.error")).toMatchObject({ setup: true }); + }); + it("selectModel confirms the requested model before prompting", async () => { process.env.FAKE_ACP_MODELS = "m-one,m-two"; await create(SelectModelDriver); diff --git a/server/drivers/acp/core.ts b/server/drivers/acp/core.ts index d561f7428c..cd66129ad0 100644 --- a/server/drivers/acp/core.ts +++ b/server/drivers/acp/core.ts @@ -26,9 +26,11 @@ import type { ProviderDriver, ProviderInstance, ProviderSnapshot, + ModelCatalog, RuntimeEvent, RuntimeEventListener, SendTurnInput, + ProviderErrorCode, } from "../../contracts.ts"; import { newEventId, newId } from "../../contracts.ts"; import { computerProxyEnv } from "../../container-computer.ts"; @@ -56,6 +58,8 @@ export interface AcpSupport { models: { default: string; options: Array<{ id: string; label: string }> }; /** 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; /** Native-protocol log label, e.g. "grok.acp". */ nativeSource: string; /** Message shown when the CLI is present but not signed in. */ @@ -64,6 +68,8 @@ export interface AcpSupport { install?: EngineInstall; /** CLI argv AFTER the binary name to enter ACP stdio mode. */ spawnArgs(config: AcpConfig, 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 * harnesses whose ACP subcommand takes no -m (opencode). The agent must * CONFIRM the requested model before we prompt: silently running a model @@ -81,10 +87,8 @@ export interface AcpSupport { /** 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; - /** Per-instance model catalog, for CLIs whose real catalog is user-local - * (custom providers, favourites) rather than a fixed vendor list. Falls - * back to the static `models` when absent or when it throws. */ - resolveModels?(env: Record): AcpSupport["models"]; + /** Classify provider-native failures without coupling the core to messages. */ + classifyError?(error: unknown): ProviderErrorCode | undefined; /** Compose the session/prompt text. Default prepends the persona. */ buildPromptText?(turn: SendTurnInput): string; /** Apply per-session settings between session/new (or session/load) and the @@ -103,6 +107,17 @@ const INIT_TIMEOUT = 20_000; const SESSION_CONFIG_TIMEOUT = 20_000; // configureSession's per-request default const NEW_SESSION_TIMEOUT = 30_000; const LOAD_SESSION_TIMEOUT = 120_000; // history replay on a long thread is slow +const PROVIDER_CREDENTIAL_ENV = [ + "ANTHROPIC_API_KEY", + "FACTORY_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "KIMI_API_KEY", + "MOONSHOT_API_KEY", + "OPENAI_API_KEY", + "OPENCODE_API_KEY", + "XAI_API_KEY", +] as const; function decodeAcpConfig(defaultCli: string) { return (raw: unknown): AcpConfig => { @@ -132,6 +147,30 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver async create(input: DriverCreateInput): Promise { const { instanceId, config } = input; + const childEnv = () => { + const env: Record = { + ...process.env, + ...input.environment, + PATH: augmentedPath(), + }; + const allowedCredentials = new Set(support.credentialEnv ?? []); + for (const key of PROVIDER_CREDENTIAL_ENV) { + if (!allowedCredentials.has(key)) delete env[key]; + } + support.transformEnv?.(env, config); + return env; + }; + let models = support.models; + const refreshModels = async () => { + if (!support.resolveModels) return; + try { + const resolved = await support.resolveModels(childEnv()); + if (resolved.options.length) models = resolved; + } catch { + // Keep the last usable catalog when an optional discovery source is down. + } + }; + await refreshModels(); const listeners = new Set(); interface Turn { stop: () => void; @@ -152,28 +191,6 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver createdAt: new Date().toISOString(), }); - const childEnv = () => { - const env: Record = { - ...process.env, - ...input.environment, - PATH: augmentedPath(), - }; - support.transformEnv?.(env, config); - return env; - }; - - // A user-local catalog must never be able to break the picker: any - // failure reading it falls back to the driver's static list. - const instanceModels = () => { - if (!support.resolveModels) return support.models; - try { - const resolved = support.resolveModels(childEnv()); - return resolved.options.length ? resolved : support.models; - } catch { - return support.models; - } - }; - // ACP session mcpServers: stdio is the baseline every ACP agent // supports (mcpCapabilities.http/.sse only add EXTRA transports), so // an injected stdio proxy — e.g. the peer-agent comms tool — attaches @@ -412,7 +429,13 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver if (pend) { rpcPending.delete(msg.id); if (pend.timer) clearTimeout(pend.timer); - msg.error ? pend.reject(new Error(msg.error.message ?? JSON.stringify(msg.error))) : pend.resolve(msg.result); + if (msg.error) { + const error = new Error(msg.error.message ?? JSON.stringify(msg.error)); + Object.assign(error, { code: msg.error.code, data: msg.error.data }); + pend.reject(error); + } else { + pend.resolve(msg.result); + } } } else if (msg.id !== undefined && msg.method) { handleServerRequest(msg); @@ -572,11 +595,13 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver else settle(false, reason ?? "failed"); } catch (e) { if (!state.settled) { - const message = (e as Error).message; - // "not signed in" is a setup problem like a missing binary: the - // fix is a command in a terminal, not another attempt. Flagging - // it lets the error card show the sign-in step. - const needsAuth = message === support.loginNote; + const message = e instanceof Error ? e.message : String(e); + const code = support.classifyError?.(e); + // Authentication setup is a user action, not a retry. The + // classifier is preferred; loginNote remains a compatibility + // fallback for existing ACP supports. + const needsAuth = code === "invalid_credentials" || code === "inactive_subscription" + || message === support.loginNote; emit({ ...base(threadId, turnId), type: "runtime.error", @@ -607,9 +632,10 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver driverKind: DRIVER_KIND, displayName: input.displayName, enabled: input.enabled, - // Read once per instance: the picker reads this synchronously, and a - // user-local catalog changes about as often as the CLI is reinstalled. - models: instanceModels(), + get models() { + return models; + }, + refreshModels: support.resolveModels ? refreshModels : undefined, snapshot, adapter: { provider: DRIVER_KIND, diff --git a/server/drivers/acp/droid.ts b/server/drivers/acp/droid.ts index 3ccea33e9a..1fda9be30e 100644 --- a/server/drivers/acp/droid.ts +++ b/server/drivers/acp/droid.ts @@ -146,6 +146,7 @@ const support: AcpSupport = { // No model/mode flags here on purpose: see the header note. `-o acp` is the // ACP entry point; everything else is negotiated over the protocol. spawnArgs: () => ["exec", "-o", "acp"], + credentialEnv: ["FACTORY_API_KEY"], // The advertised methods are device-pairing (a browser flow that cannot be // driven over ACP) and factory-api-key (read from the child env, no diff --git a/server/drivers/acp/gemini.ts b/server/drivers/acp/gemini.ts index b37fe91770..f911877260 100644 --- a/server/drivers/acp/gemini.ts +++ b/server/drivers/acp/gemini.ts @@ -41,6 +41,7 @@ const support: AcpSupport = { loginNote: "Gemini CLI is not signed in — run `gemini` once to log in, or set GEMINI_API_KEY", spawnArgs: (_config, turn) => ["--experimental-acp", ...(turn.model ? ["-m", turn.model] : [])], + credentialEnv: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], pickAuthMethod: (methods) => { const ids = methods.map((m) => m.id).filter((id): id is string => typeof id === "string"); diff --git a/server/drivers/acp/opencode-go.test.ts b/server/drivers/acp/opencode-go.test.ts new file mode 100644 index 0000000000..ab0cc70100 --- /dev/null +++ b/server/drivers/acp/opencode-go.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + classifyOpenCodeGoError, + createOpenCodeGoDriver, + fetchOpenCodeGoModels, + resetOpenCodeGoModelCache, +} from "./opencode-go.ts"; + +const FAKE_CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "testing", "fake-acp-cli.ts"); + +describe("OpenCode Go catalog", () => { + beforeEach(() => resetOpenCodeGoModelCache()); + + it("normalizes valid catalog records to provider-qualified model ids", async () => { + const models = await fetchOpenCodeGoModels(async () => + new Response(JSON.stringify({ + data: [ + { id: "minimax-m3", object: "model" }, + { id: "bad id", object: "model" }, + { object: "model" }, + ], + }), { status: 200 }), + ); + + expect(models).toEqual({ + default: "opencode-go/minimax-m3", + options: [{ id: "opencode-go/minimax-m3", label: "Minimax M3" }], + }); + }); + + it("uses the last successful catalog when the endpoint fails", async () => { + const fetcher = async () => + new Response(JSON.stringify([{ id: "kimi-k3" }]), { status: 200 }); + await fetchOpenCodeGoModels(fetcher); + + const fallback = await fetchOpenCodeGoModels(async () => { + throw new Error("network down"); + }); + + expect(fallback.default).toBe("opencode-go/kimi-k3"); + }); + + it("refreshes the same instance catalog on each explicit refresh", async () => { + let calls = 0; + const driver = createOpenCodeGoDriver(async () => { + calls += 1; + const id = calls === 1 ? "minimax-m3" : calls === 2 ? "kimi-k3" : "glm-5.2"; + return new Response(JSON.stringify([{ id }]), { status: 200 }); + }); + const instance = await driver.create({ + instanceId: "opencode-refresh", + displayName: "OpenCode Go", + environment: {}, + enabled: true, + config: driver.defaultConfig(), + }); + + expect(instance.models.default).toBe("opencode-go/minimax-m3"); + await instance.refreshModels?.(); + expect(instance.models.default).toBe("opencode-go/kimi-k3"); + await instance.refreshModels?.(); + expect(instance.models.default).toBe("opencode-go/glm-5.2"); + await instance.dispose(); + }); + + it("keeps the driver optional and declares the OpenCode CLI setup", () => { + const driver = createOpenCodeGoDriver(async () => new Response("[]", { status: 200 })); + expect(driver.driverKind).toBe("opencodeGo"); + expect(driver.decodeConfig(undefined)).toEqual({ cli: "opencode", fullAuto: false, workspace: undefined }); + expect(driver.install?.docsUrl).toContain("opencode.ai"); + }); + + it("recognizes an OpenCode Go login stored by the CLI", async () => { + const scratch = mkdtempSync(join(tmpdir(), "omb-opencode-auth-")); + const authDir = join(scratch, "opencode"); + mkdirSync(authDir, { recursive: true }); + writeFileSync(join(authDir, "auth.json"), JSON.stringify({ + "opencode-go": { type: "api", key: "stored-secret" }, + })); + const driver = createOpenCodeGoDriver(async () => new Response("[]", { status: 200 })); + const instance = await driver.create({ + instanceId: "opencode-auth", + displayName: "OpenCode Go", + environment: { XDG_DATA_HOME: scratch, OPENCODE_API_KEY: "" }, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + try { + expect((await instance.snapshot()).authenticated).toBe(true); + } finally { + await instance.dispose(); + rmSync(scratch, { recursive: true, force: true }); + } + }); + + it("classifies ACP's standard authentication error", () => { + expect(classifyOpenCodeGoError({ code: -32000 })).toBe("invalid_credentials"); + }); + + it("keeps the OpenCode key in the child environment only", async () => { + const scratch = mkdtempSync(join(tmpdir(), "omb-opencode-go-")); + try { + const dump = join(scratch, "env.json"); + const driver = createOpenCodeGoDriver(async () => new Response(JSON.stringify([{ id: "minimax-m3" }]), { status: 200 })); + const instance = await driver.create({ + instanceId: "opencode-go", + displayName: "OpenCode Go", + environment: { + OPENCODE_API_KEY: "secret-value", + OPENAI_API_KEY: "wrong-provider-secret", + ANTHROPIC_API_KEY: "wrong-provider-secret", + FAKE_ACP_DUMP: dump, + }, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + await instance.snapshot(); + const child = JSON.parse(readFileSync(dump, "utf8")) as { env: Record }; + expect(child.env.OPENCODE_API_KEY).toBe("secret-value"); + expect(child.env.OPENAI_API_KEY).toBeUndefined(); + expect(child.env.ANTHROPIC_API_KEY).toBeUndefined(); + await instance.dispose(); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); +}); diff --git a/server/drivers/acp/opencode-go.ts b/server/drivers/acp/opencode-go.ts new file mode 100644 index 0000000000..1ad101db35 --- /dev/null +++ b/server/drivers/acp/opencode-go.ts @@ -0,0 +1,153 @@ +// OpenCode Go subscription/API product through the maintained OpenCode CLI's +// ACP stdio interface. The generic protocol runtime lives in core.ts. +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { createAcpDriver, type AcpSupport } from "./core.ts"; +import type { ModelCatalog, ProviderErrorCode } from "../../contracts.ts"; + +const CATALOG_URL = "https://opencode.ai/zen/go/v1/models"; +const STATIC_MODELS: ModelCatalog = { + default: "opencode-go/minimax-m3", + options: [ + { id: "opencode-go/minimax-m3", label: "Minimax M3" }, + { id: "opencode-go/kimi-k3", label: "Kimi K3" }, + { id: "opencode-go/glm-5.2", label: "GLM 5.2" }, + ], +}; + +let lastSuccessfulCatalog: ModelCatalog | null = null; + +function labelForModel(id: string): string { + return id + .split(/[-_.]+/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function resetOpenCodeGoModelCache() { + lastSuccessfulCatalog = null; +} + +export async function fetchOpenCodeGoModels(fetcher: typeof fetch = fetch): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8_000); + timeout.unref?.(); + try { + const response = await fetcher(CATALOG_URL, { signal: controller.signal }); + if (!response.ok) throw new Error(`catalog HTTP ${response.status}`); + const payload = await response.json() as unknown; + const records = Array.isArray(payload) + ? payload + : payload && typeof payload === "object" && Array.isArray((payload as { data?: unknown }).data) + ? (payload as { data: unknown[] }).data + : []; + const ids = records + .map((record) => record && typeof record === "object" ? (record as { id?: unknown }).id : undefined) + .filter((id): id is string => typeof id === "string" && /^[a-z0-9][a-z0-9._-]*$/i.test(id)); + if (!ids.length) throw new Error("catalog contained no valid models"); + const catalog = { + default: `opencode-go/${ids[0]}`, + options: ids.map((id) => ({ id: `opencode-go/${id}`, label: labelForModel(id) })), + } satisfies ModelCatalog; + lastSuccessfulCatalog = catalog; + return catalog; + } finally { + clearTimeout(timeout); + } + } catch { + return lastSuccessfulCatalog ?? STATIC_MODELS; + } +} + +const stripForeignProviderKeys = (env: Record) => { + for (const key of [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "XAI_API_KEY", + "KIMI_API_KEY", + "MOONSHOT_API_KEY", + ]) delete env[key]; +}; + +function storedAuthPath(env: Record) { + const home = env.HOME || env.USERPROFILE || homedir(); + const dataRoot = env.XDG_DATA_HOME + || (process.platform === "darwin" + ? join(home, "Library", "Application Support") + : process.platform === "win32" + ? env.LOCALAPPDATA || join(home, "AppData", "Local") + : join(home, ".local", "share")); + return join(dataRoot, "opencode", "auth.json"); +} + +function hasStoredOpenCodeGoAuth(env: Record) { + const candidates: string[] = []; + if (env.OPENCODE_AUTH_CONTENT) candidates.push(env.OPENCODE_AUTH_CONTENT); + try { + candidates.push(readFileSync(storedAuthPath(env), "utf8")); + } catch { + // A missing or unreadable file simply means there is no ambient login. + } + return candidates.some((raw) => { + try { + const parsed = JSON.parse(raw) as Record; + const auth = parsed["opencode-go"]; + return Boolean(auth && typeof auth === "object" && (auth as { key?: unknown }).key); + } catch { + return false; + } + }); +} + +const support = (fetcher: typeof fetch): AcpSupport => ({ + driverKind: "opencodeGo", + displayName: "OpenCode Go", + models: STATIC_MODELS, + defaultCli: "opencode", + nativeSource: "opencode-go.acp", + loginNote: "OpenCode Go is not configured — add an OPENCODE_API_KEY in OpenMausBot settings", + install: { + command: { + darwin: "npm install -g opencode-ai", + linux: "npm install -g opencode-ai", + win32: "npm install -g opencode-ai", + }, + docsUrl: "https://opencode.ai/docs/", + signInCommand: "opencode auth login", + needsNode: true, + }, + spawnArgs: () => ["acp"], + credentialEnv: ["OPENCODE_API_KEY"], + selectModel: { configId: "model" }, + transformEnv: stripForeignProviderKeys, + pickAuthMethod: () => null, + authFailure: "continue", + isAuthenticated: (env) => Boolean(env.OPENCODE_API_KEY) || hasStoredOpenCodeGoAuth(env), + classifyError: classifyOpenCodeGoError, + resolveModels: () => fetchOpenCodeGoModels(fetcher), + buildPromptText: (turn) => turn.system ? `${turn.system}\n\n${turn.text}` : turn.text, +}); + +export function classifyOpenCodeGoError(error: unknown): ProviderErrorCode | undefined { + const value = error && typeof error === "object" ? error as Record : {}; + const code = value.code; + if (code === -32000) return "invalid_credentials"; + if (code === "AUTH_REQUIRED" || code === "INVALID_API_KEY" || code === "UNAUTHORIZED") return "invalid_credentials"; + if (code === "SUBSCRIPTION_INACTIVE") return "inactive_subscription"; + if (code === "QUOTA_EXCEEDED" || code === "REGION_RESTRICTED") return "quota_or_region_restriction"; + if (code === "UPSTREAM_UNAVAILABLE" || code === "SERVICE_UNAVAILABLE") return "upstream_outage"; + if (code === "MODEL_CATALOG_UNAVAILABLE") return "model_catalog_outage"; + return undefined; +} + +export function createOpenCodeGoDriver(fetcher: typeof fetch = fetch) { + return createAcpDriver(support(fetcher)); +} + +export const OpenCodeGoDriver = createOpenCodeGoDriver(); diff --git a/server/drivers/builtIn.ts b/server/drivers/builtIn.ts index 9d437e6d53..0ad6eb1393 100644 --- a/server/drivers/builtIn.ts +++ b/server/drivers/builtIn.ts @@ -10,6 +10,7 @@ import { GrokAgentDriver } from "./acp/grok.ts"; import { GeminiAgentDriver } from "./acp/gemini.ts"; import { KimiAgentDriver } from "./acp/kimi.ts"; import { DroidAgentDriver } from "./acp/droid.ts"; +import { OpenCodeGoDriver } from "./acp/opencode-go.ts"; export const BUILT_IN_DRIVERS: readonly AnyProviderDriver[] = [ GrokDriver, @@ -17,6 +18,7 @@ export const BUILT_IN_DRIVERS: readonly AnyProviderDriver[] = [ GeminiAgentDriver, KimiAgentDriver, DroidAgentDriver, + OpenCodeGoDriver, ClaudeDriver, CodexDriver, AntigravityDriver, diff --git a/server/harness/registry.ts b/server/harness/registry.ts index 8f3c571670..df567fa5d8 100644 --- a/server/harness/registry.ts +++ b/server/harness/registry.ts @@ -104,6 +104,7 @@ export class ProviderRegistry { const inst = entry.live; let snapshot: ProviderSnapshot; try { + await inst.refreshModels?.(); snapshot = await inst.snapshot(); } catch (e) { snapshot = { state: "unavailable", reason: e instanceof Error ? e.message : String(e) }; diff --git a/server/index.test.ts b/server/index.test.ts index 07486ba716..bc0f39cd94 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -279,6 +279,27 @@ describe("harness HTTP API", () => { expect(after.body.profile).toEqual({ name: "Ada Lovelace", email: "Ada@Example.com" }); }); + it("stores OpenCode Go credentials as a configured-only status", async () => { + const put = await api("PUT", "/api/config", { opencodeGo: { apiKey: "opencode-secret" } }); + expect(put.status).toBe(200); + expect(put.body.opencodeGo).toEqual({ configured: true }); + expect(JSON.stringify(put.body)).not.toContain("opencode-secret"); + + const after = await api("GET", "/api/config"); + expect(after.body.opencodeGo).toEqual({ configured: true }); + expect(JSON.stringify(after.body)).not.toContain("opencode-secret"); + }); + + it("rejects a non-string OpenCode Go API key", async () => { + const bad = await api("PUT", "/api/config", { opencodeGo: { apiKey: 123 } }); + expect(bad.status).toBe(400); + expect(bad.body.error).toContain("opencodeGo.apiKey"); + + const array = await api("PUT", "/api/config", { opencodeGo: [] }); + expect(array.status).toBe(400); + expect(array.body.error).toContain("opencodeGo"); + }); + it("404s unknown routes with the route in the error", async () => { const res = await api("GET", "/api/definitely-not-a-route"); expect(res.status).toBe(404); diff --git a/server/index.ts b/server/index.ts index 35f325d53d..1cb38bd626 100644 --- a/server/index.ts +++ b/server/index.ts @@ -977,6 +977,7 @@ function configStatus() { xai: { configured: Boolean(cfg.xai?.key) }, composio: { configured: Boolean(cfg.composio?.key), apiKeyConfigured: Boolean(cfg.composio?.apiKey) }, box: { configured: Boolean(cfg.box?.token) }, + opencodeGo: { configured: Boolean(cfg.opencodeGo?.apiKey) }, // the chosen voice is a setting, not a secret; the key is reported the // same configured-or-not way as every other credential tts: tts.describeVoice(cfg), @@ -1684,8 +1685,22 @@ const server = createServer(async (req, res) => { } if ((method === "PUT" || method === "PATCH") && path === "/api/config") { const body = await readBody(req); + const rawOpenCode = body.opencodeGo; + if ( + rawOpenCode !== undefined + && (rawOpenCode === null || typeof rawOpenCode !== "object" || Array.isArray(rawOpenCode)) + ) { + return json(res, 400, { error: "opencodeGo must be an object" }); + } + if ( + rawOpenCode + && Object.prototype.hasOwnProperty.call(rawOpenCode, "apiKey") + && typeof (rawOpenCode as { apiKey?: unknown }).apiKey !== "string" + ) { + return json(res, 400, { error: "opencodeGo.apiKey must be a string" }); + } const patch: Record = {}; - for (const key of ["xai", "composio", "box", "tts", "profile"] as const) { + for (const key of ["xai", "composio", "box", "opencodeGo", "tts", "profile"] as const) { if (body[key] && typeof body[key] === "object") patch[key] = body[key]; } if (!Object.keys(patch).length) return json(res, 400, { error: "nothing to save" }); diff --git a/server/testing/fake-acp-cli.ts b/server/testing/fake-acp-cli.ts index e0d1793bbd..5c734a987d 100755 --- a/server/testing/fake-acp-cli.ts +++ b/server/testing/fake-acp-cli.ts @@ -5,7 +5,7 @@ // session/prompt, and streams session/update notifications for a scripted // turn. Failure modes mirror how real ACP agents misbehave: // -// FAKE_ACP_MODE happy (default) | exit-early | hang | no-auth | permission +// FAKE_ACP_MODE happy (default) | exit-early | hang | no-auth | auth-required | permission // | no-session-config (reject session/set_mode + set_model // with -32601, i.e. an agent predating those methods) // | ask-peer (spawn the injected "agents" MCP server from @@ -48,16 +48,36 @@ const configOptions = () => ] : null; const argv = process.argv.slice(2); +if (process.env.FAKE_ACP_DUMP) { + const dumpEnv = Object.fromEntries( + [ + "PATH", + "HOME", + "USERPROFILE", + "SystemRoot", + "FAKE_ACP_MODE", + "FAKE_ACP_RPC_DUMP", + "TEST_POLICY", + "OPENCODE_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "XAI_API_KEY", + ].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)); +} if (argv.includes("--version")) { console.log("fake-acp 1.0.0"); process.exit(0); } -if (process.env.FAKE_ACP_DUMP) { - writeFileSync(process.env.FAKE_ACP_DUMP, JSON.stringify({ argv, env: process.env }, null, 2)); -} const out = (obj: unknown) => process.stdout.write(JSON.stringify(obj) + "\n"); const result = (id: unknown, res: unknown) => out({ jsonrpc: "2.0", id, result: res }); +const rpcMethods: string[] = []; +const recordMethod = (method: string) => { + rpcMethods.push(method); + if (process.env.FAKE_ACP_RPC_DUMP) writeFileSync(process.env.FAKE_ACP_RPC_DUMP, JSON.stringify(rpcMethods)); +}; // session/set_mode + session/set_model calls seen this run const configCalls: Array<{ method: string; params: unknown }> = []; @@ -152,6 +172,7 @@ function handle(msg: any) { return; } if (!msg.method) return; + recordMethod(msg.method); switch (msg.method) { case "initialize": { @@ -167,6 +188,14 @@ function handle(msg: any) { result(msg.id, {}); break; case "session/new": { + if (mode === "auth-required") { + out({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32000, message: "Authentication required", data: { providerId: "opencode-go" } }, + }); + break; + } const servers: McpEntry[] = Array.isArray(msg.params?.mcpServers) ? msg.params.mcpServers : []; agentsMcp = servers.find((s: any) => s?.name === "agents") ?? null; const opts = configOptions(); @@ -227,7 +256,8 @@ function handle(msg: any) { setInterval(() => {}, 1_000); return; } - const complete = () => + const complete = () => { + recordMethod("session/prompt.result"); result( msg.id, // FAKE_ACP_USAGE_ROOT reproduces opencode 1.18.18's shape: usage at @@ -236,6 +266,7 @@ function handle(msg: any) { ? { stopReason: "end_turn", usage: { inputTokens: 10, outputTokens: 5 }, _meta: {} } : { stopReason: "end_turn", _meta: { inputTokens: 10, outputTokens: 5 } }, ); + }; if (mode === "ask-peer" && agentsMcp) { // the comms e2e: reach a peer bot through the injected agents proxy // and reply with whatever it said (the peer's fake runs plain happy diff --git a/src/components/ApiKeys.tsx b/src/components/ApiKeys.tsx index ac08f5d43e..7d5ac44468 100644 --- a/src/components/ApiKeys.tsx +++ b/src/components/ApiKeys.tsx @@ -6,7 +6,7 @@ import { Check, CircleHelp, ExternalLink, Loader2, TriangleAlert } from "lucide- import { api, useStore, type ConfigStatus } from "@/state/store"; import { cn } from "@/lib/cn"; -export type ConfigSection = "composio" | "composioApi" | "box"; +export type ConfigSection = "composio" | "composioApi" | "box" | "opencodeGo"; const SECTIONS: Record< ConfigSection, @@ -18,6 +18,7 @@ const SECTIONS: Record< flag: (c) => c.composio.apiKeyConfigured ?? false, }, box: { body: (v) => ({ box: { token: v } }), flag: (c) => c.box.configured }, + opencodeGo: { body: (v) => ({ opencodeGo: { apiKey: v } }), flag: (c) => c.opencodeGo?.configured ?? false }, }; const CREDENTIALS: Record< @@ -57,6 +58,14 @@ const CREDENTIALS: Record< optional: true, warning: "Box is a paid service after its trial. Usage may incur charges.", }, + opencodeGo: { + label: "OpenCode Go API key", + placeholder: "Paste your OpenCode Go API key", + description: "Run OpenCode Go models through the maintained OpenCode CLI and ACP.", + href: "https://opencode.ai/docs/go/", + linkLabel: "Open OpenCode Go setup guide", + optional: true, + }, }; function CredentialHelp({ section }: { section: ConfigSection }) { diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx index 184c0ed04b..bbeecb7faf 100644 --- a/src/components/Onboarding.tsx +++ b/src/components/Onboarding.tsx @@ -137,6 +137,7 @@ export function Onboarding({ onDone }: { onDone: () => void }) { const codex = byKind("codex"); const grok = byKind("grokAgent"); const antigravity = byKind("antigravityAgent"); + const opencodeGo = byKind("opencodeGo"); return (
@@ -213,6 +214,7 @@ export function Onboarding({ onDone }: { onDone: () => void }) { label="Antigravity" readyNote="Installed — bots can run on Antigravity too." /> + )}
diff --git a/src/components/ProviderIcons.tsx b/src/components/ProviderIcons.tsx index bea0ce1201..aed2fd0240 100644 --- a/src/components/ProviderIcons.tsx +++ b/src/components/ProviderIcons.tsx @@ -47,6 +47,8 @@ export function ProviderMark({ driverKind, size, className }: IconProps & { driv return ; case "boxAgent": return ; + case "opencodeGo": + return OC; default: return {driverKind.slice(0, 2).toUpperCase()}; } diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 19bb690d9c..040236f682 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -209,6 +209,7 @@ export function SettingsModal() { + )} diff --git a/src/state/store.tsx b/src/state/store.tsx index 9ed1a95c7c..0181306b6b 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -161,6 +161,7 @@ export interface ConfigStatus { xai?: { configured: boolean }; composio: { configured: boolean; apiKeyConfigured?: boolean }; box: { configured: boolean }; + opencodeGo?: { configured: boolean }; /** Voice (ElevenLabs). `configured` = a key is saved; `ready` = a key AND * a voice, which is what it takes to actually speak. The key itself is * never echoed back. */