feat(opencode): drive turns through opencode serve with enforced permissions - #15
Merged
Merged
Conversation
Most provider CLIs speak their protocol over stdout and stdin, so the turn loop reads frames from the child and writes them back to it. `opencode serve` does not: it exposes HTTP and Server-Sent Events on a loopback port and leaves its own stdio empty, so a turn driven that way had no way to join the existing loop. Add `AgentAdapter::attach`, which may return `ProtocolStreams` — a reader and a writer the runtime uses in place of the child's stdout and stdin. The frame contract is unchanged on purpose: newline-delimited frames in, newline-terminated frames out, so `parse_line` stays one synchronous, fully testable state machine and cancellation, cooperative interrupts, interaction timeouts and line bounding keep working for both kinds of provider instead of growing a second turn loop. `prepare_turn` now runs before the command is built, and `command_for_turn` exposes the state it seeded. An adapter that must agree with itself about a per-turn value — the loopback port `opencode serve` is told to bind and that `attach` later connects to — decides it once rather than twice. Both additions are defaulted, so existing adapters are unaffected.
`opencode serve` speaks HTTP and Server-Sent Events on a loopback port and writes nothing useful to its own stdio, so a turn driven that way needs a carrier before it can use `AgentAdapter::attach`. Add one, deliberately dumb: it performs the requests it is told to perform and reports what came back, knowing nothing about sessions, permissions or prompts. All protocol sequencing stays in the synchronous state machine that lands next, so that logic remains unit-testable against scripted lines exactly like the Codex app-server transport, and this module remains testable against a scripted HTTP server. No HTTP crate is used. `reqwest` is optional and wired only to the `temps-sandbox` feature; pulling a TLS-capable client stack into the default feature set to talk to 127.0.0.1 would be a poor trade, and `tailnet::proxy` already speaks HTTP/1.1 over `tokio::net::TcpStream` here for the same reason. Chunked transfer framing is decoded properly rather than scanned for `data:` prefixes: SSE is always chunked, so an event that straddles a chunk boundary would otherwise be silently truncated. A test covers exactly that case, and another covers a server that drops its connection mid-stream — the shape a crashing `opencode serve` takes — which is reported as a frame instead of being awaited until the turn deadline.
…issions
`opencode run --format json` has no permission enforcement an application
can rely on. The only flags it accepts are `--auto`, which approves
everything, and `--agent plan`; every other policy comes from whatever
`opencode` configuration happens to exist on the machine. A caller could
not ask for "prompt before running a shell command", could not be told that
a tool call was refused, and — worst of all — a silently auto-refused edit
looked exactly like a successful turn that produced no text.
Add `OpenCode::serve()`, a second turn mode that starts `opencode serve` on
a reserved loopback port and drives it over HTTP and SSE. The turn's policy
is supplied through `OPENCODE_CONFIG_CONTENT`, which the server reads
instead of the ambient configuration, so the policy an application asked
for is the one the harness runs under. Anything marked `ask` arrives as a
live approval and is answered on the native permission endpoint.
Mapping onto OpenCode's two permission axes:
Default/Custom edit: ask bash: ask
AcceptEdits edit: allow bash: ask
FullAccess edit: allow bash: allow
Plan edit: deny bash: deny
Reviewing an edit you were never asked about is not a review, hence
AcceptEdits is "write freely, ask before a shell command". Plan denies both
categories rather than selecting the planning agent: read-only tools are
gated by neither, so a plan turn can still look around but can never have a
side effect. An explicitly empty tool allowlist becomes a `{"*": "deny"}`
wildcard, because an empty tool set has to be an enforcement boundary
rather than a suggestion in the prompt.
A plan turn and an empty allowlist also refuse any permission that reaches
the adapter without consulting the application. Both are already denied by
the configuration the server started with, so arriving there means the
policy did not hold — and the turn promised the user no such choice would
exist.
`Run` mode is untouched and still reports `live_approvals: false`.
Also carried over from the reference driver: subscribing before prompting
so early events cannot be missed, resolving a resumed session by id rather
than by listing and filtering (which fails closed on a symlinked
workspace), failing fast on a subagent session instead of hanging until the
deadline, treating an idle turn that produced nothing as a failure, and
per-turn stdio and HTTP MCP servers whose credentials are referenced by
variable name and never serialized into the configuration.
The fixture is a real HTTP server bound to the very port the adapter reserved, speaking the real protocol over a real socket: readiness, session, chunked SSE, prompt, and the permission endpoint. Nothing stubs the carrier, so a served turn is covered end to end rather than only the state machine above it. Covered: a permission allowed and answered `once`, a permission denied and answered `reject`, a plan turn whose policy denies both categories and which refuses without ever consulting the application, the requested policy and MCP servers arriving in the child's environment with the credential referenced by name rather than serialized, a server that dies mid-turn, and a cancelled turn that aborts the session cooperatively before the process is killed. Two real lifecycle defects surfaced while writing them. `opencode serve` is a server: nothing makes it exit because a turn ended, so waiting for a natural exit hung until the turn deadline. A turn carried on adapter-supplied streams now terminates the child as its normal shutdown, with a bounded wait to reap it. The bridge also deadlocked. Its reader and writer were the two halves of one duplex stream, which stays alive until both halves drop — so the runtime closing its writer never reached the bridge as end-of-input, while the bridge held the reader open waiting for exactly that. Each direction is now its own pipe. The turn loop additionally stops reading at a terminal frame when it is attached, so a third-party carrier that never closes its reader cannot hang a turn either.
Records what `Serve` mode changes and, importantly, why it exists: `Run` mode's only permission levers are `--auto` and `--agent plan`, so the policy a turn runs under is whatever configuration the machine happens to have. The capability matrix now reports both modes separately rather than attributing `Run`'s limits to the provider as a whole. Documents the `PermissionMode` mapping onto OpenCode's `edit`/`bash` axes, why plan denies both categories instead of selecting the planning agent, why an empty tool allowlist becomes a wildcard deny, why a non-empty allowlist and the system prompt are carried as a prompt prefix, and that `Serve` needs a transport running the provider on the SDK host. Also states plainly that reading transcripts from OpenCode's local database is not implemented, so the gap is visible rather than inferred.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a
Serveturn mode to the OpenCode provider, opt-in viaOpenCode::serve(), alongside the existingRunmode (opencode run --format json, unchanged, still the default). This closes the security gap flagged in the prior gap analysis:Runmode has no real permission enforcement, which is an enforcement-boundary risk, not just a UX gap.Servemode spawnsopencode serveon an ephemeral loopback port and drives it over HTTP + SSE.PermissionModemapping:Default/Custom→ ask/ask,AcceptEdits→ allow/ask,FullAccess→ allow/allow,Plan→ deny/deny (both categories). Emptyallowed_tools→{"*":"deny"}.InteractionHandler/ApprovalRequest/ApprovalDecisiontypes — no new interaction types.live_questionsstaysfalsein both modes; OpenCode has no question channel.permission.askedthat reaches the adapter without consulting the handler at all — if one arrives, the policy didn't hold, and the turn already promised the user no such choice existed.LaunchContext/TurnRequestfields — no new parallel per-provider mechanism.session.get, with fail-fast rejection ofparentIDsubagent sessions.New adapter primitive:
AgentAdapter::attach()HTTP/SSE is new territory for the adapter model — every turn-time method was sync and the runtime loop was clocked by child-stdout lines. Rather than a second turn loop or a sidecar process,
AgentAdapter::attach() -> Option<ProtocolStreams>lets a provider substitute anAsyncRead/AsyncWritepair for the child's stdout/stdin as the frame carrier. The newline-delimited frame contract, cancellation, interrupt, and interaction timeouts are all unchanged and shared with Codex — the child is still spawned/supervised/stderr-drained/killed by the runtime as before.No new dependency: implements HTTP/1.1 + chunked-transfer SSE decoding by hand (matching the existing style in
tailnet/proxy.rs) rather than pulling in a TLS client stack for loopback-only traffic.Two runtime bugs the integration tests caught, both fixed:
opencode servenever exited on turn end (process.wait()hung to the turn deadline) — attached turns now terminate the child as part of normal shutdown, with a bounded reap.Scoped out (deliberate)
opencode.db/ snapshot import — stays in Fleet; the SDK has nosqlxdependency orImportedTurnequivalent, and it's independent of any live server.OpenCodeTurnMode::Serve.native_image_attachmentsnot advertised for OpenCode: attachments are sent asfileparts and still described in prompt text, since the SDK only has a host path (not a URL) and thefile://wire shape couldn't be verified against a live server. Additive, not silently dropped.Public API surface
Behavior note:
prepare_turnnow runs before the command is built, so an adapter can pick a port first.Testing
cargo test --all-features: all 8 targets green (270 lib incl. +17 state machine / +6 bridge, +7 new end-to-end).$(command -v cargo) clippy --all-features --all-targets -- -D warnings: clean.cargo fmt --check: clean on touched files.session/abort→shutdown.Rebased onto
mainafter #13/#14 merged; verified fmt/clippy/tests again post-rebase.🤖 Generated with Claude Code