Skip to content

feat: opencode is a codeoid backend, with every tool call approved by codeoid - #450

Merged
saucam merged 81 commits into
mainfrom
feat/opencode-447
Nov 2, 2026
Merged

saucam merged 81 commits into
mainfrom
feat/opencode-447

Conversation

@saucam

@saucam saucam commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #447.

Summary

opencode (2.2.5 and later 2.2 releases) is a codeoid backend, at parity with Claude and Codex. Sessions, routines and dispatched work can run on any model opencode supports, with codeoid's approvals, spend caps, risk levels, memory and session features on top. codeoid runs one private opencode server per daemon, and hands it codeoid's own configured credentials (including the Qwen/Bailian setup and oai:* endpoints).

What we want

One integration that opens every opencode-supported model to codeoid without giving up anything codeoid guarantees: every tool call approved by codeoid, real cost per turn counted by spend caps (#445), shell commands rated by risk (#446), full history for forks and rewinds, and resume after a restart.

How it behaves

  • One private server per daemon, started on first use, shared by opencode sessions, restarted if it dies, stopped with the daemon. Localhost only, a fresh random password per start, run in an empty folder of its own away from anything of codeoid's. Its database sits beside codeoid's; its data, logs and caches go under providers.opencode.dataDir (default: beside the transcript directory). Never the user's own opencode service or folders.
  • Credentials: codeoid's Anthropic, OpenAI and Gemini keys, its Qwen backend and its OpenAI-compatible endpoints become opencode providers. Keys reach opencode from private files referenced in its config, never through the server's environment; the gate refuses those files and opencode's database. A gateway key only ever goes to that gateway.
  • Every tool call goes through codeoid's gate, under codeoid's tool names. A shell command is Bash with its full command line, so feat: approvals show how risky a shell command is, and ask about the risky ones #446's risk levels apply exactly as for Claude. Paths are resolved the way the kernel resolves them in opencode's process; one that can't be resolved for certain always asks. codeoid answers once and never "always allows"; anything unexpected is refused.
  • codeoid owns opencode's policy: its own config folder, no project config, only provider and model settings carried from the user's global opencode config, Code Mode and formatters off, slash commands limited to built-ins and the session's own MCP prompts, and a server refused if any agent or plugin could act without asking. The agent's commands get a bare environment. Programs opencode runs on its own (git, hg) run none of a repository's code. A tool call that runs without codeoid being asked stops the server.
  • Cost: opencode's own per-step cost (cache, reasoning, subagents, compaction and title requests included) is the turn's cost (costSource: backend), or unknown where part of it went unreported, so fix: spend caps work on every backend, and an unknown cost is never shown as $0 #445's routine caps stop correctly; a capped run counts steps opencode billed at $0 and stops when nothing prices them.
  • Session features: streaming replies and thinking into history, stop, resume, native fork, rewind, model and effort switching, an opencode agent per config, slash commands and skills, questions, steering, queued messages, foreground and background subagents, codeoid's MCP servers, and a recovery path when the event feed drops.
  • Settings: an opencode tab in the web UI and /settings (enabled, command, agent, default model, database, data folder; applied at restart).
  • Hosted models off by default: opencode's own hosted service (opencode.ai: the opencode and opencode-go providers, aliases of them, and any provider whose URL or host-building setting leads there) is never offered, used or contacted unless providers.opencode.hostedModels is on (Settings > opencode). Its model-catalog fetch from models.opencode.ai at every start is off too. A turn with no usable model fails before anything is sent, saying what to set.
  • Versions: opencode 2.2.5 or a later 2.2.x. Earlier releases are refused (2.2.2 runs some shell lines and script fetches without asking). A git workspace is refused when the server's git is older than 2.31.

Also in this PR (decided while it was open; they apply to every backend)

  • One daemon per data directory. codeoid start locks each directory it owns (database, transcripts, opencode's, memory's) before opening anything. A second daemon on the same data is refused, naming the holder and the lock file. A lock is taken over only from a holder provably gone on this machine and boot (pid gone or reused, judged by machine id, hostname and start time); never across machines or pid namespaces. A daemon that loses its lock stops serving.
  • Autonomous mode asks before writing outside the working directory. For file-writing tools on every backend this is a hard rule: the target is resolved where the kernel writes it (symlinks, dangling links, .., hard links, unresolvable paths ask); the temp dir counts as inside, except codeoid's own entries there. For shell commands it is best-effort guidance, like risk levels: a target that is provably outside, or whose text could leave (absolute, ~, .., $, a changed HOME), asks; everyday in-workdir work (cd src && sed -i …, cp -r src dist, rm -f *.log) doesn't. The OS sandbox (Run agent shell commands in a sandbox so credentials and files outside the workspace are protected by the OS #448) is the real boundary for shell; known limits of the shell reader are listed in docs/approvals.md. Unattended runs park on the owner's approval, as they do for any ask. Codex in autonomous mode isn't covered (Codex in autonomous mode runs shell commands without codeoid's approval gate, so risky commands never ask #442).
  • Claude's Grep and Glob relative paths are judged from the CLI's real cwd, and its Monitor tool goes through the same shell checks as Bash.

Verification

  • Real binaries: a 60+ case integration suite against real opencode with a local scripted model (streaming, approvals, denials, resume, fork, questions, subagents, steering, model switching, interrupt, cost against opencode's own totals, credential isolation, hostile git/hg workspaces, the server's working directory). It passes on 2.2.5; 2.2.2 is checked to be refused.
  • Hosted model: about 17 real turns on Alibaba Bailian qwen3.8-max through codeoid's own Qwen configuration; every hosted case passed.
  • Offline: 250+ unit tests against a fake opencode server corrected to behave like the real one. Full suite, end-to-end suite, typecheck and lint pass.
  • 23 audit rounds of three independent auditors each (correctness, security, real-binary behaviour), all in an OS sandbox with credentials hidden. Every finding was reproduced first and fixed with a regression test. The last round found one medium: an agent could write a plugin into codeoid's own opencode config dir, which opencode loads live. That dir, and the server's data, state and cache dirs, are now refused to every tool call, directory card and shell path in every mode, except the tool-output and shell-output dirs opencode points the model at. It also found one low: a queued mid-turn message opencode never started is now withdrawn with a notice instead of silently. Both are fixed in 5ad3a16 and 22168a5, with no audit round after them, per the agreed cap.
  • The three decisions above had three further audit rounds (24 to 26, three sandboxed auditors each) with every high and medium fixed; round 26's shell findings are the documented limits of a best-effort reader.
  • On 3539720: full suite 5,394 pass, 0 fail; the integration suite on real opencode 2.2.5 (scripted model) passes (68 cases, including no request to opencode.ai while hosted models are off); 2.2.2 is refused as expected.
  • Rebased onto main after fix: spend caps work on every backend, and an unknown cost is never shown as $0 #445 and feat: approvals show how risky a shell command is, and ask about the risky ones #446 merged, and integrated with both.

Known limits (documented in docs/opencode.md and docs/approvals.md)

Decisions left open

  • How to cost subscription sign-ins (they report $0 today), codeoid-wide.

🤖 Implementation context (for agents / maintainers)

Files

  • src/daemon/providers/opencode/: server.ts (the shared server: spawn, password, version and agent/plugin checks, live feed, restart, cwd root, git/hg overrides), client.ts (typed client for the routes used), policy.ts (injected config, session rules, bare shell environment, carried keys, commandless lines), credentials.ts (provider mapping, 0600 key files, per-daemon dirs, cleanup), translate.ts (permission → gate mapping, kernel-style path resolution), resolve.ts (binary, version range, DB and data dirs), index.ts (OpencodeProvider: turns, gap read-back, subagents, steering, fork, cost).
  • Wiring: src/config.ts (providers.opencode.*), registry.ts, src/daemon/server.ts (start-up order, shutdown), settings/manifest.ts (opencode tab), secret-paths.ts, mcp/types.ts, context-windows.ts, tool-safety.ts, risk/shell-risk.ts (process-environment reads, $(< file), debugger attach), e2e/harness.ts.
  • Tests: src/tests/fixtures/fake-opencode.ts, provider-opencode.test.ts, opencode-session.test.ts, opencode-credentials.test.ts, opencode-risk.test.ts, opencode-routine.test.ts, provider-opencode.integration.test.ts (gated: CODEOID_OPENCODE_INTEGRATION=1, CODEOID_OPENCODE_COMMAND, CODEOID_OPENCODE_MODEL=scripted or provider/model).
  • Docs: docs/opencode.md, docs/approvals.md (Limits), docs/CONFIGURATION.md, CHANGELOG, CLAUDE.md.

Real-binary findings that differ from opencode's docs

  • The durable per-session log delivers no events in 2.2.x; everything comes from the live feed, deduped by durable.seq.
  • 2.2.2 runs Code Mode fetch and redirection-only shell lines without asking; assignment-only, cd/export lines run unasked in 2.2.5 too (bare shell environment, commandlessShellLine).
  • A hidden title request is billed only in the session total. opencode runs git/hg and formatters in the workspace on its own.

History: the full commit history before the squash is kept as the local tag recovered/447-history (91 commits) plus the round fixes on top.

🤖 Generated with Claude Code

saucam and others added 30 commits November 1, 2026 07:03
…wn where part of its spend went unreported

Since #445 Session prices any turn result without a cost source from its
tokens, records a turn that ended with no result itself, and lets a routine
cap stop on an unknown cost. opencode's results carried no source, so its
own figure was priced again at list price (or unknown), and a failed turn's
spend, which the provider carried into the next turn, was counted twice.

- A turn's result says `backend` when every step with tokens had a price
  (or nothing was spent); `unknown` when a request's spend went unreported:
  a step stopped while the model answered (opencode bills it nothing, as
  real 2.2.5 does), a step with no usage, one still open when its turn
  ended or its server or session went, or a feed gap whose read-back
  failed; and no source for a turn of only $0-with-tokens steps (a
  subscription sign-in, a model priced at $0), which Session prices under
  `pricing.models`, else unknown, as on Claude. Mixed with priced steps it
  is unknown.
- A failed turn's result rides its `error` event (`error.result`, new),
  which Session records instead of an estimate; nothing is carried.
- The fake server bills a bare reject's step and fails an interrupted
  step with no usage, as real 2.2.5 does.
- Routine caps on opencode count real cost and stop on an unknown turn
  (opencode-routine.test.ts); the real-binary suite checks the cost source
  of a text turn, a stop mid-reply, a refused request, the title, and a
  model priced at $0 (I48).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, a directory it can't resolve for certain included

#446 rates every shell call that reaches the gate. opencode's reaches it as
`Bash` with its full command line and, when it names a `workdir`, that
directory as `cwd`, resolved by the path rule (a link judged where it
leads); the card and the pending approval show the same level and reasons
as Claude's for the same call (opencode-risk.test.ts, over the fake server).

A shell call whose `workdir` can't be resolved for certain is an
`opencode_unresolved_path` card, asked in every mode, but its command was
never rated: a credential read with such a workdir was asked instead of
refused. Its command is now judged in each place the card says it may run,
the worst verdict standing: the card shows its level, and a credential
read is refused as anywhere.

A line opencode 2.2.5 runs unasked (no command in it) rates medium at most,
never one #446 would ask about in every mode or refuse, and is still judged
after the fact as before: one that acts stops the server.

On the real binary (I49, through a Session, scripted model): `rm -rf ~`
asks in autonomous mode, `git status` runs unasked in guarded mode with
`autoAllowLowRisk`, a credential read is refused. The case's gate wrapper
answers opencode `once` only for `git status`, whatever the Session
decides, and the session's shell has a throwaway HOME.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s let go, so one test daemon's /tmp paths don't make later sessions ask

The default registry registers opencode's credential dir and database with
the gate and never undid it. With #446 a command that reaches a directory
holding credential files asks in every mode, so after any test built a
SessionManager over a database under /tmp, `ls /tmp` asked in autonomous
mode, and the full suite failed (T2 single-decrement, on the rebased
branch as committed).

`ProviderRegistry.dispose()` undoes what building it registered, and a
SessionManager disposes a registry it built itself once it has drained
(the daemon's only drain is at shutdown, after its sessions stopped); a
registry handed to it stays the caller's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…430 and #431 mean for it

Rebased past #431, the opencode entry sat below it; it now leads the
Added list, and says that its shell commands are judged by risk and when
a turn's cost is unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… be resolved is asked under autoAllowLowRisk too

#riskAllows short-circuited the isUnresolvedPathTool hard gate once
19f6fdb gave an opencode_unresolved_path card its command's risk, so
guarded mode with approvals.autoAllowLowRisk ran it unasked. It now
refuses a hard-gated tool, at the gate and in the card peek.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ll in the directory it ran in

irreversibleEffects judged every shell command from the workdir, while the
gate judges it in the call's cwd/workdir. An opencode (or Codex, Gemini)
call that wrote outside the workspace by naming its directory was missed.
It now reads the call with shellCallOf and passes its directory to
outsideEffects (a new `cwd` in EffectContext); an opencode_unresolved_path
card is judged in each of its candidate places.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e daemon user's login shell

opencode 2.2.5 runs a line with the server's $SHELL for any shell but fish
and nu (pwsh with -Command), while codeoid rates every line as bash reads
it. A login shell that reads lines otherwise could run what the card and
the risk level don't show. The server's SHELL is now the bash on its PATH,
else /bin/sh (opencodeServerShell); the session's shell environment keeps
the user's $SHELL for the commands themselves. I50 shows it on the real
binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…opencode runs unasked keep the server

opencode 2.2.5 finds no command in a declaration builtin with options or
none (export -p, declare -p, readonly -p, unset -v X, local) and runs it
unasked; codeoid took that for a settled request and stopped the shared
server, ending every opencode session's turn. In the session's own shell,
whose environment is codeoid's bare one, such a listing now counts as
opencode's own rule (commandlessShellLine's `listing`). A subagent's still
stops the server: one whose start fell in a feed gap may have the server's
environment, and the stop rotates its password. I51 shows the lines on
the real binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…est makes the turn's cost unknown, not a known $0

opencode reports a compaction request's spend only as the request
finishes; one stopped while its summary streams (an interrupt, a budget
stop, a loop cancel) is never reported, though it was sent with the whole
conversation. The turn came out `backend` $0. `session.compaction.failed`
with `compaction.interrupted` now counts as unreported spend, for the
session and its subagents. Real 2.2.5 announces no compaction start, so
the fake no longer emits `compaction.started` and gains a
`compact-interrupt` scenario; the scripted model gains `[[usage:N]]` and
`[[compact-slow:ms]]` so I52 shows it on the real binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it couldn't reach is $0, not of unknown cost

opencode fails a step it couldn't connect for (ECONNREFUSED, DNS) with a
`provider.transport` error and no status, which codeoid counted as spend
nobody reported: the failed turn was unknown, and a --daily-cost routine
started no more runs that day. A step whose connection was never made
(neverConnected) and that carried nothing past its start on the feed, or
holds nothing read back, now bills nothing, as a refused request does. A
reset or a timeout, or a failure after part of an answer, stays unknown:
the request may have been served. The fake gains `transport:<kind>`; I53
shows it on the real binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… billed $0 as they land, and stops when nothing prices them

opencode's native ceiling added only opencode's own cost, so on a model it
reports at $0 with tokens (a subscription sign-in, a custom provider with
no cost) --max-cost never fired mid-turn, though docs/routines.md says the
agent stops at the ceiling. Under a cap, such a step now counts at the
price Session puts on it (pricing.models, else the snapshot; the registry
hands the provider `modelPrices`); with no price the turn stops at once
(UNPRICED_STOP_MESSAGE), as Session would stop the run at the turn's end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt of an answer, then retried, makes the turn's cost unknown

Real opencode 2.2.5 fails an attempt whose response broke off mid-reply (a
stream that ended without finish_reason, a connection reset mid-body) with
the response's own status, 200, bills it nothing, and retries in a new
message. The request was served, but #stepSettled took any numeric status
as the provider refusing it, so the turn was a known figure covering only
the retry, or a known $0 when the retry couldn't connect. Only a status of
400 or more with nothing of an answer streamed is a refusal now. A retry
scheduled straight after part of an answer, with no failed step first,
also counts that attempt as unreported.

The fake gains the real shape (`midstream:<error>:<then>`), the scripted
model `[[midfail:N]]`, and I54 covers both breaks on the real binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s start stops what it launched and removes its key files

stopOpencodeServers only reached servers that had finished starting, and
stop() did nothing to a start in flight, so a SIGTERM during a session's
first turn (or a restart, or the relaunch for agent overrides) let the start
adopt its server after the shutdown step ran: a live connection in the
grace period, and 0600 key files left on disk until a later daemon cleared
them as stale.

A server joins the shutdown set as its start begins. stop() counts as a
stop epoch the start checks after each await and before adopting; one it
overtook shuts down what it launched, clears the key files and fails. stop()
closes the launching process's stdin and waits the start out. A later start
goes ahead. The fake gains FAKE_OPENCODE_SLOW_START_MS to stop a start midway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er a feed gap bills only its steps since codeoid attached

#readBackChild billed every ended step of a running child not in
#countedSteps, a per-provider set that is empty after a rebuild (a model or
backend switch, a session rebuild). A subagent continued by its id carries
its whole history, so its earlier turns' steps were billed into the turn and
the spend cap again, with extra llm_calls. A step that ended before codeoid
attached is now skipped there, as #settleReadBack already skips a call made
before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ide codeoid while a pushed message waits ends the turn and withdraws the message

#executionEnded waited for a next execution whenever a steer was owed, with
no timer, whatever the outcome. opencode runs no input after an interrupted
execution, so one stopped by another holder of the server's password, or by
opencode itself, before the steer was delivered left the turn hanging until
Session's stall watchdog, and the steer rode the next prompt. A `later`
message ended the turn but also stayed in opencode's inbox.

Now such an execution's waiting inputs are withdrawn, each steer's boundary
is paid at the turn's end (Session still counts it: it didn't interrupt),
the turn says the message wasn't run, and it ends interrupted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a relative path

