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.
+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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user