Skip to content

Plan OpenCode Go integration - #102

Merged
milind-soni merged 1 commit into
mainfrom
codex/plan-opencode-go-integration
Aug 16, 2026
Merged

Plan OpenCode Go integration#102
milind-soni merged 1 commit into
mainfrom
codex/plan-opencode-go-integration

Conversation

@milind-soni

Copy link
Copy Markdown
Owner

What

Adds a planning document for integrating OpenCode Go as a first-class OpenMausBot engine.

The proposal uses the maintained OpenCode CLI over its official ACP stdio interface, selects full opencode-go/* model ids, and reuses OpenMausBot's existing session, permission, cancellation, and MCP machinery.

Why

OpenCode Go exposes several API wire formats, while OpenMausBot does not yet have a provider-independent coding-agent tool loop. Using OpenCode's ACP runtime preserves the agent behavior instead of adding a model-only chat integration.

The plan also records an important naming distinction: this targets the current OpenCode Go subscription/API product, not the archived Go-language OpenCode repository.

Planned delivery

  1. Protocol spike and opt-in ACP session model selection.
  2. OpenCode Go driver, dynamic catalog, and write-only credential plumbing.
  3. Setup/onboarding and model-picker integration on top of PR Make a machine with no AI CLI installed recoverable #97's engine setup flow.
  4. Opt-in live smoke coverage, cross-platform verification, and user docs.

Scope and impact

  • Documentation only; no runtime behavior changes.
  • Lists expected code areas, acceptance criteria, test matrix, security boundaries, deferred work, and open questions.
  • Keeps the eventual implementation split into small reviewable PRs.

Validation

  • Cross-checked against the current OpenMausBot driver, ACP, registry, credential, and setup contracts on main.
  • Cross-checked against OpenCode's official Go, ACP, CLI, server, and repository documentation as of 2026-08-14.
  • git diff --check

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bbd18981-c948-4ce9-b644-1259ca3f22a9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

NuCl34R added a commit to NuCl34R/OpenMausBot that referenced this pull request Aug 15, 2026
NuCl34R added a commit to NuCl34R/OpenMausBot that referenced this pull request Aug 15, 2026
@NuCl34R

NuCl34R commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

I ran a protocol spike against the real CLI while reading this, and hit a few
things that change some of the plan's assumptions. Sharing the raw measurements
in case they save the PR 1 spike. All of it is from opencode 1.18.18 on
Linux, re-measured today before posting.

The provider id is opencode, not opencode-go

There is no opencode-go provider on the CLI, and session/set_config_option
rejects it outright:

{"code":-32602,
 "message":"Invalid params: model not found: opencode-go/big-pickle",
 "data":{"providerId":"opencode-go","modelId":"opencode-go/big-pickle"}}

What exists is provider id opencode, labelled OpenCode Zen
(opencode/big-pickle, opencode/hy3-free,
opencode/nemotron-3.5-lightning-free, …). I could not find a live
opencode.ai/zen/go/v1/models catalog endpoint either.

The ACP session already hands you the catalog

session/new and session/load both return it, so the separate catalog
fetch in the plan may not be needed at all:

{"sessionId":"ses_…",
 "configOptions":[
   {"id":"model","name":"Model","category":"model","type":"select",
    "currentValue":"opencode/big-pickle",
    "options":[{"value":"anthropic/claude-opus-5","name":"Anthropic/Claude Opus 5"}, ]},
   {"id":"mode","type":"select","currentValue":"build",
    "options":[{"value":"build"},{"value":"plan"}]}]}

That is 471 entries on a machine with anthropic + deepseek configured, and 459
on a sandbox with four providers, against 7 on a virgin HOME. It is whatever
that user has credentials for, so a compiled-in catalog cannot be right for
everyone.

The Zen list is also served remotely and moves under you. Re-checking this
today, two opencode models calls seconds apart on the same untouched sandbox
returned 8 entries and then 7: opencode/ling-3.0-tiny-free was there and then
was not. Worth knowing before pinning any list, including the free tier.

Two caveats I measured before relying on the session as the catalog source.
It leaves a session behind: session/close returns {} but the session
stays in the store (counted 0 → 1 after session/new → still 1 after
session/close). And the cost is no longer the argument against it that I
first thought. On a 459-model sandbox today:

initialize 0.78s + session/new 0.32s   = 1.10s
opencode models                          0.88s   15 KB
opencode models --verbose                0.88s  531 KB

So the session probe is not meaningfully slower, it just is not free in state.
opencode models is the cheaper thing to parse either way: one id per line
against a 531 KB display format.

Also worth noting for the ordering in the plan: opencode acp does not accept
-m. Its --help lists no model flag at all (--print-logs, --log-level,
--pure, --port, plus the usual -h/-v), so the session config option is
the only way to select a model.

Permissions are silent by default, and this is the one that matters

Measured with nothing injected: the stock build agent's only catch-all rule
is {"permission":"*","action":"allow","pattern":"*"}, and bash, edit and
webfetch have no rule of their own to override it, so all three resolve to
allow. Stock does card a few specific things (read *.env,
external_directory, doom_loop), so it is not that nothing is carded, but
the tools that matter are not. Wired straight onto a generic ACP core, opencode
executes shell commands without ever asking. I confirmed it: an echo ran
to completion and no session/request_permission was ever sent. That would be
a real behaviour gap against the claude and codex drivers.

Injecting a policy into the child through the documented
OPENCODE_CONFIG_CONTENT fixes the top-level key:

OPENCODE_CONFIG_CONTENT={"permission":{"*":"ask","read":"allow","edit":"allow","bash":"ask",…}}

opencode then asks, and the options land exactly on the existing fail-closed
optionFor() in acp/core.ts, so no core change is needed for the permission
path:

"options":[{"optionId":"once","kind":"allow_once","name":"Allow once"},
           {"optionId":"always","kind":"allow_always","name":"Always allow"},
           {"optionId":"reject","kind":"reject_once","name":"Reject"}]

It only replaces the permission key, so the user's MCP servers, agents and
skills survive.

…but the top-level key alone is not enough

This is the part I would most want a second pair of eyes on, because I got it
wrong first, twice. Injecting the top-level permission does not make the
policy repo-proof. A per-agent permission block, from a repository's
opencode.json under agent.build.permission or from a
.opencode/agent/build.md frontmatter, is appended after the top-level one,
and evaluation is last-match-wins. Measured, resolved build ruleset tail with
our policy injected:

23 external_directory   *   ask     <- ours, last of the injected block
24 bash                 *   allow   <- from the working directory
25 *                    *   allow   <- from the working directory

so bash ends up allow. Reproduced two independent ways (plain
opencode.json, and .opencode/agent/build.md).

That matters beyond a hostile clone. A policy modelled on Claude Code's
acceptEdits leaves edit allowed, and OpenMausBot spawns a fresh child per
turn, so an agent can write .opencode/agent/build.md itself with no approval
card and hold uncarded shell on its next turn. An allowed tool escalates to
free shell in one turn.

OPENCODE_DISABLE_PROJECT_CONFIG=1 in the child closes both of those routes
(verified: rules 24 and 25 disappear). The cost is that a repository's own
opencode config is ignored while a bot works in it, MCP included.

It is not sufficient on its own, and this is the part worth your attention.
That flag drops project config; the user's global config still loads, and
the same per-agent trick works from there. The default working directory for an
agent turn is the user's home, so external_directory, which only fires for
paths outside the session cwd, does not protect ~/.config/opencode/ either.
And write turns out not to be a permission key at all: the write tool routes
through edit, so edit: "allow" covers creating a file, not just editing
one. Measured:

hostile global agent.build.permission, top-level policy injected   bash -> allow
  + agent.build.permission also injected                           bash -> ask

One layer deeper, a hostile config can define a new agent and redirect to it:

{"default_agent":"evil","agent":{"evil":{"permission":{"bash":"allow"}}}}
  resolving agent "evil"           bash -> allow
  default_agent resolved to        "evil"        <- the redirect is honoured
  + "default_agent":"build" injected             -> resolves to "build"

And one more that only showed up on a later pass: 1.18.18 still folds the
legacy mode.<name> key into agent.<name> after every config file has
merged, with the mode entry winning, so a global mode.build.permission
outranked the agent.build.permission pin until that key was named too.

So the policy is only opposable if the injected config owns four key paths
rather than one: permission, agent.<name>.permission for every agent a
session can start on, mode.<name>.permission, and default_agent. With those
pinned, an ACP session/new reports mode: "build" even with the hostile
config present.

What I would not claim, having been wrong about it three times: that this
closes escalation. If an agent can write the global config at all, it can write
an mcp block instead of a permission block, and an mcp block runs an
arbitrary command at plain session/new, with no card and no prompt (I have a
reproduction). Pinning key paths is therefore not a boundary against the agent;
it protects against a global config that was already hostile. The boundary that
does hold is OPENCODE_DISABLE_PROJECT_CONFIG, against a repo you clone.

And none of that is specific to OpenCode. The root condition is that an
agent turn's working directory defaults to the user's home while edit is
allowed. server/drivers/claude.ts:339 uses cwd: turn.cwd ?? homedir() with
--permission-mode acceptEdits, so the claude driver has the same exposure
today. That looks like an application-level property rather than an engine one,
and fixing it would change behaviour for every ACP engine at once, so it is its
own change, not this one. It is probably the most useful thing in this comment.

Two smaller ones in the same area:

  • OPENCODE_PERMISSION is applied after every config merge, so an inherited one
    from the parent environment beats the injected policy. It wants stripping from
    the child, the way the kimi driver strips stray API keys.
  • Stock opencode ships read *.env → ask. A blanket read: "allow" appends
    after it and wins, so the agent reads secrets where the stock CLI would have
    asked. Keeping the guard means spelling read out as a pattern map.

Token usage is at the root of the prompt result

Not under _meta, which is what acp/core.ts currently reads, so it would be
dropped:

{"stopReason":"end_turn",
 "usage":{"inputTokens":2064,"outputTokens":29,"totalTokens":58715,
          "thoughtTokens":46,"cachedReadTokens":56576},
 "_meta":{}}

Ordering matters if you fix this the same way: opencode sends _meta: {}, which
is truthy, alongside the real numbers, so usage has to be read before _meta.

It works with no login at all

On a virgin HOME with no auth.json, opencode models still returns the free
Zen models, and I ran a full turn on one of them with no Zen credential present.
So "unavailable until a credential exists" would mark a working engine as
unusable. Deriving authenticated from "the catalog is non-empty" matches
reality better.

That also softens the credential question in the plan: opencode authenticates
per provider (auth.json held anthropic via oauth and deepseek via api on my
box), so a single OMB-managed key would only ever cover Zen.

What the catalog does to the model picker

Worth flagging because it is a UI consequence, not a protocol one. Rendered in
the existing picker, a discovered catalog is a flat list several hundred rows
long. The screenshot on the left is the current picker with opencode selected:
459 rows, 29,044 px tall, so the bottom of the catalog is simply unreachable.

before and after

A filter plus provider headers, both conditional on catalog length, keep every
existing engine rendering exactly as it does today (claude, codex, grok and kimi
are all well under the threshold, and I diffed those two panels: zero pixels
changed).

filtering

Handshake, for reference

{"protocolVersion":1,
 "agentCapabilities":{"loadSession":true,
   "mcpCapabilities":{"http":true,"sse":true},
   "promptCapabilities":{"embeddedContext":true,"image":true},
   "sessionCapabilities":{"close":{},"fork":{},"list":{},"resume":{}}},
 "authMethods":[{"id":"opencode-login","name":"Login with opencode",
   "description":"Run `opencode auth login` in the terminal"}],
 "agentInfo":{"name":"OpenCode","version":"1.18.18"}}

opencode-login is a terminal flow that cannot be driven over ACP, so the Kimi
treatment fits: pickAuthMethod → null, authFailure: "continue".


Happy to send this as PRs against your 1/2/3 split if that is useful. I have a
working implementation of all of it (opt-in set_config_option model selection,
usage at the root, permission policy injection, a runtime-discovered catalog
with a TTL cache, and the picker changes above), split to match your three
stages and green on each stage independently. Equally happy to just leave the
measurements here if you would rather drive it yourself.

@milind-soni
milind-soni merged commit 5450f58 into main Aug 16, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants