From 7c50cce52ea5d0229a00a3d9615bb1b18b8d4e71 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Fri, 14 Aug 2026 20:37:28 +0200 Subject: [PATCH] Features: correct the config-reload split; record the central secret store Two fixes to entries that had drifted from the code. Config reload: the entry claimed a profile's `model` and `tabLabel` were hot. They are not. HerdrPeerLauncher takes Map.copyOf(profiles) at construction and resolves every spawn out of that copy, so a reloaded launch setting never reaches a launch. Only `fleet:`, `placement:` and a profile's weight/maxLoad are read live. Worktree-hostile config isolation: the reason changed shape. It used to be a crash (a dangling {file:.secrets/...} reference OpenCode refuses to start on). Credentials now live in one shell-level store and opencode.json reads them as {env:...}, so in a worktree that reference resolves instead of failing, and a member would inherit the primary's admin-scoped token. A loud crash became a quiet privilege leak. Worker opens its own PR: say where the value comes from. bridged reads gitTokenEnv from its own process environment, so a daemon started before the export existed injects an empty token and every worker push fails. --- 11-Features.md | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/11-Features.md b/11-Features.md index 4a63e08..7b427d9 100644 --- a/11-Features.md +++ b/11-Features.md @@ -268,10 +268,16 @@ with a valid format-specific stub, then marks a tracked replacement `--skip-work **On.** Automatic at worktree provisioning; no configuration. -**Why.** The tracked `opencode.json` can reference gitignored `.secrets/` files that a worktree never -contains, and OpenCode refuses to start with that dangling reference. The same isolation rule also -prevents a primary-only MCP configuration or an autoenv authorization prompt from entering a worker's -tool surface. +**Why.** The tracked `opencode.json` mounts the primary's own `gitea` and `context7` servers, with the +primary's credentials, so a worktree that keeps it hands a member access it must never hold. The same +isolation rule also keeps a primary-only MCP configuration or an autoenv authorization prompt out of a +worker's tool surface. + +The reason used to be milder, and the change is worth recording. `opencode.json` referenced gitignored +`.secrets/` files that a worktree never contains, and OpenCode refuses to start on that dangling +reference — a crash, but a loud one. Those credentials now live in one shell-level store and the file +reads them as `{env:…}`, so in a worktree the reference resolves instead of failing. A loud crash +became a quiet privilege leak, which makes this isolation load-bearing rather than a workaround. **Gotcha.** The replacement is deliberately valid, not deleted: `{}` for `opencode.json`, an empty server map for `.mcp.json`, and an empty `.autoenv`. A deletion could be undone by a later checkout; the @@ -291,6 +297,13 @@ works. PR but must not be able to merge — the primary is the gate, and a worker that can merge is not gated. +**Where the value comes from.** `gitTokenEnv:` names a variable, and `bridged` reads it from **its own +process environment** — so the token must be exported in the shell that launches the daemon, not +stored in the repo. Keep it beside the operator's other credentials in one sourced file; a per-repo +copy is a second copy of the same secret, and the copy you forget is the one that leaks or goes +stale. If the daemon was started before that export existed, it injects an empty token and every +worker push fails: restart it from a login shell. + ## Session lifecycle caps **What.** Reaps idle sessions, caps turns per session, optionally clears a reused Claude Code @@ -699,8 +712,8 @@ it owns. Changing one pool's `weight` cost the whole fleet's state, so in practi | Class | Keys | What a reload does | |---|---|---| -| **Hot** | `fleet:` (pools + `tabLabel`), `placement:`, an existing profile's `weight` / `maxLoad` / `model` / `tabLabel` | takes effect on the next spawn | -| **Deferred** | `lifecycle:`, `leadHeartbeat:`, `guard:`, `worktreeRoot:`, `spawnReadyTimeoutMs` / `spawnReadyPollMs`, **adding or removing** a profile | accepted into the new config, but the startup wiring keeps the old value; logged by name | +| **Hot** | `fleet:` (pools + `tabLabel`), `placement:`, an existing profile's `weight` / `maxLoad` | takes effect on the next spawn | +| **Deferred** | `lifecycle:`, `leadHeartbeat:`, `guard:`, `worktreeRoot:`, `spawnReadyTimeoutMs` / `spawnReadyPollMs`, **adding or removing** a profile, **and an existing profile's launch settings** (`model`, `baseUrl`, `argv`, `env`, `configDir`, `mcpUrl`, `tabLabel`) | accepted into the new config, but the startup wiring keeps the old value; logged by name | | **Cold** | `bind:`, `herdrSocket:`, `broker:`, `auth:` | **refuses the whole reload** | A changed cold key refuses everything, not just itself. Applying the hot half and warning about the @@ -708,8 +721,17 @@ cold half would leave the daemon in a state matching no file on disk — the wor operator who is reading the file to work out what the daemon is doing. Refusing keeps one invariant: the live config is always *some* version of the file. -Adding a profile is deferred, not hot, because a new backend needs its own launcher and launchers are -built once at startup. Changing an existing profile's fields is hot, because those are read per spawn. +What decides the class is **who reads the key and when**, not how important it is. `weight` and +`maxLoad` are hot because the placement policy reads them live through a supplier. A profile's +`model` looks like it should behave the same way and does not: `HerdrPeerLauncher` takes +`Map.copyOf(profiles)` at construction and resolves every spawn out of that copy, so a reloaded +`model` never reaches a launch. Adding a profile is deferred for the same underlying reason — a new +backend needs its own launcher, and launchers are built once. + +That distinction is worth stating plainly because getting it wrong is invisible. A reload that +reported a changed `model` as applied would log a clean "config reloaded" while every spawn kept +using the old one, and an operator would have no reason to doubt it. So the reload compares each +surviving profile's launch settings and names the profile in the deferred list instead. A reload that fails to parse, or fails any of the four startup validators, is refused the same way and the running config stays live. A config file being saved is sometimes read mid-write, and