opencodeServerShell used Bun.which("bash") on the server's PATH, which
honours relative entries against the daemon's cwd: with `.` or `bin` on
PATH and a `bash` there, SHELL became `./bash` or `bin/bash`, a different
file in each directory opencode runs a line in (a repository's own, for
the commandless lines opencode runs unasked). Only absolute PATH entries
are searched now, and anything but an absolute result falls back to
/bin/sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…used as a read of /proc/<pid>/environ

With BSD `e` (`ps eww`, `ps auxe`), macOS's `-E` or an `env`/`environ`
field, procps prints the environment of every process the same user owns,
reading /proc/<pid>/environ itself, so the line named no path the secret
check sees and rated a plain medium "runs ps". On opencode that hands the
agent the private server's password (and the daemon's own keys on any
backend), so in autonomous mode it could reach the server's API past the
gate. Such a line is now high with a `deny` secret, as `strings
/proc/<pid>/environ` is; an option word only known at run time counts as
one. Option arguments (`-C opencode`, `o user`) and Unix `-e` (every
process) aren't read as the modifier, so a plain ps stays medium.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…led $0 is priced at the subagent's own model

#recordStep priced an unpriced step at the turn's model (or the session's
last), but a subagent can run on another model (the `subagent` tool takes
`model`), so a capped run counted its $0-billed steps at the parent's price
instead of stopping when nothing prices them. Each step's model is now
recorded from its `session.step.started` (the session's and its
subagents'), or read from the stored message on a read-back, and the step
is priced at that model. The fake stores each assistant message's model,
as real 2.2.5 does, and gains a `free-child` subagent on a $0 model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…priced only by a pricing.models entry under its provider/model id

docs/routines.md and docs/opencode.md said a step opencode bills $0 counts
toward a spend ceiling at the pricing.models price, "else the list price".
The list-price snapshot is keyed by bare model ids (`claude-sonnet-4-5`) and
opencode's ids are `provider/model`, so the snapshot never priced one, and
the capped run stopped at the first such step, as Session's turn-end
pricing (documented in the same Cost row as overrides-only) also treats
them. The docs now say so, and how to price one. A test pins that the
snapshot doesn't price an opencode id and that a pricing.models entry does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… link to an environment or a credential file is refused

The secret-path check matched /proc/<x>/environ (and mem, fd, fdinfo,
map_files) only right under the pid, and looked through a root link only
when realpath could resolve it, so /proc/4242/root/proc/4242/environ, or
a credential file under /proc/<pid>/root/ for a pid that isn't running
on this host, was a plain path. Any such component below /proc is now a
process secret, and a /proc/<pid>[/task/<tid>]/root prefix is also
judged as the path after it, for the file tools and the shell check alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… sets and each word's possible values, and a word the grammar splits is read whole

A `for X in` loop's variable, `getopts`' name and a quoted `export
"X=…"` weren't recorded as set, so `export "LD_PRELOAD=./x.so"` was a
plain line and a ps reading `$PS_FORMAT` couldn't tell it was set. They
are now; a variable whose name is only known at run time (`declare -x
"$n=…"`, `printf -v "$n"`, `read "$n"`) isn't understood. Every word
carries a shape, the pattern of the values it could take (run-time
parts, globs, brace expansions, xargs, find and fd arguments), for the
risk check's rule on process environments. tree-sitter-bash splits
`/proc/$A/task/$B/environ` into two adjacent arguments, dropping a `$`:
adjacent arguments are joined back into the one word bash reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cess's environment is refused, and ps unless it provably shows none

Round 17 refused `ps eww` and a literal /proc/<pid>/environ, but
`PS_FORMAT=pid,environ ps -C opencode` and `cat /proc/$PPID/environ`
(an opencode shell's parent is its server, OPENCODE_PASSWORD included)
rated medium and ran unasked in autonomous mode, on opencode and on
Claude's Bash alike. One fail-closed rule in the shell risk check now
refuses, as a credential read on every backend, a command that could
read a process environment: a word whose possible values include a
/proc/<pid>/environ (or mem, fd) however the pid or the name is written
(`$PPID`, `$$`, `$(…)`, globs, a value set on the line, relative to
/proc, through a root link); what a search of /proc hands on (find
-exec, xargs, a loop); a recursive read of / or /proc; a debugger
attached to a process; top; inline code naming /proc/…/environ. ps is
an environment reader unless every option is one documented as showing
none, every format and any PS_FORMAT set on the line names no environ
field, every word is known (a single quoted value counts), and the line
sets no other variable ps reads (PS_PERSONALITY, UNIX95, …).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…only directory of its own, so /proc/$PPID/cwd leads to nothing of codeoid's

The server was spawned in the database's folder, beside codeoid-credentials/
and its config dir: a session's shell reached the injected model keys through
/proc/$PPID/cwd, which the gate can't follow when the pid is only known at run
time. Each start now gets a fresh mkdtemp dir (0500) in the daemon's temp dir,
removed when the server exits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its way stops the execution that prompt starts, and the turn ends with it

The interrupt found nothing running and was a no-op; the prompt was stored
after it and its execution ran on unobserved, its reply dropped and its spend
carried into the next turn. #post now stops what a prompt (or command, or
skill) starts when the turn was interrupted meanwhile, and #settleInterrupt
waits (bounded) for the prompt and for that execution's end. The fake now
stops an execution interrupted between admission and its start, as real 2.2.5
does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…leaves no MCP mount on the shared server and no live token

The attach went on after the teardown (waiting on a fork's move or a
leftover's stop) and mounted the session's servers with a freshly minted
memory or proxy token nobody revoked. A teardown now bumps an epoch the attach
checks after opening the session and after each mount, and waits (bounded)
for an attach already mounting, so what it mounted is unmounted and revoked
with the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is, on every backend

A substitution of only a redirect made no command, so its target reached
neither the credential-file check nor the environment rule: echo $(< FILE)
read codeoid's config or /proc/$PPID/environ at medium. The parser now
records it as the cat < FILE bash runs for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at run time is refused, and one whose words could attach is not understood

attaches() matched each word's value, which is null for any word holding a
variable, so gdb --pid=$PPID, gdb -p$PPID, lldb -p$PPID and
gdb -ex "attach $PPID" were medium. It now matches the word's shape (its fixed
text with run-time marks). A gdb, lldb, strace or ltrace word that starts with
a run-time part, or gdb's pid after the program only known at run time, is
not understood (asked in every mode).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s it, so /dev/fd/../.. is /proc and /proc/self/cwd/.. is the workdir's parent

Both the file-tool check and the shell reader collapsed .. lexically before
following links, so cat /dev/fd/../../<pid>/environ was judged as
/<pid>/environ and /proc/self/cwd/../.codeoid/config.json as a path under
/proc/self: both medium, and null for a Read. kernelPath (secret-paths.ts)
resolves .. after the physical prefix, the round-15 rule, with a process's own
cwd link taken as the directory it opens the path from; a path that names the
same file either way keeps its written form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lpath) isn't refused as an environment read: it reads no file

The round-18 rule refused ls -l /proc/$$/fd, ls /proc/$PPID and
ls /proc/self/fd as reads of a process's environment, and nothing can approve
a refusal. A program that only lists or stats its operands, not recursing,
now skips that rule for them (a credential file is still refused, and so are
its redirects). A line that lists a /proc dir only known at run time and then
reads a run-time path (ls /proc/$PPID | xargs cat) is still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
saucam and others added 11 commits November 2, 2026 01:30
…ger asks in autonomous mode

gemini-cli sends no rawInput with a permission request, only `locations`
({path}) and diff content naming the file, and the ACP provider handed the
gate `rawInput ?? {}`, so every edit was "names no path" and asked. The
gate and the card now get those paths as `locations`; a request naming no
file still asks. The fake ACP agent sends the real CLI's shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd a fixed-zone start time, recovers a crashed container's lock and a dead start's guard, and reads its file safely

- macOS's start time is read with `ps` under TZ=UTC LC_ALL=C, so a start
  from a shell in another zone no longer takes a live daemon's lock.
- The machine is told by /etc/machine-id (IOPlatformUUID on macOS) where
  both sides have one, hostname otherwise: a same-named other machine is
  never taken over; a renamed machine knows its own lock.
- The holder refreshes its lock's mtime every minute; a lock from another
  pid namespace on this machine and boot (a container restarted after a
  crash) is taken over once 10 minutes stale, instead of refusing forever.
- A takeover guard left by a start that died is judged as a lock is and
  cleared. A stale lock or guard is moved aside and checked before it goes.
- Directories the lock makes are 0700; existing ones are left alone.
- The lock is read with O_NOFOLLOW|O_NONBLOCK, as a regular file of at most
  4 KB; a symlink, FIFO or big file refuses the start, saying so.
- The temp file is fsynced before it's linked; a filesystem without hard
  links creates the lock in place; other link errors are DataDirLockError.
- A signal before the daemon's own handlers are installed releases the lock.
- Old temp files of dead starts are swept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sted model while they're off, and a carried enabled_providers keeps its meaning

With hostedModels off only the provider id `opencode` was treated as
hosted. opencode 2.2.5 has a second, `opencode-go` (its free-model filter
and Console integration name both), and a carried provider can reach
opencode.ai under any id: `canonical` copies a hosted provider's URL and
catalog (overlayProvider), or its base URL is on opencode.ai.

- HOSTED_PROVIDER_IDS = opencode, opencode-go; `hostedIdsOf` adds every
  carried or codeoid-own entry whose canonical names one (transitively) or
  that has a URL on opencode.ai. Their entries, models and small_model
  are dropped with a log line, all are denied, the picker, the persisted
  catalog filter, #offered and hostedProviderProblem (also by canonical)
  use the set.
