From 0a21b49b3b31e43899b2ab4b978d4ffa71f5c164 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Thu, 13 Aug 2026 10:43:19 +0200 Subject: [PATCH] =?UTF-8?q?12:=20how=20secrets=20actually=20reach=20openco?= =?UTF-8?q?de=20=E2=80=94=20and=20the=20trap=20in=20`{env:=E2=80=A6}`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `opencode.json` is committable because it holds no secret, only substitutions. What was missing is where the values come from, and the answer differs by who launched opencode — which is exactly the part that fails silently. `{env:…}` reads opencode's process environment, and opencode has no env store of its own. This project's secrets are supplied by Claude Code's settings cascade — files opencode never reads — so `{env:…}` works inside a Claude-Code-spawned process and fails in a real terminal. Verified both ways: a clean login shell has none of the three variables, and `opencode mcp list` is green in one and `needs authentication` in the other. The launch paths also disagree on names: a bridge-spawned worker receives GITEA_TOKEN + GITEA_HOST from applyGitToken, not GITEA_ACCESS_TOKEN. So a config written against the terminal's environment breaks inside a worker and vice versa. Documented as a table keyed on launcher: `{file:}` for a human, `{env:}` for a worker, with `.secrets/` gitignored and therefore absent from a worktree — the same tracked-files-only rule that makes opencode.json worth committing. Adds the `.autoenv` route that keeps Claude Code on that one store: it exports `.secrets/` into the environment so `.mcp.json`'s `${VAR}` resolves without duplicating tokens as literals in settings.local.json. Two verified caveats recorded — an unset `${VAR}` degrades to the literal text rather than failing, and leaving the directory does not unload. Finally, the verification step now distrusts `opencode mcp list`: connected is not working. A stdio server missing its credential completes the handshake and reports green — verified with gitea, which showed connected and then failed the first call with `token is required`. Exercise one authenticated tool per server. --- 12-Claude-to-OpenCode.md | 59 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/12-Claude-to-OpenCode.md b/12-Claude-to-OpenCode.md index 8e8d544..f9bf5a8 100644 --- a/12-Claude-to-OpenCode.md +++ b/12-Claude-to-OpenCode.md @@ -84,6 +84,58 @@ OpenCode substitutes at load time, in both `headers` and `environment`: This is what makes `opencode.json` safe to commit — and it **must** be committable, because a peer running in a git worktree receives tracked files only. No credential ever belongs in the file. +**The trap: `{env:…}` reads OpenCode's process environment, and OpenCode has no env store of its +own.** Claude Code supplies this project's secrets through `env` blocks in its settings cascade — +files opencode never reads. Verified here: a clean login shell +(`env -u VAR zsh -lc …`) has none of `CONTEXT7_TOKEN`, `GITEA_ACCESS_TOKEN`, `GITEA_HOST`; they +exist only inside processes Claude Code spawned. Run `opencode mcp list` from such a process and +everything is green; run it from a real terminal and context7 is `⚠ needs authentication`. + +Worse, the two launch paths disagree on names. A bridge-spawned worker gets `GITEA_TOKEN` + +`GITEA_HOST` from `applyGitToken` (see [Features](11-Features) → the git-forge token grant), not +`GITEA_ACCESS_TOKEN` — so an `opencode.json` written against the terminal's environment fails +inside a worker, and vice versa. + +So the supply route follows from who launches opencode: + +| Launcher | Route | Here | +|---|---|---| +| a human, from a terminal | `{file:.secrets/…}` | gitignored `.secrets/`, workspace-scoped | +| a bridge-spawned worker | `{env:…}` | `applyGitToken` + the profile's `env:` block | + +For the terminal case this repo uses `{file:}` with **workspace-relative** paths — verified: opencode +resolves them against the project root, so no shell export and no direnv is needed, and the secret +never enters an rc file that every process inherits. + +`.secrets/` being gitignored is the point *and* the limitation: a worker in a git worktree does not +receive it, by the same tracked-files-only rule that makes `opencode.json` worth committing. Workers +are fed by the launcher instead — which is also where the name mismatch above must be reconciled. + +### Keeping Claude Code on the same source of truth + +Claude Code expands `${VAR}` in `.mcp.json` but cannot read a file, so left alone it needs the same +tokens duplicated as literals in `.claude/settings.local.json`. A committed `.autoenv` (directory-scoped +env loader — `autoenv` here, `direnv` elsewhere) closes that gap by exporting `.secrets/` into the +environment, holding no secret of its own: + +```mermaid +flowchart LR + S[".secrets/
(gitignored, 0600)"] + S -->|"{file:...}"| OC["opencode.json"] + S -->|".autoenv exports"| E["process env"] + E -->|"${VAR}"| MJ[".mcp.json"] + L["launcher env"] -->|"{env:...}"| WK["spawned worker"] + classDef warn fill:#b7791f,stroke:#7b341e,color:#ffffff; + class L warn +``` + +*One store, three readers — the worker path stays separate because a worktree has no `.secrets/`.* + +Two verified caveats. The loader fires on entering the directory **and** when a shell starts there, +so a terminal opened directly in the repo is covered — but a Claude Code launched outside a login +shell is not, and an unset `${VAR}` degrades silently to the literal text `${VAR}` rather than +failing. And leaving the directory does not unload the variables unless leave-files are enabled. + ## What must never cross Machine-local MCP servers — an IDE index, a language server, an editor bridge. A committed project @@ -101,8 +153,11 @@ A file on disk proves nothing about what the agent loaded. opencode run "In one line: state a rule from this project's instructions." ``` -The answer must reflect the real `CLAUDE.md`. Then list the MCP servers and confirm each expected -one is connected — and that nothing you withheld appears. +The answer must reflect the real `CLAUDE.md`. Then `opencode mcp list` — and treat its verdict with +suspicion: **"connected" does not mean "working"**. A stdio server missing its credential completes +the handshake and reports green; only a tool call exposes it. Verified: `gitea` showed +`✓ connected` with no token, then failed the first call with `token is required`. Exercise one +authenticated tool per server, and confirm nothing you withheld appears. ## Gotchas