Skip to content

fix(bin): bind each second mate to its own Claude account store - #2902

Open
mdn0420 wants to merge 5 commits into
kunchenguid:mainfrom
mdn0420:fm/sm-account-pin
Open

fix(bin): bind each second mate to its own Claude account store#2902
mdn0420 wants to merge 5 commits into
kunchenguid:mainfrom
mdn0420:fm/sm-account-pin

Conversation

@mdn0420

@mdn0420 mdn0420 commented Aug 23, 2026

Copy link
Copy Markdown

Intent

Bind each second mate's home to a durable Claude account (config store), so a restart cannot silently change which account it and its workers authenticate and bill against.

VERIFIED PROBLEM. bin/fm-spawn.sh prefixed the claude launch line with whatever CLAUDE_CONFIG_DIR happened to be set in fm-spawn's OWN environment at the moment it ran. For a --secondmate launch that made the account a second mate runs under ambient, not recorded anywhere. The automatic relaunch path in bin/fm-bootstrap.sh calls bin/fm-spawn.sh <id> --secondmate bare, from the primary firstmate's session, so a second mate deliberately started under a different store came back under the PRIMARY's store after any recovery relaunch, with no visible difference in the pane and no diagnostic. The launch line begins with env, which skips shell alias expansion and resolves the real binary on PATH, so any shell-alias guard the operator has against mixing accounts did not protect this path at all.

