diff --git a/11-Features.md b/11-Features.md index 6715aae..96ecdce 100644 --- a/11-Features.md +++ b/11-Features.md @@ -79,6 +79,7 @@ six weeks, and the table alone will not carry it. | [An exhausted backend is quarantined even from a chrome-only pane](#an-exhausted-backend-is-quarantined-even-from-a-chrome-only-pane) | automatic | #211 | `inject/CompletionResolver` | | [The credential scrub follows the member's own user](#the-credential-scrub-follows-the-members-own-user) | `memberLoginShell:` + `worktreeGroup:` | #213 | `member/HerdrPeerLauncher` | | [An opencode member's config follows the member's own user](#an-opencode-members-config-follows-the-members-own-user) | `memberHerdrSocket:` + `worktreeGroup:` | #219 | `member/OpenCodeLauncher` | +| [Every file a member must read follows the member's own user](#every-file-a-member-must-read-follows-the-members-own-user) | `memberHerdrSocket:` + `worktreeGroup:` | #222 #224 | `member/ClaudeCodeLauncher`, `session/GitWorktrees` | | [A role fleetd cannot bind is refused](#a-role-fleetd-cannot-bind-is-refused-not-quietly-downgraded) | automatic | #123 | `auth/MemberRegistry` | Nearly every knob above lives in one file, on one profile: @@ -2812,6 +2813,39 @@ beats an answer read from the wrong directory. --- +## Every file a member must read follows the member's own user + +**What.** Two more places where fleetd used to write a file into its own process's filesystem and +then hand a member the path. The claude-code **role/reply charter** now goes under `worktreeRoot` +instead of `java.io.tmpdir`, shared with `worktreeGroup`. And `worktreeRoot` **itself** is now made +group-traversable where it is created, not only the worktrees inside it. + +**On.** `memberHerdrSocket:` plus `worktreeRoot`/`worktreeGroup`. With `memberHerdrSocket:` absent, +the charter path is byte-identical to before; with `worktreeGroup:` unset, the root is untouched. + +**Why it exists.** This is the same shape as the two entries above, found twice more. The charter one +became load-bearing without anyone noticing: the charter used to ride inline on +`--append-system-prompt`, and the file was the exception. #220 made the file the **only** delivery +path, always, to keep the launch command inside the pane's 1024-byte line. So under +`memberHerdrSocket:` every claude-code member would have been handed a charter path it could not +read. Measured against the real binary (claude 2.1.258), that is the loud failure — it prints +`Error reading append system prompt file: EACCES` and exits 1 before it touches auth or the network, +so the pane dies and the spawn fails at the readiness gate. Loud, but with no clue in the message. + +The `worktreeRoot` one is the floor the other three stand on. A different uid needs the execute bit +on **every** ancestor directory, so a root at `0700` makes every carefully-shared child unreachable — +the member cannot read the opencode config, cannot reach its own checkout, and cannot read the +credential scrub. `Files.createDirectories` respects the umask, so whether this bites depends on the +umask of whatever shell started the daemon. It would work on the machine it was developed on and fail +on the next one, and the failure looks like a member that never becomes deliverable. + +**The gotcha: both refuse rather than degrade, and both are invisible until you turn the key on.** +A charter is not a control, it is the member's turn contract — the rule that ends every turn with +`fleet_reply` — so a member that cannot read it is broken, not weakened. Missing config refuses the +spawn and names the key; an untraversable root refuses and names the root, its current mode and the +group. None of this changes anything on a host where `memberHerdrSocket:` is unset, which is why it +can look like dead code until the #185 rollout starts. + ## A role fleetd cannot bind is refused, not quietly downgraded **What.** A spawn asking for `role: architect` on a profile that no configured slot carries now fails @@ -2834,13 +2868,24 @@ Refusing was chosen over binding anyway for one reason: an architect's identity was bound to. With no slot, there is nothing to bind an identity to, so binding would mean inventing one. The operator's fix is one line of config, and the refusal names it. -**The gotcha: only the config gap is closed, not the race.** If a slot exists but is already bound -when the bind runs, the member has already launched on the architect charter and is then recorded as -`dev`. The roster stays honest and the demotion is logged at WARN, so this no longer hides — but the -charter and the bound role still disagree for that member. A pre-check cannot close that window; -#226 carries the reserve-then-bind fix. The refusal is also scoped to `architect` alone: dev and -reviewer pools are placement candidates, not identity bindings, so an explicit profile outside them -stays a supported override. +**The race is closed too, by reserving first (#226).** A pre-check cannot close it: "does the config +carry a slot" is stable, but "will a slot still be free after the launch" depends on a spawn that may +not have happened yet, so widening the check only moves the window. Instead fleetd now **reserves** a +matching slot before it launches, **binds** that reservation after, and **releases** it if the launch +fails. A spawn that would lose the race is refused before a process exists, so no member is ever +started on a charter it will not hold. + +Two things were deliberately kept. The old `acquired` fallback and its WARN stay, because a +reservation narrows the window and claiming it removes the window is the mistake to avoid. And a +failed launch must return its reservation, or the pool shrinks silently with every failure — that is +the way this shape usually goes wrong, so it is the case the tests pin. + +**The gotcha: a full architect pool now fails your spawn instead of demoting it.** `fleet_spawn{role: +"architect"}` used to hand back a plain `dev` when every slot was taken. It now throws. That is the +point — a refusal is recoverable and a mismatched charter is not — but it is a visible behaviour +change for anyone who relied on the old silence. The refusal stays scoped to `architect` alone: dev +and reviewer pools are placement candidates, not identity bindings, so an explicit profile outside +them stays a supported override. ### A note for anyone briefing a worker to read this page