From 1d48fb6d7e2e1a25e2cc52b2d34939a1f8abc514 Mon Sep 17 00:00:00 2001 From: Karan Manoharan Date: Mon, 14 Sep 2026 18:50:05 +0100 Subject: [PATCH 1/2] feat(herdr): report fleet role token for sidebar rules Herdr sidebar colour rules key on a $role metadata token. Firstmate now reports role= with `herdr pane report-metadata --source firstmate --token role=`. - Spawn and relaunch report crewmate, scout, or secondmate for the exact pane in the published task record. - A locked session start reports firstmate, or secondmate in a marked secondmate home, for its own pane after its socket identity matches the named session. - Reports need Herdr 0.7.4 or newer and skip silently below it. A failed report warns and never fails the spawn or the session start. - tests/lib.sh drops inherited Herdr pane identity so suites run from a Herdr pane cannot report metadata to the developer's live pane. Claude-Session: https://claude.ai/code/session_01XUEUUVFLABdU3vL4NoAe2f --- bin/backends/herdr.sh | 111 ++++++++++++++- bin/fm-session-start.sh | 11 +- bin/fm-spawn.sh | 14 ++ docs/herdr-backend.md | 8 ++ docs/verification/runtime-backends.md | 1 + ...ckend-herdr-launcher-workspace-e2e.test.sh | 23 ++++ tests/fm-backend-herdr.test.sh | 129 ++++++++++++++++++ tests/fm-session-start.test.sh | 72 ++++++++++ tests/lib.sh | 6 + 9 files changed, 367 insertions(+), 8 deletions(-) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 8728b356cc0..398c5bd2b29 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -117,6 +117,17 @@ FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL=16 # both fixes reaches 19, and the pre-fix builds top out at 17. FM_BACKEND_HERDR_MIN_PRESENTATION_PROTOCOL=19 FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION=0.8.0 +# The version floor for the fleet role metadata token (docs/herdr-backend.md +# "Fleet role token"). Custom pane metadata tokens and `herdr pane +# report-metadata --token` first shipped in Herdr 0.7.4. That release shares +# protocol 16 with 0.7.3, so the release core of the version string decides at +# protocol 16, and protocol 17 (Herdr 0.7.5) is the first protocol that implies +# the feature on its own. +FM_BACKEND_HERDR_MIN_ROLE_TOKEN_PROTOCOL=17 +FM_BACKEND_HERDR_MIN_ROLE_TOKEN_VERSION=0.7.4 +# The metadata source id Firstmate reports under, so its tokens never collide +# with an integration hook's own display metadata. +FM_BACKEND_HERDR_METADATA_SOURCE=firstmate # One-warning-per-release dedupe marker prefix, under the state dir. The # projection decision is remade on every spawn, so an undeduplicated # below-floor warning would repeat on every crewmate; the key is the detected @@ -194,25 +205,28 @@ fm_backend_herdr_version_at_least() { # return 0 } -# fm_backend_herdr_release_floor_verdict : the pure -# classifier for the presentation version floor. Return codes: 0 at or above the -# floor, 1 provably below it, 2 indeterminate. +# fm_backend_herdr_release_floor_verdict [ +# ]: the pure classifier for a release floor, by default the +# presentation version floor. Return codes: 0 at or above the floor, 1 provably +# below it, 2 indeterminate. # Two independent signals are read so no single field is load-bearing, and # either one can carry a positive verdict: the protocol number, which is the # structural signal this adapter already uses for every other capability gate, # and the release core of the version string. A signal that is unreadable or # unparseable simply cannot carry a verdict; a readable protocol below the floor # is decisive on its own, and only losing BOTH signals reports indeterminate. -fm_backend_herdr_release_floor_verdict() { # +fm_backend_herdr_release_floor_verdict() { # [ ] local protocol=${1:-} version=${2:-} protocol_known=0 version_status=0 + local min_protocol=${3:-$FM_BACKEND_HERDR_MIN_PRESENTATION_PROTOCOL} + local min_version=${4:-$FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION} case "$protocol" in ''|*[!0-9]*) ;; *) protocol_known=1 - [ "$protocol" -ge "$FM_BACKEND_HERDR_MIN_PRESENTATION_PROTOCOL" ] && return 0 + [ "$protocol" -ge "$min_protocol" ] && return 0 ;; esac - fm_backend_herdr_version_at_least "$version" "$FM_BACKEND_HERDR_MIN_PRESENTATION_VERSION" \ + fm_backend_herdr_version_at_least "$version" "$min_version" \ || version_status=$? [ "$version_status" -eq 0 ] && return 0 { [ "$protocol_known" -eq 1 ] || [ "$version_status" -eq 1 ]; } && return 1 @@ -337,6 +351,91 @@ fm_backend_herdr_presentation_enabled() { # [] fm_backend_herdr_presentation_default_supported "$state_dir" } +# fm_backend_herdr_role_token_supported []: whether the selected +# session can store the fleet role metadata token. The client parses --token +# and a running server stores it, so both must pass the floor; with no running +# server only the client applies. Same return codes as +# fm_backend_herdr_release_floor_verdict. +fm_backend_herdr_role_token_supported() { # [] + local session=${1:-} status protocol version running client_verdict=0 server_verdict=0 + command -v herdr >/dev/null 2>&1 || return 2 + command -v jq >/dev/null 2>&1 || return 2 + [ -n "$session" ] || session=$(fm_backend_herdr_session) + status=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null) || return 2 + protocol=$(printf '%s' "$status" | jq -r '.client.protocol // empty' 2>/dev/null) || return 2 + version=$(printf '%s' "$status" | jq -r '.client.version // empty' 2>/dev/null) || return 2 + fm_backend_herdr_release_floor_verdict "$protocol" "$version" \ + "$FM_BACKEND_HERDR_MIN_ROLE_TOKEN_PROTOCOL" "$FM_BACKEND_HERDR_MIN_ROLE_TOKEN_VERSION" \ + || client_verdict=$? + running=$(printf '%s' "$status" | jq -r '.server.running // empty' 2>/dev/null) || return 2 + [ "$running" = true ] || return "$client_verdict" + protocol=$(printf '%s' "$status" | jq -r '.server.protocol // empty' 2>/dev/null) || return 2 + version=$(printf '%s' "$status" | jq -r '.server.version // empty' 2>/dev/null) || return 2 + fm_backend_herdr_release_floor_verdict "$protocol" "$version" \ + "$FM_BACKEND_HERDR_MIN_ROLE_TOKEN_PROTOCOL" "$FM_BACKEND_HERDR_MIN_ROLE_TOKEN_VERSION" \ + || server_verdict=$? + { [ "$client_verdict" -eq 1 ] || [ "$server_verdict" -eq 1 ]; } && return 1 + { [ "$client_verdict" -eq 0 ] && [ "$server_verdict" -eq 0 ]; } && return 0 + return 2 +} + +# fm_backend_herdr_report_role : report the fleet role +# token for one exact pane (docs/herdr-backend.md "Fleet role token" owns the +# contract). The token is display-only: every caller treats a non-zero return +# as a warning, never as a failed operation. A release below the floor has no +# metadata token surface, so it is skipped silently and returns 0. +fm_backend_herdr_report_role() { # + local session=${1:-} pane=${2:-} role=${3:-} verdict=0 + case "$role" in + firstmate|secondmate|crewmate|scout) ;; + *) + echo "warning: herdr role token not reported: unknown fleet role '$role'" >&2 + return 1 + ;; + esac + if [ -z "$session" ] || [ -z "$pane" ]; then + echo "warning: herdr role token '$role' not reported: no exact herdr session and pane" >&2 + return 1 + fi + fm_backend_herdr_role_token_supported "$session" || verdict=$? + case "$verdict" in + 0) ;; + 1) return 0 ;; + *) + echo "warning: herdr role token '$role' not reported for pane '$pane': the herdr release for session '$session' could not be read" >&2 + return 1 + ;; + esac + if ! fm_backend_herdr_cli "$session" pane report-metadata "$pane" \ + --source "$FM_BACKEND_HERDR_METADATA_SOURCE" --token "role=$role" >/dev/null 2>&1; then + echo "warning: herdr role token '$role' not reported for pane '$pane' in session '$session'; the sidebar cannot show this pane's fleet role" >&2 + return 1 + fi + return 0 +} + +# fm_backend_herdr_report_own_role : report the fleet role token for the +# herdr pane this process runs in. A home carrying the secondmate marker is a +# secondmate, and every other home is the primary firstmate. A process outside +# herdr has no pane and returns 0 with no herdr call. Herdr pane ids restart at +# the same low numbers in every session, so the pane is reported only after its +# injected socket identity proves it belongs to the selected session. +fm_backend_herdr_report_own_role() { # + local home=${1:-} pane=${HERDR_PANE_ID:-} role=firstmate session claimed_socket="" session_socket="" + [ "${HERDR_ENV:-}" = 1 ] && [ -n "$pane" ] || return 0 + if [ -n "$home" ] && { [ -e "$home/$FM_BACKEND_HERDR_SECONDMATE_MARKER" ] || [ -L "$home/$FM_BACKEND_HERDR_SECONDMATE_MARKER" ]; }; then + role=secondmate + fi + session=$(fm_backend_herdr_session) + claimed_socket=$(fm_backend_herdr_canonical_socket_path "${HERDR_SOCKET_PATH:-}") || claimed_socket="" + session_socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || session_socket="" + if [ -z "$claimed_socket" ] || [ "$claimed_socket" != "$session_socket" ]; then + echo "warning: herdr role token '$role' not reported: pane '$pane' could not be proved to belong to herdr session '$session'" >&2 + return 1 + fi + fm_backend_herdr_report_role "$session" "$pane" "$role" +} + # fm_backend_herdr_workspace_label: the per-firstmate-HOME herdr workspace # label (docs/herdr-backend.md "Default task container shape"). The PRIMARY home (no # secondmate marker) resolves to the constant "firstmate", byte-identical to diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 8e38c464ccf..0760649bda4 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -28,8 +28,9 @@ # # 1. lock - acquire the per-home session lock FIRST, before any # mutating step runs. -# 2. bootstrap - home-local stale Herdr projection cleanup runs only -# when this session actually holds the lock. Detect-only +# 2. bootstrap - home-local stale Herdr projection cleanup and the +# report of this session's own Herdr fleet role token +# run only when this session actually holds the lock. Detect-only # diagnostics always run. Bootstrap's six MUTATING sweeps # (same-home backlog reconciliation, # secondmate convergence, secondmate liveness, pending remote @@ -689,6 +690,12 @@ elif [ "$REEMIT" -eq 1 ]; then else BOOT_OUT=$( "$SCRIPT_DIR/fm-herdr-session-cleanup.sh" 2>&1 || true + # Report this session's own fleet role token when it runs in a Herdr pane. + # It is display-only, so a failure prints one warning and never blocks + # startup (docs/herdr-backend.md "Fleet role token"). + if [ "${HERDR_ENV:-}" = 1 ] && [ -n "${HERDR_PANE_ID:-}" ] && fm_backend_source herdr 2>/dev/null; then + fm_backend_herdr_report_own_role "$FM_HOME" 2>&1 || true + fi FM_BOOTSTRAP_NETWORK=skip FM_TASKS_AXI_COMPATIBLE="$TASKS_AXI_COMPATIBLE" \ "$SCRIPT_DIR/fm-bootstrap.sh" 2>&1 ) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 7478f66c5ae..e4239ef9ee8 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -3213,6 +3213,20 @@ if [ -n "$SPAWN_DEFERRED_SIGNAL" ]; then exit "$SPAWN_DEFERRED_SIGNAL_STATUS" fi +# Herdr sidebar rules key on a fleet role token. The token is display-only, so a +# failed report warns and never fails the spawn or relaunch +# (docs/herdr-backend.md "Fleet role token"). The exact pane comes from the task +# record this spawn just published, so a relaunch reports its replacement pane. +if [ "$BACKEND" = herdr ]; then + case "$KIND" in + secondmate) SPAWN_HERDR_ROLE=secondmate ;; + scout) SPAWN_HERDR_ROLE=scout ;; + *) SPAWN_HERDR_ROLE=crewmate ;; + esac + fm_backend_herdr_report_role "$(fm_meta_get "$STATE/$ID.meta" herdr_session)" \ + "$(fm_meta_get "$STATE/$ID.meta" herdr_pane_id)" "$SPAWN_HERDR_ROLE" || true +fi + SPAWN_DELIVERY= [ -z "$MODE" ] || SPAWN_DELIVERY=" mode=$MODE yolo=$YOLO" echo "spawned $ID harness=$HARNESS kind=$KIND$SPAWN_DELIVERY window=$META_WINDOW worktree=$WT" diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 390e8f15172..3b91ac6a924 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -172,6 +172,14 @@ Operational compromises: `tests/fm-herdr-session-cleanup-e2e.test.sh` covers the restored-shell cleanup in a guarded non-default named lab. `tests/fm-backend-herdr-focus-flash-e2e.test.sh` reproduces the raw explicit-close focus steal on the installed release and proves the focus-safe emptying-close plan removes a doomed workspace with no wrong-focus interval; [`verification/runtime-backends.md`](verification/runtime-backends.md#workspace-removal-focus-safety) owns the active versioned evidence. +### Fleet role token + +Firstmate reports a display-only `role` pane metadata token, so Herdr sidebar rules can tell fleet roles apart with `$role`. +The values are `firstmate` for the primary, `secondmate` for a secondmate agent, `crewmate` for a ship worker, and `scout` for a scout. +Every spawn and relaunch reports it for the exact pane in the published task record. +A locked session start reports it for its own pane, and only after that pane's injected socket identity matches the named session. +Reports use metadata source `firstmate` and need Herdr 0.7.4 or newer; an older release is skipped silently, and a failed report warns but never fails the spawn or the session start. + ## Default-tab prune safety `herdr workspace create` seeds one default tab. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 916e913a4ff..ea4b10c7791 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -298,6 +298,7 @@ The CLI matrix was checked directly: | Native state | `herdr agent get ` | Working and done transitions were visible on some harnesses; live Claude Code 2.1.236 on Herdr 0.8.0 kept `agent_status=idle` for an entire landed turn, including a multi-second tool call, so submit confirmation falls through to the shared composer verdict. Native `busy` remains positive activity evidence, while native `idle` cannot close a turn and the adapter's semantic lifecycle decides worker state. | | Restart | guarded named-session stop then start | Workspace, tab, pane, and labels persisted; the agent process and registration did not. | | Close | `herdr pane close --session ` | The exact one-pane task tab closed; closing a final tab could remove the workspace. | +| Fleet role token | `herdr pane report-metadata --source firstmate --token role= --session ` | On 2026-09-14 against Herdr 0.9.0 protocol 22 in a named lab session, `pane get` returned `"tokens":{"role":"crewmate"}`, a second report replaced the value, a `$role` key failed with `invalid_metadata_token`, and an unknown pane failed with `pane_not_found`. | All destructive verification used `bin/fm-herdr-lab.sh` with a non-default `fm-lab-` name and a byte-identical default-session tripwire. No ambient `herdr server stop` command is a supported test operation. diff --git a/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh b/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh index e961550d839..9def0e48e1a 100755 --- a/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +++ b/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh @@ -103,6 +103,10 @@ workspace_of_pane() { # lab pane get "$1" 2>/dev/null | jq -r '.result.pane.workspace_id // empty' 2>/dev/null } +role_of_pane() { # + lab pane get "$1" 2>/dev/null | jq -r '.result.pane.tokens.role // empty' 2>/dev/null +} + label_of_workspace() { # lab workspace list 2>/dev/null \ | jq -r --arg id "$1" '.result.workspaces[]? | select(.workspace_id == $id) | .label' 2>/dev/null @@ -231,6 +235,21 @@ WS_PRIMARY=$(workspace_of_pane "$UNIQA_PANE") [ "$(label_of_workspace "$WS_PRIMARY")" = firstmate ] || fail "uniqA did not land in a 'firstmate' workspace" [ "$(focused_workspace)" = "$WS_OTHER" ] || fail "the spawn stole focus from the captain's workspace" pass "real herdr E2E: with one 'firstmate' workspace and no herdr parent, a crewmate still lands in this home's own workspace without stealing focus" +[ "$(role_of_pane "$UNIQA_PANE")" = crewmate ] || fail "a crewmate spawn did not report role=crewmate for its exact pane" +pass "real herdr E2E: a crewmate spawn reports the crewmate fleet role token for its exact pane" + +# --- 1b. a scout reports the scout fleet role token -------------------------- + +mkdir -p "$PRIMARY_HOME/data/scoutR" +write_ship_brief "$PRIMARY_HOME/data/scoutR/brief.md" scoutR +spawn_from_launcher "" "$PRIMARY_HOME" scoutR "$PROJ" --scout +[ "$SPAWN_RC" -eq 0 ] || fail "a primary scout spawn failed"$'\n'"$(cat "$SPAWN_ERR")" +SCOUTR_META="$PRIMARY_HOME/state/scoutR.meta" +record_worktree "$SCOUTR_META" +SCOUTR_PANE=$(grep '^herdr_pane_id=' "$SCOUTR_META" | cut -d= -f2-) +[ -n "$SCOUTR_PANE" ] || fail "scoutR meta is missing herdr_pane_id" +[ "$(role_of_pane "$SCOUTR_PANE")" = scout ] || fail "a scout spawn did not report role=scout for its exact pane" +pass "real herdr E2E: a scout spawn reports the scout fleet role token for its exact pane" # --- 2. unique label, WITH a launcher pane: same workspace, now by identity -- @@ -409,6 +428,8 @@ SME_WS=$(workspace_of_pane "$SME_PANE") [ "$(tab_labels_of_workspace "$WS_SM_DECOY")" = "$WS_SM_DECOY_TABS_BEFORE" ] \ || fail "the duplicate secondmate-labeled workspace was mutated" pass "real herdr E2E: a secondmate launching its own worker gets the same exact-workspace guarantee, and its same-labeled sibling is untouched" +[ "$(role_of_pane "$SME_PANE")" = crewmate ] || fail "a secondmate's own crewmate did not report role=crewmate for its exact pane" +pass "real herdr E2E: a secondmate's own crewmate reports the crewmate fleet role token" # --- 7. a --secondmate launch is NOT collapsed into the launcher's workspace - @@ -422,6 +443,8 @@ SM2_WS=$(workspace_of_pane "$SM2_PANE") [ "$(label_of_workspace "$SM2_WS")" = "2ndmate-$SM2_ID" ] \ || fail "a --secondmate launch should land in '2ndmate-$SM2_ID', got '$(label_of_workspace "$SM2_WS")'" pass "real herdr E2E: a --secondmate launch still stands up that secondmate's own workspace instead of inheriting the launcher's" +[ "$(role_of_pane "$SM2_PANE")" = secondmate ] || fail "a --secondmate launch did not report role=secondmate for its exact pane" +pass "real herdr E2E: a --secondmate launch reports the secondmate fleet role token for its exact pane" # --- 8. teardown closes only the worker's own pane -------------------------- diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 426d7ceac26..359e2b7558a 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -256,6 +256,131 @@ test_version_check_refuses_missing_herdr() { pass "fm_backend_herdr_version_check: refuses loudly when herdr is not installed" } +# --- fleet role token --------------------------------------------------------- + +HERDR_STATUS_090='{"client":{"version":"0.9.0","protocol":22},"server":{"running":true,"version":"0.9.0","protocol":22}}' + +# role_token_case -> echoes "|||" +role_token_case() { # + local dir="$TMP_ROOT/role-token-$1" + mkdir -p "$dir/responses" + : > "$dir/log" + printf '%s|%s|%s|%s\n' "$dir" "$dir/log" "$dir/responses" "$(make_herdr_fakebin "$dir")" +} + +run_report_role() { # + PATH="$3:$PATH" FM_HERDR_LOG="$1" FM_HERDR_RESPONSES="$2" FM_HERDR_SCRIPT_STATUS=1 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_report_role "$1" "$2" "$3"' "$ROOT" "$4" "$5" "$6" +} + +test_report_role_reports_exact_pane_token() { + local dir log resp fb out status + IFS='|' read -r dir log resp fb <<<"$(role_token_case supported)" + printf '%s\n' "$HERDR_STATUS_090" > "$resp/1.out" + out=$(run_report_role "$log" "$resp" "$fb" fmtest w1:p2 scout 2>&1) + status=$? + expect_code 0 "$status" "report_role should succeed on Herdr 0.9.0: $out" + assert_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''report-metadata'$'\x1f''w1:p2'$'\x1f''--source'$'\x1f''firstmate'$'\x1f''--token'$'\x1f''role=scout'$'\x1f''--session'$'\x1f''fmtest' \ + "report_role did not report role=scout for the exact pane under source firstmate in the named session" + [ -z "$out" ] || fail "a successful role report must stay silent, got: $out" + pass "fm_backend_herdr_report_role: reports the role token for the exact pane under the firstmate source" +} + +test_report_role_version_floor() { + local dir log resp fb out status + IFS='|' read -r dir log resp fb <<<"$(role_token_case floor-074)" + printf '{"client":{"version":"0.7.4","protocol":16},"server":{"running":false}}\n' > "$resp/1.out" + run_report_role "$log" "$resp" "$fb" fmtest w1:p2 crewmate >/dev/null 2>&1 + assert_contains "$(cat "$log")" 'role=crewmate' "Herdr 0.7.4 (protocol 16) carries metadata tokens and must be reported" + + IFS='|' read -r dir log resp fb <<<"$(role_token_case floor-073)" + printf '{"client":{"version":"0.7.3","protocol":16},"server":{"running":false}}\n' > "$resp/1.out" + out=$(run_report_role "$log" "$resp" "$fb" fmtest w1:p2 crewmate 2>&1) + status=$? + expect_code 0 "$status" "a release below the floor must skip without failing" + assert_not_contains "$(cat "$log")" 'report-metadata' "Herdr 0.7.3 has no metadata token surface and must not be sent a report" + [ -z "$out" ] || fail "a below-floor skip must stay silent, got: $out" + + IFS='|' read -r dir log resp fb <<<"$(role_token_case floor-old-server)" + printf '{"client":{"version":"0.9.0","protocol":22},"server":{"running":true,"version":"0.7.3","protocol":16}}\n' > "$resp/1.out" + run_report_role "$log" "$resp" "$fb" fmtest w1:p2 crewmate >/dev/null 2>&1 + assert_not_contains "$(cat "$log")" 'report-metadata' "a running server below the floor must not be sent a report even from a newer client" + pass "fm_backend_herdr_report_role: gates on the 0.7.4 token floor for both the client and a running server" +} + +test_report_role_refusals_warn_without_failing_callers() { + local dir log resp fb out status + IFS='|' read -r dir log resp fb <<<"$(role_token_case unknown-role)" + out=$(run_report_role "$log" "$resp" "$fb" fmtest w1:p2 captain 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "an unknown fleet role must return non-zero" + assert_contains "$out" "unknown fleet role 'captain'" "unknown role warning did not name the role" + [ ! -s "$log" ] || fail "an unknown fleet role must make no herdr call" + + IFS='|' read -r dir log resp fb <<<"$(role_token_case no-pane)" + out=$(run_report_role "$log" "$resp" "$fb" fmtest "" crewmate 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "a missing pane must return non-zero" + assert_contains "$out" "no exact herdr session and pane" "missing pane warning did not explain the gap" + [ ! -s "$log" ] || fail "a missing pane must make no herdr call" + + IFS='|' read -r dir log resp fb <<<"$(role_token_case report-fails)" + printf '%s\n' "$HERDR_STATUS_090" > "$resp/1.out" + printf '1\n' > "$resp/2.exit" + out=$(run_report_role "$log" "$resp" "$fb" fmtest w9:p9 crewmate 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "a failed herdr report must return non-zero" + assert_contains "$out" "warning: herdr role token 'crewmate' not reported for pane 'w9:p9'" "failed report did not warn with the pane" + pass "fm_backend_herdr_report_role: an unknown role, a missing pane, and a failed report each warn and return non-zero" +} + +run_report_own_role() { # [env-args...] + local log=$1 resp=$2 fb=$3 home=$4 socket=$5 + shift 5 + # shellcheck disable=SC2016 # $0 and $1 expand in the child shell. + env "$@" PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 \ + HERDR_SESSION=fmtest HERDR_SOCKET_PATH="$socket" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_report_own_role "$1"' "$ROOT" "$home" +} + +test_report_own_role_primary_and_secondmate() { + local dir log resp fb out status home + IFS='|' read -r dir log resp fb <<<"$(role_token_case own-outside)" + out=$(run_report_own_role "$log" "$resp" "$fb" "$dir" "$dir/herdr.sock" -u HERDR_ENV -u HERDR_PANE_ID 2>&1) + status=$? + expect_code 0 "$status" "a process outside herdr must return 0" + { [ ! -s "$log" ] && [ -z "$out" ]; } || fail "a process outside herdr must make no herdr call and stay silent" + + IFS='|' read -r dir log resp fb <<<"$(role_token_case own-primary)" + home="$dir/home"; mkdir -p "$home" + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"%s"}]}\n' "$dir/herdr.sock" > "$resp/1.out" + printf '%s\n' "$HERDR_STATUS_090" > "$resp/2.out" + out=$(run_report_own_role "$log" "$resp" "$fb" "$home" "$dir/herdr.sock" HERDR_ENV=1 HERDR_PANE_ID=w1:p1 2>&1) + status=$? + expect_code 0 "$status" "primary own-role report should succeed: $out" + assert_contains "$(cat "$log")" $'\x1f''report-metadata'$'\x1f''w1:p1'$'\x1f''--source'$'\x1f''firstmate'$'\x1f''--token'$'\x1f''role=firstmate' \ + "a primary home did not report role=firstmate for its own pane" + + IFS='|' read -r dir log resp fb <<<"$(role_token_case own-secondmate)" + home="$dir/home"; mkdir -p "$home" + printf 'sm1\n' > "$home/.fm-secondmate-home" + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"%s"}]}\n' "$dir/herdr.sock" > "$resp/1.out" + printf '%s\n' "$HERDR_STATUS_090" > "$resp/2.out" + run_report_own_role "$log" "$resp" "$fb" "$home" "$dir/herdr.sock" HERDR_ENV=1 HERDR_PANE_ID=w2:p1 >/dev/null 2>&1 + assert_contains "$(cat "$log")" $'\x1f''w2:p1'$'\x1f''--source'$'\x1f''firstmate'$'\x1f''--token'$'\x1f''role=secondmate' \ + "a secondmate home did not report role=secondmate for its own pane" + + IFS='|' read -r dir log resp fb <<<"$(role_token_case own-foreign-socket)" + home="$dir/home"; mkdir -p "$home" + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"%s"}]}\n' "$dir/other.sock" > "$resp/1.out" + out=$(run_report_own_role "$log" "$resp" "$fb" "$home" "$dir/herdr.sock" HERDR_ENV=1 HERDR_PANE_ID=w1:p1 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "a pane whose socket belongs to another server must return non-zero" + assert_contains "$out" "could not be proved to belong to herdr session 'fmtest'" "foreign socket warning did not name the session" + assert_not_contains "$(cat "$log")" 'report-metadata' "a pane from another server must never be sent a role report" + pass "fm_backend_herdr_report_own_role: primary and secondmate homes report their own pane, outside herdr is a no-op, and a foreign socket is refused" +} + # --- workspace_label: per-firstmate-HOME resolution (P3, herdr-sm-spaces-k4) - test_workspace_label_primary_home_no_marker() { @@ -4658,3 +4783,7 @@ test_wait_transition_stream_absorb_clears_then_timeout test_wait_transition_reader_failure_returns_2 test_wait_transition_bad_ack_returns_2_and_cleans_up test_wait_transition_clean_timeout_returns_1 +test_report_role_reports_exact_pane_token +test_report_role_version_floor +test_report_role_refusals_warn_without_failing_callers +test_report_own_role_primary_and_secondmate diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 75fb3ce00be..b8fafffcc6c 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -1347,6 +1347,77 @@ EOF pass "herdr endpoint liveness is reported per task: alive for a live pane, dead for a gone one" } +# make_fake_herdr_role : a herdr stub that logs every +# call and answers the session-socket and release reads the own-pane role token +# report makes, for a named session "fmtest" served at . +make_fake_herdr_role() { + local fakebin=$1 log=$2 socket=$3 + cat > "$fakebin/herdr" <> "$log" +case "\${1:-} \${2:-}" in + "session list") printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"%s"}]}\n' "$socket" ;; + "status --json") printf '{"client":{"version":"0.9.0","protocol":22},"server":{"running":true,"version":"0.9.0","protocol":22}}\n' ;; +esac +exit 0 +SH + chmod +x "$fakebin/herdr" +} + +# run_session_start_in_herdr_pane [secondmate|locked-out]: a session +# start run from inside herdr pane w1:p1 of session "fmtest". Echoes the herdr +# call log path. +run_session_start_in_herdr_pane() { + local name=$1 variant=${2:-} rec root home fakebin log socket holder_pid="" + rec=$(new_world "$name") + IFS='|' read -r root home fakebin < "$log" + make_fake_herdr_role "$fakebin" "$log" "$socket" + printf 'tmux\n' > "$home/config/backend" + case "$variant" in + secondmate) printf 'sm1\n' > "$home/.fm-secondmate-home" ;; + locked-out) + sleep 300 & + holder_pid=$! + printf '%s\n' "$holder_pid" > "$home/state/.lock" + ;; + esac + HERDR_ENV=1 HERDR_PANE_ID=w1:p1 HERDR_SESSION=fmtest HERDR_SOCKET_PATH="$socket" \ + run_session_start "$home" "$root" "$fakebin:$BASE_PATH" >/dev/null 2>&1 || true + if [ -n "$holder_pid" ]; then + kill "$holder_pid" 2>/dev/null || true + wait "$holder_pid" 2>/dev/null || true + fi + printf '%s\n' "$log" +} + +test_herdr_role_token_reported_for_own_pane() { + local log + if ! PATH="$BASE_PATH" command -v jq >/dev/null 2>&1; then + echo "skip: jq not on the base PATH (required by the herdr adapter)" + return 0 + fi + log=$(run_session_start_in_herdr_pane herdr-role-primary) + assert_contains "$(cat "$log")" "pane report-metadata w1:p1 --source firstmate --token role=firstmate --session fmtest" \ + "a primary session start inside herdr did not report role=firstmate for its own pane" + + log=$(run_session_start_in_herdr_pane herdr-role-secondmate secondmate) + assert_contains "$(cat "$log")" "pane report-metadata w1:p1 --source firstmate --token role=secondmate --session fmtest" \ + "a secondmate session start inside herdr did not report role=secondmate for its own pane" + + log=$(run_session_start_in_herdr_pane herdr-role-locked-out locked-out) + assert_not_contains "$(cat "$log")" "report-metadata" \ + "a lock-refused read-only session start must not report a role token" + + pass "session start reports its own herdr pane's fleet role token: firstmate for the primary, secondmate for a secondmate home, nothing when read-only" +} + # --- composition: real scripts run, not reimplemented ------------------------ test_composition_invokes_real_scripts() { @@ -2573,6 +2644,7 @@ test_status_tail_line_cap test_orphan_status_logs_are_printed test_endpoint_liveness_tmux test_endpoint_liveness_herdr +test_herdr_role_token_reported_for_own_pane test_composition_invokes_real_scripts test_branch_outcome_replay_respects_captain_barrier_and_lease_sweep test_non_pi_session_start_leaves_branch_state_untouched diff --git a/tests/lib.sh b/tests/lib.sh index 12164936914..1a2eba6c6fb 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -35,6 +35,12 @@ FM_TEST_LIB_SOURCED=1 # strips this to verify real refusal. export FM_GATE_REFUSE_BYPASS=1 +# A suite run from inside a Herdr pane inherits that pane's identity, and a +# session start or spawn under test would then report display metadata to the +# developer's own live pane. Drop it so every suite sees the same environment as +# CI; a suite that exercises a Herdr pane sets these itself. +unset HERDR_ENV HERDR_PANE_ID HERDR_TAB_ID HERDR_WORKSPACE_ID HERDR_SOCKET_PATH + # Resolve the repo root from this library's own location. Consumed by sourcing # test files, not by this library, so it reads as "unused" here. # shellcheck disable=SC2034 From 34e9d9c68086819c2bd4dec07cc93b4a37754941 Mon Sep 17 00:00:00 2001 From: Karan Manoharan Date: Mon, 14 Sep 2026 21:12:35 +0100 Subject: [PATCH 2/2] no-mistakes(document): Promote Herdr fleet role token doc section heading --- docs/herdr-backend.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 3b91ac6a924..797f2f8f0d9 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -172,7 +172,7 @@ Operational compromises: `tests/fm-herdr-session-cleanup-e2e.test.sh` covers the restored-shell cleanup in a guarded non-default named lab. `tests/fm-backend-herdr-focus-flash-e2e.test.sh` reproduces the raw explicit-close focus steal on the installed release and proves the focus-safe emptying-close plan removes a doomed workspace with no wrong-focus interval; [`verification/runtime-backends.md`](verification/runtime-backends.md#workspace-removal-focus-safety) owns the active versioned evidence. -### Fleet role token +## Fleet role token Firstmate reports a display-only `role` pane metadata token, so Herdr sidebar rules can tell fleet roles apart with `$role`. The values are `firstmate` for the primary, `secondmate` for a secondmate agent, `crewmate` for a ship worker, and `scout` for a scout.