- A carried enabled_providers stays a restriction: hosted ids are removed
  but an emptied list stays (it denies every provider, as opencode reads
  it), and codeoid's own provider ids are added to any carried list
  instead of being silently shut out.
- The fake models enabled_providers, canonical copies and opencode-go.
- Docs: titles run on a carried small_model when there is one; a
  session whose own model is hosted keeps failing until it's changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lf, so the file typechecks

The time-zone case reads the start time in child processes; its own
import of processStartTime was left unused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…canonical copy or opencode.ai alias from the user's config while hosted models are off (I66)

The integration case the fake can't stand in for: real 2.2.5's own
provider listing, default and outbound requests, behind a refusing proxy.
Without the hosted-id fix it lists opencode-go, z (canonical: opencode)
and zen (baseURL on opencode.ai).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uses the start with a named reason, not a raw stack trace

An error writing the lock's temp file (EACCES, ENOSPC, EROFS) escaped
createExclusive as a raw error; it is now a DataDirLockError naming the
directory, which `codeoid start` prints and exits on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… is sure of it, and deleting or renaming a link judges the link

Autonomous mode's outside-write check now fails closed on shell lines
(risk/effects.ts shellWriteTargets):

- A target is placed only when it is a plain literal word: a brace,
  a pattern, an extglob, a variable or substitution, ~name or a ~
  mid-word is unplaced and asks (C1, F1, F2).
- Each writing program is read with an allow-list of the options the
  reader is sure of (OPTIONS); anything else asks: unknown or
  abbreviated options, an option after the operands, -T, backups, cp
  forms that may copy a link (-P/-d/-a/-r/-R/-l/-s), find -L -delete,
  a sed script that may write or run something (sedScriptWrites), a
  sed -i suffix naming another place. -t DIR is read as getopt reads
  it, bundled or attached (F3, S2).
- A cp that may copy a link, or an mv of anything but an existing
  plain file, leaves the line's other targets unplaced, as ln does.
- A cd by a bare name isn't followed: CDPATH may send it elsewhere (C3).
- find -fprint/-fls/-fprintf files are judged.
- rm, rmdir, unlink and mv's sources are judged by the name itself:
  a link in the workdir that leads out, or a hard-linked file, no
  longer asks to be deleted or renamed; rm -r link/ still does (F5).
- outsideWriteOf takes the shell's cwd as the backend reports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…udged from where its Bash really is, at the hook and at codeoid's gate

Claude's Bash keeps a `cd` between calls, and codeoid allows its cwd
into ~/.claude/skills, so `cd ~/.claude/skills` then `echo x >
my-skill/NOTE.md` was judged inside the workdir and ran unasked in
autonomous mode (S1, F4). The PreToolUse hook's `cwd` is where the
shell is now: the hook judges the call's risk and outside writes from
it, keeps it by tool_use_id, and canUseTool hands it to Session's gate
(ToolApprovalFn's and tool_start's `shellCwd`), so the gate, the card
and pendingToolApprovals agree. A Claude shell call the hook reported
no cwd for has an unknown one: a relative target asks. Other backends
start their shell in the workdir each call and report none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se URL comes through {file:} isn't carried, and a server listing one by an opencode.ai URL is refused

A carried provider whose base URL (or canonical) came through a
{file:} reference reached opencode.ai on real 2.2.5: the filter read
the text, opencode the substituted URL (S3, F6). While hosted models
are off, an entry with a {file:} anywhere but its apiKey or a header's
value is dropped with a log line (uncheckedReference).

hostedProviderProblem refused a listed provider only by id or
canonical (C2). It now also refuses one that a config document
(GET /api/config, /etc/opencode included) points at opencode.ai, and
one whose models GET /api/model lists with an opencode.ai URL as
opencode resolved it; #offered applies the same test to each model
per turn (hostedModelInfo). The fake lists a configured provider's
models with their substituted settings, and merges a system
document's providers and default, as 2.2.5 does. I67 checks it on the
real binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… provably gone holder on this machine and boot, and a daemon that loses its lock stops

- A holder is local only when hostname, machine id and boot id all
  match, each where either side has one: a machine id alone is shared
  by cloned VMs and baked-in images, and took a live lock on another
  host (S4, F7). An earlier boot can't be told from such a twin, so
  it refuses too, naming the lock file.
- A lock from another pid namespace is never taken over (no mtime
  age, no heartbeat): refused, naming the file to remove (S5, F8).
- The holder checks every minute that its locks are still its own;
  one removed or replaced shuts the daemon down (onLost, wired to the
  ShutdownManager), rather than keep writing beside another.
- On a filesystem without hard links, another start's lock moved
  aside by mistake is written back in place, exclusively; one that
  can't be put back stays aside and is named, never deleted (F9).
- Every failure to create a data dir or lock it is a DataDirLockError
  with a named reason (S6, F10).
