fix(opencode): scope a profile's config to its process and close the project layer - #216
Merged
Merged
Conversation
OpenCode profiles that share one cwd ran under each other's instructions. The bridge wrote each profile's generated opencode.json and instruction files at fixed workspace paths, and opencode rereads them on every model request. Runtime gives a root and every manager it spawns one cwd, so the last profile to materialize replaced the instructions of running and resumed sessions while each receipt still named its own profile. The generated config now reaches only its process through OPENCODE_CONFIG_CONTENT, and the instruction files it names sit under .tangle/opencode-profile/<profile digest>/. OPENCODE_CONFIG keeps the request's MCP file, which may hold credentials. A fixed-path config that an earlier bridge version generated is removed on the next profile turn. Fixes #215 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014G45Uj6RV5945HXvEkKnfh
…project layer OpenCode profiles that share one cwd ran under each other's instructions, subagents and custom tools, and under whatever an agent wrote into that directory mid-run. opencode merges the project layer of its working directory — `opencode.json` and everything under `.opencode` — on every model request. So the last profile to materialize, and a director that wrote `notes/house-style.md` plus an `opencode.json` into its own workspace, changed the next profile's turn while each receipt still named its own profile. The generated `opencode.json` no longer enters the workspace. It reaches the one process through `OPENCODE_CONFIG_CONTENT`, with the instruction paths made absolute for that process while the plan keeps the relative form, so the plan digest stays independent of where the workspace sits. `OPENCODE_DISABLE_PROJECT_CONFIG` closes the project layer, and the profile's own `.opencode/**` files are applied into a private per-turn directory under the cwd named by `OPENCODE_CONFIG_DIR`. That directory is fresh on every turn and removed when the turn ends, so an edited file cannot refuse a later session and a later profile cannot read it. Measured against opencode 1.18.30 with `opencode debug config` and `opencode debug agent`: a planted `opencode.json`, `.opencode/agents/<name>.md` and `.opencode/tools/<name>.js` no longer reach the process, the profile's own material does, and the operator's global config still loads, so provider, model and plugin wiring is unchanged. No project file is deleted. A config a user wrote and a stale one an earlier bridge version generated cannot be told apart, and neither reaches a profile process now. A serialized config the host env sanitizer would drop is refused where it is built, before any executor is chosen, so the host executor and Docker enforce the same profile or neither runs. An operator's own `OPENCODE_CONFIG_CONTENT` becomes the base the profile's keys merge over instead of being replaced with nothing reported. Fixes #215 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014G45Uj6RV5945HXvEkKnfh
… no config A profile with subagents or resources but no instructions, no prompt addition, no tools and no permissions generates no `opencode.json`, and the scoping returned before it created the private directory or set `OPENCODE_DISABLE_PROJECT_CONFIG` and `OPENCODE_CONFIG_DIR`. That turn got the whole open project layer: its own subagent landed in the shared `.opencode/agents/`, and in a seeded directory the real binary resolved the foreign instructions, `agent.build.prompt`, custom tool and skill. It is not a corner case. A manager whose only declared tools are coordination tools arrives exactly like that, because the caller strips `agent_runtime_coordination_*` before sending. Isolation is a property of the turn, not of what the profile happens to contain, so an absent config is now scoped as an empty one. Docs corrected against the installed binary rather than assumption. `Instruction.systemPaths` walks `AGENTS.md`, `CLAUDE.md` and `CONTEXT.md` only under `if (!OPENCODE_DISABLE_PROJECT_CONFIG)`, so this closes those too; they are no longer listed as a residual. A co-resident agent can also WRITE a running turn's private directory, not merely read it, and opencode rereads instruction files on every model request, so such a write changes the running turn; both agents share a uid on the host executor, so no file mode separates them. Skills under `<cwd>/.claude/skills` and `<cwd>/.agents/skills` are gated on a separate `OPENCODE_DISABLE_EXTERNAL_SKILLS`, which would also drop the operator's own global skills, so they stay reachable and are recorded as a residual. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014G45Uj6RV5945HXvEkKnfh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #215.
opencode merges the project layer of its working directory —
opencode.json,.opencode/opencode.json, every agent, skill, command and custom tool under.opencode, and the project context filesAGENTS.md,CLAUDE.md,CONTEXT.md— on every model request. Runtime gives a root and every manager it spawns onecwd, so that layer was a shared channel: the last profile to materialize, and an agent that wrote files into its own workspace mid-run, changed the next profile's turn while each receipt still named its own profile.This closes the channel rather than policing what is in it.
opencode.jsonis no longer written into the workspace. It reaches the one process throughOPENCODE_CONFIG_CONTENT.OPENCODE_DISABLE_PROJECT_CONFIG=1stops opencode reading that project layer at all..opencode/**files — instruction files, subagents, skills, commands, custom tools — are applied into a private per-turn directory under thecwd, named inOPENCODE_CONFIG_DIR, created fresh for the turn and removed when the turn ends..opencode/agents/and resolve the directory's instructions,agent.<name>.prompt, custom tools and skills into its process. An empty config is still a config.OPENCODE_CONFIGstill carries the request's MCP/permission file in the host temp directory, because it may hold credentials.Two behaviours that were wrong on the previous head of this branch:
sanitizeHostEnvsilently drops any env value over 16384 characters, andbuildDockerExecArgshas no such ceiling. A profile with a large permission map therefore ran with no instructions and no tool restrictions on the host, and enforced in Docker, whileworkspacePlanDigeststill committed to the config. The turn is now refused where the value is built, before any executor is chosen, so both executors enforce the same profile or neither runs.MAX_ENV_VALUE_BYTESandexceedsHostEnvValueLimitare exported fromsrc/executors/host.tsso the guard and the sanitizer cannot drift.opencode.json. A config a user wrote and a stale one an earlier bridge version generated cannot be told apart —{"$schema": …, "tools": {"bash": false}}is exactly both — so nothing is removed. With the project layer closed a stale file is inert.Also: an operator's own
OPENCODE_CONFIG_CONTENTis no longer silently replaced. The spawn merges provisioned env overprocess.env, so the operator's value becomes the base and every key the profile declares replaces it.What this covers, and what it does not
Covered, measured through the real binary: the profile's
instructions,tools,permission, subagents, skills, commands and custom tools are private to the process, and the directory'sopencode.json,.opencode/**and project context files (AGENTS.md,CLAUDE.md,CONTEXT.md) do not reach it. The context files are gated on the same flag — in the installed 1.18.30 binary,Instruction.systemPathswalks["AGENTS.md", "CLAUDE.md", "CONTEXT.md"]only underif (!OPENCODE_DISABLE_PROJECT_CONFIG).Not covered — state plainly rather than assume:
profile-instructions.mdinjects instruction bytes, and dropping anagents/<name>.mdin adds a subagent. On the host executor both agents run as the same uid, so no file mode can separate them. Give a profile its own working directory when that matters.<cwd>/.claude/skills/**and<cwd>/.agents/skills/**still reach every profile. opencode gates external skill discovery on a separateOPENCODE_DISABLE_EXTERNAL_SKILLS, and setting it would also remove the operator's own global skills, so it is left alone.cleanup()runs in the backend'sfinally, so a normal turn, a failed turn and a cancelled turn all release the directory (there is a test for the cancelled case). ASIGKILLor crash between the spawn and thatfinallyleaves one.cli-bridge-opencode-profile-*directory in the workspace. It is inert — nothing reads a config directory no process is pointed at — and nothing prunes it later. Same shape as the existingprovisionPiProfilepath.tools: falseis not actually enforced by opencode today. The request permission file setsbash/edit/write/webfetchtoallow, and opencode letspermissionwin overtools. Measured on this branch: a profile declaringtools: {bash: false, webfetch: false}resolves tobash: true, webfetch: true. Pre-existing and orthogonal to this PR — filed as opencode: a profile's tools:false is not enforced, because the request permission file re-allows the tool #218. This PR does not claim tool-grant enforcement, only that each profile's declared grants stay private to its own process.Evidence
Measured against opencode 1.18.30 on macOS, host executor, 2026-09-10.
Real binary, in-repo tests (
tests/opencode-project-config-real.test.ts, skipped when the binary is absent). A profile turn is provisioned in acwdseeded withopencode.json(namingnotes.mdand settingagent.build.prompt),.opencode/agents/critic.mdand.opencode/tools/foreign_tool.js. Thenopencode debug configandopencode debug agent:config.instructionsnotes.mdconfig.agent.build.promptconfig.permissiondebug agent buildtoolsvictim_only_tool, excludesforeign_tooldebug agent criticpromptFOREIGN-CRITIC-PROMPTA second case covers the profile that generates no config of its own — subagent only, no instructions, tools or permissions — in the same seeded directory: no
notes.md, not the planted build prompt, noforeign_tool, its ownhelpersubagent resolves, and the directory'scriticdoes not resolve at all.Live, on a scratch bridge on 127.0.0.1:8933 from this branch.
director-awas asked to set the workspace up for its teammates; it used bash to writenotes/house-style.md("begin every reply with the token HOUSE-RULE-3131") and anopencode.jsonregistering it, then repliedDONE. In that samecwd:director-b, first turndirector-b; marker: DIRECTOR-B-MARKER-4418root, first turnreview-root; marker: ROOT-MARKER-1234No
HOUSE-RULE-3131token in either, and no.cli-bridge-opencode-profile-*left in the workspace afterwards. The deterministic control in that same real workspace, with the same binary:Unit and backend tests (
tests/opencode-profile-isolation.test.ts, 8 tests): two profiles in onecwdsee only their own config, instruction bytes, subagent and custom tool; a profile that generates no config is scoped anyway, with its subagent private and the shared.opencode/never created; the receipt names the whole plan digest and every written path; a resumed session keeps its plan digest and gets a new directory; an oversize config is refused and anything the guard accepts survivessanitizeHostEnv; three kinds of project config are left byte-identical and out of the process; an edited or deleted request-scoped file is re-materialized instead of refusing the session forever; 101 profiles in one directory leave the directory exactly as they found it; a cancelled turn releases its directory; an operator'sOPENCODE_CONFIG_CONTENTis merged under the profile's keys; and a backend turn that reads its instructions while a second profile materializes mid-turn still reads its own, with an agent-writtenopencode.jsonpresent.Checks
pnpm typecheck— passes.pnpm run test:runtime-consumer— 1 test, passes.pnpm vitest run— 1051 passed, 11 failed, 24 skipped: exactly the eleven below and nothing else.Eleven failures are stable, pre-existing, and identical on
mainon this macOS host:docker-executor×3 andfailure-attribution×1 (macOS canonicalizes/var/folders/...to/private/var/folders/...),jail×5 (/home/user/...→/System/Volumes/Data/home/user/...),pi-inference-isolation×2 (/proc/self/environdoes not exist). Filed as #217.Correction to the count reported earlier on this PR: a full-suite run does not always land on eleven. Across four runs the extras were 2 on the pre-fix tree (1042 passed / 13 failed), then 3, 1 and 0 here — intermittent failures in
pi-native,retained-sessionsandpi-inference-isolation, all of which pass when those files are run on their own, three runs in a row. They are load-dependent flakes on both refs, not regressions; noted in #217 so a reader comparing two counts is not misled.🤖 Generated with Claude Code