diff --git a/AGENTS.md b/AGENTS.md index 90f9e796e..e95ca7c5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,12 @@ A silent bootstrap section needs no action; for any printed actionable diagnosti `BOOTSTRAP_INFO:` lines are completed no-action facts and do not require loading a skill. `secondmate-provisioning` owns startup secondmate sync, liveness, and inherited-config convergence. +If the digest's lock step could not acquire the lock, it prints a loud, bordered read-only banner instead of silently continuing: every mutating step was skipped, and the rest of the digest is the read-only-safe subset described above. +The banner distinguishes two causes, because they need different responses. +When another live session genuinely holds the lock (`FM_LOCK_REASON=lock-held`), the banner says another session holds the fleet lock; tell the captain another active session is already managing the work and operate read-only until it resolves. +When this session cannot inspect processes to identify its own harness (`FM_LOCK_REASON=ps-unavailable`, as a Codex sandbox that denies `ps` produces, or `harness-detect-failed`), the banner instead reports that firstmate is unable to verify this session's identity and explicitly does NOT claim another session holds the lock; firstmate still refuses to mutate fleet state, and the banner points at `bin/fm-lock.sh status` plus session/process discovery commands to determine the true state. +In both cases, operate read-only - do not spawn, steer, merge, or otherwise mutate fleet state from this session until it resolves. + ## 4. Harness and runtime dispatch Load `harness-adapters` before every spawn or recovery and before trust handling, skill invocation, interrupt, exit, resume, or adapter verification. diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 33e4b0d27..1652e2a05 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -3,7 +3,18 @@ # Writes the harness (agent) process PID found by walking the shell's ancestry, # which lives as long as the firstmate session - unlike the transient subshell # PID of any one tool call, which is dead moments after it is written. -# Usage: fm-lock.sh acquire; exit 1 if another live session holds it +# Usage: fm-lock.sh acquire; always exits 1 on any failure. On failure +# it also prints a stable machine-readable line +# FM_LOCK_REASON= to stderr, alongside the +# human-readable error. The three reasons are: +# lock-held another live session holds it +# ps-unavailable a ps call failed or was denied +# (e.g. a Codex sandbox), so this +# session cannot identify its harness +# harness-detect-failed ps worked but no harness was +# found while walking the ancestry +# Callers must NOT treat ps-unavailable/harness-detect-failed +# as another session holding the lock. # fm-lock.sh status print holder and liveness; always exits 0 set -u @@ -17,21 +28,44 @@ mkdir -p "$STATE" # Known harness command names; extend when a new adapter is verified. HARNESS_RE='claude|codex|opencode|grok|^pi$' +# harness_pid() reports through globals rather than stdout so its failure +# classification survives: a caller must NOT wrap it in $(...) (a command +# substitution subshell would discard HARNESS_FAIL_REASON). On success it sets +# HARNESS_PID; on failure it sets HARNESS_FAIL_REASON so the caller can tell a +# denied ps (ps-unavailable) apart from a clean ancestry walk that simply found +# no harness (harness-detect-failed). These fail for entirely different reasons +# and must not be conflated with another session holding the lock. +HARNESS_PID="" +HARNESS_FAIL_REASON="" + harness_pid() { - local pid=$$ comm args + local pid=$$ comm args ppid + HARNESS_PID="" + HARNESS_FAIL_REASON="" for _ in 1 2 3 4 5 6 7 8; do - comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 + if ! comm=$(ps -o comm= -p "$pid" 2>/dev/null); then + HARNESS_FAIL_REASON=ps-unavailable + return 1 + fi args=$(ps -o args= -p "$pid" 2>/dev/null) if printf '%s' "$(basename "$comm")" | grep -qE "$HARNESS_RE"; then - echo "$pid"; return 0 + HARNESS_PID=$pid; return 0 fi # Bare interpreter (e.g. node): match the harness name in its script path. case "$comm" in - *node*|*python*) printf '%s' "$args" | grep -qE "$HARNESS_RE" && { echo "$pid"; return 0; } ;; + *node*|*python*) printf '%s' "$args" | grep -qE "$HARNESS_RE" && { HARNESS_PID=$pid; return 0; } ;; esac - pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') - [ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1 + if ! ppid=$(ps -o ppid= -p "$pid" 2>/dev/null); then + HARNESS_FAIL_REASON=ps-unavailable + return 1 + fi + pid=$(printf '%s' "$ppid" | tr -d ' ') + if [ -z "$pid" ] || [ "$pid" -le 1 ]; then + HARNESS_FAIL_REASON=harness-detect-failed + return 1 + fi done + HARNESS_FAIL_REASON=harness-detect-failed return 1 } @@ -49,10 +83,28 @@ if [ "${1:-}" = "status" ]; then exit 0 fi -me=$(harness_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; } +# Call harness_pid directly, never as $(harness_pid): its result travels through +# HARNESS_PID/HARNESS_FAIL_REASON, which a command-substitution subshell would +# strand. +if harness_pid; then + me=$HARNESS_PID +else + case "$HARNESS_FAIL_REASON" in + ps-unavailable) + echo "FM_LOCK_REASON=ps-unavailable" >&2 + echo "error: cannot inspect processes to identify this session's harness (ps failed or was denied)" >&2 + ;; + *) + echo "FM_LOCK_REASON=harness-detect-failed" >&2 + echo "error: cannot locate harness process in ancestry" >&2 + ;; + esac + exit 1 +fi if [ -f "$LOCK" ]; then old=$(cat "$LOCK") if [ "$old" != "$me" ] && holder_alive "$old"; then + echo "FM_LOCK_REASON=lock-held" >&2 echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2 exit 1 fi diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index a229956ac..21893d666 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -242,20 +242,50 @@ section "SESSION START - $FM_HOME" subsection "LOCK" LOCK_OUT=$("$SCRIPT_DIR/fm-lock.sh" 2>&1) LOCK_RC=$? -printf '%s\n' "$LOCK_OUT" +# fm-lock.sh emits a stable machine-readable FM_LOCK_REASON= line to +# stderr on failure. Parse it out for banner branching, and strip it from the +# human-facing echo so only the readable message is shown. +LOCK_REASON=$(printf '%s\n' "$LOCK_OUT" | sed -n 's/^FM_LOCK_REASON=//p' | head -1) +LOCK_MSG=$(printf '%s\n' "$LOCK_OUT" | grep -v '^FM_LOCK_REASON=') +printf '%s\n' "$LOCK_MSG" READ_ONLY=0 if [ "$LOCK_RC" -ne 0 ]; then READ_ONLY=1 BAR='●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' { printf '%s\n' "$BAR" - printf '● READ-ONLY SESSION - ANOTHER LIVE FIRSTMATE SESSION HOLDS THE FLEET LOCK\n' - printf '● %s\n' "$LOCK_OUT" - printf '● Skipping every mutating step: PR-check migration, secondmate sync,\n' - printf '● X-mode artifacts, fleet sync, and wake-queue drain. Detect-only bootstrap\n' - printf '● diagnostics and the rest of this read-only-safe digest still ran below.\n' - printf '● Operate read-only until this resolves - do not spawn, steer, merge, or\n' - printf '● otherwise mutate fleet state from this session.\n' + # Only an explicit lock-held reason produces the "another session" banner. + # ps-unavailable, harness-detect-failed, and any unrecognized/absent reason + # all default to the honest identity-unverifiable banner: firstmate still + # refuses to mutate fleet state (it cannot verify its own session identity), + # but it must NOT falsely claim another session holds the lock. + if [ "$LOCK_REASON" = lock-held ]; then + printf '● READ-ONLY SESSION - ANOTHER LIVE FIRSTMATE SESSION HOLDS THE FLEET LOCK\n' + printf '● %s\n' "$LOCK_MSG" + printf '● Skipping every mutating step: PR-check migration, secondmate sync,\n' + printf '● X-mode artifacts, fleet sync, and wake-queue drain. Detect-only bootstrap diagnostics and\n' + printf '● the rest of this read-only-safe digest still ran below.\n' + printf '● Operate read-only until this resolves - do not spawn, steer, merge, or\n' + printf '● otherwise mutate fleet state from this session.\n' + else + printf "● READ-ONLY SESSION - UNABLE TO VERIFY THIS SESSION'S IDENTITY\n" + printf '● Process inspection failed or was denied, so this session cannot confirm\n' + printf '● which harness it is running under. This is NOT a claim that another\n' + printf '● session holds the fleet lock - run the discovery commands below to find\n' + printf '● the true state.\n' + printf '● %s\n' "$LOCK_MSG" + printf '● Skipping every mutating step: PR-check migration, secondmate sync,\n' + printf '● X-mode artifacts, fleet sync, and wake-queue drain, because firstmate must not mutate\n' + printf '● fleet state when it cannot verify its own session identity. Detect-only\n' + printf '● bootstrap diagnostics and the rest of this read-only-safe digest still\n' + printf '● ran below.\n' + printf '● Determine the true state before acting:\n' + printf '● bin/fm-lock.sh status\n' + printf '● tmux list-sessions\n' + printf '● tmux list-panes -a\n' + printf '● ps -axo pid,ppid,stat,lstart,command\n' + printf '● A Codex sandbox may require approval for process inspection.\n' + fi printf '%s\n' "$BAR" } fi @@ -386,12 +416,23 @@ fi # --- 6. closing reminder ----------------------------------------------- section "NEXT STEP" if [ "$READ_ONLY" -eq 1 ]; then - cat <<'EOF' + if [ "$LOCK_REASON" = lock-held ]; then + cat <<'EOF' This session did not acquire the fleet lock. Stay read-only: do not arm, drain, spawn, steer, merge, or repair fleet state from here. The session holding the lock owns mutable follow-up. EOF + else + cat <<'EOF' +This session could not verify its own identity, so it did not take the fleet +lock. Stay read-only: do not arm, drain, spawn, steer, merge, or repair fleet +state from here. Run bin/fm-lock.sh status and the session/process discovery +commands in the banner above to determine the true state before acting; a +Codex sandbox may require approval for process inspection. + +EOF + fi elif [ "$AFK_PRESENT" -eq 1 ]; then cat <<'EOF' Away mode is active. Follow the supervision operating instructions block above: diff --git a/docs/architecture.md b/docs/architecture.md index 4c47f6152..b0b91638e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -103,7 +103,7 @@ Only a named non-default branch checked out in `FM_ROOT` is a worktree tangle. `fm-tangle-lib.sh` resolves the default branch from `origin/HEAD`, then local `main` or `master`, and classifies that named non-default primary branch as the tangle. `fm-guard.sh` prints the repair command on the next mutable fleet action, while `bin/fm-session-start.sh` reports the same condition through bootstrap as a `TANGLE:` line at session start. -If another live session holds the fleet lock, both surfaces keep the alarm but switch to read-only wording with no repair command. +If this session did not acquire the fleet lock, both surfaces keep the alarm but switch to read-only wording with no repair command. Ship briefs also tell the crewmate to verify `pwd -P` and `git rev-parse --show-toplevel` before creating `fm/`, then stop with a blocked status if it landed in the primary checkout. ## No-mistakes gate authority boundary diff --git a/docs/configuration.md b/docs/configuration.md index 8256048c9..0f6863a9d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -257,6 +257,31 @@ It uses the same live secondmate discovery and propagation helper as bootstrap, 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. +## Session lock and read-only sessions + +Each firstmate home takes a per-home session lock at session start (`bin/fm-lock.sh`), keyed to the harness process it finds by walking its own process ancestry with `ps`. +A session that cannot take the lock stays read-only and skips every mutating step, and `bin/fm-session-start.sh` prints a loud banner explaining why. +The banner distinguishes two very different failures, because they need different responses. + +When another live firstmate session genuinely holds the lock, the banner says another session holds the fleet lock; operate read-only and let that session own mutable follow-up. + +When this session cannot inspect processes to identify its own harness, the banner instead says it is unable to verify this session's identity, and explicitly does not claim another session holds the lock. +This is the case a Codex macOS sandbox produces: `workspace-write` with `on-request` approvals can deny `ps`, so `bin/fm-lock.sh` cannot walk the ancestry and cannot confirm the harness. +A detached background job whose ancestry is not walkable hits the same path. +Firstmate stays read-only here on purpose, because it must not mutate fleet state when it cannot verify its own identity. +To determine the true state, run the diagnostics the banner prints: + +```sh +bin/fm-lock.sh status +tmux list-sessions +tmux list-panes -a +ps -axo pid,ppid,stat,lstart,command +``` + +If `bin/fm-lock.sh status` reports `lock: free` and no other firstmate session is running, the read-only state was caused by denied process inspection, not a real lock holder. +Under a Codex sandbox, granting the approval that lets `ps` run (or launching the harness where its ancestry is inspectable) lets the next session start acquire the lock normally. +`bin/fm-lock.sh` emits a stable `FM_LOCK_REASON=` line to stderr on any acquire failure so this classification is scriptable; every acquire failure still exits 1. + ## X mode (.env) X mode lets a firstmate instance answer public `@myfirstmate` mentions and act on normal reversible mention requests through firstmate's normal lifecycle. diff --git a/docs/scripts.md b/docs/scripts.md index b346d73ff..cbde08106 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -73,7 +73,7 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm | `fm-promote.sh` | Promote a scout task in place to a protected ship task | | `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | -| `fm-lock.sh` | Per-home firstmate session lock | +| `fm-lock.sh` | Per-home firstmate session lock; classifies acquire failures with a stable `FM_LOCK_REASON` line | | `fm-x-lib.sh` | Shared X-mode config, relay, and reply-threading helpers | | `fm-x-poll.sh` | One bounded X relay poll: stash pending mentions, print `x-mention ` | | `fm-x-reply.sh` | Post or dry-run preview a composed X-mode reply or follow-up | diff --git a/tests/fm-lock.test.sh b/tests/fm-lock.test.sh new file mode 100755 index 000000000..af4d131d9 --- /dev/null +++ b/tests/fm-lock.test.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# tests/fm-lock.test.sh - behavior tests for bin/fm-lock.sh's acquire-failure +# classification. fm-lock.sh walks process ancestry with `ps` to find the +# harness PID; an acquire failure can mean three very different things, and a +# caller (fm-session-start.sh) must be able to tell them apart: +# +# lock-held another live session genuinely holds the lock +# ps-unavailable a `ps` invocation failed/was denied (Codex sandbox, +# detached background job) so identity is unknowable +# harness-detect-failed `ps` worked but no harness was found in the ancestry +# +# Every acquire failure must still exit 1 (the caller's LOCK_RC contract), and +# emit a stable FM_LOCK_REASON= line to stderr alongside the readable +# message. This regression-guards issue #306, where a sandboxed ps failure was +# mislabeled as another session holding the lock. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +LOCK="$ROOT/bin/fm-lock.sh" +BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} +TMP_ROOT=$(fm_test_tmproot fm-lock-tests) + +# new_home : a fresh FM_HOME with an empty state/ and a fakebin. Echoes +# "|". +new_home() { + local name=$1 w home fakebin + w="$TMP_ROOT/$name" + home="$w/home" + fakebin="$w/fakebin" + mkdir -p "$home/state" "$fakebin" + printf '%s|%s\n' "$home" "$fakebin" +} + +# make_fake_ps_claude : report EVERY queried pid as a live `claude` +# harness, so the first ancestry check (this process's own pid) matches and +# harness detection succeeds deterministically. +make_fake_ps_claude() { + local fakebin=$1 + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"comm="*) printf '/usr/local/bin/claude\n'; exit 0 ;; + *"args="*) printf 'claude\n'; exit 0 ;; +esac +exit 1 +SH + chmod +x "$fakebin/ps" +} + +# make_fake_ps_no_harness : `ps` works but never reports a harness - +# every process looks like bash, and every parent resolves to init (pid 1), so +# the ancestry walk exhausts without a match. +make_fake_ps_no_harness() { + local fakebin=$1 + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"comm="*) printf '/bin/bash\n'; exit 0 ;; + *"args="*) printf 'bash\n'; exit 0 ;; + *"ppid="*) printf '1\n'; exit 0 ;; +esac +exit 1 +SH + chmod +x "$fakebin/ps" +} + +# make_fake_ps_denied : every `ps` invocation fails, mimicking a Codex +# sandbox that denies process inspection. +make_fake_ps_denied() { + local fakebin=$1 + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +exit 1 +SH + chmod +x "$fakebin/ps" +} + +run_lock() { # [args...] + local home=$1 fakebin=$2 + shift 2 + FM_HOME="$home" PATH="$fakebin:$BASE_PATH" "$LOCK" "$@" +} + +# --- success: a clean acquire still works ------------------------------------ + +test_acquire_success() { + local rec home fakebin out status + rec=$(new_home acquire-success) + IFS='|' read -r home fakebin <&1) || status=$? + expect_code 0 "$status" "a clean acquire must exit 0" + assert_contains "$out" "lock acquired: harness pid" "success path did not report the acquired lock" + assert_not_contains "$out" "FM_LOCK_REASON=" "success path must not emit a failure reason" + [ -f "$home/state/.lock" ] || fail "acquire did not write the lock file" + + pass "a clean acquire writes the lock and exits 0 with no failure reason" +} + +# --- case (i): a real live lock-holder --------------------------------------- + +test_lock_held() { + local rec home fakebin holder_pid out status + rec=$(new_home lock-held) + IFS='|' read -r home fakebin < "$home/state/.lock" + + status=0 + out=$(run_lock "$home" "$fakebin" 2>&1) || status=$? + kill "$holder_pid" 2>/dev/null || true + wait "$holder_pid" 2>/dev/null || true + + expect_code 1 "$status" "a live-held lock must exit 1" + assert_contains "$out" "FM_LOCK_REASON=lock-held" "live-held lock did not emit the lock-held reason token" + assert_contains "$out" "another live firstmate session holds the lock" "live-held lock dropped its readable message" + + pass "a live lock-holder is classified lock-held and exits 1" +} + +# --- case (ii): harness not found in the ancestry (ps works) ----------------- + +test_harness_detect_failed() { + local rec home fakebin out status + rec=$(new_home harness-detect-failed) + IFS='|' read -r home fakebin <&1) || status=$? + expect_code 1 "$status" "a failed harness detection must still exit 1" + assert_contains "$out" "FM_LOCK_REASON=harness-detect-failed" "harness-not-found did not emit the harness-detect-failed reason token" + assert_contains "$out" "cannot locate harness process in ancestry" "harness-detect-failed dropped the existing readable message" + assert_not_contains "$out" "another live firstmate session holds the lock" "harness-detect-failed falsely claimed another session" + [ ! -f "$home/state/.lock" ] || fail "harness-detect-failed wrote a lock it could not verify" + + pass "a clean ancestry walk with no harness is classified harness-detect-failed" +} + +# --- case (iii): ps failed or was denied (Codex sandbox) --------------------- + +test_ps_unavailable() { + local rec home fakebin out status + rec=$(new_home ps-unavailable) + IFS='|' read -r home fakebin <&1) || status=$? + expect_code 1 "$status" "a denied ps must still exit 1" + assert_contains "$out" "FM_LOCK_REASON=ps-unavailable" "denied ps did not emit the ps-unavailable reason token" + assert_contains "$out" "ps failed or was denied" "ps-unavailable dropped its readable message" + assert_not_contains "$out" "another live firstmate session holds the lock" "ps-unavailable falsely claimed another session" + [ ! -f "$home/state/.lock" ] || fail "ps-unavailable wrote a lock it could not verify" + + pass "a denied ps is classified ps-unavailable, not another session holding the lock" +} + +# --- case (iv): status reports lock: free ------------------------------------ + +test_status_free() { + local rec home fakebin out status + rec=$(new_home status-free) + IFS='|' read -r home fakebin < status is unambiguously free, regardless of whether + # process inspection is available. status must never exit non-zero. + status=0 + out=$(run_lock "$home" "$fakebin" status 2>&1) || status=$? + expect_code 0 "$status" "status must always exit 0" + assert_contains "$out" "lock: free" "status did not report a free lock" + assert_not_contains "$out" "FM_LOCK_REASON=" "status must not emit a failure reason" + + pass "status reports lock: free and exits 0 even when process inspection is denied" +} + +test_acquire_success +test_lock_held +test_harness_detect_failed +test_ps_unavailable +test_status_free diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 7d48deba8..fc9d27be4 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -156,6 +156,19 @@ SH printf '%s\n' "$harness" > "$fakebin/.harness-name" } +# make_fake_ps_denied : every `ps` invocation fails, mimicking a Codex +# sandbox that blocks process inspection. fm-lock.sh cannot then identify its +# harness and classifies the failure ps-unavailable (issue #306) - distinct from +# another live session holding the lock. +make_fake_ps_denied() { + local fakebin=$1 + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +exit 1 +SH + chmod +x "$fakebin/ps" +} + make_fake_ps_pi_holder() { local fakebin=$1 holder_pid=$2 cat > "$fakebin/ps" < fm-lock.sh cannot identify its harness -> ps-unavailable, NOT a + # held lock. No .lock file exists, so a false "another session" banner would be + # doubly wrong. A queued wake proves the read-only path still skips the drain. + make_fake_ps_denied "$fakebin" + append_wake "$home/state" signal task-z "needs-decision: pick a library" || fail "seed wake failed" + + status=0 + out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") || status=$? + + expect_code 0 "$status" "fm-session-start.sh must exit 0 even when it cannot verify identity" + # The honest banner: identity unverifiable, and explicitly NOT a lock claim. + assert_contains "$out" "UNABLE TO VERIFY THIS SESSION'S IDENTITY" "identity-unverifiable banner missing" + assert_contains "$out" "NOT a claim that another" "banner did not disclaim the false lock-held reading" + assert_not_contains "$out" "ANOTHER LIVE FIRSTMATE SESSION HOLDS THE FLEET LOCK" "identity-unverifiable path falsely claimed another session holds the lock" + assert_contains "$out" "ps failed or was denied" "banner did not surface fm-lock.sh's ps-unavailable message" + # The recommended diagnostics. + assert_contains "$out" "bin/fm-lock.sh status" "banner did not recommend fm-lock.sh status" + assert_contains "$out" "tmux list-sessions" "banner did not recommend tmux session discovery" + assert_contains "$out" "ps -axo pid,ppid,stat,lstart,command" "banner did not recommend process discovery" + assert_contains "$out" "Codex sandbox may require approval" "banner did not note the Codex sandbox caveat" + # The raw machine-readable token must not leak into the human-facing digest. + assert_not_contains "$out" "FM_LOCK_REASON=" "machine-readable reason token leaked into the digest" + + # Still read-only: every mutating step skipped, exactly like a held lock. + assert_contains "$out" "skipped (read-only session)" "identity-unverifiable path did not skip the wake-queue drain" + assert_not_contains "$out" "$(printf 'signal\ttask-z\tneeds-decision: pick a library')" "identity-unverifiable path drained the wake queue instead of leaving it" + + # Honest next-step guidance, not the "session holding the lock owns follow-up". + assert_contains "$out" "could not verify its own identity" "next step did not explain the identity-unverifiable cause" + assert_contains "$out" "Stay read-only: do not arm" "next step did not block direct watcher repair" + + pass "a denied ps yields an honest identity-unverifiable banner that stays read-only without claiming another session" +} + # --- output ordering ---------------------------------------------------------- test_output_ordering_diagnostics_lead() { @@ -901,6 +957,7 @@ EOF test_context_digest_absent_empty_present test_lock_refusal_read_only_path +test_identity_unverifiable_read_only_path test_output_ordering_diagnostics_lead test_herdr_backend_diagnostics_follow_real_session_start test_status_tail_bounding