12: how secrets actually reach opencode — and the trap in {env:…}

`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.
Dai Ha
2026-08-13 10:43:19 +02:00
parent 7b5381bdb4
commit 0a21b49b3b
+57 -2
@@ -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/<br/>(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