diff --git a/.claude/skills/port-to-opencode/SKILL.md b/.claude/skills/port-to-opencode/SKILL.md index 8bd2783..74f5504 100644 --- a/.claude/skills/port-to-opencode/SKILL.md +++ b/.claude/skills/port-to-opencode/SKILL.md @@ -92,24 +92,32 @@ route by who launches opencode: | Launcher | Route | |---|---| -| a human, from a terminal | `{file:…}` — see below | +| a human, from a terminal | one central store, sourced by the login shell | | a spawner (bridge, CI, IDE) | `{env:…}`, with the spawner injecting the variable | -For the human case prefer **`{file:…}` with a workspace-relative path**, kept in a gitignored -`.secrets/` directory beside `opencode.json`: +**For the human case, keep every credential in one file the login shell sources.** Here that file +is `${SHARED_ENV}/tools/secrets.sh`, sourced from `${SHARED_ENV}/.ltms`, kept at mode 600 and never +committed. `opencode.json` then names variables and holds no values: ```json -"headers": { "Authorization": "Bearer {file:.secrets/api-token}" } +"headers": { "Authorization": "Bearer {env:CONTEXT7_TOKEN}" } ``` -Verified: opencode resolves relative `{file:}` paths against the project root, so this needs no -shell setup at all — no rc export leaking the secret to every process, no direnv dependency. +Both routes end at the same syntax, and that is the point. The file does not change when a human +launches opencode instead of the bridge. -**The catch, and state it out loud:** `.secrets/` is gitignored, so a peer running in a git worktree -does **not** get it — worktrees receive tracked files only, the same rule that makes `opencode.json` -itself worth committing. Spawned peers must therefore be fed through `{env:…}` by whatever launches -them. Check the variable *names* match: a spawner often injects under a different name than your -shell uses, and the config has no fallback. +**`{file:…}` also works, and this project moved away from it.** Opencode resolves a relative +`{file:}` path against the project root, so a gitignored `.secrets/` beside `opencode.json` needs no +shell setup at all. It did not fail; the problem is that it makes a second copy of the token. The +same secret then lives in two places, and the copy you forget is the one that leaks or goes stale. +One store with many references is easier to rotate and to audit. + +**One catch survives either choice, so state it out loud:** what a human's shell exports does not +reach a spawned peer, and neither does a gitignored `.secrets/` — a git worktree receives tracked +files only. Spawned peers must be fed through `{env:…}` by whatever launches them. Check the +variable *names* match: a spawner often injects under a different name than your shell uses, and +the config has no fallback. In this repo the bridge goes further: it neutralizes a worktree's +`opencode.json`, so a member cannot inherit the primary's credentials by accident. ## 4. Do not port machine-local MCP servers diff --git a/.gitignore b/.gitignore index 95bc6ee..5a0e8dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -# Workspace-scoped secrets for tools that read no settings cascade of their own -# (opencode resolves these via {file:.secrets/...} in opencode.json). +# No secret belongs in this repo any more: every credential lives in one shell-level store +# (${SHARED_ENV}/tools/secrets.sh), and opencode.json reads it as {env:...}. This line stays as a +# backstop, so a workspace-scoped copy that someone re-creates by habit still cannot be committed. .secrets/ # Settings backups inherit the env block — and secrets with it. diff --git a/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java b/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java index 7387ca5..c574c5b 100644 --- a/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java +++ b/bridged/src/main/java/dev/ltms/bridged/session/GitWorktrees.java @@ -35,10 +35,17 @@ public final class GitWorktrees implements Worktrees { /** What {@link #isolateToolSurface} writes for {@code .mcp.json}: a valid, explicitly empty server map. */ private static final String NEUTRAL_MCP_CONFIG = "{\n \"mcpServers\": {}\n}\n"; - /** OpenCode's repo-level config. Tracked here, so it lands in every worktree; it carries - * {@code {file:.secrets/...}} references to gitignored secrets that never reach a worktree, and - * opencode refuses to start on a dangling reference — so it is neutralized and the worker gets - * only the config its launcher writes via {@code OPENCODE_CONFIG}. */ + /** OpenCode's repo-level config. Tracked here, so it lands in every worktree, and it mounts the + * primary's gitea and context7 servers with the primary's credentials. Neutralized so the worker + * gets only the config its launcher writes via {@code OPENCODE_CONFIG}. + * + *

The reason has changed shape and is now stronger. It used to be a crash: the file carried + * {@code {file:.secrets/...}} references to gitignored files that never reached a worktree, and + * opencode refuses to start on a dangling reference (CB-543). Those credentials now live in one + * shell-level store and the file reads them as {@code {env:...}}, so in a worktree the reference + * resolves instead of failing. That is worse, not better: a member would silently inherit the + * primary's admin-scoped {@code GITEA_ACCESS_TOKEN}. A loud crash became a quiet privilege leak, + * so this entry protects a boundary now rather than papering over a startup error. */ private static final String OPENCODE_CONFIG = "opencode.json"; /** What {@link #isolateToolSurface} writes for {@code opencode.json}: a valid, empty JSON object. */ @@ -112,8 +119,8 @@ public final class GitWorktrees implements Worktrees { * paths outside its own worktree. That is not hypothetical: a CB-523 worker made all 59 of its * edits in the primary checkout while compiling its worktree, so every build it ran was of code * that did not contain its changes. {@code opencode.json} is the same trap one tool over — tracked, - * so it lands in every worktree, referencing gitignored {@code .secrets/} files that never do, and - * opencode refuses to start on the dangling reference. {@code .autoenv} extends the principle to a + * so it lands in every worktree, and it mounts gitea and context7 with the primary's own + * credentials, which a member must never hold. {@code .autoenv} extends the principle to a * config that is not tracked today: autoenv authorizes by path, so a fresh worktree path is always * unauthorized and its interactive prompt would block every spawn, so re-landing one must be safe. * diff --git a/opencode.json b/opencode.json index de4c029..eb11b2c 100644 --- a/opencode.json +++ b/opencode.json @@ -14,7 +14,7 @@ "url": "https://ct7.ltms.dev/mcp", "enabled": true, "headers": { - "Authorization": "Bearer {file:.secrets/context7-token}" + "Authorization": "Bearer {env:CONTEXT7_TOKEN}" } }, "gitea": { @@ -26,8 +26,8 @@ ], "enabled": true, "environment": { - "GITEA_ACCESS_TOKEN": "{file:.secrets/gitea-token}", - "GITEA_HOST": "{file:.secrets/gitea-host}" + "GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}", + "GITEA_HOST": "{env:GITEA_HOST}" } } } diff --git a/wiki b/wiki index 8b1eb68..7c50cce 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit 8b1eb688039c8be502932cbc1139f14214a069e3 +Subproject commit 7c50cce52ea5d0229a00a3d9615bb1b18b8d4e71