Compare commits

...

5 Commits

Author SHA1 Message Date
Dai Ha 7e0ff9ab06 Keep every credential in one store, not a per-repo copy
The repo carried a gitignored .secrets/ directory with four files. Two of them
(context7-token, gitea-token) were byte-identical copies of variables the login
shell already exported. One (gitea-host) is not a secret. The fourth
(worker-gitea-token) was the only copy anywhere, and nothing exported it, so
bridged read gitTokenEnv from an environment that never had it and every worker
push got an empty token.

All four values now live in the operator's single sourced secrets file, verified by
sha256 before the copies were removed. opencode.json reads them as {env:...}, which
.mcp.json already did. A second copy of a secret is the problem: the copy you forget
is the one that leaks or goes stale.

This makes worktree isolation load-bearing rather than a workaround. opencode.json is
tracked, so it lands in every worktree. It used to fail there, because {file:.secrets/}
pointed at files a worktree never receives and OpenCode refuses to start on a dangling
reference. With {env:...} the reference resolves, and a member would silently inherit
the primary's admin-scoped GITEA_ACCESS_TOKEN. GitWorktrees already neutralizes the
file; only its stated reason changes, and it is now a confidentiality boundary.

The port-to-opencode skill taught {file:.secrets/} as the preferred pattern, so it is
rewritten to teach the central store and to say why we moved. .gitignore keeps the
.secrets/ line as a backstop against habit.

Includes the wiki pointer, which also carries the CB-559 config-reload correction.
2026-08-14 20:38:31 +02:00
Dai Ha 6d1565d8b6 Merge CB-559 correction: profile launch settings are deferred, not hot 2026-08-14 20:37:47 +02:00
Dai Ha e18e002d2f CB-559: a profile's launch settings are deferred, not hot
The shipped docs and javadoc said a profile's `model` and `tabLabel` take effect on
the next spawn. They do not, and ConfigRef did not detect the change either, so a
reload logged a clean "config reloaded" and silently did nothing. That is the worst
outcome a reload can produce: the operator has no reason to doubt it.

What makes a key hot is who reads it and when, not that it is config. Placement
reads weight and maxLoad through a supplier on CompositePeerLauncher, so those are
genuinely hot. HerdrPeerLauncher takes Map.copyOf(profiles) at construction and
resolves every spawn out of that copy, so model, baseUrl, argv, env and the rest
cannot move until the daemon restarts.

changedDeferredKeys now compares every launch component of an existing profile,
excluding weight and maxLoad, and names the profiles that need a restart. The
javadoc and bridged.example.yaml say the same thing. Two tests pin the pair:
weight/maxLoad reports nothing deferred, a changed model reports the profile by name.
2026-08-14 20:37:42 +02:00
Dai Ha c18572ea9d Bump wiki to 8b1eb68 — the CB-557/558/559 Features entries
CI / contract (push) Successful in 45s
CI / build (push) Successful in 1m14s
2026-08-14 19:52:02 +02:00
Dai Ha e9d02bc5e8 Merge CB-557/558/559: the fleet block, role pools, lead auto-launch, config reload
Four commits that together reshape how the daemon is told who it may run.

CB-557 (schema) — one `fleet:` block replaces `leaders:`, `members:`, `leadScan:`
and `defaultProfile:`. Role is the containing map key, not a `role:` field, so a
misspelled pool name declares nothing instead of producing a member with no
contract. Tab labels are role-first and counted per role+profile.

CB-557 (routing) — an unqualified spawn picks its profile from its role's pool.
Role and profile stay orthogonal: a reviewer may run on the same profile as the
dev whose diff it reads, so the two cannot be one field. An explicit
`bridge_spawn{profile:…}` stays exempt, because it carries no role and would
otherwise be refused for a profile the operator named.

CB-558 — the daemon launches a declared lead when fewer than `instances` are
running. An auto-launched lead is NOT a member: no reply charter, never
registered with SessionManager (the idle reaper would kill it), and no Anthropic
binding in its env. A lead counts as live only when herdr reports a running
agent, so one crash does not disable auto-launch forever, and an unreachable
herdr starts nothing at all.

CB-559 — `bridged.yaml` can be re-read without a restart, opt-in through
`configReload:`. Keys are hot (`fleet:`, `placement:`, an existing profile's
fields), deferred (lifecycle, guard, adding a profile) or cold (bind,
herdrSocket, broker, auth). A cold change refuses the WHOLE reload rather than
half-applying it, because a daemon matching no file on disk is worse for an
operator than no reload at all.