WHY IT MATTERS. The operator runs two Claude accounts selected by shell aliases: ~/.claude (personal) and ~/.claude-utm (a client's team account). A second mate is about to be created for that client's domain, and its work must never authenticate or bill against the personal account. This binding is the precondition for creating it.

DESIGN, DECIDED IN ADVANCE BY THE USER AND IMPLEMENTED AS SPECIFIED.

  • Store the binding as an OPTIONAL field in the data/secondmates.md registry entry, alongside home:, scope:, projects:, named claude-config-dir:. Rationale: the primary is what launches the mate; the registry is the durable routing record already consulted on recovery when metadata is missing; remote routes already carry launch-target fields (host:, root:) there; and data/secondmates.md is printed in full in the session-start digest, so the binding becomes visible rather than silent.
  • Resolution: bin/fm-spawn.sh --secondmate resolves the field for that id and uses it for the launch prefix instead of the ambient value. Every respawn re-resolves it, exactly like the harness pin does.
  • Precedence: an explicit per-spawn override wins if one is added; otherwise the registry field; otherwise today's ambient-inheritance behavior, unchanged.
  • Backward compatibility was REQUIRED: an entry with no claude-config-dir: field must parse and behave exactly as it does today, and existing registry lines must not need rewriting.
  • Fail closed: if the field names a store directory that does not exist or is not a directory, refuse the launch with a concrete diagnostic naming the missing path. Do NOT silently fall back to the ambient store; silent fallback is the exact defect being fixed.
  • Scope: local routes and the claude harness. For a remote route the account lives on that host: either carry the field through the remote path if genuinely straightforward, or explicitly refuse it with a clear diagnostic. Half-implementing it was explicitly ruled out.
  • Seeding: bin/fm-home-seed.sh must be able to record the binding at seed time, following that script's existing option/env conventions, validating that the path is absolute and exists at seed time, and rolling back per the script's existing transactional contract on failure. bin/fm-home-seed.sh validate must validate the new field.
  • Precedent to follow: config/secondmate-harness already solves this same class of problem (a durable launch attribute re-resolved on every spawn that survives every respawn: recovery, /updatefirstmate, restart). Match that shape and its backward-compatibility discipline. The one difference that matters: the harness pin is GLOBAL to all second mates, whereas this binding is PER second mate and must not be global.

ACCEPTANCE CRITERIA THE USER SET.

  1. A second mate whose registry entry records a store launches under that store, and bin/fm-spawn.sh produces a launch line carrying it.
  2. The same holds on a relaunch driven through the bootstrap liveness path from a primary session whose own CLAUDE_CONFIG_DIR is DIFFERENT. This is the actual regression being fixed and had to be covered explicitly with a test that would fail before the change.
  3. An entry with no field behaves exactly as today (ambient inheritance), proven by test.
  4. A field naming a nonexistent directory refuses the launch with a diagnostic naming the path, and does not fall back.
  5. Two second mates with different recorded stores each get their own; no cross-contamination.
  6. bin/fm-home-seed.sh can record the binding, and validate accepts good records and refuses malformed ones.
  7. Tests live in tests/ and follow the conventions of the existing tests there.
  8. Docs updated at their owners: docs/configuration.md for the schema, and .agents/skills/secondmate-provisioning/SKILL.md for the registry format plus the seed and recovery contract. Deliberately NOT restated in AGENTS.md, which points at the skill instead.

CONSTRAINTS THE USER SET.

  • The firstmate-coding-guidelines skill had to be loaded before editing anything, because this is firstmate's shared tracked material. It was.
  • Do not change the primary's own crewmate launch behavior; ambient inheritance is correct there today and is left untouched.
  • Do not add a global "all second mates use store X" knob. Per-mate only.
  • Do not touch anything under projects/.

DECISIONS AND TRADEOFFS MADE WHILE IMPLEMENTING, which a reviewer reading only the diff would not know.

  • NO per-spawn override flag was added, even though the design permitted one ("an explicit per-spawn override wins if you add one"). A flag that firstmate must remember to pass on every relaunch is the same drift class this change exists to eliminate, and the bootstrap relaunch path deliberately passes no such flag. The registry is therefore the single source. This is a deliberate omission, not an oversight.
  • A recorded binding on a launch whose harness resolves to anything other than claude REFUSES rather than being silently ignored. The specification scoped the feature to the claude harness but did not state this case. Silently ignoring would leak: a second mate running a non-claude harness still spawns its OWN crewmates through fm-spawn, which would then inherit the primary's ambient CLAUDE_CONFIG_DIR, which is exactly the drift being fixed. Refusing with a diagnostic naming the resolved harness is the fail-closed reading. The accepted consequence is that switching config/secondmate-harness away from claude while a mate is bound makes that mate's relaunch refuse until the conflict is resolved.
  • For the remote route the choice offered was "carry it through or explicitly refuse". REFUSE was chosen deliberately: a remote second mate's account store lives on its own host, where this home's launch prefix never reaches, so honoring the field locally would bind nothing while reading as if it had. The refusal is enforced in three places: the shared registry validator rejects the field on a remote record, bin/fm-spawn.sh's remote secondmate path refuses the launch, and bin/fm-remote-home-seed.sh refuses the FM_SECONDMATE_CLAUDE_CONFIG_DIR variable outright rather than silently dropping it.
  • Validation was split deliberately. STRUCTURAL validation (absolute, delimiter-free, no traversal components, local-route-only) lives in the shared parser bin/fm-secondmate-registry-lib.sh, so every registry read enforces it. EXISTENCE of the store directory is checked only at seed time and at launch time, NOT in the shared validator, because that parser also runs in sweeps and on hosts that never launch that particular second mate; an absent store must refuse the launch rather than break every read of the registry.
  • The optional field is expressed as an optional regex group placed immediately before added in BOTH the local and remote line forms. A non-participating optional group still occupies its BASH_REMATCH index and yields an empty string, so the surrounding capture indices stay fixed whether or not the field matched; this is what preserves byte-identical parsing of existing fieldless lines. Adding the group to the remote form as well is what allows the remote refusal to produce a precise diagnostic instead of a generic "malformed entry".
  • An unset binding at seed time writes the registry record byte-identically to before the field existed, so an ordinary single-account seed produces no new bytes at all.
  • bin/fm-fleet-snapshot.sh carries a SECOND, independent jq-based parser of the same registry lines. It was updated with the same optional group. Without that, a bound record would have been read there as having no home. This file was not named in the specification; it was found by grepping for other parsers of the same contract.
  • Tests EXTEND three existing suites rather than adding a new test file, per the repo's coding guidelines ("extend an existing script rather than inventing a new one"): the launch-binding, backward-compat, missing-store refusal, non-claude-harness refusal, two-mate isolation, and remote-refusal cases went into tests/fm-secondmate-harness.test.sh alongside the existing harness/model/effort durable-pin coverage it already owns; the bootstrap-sweep relaunch regression went into tests/fm-secondmate-liveness.test.sh, which already owns the sweep scaffolding; and the seed/validate coverage went into tests/fm-secondmate-safety.test.sh, which already owns the registry-validation cases. All three are already registered in the runner's secondmate family, so no runner change was needed and the coverage guard still passes.
  • The liveness suite's shared fake tmux gained a send-keys branch that captures the launch command only when a caller opts in via FM_FAKE_LAUNCH_LOG, so every existing window-op assertion in that file keeps reading an unchanged call log.
  • The bootstrap-relaunch regression test was verified to FAIL against the pre-change bin/ and pass after, by temporarily restoring the previous bin/fm-spawn.sh and bin/fm-secondmate-registry-lib.sh from HEAD and re-running the suite.

VERIFICATION ALREADY RUN LOCALLY: bin/fm-lint.sh clean (shellcheck 0.11.0 and actionlint 1.7.12, both pinned), bin/fm-doc-audience-check.sh ok, bin/fm-test-run.sh --check-coverage ok, and bin/fm-test-run.sh --changed green at 66 scripts with 0 failures (2 gate skips are pre-existing environment gaps for an uninstalled Pi package, unrelated to this change).

What Changed

  • Added an optional claude-config-dir: field to the data/secondmates.md registry (local routes only), parsed by bin/fm-secondmate-registry-lib.sh and the second jq-based parser in bin/fm-fleet-snapshot.sh, with fieldless entries parsing byte-identically to before.
  • bin/fm-spawn.sh resolves this field for --secondmate launches and uses it in place of the ambient CLAUDE_CONFIG_DIR, so a relaunch (including through the bootstrap liveness/recovery path) re-resolves and honors the bound store instead of inheriting whatever account the primary firstmate happens to be running under. Launch refuses with a diagnostic if the named store directory doesn't exist, if the resolved harness isn't claude, or if the record is a remote route.
  • bin/fm-home-seed.sh gains support for recording the binding at seed time (validating the path is absolute and exists, with rollback on failure), and its validate subcommand checks the new field; bin/fm-remote-home-seed.sh explicitly refuses the corresponding remote env var.
  • Extended tests/fm-secondmate-harness.test.sh, tests/fm-secondmate-liveness.test.sh, and tests/fm-secondmate-safety.test.sh with coverage for bound-launch, backward compatibility, missing-store refusal, non-claude-harness refusal, multi-mate isolation, remote refusal, and the bootstrap-relaunch regression.
  • Updated docs/configuration.md and .agents/skills/secondmate-provisioning/SKILL.md to document the registry field, seed behavior, and recovery contract.

Risk Assessment

✅ Low: The change is well-bounded, thoroughly tested against real observable behavior (captured launch commands, not source-text matching), fails closed on every documented edge case (missing store, non-claude harness, remote route), preserves backward compatibility via a verified BASH_REMATCH index calculation, and matches every acceptance criterion and design decision stated in the user intent with no contradictions found.

Testing

Targeted test suites (harness, liveness, safety) all pass, exercising every numbered acceptance criterion end-to-end via real fm-spawn.sh launch-line inspection rather than source-text matching, including the specific bootstrap-relaunch-under-a-different-ambient-account regression the intent calls out; docs were confirmed updated at their named owners; no findings.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • ./bin/fm-test-run.sh tests/fm-secondmate-harness.test.sh tests/fm-secondmate-liveness.test.sh tests/fm-secondmate-safety.test.sh - 3/3 suites pass, 0 failures
  • Inspected tests/fm-secondmate-harness.test.sh D1-D6 cases directly to confirm they call fm-spawn.sh --secondmate and assert on the captured launch line (real behavior), not a source-text grep
  • git diff 505c819..0f1a6f4 -- docs/configuration.md .agents/skills/secondmate-provisioning/SKILL.md - confirmed doc updates land at the specified owners
  • git status --short - confirmed a clean working tree after testing, no cleanup needed
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@greptile-apps

greptile-apps Bot commented Aug 23, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

The PR does not yet appear safe to merge because mutable launch state can change after relaunch preflight and cause a healthy secondmate to be stopped before its replacement is refused.

The relaunch preflight and replacement spawn independently validate mutable registry and store state on opposite sides of the stop, with no shared snapshot or lock spanning those checks, so the previously reported race remains.

Files Needing Attention: bin/fm-control.sh, bin/fm-spawn.sh

Reviews (5): Last reviewed commit: "no-mistakes: apply CI fixes" | Re-trigger Greptile

Comment thread bin/fm-spawn.sh
A --secondmate launch prefixed `claude` with whatever CLAUDE_CONFIG_DIR
happened to be set in fm-spawn's own environment, so the account a second
mate ran under was ambient rather than recorded. The session-start liveness
sweep relaunches a dead second mate with a bare `fm-spawn.sh <id>
--secondmate` from the primary's session, which silently moved a mate
started under another store onto the primary's, with no visible difference
in the pane and no diagnostic. The launch line begins with `env`, so a
shell-alias guard against mixing accounts never protected this path.

Record the store as an optional `claude-config-dir:` field in that second
mate's own data/secondmates.md entry, and re-resolve it on every launch and
relaunch exactly as the harness pin is re-resolved, so the account a mate
and its own workers authenticate and bill against is durable. The binding is
per mate, not fleet-wide: one global setting could not express two mates on
two accounts.

Resolution fails closed. A recorded store that is not an existing directory,
a spawn whose harness resolves to anything but claude, and a remote route
each refuse the launch naming the concrete gap, because falling back to the
ambient store is the exact drift the binding exists to prevent. A remote
second mate's account lives on its own host, out of reach of this launch
prefix, so the field is refused there rather than half-applied.

An entry without the field parses and behaves exactly as before, inheriting
the ambient store, and an unset binding writes the record byte-identically
to before the field existed, so no existing registry line needs rewriting.
Crewmate and scout launches are untouched: ambient inheritance is correct
there, since those workers work alongside firstmate on firstmate's account.

fm-home-seed.sh records the binding from FM_SECONDMATE_CLAUDE_CONFIG_DIR,
validating it before any mutation so a bad value rolls the seed back, and
`validate` covers the new field. fm-remote-home-seed.sh refuses the variable
outright. fm-fleet-snapshot.sh's independent registry filter is updated so
bound records still resolve there.
…he stop

bin/fm-spawn.sh refuses to launch a second mate whose registry entry records a
claude-config-dir onto a harness with no Claude store to bind, and refuses a
recorded store that is not an existing directory. Both refusals are correct and
both are fail-closed, but the launch owner is reached during a relaunch only
AFTER the running agent has been stopped.

On that path the ordering was the defect. Relaunching a bound second mate onto
an incompatible harness stopped its healthy agent, then refused the replacement
launch, and the exited|launching rollback left the mate down with no agent at
all. Automated recovery then repeated the identical refusal, so it never healed.

bin/fm-control.sh already keeps the launch owner's task-kind capability question
on the pre-stop side of the transaction for exactly this reason. Ask the two
account questions in the same preflight, so an incompatible harness or a missing
store is rejected while the current agent is still running and nothing has
changed. The spawn-time refusals stay as the fail-closed backstop for a direct
spawn, which reaches them with no agent to lose.

Regression coverage proves a healthy bound second mate survives both refusals:
its agent is still running afterwards, no transaction journal is opened, and its
durable record still names the recorded harness. Both tests fail without this
change, on the agent being killed. A bound mate whose store is present still
relaunches normally, so the preflight refuses only what the launch owner refuses.
@mdn0420
mdn0420 force-pushed the fm/sm-account-pin branch from 0f1a6f4 to 0d23682 Compare August 23, 2026 22:26
Comment thread bin/fm-control.sh Outdated
Comment thread bin/fm-control.sh Outdated
…ner's own validation

The pre-stop preflight looked the binding up with a bare registry field read and
treated any failure as "this mate records no store". That lookup fails for every
unusable registry: a malformed entry, the same id registered twice, an id that
does not parse, and a registry that is a symlink or unreadable. The launch owner
refuses all of them - it validates the whole registry through
secondmate_registry_validate_bindings before it resolves anything.

So the two sides disagreed, and the disagreement reopened the outage this fix
exists to close, just through a narrower door. A malformed or duplicated registry
waved straight past the preflight, the healthy agent was stopped, and the spawn
then refused, leaving the second mate with no agent at all and automated recovery
repeating the same refusal.

Resolve the binding here the way the launch owner resolves it: same trigger (a
registry present in any form), same validation, same expected id and home, and
fail closed on its error naming the concrete registry problem. A bare field read
cannot tell "records no store" from "this registry is unusable", and only one of
those may proceed to a stop.

Both new regressions reproduce the outage without this change - the agent is
stopped and the rollback reports no agent running - and refuse before the stop
with it. The unbound and satisfied-binding paths are unchanged, so the preflight
still refuses exactly what the launch owner refuses and nothing more.
Comment thread bin/fm-control.sh
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant