Repository navigation
feat: opencode is a codeoid backend, with every tool call approved by codeoid - #450
Merged
Merged
Conversation
…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>
… 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>
…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>
rsharath
approved these changes
Nov 1, 2026
…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>
# Conflicts: # CHANGELOG.md
…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>
This was referenced Nov 2, 2026
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.
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
providers.opencode.dataDir(default: beside the transcript directory). Never the user's own opencode service or folders.Bashwith 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.costSource: backend), orunknownwhere 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./settings(enabled, command, agent, default model, database, data folder; applied at restart).opencodeandopencode-goproviders, aliases of them, and any provider whose URL or host-building setting leads there) is never offered, used or contacted unlessproviders.opencode.hostedModelsis 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.Also in this PR (decided while it was open; they apply to every backend)
codeoid startlocks 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..., 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 changedHOME), 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).Monitortool goes through the same shell checks as Bash.Verification
qwen3.8-maxthrough codeoid's own Qwen configuration; every hosted case passed.mainafter 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
🤖 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).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.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=scriptedorprovider/model).docs/opencode.md, docs/approvals.md (Limits), docs/CONFIGURATION.md, CHANGELOG, CLAUDE.md.Real-binary findings that differ from opencode's docs
durable.seq.fetchand redirection-only shell lines without asking; assignment-only,cd/exportlines run unasked in 2.2.5 too (bare shell environment,commandlessShellLine).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