Skip to content

feat(opencode): drive turns through opencode serve with enforced permissions - #15

Merged
dviejokfs merged 6 commits into
mainfrom
feat/opencode-serve-interactions
Sep 21, 2026
Merged

dviejokfs merged 6 commits into
mainfrom
feat/opencode-serve-interactions

Conversation

@dviejokfs

Copy link
Copy Markdown
Contributor

Summary

Adds a Serve turn mode to the OpenCode provider, opt-in via OpenCode::serve(), alongside the existing Run mode (opencode run --format json, unchanged, still the default). This closes the security gap flagged in the prior gap analysis: Run mode has no real permission enforcement, which is an enforcement-boundary risk, not just a UX gap.

  • Serve mode spawns opencode serve on an ephemeral loopback port and drives it over HTTP + SSE.
  • Permission policy translated from Fleet's PermissionMode mapping: Default/Custom → ask/ask, AcceptEdits → allow/ask, FullAccess → allow/allow, Plan → deny/deny (both categories). Empty allowed_tools → {"*":"deny"}.
  • Live approvals answered through the existing InteractionHandler/ApprovalRequest/ApprovalDecision types — no new interaction types. live_questions stays false in both modes; OpenCode has no question channel.
  • Extra hardening beyond Fleet's driver: a plan turn or empty allowlist also auto-rejects any permission.asked that 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.
  • Stdio MCP passthrough, persona/system-prompt prefix, and tailnet env all reuse existing LaunchContext/TurnRequest fields — no new parallel per-provider mechanism.
  • Session resume via session.get, with fail-fast rejection of parentID subagent 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 an AsyncRead/AsyncWrite pair 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:

  1. opencode serve never 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.
  2. Bridge deadlock from reader/writer sharing one duplex stream — each direction is now its own pipe.

Scoped out (deliberate)

  • History import / opencode.db / snapshot import — stays in Fleet; the SDK has no sqlx dependency or ImportedTurn equivalent, and it's independent of any live server.
  • Serve mode over SSH/remote transports — the bridge dials the SDK host's loopback; remote use needs port forwarding the SDK doesn't arrange. Documented on OpenCodeTurnMode::Serve.
  • native_image_attachments not advertised for OpenCode: attachments are sent as file parts and still described in prompt text, since the SDK only has a host path (not a URL) and the file:// wire shape couldn't be verified against a live server. Additive, not silently dropped.

Public API surface

pub enum OpenCodeTurnMode { Run /* default */, Serve }   // #[non_exhaustive]
impl OpenCode {
    pub fn serve() -> Self;
    pub fn with_turn_mode(self, OpenCodeTurnMode) -> Self;
    pub fn turn_mode(&self) -> OpenCodeTurnMode;
}
pub struct ProtocolStreams { pub reader: TransportReader, pub writer: TransportWriter }
// AgentAdapter, both defaulted — existing adapters unaffected
async fn attach(&self, &TurnRequest, &AdapterState) -> Result<Option<ProtocolStreams>>;
fn command_for_turn(&self, &TurnRequest, &AdapterState) -> Result<CommandSpec>;

Behavior note: prepare_turn now 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.
  • Integration tests bind a real socket on the port the adapter reserved (no stubbing): ask→allow, ask→deny, plan-mode denial without consulting the handler, policy+MCP reaching the child env, server death mid-turn, clean cancel→session/abort→shutdown.

Rebased onto main after #13/#14 merged; verified fmt/clippy/tests again post-rebase.

🤖 Generated with Claude Code

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.
@dviejokfs
dviejokfs merged commit e5d0f8d into main Sep 21, 2026
6 checks passed
@dviejokfs
dviejokfs deleted the feat/opencode-serve-interactions branch September 21, 2026 17:05
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.

1 participant