From 889e86679c3ef8c5e701c1f0bca4a75756f104b3 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Wed, 29 Jul 2026 03:33:34 -0400 Subject: [PATCH 01/10] fix crew account rotation and Claude model anchor --- .gitignore | 1 + AGENTS.md | 11 +- bin/fm-account-directory.sh | 316 +++++++++++++++++++++--- bin/fm-bootstrap.sh | 6 +- bin/fm-config-inherit-lib.sh | 7 +- bin/fm-harness.sh | 42 ++++ bin/fm-spawn.sh | 25 +- docs/architecture.md | 7 + docs/configuration.md | 26 +- tests/fm-account-directory.test.sh | 285 +++++++++++++++++++-- tests/fm-bootstrap.test.sh | 13 +- tests/fm-secondmate-harness.test.sh | 73 +++++- tests/fm-spawn-dispatch-profile.test.sh | 39 ++- 13 files changed, 761 insertions(+), 90 deletions(-) diff --git a/.gitignore b/.gitignore index e3ec1347a0..8110029572 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ __pycache__/ .env config/crew-harness config/crew-dispatch.json +config/claude-crew-model config/checkout-refresh config/account-routing-mode config/secondmate-account-pool diff --git a/AGENTS.md b/AGENTS.md index eeed517dc9..592a7e9256 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,7 @@ bin/ helper scripts, committed; read each script's header before tools/ independently versioned provider-neutral components, committed; Agent Fleet lives under tools/agent-fleet .env optional X-mode pairing token; LOCAL, gitignored; presence-gates section 14 config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "default" = same as firstmate. Inherited as the literal file: a concrete primary adapter value also controls a secondmate home's own crewmates (section 4) +config/claude-crew-model Claude crewmate/scout model anchor; LOCAL, gitignored, inherited, and absent = `claude-opus-5`. An unreadable, empty, or `default` value fails closed instead of inheriting Claude CLI state (section 4) config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignored; firstmate-maintained but human-editable natural-language rules that choose a per-task harness/model/effort profile (section 4). Inherited by secondmate homes config/checkout-refresh optional extra checkout and shallow scan-root directives for this home's safe checkout refresher; LOCAL, gitignored; see docs/configuration.md "Checkout refresh" config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates) @@ -219,6 +220,11 @@ Secondmate launches are exempt because they resolve through `fm-harness.sh secon `quota-balanced` selection is deterministic and owned by `bin/fm-dispatch-select.sh`; every real new ship/scout launch uses direct account-directory selection. When candidates carry account pools, the selector chooses the ordered first profile and passes its pool to spawn only as a compatibility activation input, never as an account choice or lease request. +Direct selection reads Agent Fleet's profile registry only for pool membership, excludes profiles outside the provider's crew pool, and requires Claude's non-secret per-directory quota-axi Keychain approval marker before any fallback or rotation. +When no usable Claude crew account survives those checks, selection fails closed and names the reserved or approval-required reasons. +An explicitly declared `claude-crew-last-resort` pool is consulted only after `claude-crew` has no usable account; manual-only profiles remain excluded, and selector code never guesses account ownership from unreadable identity data. +Fresh Codex quota is an opportunistic ranking signal. +Exact best-score Codex ties and an all-unavailable quota result rotate across eligible accounts under a serialized persistent cursor; Claude always uses that rotation because its per-directory quota signal is unreadable. The now-unreachable Agent Fleet pool-summary implementation and inactive new-lease fixtures are legacy code deferred to follow-up task `remove-fleet-routing-deadcode`. Quota trouble must never block dispatch. @@ -235,6 +241,9 @@ If a dispatch rule or default names an unverified harness, ignore that profile, The shell scripts never parse or match the natural-language rules; firstmate does the matching and passes only concrete flags to `fm-spawn`. Per-harness model/effort flags: `harness-adapters` (loaded before every spawn per section 4's closing trigger). +Claude crewmate and scout launches resolve a non-default model before endpoint creation. +An explicit `--model` wins; otherwise `fm-harness.sh claude-crew-model` reads the captain-changeable `config/claude-crew-model` anchor and defaults its absence to `claude-opus-5`. +An empty, invalid, or `default` anchor and a raw Claude launch fail closed, and `state/.meta` records the same resolved value passed through Claude's `--model`. Secondmates can run on a different harness than crewmates. `config/secondmate-harness` (local, gitignored) is the harness the primary uses to launch SECONDMATE agents; resolve it with `bin/fm-harness.sh secondmate`, which follows the fallback chain `config/secondmate-harness` -> `config/crew-harness` -> your own harness. @@ -242,7 +251,7 @@ An explicit per-spawn harness still overrides either kind, and every secondmate `config/secondmate-harness` can also pin a model/effort for the secondmate agent in one line (` [] []`); format, accessors, and inheritance exceptions live in `secondmate-provisioning` (load before creating/seeding/launching/recovering a secondmate). -`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, and `config/account-routing-mode` are inherited into every secondmate home; `config/secondmate-harness` and `config/secondmate-account-pool` are primary-owned launch knobs and are not inherited. +`config/crew-dispatch.json`, `config/crew-harness`, `config/claude-crew-model`, `config/backlog-backend`, and `config/account-routing-mode` are inherited into every secondmate home; `config/secondmate-harness` and `config/secondmate-account-pool` are primary-owned launch knobs and are not inherited. `secondmate-provisioning` owns the propagation timing, mechanism, the literal-file inheritance nuance, and `bin/fm-config-push.sh`. Each adapter splits into mechanics and knowledge. diff --git a/bin/fm-account-directory.sh b/bin/fm-account-directory.sh index 3ba66b5462..468a3b641a 100755 --- a/bin/fm-account-directory.sh +++ b/bin/fm-account-directory.sh @@ -6,28 +6,48 @@ # fm-account-directory.sh prepare # # This header is the single owner of the direct account-directory contract. -# FM_ACCOUNT_DIRECTORY_CUTOVER: direct-observe-passwd-home-v2 +# FM_ACCOUNT_DIRECTORY_CUTOVER: direct-pool-rotation-v3 # Account homes are discovered under the current passwd user's # .local/share/agent-fleet/accounts// tree without fixed counts. +# Agent Fleet's read-only profile list is the source of pool eligibility: +# direct crew selection considers only real homes registered in the fixed +# -crew pool, so a manual-only profile is never a crew candidate and no +# caller-supplied compatibility alias can weaken that boundary. +# Claude may also declare a separate claude-crew-last-resort pool. +# It is consulted only when no usable claude-crew profile remains; Firstmate +# never guesses that membership from credentials or identity metadata. +# Claude eligibility also requires quota-axi's exact, non-secret per-directory +# Keychain access marker. +# That check happens after pool filtering and before rotation, so a reserved +# profile cannot become a fallback and an unapproved crew profile fails honestly. # Codex selection removes that account's quota-axi window cache immediately # before every read, sets CODEX_HOME plus the account-isolated XDG_CACHE_HOME, # accepts only a fresh result with at least one numeric five_hour or weekly -# window, and picks the account with the highest minimum remaining percentage. -# A Codex account with no such freshly readable window is skipped as unhealthy. +# window, and finds the accounts with the highest minimum remaining percentage. +# Accounts without a freshly readable window do not participate in quota +# ranking while any readable account remains. +# If every eligible Codex account lacks a readable window, selection degrades +# explicitly to rotation across the whole eligible set instead of failing or +# choosing the stable first directory. # Claude quota is not currently distinguishable per config directory because # quota-axi cannot non-interactively resolve Claude's config-dir-specific macOS # Keychain credential. # Claude therefore never treats a missing usage window as account failure and -# selects the first real account directory in stable bytewise sort order. +# rotates across every eligible account in stable bytewise order. +# Codex uses the same rotation only to break exact best-score ties. +# Rotation is machine-global, persisted under the passwd user's +# .local/state/firstmate/account-directory/, and serialized by an advisory file +# lock so concurrent selections spread deterministically instead of racing back +# to the first candidate. # Selection prints only the chosen absolute account home on stdout and logs # health, fallback, and choice diagnostics on stderr. # prepare selects the account and idempotently runs Herdr's own integration # installer with CODEX_HOME or CLAUDE_CONFIG_DIR set to the chosen home. # It verifies the installed per-profile hook before printing the chosen home. # -# Credential state is read-only. +# Credential and profile-registry state is read-only. # This script never logs in, imports credentials, or invokes a provider model. -# Test-only command, root, passwd-home, Perl, and timeout overrides require +# Test-only command, root, state-root, passwd-home, Perl, and timeout overrides require # FM_ACCOUNT_DIRECTORY_TEST_LAB=firstmate-account-directory-test-lab-v1. set -u @@ -136,6 +156,56 @@ herdr_command() { } } +agent_fleet_command() { + local home binary + if test_lab_enabled && [ -n "${FM_ACCOUNT_DIRECTORY_AGENT_FLEET:-}" ]; then + printf '%s\n' "$FM_ACCOUNT_DIRECTORY_AGENT_FLEET" + return 0 + fi + home=$(passwd_home) || return 1 + binary=$home/.local/bin/agent-fleet + [ -x "$binary" ] && [ -f "$binary" ] && [ ! -L "$binary" ] || { + echo "error: agent-fleet is required to enforce direct crew-pool eligibility" >&2 + return 1 + } + printf '%s\n' "$binary" +} + +read_profile_registry() { # + local passwd_root + if test_lab_enabled; then + "$1" --format json profile list + else + passwd_root=$(passwd_home) || return 1 + /usr/bin/env -i \ + HOME="$passwd_root" \ + PATH=/usr/bin:/bin:/usr/sbin:/sbin \ + "$1" --format json profile list + fi +} + +rotation_state_root() { + local root home + if test_lab_enabled && [ -n "${FM_ACCOUNT_DIRECTORY_STATE_ROOT:-}" ]; then + root=$FM_ACCOUNT_DIRECTORY_STATE_ROOT + else + home=$(passwd_home) || return 1 + root=$home/.local/state/firstmate/account-directory + fi + case "$root" in + *$'\n'*|*$'\r'*) + echo "error: account rotation state root contains a line break" >&2 + return 1 + ;; + /*) ;; + *) + echo "error: account rotation state root must be absolute: $root" >&2 + return 1 + ;; + esac + printf '%s\n' "$root" +} + quota_timeout_seconds() { local timeout=15 if test_lab_enabled && [ -n "${FM_ACCOUNT_DIRECTORY_QUOTA_TIMEOUT_SECONDS:-}" ]; then @@ -203,23 +273,169 @@ valid_account_home() { # esac } -first_account_home() { # - local vendor=$1 root vendor_dir candidate +valid_pool_id() { + case "$1" in + ''|.*|-*|*[!A-Za-z0-9._-]*) return 1 ;; + *) return 0 ;; + esac +} + +crew_pool() { # + local vendor=$1 pool=$1-crew + valid_pool_id "$pool" || { + echo "error: invalid direct account crew pool '$pool'" >&2 + return 1 + } + printf '%s\n' "$pool" +} + +last_resort_pool() { # + local vendor=$1 pool=$1-crew-last-resort + valid_pool_id "$pool" || return 1 + printf '%s\n' "$pool" +} + +eligible_account_homes() { # + local vendor=$1 pool=$2 root vendor_dir fleet_bin profiles candidate eligible matched marker marker_root marker_dir root=$(account_root) || return 1 vendor_dir=$root/$vendor [ -d "$vendor_dir" ] && [ ! -L "$vendor_dir" ] || { echo "error: no account-directory root for $vendor at $vendor_dir" >&2 return 1 } + command -v jq >/dev/null 2>&1 || { + echo "error: jq is required for direct account pool eligibility" >&2 + return 1 + } + fleet_bin=$(agent_fleet_command) || return 1 + profiles=$(read_profile_registry "$fleet_bin" 2>/dev/null) || { + echo "error: agent-fleet could not read the account profile registry for $vendor crew selection" >&2 + return 1 + } + printf '%s\n' "$profiles" | jq -e \ + 'type == "object" and (.profiles | type) == "array"' >/dev/null 2>&1 || { + echo "error: agent-fleet returned an invalid account profile registry" >&2 + return 1 + } + eligible=$(printf '%s\n' "$profiles" | jq -r \ + --arg vendor "$vendor" --arg pool "$pool" ' + [.profiles[] + | select( + (.provider? == $vendor) + and (.home? | type) == "string" + and (.pools? | type) == "array" + and (.pools | all(type == "string")) + and ((.pools | index($pool)) != null) + ) + | .home] + | unique[] + ' 2>/dev/null) || { + echo "error: agent-fleet returned invalid profile fields for $vendor crew selection" >&2 + return 1 + } LC_ALL=C export LC_ALL for candidate in "$vendor_dir"/*; do valid_account_home "$vendor_dir" "$candidate" || continue + matched=0 + while IFS= read -r registered; do + [ "$candidate" = "$registered" ] || continue + matched=1 + break + done <&2 - return 1 +} + +rotate_account_home() { # ... + local vendor=$1 pool=$2 state_root perl_bin selected + shift 2 + [ "$#" -gt 0 ] || { + echo "error: no eligible account directories remain for $vendor crew pool '$pool'" >&2 + return 1 + } + state_root=$(rotation_state_root) || return 1 + if [ -e "$state_root" ] || [ -L "$state_root" ]; then + [ -d "$state_root" ] && [ ! -L "$state_root" ] || { + echo "error: account rotation state root is not a real directory: $state_root" >&2 + return 1 + } + else + mkdir -p "$state_root" || { + echo "error: cannot create account rotation state root: $state_root" >&2 + return 1 + } + fi + chmod 700 "$state_root" 2>/dev/null || { + echo "error: cannot secure account rotation state root: $state_root" >&2 + return 1 + } + perl_bin=$(system_perl) || return 1 + # shellcheck disable=SC2016 # Perl source is intentionally single-quoted. + selected=$(PERL5LIB='' PERL5OPT='' "$perl_bin" -e ' + use strict; + use warnings; + use Fcntl qw(:DEFAULT :flock); + + my ($state_root, $vendor, $pool, @homes) = @ARGV; + die "no candidates\n" unless @homes; + my $stem = "$vendor-$pool"; + my $lock_path = "$state_root/$stem.lock"; + my $state_path = "$state_root/$stem.last"; + + sysopen(my $lock, $lock_path, O_CREAT | O_RDWR, 0600) + or die "cannot open rotation lock\n"; + chmod 0600, $lock_path; + local $SIG{ALRM} = sub { die "rotation lock timed out\n" }; + alarm 10; + flock($lock, LOCK_EX) or die "cannot lock rotation state\n"; + alarm 0; + + my $last = ""; + if (open(my $state, "<", $state_path)) { + $last = <$state> // ""; + close $state; + chomp $last; + } + my $index = 0; + for my $i (0 .. $#homes) { + if ($homes[$i] eq $last) { + $index = ($i + 1) % scalar(@homes); + last; + } + } + my $selected = $homes[$index]; + my $tmp = "$state_path.$$"; + unlink $tmp if -e $tmp; + sysopen(my $out, $tmp, O_CREAT | O_EXCL | O_WRONLY, 0600) + or die "cannot create rotation state\n"; + print {$out} "$selected\n" or die "cannot write rotation state\n"; + close $out or die "cannot close rotation state\n"; + rename $tmp, $state_path or die "cannot publish rotation state\n"; + chmod 0600, $state_path; + print "$selected\n"; + ' "$state_root" "$vendor" "$pool" "$@" 2>/dev/null) || { + echo "error: deterministic account rotation failed for $vendor crew pool '$pool'" >&2 + return 1 + } + printf '%s\n' "$selected" } fresh_codex_usage_json() { # @@ -280,12 +496,20 @@ EOF } select_codex() { - local root vendor_dir quota_bin candidate usage score - local best_home='' best_score='' - root=$(account_root) || return 1 - vendor_dir=$root/codex - [ -d "$vendor_dir" ] && [ ! -L "$vendor_dir" ] || { - echo "error: no account-directory root for codex at $vendor_dir" >&2 + local pool quota_bin candidate usage score selected eligible + local best_score='' + local -a best_homes=() + local -a candidates=() + pool=$(crew_pool codex) || return 1 + eligible=$(eligible_account_homes codex "$pool") || return 1 + while IFS= read -r candidate; do + [ -n "$candidate" ] || continue + candidates+=("$candidate") + done <&2 return 1 } command -v jq >/dev/null 2>&1 || { @@ -295,8 +519,7 @@ select_codex() { quota_bin=$(quota_command) || return 1 LC_ALL=C export LC_ALL - for candidate in "$vendor_dir"/*; do - valid_account_home "$vendor_dir" "$candidate" || continue + for candidate in "${candidates[@]}"; do usage=$(fresh_codex_usage_json "$candidate" "$quota_bin") || usage= score=$(codex_score "$usage") || score= if [ -z "$score" ]; then @@ -304,24 +527,57 @@ select_codex() { continue fi log "codex account $candidate fresh remaining score=$score" - if [ -z "$best_home" ] || awk -v candidate_score="$score" -v current_score="$best_score" \ + if [ "${#best_homes[@]}" -eq 0 ] || awk -v candidate_score="$score" -v current_score="$best_score" \ 'BEGIN { exit !(candidate_score > current_score) }'; then - best_home=$candidate + best_homes=("$candidate") best_score=$score + elif awk -v candidate_score="$score" -v current_score="$best_score" \ + 'BEGIN { exit !(candidate_score == current_score) }'; then + best_homes+=("$candidate") fi done - [ -n "$best_home" ] || { - echo "error: no healthy Codex account has a freshly readable usage window" >&2 - return 1 - } - log "selected codex account $best_home with fresh remaining score=$best_score" - printf '%s\n' "$best_home" + if [ "${#best_homes[@]}" -eq 0 ]; then + selected=$(rotate_account_home codex "$pool" "${candidates[@]}") || return 1 + log "CODEX USAGE UNAVAILABLE: no eligible account has a freshly readable usage window; round-robin selection across ${#candidates[@]} eligible $pool accounts chose $selected" + printf '%s\n' "$selected" + return 0 + fi + selected=$(rotate_account_home codex "$pool" "${best_homes[@]}") || return 1 + log "selected codex account $selected with fresh remaining score=$best_score; round-robin among ${#best_homes[@]} tied accounts" + printf '%s\n' "$selected" } select_claude() { - local selected - selected=$(first_account_home claude) || return 1 - log "CLAUDE USAGE UNREADABLE: quota-axi cannot non-interactively resolve Claude's config-dir-specific macOS Keychain credential today; selecting the first account directory by stable sort: $selected" + local pool fallback_pool selected eligible candidate + local -a candidates=() + pool=$(crew_pool claude) || return 1 + eligible=$(eligible_account_homes claude "$pool") || return 1 + while IFS= read -r candidate; do + [ -n "$candidate" ] || continue + candidates+=("$candidate") + done <&2 + return 1 + } + selected=$(rotate_account_home claude "$pool" "${candidates[@]}") || return 1 + log "CLAUDE USAGE UNREADABLE: quota-axi cannot non-interactively resolve Claude's config-dir-specific macOS Keychain credential today; round-robin selection across ${#candidates[@]} eligible $pool accounts chose $selected" printf '%s\n' "$selected" } diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index fa63bd898c..31f5a6949e 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -55,6 +55,8 @@ # bootstrap prints TASKS_AXI: available. quota-axi is required because # crew-dispatch quota-balanced may call it; fm-dispatch-select.sh still # degrades at runtime when quota data is unavailable. +# Routed direct crew selection also requires Agent Fleet's read-only +# profile registry so pool-reserved accounts can fail closed. # X mode is OPTIONAL and inert unless FM_HOME/.env has a non-empty # FMX_PAIRING_TOKEN. When opted in, bootstrap requires curl+jq, writes # the relay poll shim and 30s cadence config, and prints an FMX line. @@ -692,7 +694,9 @@ account_routing_dependency_preflight() { if [ "$needs_direct" = 1 ] && [ ! -x "$direct_perl" ]; then missing_tool_diagnostic perl fi - [ "$needs_agent_fleet" = 0 ] || fm_account_fleet_bin >/dev/null 2>&1 || missing_tool_diagnostic agent-fleet + if [ "$needs_direct" = 1 ] || [ "$needs_agent_fleet" = 1 ]; then + fm_account_fleet_bin >/dev/null 2>&1 || missing_tool_diagnostic agent-fleet + fi } crew_dispatch_validate() { diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index 5d68e16ad1..54ed9bd7e6 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -4,8 +4,9 @@ # home's config/, so a secondmate's OWN crewmates inherit the primary's settings # (e.g. primary config/crew-dispatch.json makes a secondmate use the same dispatch # profile rules, primary config/crew-harness=codex makes a secondmate's crewmates -# spawn on codex too, and primary config/backlog-backend=manual makes that home -# hand-edit backlog files too). +# spawn on codex too, primary config/claude-crew-model pins their Claude model, +# and primary config/backlog-backend=manual makes that home hand-edit backlog +# files too). # # Usage: . bin/fm-config-inherit-lib.sh (no FM_* setup required) # @@ -28,7 +29,7 @@ # The declared inheritable set (space-separated, config-dir-relative item paths). # Extend here to inherit more of the primary's local config; override via the # environment only in tests. Items must not contain whitespace. -FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend account-routing-mode}" +FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness claude-crew-model backlog-backend account-routing-mode}" copy_inheritable_file() { local src=$1 dest=$2 dest_parent tmp diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index e01e871d8c..b348c8976e 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -13,6 +13,9 @@ # config/secondmate-harness, or empty when absent. # fm-harness.sh secondmate-effort print the optional EFFORT token from # config/secondmate-harness, or empty when absent. +# fm-harness.sh claude-crew-model print the required Claude crewmate/scout +# model anchor from config/claude-crew-model, +# defaulting to claude-opus-5 when absent. # config/secondmate-harness format: a single line " [] []", # whitespace-separated. A bare "" (today's format) behaves exactly as before: # harness only, no model/effort. Only the first non-empty, non-comment line is parsed. @@ -26,6 +29,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" +CLAUDE_CREW_MODEL_DEFAULT=claude-opus-5 detect_own() { # Layer 1: environment markers for verified harnesses. @@ -136,10 +140,48 @@ resolve_secondmate_effort() { secondmate_field 3 } +resolve_claude_crew_model() { + local file=$CONFIG/claude-crew-model line model='' found=0 + if [ ! -e "$file" ] && [ ! -L "$file" ]; then + printf '%s\n' "$CLAUDE_CREW_MODEL_DEFAULT" + return 0 + fi + [ -f "$file" ] && [ ! -L "$file" ] || { + echo "error: config/claude-crew-model must be a real file" >&2 + return 1 + } + while IFS= read -r line || [ -n "$line" ]; do + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [ -n "$line" ] || continue + case "$line" in + '#'*) continue ;; + esac + [ "$found" = 0 ] || { + echo "error: config/claude-crew-model must contain exactly one model value" >&2 + return 1 + } + model=$line + found=1 + done < "$file" + [ "$found" = 1 ] || { + echo "error: config/claude-crew-model is present but has no model value" >&2 + return 1 + } + case "$model" in + default|*[[:space:]]*|''|-*|*[!A-Za-z0-9._:/-]*) + echo "error: config/claude-crew-model must contain one explicit Claude model id, not '$model'" >&2 + return 1 + ;; + esac + printf '%s\n' "$model" +} + case "${1:-}" in crew) resolve_crew ;; secondmate) resolve_secondmate ;; secondmate-model) resolve_secondmate_model ;; secondmate-effort) resolve_secondmate_effort ;; + claude-crew-model) resolve_claude_crew_model ;; *) detect_own ;; esac diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index cec185d8bb..497a9a5c2b 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# FM_ACCOUNT_DIRECTORY_CUTOVER: direct-observe-passwd-home-v2 +# FM_ACCOUNT_DIRECTORY_CUTOVER: direct-pool-rotation-v3 # Spawn a direct report: a new crewmate in a treehouse worktree, an eligible # pre-cutover Orca direct recovery with empirically verified provider authority, # or a secondmate in its isolated firstmate home. @@ -51,6 +51,11 @@ # the file governs the spawn, its model/effort tokens are re-resolved on every # respawn exactly like the harness axis, and explicit --model/--effort flags # still win over the file's tokens. +# Claude ship/scout launches never inherit the CLI's ambient model. +# Without an explicit --model, they resolve config/claude-crew-model (inherited +# into secondmate homes), whose absent-file default is claude-opus-5. +# An empty/default/unresolvable model or a raw Claude launch fails closed before +# endpoint creation because firstmate cannot prove which model it would run. # Account routing is independently default-off. Its precedence and off/observe/ # enforce resolution is owned by fm-account-routing-lib.sh. Direct account- # directory launch currently covers ship/scout crewmates only; secondmate @@ -1263,8 +1268,8 @@ persist_failed_direct_recovery() { echo "mode=${MODE:-${RECORDED_MODE:-no-mistakes}}" echo "yolo=${YOLO:-${RECORDED_YOLO:-off}}" echo "tasktmp=${TASK_TMP:-${RECORDED_TASKTMP:-/tmp/fm-$ID}}" - echo "model=${RECORDED_MODEL:-${MODEL:-default}}" - echo "effort=${RECORDED_EFFORT:-${EFFORT:-default}}" + echo "model=${MODEL:-default}" + echo "effort=${EFFORT:-default}" echo "generation_id=${RECORDED_GENERATION:-${SPAWN_GENERATION_ID:-}}" [ "${RECORDED_REPORT_REQUIRED_SET:-0}" != 1 ] || echo "report_required=${RECORDED_REPORT_REQUIRED:-}" [ -z "$account_home" ] || echo "account_home=$account_home" @@ -2239,6 +2244,20 @@ if [ "$KIND" = secondmate ] && [ -z "$ARG3" ]; then fi fi +if [ "$KIND" != secondmate ] && [ "$HARNESS" = claude ]; then + [ "$RAW_LAUNCH" != 1 ] || { + echo "error: raw Claude crew/scout launch cannot prove its resolved model; use --harness claude with an optional explicit --model" >&2 + exit 1 + } + if [ -z "$MODEL" ] && [ "$MODEL_SET" -eq 0 ]; then + MODEL=$("$SCRIPT_DIR/fm-harness.sh" claude-crew-model) || exit 1 + fi + [ -n "$MODEL" ] && [ "$MODEL" != default ] || { + echo "error: Claude crew/scout launch requires an explicitly resolved model; set config/claude-crew-model or pass --model" >&2 + exit 1 + } +fi + ACCOUNT_EXPLICIT=0 if [ "$ACCOUNT_POOL_SET" = 1 ] || [ "$ACCOUNT_PROFILE_SET" = 1 ]; then ACCOUNT_EXPLICIT=1 diff --git a/docs/architecture.md b/docs/architecture.md index b865f8be38..0ae0f142f9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -130,10 +130,16 @@ The shell scripts validate the JSON shape and verified harness/effort combinatio The session-start bootstrap step surfaces either the active rule block or a concise invalid-config line at startup. When the file exists, `fm-spawn.sh` refuses crewmate and scout launches without an explicit harness, so `config/crew-harness` is only automatic when no dispatch profile file is active. Secondmate launches are exempt because they resolve the secondmate harness and any optional secondmate model or effort tokens instead. +Claude crewmate and scout launches independently fail closed unless they resolve a concrete model before endpoint creation. +An explicit model wins; otherwise the inherited `config/claude-crew-model` anchor applies, with `claude-opus-5` as its absent-file default. Unsupported effort values are still recorded in task meta when passed to `fm-spawn.sh`, but the launch template omits any effort flag that the selected harness does not accept. That keeps spawn launch compatible across claude, codex, grok, pi, and opencode while preserving the requested profile for later audit. New ship/scout observe and enforce account routing is backend-neutral and uses the direct profile-directory contract owned by [configuration.md](configuration.md#agent-fleet-account-routing). Routing remains default-off and adds no account field or provider environment override until an explicit account flag or observe/enforce policy enables it. +The direct selector reads Agent Fleet only for pool membership, excludes manual-only profiles, requires Claude's non-secret per-directory Keychain approval marker, then uses fresh Codex quota opportunistically. +If no usable Claude crew account survives eligibility, the launch fails closed before fallback selection. +Claude can declare a separate last-resort crew pool, consulted only after its primary crew pool is empty; no identity inference participates in either tier. +It serializes round-robin fallback and exact-score tie-breaking so unavailable usage and concurrent launches cannot collapse onto the stable first account. For quota-balanced account-pool candidates, new dispatch deterministically uses the ordered first profile and passes its compatibility alias to spawn only as the activation signal for direct per-account selection. The legacy Agent Fleet pool-summary selector is inactive deferred code tracked by `remove-fleet-routing-deadcode`; it is not available to new dispatches. @@ -169,6 +175,7 @@ When the harness token is unset or `default`, launch falls back to `config/crew- Those optional tokens are re-read on every secondmate spawn or respawn and are overridden by explicit per-spawn `--model` or `--effort` flags. An explicit per-spawn harness or raw launch command does not inherit model or effort tokens from `config/secondmate-harness`. `config/crew-harness` remains the crewmate harness and is inherited into secondmate homes. +`config/claude-crew-model` is inherited too, so Claude crews in every home use the same captain-changeable model anchor. `config/crew-dispatch.json` is inherited too; secondmates use the same natural-language dispatch profiles when spawning their own crewmates. `config/backlog-backend` is inherited too; absent or `tasks-axi` selects the default tasks-axi backlog backend, while `manual` forces routine backlog updates to hand-editing across the fleet without disabling validated handoff delegation. diff --git a/docs/configuration.md b/docs/configuration.md index 22e20187d7..f26ddf8c41 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -152,6 +152,12 @@ Primary-session watcher wake protocols are rendered at session start by [`bin/fm Claude and Grok use background-notify cycles, Codex uses bounded foreground checkpoints, Pi uses its two tracked primary extensions, and OpenCode uses its TUI plugin. `config/crew-harness` is a local, gitignored file containing one adapter name for crewmate and scout launches. When it is absent or contains `default`, crewmates mirror the firstmate's own harness. +Claude crewmate and scout launches additionally require a resolved model before endpoint creation. +An explicit `--model` wins; otherwise `fm-harness.sh claude-crew-model` reads the one-value local `config/claude-crew-model` anchor, whose absent-file default is `claude-opus-5`. +The anchor is inherited into secondmate homes so the same rule governs their Claude crews. +An empty, invalid, or `default` anchor and a raw Claude launch fail closed rather than inheriting the Claude CLI's ambient model. +The resolved model is both passed through Claude's `--model` flag and recorded as `model=` in task metadata. +Fresh launches and recovery share this final resolution guard: recovery preserves an explicit recorded model, while legacy `model=default` metadata resolves through the anchor before relaunch and is rewritten to the value actually used. `config/secondmate-harness` is a separate local, gitignored file containing the adapter the primary uses to launch secondmate agents, optionally followed by model and effort tokens on the same line. The first non-empty, non-comment line is parsed as ` [] []`. A bare `` preserves the previous behavior: harness only, with no model or effort launch flag. @@ -160,7 +166,7 @@ When the harness token is absent or `default`, secondmate launch falls back thro An explicit harness argument to `fm-spawn.sh` still overrides either config file for that spawn only. An explicit `--model` or `--effort` overrides the matching token from `config/secondmate-harness`; an explicit harness or raw launch command starts with clean model and effort defaults unless those flags are also passed. When `config/crew-dispatch.json` exists, crewmate and scout spawns require an explicit resolved harness instead of automatically falling back to `config/crew-harness`. -The primary propagates `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, and `config/account-routing-mode` into secondmate homes at secondmate spawn, during the locked session-start bootstrap secondmate sweep, and during explicit `bin/fm-config-push.sh` runs, so a secondmate's own crewmates use the primary dispatch and routing policy. +The primary propagates `config/crew-dispatch.json`, `config/crew-harness`, `config/claude-crew-model`, `config/backlog-backend`, and `config/account-routing-mode` into secondmate homes at secondmate spawn, during the locked session-start bootstrap secondmate sweep, and during explicit `bin/fm-config-push.sh` runs, so a secondmate's own crewmates use the primary dispatch, model-anchor, and routing policy. `config/secondmate-harness` and `config/secondmate-account-pool` are not inherited because they are primary-owned knobs for launching secondmate agents. For grok, `fm-spawn.sh` installs one firstmate-owned global turn-end hook under `$GROK_HOME/hooks/`, or `~/.grok/hooks/` when `GROK_HOME` is unset, and drops a per-task `.fm-grok-turnend` pointer in the worktree, with teardown removing the task token and pointer. For Pi secondmate launches, `fm-spawn.sh` starts Pi with `-e` pointed at the secondmate home's own tracked `.pi/extensions/fm-primary-pi-watch.ts` and `.pi/extensions/fm-primary-turnend-guard.ts`, both already present from the secondmate home's git worktree. @@ -171,14 +177,23 @@ Firstmate routes new observe and enforce Claude and Codex ship/scout launches di The exact discovery, fresh-usage, health, fallback, and Herdr-hook mechanics are owned by `bin/fm-account-directory.sh`'s header and help output. The selected provider command receives `CLAUDE_CONFIG_DIR=` or `CODEX_HOME=`, and task metadata records the non-secret `account_home=` for observability. New ship/scout launches never ask Agent Fleet to enable a profile, establish identity, install a bundle, or acquire a lease. +They use Agent Fleet's read-only profile listing as the authoritative pool-membership registry. +Only real account homes registered in the provider's crew pool are eligible, so a profile reserved to a manual-only pool cannot be selected for crew work. +Claude candidates must additionally carry quota-axi's exact non-secret per-directory `claude-keychain-access-granted` marker. +Pool filtering and the marker check happen before fallback selection; if no Claude candidate survives, launch fails closed with the reserved and captain-approval-required reasons instead of borrowing a manual profile. +The optional declared `claude-crew-last-resort` pool is checked only after no usable `claude-crew` profile remains and is announced loudly when used. +Because Claude account identity is not machine-readable per directory, pool membership is the only ownership policy: the selector never infers an account from `.claude.json`, a hidden quota identity, or the nondiscriminating Keychain account label. +A profile assigned only to `claude-manual` remains a hard exclusion; declaring last-resort behavior requires adding the separate last-resort pool deliberately. They invoke Herdr's own integration installer against the selected profile directory and verify its per-profile hook file before launching. Account credentials remain captain-owned and read-only to Firstmate; selection never authenticates, logs in, or invokes a model. Codex health and usage are genuinely readable per account. Every selection performs a fresh per-account quota read instead of trusting a prior cache, and a Codex directory with no fresh general usage window is skipped. This means an account re-authenticated immediately before spawn is eligible on that spawn. +Fresh quota is an opportunistic ranking layer: the highest minimum remaining percentage wins, exact best-score ties rotate, and if every eligible account's signal is unavailable the selector explicitly rotates across the whole eligible set. Claude's config-directory-specific macOS Keychain credential is not currently distinguishable through non-interactive quota reads. -Claude therefore treats missing quota as unreadable rather than unhealthy, chooses the first real profile directory in stable sort order, and prints a loud `CLAUDE USAGE UNREADABLE` note explaining that keychain/quota-read gap. +For candidates with prior Keychain approval, Claude treats missing quota as unreadable rather than unhealthy, rotates across eligible accounts in stable bytewise order, and prints a loud `CLAUDE USAGE UNREADABLE` note explaining that keychain/quota-read gap. +The rotation cursor is persisted under the passwd user's `.local/state/firstmate/account-directory/` and guarded by an advisory lock, so concurrent selectors spread across the same deterministic candidate sequence instead of racing to its first element. Account routing remains default-off. An unchanged installation does not select an account directory, does not alter the provider launch, and adds no account field to task metadata. @@ -188,7 +203,7 @@ Ambient `FM_ACCOUNT_ROUTING=off` cannot override the authoritative config. The valid modes are `off`, `observe`, and `enforce`. Bootstrap reports an `ACCOUNT_ROUTING` diagnostic when the configured policy is unreadable, contains multiple values, or names any other mode. `observe` and `enforce` both activate direct account-directory selection for a new Claude or Codex ship/scout launch on any supported runtime backend. -Neither mode invokes Agent Fleet selection or leases for a new ship/scout launch. +Neither mode invokes Agent Fleet selection or leases for a new ship/scout launch; the profile-list read is eligibility enforcement only. The existing `--account-pool`, `--account-profile`, and dispatch-profile fields remain compatibility activation inputs for those crewmates while the inactive-code removal is handled separately. Their legacy aliases do not constrain the new usage-based account choice. Secondmate integration is deferred: secondmate launches retain their pre-cutover Agent Fleet selection and lease behavior, including `config/secondmate-account-pool`. @@ -200,7 +215,8 @@ It never creates `account_pool=`, `account_profile=`, `account_task=`, `account_ Existing ship/scout tasks that already carry `account_profile=` metadata remain legacy Agent Fleet managed generations. That compatibility path is recovery-only for ordinary crewmates and is not used for any new ship/scout task. Secondmate launches continue to create and recover legacy Agent Fleet managed generations until their dedicated direct-account integration is designed. -Bootstrap requires Agent Fleet for enforced secondmate routing and when legacy `account_profile=` or pending rollback metadata exists, while new direct ship/scout routing requires `jq`, `quota-axi`, and Herdr's integration installer instead. +Bootstrap requires Agent Fleet for direct crew-pool eligibility, for enforced secondmate routing, and when legacy `account_profile=` or pending rollback metadata exists. +New direct ship/scout routing also requires `jq`, `quota-axi`, and Herdr's integration installer. Same-profile recovery is sticky and fail-closed: `bin/fm-spawn.sh --resume-account` validates existing task metadata and Agent Fleet's session mapping, uses `lease recover` rather than new-task quota selection, resumes the recorded provider session without replaying the brief as a new prompt, and requires a higher monotonic `session_event_seq` from a SessionStart accepted after its local launch gate before committing the recovered lease. Wall-clock `updated_at` remains diagnostic only and never decides launch freshness. Schema-1 mappings remain readable as virtual sequence zero; the next same-binding SessionStart atomically migrates them to schema 2 / sequence 1, while a changed binding is rejected without modifying the legacy record. @@ -383,7 +399,7 @@ It emits `SECONDMATE_SYNC:` only when a home was skipped for an actionable sync `NUDGE_SECONDMATES:` lists stable `fm-` task selectors; the `bootstrap-diagnostics` skill owns the send procedure. The same bootstrap run also emits `SECONDMATE_LIVENESS:` outcomes for live secondmate endpoints; the `bootstrap-diagnostics` skill owns the response to handled, deferred, skipped, and failed outcomes. For a mid-session inherited config edit where tracked-file sync and reread nudges are not needed, run `bin/fm-config-push.sh`. -It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, and `account-routing-mode` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero only for real propagation errors. +It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `claude-crew-model`, `backlog-backend`, and `account-routing-mode` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero only for real propagation errors. That live discovery starts from `state/*.meta` records with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. Skipped items, such as a destination checkout that does not yet gitignore the item, are visible warnings but not hard failures. diff --git a/tests/fm-account-directory.test.sh b/tests/fm-account-directory.test.sh index 96e496b525..08282099e4 100755 --- a/tests/fm-account-directory.test.sh +++ b/tests/fm-account-directory.test.sh @@ -12,6 +12,7 @@ FAKEBIN=$(fm_fakebin "$TMP_ROOT") QUOTA_LOG="$TMP_ROOT/quota.log" HERDR_LOG="$TMP_ROOT/herdr.log" TREEHOUSE_LOG="$TMP_ROOT/treehouse.log" +ROTATION_STATE="$TMP_ROOT/rotation-state" mkdir -p "$ACCOUNT_ROOT/codex" "$ACCOUNT_ROOT/claude" @@ -79,9 +80,44 @@ fi SH chmod +x "$FAKEBIN/herdr" +cat > "$FAKEBIN/profile-agent-fleet" <<'SH' +#!/usr/bin/env bash +set -u +[ "$#" -eq 4 ] && [ "$1" = --format ] && [ "$2" = json ] \ + && [ "$3" = profile ] && [ "$4" = list ] || exit 64 +root=${FM_ACCOUNT_DIRECTORY_ROOT:-${FM_ACCOUNT_DIRECTORY_PASSWD_HOME:?}/.local/share/agent-fleet/accounts} +separator= +printf '{"profiles":[' +for provider in claude codex; do + vendor_dir=$root/$provider + [ -d "$vendor_dir" ] || continue + for home in "$vendor_dir"/*; do + [ -d "$home" ] && [ ! -L "$home" ] || continue + account=${home##*/} + case "$account" in + ''|.*|*[!A-Za-z0-9._-]*) continue ;; + esac + if [ -f "$home/test-last-resort" ]; then + pools="[\"$provider-crew-last-resort\",\"$provider-manual\"]" + elif [ -f "$home/test-manual-only" ]; then + pools="[\"$provider-manual\"]" + else + pools="[\"$provider-crew\",\"$provider-manual\"]" + fi + printf '%s{"id":"%s-%s","provider":"%s","home":"%s","pools":%s,"enabled":false}' \ + "$separator" "$provider" "$account" "$provider" "$home" "$pools" + separator=, + done +done +printf ']}\n' +SH +chmod +x "$FAKEBIN/profile-agent-fleet" + run_selector() { FM_ACCOUNT_DIRECTORY_TEST_LAB=firstmate-account-directory-test-lab-v1 \ FM_ACCOUNT_DIRECTORY_ROOT="$ACCOUNT_ROOT" \ + FM_ACCOUNT_DIRECTORY_STATE_ROOT="$ROTATION_STATE" \ + FM_ACCOUNT_DIRECTORY_AGENT_FLEET="$FAKEBIN/profile-agent-fleet" \ FM_ACCOUNT_DIRECTORY_QUOTA_AXI="$FAKEBIN/quota-axi" \ FM_ACCOUNT_DIRECTORY_HERDR="$FAKEBIN/herdr" \ FM_FAKE_QUOTA_LOG="$QUOTA_LOG" FM_FAKE_HERDR_LOG="$HERDR_LOG" \ @@ -97,11 +133,28 @@ set_remaining() { reset_accounts() { rm -rf "$ACCOUNT_ROOT/codex" "$ACCOUNT_ROOT/claude" + rm -rf "$ROTATION_STATE" mkdir -p "$ACCOUNT_ROOT/codex" "$ACCOUNT_ROOT/claude" : > "$QUOTA_LOG" : > "$HERDR_LOG" } +mark_manual_only() { + local provider=$1 account=$2 + touch "$ACCOUNT_ROOT/$provider/$account/test-manual-only" +} + +mark_last_resort() { + local provider=$1 account=$2 + touch "$ACCOUNT_ROOT/$provider/$account/test-last-resort" +} + +mark_claude_keychain_ready() { + local account=$1 + mkdir -p "$ACCOUNT_ROOT/claude/$account/.agent-fleet-quota-cache/quota-axi" + touch "$ACCOUNT_ROOT/claude/$account/.agent-fleet-quota-cache/quota-axi/claude-keychain-access-granted" +} + test_codex_picks_highest_fresh_minimum_and_skips_no_window() { local out err reset_accounts @@ -136,17 +189,27 @@ test_codex_rechecks_health_on_every_selection() { pass "Codex health is read fresh at selection time so a newly authenticated account is immediately eligible" } -test_codex_fails_when_no_account_has_a_fresh_window() { - local out status +test_codex_rotates_when_no_account_has_a_fresh_window() { + local out expected reset_accounts set_remaining 1 none set_remaining 2 none - out=$(run_selector select codex 2>&1) - status=$? - expect_code 1 "$status" "Codex selection with no healthy accounts should fail closed" - assert_contains "$out" "no healthy Codex account has a freshly readable usage window" \ - "Codex all-unhealthy failure was not actionable" - pass "Codex refuses selection when every discovered account lacks fresh readable usage" + out=$({ + run_selector select codex + run_selector select codex + run_selector select codex + run_selector select codex + } 2>"$TMP_ROOT/codex-unavailable.err") + expected=$(printf '%s\n' \ + "$ACCOUNT_ROOT/codex/1" "$ACCOUNT_ROOT/codex/2" \ + "$ACCOUNT_ROOT/codex/1" "$ACCOUNT_ROOT/codex/2") + [ "$out" = "$expected" ] || fail "Codex unavailable-usage fallback did not rotate: $out" + assert_contains "$(cat "$TMP_ROOT/codex-unavailable.err")" "CODEX USAGE UNAVAILABLE" \ + "Codex unavailable-usage fallback did not identify the degraded signal" + assert_contains "$(cat "$TMP_ROOT/codex-unavailable.err")" \ + "round-robin selection across 2 eligible codex-crew accounts" \ + "Codex unavailable-usage fallback did not report its rotation" + pass "Codex rotates eligible accounts when every quota signal is unavailable" } test_codex_timeout_skips_wedged_account() { @@ -163,18 +226,127 @@ test_codex_timeout_skips_wedged_account() { pass "Codex bounds each usage read and continues to later healthy accounts" } -test_claude_uses_stable_first_without_treating_usage_as_health() { - local out err +test_claude_rotates_eligible_accounts_without_treating_usage_as_health() { + local out err expected reset_accounts - mkdir -p "$ACCOUNT_ROOT/claude/2" "$ACCOUNT_ROOT/claude/1" - out=$(run_selector select claude 2>"$TMP_ROOT/claude-select.err") + mkdir -p "$ACCOUNT_ROOT/claude/3" "$ACCOUNT_ROOT/claude/2" "$ACCOUNT_ROOT/claude/1" + mark_manual_only claude 1 + mark_claude_keychain_ready 2 + mark_claude_keychain_ready 3 + out=$({ + run_selector select claude + run_selector select claude + run_selector select claude + run_selector select claude + } 2>"$TMP_ROOT/claude-select.err") err=$(cat "$TMP_ROOT/claude-select.err") - [ "$out" = "$ACCOUNT_ROOT/claude/1" ] || fail "Claude fallback did not use stable bytewise directory order: $out" + expected=$(printf '%s\n' \ + "$ACCOUNT_ROOT/claude/2" "$ACCOUNT_ROOT/claude/3" \ + "$ACCOUNT_ROOT/claude/2" "$ACCOUNT_ROOT/claude/3") + [ "$out" = "$expected" ] || fail "Claude unreadable-usage fallback did not round-robin across eligible accounts: $out" assert_contains "$err" "CLAUDE USAGE UNREADABLE" "Claude fallback did not carry the required obvious warning" assert_contains "$err" "config-dir-specific macOS Keychain credential" \ "Claude fallback did not explain the keychain/quota-read gap" + assert_contains "$err" "excluded from claude-crew" \ + "Claude fallback did not report the reserved account's pool exclusion" + assert_not_contains "$out" "$ACCOUNT_ROOT/claude/1" \ + "Claude fallback selected an account reserved to the manual-only pool" [ ! -s "$QUOTA_LOG" ] || fail "Claude fallback called quota-axi even though per-directory usage is known unreadable" - pass "Claude deterministically selects the first directory and explains why usage is not a health signal" + pass "Claude deterministically rotates across crew-pool accounts and excludes manual-only accounts when usage is unreadable" +} + +test_concurrent_claude_selections_spread_without_usage() { + local output_dir i status counts expected pid + local -a pids=() + reset_accounts + mkdir -p "$ACCOUNT_ROOT/claude/3" "$ACCOUNT_ROOT/claude/2" "$ACCOUNT_ROOT/claude/1" + mark_manual_only claude 1 + mark_claude_keychain_ready 2 + mark_claude_keychain_ready 3 + output_dir="$TMP_ROOT/concurrent-claude" + rm -rf "$output_dir" + mkdir -p "$output_dir" + status=0 + for i in 1 2 3 4 5 6 7 8 9 10 11 12; do + run_selector select claude > "$output_dir/$i.out" 2> "$output_dir/$i.err" & + pids+=("$!") + done + for pid in "${pids[@]}"; do + wait "$pid" || status=1 + done + [ "$status" = 0 ] || fail "one or more concurrent Claude selections failed" + if grep -Fqx "$ACCOUNT_ROOT/claude/1" "$output_dir"/*.out; then + fail "concurrent Claude selection used the reserved manual-only account" + fi + counts=$(sort "$output_dir"/*.out | uniq -c | sed 's/^[[:space:]]*//') + expected=$(printf '%s\n' \ + "6 $ACCOUNT_ROOT/claude/2" \ + "6 $ACCOUNT_ROOT/claude/3") + [ "$counts" = "$expected" ] || fail "concurrent Claude selections did not spread evenly: $counts" + [ ! -s "$QUOTA_LOG" ] || fail "concurrent Claude fallback unexpectedly called quota-axi" + pass "concurrent unreadable-usage Claude selections serialize into an even deterministic rotation" +} + +test_claude_fails_closed_when_no_usable_crew_account_exists() { + local out status + reset_accounts + mkdir -p "$ACCOUNT_ROOT/claude/2" "$ACCOUNT_ROOT/claude/1" + mark_manual_only claude 1 + if out=$(run_selector select claude 2>&1); then + status=0 + else + status=$? + fi + [ "$status" -ne 0 ] || fail "Claude selection silently used a reserved or unapproved account" + assert_contains "$out" "$ACCOUNT_ROOT/claude/1 excluded from claude-crew" \ + "zero-eligible Claude refusal did not identify the reserved account" + assert_contains "$out" "$ACCOUNT_ROOT/claude/2 excluded from claude-crew: missing quota-axi's non-secret Keychain access marker" \ + "zero-eligible Claude refusal did not identify the account awaiting approval" + assert_contains "$out" "no usable Claude account directories remain in claude-crew or claude-crew-last-resort" \ + "zero-eligible Claude refusal was not actionable" + pass "Claude fails closed and names the reserved and unapproved reasons when no usable crew account exists" +} + +test_claude_uses_only_explicit_last_resort_after_primary_exhaustion() { + local out err + reset_accounts + mkdir -p "$ACCOUNT_ROOT/claude/2" "$ACCOUNT_ROOT/claude/1" + mark_last_resort claude 1 + mark_claude_keychain_ready 1 + out=$(run_selector select claude 2>"$TMP_ROOT/claude-last-resort.err") + err=$(cat "$TMP_ROOT/claude-last-resort.err") + [ "$out" = "$ACCOUNT_ROOT/claude/1" ] || fail "Claude did not select the declared last-resort account: $out" + assert_contains "$err" "CLAUDE LAST RESORT" \ + "Claude last-resort selection was not explicit" + assert_contains "$err" "using the explicitly declared claude-crew-last-resort tier" \ + "Claude last-resort selection did not name its declared policy" + pass "Claude consults an explicitly declared last-resort tier only after no primary crew account remains" +} + +test_codex_rotates_accounts_tied_for_best_fresh_score() { + local out expected + reset_accounts + set_remaining 1 98,98 + set_remaining 2 98,98 + set_remaining 3 100,100 + set_remaining 4 100,100 + set_remaining 5 100,100 + mark_manual_only codex 5 + out=$({ + run_selector select codex + run_selector select codex + run_selector select codex + run_selector select codex + } 2>"$TMP_ROOT/codex-tie.err") + expected=$(printf '%s\n' \ + "$ACCOUNT_ROOT/codex/3" "$ACCOUNT_ROOT/codex/4" \ + "$ACCOUNT_ROOT/codex/3" "$ACCOUNT_ROOT/codex/4") + [ "$out" = "$expected" ] || fail "Codex best-score ties concentrated instead of rotating: $out" + assert_not_contains "$out" "$ACCOUNT_ROOT/codex/5" \ + "Codex tie rotation selected an account reserved to the manual-only pool" + assert_contains "$(cat "$TMP_ROOT/codex-tie.err")" "round-robin among 2 tied accounts" \ + "Codex tie rotation did not report its deterministic tie-break" + pass "Codex preserves fresh usage scoring and round-robins accounts tied for the best score" } test_default_root_uses_passwd_home_not_ambient_home() { @@ -183,9 +355,13 @@ test_default_root_uses_passwd_home_not_ambient_home() { hostile_home="$TMP_ROOT/hostile-home" expected="$passwd_home/.local/share/agent-fleet/accounts/claude/1" mkdir -p "$expected" "$hostile_home/.local/share/agent-fleet/accounts/claude/0" + mkdir -p "$expected/.agent-fleet-quota-cache/quota-axi" + touch "$expected/.agent-fleet-quota-cache/quota-axi/claude-keychain-access-granted" out=$(HOME="$hostile_home" \ FM_ACCOUNT_DIRECTORY_TEST_LAB=firstmate-account-directory-test-lab-v1 \ FM_ACCOUNT_DIRECTORY_PASSWD_HOME="$passwd_home" \ + FM_ACCOUNT_DIRECTORY_STATE_ROOT="$ROTATION_STATE" \ + FM_ACCOUNT_DIRECTORY_AGENT_FLEET="$FAKEBIN/profile-agent-fleet" \ "$SELECTOR" select claude 2>"$TMP_ROOT/passwd-home.err") [ "$out" = "$expected" ] || fail "ambient HOME redirected account discovery away from the passwd home: $out" pass "default account discovery ignores ambient HOME and stays under the passwd home" @@ -196,6 +372,7 @@ test_prepare_installs_and_verifies_per_account_herdr_hooks() { reset_accounts set_remaining 1 90,80 mkdir -p "$ACCOUNT_ROOT/claude/1" + mark_claude_keychain_ready 1 codex_home=$(run_selector prepare codex 2>"$TMP_ROOT/prepare-codex.err") claude_home=$(run_selector prepare claude 2>"$TMP_ROOT/prepare-claude.err") @@ -299,6 +476,8 @@ run_direct_spawn() { PATH="$FAKEBIN:$PATH" \ FM_ACCOUNT_DIRECTORY_TEST_LAB=firstmate-account-directory-test-lab-v1 \ FM_ACCOUNT_DIRECTORY_ROOT="$ACCOUNT_ROOT" \ + FM_ACCOUNT_DIRECTORY_STATE_ROOT="$ROTATION_STATE" \ + FM_ACCOUNT_DIRECTORY_AGENT_FLEET="$FAKEBIN/profile-agent-fleet" \ FM_ACCOUNT_DIRECTORY_QUOTA_AXI="$FAKEBIN/quota-axi" \ FM_ACCOUNT_DIRECTORY_HERDR="$FAKEBIN/herdr" \ FM_FAKE_QUOTA_LOG="$QUOTA_LOG" FM_FAKE_HERDR_LOG="$HERDR_LOG" \ @@ -357,7 +536,7 @@ test_spawn_uses_direct_codex_home_without_agent_fleet() { if grep -q '^account_profile=' "$meta"; then fail "new direct spawn wrote legacy managed profile metadata"; fi if grep -q '^account_pool=' "$meta"; then fail "new direct spawn wrote legacy managed pool metadata"; fi [ ! -s "$TMP_ROOT/agent-fleet.log" ] || fail "new direct spawn invoked Agent Fleet" - pass "new enforced Codex spawn uses CODEX_HOME and never enters Agent Fleet" + pass "new enforced Codex spawn uses CODEX_HOME and never enters Agent Fleet lease selection" } test_spawn_uses_direct_claude_fallback_and_hook() { @@ -365,21 +544,73 @@ test_spawn_uses_direct_claude_fallback_and_hook() { reset_accounts : > "$TMP_ROOT/agent-fleet.log" mkdir -p "$ACCOUNT_ROOT/claude/2" "$ACCOUNT_ROOT/claude/1" + mark_manual_only claude 1 + mark_claude_keychain_ready 2 id=direct-claude-z2 record=$(make_spawn_case direct-claude claude "$id") read_spawn_case "$record" out=$(run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \ - "$id" "$SPAWN_PROJECT" --account-pool legacy-claude-pool 2>&1) + "$id" "$SPAWN_PROJECT" --account-pool claude-crew 2>&1) launch=$(cat "$SPAWN_LAUNCH_LOG") meta=$SPAWN_HOME/state/$id.meta assert_contains "$out" "CLAUDE USAGE UNREADABLE" "spawn hid the required Claude quota-read warning" - assert_contains "$launch" "CLAUDE_CONFIG_DIR='$ACCOUNT_ROOT/claude/1' claude" \ - "spawn did not scope Claude to the deterministic first account home" - assert_grep "account_home=$ACCOUNT_ROOT/claude/1" "$meta" "Claude spawn metadata omitted account_home" - [ -f "$ACCOUNT_ROOT/claude/1/hooks/herdr-agent-state.sh" ] || fail "Claude spawn did not install its per-account Herdr hook" + assert_contains "$launch" "CLAUDE_CONFIG_DIR='$ACCOUNT_ROOT/claude/2' claude" \ + "spawn did not scope Claude to the eligible rotated account home" + assert_contains "$launch" "--model 'claude-opus-5'" \ + "Claude spawn did not carry the anchored Opus 5 model" + assert_grep "account_home=$ACCOUNT_ROOT/claude/2" "$meta" "Claude spawn metadata omitted account_home" + assert_grep "model=claude-opus-5" "$meta" "Claude spawn metadata omitted the resolved Opus 5 model" + [ -f "$ACCOUNT_ROOT/claude/2/hooks/herdr-agent-state.sh" ] || fail "Claude spawn did not install its per-account Herdr hook" [ ! -s "$TMP_ROOT/agent-fleet.log" ] || fail "new direct Claude spawn invoked Agent Fleet" - pass "new account-flagged Claude spawn uses deterministic CLAUDE_CONFIG_DIR with an explicit warning" + pass "new account-flagged Claude spawn excludes reserved profiles and launches on the anchored Opus 5 model" +} + +test_claude_spawn_fails_closed_on_inherited_default_model() { + local record id out status + reset_accounts + id=claude-default-model-refused-z2 + record=$(make_spawn_case claude-default-model-refused claude "$id") + read_spawn_case "$record" + + if out=$(run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \ + "$id" "$SPAWN_PROJECT" --model default 2>&1); then + status=0 + else + status=$? + fi + [ "$status" -ne 0 ] || fail "Claude crew launch silently inherited a default model" + assert_contains "$out" "Claude crew/scout launch requires an explicitly resolved model" \ + "Claude missing-model refusal was not actionable" + [ ! -e "$SPAWN_HOME/state/.fake-endpoint" ] || fail "Claude missing-model refusal created an endpoint" + pass "Claude crew/scout launch fails closed instead of inheriting an unspecified model" +} + +test_direct_claude_recovery_resolves_legacy_default_to_anchor() { + local record id meta meta_tmp launch + reset_accounts + mkdir -p "$ACCOUNT_ROOT/claude/2" + mark_claude_keychain_ready 2 + id=claude-recovery-model-anchor-z2 + record=$(make_spawn_case claude-recovery-model-anchor claude "$id") + read_spawn_case "$record" + + run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \ + "$id" "$SPAWN_PROJECT" --account-pool claude-crew >/dev/null 2>&1 + meta=$SPAWN_HOME/state/$id.meta + meta_tmp=$(mktemp "$SPAWN_HOME/state/.claude-recovery-model.XXXXXX") + awk '/^model=/ { print "model=default"; next } { print }' "$meta" > "$meta_tmp" + mv "$meta_tmp" "$meta" + rm -f "$SPAWN_HOME/state/.fake-endpoint" + + run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \ + "$id" --recover-direct-account >/dev/null 2>&1 + launch=$(cat "$SPAWN_LAUNCH_LOG") + assert_contains "$launch" "--model 'claude-opus-5'" \ + "direct Claude recovery inherited the legacy default model" + assert_grep "model=claude-opus-5" "$meta" \ + "direct Claude recovery metadata did not record the model actually launched" + pass "direct Claude recovery preserves explicit models and upgrades a legacy default to the Opus 5 anchor" } test_observe_spawn_uses_direct_directory_without_agent_fleet() { @@ -401,7 +632,7 @@ test_observe_spawn_uses_direct_directory_without_agent_fleet() { assert_grep "account_home=$ACCOUNT_ROOT/codex/1" "$meta" "observe metadata omitted the direct account home" [ ! -s "$TMP_ROOT/agent-fleet.log" ] || fail "observe launch invoked Agent Fleet" assert_not_contains "$out" "fm-account-routing: observe" "observe launch entered the legacy dry-run selector" - pass "observe mode uses direct account-directory routing without Agent Fleet" + pass "observe mode uses direct account-directory routing without Agent Fleet leases" } test_direct_spawn_and_recovery_support_detached_worktree() { @@ -960,13 +1191,19 @@ fi test_codex_picks_highest_fresh_minimum_and_skips_no_window test_codex_rechecks_health_on_every_selection -test_codex_fails_when_no_account_has_a_fresh_window +test_codex_rotates_when_no_account_has_a_fresh_window test_codex_timeout_skips_wedged_account -test_claude_uses_stable_first_without_treating_usage_as_health +test_claude_rotates_eligible_accounts_without_treating_usage_as_health +test_concurrent_claude_selections_spread_without_usage +test_claude_fails_closed_when_no_usable_crew_account_exists +test_claude_uses_only_explicit_last_resort_after_primary_exhaustion +test_codex_rotates_accounts_tied_for_best_fresh_score test_default_root_uses_passwd_home_not_ambient_home test_prepare_installs_and_verifies_per_account_herdr_hooks test_spawn_uses_direct_codex_home_without_agent_fleet test_spawn_uses_direct_claude_fallback_and_hook +test_claude_spawn_fails_closed_on_inherited_default_model +test_direct_claude_recovery_resolves_legacy_default_to_anchor test_observe_spawn_uses_direct_directory_without_agent_fleet test_direct_spawn_and_recovery_support_detached_worktree test_direct_recovery_preserves_recorded_task_context diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 773734b481..10bf17f14e 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -804,7 +804,7 @@ SH out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ FM_ACCOUNT_ROUTING_TEST_LAB=firstmate-account-routing-test-lab-v1 \ FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") - assert_not_contains "$out" 'MISSING_MANUAL: agent-fleet' "account-routed dispatch profile still required Agent Fleet" + assert_contains "$out" 'MISSING_MANUAL: agent-fleet' "account-routed dispatch profile did not require the pool-membership registry" assert_contains "$out" 'CREW_DISPATCH: active config/crew-dispatch.json' "account dependency preflight suppressed dispatch validation" case_dir="$TMP_ROOT/account-routing-observe" @@ -827,8 +827,9 @@ jq() { } SH out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ - BASH_ENV="$bash_env" FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") - assert_not_contains "$out" 'MISSING_MANUAL: agent-fleet' "observe mode treated Agent Fleet as a new-launch dependency" + BASH_ENV="$bash_env" FM_ACCOUNT_ROUTING_TEST_LAB=firstmate-account-routing-test-lab-v1 \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") + assert_contains "$out" 'MISSING_MANUAL: agent-fleet' "observe mode did not require the pool-membership registry" assert_contains "$out" 'MISSING: jq' "observe mode did not report the direct selector dependency" assert_contains "$out" 'MISSING_MANUAL: herdr' "observe mode did not report the direct hook installer" case_dir="$TMP_ROOT/account-routing-legacy-recovery" @@ -860,6 +861,7 @@ SH printf '%s\n' 'account_home=/accounts/codex/1' > "$case_dir/home/state/direct.meta" fakebin=$(make_fake_toolchain "$case_dir") rm -f "$fakebin/herdr" + rm -f "$fakebin/agent-fleet" bash_env="$case_dir/no-jq.bash" cat > "$bash_env" <<'SH' command() { @@ -873,14 +875,15 @@ jq() { } SH out=$(PATH="$fakebin:$BASE_PATH" BASH_ENV="$bash_env" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_ACCOUNT_ROUTING_TEST_LAB=firstmate-account-routing-test-lab-v1 \ FM_ACCOUNT_DIRECTORY_TEST_LAB=firstmate-account-directory-test-lab-v1 \ FM_ACCOUNT_DIRECTORY_PERL_BIN="$case_dir/missing-perl" \ FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") assert_contains "$out" 'MISSING: jq' "direct metadata did not retain the selector dependency when routing was off" assert_contains "$out" 'MISSING_MANUAL: herdr' "direct metadata did not retain the hook dependency when routing was off" assert_contains "$out" 'MISSING_MANUAL: perl' "direct metadata did not preflight the fixed passwd-home resolver" - assert_not_contains "$out" 'MISSING_MANUAL: agent-fleet' "direct metadata incorrectly restored the legacy recovery dependency" - pass "bootstrap requires direct launch tools for crews and Agent Fleet for enforced secondmates or legacy recovery" + assert_contains "$out" 'MISSING_MANUAL: agent-fleet' "direct metadata did not retain the pool-membership registry dependency" + pass "bootstrap requires direct selector tools and the read-only pool registry for routed crews" } test_agent_fleet_install_requires_manual_release() { diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 1dea94fa22..f690eeb013 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -14,10 +14,11 @@ # explicit per-spawn harness arg still wins. # B) Inheritance. The primary pushes a declared, extensible set of LOCAL # (gitignored) config items - config/crew-dispatch.json, config/crew-harness, -# and config/backlog-backend - down into each secondmate home's config/, so -# the secondmate's OWN crewmates, dispatch profiles, and backlog backend -# inherit the primary's settings. It is primary-authoritative (re-pushed at -# secondmate spawn, on the bootstrap secondmate sweep, and by config push). +# config/claude-crew-model, and config/backlog-backend - down into each +# secondmate home's config/, so the secondmate's OWN crewmates, dispatch +# profiles, Claude model anchor, and backlog backend inherit the primary's +# settings. It is primary-authoritative (re-pushed at secondmate spawn, on +# the bootstrap secondmate sweep, and by config push). # config/secondmate-harness is deliberately NOT inherited (secondmates do # not spawn secondmates). # C) Model/effort pin. config/secondmate-harness may carry optional model and @@ -114,6 +115,30 @@ ROWS pass "C1 fm-harness.sh secondmate-model/secondmate-effort resolve the optional tokens; bare harness stays empty (backward-compat)" } +test_claude_crew_model_anchor_resolution() { + local cfg out err status + cfg="$TMP_ROOT/claude-crew-model/config" + mkdir -p "$cfg" + out=$(FM_CONFIG_OVERRIDE="$cfg" "$ROOT/bin/fm-harness.sh" claude-crew-model) + [ "$out" = claude-opus-5 ] || fail "absent Claude crew model config did not resolve the Opus 5 anchor: $out" + + printf 'claude-opus-5-custom\n' > "$cfg/claude-crew-model" + out=$(FM_CONFIG_OVERRIDE="$cfg" "$ROOT/bin/fm-harness.sh" claude-crew-model) + [ "$out" = claude-opus-5-custom ] || fail "configured Claude crew model override was ignored: $out" + + printf 'default\n' > "$cfg/claude-crew-model" + err="$TMP_ROOT/claude-crew-model/invalid.err" + if FM_CONFIG_OVERRIDE="$cfg" "$ROOT/bin/fm-harness.sh" claude-crew-model > /dev/null 2>"$err"; then + status=0 + else + status=$? + fi + [ "$status" -ne 0 ] || fail "Claude crew model resolver accepted an inherited-default sentinel" + assert_contains "$(cat "$err")" "must contain one explicit Claude model id" \ + "invalid Claude crew model error was not actionable" + pass "C2 fm-harness.sh resolves the Opus 5 anchor, honors one local override, and rejects inherited defaults" +} + # =========================================================================== # B) propagate_inheritable_config unit behavior # =========================================================================== @@ -127,6 +152,7 @@ test_propagate_lib() { # 1. present source is copied printf '{"default":{"harness":"codex"}}\n' > "$src/crew-dispatch.json" printf 'codex\n' > "$src/crew-harness" + printf 'claude-opus-5\n' > "$src/claude-crew-model" printf 'manual\n' > "$src/backlog-backend" stdout="$d/clean-copy.out" stderr="$d/clean-copy.err" @@ -135,6 +161,7 @@ test_propagate_lib() { [ ! -s "$stderr" ] || fail "clean copy wrote to stderr" [ "$(cat "$dest/crew-dispatch.json")" = '{"default":{"harness":"codex"}}' ] || fail "crew-dispatch.json not propagated" [ "$(cat "$dest/crew-harness")" = codex ] || fail "crew-harness not propagated" + [ "$(cat "$dest/claude-crew-model")" = claude-opus-5 ] || fail "Claude crew model anchor not propagated" [ "$(cat "$dest/backlog-backend")" = manual ] || fail "backlog-backend not propagated" # 2. idempotent: an unchanged re-run does not churn the mtime @@ -151,10 +178,12 @@ test_propagate_lib() { # 3. a changed source value converges downstream printf '{"default":{"harness":"claude"}}\n' > "$src/crew-dispatch.json" printf 'claude\n' > "$src/crew-harness" + printf 'claude-opus-5-custom\n' > "$src/claude-crew-model" printf 'tasks-axi\n' > "$src/backlog-backend" propagate_inheritable_config "$src" "$dest" [ "$(cat "$dest/crew-dispatch.json")" = '{"default":{"harness":"claude"}}' ] || fail "changed dispatch profile did not converge" [ "$(cat "$dest/crew-harness")" = claude ] || fail "changed value did not converge" + [ "$(cat "$dest/claude-crew-model")" = claude-opus-5-custom ] || fail "changed Claude model anchor did not converge" [ "$(cat "$dest/backlog-backend")" = tasks-axi ] || fail "changed backlog backend did not converge" outside="$d/outside-target" @@ -168,10 +197,11 @@ test_propagate_lib() { [ "$(cat "$outside")" = outside ] || fail "destination symlink target was overwritten" # 4. removing the source mirrors absence downstream (primary-authoritative) - rm -f "$src/crew-dispatch.json" "$src/crew-harness" "$src/backlog-backend" + rm -f "$src/crew-dispatch.json" "$src/crew-harness" "$src/claude-crew-model" "$src/backlog-backend" propagate_inheritable_config "$src" "$dest" [ -e "$dest/crew-dispatch.json" ] && fail "dispatch profile absence not mirrored downstream" [ -e "$dest/crew-harness" ] && fail "absence not mirrored downstream" + [ -e "$dest/claude-crew-model" ] && fail "Claude model anchor absence not mirrored downstream" [ -e "$dest/backlog-backend" ] && fail "backlog-backend absence not mirrored downstream" rm -f "$dest/crew-harness" @@ -193,6 +223,7 @@ test_propagate_lib() { printf 'grok\n' > "$src/secondmate-harness" printf '{"default":{"harness":"codex"}}\n' > "$src/crew-dispatch.json" printf 'codex\n' > "$src/crew-harness" + printf 'claude-opus-5\n' > "$src/claude-crew-model" printf 'manual\n' > "$src/backlog-backend" rm -rf "$d/dest2" mkdir -p "$d/dest2" @@ -200,6 +231,7 @@ test_propagate_lib() { [ -e "$d/dest2/secondmate-harness" ] && fail "secondmate-harness was inherited (must not be)" [ "$(cat "$d/dest2/crew-dispatch.json")" = '{"default":{"harness":"codex"}}' ] || fail "crew-dispatch.json not propagated alongside" [ "$(cat "$d/dest2/crew-harness")" = codex ] || fail "crew-harness not propagated alongside" + [ "$(cat "$d/dest2/claude-crew-model")" = claude-opus-5 ] || fail "Claude crew model not propagated alongside" [ "$(cat "$d/dest2/backlog-backend")" = manual ] || fail "backlog-backend not propagated alongside" # 6. nothing to propagate -> destination dir is never created (a true no-op) @@ -212,7 +244,7 @@ test_propagate_lib() { # stderr warning and a skip, not a silent miss. guard_repo="$d/guard-repo" git init -q -b main "$guard_repo" - printf 'config/crew-harness\nconfig/backlog-backend\n' > "$guard_repo/.gitignore" + printf 'config/crew-harness\nconfig/claude-crew-model\nconfig/backlog-backend\n' > "$guard_repo/.gitignore" printf 'guard\n' > "$guard_repo/README.md" git -C "$guard_repo" add -A git -C "$guard_repo" commit -qm guard @@ -262,6 +294,7 @@ make_fixture_clone() { make_seeded_home() { local home=$1 id=$2 make_fixture_clone "$home" + printf 'config/claude-crew-model\n' >> "$home/.git/info/exclude" mkdir -p "$home/data" printf '%s\n' "$id" > "$home/.fm-secondmate-home" printf 'charter\n' > "$home/data/charter.md" @@ -315,6 +348,7 @@ test_spawn_split_and_inherit() { mkdir -p "$w/home/config" printf '{"default":{"harness":"claude","model":"haiku","effort":"low"}}\n' > "$w/home/config/crew-dispatch.json" printf 'claude\n' > "$w/home/config/crew-harness" + printf 'claude-opus-5\n' > "$w/home/config/claude-crew-model" printf 'codex\n' > "$w/home/config/secondmate-harness" printf 'manual\n' > "$w/home/config/backlog-backend" make_seeded_home "$sm" sm @@ -329,6 +363,8 @@ test_spawn_split_and_inherit() { || fail "split: home crew-harness not inherited as claude (got '$(cat "$sm/config/crew-harness" 2>/dev/null)')" [ "$(cat "$sm/config/crew-dispatch.json" 2>/dev/null)" = '{"default":{"harness":"claude","model":"haiku","effort":"low"}}' ] \ || fail "split: home crew-dispatch.json not inherited" + [ "$(cat "$sm/config/claude-crew-model" 2>/dev/null)" = claude-opus-5 ] \ + || fail "split: home Claude crew model anchor not inherited" [ "$(cat "$sm/config/backlog-backend" 2>/dev/null)" = manual ] \ || fail "split: home backlog-backend not inherited as manual" [ -e "$sm/config/secondmate-harness" ] \ @@ -734,7 +770,7 @@ new_world() { { printf 'projects/\nstate/\ndata/\n.no-mistakes/\n' [ "$dispatch_ignore" = no ] || printf 'config/crew-dispatch.json\n' - printf 'config/crew-harness\nconfig/secondmate-harness\nconfig/backlog-backend\n' + printf 'config/crew-harness\nconfig/claude-crew-model\nconfig/secondmate-harness\nconfig/backlog-backend\n' } > "$w/main/.gitignore" printf 'v1\n' > "$w/main/AGENTS.md" printf 'r1\n' > "$w/main/README.md" @@ -813,6 +849,7 @@ test_bootstrap_sweep_propagates_and_reconverges() { # Initial push: primary crew-harness=codex, secondmate-harness=grok (must NOT flow). printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" + printf 'claude-opus-5\n' > "$w/home/config/claude-crew-model" printf 'manual\n' > "$w/home/config/backlog-backend" printf 'grok\n' > "$w/home/config/secondmate-harness" run_bootstrap "$w" >/dev/null @@ -820,6 +857,8 @@ test_bootstrap_sweep_propagates_and_reconverges() { || fail "sweep: crew-harness not pushed into the live home" [ "$(cat "$w/sm/config/crew-dispatch.json" 2>/dev/null)" = '{"default":{"harness":"codex"}}' ] \ || fail "sweep: crew-dispatch.json not pushed into the live home" + [ "$(cat "$w/sm/config/claude-crew-model" 2>/dev/null)" = claude-opus-5 ] \ + || fail "sweep: Claude crew model anchor not pushed into the live home" [ "$(cat "$w/sm/config/backlog-backend" 2>/dev/null)" = manual ] \ || fail "sweep: backlog-backend not pushed into the live home" [ -e "$w/sm/config/secondmate-harness" ] \ @@ -828,22 +867,28 @@ test_bootstrap_sweep_propagates_and_reconverges() { # Re-converge: primary changes inheritable values; the home follows on the next sweep. printf '{"default":{"harness":"claude"}}\n' > "$w/home/config/crew-dispatch.json" printf 'claude\n' > "$w/home/config/crew-harness" + printf 'claude-opus-5-custom\n' > "$w/home/config/claude-crew-model" printf 'tasks-axi\n' > "$w/home/config/backlog-backend" run_bootstrap "$w" >/dev/null [ "$(cat "$w/sm/config/crew-harness" 2>/dev/null)" = claude ] \ || fail "sweep: home did not re-converge to the primary's new crew-harness" [ "$(cat "$w/sm/config/crew-dispatch.json" 2>/dev/null)" = '{"default":{"harness":"claude"}}' ] \ || fail "sweep: home did not re-converge to the primary's new crew-dispatch.json" + [ "$(cat "$w/sm/config/claude-crew-model" 2>/dev/null)" = claude-opus-5-custom ] \ + || fail "sweep: home did not re-converge to the primary's new Claude crew model anchor" [ "$(cat "$w/sm/config/backlog-backend" 2>/dev/null)" = tasks-axi ] \ || fail "sweep: home did not re-converge to the primary's new backlog-backend" # Mirror absence: primary clears inheritable config; the home's copies are removed. - rm -f "$w/home/config/crew-dispatch.json" "$w/home/config/crew-harness" "$w/home/config/backlog-backend" + rm -f "$w/home/config/crew-dispatch.json" "$w/home/config/crew-harness" \ + "$w/home/config/claude-crew-model" "$w/home/config/backlog-backend" run_bootstrap "$w" >/dev/null [ -e "$w/sm/config/crew-dispatch.json" ] \ && fail "sweep: home crew-dispatch.json not removed after the primary cleared it" [ -e "$w/sm/config/crew-harness" ] \ && fail "sweep: home crew-harness not removed after the primary cleared it" + [ -e "$w/sm/config/claude-crew-model" ] \ + && fail "sweep: home Claude crew model anchor not removed after the primary cleared it" [ -e "$w/sm/config/backlog-backend" ] \ && fail "sweep: home backlog-backend not removed after the primary cleared it" pass "B7 bootstrap sweep pushes, re-converges, and mirrors absence; never inherits secondmate-harness" @@ -859,12 +904,15 @@ test_bootstrap_sweep_propagates_when_tracked_current() { printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" + printf 'claude-opus-5\n' > "$w/home/config/claude-crew-model" printf 'manual\n' > "$w/home/config/backlog-backend" run_bootstrap "$w" >/dev/null [ "$(cat "$w/sm/config/crew-dispatch.json" 2>/dev/null)" = '{"default":{"harness":"codex"}}' ] \ || fail "crew-dispatch.json did not propagate to a tracked-current home" [ "$(cat "$w/sm/config/crew-harness" 2>/dev/null)" = codex ] \ || fail "config did not propagate to a tracked-current home" + [ "$(cat "$w/sm/config/claude-crew-model" 2>/dev/null)" = claude-opus-5 ] \ + || fail "Claude model anchor did not propagate to a tracked-current home" [ "$(cat "$w/sm/config/backlog-backend" 2>/dev/null)" = manual ] \ || fail "backlog-backend did not propagate to a tracked-current home" pass "B8 bootstrap sweep propagates config even when the home's tracked files are already current" @@ -883,6 +931,7 @@ test_bootstrap_sweep_defers_dispatch_on_stale_unignored_home() { printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" + printf 'claude-opus-5\n' > "$w/home/config/claude-crew-model" printf 'manual\n' > "$w/home/config/backlog-backend" out=$(run_bootstrap "$w") @@ -892,6 +941,8 @@ test_bootstrap_sweep_defers_dispatch_on_stale_unignored_home() { || fail "stale dispatch: crew-dispatch.json was copied before the home ignored it" [ "$(cat "$w/sm/config/crew-harness" 2>/dev/null)" = codex ] \ || fail "stale dispatch: existing ignored config stopped propagating" + [ "$(cat "$w/sm/config/claude-crew-model" 2>/dev/null)" = claude-opus-5 ] \ + || fail "stale dispatch: Claude model anchor stopped propagating" [ "$(cat "$w/sm/config/backlog-backend" 2>/dev/null)" = manual ] \ || fail "stale dispatch: backlog backend stopped propagating" status=$(git -C "$w/sm" status --porcelain -- config/crew-dispatch.json) @@ -957,6 +1008,7 @@ test_config_push_propagates_reports_without_ff_or_nudge() { printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" + printf 'claude-opus-5\n' > "$w/home/config/claude-crew-model" printf 'manual\n' > "$w/home/config/backlog-backend" err="$w/config-push-basic.err" out=$(run_config_push "$w" 2>"$err"); status=$? @@ -970,6 +1022,8 @@ test_config_push_propagates_reports_without_ff_or_nudge() { "config push did not report crew-dispatch as pushed" assert_contains "$out" "crew-harness: pushed" \ "config push did not report crew-harness as pushed" + assert_contains "$out" "claude-crew-model: pushed" \ + "config push did not report Claude crew model as pushed" assert_contains "$out" "backlog-backend: pushed" \ "config push did not report backlog-backend as pushed" assert_not_contains "$out" "NUDGE_SECONDMATES" \ @@ -984,6 +1038,8 @@ test_config_push_propagates_reports_without_ff_or_nudge() { "idempotent config push did not report crew-dispatch as unchanged" assert_contains "$out2" "crew-harness: unchanged" \ "idempotent config push did not report crew-harness as unchanged" + assert_contains "$out2" "claude-crew-model: unchanged" \ + "idempotent config push did not report Claude crew model as unchanged" assert_contains "$out2" "backlog-backend: unchanged" \ "idempotent config push did not report backlog-backend as unchanged" pass "B12 config-push propagates via shared live discovery, reports items, and does not fast-forward or nudge" @@ -1059,6 +1115,7 @@ test_config_push_exits_nonzero_on_copy_error() { test_harness_resolution test_secondmate_model_effort_tokens +test_claude_crew_model_anchor_resolution test_propagate_lib test_spawn_split_and_inherit test_spawn_backward_compat_crew_fallback diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 477e8b9541..7988d6e247 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -42,7 +42,15 @@ esac exit 0 SH chmod +x "$fakebin/tmux" - fm_fake_exit0 "$fakebin" treehouse + cat > "$fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +set -u +if [ "${1:-}" = get ]; then + printf '%s\n' "${FM_FAKE_TREEHOUSE_WORKTREE:?}" +fi +exit 0 +SH + chmod +x "$fakebin/treehouse" printf '%s\n' "$fakebin" } @@ -55,9 +63,12 @@ make_spawn_case() { wt="$case_dir/wt" launchlog="$case_dir/launch.log" fakebin=$(make_spawn_fakebin "$case_dir/fake") - mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config" + mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config" \ + "$home/treehouse-pools" printf '%s\n' "$harness" > "$home/config/crew-harness" fm_git_worktree "$proj" "$wt" "wt-$name" + git -C "$wt" checkout --quiet --detach HEAD + git -C "$proj" branch --quiet -D "wt-$name" touch "$home/state/.last-watcher-beat" for id in "$@"; do mkdir -p "$home/data/$id" @@ -74,8 +85,11 @@ enable_dispatch_profile() { make_seeded_secondmate_home() { local home=$1 id=$2 - mkdir -p "$home/bin" "$home/data" - printf '# Firstmate\n' > "$home/AGENTS.md" + git clone -q --no-checkout "$ROOT" "$home" + git -C "$home" checkout -q main + git -C "$home" update-ref refs/remotes/origin/main refs/heads/main + git -C "$home" remote set-head origin main + mkdir -p "$home/data" printf '%s\n' "$id" > "$home/.fm-secondmate-home" printf 'charter for %s\n' "$id" > "$home/data/charter.md" } @@ -87,7 +101,10 @@ run_spawn() { FM_ROOT_OVERRIDE='' FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_TREEHOUSE_ROOT="$home/treehouse-pools" \ + FM_CHECKOUT_REFRESH_STATE_BASE="$home/checkout-refresh-state" \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ + FM_FAKE_TREEHOUSE_WORKTREE="$wt" \ FM_FAKE_LAUNCH_LOG="$launchlog" GROK_HOME="$home/grok-home" PATH="$fakebin:$PATH" \ "$SPAWN" "$@" 2>&1 } @@ -105,7 +122,7 @@ assert_meta_profile() { assert_grep "effort=$effort" "$meta" "meta missing effort=$effort" } -test_no_profile_keeps_claude_launch_unchanged() { +test_no_profile_resolves_claude_model_anchor() { local rec id out status expected launch id=profile-off-z1 rec=$(make_spawn_case profile-off claude "$id") @@ -115,12 +132,12 @@ test_no_profile_keeps_claude_launch_unchanged() { status=$? expect_code 0 "$status" "claude spawn without profile flags should succeed" assert_contains "$out" "spawned $id harness=claude" "spawn did not report claude" - assert_meta_profile "$HOME_DIR/state/$id.meta" claude default default + assert_meta_profile "$HOME_DIR/state/$id.meta" claude claude-opus-5 default launch=$(cat "$LAUNCH_LOG") - expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions \"\$(cat '$HOME_DIR/data/$id/brief.md')\"" - [ "$launch" = "$expected" ] || fail "no-profile claude launch changed"$'\n'"expected: $expected"$'\n'"actual: $launch" - pass "no --model/--effort records defaults and keeps the claude launch byte-identical" + expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions --model 'claude-opus-5' \"\$(cat '$HOME_DIR/data/$id/brief.md')\"" + [ "$launch" = "$expected" ] || fail "unconfigured Claude launch missed the anchored model"$'\n'"expected: $expected"$'\n'"actual: $launch" + pass "Claude spawn without profile flags resolves, records, and launches the Opus 5 anchor" } test_active_dispatch_profile_requires_explicit_harness_for_ship() { @@ -373,6 +390,8 @@ test_active_dispatch_profile_does_not_block_secondmate_launch() { enable_dispatch_profile "$HOME_DIR" sm="$CASE_DIR/secondmate-home" make_seeded_secondmate_home "$sm" "$id" + printf -- '- %s - dispatch profile test (home: %s; scope: test; projects: ; added 2026-07-29)\n' \ + "$id" "$(cd "$sm" && pwd -P)" > "$HOME_DIR/data/secondmates.md" out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$sm" --secondmate) status=$? @@ -383,7 +402,7 @@ test_active_dispatch_profile_does_not_block_secondmate_launch() { pass "active crew-dispatch profile does not block secondmate launches" } -test_no_profile_keeps_claude_launch_unchanged +test_no_profile_resolves_claude_model_anchor test_active_dispatch_profile_requires_explicit_harness_for_ship test_active_dispatch_profile_requires_explicit_harness_for_scout test_active_dispatch_profile_allows_explicit_harness From daa259114ccb15a4d19556378440ab674352c46d Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Wed, 29 Jul 2026 03:58:28 -0400 Subject: [PATCH 02/10] no-mistakes(review): Harden Firstmate account eligibility, approvals, and Opus enforcement --- AGENTS.md | 2 +- bin/fm-account-directory.sh | 60 +++++++++++++++++++-- bin/fm-harness.sh | 6 ++- bin/fm-spawn.sh | 15 +++--- docs/architecture.md | 8 +-- docs/configuration.md | 10 ++-- tests/fm-account-directory.test.sh | 81 ++++++++++++++++++++++++----- tests/fm-secondmate-harness.test.sh | 13 +++-- 8 files changed, 155 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 592a7e9256..3a3fa8d4d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -242,7 +242,7 @@ The shell scripts never parse or match the natural-language rules; firstmate doe Per-harness model/effort flags: `harness-adapters` (loaded before every spawn per section 4's closing trigger). Claude crewmate and scout launches resolve a non-default model before endpoint creation. -An explicit `--model` wins; otherwise `fm-harness.sh claude-crew-model` reads the captain-changeable `config/claude-crew-model` anchor and defaults its absence to `claude-opus-5`. +`fm-harness.sh claude-crew-model` resolves the exact installed Opus 5 anchor from `config/claude-crew-model`, defaulting its absence to `claude-opus-5`, and an explicit `--model` must equal that anchor. An empty, invalid, or `default` anchor and a raw Claude launch fail closed, and `state/.meta` records the same resolved value passed through Claude's `--model`. Secondmates can run on a different harness than crewmates. diff --git a/bin/fm-account-directory.sh b/bin/fm-account-directory.sh index 468a3b641a..19069d48fb 100755 --- a/bin/fm-account-directory.sh +++ b/bin/fm-account-directory.sh @@ -10,9 +10,10 @@ # Account homes are discovered under the current passwd user's # .local/share/agent-fleet/accounts// tree without fixed counts. # Agent Fleet's read-only profile list is the source of pool eligibility: -# direct crew selection considers only real homes registered in the fixed -# -crew pool, so a manual-only profile is never a crew candidate and no -# caller-supplied compatibility alias can weaken that boundary. +# direct crew selection considers only enabled worker profiles with real homes +# registered in the fixed -crew pool, so a disabled, manual-only, or +# non-worker profile is never a crew candidate and no caller-supplied +# compatibility alias can weaken that boundary. # Claude may also declare a separate claude-crew-last-resort pool. # It is consulted only when no usable claude-crew profile remains; Firstmate # never guesses that membership from credentials or identity metadata. @@ -296,7 +297,7 @@ last_resort_pool() { # } eligible_account_homes() { # - local vendor=$1 pool=$2 root vendor_dir fleet_bin profiles candidate eligible matched marker marker_root marker_dir + local vendor=$1 pool=$2 root vendor_dir fleet_bin profiles candidate eligible matched reason marker marker_root marker_dir root=$(account_root) || return 1 vendor_dir=$root/$vendor [ -d "$vendor_dir" ] && [ ! -L "$vendor_dir" ] || { @@ -326,6 +327,8 @@ eligible_account_homes() { # and (.pools? | type) == "array" and (.pools | all(type == "string")) and ((.pools | index($pool)) != null) + and (.enabled? == true) + and (.safety_policy? == "worker") ) | .home] | unique[] @@ -346,7 +349,27 @@ eligible_account_homes() { # $eligible EOF if [ "$matched" != 1 ]; then - log "$vendor account $candidate excluded from $pool: registry pool membership does not allow crew selection" + reason=$(printf '%s\n' "$profiles" | jq -r \ + --arg vendor "$vendor" --arg pool "$pool" --arg home "$candidate" ' + [.profiles[] + | select( + (.provider? == $vendor) + and (.home? == $home) + and (.pools? | type) == "array" + and (.pools | all(type == "string")) + and ((.pools | index($pool)) != null) + )] + | if length == 0 then + "registry pool membership does not allow crew selection" + elif any(.[]; .enabled? != true) then + "profile is disabled" + elif any(.[]; .safety_policy? != "worker") then + "safety policy is not worker" + else + "registry eligibility does not allow crew selection" + end + ' 2>/dev/null) || reason="registry eligibility could not be verified" + log "$vendor account $candidate excluded from $pool: $reason" continue fi if [ "$vendor" = claude ]; then @@ -359,6 +382,33 @@ EOF log "claude account $candidate excluded from $pool: missing quota-axi's non-secret Keychain access marker; captain approval is required" continue fi + if ! PERL5LIB='' PERL5OPT='' "$(system_perl)" -e ' + use strict; + use warnings; + use Fcntl qw(:DEFAULT); + my ($path) = @ARGV; + my @before = lstat($path); + exit 1 unless @before && -f _ && !-l _; + exit 1 unless ($before[2] & 07777) == 0600; + exit 1 unless $before[4] == $< && $before[3] == 1; + sysopen(my $fh, $path, O_RDONLY) or exit 1; + binmode($fh); + my @opened = stat($fh); + exit 1 unless @opened; + local $/; + my $payload = <$fh>; + exit 1 unless defined($payload) && $payload eq "granted\n"; + exit 1 unless close($fh); + my @after = lstat($path); + exit 1 unless @after; + for my $index (0, 1, 2, 3, 4, 5, 7, 9, 10) { + exit 1 unless $before[$index] == $opened[$index] + && $opened[$index] == $after[$index]; + } + ' "$marker" 2>/dev/null; then + log "claude account $candidate excluded from $pool: invalid quota-axi Keychain approval marker; expected exact granted payload, mode 0600, current-user ownership, one link, real path components, and stable metadata" + continue + fi fi printf '%s\n' "$candidate" done diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index b348c8976e..a1709b8a63 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -174,7 +174,11 @@ resolve_claude_crew_model() { return 1 ;; esac - printf '%s\n' "$model" + [ "$model" = "$CLAUDE_CREW_MODEL_DEFAULT" ] || { + echo "error: config/claude-crew-model must equal the installed Opus 5 anchor '$CLAUDE_CREW_MODEL_DEFAULT', not '$model'" >&2 + return 1 + } + printf '%s\n' "$CLAUDE_CREW_MODEL_DEFAULT" } case "${1:-}" in diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 497a9a5c2b..95fb7918f5 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -52,10 +52,10 @@ # respawn exactly like the harness axis, and explicit --model/--effort flags # still win over the file's tokens. # Claude ship/scout launches never inherit the CLI's ambient model. -# Without an explicit --model, they resolve config/claude-crew-model (inherited -# into secondmate homes), whose absent-file default is claude-opus-5. -# An empty/default/unresolvable model or a raw Claude launch fails closed before -# endpoint creation because firstmate cannot prove which model it would run. +# They resolve config/claude-crew-model (inherited into secondmate homes), whose +# absent-file default and only accepted value is claude-opus-5. An explicit +# --model must equal that anchor. An empty/default/unresolvable/mismatched model +# or a raw Claude launch fails closed before endpoint creation. # Account routing is independently default-off. Its precedence and off/observe/ # enforce resolution is owned by fm-account-routing-lib.sh. Direct account- # directory launch currently covers ship/scout crewmates only; secondmate @@ -2245,15 +2245,16 @@ if [ "$KIND" = secondmate ] && [ -z "$ARG3" ]; then fi if [ "$KIND" != secondmate ] && [ "$HARNESS" = claude ]; then + CLAUDE_CREW_MODEL=$("$SCRIPT_DIR/fm-harness.sh" claude-crew-model) || exit 1 [ "$RAW_LAUNCH" != 1 ] || { echo "error: raw Claude crew/scout launch cannot prove its resolved model; use --harness claude with an optional explicit --model" >&2 exit 1 } if [ -z "$MODEL" ] && [ "$MODEL_SET" -eq 0 ]; then - MODEL=$("$SCRIPT_DIR/fm-harness.sh" claude-crew-model) || exit 1 + MODEL=$CLAUDE_CREW_MODEL fi - [ -n "$MODEL" ] && [ "$MODEL" != default ] || { - echo "error: Claude crew/scout launch requires an explicitly resolved model; set config/claude-crew-model or pass --model" >&2 + [ "$MODEL" = "$CLAUDE_CREW_MODEL" ] || { + echo "error: Claude crew/scout model '$MODEL' does not match the installed Opus 5 anchor '$CLAUDE_CREW_MODEL'" >&2 exit 1 } fi diff --git a/docs/architecture.md b/docs/architecture.md index 0ae0f142f9..8eeee5ab4d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -130,13 +130,13 @@ The shell scripts validate the JSON shape and verified harness/effort combinatio The session-start bootstrap step surfaces either the active rule block or a concise invalid-config line at startup. When the file exists, `fm-spawn.sh` refuses crewmate and scout launches without an explicit harness, so `config/crew-harness` is only automatic when no dispatch profile file is active. Secondmate launches are exempt because they resolve the secondmate harness and any optional secondmate model or effort tokens instead. -Claude crewmate and scout launches independently fail closed unless they resolve a concrete model before endpoint creation. -An explicit model wins; otherwise the inherited `config/claude-crew-model` anchor applies, with `claude-opus-5` as its absent-file default. +Claude crewmate and scout launches independently fail closed unless they resolve the exact installed Opus 5 model before endpoint creation. +The inherited `config/claude-crew-model` anchor has `claude-opus-5` as its absent-file default and only accepted value, and any explicit model must match it. Unsupported effort values are still recorded in task meta when passed to `fm-spawn.sh`, but the launch template omits any effort flag that the selected harness does not accept. That keeps spawn launch compatible across claude, codex, grok, pi, and opencode while preserving the requested profile for later audit. New ship/scout observe and enforce account routing is backend-neutral and uses the direct profile-directory contract owned by [configuration.md](configuration.md#agent-fleet-account-routing). Routing remains default-off and adds no account field or provider environment override until an explicit account flag or observe/enforce policy enables it. -The direct selector reads Agent Fleet only for pool membership, excludes manual-only profiles, requires Claude's non-secret per-directory Keychain approval marker, then uses fresh Codex quota opportunistically. +The direct selector reads Agent Fleet only for pool membership and enabled worker eligibility, excludes disabled, non-worker, and manual-only profiles, requires Claude's fully validated non-secret per-directory Keychain approval marker, then uses fresh Codex quota opportunistically. If no usable Claude crew account survives eligibility, the launch fails closed before fallback selection. Claude can declare a separate last-resort crew pool, consulted only after its primary crew pool is empty; no identity inference participates in either tier. It serializes round-robin fallback and exact-score tie-breaking so unavailable usage and concurrent launches cannot collapse onto the stable first account. @@ -175,7 +175,7 @@ When the harness token is unset or `default`, launch falls back to `config/crew- Those optional tokens are re-read on every secondmate spawn or respawn and are overridden by explicit per-spawn `--model` or `--effort` flags. An explicit per-spawn harness or raw launch command does not inherit model or effort tokens from `config/secondmate-harness`. `config/crew-harness` remains the crewmate harness and is inherited into secondmate homes. -`config/claude-crew-model` is inherited too, so Claude crews in every home use the same captain-changeable model anchor. +`config/claude-crew-model` is inherited too, so Claude crews in every home validate the same exact `claude-opus-5` model anchor. `config/crew-dispatch.json` is inherited too; secondmates use the same natural-language dispatch profiles when spawning their own crewmates. `config/backlog-backend` is inherited too; absent or `tasks-axi` selects the default tasks-axi backlog backend, while `manual` forces routine backlog updates to hand-editing across the fleet without disabling validated handoff delegation. diff --git a/docs/configuration.md b/docs/configuration.md index f26ddf8c41..9f67ff17c4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -152,10 +152,10 @@ Primary-session watcher wake protocols are rendered at session start by [`bin/fm Claude and Grok use background-notify cycles, Codex uses bounded foreground checkpoints, Pi uses its two tracked primary extensions, and OpenCode uses its TUI plugin. `config/crew-harness` is a local, gitignored file containing one adapter name for crewmate and scout launches. When it is absent or contains `default`, crewmates mirror the firstmate's own harness. -Claude crewmate and scout launches additionally require a resolved model before endpoint creation. -An explicit `--model` wins; otherwise `fm-harness.sh claude-crew-model` reads the one-value local `config/claude-crew-model` anchor, whose absent-file default is `claude-opus-5`. +Claude crewmate and scout launches additionally require the exact installed Opus 5 model before endpoint creation. +`fm-harness.sh claude-crew-model` resolves the one-value local `config/claude-crew-model` anchor, whose absent-file default and only accepted value is `claude-opus-5`; an explicit `--model` must equal that resolved anchor. The anchor is inherited into secondmate homes so the same rule governs their Claude crews. -An empty, invalid, or `default` anchor and a raw Claude launch fail closed rather than inheriting the Claude CLI's ambient model. +An empty, invalid, mismatched, or `default` anchor, a mismatched explicit model, and a raw Claude launch fail closed rather than inheriting or selecting another Claude model. The resolved model is both passed through Claude's `--model` flag and recorded as `model=` in task metadata. Fresh launches and recovery share this final resolution guard: recovery preserves an explicit recorded model, while legacy `model=default` metadata resolves through the anchor before relaunch and is rewritten to the value actually used. `config/secondmate-harness` is a separate local, gitignored file containing the adapter the primary uses to launch secondmate agents, optionally followed by model and effort tokens on the same line. @@ -178,8 +178,8 @@ The exact discovery, fresh-usage, health, fallback, and Herdr-hook mechanics are The selected provider command receives `CLAUDE_CONFIG_DIR=` or `CODEX_HOME=`, and task metadata records the non-secret `account_home=` for observability. New ship/scout launches never ask Agent Fleet to enable a profile, establish identity, install a bundle, or acquire a lease. They use Agent Fleet's read-only profile listing as the authoritative pool-membership registry. -Only real account homes registered in the provider's crew pool are eligible, so a profile reserved to a manual-only pool cannot be selected for crew work. -Claude candidates must additionally carry quota-axi's exact non-secret per-directory `claude-keychain-access-granted` marker. +Only enabled worker profiles with real account homes registered in the provider's crew pool are eligible, so disabled, non-worker, and manual-only profiles cannot be selected for crew work. +Claude candidates must additionally carry quota-axi's exact non-secret per-directory `claude-keychain-access-granted` marker with the exact `granted` newline payload, mode 0600, current-user ownership, one link, non-symlink path components, and stable metadata. Pool filtering and the marker check happen before fallback selection; if no Claude candidate survives, launch fails closed with the reserved and captain-approval-required reasons instead of borrowing a manual profile. The optional declared `claude-crew-last-resort` pool is checked only after no usable `claude-crew` profile remains and is announced loudly when used. Because Claude account identity is not machine-readable per directory, pool membership is the only ownership policy: the selector never infers an account from `.claude.json`, a hidden quota identity, or the nondiscriminating Keychain account label. diff --git a/tests/fm-account-directory.test.sh b/tests/fm-account-directory.test.sh index 08282099e4..b04a6b0c67 100755 --- a/tests/fm-account-directory.test.sh +++ b/tests/fm-account-directory.test.sh @@ -104,8 +104,13 @@ for provider in claude codex; do else pools="[\"$provider-crew\",\"$provider-manual\"]" fi - printf '%s{"id":"%s-%s","provider":"%s","home":"%s","pools":%s,"enabled":false}' \ - "$separator" "$provider" "$account" "$provider" "$home" "$pools" + enabled=true + safety_policy=worker + [ ! -f "$home/test-disabled" ] || enabled=false + [ ! -f "$home/test-non-worker" ] || safety_policy=manual_only + printf '%s{"id":"%s-%s","provider":"%s","home":"%s","pools":%s,"enabled":%s,"safety_policy":"%s"}' \ + "$separator" "$provider" "$account" "$provider" "$home" "$pools" \ + "$enabled" "$safety_policy" separator=, done done @@ -152,7 +157,52 @@ mark_last_resort() { mark_claude_keychain_ready() { local account=$1 mkdir -p "$ACCOUNT_ROOT/claude/$account/.agent-fleet-quota-cache/quota-axi" - touch "$ACCOUNT_ROOT/claude/$account/.agent-fleet-quota-cache/quota-axi/claude-keychain-access-granted" + printf 'granted\n' > "$ACCOUNT_ROOT/claude/$account/.agent-fleet-quota-cache/quota-axi/claude-keychain-access-granted" + chmod 600 "$ACCOUNT_ROOT/claude/$account/.agent-fleet-quota-cache/quota-axi/claude-keychain-access-granted" +} + +test_profile_eligibility_requires_enabled_worker() { + local out err expected + reset_accounts + mkdir -p "$ACCOUNT_ROOT/codex/1" "$ACCOUNT_ROOT/codex/2" "$ACCOUNT_ROOT/codex/3" + touch "$ACCOUNT_ROOT/codex/1/test-disabled" + touch "$ACCOUNT_ROOT/codex/2/test-non-worker" + set_remaining 1 100,100 + set_remaining 2 100,100 + set_remaining 3 80,80 + out=$(run_selector select codex 2>"$TMP_ROOT/profile-eligibility.err") + err=$(cat "$TMP_ROOT/profile-eligibility.err") + expected="$ACCOUNT_ROOT/codex/3" + [ "$out" = "$expected" ] || fail "disabled or non-worker profile entered crew selection: $out" + assert_contains "$err" "$ACCOUNT_ROOT/codex/1 excluded from codex-crew: profile is disabled" \ + "disabled profile exclusion was not explicit" + assert_contains "$err" "$ACCOUNT_ROOT/codex/2 excluded from codex-crew: safety policy is not worker" \ + "non-worker profile exclusion was not explicit" + pass "crew selection hard-excludes disabled and non-worker profiles with explicit diagnostics" +} + +test_claude_approval_marker_contract() { + local marker out status + reset_accounts + mkdir -p "$ACCOUNT_ROOT/claude/1/.agent-fleet-quota-cache/quota-axi" + marker="$ACCOUNT_ROOT/claude/1/.agent-fleet-quota-cache/quota-axi/claude-keychain-access-granted" + printf 'granted' > "$marker" + chmod 600 "$marker" + if out=$(run_selector select claude 2>&1); then status=0; else status=$?; fi + [ "$status" -ne 0 ] || fail "Claude selection accepted a marker without the exact granted newline payload" + assert_contains "$out" "invalid quota-axi Keychain approval marker" \ + "malformed approval payload did not fail with a clear diagnostic" + + printf 'granted\n' > "$marker" + chmod 644 "$marker" + if out=$(run_selector select claude 2>&1); then status=0; else status=$?; fi + [ "$status" -ne 0 ] || fail "Claude selection accepted an insecure approval marker mode" + + chmod 600 "$marker" + ln "$marker" "$marker.link" + if out=$(run_selector select claude 2>&1); then status=0; else status=$?; fi + [ "$status" -ne 0 ] || fail "Claude selection accepted a multiply linked approval marker" + pass "Claude approval marker requires exact content, secure metadata, and one stable link" } test_codex_picks_highest_fresh_minimum_and_skips_no_window() { @@ -356,7 +406,8 @@ test_default_root_uses_passwd_home_not_ambient_home() { expected="$passwd_home/.local/share/agent-fleet/accounts/claude/1" mkdir -p "$expected" "$hostile_home/.local/share/agent-fleet/accounts/claude/0" mkdir -p "$expected/.agent-fleet-quota-cache/quota-axi" - touch "$expected/.agent-fleet-quota-cache/quota-axi/claude-keychain-access-granted" + printf 'granted\n' > "$expected/.agent-fleet-quota-cache/quota-axi/claude-keychain-access-granted" + chmod 600 "$expected/.agent-fleet-quota-cache/quota-axi/claude-keychain-access-granted" out=$(HOME="$hostile_home" \ FM_ACCOUNT_DIRECTORY_TEST_LAB=firstmate-account-directory-test-lab-v1 \ FM_ACCOUNT_DIRECTORY_PASSWD_HOME="$passwd_home" \ @@ -566,24 +617,24 @@ test_spawn_uses_direct_claude_fallback_and_hook() { pass "new account-flagged Claude spawn excludes reserved profiles and launches on the anchored Opus 5 model" } -test_claude_spawn_fails_closed_on_inherited_default_model() { +test_claude_spawn_rejects_mismatched_explicit_model() { local record id out status reset_accounts - id=claude-default-model-refused-z2 - record=$(make_spawn_case claude-default-model-refused claude "$id") + id=claude-model-mismatch-refused-z2 + record=$(make_spawn_case claude-model-mismatch-refused claude "$id") read_spawn_case "$record" if out=$(run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \ - "$id" "$SPAWN_PROJECT" --model default 2>&1); then + "$id" "$SPAWN_PROJECT" --model sonnet 2>&1); then status=0 else status=$? fi - [ "$status" -ne 0 ] || fail "Claude crew launch silently inherited a default model" - assert_contains "$out" "Claude crew/scout launch requires an explicitly resolved model" \ - "Claude missing-model refusal was not actionable" - [ ! -e "$SPAWN_HOME/state/.fake-endpoint" ] || fail "Claude missing-model refusal created an endpoint" - pass "Claude crew/scout launch fails closed instead of inheriting an unspecified model" + [ "$status" -ne 0 ] || fail "Claude crew launch accepted a non-Opus-5 explicit model" + assert_contains "$out" "model 'sonnet' does not match the installed Opus 5 anchor 'claude-opus-5'" \ + "Claude model mismatch refusal was not actionable" + [ ! -e "$SPAWN_HOME/state/.fake-endpoint" ] || fail "Claude model mismatch created an endpoint" + pass "Claude crew/scout launch rejects an explicit model that differs from the Opus 5 anchor" } test_direct_claude_recovery_resolves_legacy_default_to_anchor() { @@ -1190,6 +1241,8 @@ if [ "${FM_TEST_FOCUSED:-}" = direct-recovery-lifecycle ]; then fi test_codex_picks_highest_fresh_minimum_and_skips_no_window +test_profile_eligibility_requires_enabled_worker +test_claude_approval_marker_contract test_codex_rechecks_health_on_every_selection test_codex_rotates_when_no_account_has_a_fresh_window test_codex_timeout_skips_wedged_account @@ -1202,7 +1255,7 @@ test_default_root_uses_passwd_home_not_ambient_home test_prepare_installs_and_verifies_per_account_herdr_hooks test_spawn_uses_direct_codex_home_without_agent_fleet test_spawn_uses_direct_claude_fallback_and_hook -test_claude_spawn_fails_closed_on_inherited_default_model +test_claude_spawn_rejects_mismatched_explicit_model test_direct_claude_recovery_resolves_legacy_default_to_anchor test_observe_spawn_uses_direct_directory_without_agent_fleet test_direct_spawn_and_recovery_support_detached_worktree diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index f690eeb013..8d3d1f7ac8 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -123,8 +123,15 @@ test_claude_crew_model_anchor_resolution() { [ "$out" = claude-opus-5 ] || fail "absent Claude crew model config did not resolve the Opus 5 anchor: $out" printf 'claude-opus-5-custom\n' > "$cfg/claude-crew-model" - out=$(FM_CONFIG_OVERRIDE="$cfg" "$ROOT/bin/fm-harness.sh" claude-crew-model) - [ "$out" = claude-opus-5-custom ] || fail "configured Claude crew model override was ignored: $out" + err="$TMP_ROOT/claude-crew-model/mismatch.err" + if FM_CONFIG_OVERRIDE="$cfg" "$ROOT/bin/fm-harness.sh" claude-crew-model > /dev/null 2>"$err"; then + status=0 + else + status=$? + fi + [ "$status" -ne 0 ] || fail "Claude crew model resolver accepted a non-Opus-5 anchor" + assert_contains "$(cat "$err")" "must equal the installed Opus 5 anchor 'claude-opus-5'" \ + "mismatched Claude anchor error was not actionable" printf 'default\n' > "$cfg/claude-crew-model" err="$TMP_ROOT/claude-crew-model/invalid.err" @@ -136,7 +143,7 @@ test_claude_crew_model_anchor_resolution() { [ "$status" -ne 0 ] || fail "Claude crew model resolver accepted an inherited-default sentinel" assert_contains "$(cat "$err")" "must contain one explicit Claude model id" \ "invalid Claude crew model error was not actionable" - pass "C2 fm-harness.sh resolves the Opus 5 anchor, honors one local override, and rejects inherited defaults" + pass "C2 fm-harness.sh resolves only the exact installed Opus 5 anchor and rejects mismatches" } # =========================================================================== From b4eca6e187388163d51611d9f56daa0396ccf365 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Wed, 29 Jul 2026 04:18:33 -0400 Subject: [PATCH 03/10] no-mistakes(review): Apply Firstmate-directed path trust hardening --- bin/fm-account-directory.sh | 56 ++++++++++++++++++++++++------ tests/fm-account-directory.test.sh | 30 ++++++++++++++-- 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/bin/fm-account-directory.sh b/bin/fm-account-directory.sh index 19069d48fb..678f9a83e9 100755 --- a/bin/fm-account-directory.sh +++ b/bin/fm-account-directory.sh @@ -54,6 +54,15 @@ set -u TEST_LAB_TOKEN=firstmate-account-directory-test-lab-v1 +case "${BASH_SOURCE[0]}" in + */*) FM_ACCOUNT_DIRECTORY_SOURCE_DIR=${BASH_SOURCE[0]%/*} ;; + *) FM_ACCOUNT_DIRECTORY_SOURCE_DIR=. ;; +esac +FM_ACCOUNT_DIRECTORY_BIN_DIR="$(cd "$FM_ACCOUNT_DIRECTORY_SOURCE_DIR" && pwd)" +unset FM_ACCOUNT_DIRECTORY_SOURCE_DIR +# shellcheck source=bin/fm-account-routing-lib.sh +. "$FM_ACCOUNT_DIRECTORY_BIN_DIR/fm-account-routing-lib.sh" + usage() { sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//' >&2 } @@ -158,17 +167,19 @@ herdr_command() { } agent_fleet_command() { - local home binary + local binary if test_lab_enabled && [ -n "${FM_ACCOUNT_DIRECTORY_AGENT_FLEET:-}" ]; then - printf '%s\n' "$FM_ACCOUNT_DIRECTORY_AGENT_FLEET" - return 0 + binary=$( + FM_ACCOUNT_ROUTING_TEST_LAB=firstmate-account-routing-test-lab-v1 + FM_AGENT_FLEET_BIN='' + fm_account_fleet_bin "$FM_ACCOUNT_DIRECTORY_AGENT_FLEET" + ) || return 1 + else + binary=$(fm_account_fleet_bin) || { + echo "error: agent-fleet is required to enforce direct crew-pool eligibility" >&2 + return 1 + } fi - home=$(passwd_home) || return 1 - binary=$home/.local/bin/agent-fleet - [ -x "$binary" ] && [ -f "$binary" ] && [ ! -L "$binary" ] || { - echo "error: agent-fleet is required to enforce direct crew-pool eligibility" >&2 - return 1 - } printf '%s\n' "$binary" } @@ -382,11 +393,34 @@ EOF log "claude account $candidate excluded from $pool: missing quota-axi's non-secret Keychain access marker; captain approval is required" continue fi + # shellcheck disable=SC2016 if ! PERL5LIB='' PERL5OPT='' "$(system_perl)" -e ' use strict; use warnings; use Fcntl qw(:DEFAULT); - my ($path) = @ARGV; + + my ($account_home, $path) = @ARGV; + my @directories = ( + $account_home, + "$account_home/.agent-fleet-quota-cache", + "$account_home/.agent-fleet-quota-cache/quota-axi", + ); + for my $directory (@directories) { + my @before = lstat($directory); + exit 1 unless @before && -d _ && !-l _; + exit 1 unless $before[4] == $< && ($before[2] & 0022) == 0; + sysopen(my $directory_fh, $directory, O_RDONLY) or exit 1; + my @opened = stat($directory_fh); + exit 1 unless @opened && -d _; + my @after = lstat($directory); + exit 1 unless @after; + for my $index (0, 1, 2, 3, 4, 5, 7, 9, 10) { + exit 1 unless $before[$index] == $opened[$index] + && $opened[$index] == $after[$index]; + } + close($directory_fh) or exit 1; + } + my @before = lstat($path); exit 1 unless @before && -f _ && !-l _; exit 1 unless ($before[2] & 07777) == 0600; @@ -405,7 +439,7 @@ EOF exit 1 unless $before[$index] == $opened[$index] && $opened[$index] == $after[$index]; } - ' "$marker" 2>/dev/null; then + ' "$candidate" "$marker" 2>/dev/null; then log "claude account $candidate excluded from $pool: invalid quota-axi Keychain approval marker; expected exact granted payload, mode 0600, current-user ownership, one link, real path components, and stable metadata" continue fi diff --git a/tests/fm-account-directory.test.sh b/tests/fm-account-directory.test.sh index b04a6b0c67..9680b037df 100755 --- a/tests/fm-account-directory.test.sh +++ b/tests/fm-account-directory.test.sh @@ -182,10 +182,13 @@ test_profile_eligibility_requires_enabled_worker() { } test_claude_approval_marker_contract() { - local marker out status + local account cache_dir marker marker_dir out status reset_accounts mkdir -p "$ACCOUNT_ROOT/claude/1/.agent-fleet-quota-cache/quota-axi" - marker="$ACCOUNT_ROOT/claude/1/.agent-fleet-quota-cache/quota-axi/claude-keychain-access-granted" + account="$ACCOUNT_ROOT/claude/1" + cache_dir="$account/.agent-fleet-quota-cache" + marker_dir="$cache_dir/quota-axi" + marker="$marker_dir/claude-keychain-access-granted" printf 'granted' > "$marker" chmod 600 "$marker" if out=$(run_selector select claude 2>&1); then status=0; else status=$?; fi @@ -202,7 +205,22 @@ test_claude_approval_marker_contract() { ln "$marker" "$marker.link" if out=$(run_selector select claude 2>&1); then status=0; else status=$?; fi [ "$status" -ne 0 ] || fail "Claude selection accepted a multiply linked approval marker" - pass "Claude approval marker requires exact content, secure metadata, and one stable link" + rm "$marker.link" + + chmod 770 "$account" + if out=$(run_selector select claude 2>&1); then status=0; else status=$?; fi + [ "$status" -ne 0 ] || fail "Claude selection accepted a group-writable account directory" + chmod 700 "$account" + + chmod 707 "$cache_dir" + if out=$(run_selector select claude 2>&1); then status=0; else status=$?; fi + [ "$status" -ne 0 ] || fail "Claude selection accepted a world-writable marker ancestor" + chmod 700 "$cache_dir" + + chmod 770 "$marker_dir" + if out=$(run_selector select claude 2>&1); then status=0; else status=$?; fi + [ "$status" -ne 0 ] || fail "Claude selection accepted a group-writable marker parent" + pass "Claude approval marker requires secure stable parents and leaf metadata" } test_codex_picks_highest_fresh_minimum_and_skips_no_window() { @@ -1230,6 +1248,12 @@ test_routing_off_keeps_default_provider_launch() { make_spawn_fakebin "$FAKEBIN" +if [ "${FM_TEST_FOCUSED:-}" = account-directory-trust ]; then + test_profile_eligibility_requires_enabled_worker + test_claude_approval_marker_contract + exit 0 +fi + if [ "${FM_TEST_FOCUSED:-}" = direct-recovery-lifecycle ]; then test_direct_spawn_and_recovery_support_detached_worktree test_direct_recovery_preserves_recorded_task_context From 60d923aef064afaea23d3a6a1ddb303a2b932eb6 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Wed, 29 Jul 2026 04:21:46 -0400 Subject: [PATCH 04/10] no-mistakes(review): Apply Firstmate-directed descriptor trust hardening --- bin/fm-account-directory.sh | 103 ++++++++++++++++++++--------- tests/fm-account-directory.test.sh | 17 ++++- 2 files changed, 87 insertions(+), 33 deletions(-) diff --git a/bin/fm-account-directory.sh b/bin/fm-account-directory.sh index 678f9a83e9..426c37da69 100755 --- a/bin/fm-account-directory.sh +++ b/bin/fm-account-directory.sh @@ -48,8 +48,8 @@ # # Credential and profile-registry state is read-only. # This script never logs in, imports credentials, or invokes a provider model. -# Test-only command, root, state-root, passwd-home, Perl, and timeout overrides require -# FM_ACCOUNT_DIRECTORY_TEST_LAB=firstmate-account-directory-test-lab-v1. +# Test-only command, root, state-root, passwd-home, Perl, timeout, and marker-race +# hook overrides require FM_ACCOUNT_DIRECTORY_TEST_LAB=firstmate-account-directory-test-lab-v1. set -u TEST_LAB_TOKEN=firstmate-account-directory-test-lab-v1 @@ -308,7 +308,7 @@ last_resort_pool() { # } eligible_account_homes() { # - local vendor=$1 pool=$2 root vendor_dir fleet_bin profiles candidate eligible matched reason marker marker_root marker_dir + local vendor=$1 pool=$2 root vendor_dir fleet_bin profiles candidate eligible matched reason marker marker_root marker_dir marker_race_hook root=$(account_root) || return 1 vendor_dir=$root/$vendor [ -d "$vendor_dir" ] && [ ! -L "$vendor_dir" ] || { @@ -384,6 +384,10 @@ EOF continue fi if [ "$vendor" = claude ]; then + marker_race_hook= + if test_lab_enabled && [ -n "${FM_ACCOUNT_DIRECTORY_MARKER_RACE_HOOK:-}" ]; then + marker_race_hook=$FM_ACCOUNT_DIRECTORY_MARKER_RACE_HOOK + fi marker_root=$candidate/.agent-fleet-quota-cache marker_dir=$marker_root/quota-axi marker=$marker_dir/claude-keychain-access-granted @@ -398,48 +402,83 @@ EOF use strict; use warnings; use Fcntl qw(:DEFAULT); + use Config; + + my ($account_home, $path, $race_hook) = @ARGV; + my $openat_number = + $^O eq "darwin" ? 463 : + $^O eq "linux" && $Config{archname} =~ /(?:x86_64|amd64)/ ? 257 : + $^O eq "linux" && $Config{archname} =~ /(?:aarch64|arm64)/ ? 56 : + undef; + exit 1 unless defined($openat_number); + + my $openat_handle = sub { + my ($parent, $name, $flags) = @_; + my $fd = syscall($openat_number, fileno($parent), $name, $flags, 0); + exit 1 if $fd < 0; + open(my $handle, "<&=$fd") or exit 1; + return $handle; + }; + + sysopen(my $account, $account_home, O_RDONLY | O_DIRECTORY | O_NOFOLLOW) + or exit 1; + my $cache = $openat_handle->( + $account, ".agent-fleet-quota-cache", + O_RDONLY | O_DIRECTORY | O_NOFOLLOW, + ); + my $quota = $openat_handle->( + $cache, "quota-axi", + O_RDONLY | O_DIRECTORY | O_NOFOLLOW, + ); + my $marker = $openat_handle->( + $quota, "claude-keychain-access-granted", + O_RDONLY | O_NOFOLLOW, + ); - my ($account_home, $path) = @ARGV; - my @directories = ( + my @handles = ($account, $cache, $quota); + my @paths = ( $account_home, "$account_home/.agent-fleet-quota-cache", "$account_home/.agent-fleet-quota-cache/quota-axi", ); - for my $directory (@directories) { - my @before = lstat($directory); - exit 1 unless @before && -d _ && !-l _; - exit 1 unless $before[4] == $< && ($before[2] & 0022) == 0; - sysopen(my $directory_fh, $directory, O_RDONLY) or exit 1; - my @opened = stat($directory_fh); + my @directory_stats; + for my $handle (@handles) { + my @opened = stat($handle); exit 1 unless @opened && -d _; - my @after = lstat($directory); - exit 1 unless @after; - for my $index (0, 1, 2, 3, 4, 5, 7, 9, 10) { - exit 1 unless $before[$index] == $opened[$index] - && $opened[$index] == $after[$index]; - } - close($directory_fh) or exit 1; + exit 1 unless $opened[4] == $< && ($opened[2] & 0022) == 0; + push @directory_stats, \@opened; } - my @before = lstat($path); - exit 1 unless @before && -f _ && !-l _; - exit 1 unless ($before[2] & 07777) == 0600; - exit 1 unless $before[4] == $< && $before[3] == 1; - sysopen(my $fh, $path, O_RDONLY) or exit 1; - binmode($fh); - my @opened = stat($fh); - exit 1 unless @opened; + my @opened = stat($marker); + exit 1 unless @opened && -f _; + exit 1 unless ($opened[2] & 07777) == 0600; + exit 1 unless $opened[4] == $< && $opened[3] == 1; + binmode($marker); local $/; - my $payload = <$fh>; + my $payload = <$marker>; exit 1 unless defined($payload) && $payload eq "granted\n"; - exit 1 unless close($fh); + if (length($race_hook)) { + system($race_hook) == 0 or exit 1; + } + + for my $index (0 .. $#paths) { + my @after = lstat($paths[$index]); + exit 1 unless @after && -d _ && !-l _; + my $expected = $directory_stats[$index]; + for my $field (0, 1, 2, 3, 4, 5, 7, 9, 10) { + exit 1 unless $expected->[$field] == $after[$field]; + } + } my @after = lstat($path); - exit 1 unless @after; + exit 1 unless @after && -f _ && !-l _; for my $index (0, 1, 2, 3, 4, 5, 7, 9, 10) { - exit 1 unless $before[$index] == $opened[$index] - && $opened[$index] == $after[$index]; + exit 1 unless $opened[$index] == $after[$index]; } - ' "$candidate" "$marker" 2>/dev/null; then + close($marker) or exit 1; + close($quota) or exit 1; + close($cache) or exit 1; + close($account) or exit 1; + ' "$candidate" "$marker" "$marker_race_hook" 2>/dev/null; then log "claude account $candidate excluded from $pool: invalid quota-axi Keychain approval marker; expected exact granted payload, mode 0600, current-user ownership, one link, real path components, and stable metadata" continue fi diff --git a/tests/fm-account-directory.test.sh b/tests/fm-account-directory.test.sh index 9680b037df..e9e1341ed0 100755 --- a/tests/fm-account-directory.test.sh +++ b/tests/fm-account-directory.test.sh @@ -182,7 +182,7 @@ test_profile_eligibility_requires_enabled_worker() { } test_claude_approval_marker_contract() { - local account cache_dir marker marker_dir out status + local account cache_dir marker marker_dir out race_hook status reset_accounts mkdir -p "$ACCOUNT_ROOT/claude/1/.agent-fleet-quota-cache/quota-axi" account="$ACCOUNT_ROOT/claude/1" @@ -220,6 +220,21 @@ test_claude_approval_marker_contract() { chmod 770 "$marker_dir" if out=$(run_selector select claude 2>&1); then status=0; else status=$?; fi [ "$status" -ne 0 ] || fail "Claude selection accepted a group-writable marker parent" + chmod 700 "$marker_dir" + + race_hook="$TMP_ROOT/replace-marker-parent" + cat > "$race_hook" < "$marker" +chmod 600 "$marker" +SH + chmod +x "$race_hook" + if out=$(FM_ACCOUNT_DIRECTORY_MARKER_RACE_HOOK="$race_hook" \ + run_selector select claude 2>&1); then status=0; else status=$?; fi + [ "$status" -ne 0 ] || fail "Claude selection accepted a replaced marker parent" pass "Claude approval marker requires secure stable parents and leaf metadata" } From 0086ffa83d6019a240437fff63c10fed31e5125f Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Wed, 29 Jul 2026 04:25:18 -0400 Subject: [PATCH 05/10] no-mistakes(review): Apply Firstmate-directed openat portability correction --- bin/fm-account-directory.sh | 51 +++++++++++++++++++++++------- tests/fm-account-directory.test.sh | 23 ++++++++++++++ 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/bin/fm-account-directory.sh b/bin/fm-account-directory.sh index 426c37da69..33a999d087 100755 --- a/bin/fm-account-directory.sh +++ b/bin/fm-account-directory.sh @@ -48,8 +48,8 @@ # # Credential and profile-registry state is read-only. # This script never logs in, imports credentials, or invokes a provider model. -# Test-only command, root, state-root, passwd-home, Perl, timeout, and marker-race -# hook overrides require FM_ACCOUNT_DIRECTORY_TEST_LAB=firstmate-account-directory-test-lab-v1. +# Test-only command, root, state-root, passwd-home, Perl, timeout, openat preprocessor, +# and marker-race hook overrides require FM_ACCOUNT_DIRECTORY_TEST_LAB=firstmate-account-directory-test-lab-v1. set -u TEST_LAB_TOKEN=firstmate-account-directory-test-lab-v1 @@ -196,6 +196,36 @@ read_profile_registry() { # fi } +openat_syscall_number() { + local cpp_bin output + if test_lab_enabled && [ -n "${FM_ACCOUNT_DIRECTORY_OPENAT_CPP:-}" ]; then + cpp_bin=$FM_ACCOUNT_DIRECTORY_OPENAT_CPP + elif [ -x /usr/bin/cpp ] && [ -f /usr/bin/cpp ]; then + cpp_bin=/usr/bin/cpp + elif [ -x /bin/cpp ] && [ -f /bin/cpp ]; then + cpp_bin=/bin/cpp + else + echo "error: descriptor-relative approval validation is unsupported: system openat binding unavailable" >&2 + return 1 + fi + output=$( + printf '#include \nSYS_openat\n' \ + | /usr/bin/env -i PATH=/usr/bin:/bin:/usr/sbin:/sbin "$cpp_bin" -P - 2>/dev/null + ) || { + echo "error: descriptor-relative approval validation is unsupported: system openat binding unavailable" >&2 + return 1 + } + output=${output//$'\n'/} + output=${output//[[:space:]]/} + case "$output" in + ''|*[!0-9]*) + echo "error: descriptor-relative approval validation is unsupported: system openat binding unavailable" >&2 + return 1 + ;; + esac + printf '%s\n' "$output" +} + rotation_state_root() { local root home if test_lab_enabled && [ -n "${FM_ACCOUNT_DIRECTORY_STATE_ROOT:-}" ]; then @@ -308,7 +338,7 @@ last_resort_pool() { # } eligible_account_homes() { # - local vendor=$1 pool=$2 root vendor_dir fleet_bin profiles candidate eligible matched reason marker marker_root marker_dir marker_race_hook + local vendor=$1 pool=$2 root vendor_dir fleet_bin profiles candidate eligible matched reason marker marker_root marker_dir marker_race_hook openat_number root=$(account_root) || return 1 vendor_dir=$root/$vendor [ -d "$vendor_dir" ] && [ ! -L "$vendor_dir" ] || { @@ -319,6 +349,9 @@ eligible_account_homes() { # echo "error: jq is required for direct account pool eligibility" >&2 return 1 } + if [ "$vendor" = claude ]; then + openat_number=$(openat_syscall_number) || return 1 + fi fleet_bin=$(agent_fleet_command) || return 1 profiles=$(read_profile_registry "$fleet_bin" 2>/dev/null) || { echo "error: agent-fleet could not read the account profile registry for $vendor crew selection" >&2 @@ -402,15 +435,9 @@ EOF use strict; use warnings; use Fcntl qw(:DEFAULT); - use Config; - my ($account_home, $path, $race_hook) = @ARGV; - my $openat_number = - $^O eq "darwin" ? 463 : - $^O eq "linux" && $Config{archname} =~ /(?:x86_64|amd64)/ ? 257 : - $^O eq "linux" && $Config{archname} =~ /(?:aarch64|arm64)/ ? 56 : - undef; - exit 1 unless defined($openat_number); + my ($openat_number, $account_home, $path, $race_hook) = @ARGV; + exit 1 unless $openat_number =~ /\A[0-9]+\z/; my $openat_handle = sub { my ($parent, $name, $flags) = @_; @@ -478,7 +505,7 @@ EOF close($quota) or exit 1; close($cache) or exit 1; close($account) or exit 1; - ' "$candidate" "$marker" "$marker_race_hook" 2>/dev/null; then + ' "$openat_number" "$candidate" "$marker" "$marker_race_hook" 2>/dev/null; then log "claude account $candidate excluded from $pool: invalid quota-axi Keychain approval marker; expected exact granted payload, mode 0600, current-user ownership, one link, real path components, and stable metadata" continue fi diff --git a/tests/fm-account-directory.test.sh b/tests/fm-account-directory.test.sh index e9e1341ed0..ab4ad2cd2f 100755 --- a/tests/fm-account-directory.test.sh +++ b/tests/fm-account-directory.test.sh @@ -238,6 +238,27 @@ SH pass "Claude approval marker requires secure stable parents and leaf metadata" } +test_openat_binding_failure_is_a_setup_error() { + local cpp out status + reset_accounts + mark_claude_keychain_ready 1 + cpp="$FAKEBIN/unsupported-openat-cpp" + cat > "$cpp" <<'SH' +#!/usr/bin/env bash +exit 1 +SH + chmod +x "$cpp" + if out=$(FM_ACCOUNT_DIRECTORY_OPENAT_CPP="$cpp" \ + run_selector select claude 2>&1); then status=0; else status=$?; fi + [ "$status" -ne 0 ] || fail "Claude selection accepted a platform without an openat binding" + assert_contains "$out" \ + "error: descriptor-relative approval validation is unsupported: system openat binding unavailable" \ + "unsupported openat platform did not fail at selector setup" + assert_not_contains "$out" "invalid quota-axi Keychain approval marker" \ + "unsupported openat platform was misclassified as an invalid account marker" + pass "missing system openat binding fails once at selector setup" +} + test_codex_picks_highest_fresh_minimum_and_skips_no_window() { local out err reset_accounts @@ -1266,6 +1287,7 @@ make_spawn_fakebin "$FAKEBIN" if [ "${FM_TEST_FOCUSED:-}" = account-directory-trust ]; then test_profile_eligibility_requires_enabled_worker test_claude_approval_marker_contract + test_openat_binding_failure_is_a_setup_error exit 0 fi @@ -1282,6 +1304,7 @@ fi test_codex_picks_highest_fresh_minimum_and_skips_no_window test_profile_eligibility_requires_enabled_worker test_claude_approval_marker_contract +test_openat_binding_failure_is_a_setup_error test_codex_rechecks_health_on_every_selection test_codex_rotates_when_no_account_has_a_fresh_window test_codex_timeout_skips_wedged_account From fdd048ca6fdd0960355f445cbd1419b021848c48 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Wed, 29 Jul 2026 07:04:36 -0400 Subject: [PATCH 06/10] test: Apply Firstmate-directed Claude Opus assertions --- tests/fm-spawn-dispatch-profile.test.sh | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 7988d6e247..9411179609 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -231,14 +231,30 @@ test_claude_threads_model_and_effort() { rec=$(make_spawn_case profile-claude claude "$id") read_case_record "$rec" - out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --model sonnet --effort high) + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --model claude-opus-5 --effort high) status=$? expect_code 0 "$status" "claude spawn with profile flags should succeed" - assert_meta_profile "$HOME_DIR/state/$id.meta" claude sonnet high + assert_meta_profile "$HOME_DIR/state/$id.meta" claude claude-opus-5 high launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "claude --dangerously-skip-permissions --model 'sonnet' --effort 'high'" \ + assert_contains "$launch" "claude --dangerously-skip-permissions --model 'claude-opus-5' --effort 'high'" \ "claude launch did not thread model and effort flags" - pass "claude receives --model and --effort profile flags" + pass "Claude receives the explicit Opus 5 model and effort flags" +} + +test_claude_rejects_mismatched_explicit_model() { + local rec id out status + id=profile-claude-mismatch-z16 + rec=$(make_spawn_case profile-claude-mismatch claude "$id") + read_case_record "$rec" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --model sonnet --effort high) + status=$? + expect_code 1 "$status" "Claude spawn with a mismatched explicit model should fail" + assert_contains "$out" "Claude crew/scout model 'sonnet' does not match the installed Opus 5 anchor 'claude-opus-5'" \ + "Claude mismatch refusal did not name the required and requested models" + assert_absent "$HOME_DIR/state/$id.meta" "Claude mismatch should fail before meta is written" + [ ! -s "$LAUNCH_LOG" ] || fail "Claude mismatch silently rewrote and launched another model" + pass "Claude mismatched explicit models fail closed before endpoint creation" } test_codex_threads_model_and_effort() { @@ -409,6 +425,7 @@ test_active_dispatch_profile_allows_explicit_harness test_active_dispatch_profile_allows_positional_harness test_active_dispatch_profile_allows_raw_launch_command test_claude_threads_model_and_effort +test_claude_rejects_mismatched_explicit_model test_codex_threads_model_and_effort test_codex_omits_invalid_max_effort test_grok_threads_model_and_reasoning_effort From c7eba510c173f0d7c69a2c50b4da1fd37542de67 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Wed, 29 Jul 2026 09:28:47 -0400 Subject: [PATCH 07/10] no-mistakes(document): Document Claude crew routing configuration --- .agents/skills/bootstrap-diagnostics/SKILL.md | 3 ++- .agents/skills/harness-adapters/SKILL.md | 5 +++-- .agents/skills/secondmate-provisioning/SKILL.md | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 584ec6022d..acec5cb937 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -19,7 +19,8 @@ The inline rules in `AGENTS.md` section 3 still bind: detect, then consent, then For `treehouse`, this also covers an installed version whose `treehouse get` lacks `--lease`; treat it as an upgrade request. For `no-mistakes`, this also covers an installed version older than 1.31.2, because crewmate validation briefs delegate gate mechanics to no-mistakes' version-matched guidance. For `tasks-axi`, this also covers an installed build that fails the compatibility probe (`docs/configuration.md` "Backlog backend" owns the definition); `config/backlog-backend=manual` only suppresses the `TASKS_AXI: available` capability line, not this missing-tool report. - For `quota-axi`, bootstrap requires it because crew-dispatch `quota-balanced` may call it; `bin/fm-dispatch-select.sh` still degrades at runtime when quota data is unavailable. + For `quota-axi`, bootstrap requires it because crew-dispatch `quota-balanced` may call it and direct account-directory selection uses it; `bin/fm-dispatch-select.sh` still degrades at runtime when quota data is unavailable. + Direct account-directory selection also requires Agent Fleet's read-only profile registry to enforce crew-pool eligibility. - `MISSING_MANUAL: (instructions: )` - tell the captain why the tool is required and give them the printed instructions URL, but do not pass the tool to `bin/fm-bootstrap.sh install`; wait for the captain to complete the manual installation, then rerun session start to confirm the dependency is present. - `BACKEND_INVALID: (known: )` - the resolved runtime backend has no verified dependency or lifecycle contract, so do not dispatch work until the invalid `FM_BACKEND` or `config/backend` value is corrected to one of the listed backends. - `ACCOUNT_ROUTING: invalid routing policy - ` - the environment or `config/account-routing-mode` cannot resolve to exactly one of `off`, `observe`, or `enforce`; fix the reported source before dispatch because managed spawns will fail closed on the same policy error. diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 86dbfd2ee5..22864d15ff 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -21,11 +21,12 @@ The captain may override that file at session start or later; a per-task instruc Secondmates have their own harness knob, so a secondmate can run on a different adapter than crewmates. `config/secondmate-harness` is the harness the primary uses to launch SECONDMATE agents, resolved through the fallback chain `config/secondmate-harness` -> `config/crew-harness` -> firstmate's own. An absent or `default` `config/secondmate-harness` therefore behaves exactly as the crewmate harness did before this knob existed (secondmates launched on the crewmate harness); setting it splits the two. -`config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend` are inherited by secondmate homes. -This skill owns only the harness-relevant consequence: a secondmate's own crewmates use the primary's dispatch profiles and static harness value, while `config/secondmate-harness` is the primary's own setting and is never inherited - secondmates do not spawn secondmates. +The complete inheritable-config list is owned by the `secondmate-provisioning` skill. +This skill owns only the harness-relevant consequence: a secondmate's own crewmates use the primary's dispatch profiles, static harness value, and Claude crew model anchor, while `config/secondmate-harness` is the primary's own setting and is never inherited - secondmates do not spawn secondmates. Inheritance copies the literal `config/crew-harness` file, so for a secondmate's own crewmates to run on the primary's crewmate harness the captain must set `config/crew-harness` to a concrete adapter name, such as `codex`. If `config/crew-harness` is unset or `default`, there is no concrete value to inherit, so the secondmate's own crewmates fall back to the secondmate's own/detected harness rather than the primary's effective crewmate harness. Inheritance also copies the literal `config/crew-dispatch.json` file, so secondmates apply the same best-fit profile rules for their own crewmates. +Inheritance also copies the literal `config/claude-crew-model` file, so Claude crewmate and scout launches use the same required model anchor in every home. Each adapter splits into mechanics and knowledge. The per-task mechanics, including launch command, autonomy flag, and crewmate turn-end hook, live in `bin/fm-spawn.sh`. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index 3607a37c33..f0217c4d79 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -80,7 +80,7 @@ This section is the single owner of the secondmate sync and inheritable-config p Before launch, `fm-spawn.sh --secondmate` locally fast-forwards the home to the primary firstmate checkout's current default-branch commit when it is safe; dirty, diverged, or in-flight homes launch unchanged with a warning. The locked session-start bootstrap sweep runs the same guarded fast-forward for every live secondmate home, discovered from `state/.meta` records with `kind=secondmate` (`data/secondmates.md` only backfills `home=` for older records). That no-fetch path is a purely local fast-forward of tracked files, never an origin fetch, and it never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a linked worktree advances immediately, while a standalone clone that lacks the target receives firstmate updates through `/updatefirstmate`'s origin refresh. -The same launch and the same locked bootstrap sweep also propagate the primary's declared inheritable local config, currently `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, and `config/account-routing-mode`, into the secondmate home's `config/`. +The same launch and the same locked bootstrap sweep also propagate the primary's declared inheritable local config, currently `config/crew-dispatch.json`, `config/crew-harness`, `config/claude-crew-model`, `config/backlog-backend`, and `config/account-routing-mode`, into the secondmate home's `config/`. Because `config/` is gitignored, that propagation is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared items. Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when it names a concrete adapter such as `codex`; an unset or `default` value has nothing concrete to inherit, and the secondmate's own crewmates fall back to the secondmate's own or detected harness instead. `config/secondmate-harness` and `config/secondmate-account-pool` are not inherited because they are only the primary's knobs for launching secondmate agents. From bac01ba60815ef25eb7d0803860e3535f2a551e2 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Wed, 29 Jul 2026 10:09:40 -0400 Subject: [PATCH 08/10] no-mistakes: apply CI fixes --- tests/fm-account-routing.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fm-account-routing.test.sh b/tests/fm-account-routing.test.sh index 7e795905ff..8c27aeffbe 100755 --- a/tests/fm-account-routing.test.sh +++ b/tests/fm-account-routing.test.sh @@ -561,7 +561,7 @@ test_off_is_byte_compatible_and_never_calls_agent_fleet() { [ "$status" -eq 0 ] || fail "default-off spawn should succeed (exit $status): $out" [ ! -s "$AF_LOG" ] || fail "routing off invoked Agent Fleet: $(cat "$AF_LOG")" launch=$(cat "$LAUNCH_LOG") - expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions \"\$(cat '$HOME_DIR/data/$id/brief.md')\"" + expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions --model 'claude-opus-5' \"\$(cat '$HOME_DIR/data/$id/brief.md')\"" [ "$launch" = "$expected" ] || fail "routing off changed the launch bytes" assert_not_grep '^account_' "$HOME_DIR/state/$id.meta" "routing off wrote account metadata" assert_not_grep '^provider_session_id=' "$HOME_DIR/state/$id.meta" "routing off wrote session metadata" From 7a82a3f93b17218392de8c5bb722cf78c1f887b4 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Wed, 29 Jul 2026 10:34:57 -0400 Subject: [PATCH 09/10] no-mistakes: apply CI fixes --- bin/fm-spawn.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 95fb7918f5..0d60d06146 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -2247,7 +2247,7 @@ fi if [ "$KIND" != secondmate ] && [ "$HARNESS" = claude ]; then CLAUDE_CREW_MODEL=$("$SCRIPT_DIR/fm-harness.sh" claude-crew-model) || exit 1 [ "$RAW_LAUNCH" != 1 ] || { - echo "error: raw Claude crew/scout launch cannot prove its resolved model; use --harness claude with an optional explicit --model" >&2 + echo "error: Claude crew/scout launch does not accept raw launch commands because they cannot prove the resolved model; use --harness claude with an optional explicit --model" >&2 exit 1 } if [ -z "$MODEL" ] && [ "$MODEL_SET" -eq 0 ]; then From 76b99af6577ce1381df69d438807ac6e22ce547c Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Wed, 29 Jul 2026 11:17:55 -0400 Subject: [PATCH 10/10] no-mistakes: apply CI fixes --- tests/fm-account-routing.test.sh | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/fm-account-routing.test.sh b/tests/fm-account-routing.test.sh index 8c27aeffbe..6bc31b8f62 100755 --- a/tests/fm-account-routing.test.sh +++ b/tests/fm-account-routing.test.sh @@ -2894,7 +2894,11 @@ test_cross_profile_continuation_for_harness() { id="account-continue-$harness-z21" rec=$(make_case "continue-$harness" "$harness" "$id") read_case "$rec" - source_model="$harness-source-model" + if [ "$harness" = claude ]; then + source_model=claude-opus-5 + else + source_model="$harness-source-model" + fi out=$(FM_FAKE_AF_PROVIDER="$provider" FM_FAKE_AF_PROFILE="$old_profile" FM_FAKE_AF_POOL="$harness-crew" \ run_spawn "$id" "$PROJ_DIR" --account-pool "$harness-crew" --model "$source_model" --effort high) status=$? @@ -2994,11 +2998,20 @@ PY } test_cross_provider_continuation_uses_target_default_pool() { - local source=$1 target=$2 id rec old_task out status source_model launch + local source=$1 target=$2 id rec old_task out status source_model target_model launch id="account-continue-$source-to-$target-z21a" rec=$(make_case "continue-$source-to-$target" "$source" "$id") read_case "$rec" - source_model="$source-source-model" + if [ "$source" = claude ]; then + source_model=claude-opus-5 + else + source_model="$source-source-model" + fi + if [ "$target" = claude ]; then + target_model=claude-opus-5 + else + target_model=default + fi out=$(FM_FAKE_AF_PROVIDER="$source" FM_FAKE_AF_PROFILE="$source-2" FM_FAKE_AF_POOL="$source-crew" \ run_spawn "$id" "$PROJ_DIR" --account-pool "$source-crew" --model "$source_model" --effort high) status=$? @@ -3017,7 +3030,7 @@ test_cross_provider_continuation_uses_target_default_pool() { "$source-to-$target continuation inherited the predecessor provider's pool" launch=$(cat "$LAUNCH_LOG") assert_not_contains "$launch" "$source_model" "$source-to-$target continuation inherited the source provider's model" - assert_regex '^model=default$' "$HOME_DIR/state/$id.meta" "$source-to-$target continuation did not restore the target model default" + assert_regex "^model=$target_model$" "$HOME_DIR/state/$id.meta" "$source-to-$target continuation did not resolve the target model" assert_regex '^effort=default$' "$HOME_DIR/state/$id.meta" "$source-to-$target continuation did not restore the target effort default" assert_grep "predecessor=$old_task" "$HOME_DIR/data/$id/account-attempts.md" \ "$source-to-$target continuation lost predecessor lineage"