634 tests, IDE diagnostics clean. Verified live against the running daemon: the
lead launcher recognised the existing lead and started nothing; the reload
applied a hot change, refused a `bind:` change exactly once (not once per tick),
and reverted cleanly.
2026-08-14 19:51:34 +02:00
8 changed files with 153 additions and 44 deletions
+19 -11
View File
@@ -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
+3 -2
View File
@@ -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.
+9 -5
View File
@@ -232,13 +232,17 @@ placement: weighted
# Not every key can move under a running daemon, and the difference is about what already exists
# when the reload happens — not about how important the key is:
# HOT → takes effect on the next spawn: the whole `fleet:` block (every role pool and
# `tabLabel`), `placement:`, and an existing profile's weight / maxLoad / model /
# tabLabel.
# `tabLabel`), `placement:`, and an existing profile's weight / maxLoad. Those are
# hot because the placement policy reads them through a supplier — being config is
# not by itself enough to make a key hot.
# DEFERRED → accepted into the new config, but the wiring built at startup keeps the old value
# until you restart: `lifecycle:`, `leadHeartbeat:`, `guard:`, `worktreeRoot:`,
# `spawnReadyTimeoutMs` / `spawnReadyPollMs`, and ADDING or REMOVING a profile (a new
# backend needs its own launcher, and launchers are built once). The reload logs
# these by name rather than pretending they applied.
# `spawnReadyTimeoutMs` / `spawnReadyPollMs`, ADDING or REMOVING a profile (a new
# backend needs its own launcher, and launchers are built once), AND an existing
# profile's launch settings — model, baseUrl, argv, env, configDir, mcpUrl, tabLabel.
# The launcher takes a copy of `profiles:` at startup and resolves every spawn out of
# that copy, so those never reach a launch until you restart. The reload logs them by
# name rather than pretending they applied.
# COLD → cannot change at all: `bind:`, `herdrSocket:`, `broker:` and `auth:`. The socket is
# bound, the broker connection is open, and the auth mode decides who may reach the
# port that is already listening.
@@ -7,6 +7,7 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
@@ -27,13 +28,18 @@ import java.util.function.Supplier;
* <ul>
* <li><strong>Hot</strong> — re-read per use, so a reload takes effect on the next spawn:
* {@code fleet:} (every role pool and {@code tabLabel}), {@code placement:}, and an existing
* profile's {@code weight} / {@code maxLoad} / {@code model} / {@code tabLabel}.</li>
* profile's {@code weight} / {@code maxLoad}. Those three are read through a supplier on
* {@code CompositePeerLauncher}, which is what makes them hot — not the fact that they are
* config.</li>
* <li><strong>Deferred</strong> — accepted into the new snapshot, but the wiring built at startup
* keeps the old value until a restart: {@code lifecycle:}, {@code leadHeartbeat:},
* {@code spawnReadyTimeoutMs} / {@code spawnReadyPollMs}, {@code guard:},
* {@code worktreeRoot:}, and <em>adding or removing</em> a profile (a new backend needs its
* own launcher, which is constructed once). A reload logs these rather than pretending they
* applied.</li>
* {@code worktreeRoot:}, adding or removing a profile (a new backend needs its own launcher,
* which is constructed once), <em>and an existing profile's launch settings</em> —
* {@code model}, {@code baseUrl}, {@code argv}, {@code env}, {@code mcpUrl} and the rest.
* {@code HerdrPeerLauncher} takes {@code Map.copyOf(profiles)} at construction and resolves
* each spawn out of that copy, so those never reach a launch until the daemon restarts. A
* reload logs these rather than pretending they applied.</li>
* <li><strong>Cold</strong> — cannot change at all under a running daemon: {@code bind:},
* {@code herdrSocket:}, {@code broker:} and {@code auth:}. The socket is bound, the broker
* connection is open, and the auth mode decides who may reach the port that is already
@@ -204,16 +210,60 @@ public final class ConfigRef implements Supplier<BridgedConfig> {
|| !Objects.equals(old.spawnReadyPollMs(), fresh.spawnReadyPollMs())) {
changed.add("spawnReady*");
}
// Only the profile SET is deferred: a new backend needs a launcher, and launchers are built
// once at startup. An existing profile's fields are read per spawn and so are hot.
Set<String> before = old.profiles() == null ? Set.of() : old.profiles().keySet();
Set<String> after = fresh.profiles() == null ? Set.of() : fresh.profiles().keySet();
if (!before.equals(after)) {
Set<String> diff = new LinkedHashSet<>(before);
diff.addAll(after);
diff.removeIf(p -> before.contains(p) && after.contains(p));
Map<String, BridgedConfig.Profile> before =
old.profiles() == null ? Map.of() : old.profiles();
Map<String, BridgedConfig.Profile> after =
fresh.profiles() == null ? Map.of() : fresh.profiles();
// Adding or removing a profile is deferred: a new backend needs its own launcher, and
// launchers are built once at startup.
if (!before.keySet().equals(after.keySet())) {
Set<String> diff = new LinkedHashSet<>(before.keySet());
diff.addAll(after.keySet());
diff.removeIf(p -> before.containsKey(p) && after.containsKey(p));
changed.add("profiles (added/removed: " + String.join(", ", diff) + ")");
}
// An EXISTING profile's launch settings are deferred too, and this is easy to get wrong:
// `HerdrPeerLauncher` takes `Map.copyOf(profiles)` at construction and `spawn` resolves the
// profile out of that snapshot, so a reloaded model/baseUrl/argv/env never reaches a launch.
// Only weight and maxLoad are genuinely hot, because placement reads them through the
// supplier on the composite rather than from the adapter's copy. Without this check a
// changed model would report "config reloaded" and silently do nothing — the worst outcome
// a reload can produce, because the operator has no reason to doubt it.
List<String> relaunch = new ArrayList<>();
before.forEach((name, was) -> {
BridgedConfig.Profile now = after.get(name);
if (now != null && !sameLaunchSettings(was, now)) {
relaunch.add(name);
}
});
if (!relaunch.isEmpty()) {
changed.add("profiles." + String.join("/", relaunch) + " launch settings "
+ "(model, baseUrl, argv, env, …) — the launcher holds a startup snapshot");
}
return changed;
}
/**
* Whether two versions of a profile would launch a peer identically. Compares every component
* the launcher reads at spawn; {@code weight} and {@code maxLoad} are excluded because those are
* read live by the placement policy and really do take effect on the next spawn.
*/
private static boolean sameLaunchSettings(BridgedConfig.Profile a, BridgedConfig.Profile b) {
return Objects.equals(a.baseUrl(), b.baseUrl())
&& Objects.equals(a.model(), b.model())
&& Objects.equals(a.configDir(), b.configDir())
&& Objects.equals(a.tokenEnv(), b.tokenEnv())
&& Objects.equals(a.argv(), b.argv())
&& Objects.equals(a.placement(), b.placement())
&& Objects.equals(a.workspace(), b.workspace())
&& Objects.equals(a.tabLabel(), b.tabLabel())
&& Objects.equals(a.mcpUrl(), b.mcpUrl())
&& Objects.equals(a.cwd(), b.cwd())
&& Objects.equals(a.parityOverlay(), b.parityOverlay())
&& Objects.equals(a.gitTokenEnv(), b.gitTokenEnv())
&& Objects.equals(a.gitHostEnv(), b.gitHostEnv())
&& Objects.equals(a.kind(), b.kind())
&& Objects.equals(a.env(), b.env())
&& Objects.equals(a.subscription(), b.subscription());
}
}
@@ -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}.
*
* <p>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.
*
@@ -213,9 +213,12 @@ class ConfigRefTest {
assertTrue(out.deferred().getFirst().contains("haiku"), out.deferred().toString());
}
/** Changing an existing profile's fields is hot — no launcher has to be rebuilt for it. */
/**
* Changing an existing profile's weight or maxLoad IS hot: placement reads those live through
* the supplier on the composite, so the next spawn already sees them.
*/
@Test
void changingAnExistingProfilesFieldsIsHot(@TempDir Path dir) throws Exception {
void changingAProfilesWeightOrMaxLoadIsHot(@TempDir Path dir) throws Exception {
Path f = dir.resolve("bridged.yaml");
Files.writeString(f, yaml(""));
ConfigRef ref = refFor(f);
@@ -228,8 +231,9 @@ class ConfigRefTest {
profiles:
sonnet:
baseUrl: http://gx00.gw:8000
model: sonnet-4-5
model: sonnet
maxLoad: 7
weight: 3.0
guard:
offSubscriptionHosts:
- gx00.gw
@@ -238,7 +242,42 @@ class ConfigRefTest {
assertTrue(out.applied());
assertTrue(out.deferred().isEmpty(), out.deferred().toString());
assertEquals("sonnet-4-5", ref.get().profiles().get("sonnet").model());
assertEquals(7, ref.get().profiles().get("sonnet").maxLoad());
}
/**
* Changing an existing profile's MODEL is deferred, not hot — and saying so is the whole point.
* HerdrPeerLauncher takes Map.copyOf(profiles) at construction and resolves every spawn out of
* that copy, so a reloaded model never reaches a launch. Reporting it as applied would be the
* worst outcome a reload can produce: the operator has no reason to doubt a clean "reloaded".
*/
@Test
void changingAProfilesLaunchSettingsIsReportedAsDeferred(@TempDir Path dir) throws Exception {
Path f = dir.resolve("bridged.yaml");
Files.writeString(f, yaml(""));
ConfigRef ref = refFor(f);
Files.writeString(f, """
bind:
host: 127.0.0.1
port: 8765
herdrSocket: ~/.config/herdr/herdr.sock
profiles:
sonnet:
baseUrl: http://gx00.gw:8000
model: deepseek-v4-flash
guard:
offSubscriptionHosts:
- gx00.gw
""");
ConfigRef.Outcome out = ref.reload();
assertTrue(out.applied());
assertEquals(1, out.deferred().size(), out.deferred().toString());
assertTrue(out.deferred().getFirst().contains("sonnet"), out.deferred().toString());
assertTrue(out.deferred().getFirst().contains("launch settings"), out.deferred().toString());
// The snapshot still carries the new value — a restart is what makes it take effect.
assertEquals("deepseek-v4-flash", ref.get().profiles().get("sonnet").model());
}
@Test
+3 -3
View File
@@ -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}"
}
}
}
+1 -1
Submodule wiki updated: e5424f4665...7c50cce52e