diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98cc88a5f68..87ccf38af5f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ We require this to reduce the maintainer's burden of reviewing and merging contr `no-mistakes` puts a local git proxy in front of your real remote. Pushing through it runs an AI-driven review/test/lint pipeline in an isolated worktree, forwards the push upstream only after every check passes, and opens a clean PR automatically. -A GitHub Actions check (`Require no-mistakes`) runs on PRs targeting `main` and fails if the body is missing the deterministic signature that no-mistakes writes. +A GitHub Actions check (`Require no-mistakes`) runs on PRs targeting `main` and fails unless the body contains the deterministic signature and structured pipeline-step attestation that no-mistakes v1.46.0+ writes. It evaluates every PR opening and body edit independently, so a later edit cannot replace an earlier pending compliance check. GitHub Actions and Dependabot are exempt so their automation keeps working, but regular contributor PRs without the signature will not be reviewed or merged. @@ -17,7 +17,7 @@ GitHub Actions and Dependabot are exempt so their automation keeps working, but 1. Fork the repo, then clone the parent repo or set your local `origin` back to the parent (`git@github.com:kunchenguid/firstmate.git`). 2. Create a branch and make your changes. -3. Initialize the gate with your fork as the push target: `no-mistakes init --fork-url git@github.com:/firstmate.git` (firstmate expects **no-mistakes v1.31.2+**; without a fork, plain `no-mistakes init` still works for maintainers with push access). +3. Initialize the gate with your fork as the push target: `no-mistakes init --fork-url git@github.com:/firstmate.git` (firstmate expects **no-mistakes v1.46.0+** so the PR body includes the required structured pipeline-step attestation; without a fork, plain `no-mistakes init` still works for maintainers with push access). 4. Commit your changes. 5. Push through the gate instead of pushing to `origin`: diff --git a/bin/fm-gate-refuse-lib.sh b/bin/fm-gate-refuse-lib.sh index 8e624408a5e..34609b7b446 100644 --- a/bin/fm-gate-refuse-lib.sh +++ b/bin/fm-gate-refuse-lib.sh @@ -13,7 +13,7 @@ # instructions and stamps NO_MISTAKES_GATE into the gate agent's environment). # THIS is the firstmate capability-removal half: an enforceable script refusal, # not a prose rule the neutralized agent would never read. It is sourced at the -# top of the three fleet-lifecycle entrypoints and called before any fleet +# top of every guarded fleet-mutation entrypoint and called before any fleet # mutation, so a gate agent that still reaches for the fleet is stopped cold. # # Two independent signals, either of which refuses (fail closed): @@ -54,7 +54,8 @@ # tests/fm-gate-refuse.test.sh strips the bypass so it still verifies real refusal. # # Sourced by bin/fm-spawn.sh, bin/fm-send.sh, bin/fm-teardown.sh, -# bin/fm-sessionstart-nudge.sh, and the tests. +# bin/fm-repair-legacy-endpoint-binding.sh, bin/fm-sessionstart-nudge.sh, and +# the tests. # No side effects on source. set -u / set -e safe. The refusal is a hard exit, # not a return, because there is no safe way to continue a fleet mutation from a # gate context. diff --git a/bin/fm-repair-legacy-endpoint-binding.sh b/bin/fm-repair-legacy-endpoint-binding.sh new file mode 100755 index 00000000000..3f7080278ac --- /dev/null +++ b/bin/fm-repair-legacy-endpoint-binding.sh @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +# Add the missing endpoint_task_id binding to one pre-binding Herdr task record, +# but only after a positive, fail-closed identity proof. This command repairs +# metadata only: it never starts, stops, focuses, sends to, or closes Herdr, and +# it never retires the task. A later fm-teardown.sh invocation still owns every +# landed-work, captain-hold, report, and destructive-cleanup gate. +# +# Eligibility and proof: +# * FM_HOME must be explicitly set; must name one regular meta file. +# * An existing exact binding is an idempotent no-op after the ordinary +# endpoint validator accepts it. Empty, wrong, or duplicate bindings refuse. +# * Only backend=herdr is recoverable. Every structured Herdr endpoint field +# must be singular, internally consistent, and safe as an endpoint atom. +# * The recorded worktree and project must be physical paths. The worktree +# must be a distinct linked Git worktree registered exactly once by that +# project, and both must resolve to the same Git common directory. +# * No other regular state/*.meta may claim the same endpoint or canonical +# worktree. Unsafe metadata inventory refuses instead of being skipped. +# * Two direct, read-only `herdr pane get ` responses must each +# repeat the recorded pane, tab, and workspace ids and report a live +# foreground_cwd that resolves exactly to the recorded worktree. +# +# Labels, a shared workspace, the stored pane id by itself, pane creation cwd, +# and ambient Herdr state are never identity evidence. Metadata is published by +# an atomic rename only when it is unchanged since proof began and the staged +# record passes fm_backend_validate_task_endpoint. +# +# Usage: FM_HOME=/path/to/firstmate-home fm-repair-legacy-endpoint-binding.sh +set -eu + +SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +# The gate-refusal anchor is this command's checkout, never an environment +# override. A gate agent can alter FM_ROOT_OVERRIDE, but it cannot redirect the +# physical script path without ceasing to run this checked-out command. +SCRIPT_ROOT=$(CDPATH='' cd -- "$SCRIPT_DIR/.." && pwd -P) + +die_usage() { + echo "usage: FM_HOME=/path/to/firstmate-home $0 " >&2 + exit 2 +} + +refuse() { + echo "REFUSED: $*; preserving task state." >&2 + exit 1 +} + +[ "${FM_HOME+x}" = x ] && [ -n "$FM_HOME" ] || die_usage +[ "$#" -eq 1 ] || die_usage +ID=$1 +case "$ID" in + ''|*[!A-Za-z0-9._-]*) die_usage ;; +esac + +HOME_REAL=$(CDPATH='' cd -- "$FM_HOME" 2>/dev/null && pwd -P) \ + || refuse "FM_HOME is not an existing physical directory" +[ "$FM_HOME" = "$HOME_REAL" ] \ + || refuse "FM_HOME must be its physical path, not an alias" +[ -d "$FM_HOME/state" ] && [ ! -L "$FM_HOME/state" ] \ + || refuse "FM_HOME has no regular state directory" +STATE="$FM_HOME/state" +META="$STATE/$ID.meta" +CONTROL_LOCK="$STATE/.control-$ID.lock" +TASK_SET_LOCK= +TASK_SET_LOCK_HELD=0 +CONTROL_LOCK_HELD=0 +META_LOCK= +META_LOCK_HELD=0 +SNAPSHOT= + +# shellcheck source=bin/fm-backend.sh +. "$SCRIPT_DIR/fm-backend.sh" +# shellcheck source=bin/fm-gate-refuse-lib.sh +. "$SCRIPT_DIR/fm-gate-refuse-lib.sh" +fm_refuse_if_gate_agent "$SCRIPT_ROOT" +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" + +cleanup() { + local status=$? + [ -z "$SNAPSHOT" ] || rm -f -- "$SNAPSHOT" 2>/dev/null || true + if [ "$META_LOCK_HELD" = 1 ]; then + fm_lock_release "$META_LOCK" || true + META_LOCK_HELD=0 + fi + if [ "$CONTROL_LOCK_HELD" = 1 ]; then + fm_lock_release "$CONTROL_LOCK" || true + CONTROL_LOCK_HELD=0 + fi + if [ "$TASK_SET_LOCK_HELD" = 1 ]; then + fm_lock_release "$TASK_SET_LOCK" || true + TASK_SET_LOCK_HELD=0 + fi + return "$status" +} +trap cleanup EXIT + +TASK_SET_LOCK=$(fm_task_set_lock_path "$STATE") \ + || refuse "task $ID has an unsafe task-inventory lock identity" +fm_lock_try_acquire "$TASK_SET_LOCK" \ + || refuse "this home's task inventory is changing during legacy binding recovery" +TASK_SET_LOCK_HELD=1 +fm_lock_try_acquire "$CONTROL_LOCK" || \ + refuse "another lifecycle action is already running for task $ID" +CONTROL_LOCK_HELD=1 + +[ -f "$META" ] && [ ! -L "$META" ] \ + || refuse "task $ID has no regular endpoint metadata at $META" +META_LOCK=$(fm_meta_lock_path "$META") || refuse "task $ID has an unsafe metadata lock identity" +fm_lock_acquire_wait "$META_LOCK" +META_LOCK_HELD=1 +[ -f "$META" ] && [ ! -L "$META" ] \ + || refuse "task $ID endpoint metadata changed before it could be locked" + +file_link_count() { # + if [ "$(uname)" = Darwin ]; then + stat -f %l "$1" 2>/dev/null + else + stat -c %h "$1" 2>/dev/null + fi +} + +[ "$(file_link_count "$META" 2>/dev/null || true)" = 1 ] \ + || refuse "task $ID endpoint metadata is hardlinked or unreadable" + +binding_count=$(grep -c '^endpoint_task_id=' "$META" 2>/dev/null || true) +case "$binding_count" in + 1) + binding=$(fm_backend_meta_exact_value "$META" endpoint_task_id 2>/dev/null || true) + [ "$binding" = "$ID" ] \ + || refuse "task $ID has an empty or conflicting endpoint task binding" + fm_backend_validate_task_endpoint "$META" "$ID" \ + || refuse "task $ID already has a binding but its endpoint metadata is invalid" + printf 'endpoint binding already valid for task %s; nothing changed\n' "$ID" + exit 0 + ;; + 0) ;; + *) refuse "task $ID has an ambiguous endpoint task binding" ;; +esac + +backend=$(fm_backend_meta_exact_value "$META" backend 2>/dev/null || true) +[ "$backend" = herdr ] \ + || refuse "task $ID is not singular legacy Herdr endpoint metadata" + +window=$(fm_backend_meta_exact_value "$META" window 2>/dev/null || true) +worktree=$(fm_backend_meta_exact_value "$META" worktree 2>/dev/null || true) +project=$(fm_backend_meta_exact_value "$META" project 2>/dev/null || true) +session=$(fm_backend_meta_exact_value "$META" herdr_session 2>/dev/null || true) +workspace=$(fm_backend_meta_exact_value "$META" herdr_workspace_id 2>/dev/null || true) +tab=$(fm_backend_meta_exact_value "$META" herdr_tab_id 2>/dev/null || true) +pane=$(fm_backend_meta_exact_value "$META" herdr_pane_id 2>/dev/null || true) +[ -n "$window" ] && [ -n "$worktree" ] && [ -n "$project" ] \ + && [ -n "$session" ] && [ -n "$workspace" ] && [ -n "$tab" ] && [ -n "$pane" ] \ + || refuse "task $ID has missing, empty, or ambiguous legacy Herdr identity fields" +case "$window$worktree$project$session$workspace$tab$pane" in + *$'\n'*|*$'\r'*|*$'\t'*) refuse "task $ID has control characters in legacy Herdr identity fields" ;; +esac +if ! fm_backend_endpoint_atom_valid "$session" \ + || ! fm_backend_endpoint_atom_valid "$workspace" \ + || ! fm_backend_endpoint_atom_valid "${tab//:/_}" \ + || ! fm_backend_endpoint_atom_valid "${pane//:/_}"; then + refuse "task $ID has malformed legacy Herdr endpoint atoms" +fi +[ "$window" = "$session:$pane" ] \ + || refuse "task $ID window and Herdr pane identities disagree" +case "$tab" in "$workspace":*) ;; *) refuse "task $ID tab and workspace identities disagree" ;; esac +case "$pane" in "$workspace":*) ;; *) refuse "task $ID pane and workspace identities disagree" ;; esac + +canonical_dir() { # + [ -d "$1" ] && [ ! -L "$1" ] || return 1 + CDPATH='' cd -- "$1" 2>/dev/null && pwd -P +} + +canonical_git_common_dir() { # + local root=$1 common + common=$(git -C "$root" rev-parse --git-common-dir 2>/dev/null) || return 1 + case "$common" in + /*) ;; + *) common="$root/$common" ;; + esac + canonical_dir "$common" +} + +worktree_real=$(canonical_dir "$worktree") \ + || refuse "task $ID worktree is not a physical directory" +project_real=$(canonical_dir "$project") \ + || refuse "task $ID project is not a physical directory" +[ "$worktree" = "$worktree_real" ] && [ "$project" = "$project_real" ] \ + || refuse "task $ID worktree or project uses a non-physical alias" +[ "$worktree_real" != "$project_real" ] \ + || refuse "task $ID worktree is not distinct from its project checkout" + +worktree_top=$(git -C "$worktree_real" rev-parse --show-toplevel 2>/dev/null || true) +project_top=$(git -C "$project_real" rev-parse --show-toplevel 2>/dev/null || true) +[ -n "$worktree_top" ] && [ -n "$project_top" ] \ + || refuse "task $ID worktree or project is not an inspectable Git checkout" +worktree_top=$(canonical_dir "$worktree_top" 2>/dev/null || true) +project_top=$(canonical_dir "$project_top" 2>/dev/null || true) +[ "$worktree_top" = "$worktree_real" ] && [ "$project_top" = "$project_real" ] \ + || refuse "task $ID worktree or project does not name its exact Git top level" +worktree_common=$(canonical_git_common_dir "$worktree_real" 2>/dev/null || true) +project_common=$(canonical_git_common_dir "$project_real" 2>/dev/null || true) +[ -n "$worktree_common" ] && [ "$worktree_common" = "$project_common" ] \ + || refuse "task $ID worktree is not linked to the recorded project" + +registered_count=0 +listed=$(git -C "$project_real" -c core.quotePath=false worktree list --porcelain 2>/dev/null) \ + || refuse "task $ID project worktree inventory is unreadable" +while IFS= read -r line; do + case "$line" in + worktree\ *) + listed_path=${line#worktree } + listed_real=$(canonical_dir "$listed_path" 2>/dev/null || true) + [ "$listed_real" != "$worktree_real" ] || registered_count=$((registered_count + 1)) + ;; + esac +done </dev/null || true)" = 1 ] \ + || refuse "task metadata inventory contains a hardlinked or unreadable record at $other" + other_session_match=0 + other_session_count=0 + other_pane_match=0 + while IFS= read -r line; do + case "$line" in + window=*) + [ "${line#window=}" != "$window" ] \ + || refuse "another task record claims endpoint $window" + ;; + worktree=*) + claimed=${line#worktree=} + [ "$claimed" != "$worktree" ] \ + || refuse "another task record claims worktree $worktree" + claimed_real=$(canonical_dir "$claimed" 2>/dev/null || true) + [ -z "$claimed_real" ] || [ "$claimed_real" != "$worktree_real" ] \ + || refuse "another task record canonically claims worktree $worktree" + ;; + herdr_session=*) + other_session_count=$((other_session_count + 1)) + [ "${line#herdr_session=}" != "$session" ] || other_session_match=1 + ;; + herdr_pane_id=*) + [ "${line#herdr_pane_id=}" != "$pane" ] || other_pane_match=1 + ;; + esac + done < "$other" + if [ "$other_pane_match" = 1 ]; then + [ "$other_session_count" -eq 1 ] \ + || refuse "another task record ambiguously claims Herdr pane $pane" + [ "$other_session_match" -ne 1 ] \ + || refuse "another task record claims Herdr pane $session:$pane" + fi +done + +last_byte=$(tail -c 1 "$META" 2>/dev/null | od -An -t x1 | tr -d '[:space:]') +[ "$last_byte" = 0a ] || refuse "task $ID metadata lacks a safe terminating newline" +umask 077 +SNAPSHOT=$(mktemp "$STATE/.$ID.meta.binding.XXXXXX") \ + || refuse "could not stage a private metadata snapshot for task $ID" +cp -p -- "$META" "$SNAPSHOT" \ + || refuse "could not snapshot task $ID metadata" + +fm_backend_source herdr >/dev/null 2>&1 \ + || refuse "the Herdr backend adapter is unavailable" +fm_backend_herdr_tool_check \ + || refuse "the Herdr read-only identity tools are unavailable" + +read_live_worktree() { + local out cwd cwd_real + out=$(fm_backend_herdr_cli "$session" pane get "$pane" 2>/dev/null) || return 1 + cwd=$(printf '%s' "$out" | jq -er \ + --arg workspace "$workspace" --arg tab "$tab" --arg pane "$pane" ' + .result.pane as $p + | select(($p | type) == "object") + | select($p.pane_id == $pane) + | select($p.tab_id == $tab) + | select($p.workspace_id == $workspace) + | $p.foreground_cwd + | select(type == "string" and length > 0) + ' 2>/dev/null) || return 1 + case "$cwd" in *$'\n'*|*$'\r'*|*$'\t'*) return 1 ;; esac + cwd_real=$(canonical_dir "$cwd" 2>/dev/null) || return 1 + [ "$cwd" = "$cwd_real" ] || return 1 + printf '%s' "$cwd_real" +} + +live_one=$(read_live_worktree) \ + || refuse "the exact Herdr pane did not prove its recorded pane, tab, workspace, and live foreground path" +[ "$live_one" = "$worktree_real" ] \ + || refuse "the exact Herdr pane foreground path does not match the recorded worktree" +live_two=$(read_live_worktree) \ + || refuse "the exact Herdr pane identity was not stable across two read-only samples" +[ "$live_two" = "$worktree_real" ] && [ "$live_two" = "$live_one" ] \ + || refuse "the exact Herdr pane foreground path changed during identity proof" + +cmp -s "$SNAPSHOT" "$META" \ + || refuse "task $ID metadata changed during identity proof" +printf 'endpoint_task_id=%s\n' "$ID" >> "$SNAPSHOT" +fm_backend_validate_task_endpoint "$SNAPSHOT" "$ID" \ + || refuse "the staged endpoint binding failed the ordinary teardown validator" +cmp -s <(sed '$d' "$SNAPSHOT") "$META" 2>/dev/null \ + || refuse "task $ID metadata changed before binding publication" +mv -f -- "$SNAPSHOT" "$META" \ + || refuse "could not publish the verified endpoint binding for task $ID" +SNAPSHOT= +printf 'repaired endpoint task binding for %s; no lifecycle action was performed\n' "$ID" diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 3a9e1d7ae9f..5183e5b2840 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -205,7 +205,7 @@ family_for_basename() { fm-herdr-session-cleanup.test.sh|fm-send-resolve-key.test.sh|fm-send-strict.test.sh|fm-spawn-batch.test.sh|\ fm-spawn-dispatch-profile.test.sh|\ fm-trace-context-spawn.test.sh|fm-spawn-worktree-settle.test.sh|\ - fm-teardown-endpoint-safety.test.sh) + fm-repair-legacy-endpoint-binding.test.sh|fm-teardown-endpoint-safety.test.sh) printf '%s\n' backend-dispatch ;; fm-pr-check-security.test.sh|fm-pr-merge.test.sh|fm-review-diff.test.sh|\ @@ -933,6 +933,9 @@ families_for_changed_path() { printf '%s\n' backend-dispatch printf '%s\n' real-herdr-gated ;; + bin/fm-repair-legacy-endpoint-binding.sh) + printf '%s\n' backend-dispatch + ;; bin/fm-watch*|bin/fm-wake*|bin/fm-inactive-reconcile.sh|\ bin/fm-classify-lib.sh|bin/fm-daemon*|bin/fm-turnend-guard*|bin/fm-guard.sh) printf '%s\n' watcher-wake-lock diff --git a/docs/configuration.md b/docs/configuration.md index c9d0d293a52..1ae24f95522 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -82,6 +82,9 @@ These five sentences are the single owner of the task-selector vocabulary; backe `fm-teardown.sh ` takes a task id directly and validates the complete metadata-only endpoint identity before any runtime dispatch or cleanup mutation. Missing, empty, duplicate, malformed, backend-inconsistent, or task-mismatched endpoint records are preserved and refused. Legacy tmux metadata remains cleanup-compatible when its exact window name is `fm-`; opaque non-tmux endpoints require their recorded `endpoint_task_id=` binding. +For a pre-binding Herdr record, `FM_HOME=/path/to/home bin/fm-repair-legacy-endpoint-binding.sh ` with an explicit physical home path is the only supported binding-recovery path. +It adds only the missing `endpoint_task_id=` after an exact structured pane response, two matching live foreground-worktree reads, an exclusive metadata claim, and proof that the physical linked worktree belongs to the recorded project; a label, shared workspace, or stored pane id alone never authorizes repair. +The command performs no Herdr lifecycle action and no retirement, and `fm-teardown.sh` still applies every ordinary preservation and completion gate afterward. `FM_HOME` determines Herdr's home label: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-`. [`herdr-backend.md`](herdr-backend.md#watching-and-task-containers) owns launcher-bound workspace placement, the label-only fallback, collision handling, and recovery behavior. The local `config/herdr-presentation-spaces` file instead opts a home out of, or explicitly in to, Herdr's default-on disposable single-task visual projection; [Presentation spaces](herdr-backend.md#presentation-spaces) owns its accepted values, default, Herdr version floor, migration, behavior, safety limits, recovery contract, and narrow locked session-start cleanup of exact restored idle-shell children. diff --git a/docs/scripts.md b/docs/scripts.md index 3359c32e6c8..a0e6d94aa87 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -111,6 +111,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-pr-merge.sh` | Record PR metadata, then merge a task's canonical full GitHub or GitLab URL | | `fm-promote.sh` | Promote a scout task in place to a protected ship task with an explicit delivery mode | | `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | +| `fm-repair-legacy-endpoint-binding.sh` | Repair one positively proven legacy Herdr endpoint binding without lifecycle action | | `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-x-lib.sh` | Shared Relay config, relay, and reply-threading helpers | diff --git a/tests/fm-repair-legacy-endpoint-binding.test.sh b/tests/fm-repair-legacy-endpoint-binding.test.sh new file mode 100755 index 00000000000..77c2a085b12 --- /dev/null +++ b/tests/fm-repair-legacy-endpoint-binding.test.sh @@ -0,0 +1,432 @@ +#!/usr/bin/env bash +# Regression tests for the fail-closed legacy Herdr endpoint-binding repair. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +REPAIR="$ROOT/bin/fm-repair-legacy-endpoint-binding.sh" +TMP_ROOT=$(fm_test_tmproot fm-repair-legacy-endpoint-binding) + +make_case() { # [task-id] + local id=${2:-legacy-task} dir="$TMP_ROOT/$1" + mkdir -p "$dir" + dir=$(cd "$dir" && pwd -P) + mkdir -p "$dir/home/state" "$dir/home/data" "$dir/home/config" "$dir/fakebin" + fm_git_worktree "$dir/project" "$dir/worktree" "fm/$id" + mkdir -p "$dir/home/data/$id" + printf 'durable report\n' > "$dir/home/data/$id/report.md" + printf 'needs-decision: [key=preserve-me] captain-held answer\n' > "$dir/home/state/$id.status" + : > "$dir/herdr.log" + cat > "$dir/fakebin/herdr" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FM_HERDR_LOG:?}" +if [ "${FM_HERDR_MUTATE_META_ON_CALL:-0}" = 1 ]; then + calls=$(wc -l < "${FM_HERDR_LOG:?}" | tr -d ' ') + if [ "$calls" = 2 ]; then + printf 'concurrent=keep\n' >> "${FM_HERDR_META:?}" + fi +fi +calls=$(wc -l < "${FM_HERDR_LOG:?}" | tr -d ' ') +if [ "$calls" = 2 ] && [ -n "${FM_HERDR_RESPONSE_2:-}" ]; then + printf '%s\n' "$FM_HERDR_RESPONSE_2" +else + printf '%s\n' "${FM_HERDR_RESPONSE:?}" +fi +SH + chmod +x "$dir/fakebin/herdr" + fm_write_meta "$dir/home/state/$id.meta" \ + "window=lab:w1:p2" \ + "worktree=$dir/worktree" \ + "project=$dir/project" \ + "harness=claude" \ + "kind=ship" \ + "backend=herdr" \ + "herdr_session=lab" \ + "herdr_workspace_id=w1" \ + "herdr_tab_id=w1:t2" \ + "herdr_pane_id=w1:p2" + printf '%s\n' "$dir" +} + +pane_response() { # [workspace] [tab] [pane] + jq -cn \ + --arg cwd "$1" \ + --arg workspace "${2:-w1}" \ + --arg tab "${3:-w1:t2}" \ + --arg pane "${4:-w1:p2}" \ + '{result:{pane:{pane_id:$pane,tab_id:$tab,workspace_id:$workspace,foreground_cwd:$cwd}}}' +} + +run_repair() { # [task-id] + local dir=$1 id=${2:-legacy-task} + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" \ + FM_HERDR_LOG="$dir/herdr.log" \ + FM_HERDR_META="$dir/home/state/$id.meta" \ + FM_HERDR_RESPONSE="${FM_HERDR_RESPONSE:?}" \ + FM_HERDR_RESPONSE_2="${FM_HERDR_RESPONSE_2:-}" \ + FM_HERDR_MUTATE_META_ON_CALL="${FM_HERDR_MUTATE_META_ON_CALL:-0}" \ + PATH="$dir/fakebin:$PATH" \ + "$REPAIR" "$id" +} + +assert_refused_unchanged() { # [task-id] + local dir=$1 description=$2 id=${3:-legacy-task} before rc + before=$(cat "$dir/home/state/$id.meta") + set +e + run_repair "$dir" "$id" > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "$description: repair unexpectedly succeeded" + [ "$(cat "$dir/home/state/$id.meta")" = "$before" ] \ + || fail "$description: metadata changed despite refusal" + case "$before" in + *endpoint_task_id=*) : ;; + *) + assert_no_grep "endpoint_task_id=" "$dir/home/state/$id.meta" \ + "$description: refusal published a task binding" + ;; + esac +} + +test_legacy_record_reproduces_the_metadata_only_refusal() { + local dir rc + dir=$(make_case initial-refusal) + set +e + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" \ + bash -c '. "$1/bin/fm-backend.sh"; fm_backend_validate_task_endpoint "$2" legacy-task' \ + _ "$ROOT" "$dir/home/state/legacy-task.meta" \ + > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "legacy metadata without an exact binding unexpectedly validated" + assert_contains "$(cat "$dir/stderr")" \ + "legacy Herdr endpoint metadata for task legacy-task lacks an exact task binding" \ + "legacy metadata should reproduce the teardown validator's safe refusal" + [ ! -s "$dir/herdr.log" ] || fail "metadata-only refusal queried Herdr" + pass "legacy endpoint repair: the original metadata-only teardown refusal is reproducible without runtime access" +} + +test_exact_live_identity_repairs_only_the_binding() { + local dir before expected calls head branch report status + dir=$(make_case exact) + before=$(cat "$dir/home/state/legacy-task.meta") + head=$(git -C "$dir/worktree" rev-parse HEAD) + branch=$(git -C "$dir/worktree" symbolic-ref --short HEAD) + report=$(cat "$dir/home/data/legacy-task/report.md") + status=$(cat "$dir/home/state/legacy-task.status") + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree") + export FM_HERDR_RESPONSE + + run_repair "$dir" > "$dir/stdout" 2> "$dir/stderr" \ + || fail "exact live Herdr and git identity should repair the missing binding: $(cat "$dir/stderr")" + + expected="$before +endpoint_task_id=legacy-task" + [ "$(cat "$dir/home/state/legacy-task.meta")" = "$expected" ] \ + || fail "successful repair changed more than the missing endpoint binding" + calls=$(cat "$dir/herdr.log") + [ "$calls" = $'pane get w1:p2 --session lab\npane get w1:p2 --session lab' ] \ + || fail "repair must make exactly two read-only exact-pane calls, got: $calls" + [ "$(git -C "$dir/worktree" rev-parse HEAD)" = "$head" ] \ + && [ "$(git -C "$dir/worktree" symbolic-ref --short HEAD)" = "$branch" ] \ + && [ -z "$(git -C "$dir/worktree" status --porcelain)" ] \ + || fail "successful binding repair changed the branch, commit, or worktree" + [ "$(cat "$dir/home/data/legacy-task/report.md")" = "$report" ] \ + && [ "$(cat "$dir/home/state/legacy-task.status")" = "$status" ] \ + || fail "successful binding repair changed a report or captain-held status record" + + # shellcheck source=/dev/null + . "$ROOT/bin/fm-backend.sh" + fm_backend_validate_task_endpoint "$dir/home/state/legacy-task.meta" legacy-task \ + || fail "repaired metadata should pass the teardown endpoint validator" + pass "legacy endpoint repair: exact live endpoint and registered worktree proof append only the missing binding" +} + +test_existing_binding_is_idempotent_without_runtime_access() { + local dir before + dir=$(make_case idempotent) + printf 'endpoint_task_id=legacy-task\n' >> "$dir/home/state/legacy-task.meta" + before=$(cat "$dir/home/state/legacy-task.meta") + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree") + export FM_HERDR_RESPONSE + + run_repair "$dir" > "$dir/stdout" 2> "$dir/stderr" \ + || fail "an already-valid exact binding should be idempotent" + [ "$(cat "$dir/home/state/legacy-task.meta")" = "$before" ] \ + || fail "idempotent repair rewrote valid metadata" + [ ! -s "$dir/herdr.log" ] || fail "idempotent repair should not query Herdr" + pass "legacy endpoint repair: an existing exact binding is a no-op" +} + +test_ambiguous_or_wrong_existing_bindings_refuse_without_runtime_access() { + local dir + dir=$(make_case wrong-binding) + printf 'endpoint_task_id=other-task\n' >> "$dir/home/state/legacy-task.meta" + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree") + export FM_HERDR_RESPONSE + assert_refused_unchanged "$dir" "wrong existing binding" + [ ! -s "$dir/herdr.log" ] || fail "wrong existing binding reached Herdr" + + dir=$(make_case duplicate-binding) + printf 'endpoint_task_id=legacy-task\nendpoint_task_id=legacy-task\n' >> "$dir/home/state/legacy-task.meta" + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree") + export FM_HERDR_RESPONSE + before=$(cat "$dir/home/state/legacy-task.meta") + set +e + run_repair "$dir" > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "duplicate existing binding unexpectedly succeeded" + [ "$(cat "$dir/home/state/legacy-task.meta")" = "$before" ] \ + || fail "duplicate existing binding was rewritten" + [ ! -s "$dir/herdr.log" ] || fail "duplicate existing binding reached Herdr" + pass "legacy endpoint repair: wrong and ambiguous bindings fail closed before runtime access" +} + +test_live_identity_mismatch_refuses() { + local dir + dir=$(make_case live-cwd-mismatch) + FM_HERDR_RESPONSE=$(pane_response "$dir/project") + export FM_HERDR_RESPONSE + assert_refused_unchanged "$dir" "live foreground cwd mismatch" + + dir=$(make_case live-pane-mismatch) + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree" w1 w1:t2 w1:p9) + export FM_HERDR_RESPONSE + assert_refused_unchanged "$dir" "live pane identity mismatch" + + dir=$(make_case live-tab-mismatch) + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree" w1 w1:t9 w1:p2) + export FM_HERDR_RESPONSE + assert_refused_unchanged "$dir" "live tab identity mismatch" + + dir=$(make_case live-workspace-mismatch) + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree" w9 w1:t2 w1:p2) + export FM_HERDR_RESPONSE + assert_refused_unchanged "$dir" "live workspace identity mismatch" + + dir=$(make_case unstable-live-path) + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree") + FM_HERDR_RESPONSE_2=$(pane_response "$dir/project") + export FM_HERDR_RESPONSE FM_HERDR_RESPONSE_2 + assert_refused_unchanged "$dir" "live identity changed between samples" + unset FM_HERDR_RESPONSE_2 + pass "legacy endpoint repair: labels, shared workspaces, and stale pane ids cannot substitute for exact live identity" +} + +test_other_metadata_claims_refuse_before_runtime_access() { + local dir other + dir=$(make_case shared-worktree) + other="$dir/home/state/other-task.meta" + fm_write_meta "$other" \ + "window=lab:w9:p9" "worktree=$dir/worktree" "project=$dir/project" \ + "backend=herdr" "herdr_session=lab" "herdr_workspace_id=w9" \ + "herdr_tab_id=w9:t9" "herdr_pane_id=w9:p9" + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree") + export FM_HERDR_RESPONSE + assert_refused_unchanged "$dir" "worktree claimed by another task" + [ ! -s "$dir/herdr.log" ] || fail "shared-worktree ambiguity reached Herdr" + + dir=$(make_case shared-endpoint) + other="$dir/home/state/other-task.meta" + fm_write_meta "$other" \ + "window=lab:w1:p2" "worktree=$dir/project" "project=$dir/project" \ + "backend=herdr" "herdr_session=lab" "herdr_workspace_id=w1" \ + "herdr_tab_id=w1:t2" "herdr_pane_id=w1:p2" + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree") + export FM_HERDR_RESPONSE + assert_refused_unchanged "$dir" "endpoint claimed by another task" + [ ! -s "$dir/herdr.log" ] || fail "shared-endpoint ambiguity reached Herdr" + + dir=$(make_case shared-structured-pane) + other="$dir/home/state/other-task.meta" + fm_write_meta "$other" \ + "window=lab:w9:p9" "worktree=$dir/project" "project=$dir/project" \ + "backend=herdr" "herdr_session=lab" "herdr_workspace_id=w1" \ + "herdr_tab_id=w1:t9" "herdr_pane_id=w1:p2" + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree") + export FM_HERDR_RESPONSE + assert_refused_unchanged "$dir" "structured pane claimed by another task" + [ ! -s "$dir/herdr.log" ] || fail "structured-pane ambiguity reached Herdr" + pass "legacy endpoint repair: another metadata claim makes ownership ambiguous and refuses" +} + +test_unregistered_or_cross_project_worktrees_refuse() { + local dir replacement + dir=$(make_case unregistered) + replacement="$dir/unregistered" + fm_git_init_commit "$replacement" + perl -0pi -e "s|^worktree=.*\$|worktree=$replacement|m" "$dir/home/state/legacy-task.meta" + FM_HERDR_RESPONSE=$(pane_response "$replacement") + export FM_HERDR_RESPONSE + assert_refused_unchanged "$dir" "unregistered git worktree" + [ ! -s "$dir/herdr.log" ] || fail "unregistered worktree reached Herdr" + + dir=$(make_case cross-project) + replacement="$dir/other-project" + fm_git_init_commit "$replacement" + perl -0pi -e "s|^project=.*\$|project=$replacement|m" "$dir/home/state/legacy-task.meta" + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree") + export FM_HERDR_RESPONSE + assert_refused_unchanged "$dir" "cross-project worktree" + [ ! -s "$dir/herdr.log" ] || fail "cross-project worktree reached Herdr" + pass "legacy endpoint repair: only an exact linked worktree registered to the recorded project is eligible" +} + +test_metadata_drift_refuses_without_overwriting_concurrent_state() { + local dir rc + dir=$(make_case drift) + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree") + FM_HERDR_MUTATE_META_ON_CALL=1 + export FM_HERDR_RESPONSE FM_HERDR_MUTATE_META_ON_CALL + set +e + run_repair "$dir" > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + unset FM_HERDR_MUTATE_META_ON_CALL + [ "$rc" -ne 0 ] || fail "metadata drift during proof unexpectedly succeeded" + assert_grep "concurrent=keep" "$dir/home/state/legacy-task.meta" \ + "repair overwrote concurrent metadata state" + assert_no_grep "endpoint_task_id=" "$dir/home/state/legacy-task.meta" \ + "repair published a binding after metadata drift" + pass "legacy endpoint repair: metadata drift fails closed without overwriting concurrent state" +} + +test_control_lock_contention_refuses_before_runtime_access() { + local dir lock ready release holder i=0 rc before + dir=$(make_case control-lock) + lock="$dir/home/state/.control-legacy-task.lock" + ready="$dir/ready" + release="$dir/release" + ( + # shellcheck source=/dev/null + . "$ROOT/bin/fm-wake-lib.sh" + fm_lock_try_acquire "$lock" || exit 1 + trap 'fm_lock_release "$lock"' EXIT + : > "$ready" + while [ ! -e "$release" ]; do sleep 0.01; done + ) & + holder=$! + while [ ! -e "$ready" ] && [ "$i" -lt 100 ]; do sleep 0.05; i=$((i + 1)); done + [ -e "$ready" ] || fail "could not stage lifecycle lock contention" + before=$(cat "$dir/home/state/legacy-task.meta") + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree") + export FM_HERDR_RESPONSE + set +e + run_repair "$dir" > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + : > "$release" + wait "$holder" || fail "lifecycle lock holder failed" + [ "$rc" -ne 0 ] || fail "repair unexpectedly succeeded under lifecycle lock contention" + [ "$(cat "$dir/home/state/legacy-task.meta")" = "$before" ] \ + || fail "contended repair changed metadata" + [ ! -s "$dir/herdr.log" ] || fail "contended repair reached Herdr" + pass "legacy endpoint repair: lifecycle lock contention refuses before proof or mutation" +} + +test_task_inventory_lock_contention_refuses_before_runtime_access() { + local dir lock ready release holder i=0 rc before + dir=$(make_case task-set-lock) + lock="$dir/home/state/.task-set.lock" + ready="$dir/ready" + release="$dir/release" + ( + # shellcheck source=/dev/null + . "$ROOT/bin/fm-wake-lib.sh" + fm_lock_try_acquire "$lock" || exit 1 + trap 'fm_lock_release "$lock"' EXIT + : > "$ready" + while [ ! -e "$release" ]; do sleep 0.01; done + ) & + holder=$! + while [ ! -e "$ready" ] && [ "$i" -lt 100 ]; do sleep 0.05; i=$((i + 1)); done + [ -e "$ready" ] || fail "could not stage task-inventory lock contention" + before=$(cat "$dir/home/state/legacy-task.meta") + FM_HERDR_RESPONSE=$(pane_response "$dir/worktree") + export FM_HERDR_RESPONSE + set +e + run_repair "$dir" > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + : > "$release" + wait "$holder" || fail "task-inventory lock holder failed" + [ "$rc" -ne 0 ] || fail "repair unexpectedly succeeded while the task inventory was changing" + [ "$(cat "$dir/home/state/legacy-task.meta")" = "$before" ] \ + || fail "task-inventory contention changed metadata" + [ ! -s "$dir/herdr.log" ] || fail "task-inventory contention reached Herdr" + pass "legacy endpoint repair: task-inventory changes refuse before proof or mutation" +} + +test_no_mistakes_gate_agent_cannot_drive_repair() { + local dir before rc + dir=$(make_case gate-agent) + before=$(cat "$dir/home/state/legacy-task.meta") + set +e + env -u FM_GATE_REFUSE_BYPASS NO_MISTAKES_GATE=1 \ + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$dir/fakebin:$PATH" \ + "$REPAIR" legacy-task > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + expect_code 3 "$rc" "gate-agent binding repair refusal" + [ "$(cat "$dir/home/state/legacy-task.meta")" = "$before" ] \ + || fail "gate-agent refusal changed metadata" + [ ! -s "$dir/herdr.log" ] || fail "gate-agent refusal reached Herdr" + pass "legacy endpoint repair: no-mistakes gate agents cannot drive fleet recovery" +} + +make_gate_checkout_with_repair() { # + local dir=$1 seed="$1/gate-seed" origin="$1/gate-origin.git" \ + bare="$1/.no-mistakes/repos/repair.git" \ + checkout="$1/.no-mistakes/worktrees/repair/run" + mkdir -p "$(dirname "$bare")" + git init -q --bare "$origin" + fm_git_init_commit "$seed" + git -C "$seed" remote add origin "$origin" + git -C "$seed" push -q origin HEAD:main + git clone -q --bare "$origin" "$bare" + git -C "$bare" worktree add --detach "$checkout" main >/dev/null 2>&1 + mkdir -p "$checkout/bin" + cp "$ROOT/bin/fm-backend.sh" "$ROOT/bin/fm-gate-refuse-lib.sh" \ + "$ROOT/bin/fm-repair-legacy-endpoint-binding.sh" "$checkout/bin/" + printf '%s\n' "$checkout" +} + +test_gate_worktree_path_backstop_ignores_caller_root_override() { + local dir gate_checkout repair normal_root before rc + dir=$(make_case gate-path-backstop) + gate_checkout=$(make_gate_checkout_with_repair "$dir") + repair="$gate_checkout/bin/fm-repair-legacy-endpoint-binding.sh" + normal_root="$dir/normal-root" + fm_git_init_commit "$normal_root" + mkdir -p "$dir/outside" + before=$(cat "$dir/home/state/legacy-task.meta") + set +e + ( + cd "$dir/outside" || exit 111 + env -u FM_GATE_REFUSE_BYPASS -u NO_MISTAKES_GATE \ + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$normal_root" PATH="$dir/fakebin:$PATH" \ + "$repair" legacy-task > "$dir/stdout" 2> "$dir/stderr" + ) + rc=$? + set -e + expect_code 3 "$rc" "gate-worktree path backstop with a caller-controlled root override" + [ "$(cat "$dir/home/state/legacy-task.meta")" = "$before" ] \ + || fail "gate-worktree path refusal changed metadata" + [ ! -s "$dir/herdr.log" ] || fail "gate-worktree path refusal reached Herdr" + pass "legacy endpoint repair: gate-worktree path backstop ignores a caller-controlled root override" +} + +test_legacy_record_reproduces_the_metadata_only_refusal +test_exact_live_identity_repairs_only_the_binding +test_existing_binding_is_idempotent_without_runtime_access +test_ambiguous_or_wrong_existing_bindings_refuse_without_runtime_access +test_live_identity_mismatch_refuses +test_other_metadata_claims_refuse_before_runtime_access +test_unregistered_or_cross_project_worktrees_refuse +test_metadata_drift_refuses_without_overwriting_concurrent_state +test_control_lock_contention_refuses_before_runtime_access +test_task_inventory_lock_contention_refuses_before_runtime_access +test_no_mistakes_gate_agent_cannot_drive_repair +test_gate_worktree_path_backstop_ignores_caller_root_override