Features: credential probe reads the policy, allow-list refuses a non-zsh spawn, free counts the lead's seat

Three operator-visible changes shipped and live on 2026-09-03:

- fleetd #111 — the probe fetches GET /member-credentials instead of
  carrying its own 31-name copy. Verified live in a member: 34 checked,
  29 blocked, 5 present.
- fleetd #155 — policy: allow-list now refuses a spawn under a non-zsh
  login shell instead of degrading to the weaker overlay. Includes the
  memberLoginShell/memberHerdrSocket trap that refuses every spawn.
- fleetd #176 — fleet_list's free subtracts the lead's subscription
  seat and the row carries leadSeats. Records that free and the spawn
  gate now disagree by one on purpose, so nobody raises maxLoad to
  compensate.
Dai Ha
2026-09-03 13:37:34 +07:00
parent f27d6a6954
commit bfddb43631
+85
@@ -3039,6 +3039,91 @@ is data, so copying it can only move values; `.envrc` is executable shell that `
`cd`, so copying it moves behaviour. An operator who wants it can still list it explicitly, and then
owns that choice.
## The credential probe asks the daemon what the policy is
**What.** `scripts/probe-member-credentials.sh` reads the policy's name list from the daemon over a
new read-only endpoint, `GET /member-credentials`, instead of carrying its own copy of the names. The
endpoint returns the policy mode, the known and allowed **names**, and the counts. Never a value —
the daemon does not hold the values, and `MemberCredentialPolicyView` reads no environment at all, so
there is nothing to redact by construction.
**On.** Automatic. The probe needs no flag; it refuses to run outside a member shell unless you pass
`--allow-outside-member`, which labels the reading as a comparison rather than a finding.
**Why it exists.** The probe used to hold a hardcoded array of 31 names. The live policy had 34. It
reported **26 blocked** against a policy that blocks **29**, exited 0, and printed a table that
looked complete. Both numbers were right; they were counting different sets, and nothing said so.
That is the worst direction for a verification tool to fail in. Its clean output is taken as
evidence, so a silent gap stops anyone looking. The same "second hand-maintained copy" defect had
already been fixed twice elsewhere, and the fix here is the same one: **delete the copy** rather than
correct it.
**The gotcha: it refuses rather than degrades.** An unreachable daemon, an absent or empty policy, or
a `knownCount` that disagrees with the length of `known[]` all exit non-zero. There is deliberately
no local fallback — a checker that quietly drops to a weaker check is the thing this replaced.
It also prints its own denominator: *"policy contains 34 name(s); this run checked 34 — they match."*
Two numbers a reader can compare beat one number they have to trust.
The startup log line and the endpoint now share one class, so the counting exists in one place. Watch
one subtlety if you ever recompute it: **blocked is not `known - allowed`.** Live, `known` is 34 and
`allow` is 7, but only 5 of those 7 appear in `known`, so blocked is 29 and the naive subtraction
gives 27.
## An allow-list policy refuses a spawn it cannot enforce
**What.** `memberCredentials.policy: allow-list` is enforced by a generated `.zlogin` under a
per-member `ZDOTDIR`. A non-zsh login shell ignores `ZDOTDIR` entirely, so no scrub runs. Under that
policy the launcher now **refuses the spawn**, naming the shell it actually found and offering three
ways out. Under `policy: deny-by-default` nothing changed — that overlay is applied to the pane
before any shell runs, so it does not depend on the shell.
**On.** Automatic whenever `policy: allow-list` is set.
**Why it exists.** The launcher already *detected* a non-zsh shell and logged a warning — then
degraded to the weaker overlay and spawned anyway. The operator had explicitly asked for the blocking
control and silently received the weaker one. Detection existed; refusal did not, and a control that
silently does nothing is worse than no control, because the config still says it is on.
**The gotcha: `memberLoginShell:` and `memberHerdrSocket:` must be set together.** Which shell is
checked depends on the routing. With `memberHerdrSocket` absent — today's normal mode — fleetd's own
`$SHELL` decides. With it set, member panes run as a **different OS user**, so fleetd's `$SHELL`
describes the wrong account and only the configured `memberLoginShell:` is read. If you set
`memberHerdrSocket` and leave `memberLoginShell` unset, the shell reads as `<unset>`, counts as
non-zsh, and **every spawn is refused**. The refusal message says so, but it is cheaper to know first.
## `free` counts the seat the lead itself holds
**What.** For a `subscription: true` profile, `fleet_list`'s `free` now subtracts the seat the lead's
own session occupies on that same account, and the row carries a `leadSeats` field when the count is
positive. Profiles are grouped by **account**, not by profile name: a profile with no explicit
`credentialId` that is `subscription: true` joins a shared `<subscription>` group. An explicit
`credentialId` still wins, so two genuinely separate Claude logins on one host stay apart.
**On.** Automatic for `subscription: true` profiles, once `fleet.leaders.<name>.profile` is set.
**Why it exists.** The lead is always a live `claude` session on the operator's subscription, and
`maxLoad` only ever counted members. So a fan-out that filled every member slot still left the lead's
seat unaccounted for, and the daemon advertised a slot that the account was already using.
The same grouping fixes a second, wider bug: quarantining one subscription profile now also refuses
spawns on the other. One subscription hitting a usage limit really does take out every profile
running on it.
**The gotcha: `free` and the spawn gate now disagree by one, deliberately.** The subtraction is
**reporting only**. The placement gate uses `maxLoad` and the live count and never consults the lead
seat, so with `maxLoad: 3` and two members up, `fleet_list` says `free: 0` while a third spawn still
succeeds. That is the opposite of the overstatement this was filed to fix, and which of the two
numbers is "right" is an open question — see the follow-up ticket before changing `maxLoad` to
compensate. Raising `maxLoad` to win the slot back grants a real extra member; the slot was never
taken.
The first attempt at this shipped **inert** and every test passed: the matcher grouped by
`effectiveCredentialId()`, which fell back to the profile's own *name*, so a lead on `opus` and
members on `sonnet` never matched and zero seats were charged. Every test in that change put the lead
on the same profile name as the target — the one shape the live config does not have.
### A note for anyone briefing a worker to read this page
**A worker cannot see the current version of this file.** `wiki/` is a submodule, and the parent