- A refusal names what the settings put in the dir and suggests
  XDG_CONFIG_HOME, which separates every owned dir (S7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ried only with hosted models on, and dropped with them off

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
saucam and others added 14 commits November 2, 2026 06:19
…only when its text could leave the workdir, and a bare-name cd is followed where CDPATH is known unset

The owner's split guarantee: file tools keep the hard rule; shell is
best-effort guidance (#448 is the boundary). An unplaced shell target asks
only when it is absolute, starts with ~, has a `..` (or a pattern that may
match one), a variable or substitution, comes from input of unknown origin,
or the line sets HOME, or its cwd may be anywhere (a cd to such a word,
cd -, popd, CDPATH set in the line, an unreported cwd). rm -f *.log,
cp -r src dist, cd sub && touch x and find src -exec sed -i {} no longer
ask. find -L/-H after -D, and -files0-from, now count as leaving.

Claude's CLI gets CDPATH= in its env and opencode's shells have none, so
both backends declare shellCdpathUnset and a bare cd is followed to where
it really leads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dged from the cwd the CLI resolves it from, at the hook and at codeoid's gate

The CLI resolves a Grep/Glob relative `path` (and a missing one, the search
root) from its persisted Bash cwd, so after `cd sub` a `../../.codeoid/...`
path read codeoid's own key while the secret-path gate judged it from the
workdir. The hook's cwd is now kept for every tool call, handed to
canUseTool and Session's gate, and secretPathVerdict resolves relative
paths from it; a relative path with no cwd reported asks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ame shell judgement as Bash, and a PowerShell call always asks

Monitor's `command` is a shell command or script, but no shell judgement
saw it: in autonomous mode it ran unasked whatever it wrote or read. It is
now a shell tool (risk level, credential paths, the outside-write check);
a Monitor watching a WebSocket runs no command and isn't. PowerShell's
language isn't one the reader reads, so its calls are never understood and
ask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se host-building setting isn't a plain name, or whose settings name opencode.ai, isn't carried or used; a credential kept in {file:} is carried again

opencode's bundled SDKs build hosts from plain settings (Bedrock and
Alibaba `region`, Azure `resourceName`, Vertex `location`/`project`,
Alibaba `workspaceID`, Snowflake `account`, Cloudflare `accountId`/
`gateway`, Google `universeDomain`), so `x@opencode.ai/zen/v1?` in a region
sent the prompt to opencode.ai. Those settings must now have their plain
form, and any setting naming opencode.ai after percent-decoding and
compatibility normalisation counts as hosted: at carry time, in the config
documents and listed settings at server start, and per turn.

A `{file:}` is now dropped only where it builds a URL or host (or names a
header), so Bedrock keys, a Vertex private key or an OpenAI organization
kept in files are carried, and the log names the setting to write itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hard rule, shell is best-effort guidance, and the OS sandbox (#448) is the boundary for shell

docs/approvals.md: the shell side of the outside-write check (what asks,
what doesn't, CDPATH, Monitor and PowerShell) and the shell reader's known
limits from round 26 (function bodies, traps and loops, find -execdir, a
reopened /dev/stdout, a cd into a removed dir, cp into a directory through
a link, a statement after a heredoc, writes through a link the line copies);
Claude's Grep/Glob relative paths. docs/opencode.md: host settings, carried
file credentials, the shell's CDPATH. docs/routines.md, CLAUDE.md and the
CHANGELOG to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot "deletes the whole workspace", so common in-workdir lines run unasked through the whole gate

A pattern judged by its fixed directory made `rm -f *.log` at the
workspace's top rate high, so it asked in autonomous mode though the
owner's list says it never should. A pattern with a name in its last
component deletes what matches; `rm -rf *`, `./*` and `.*` stay high, and
anything under `.git` too. A Session-gate test runs the owner's in-workdir
lines through the risk level and the outside-write check together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…des the cwd the backend reports; both are judged

On Claude the hook's `cwd` is where the CLI's Bash really runs; a `cwd`
key the model adds to the input (which Bash doesn't take) made a relative
write judged from the workdir instead. With a reported cwd, the targets are
now judged from both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… are checked for opencode.ai as its settings are

hostedModelInfo read only a model's `settings`; a variant's settings or a
`Host` header naming opencode.ai passed the start-up and per-turn checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wept, and a lock lost during a shutdown doesn't cut that shutdown short

sweepTemps deleted `.gone` files by mtime, and a rename keeps the lock's
old mtime, so another start's lock that removeIfStill couldn't put back
(which the refusal says is kept) went at the next start; one holding a
holder not judged gone now stays. onLost called process.exit(1) when a
shutdown was already draining, cutting it short; it now leaves that
shutdown to finish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s anchored on a literal name, so `rm -rf *a*` and `[a-z]*` stay high

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… after a fixed delay

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…art, so a full pipe can't block its shutdown, and shows that output when a daemon exits badly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it exits or stalls unexpectedly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Support opencode (2.x) as a backend, on par with Claude and Codex

2 participants