diff --git a/bin/fm-claude-trust.sh b/bin/fm-claude-trust.sh index 6e1a49a9776..8fc3fb6b88a 100755 --- a/bin/fm-claude-trust.sh +++ b/bin/fm-claude-trust.sh @@ -10,10 +10,13 @@ # # Usage: fm-claude-trust.sh # fm-claude-trust.sh --secondmate-home +# fm-claude-trust.sh --lab-home # the isolated task worktree this spawn launches into # the primary checkout that worktree belongs to # the seeded secondmate home this spawn launches into # the secondmate id that home must already be marked for +# --lab-home the disposable lab home bin/fm-live-lab.sh launches a lab +# primary in # Prints one line naming what it registered; refuses loudly on anything else. # # WHY THIS EXISTS. Claude Code gates a folder it has never seen behind an @@ -144,15 +147,25 @@ # argument to gate external-imports consent against, so the two import flags # are never written there. # +# LAB-HOME MODE. A disposable lab primary (bin/fm-live-lab.sh) launches in a lab +# home that is neither a task worktree nor a seeded secondmate home. +# The evidence is structural: the home must carry bin/fm-lab-home.sh's marker +# (a regular file this user owns, never a symlink, holding the token +# bin/fm-gate-refuse-lib.sh owns), hold +# AGENTS.md and bin/, and be a primary git checkout whose top level is exactly +# the argument, because Claude Code keys the launch to that root. It is +# trust-only for the same reason as a secondmate home, and bin/fm-live-lab.sh +# removes the entry again when it tears the lab down. +# # Only the launching user's own store is written. In worktree mode: the # projects entries for the worktree path and the resolved canonical project # path in ${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json, which must be a regular # file this uid owns; every unrelated key and project entry is preserved, and -# both entries land in one atomic replacement. In secondmate-home mode: the -# single projects entry for the registered home path, same store, same atomic -# replacement. fm-spawn.sh forwards CLAUDE_CONFIG_DIR onto the claude launch -# verbatim rather than resolving it, and the pane starts in the registered -# directory, so only an absolute value names the same store on both sides; a +# both entries land in one atomic replacement. In secondmate-home and lab-home +# mode: the single projects entry for the registered home path, same store, +# same atomic replacement. fm-spawn.sh forwards CLAUDE_CONFIG_DIR onto the +# claude launch verbatim rather than resolving it, and the pane starts in the +# registered directory, so only an absolute value names the same store on both sides; a # relative one is refused below rather than guessed at. set -u # Path resolution here must answer from the filesystem, never from the caller's @@ -174,6 +187,7 @@ unset CDPATH \ usage() { echo "usage: fm-claude-trust.sh " >&2 echo " fm-claude-trust.sh --secondmate-home " >&2 + echo " fm-claude-trust.sh --lab-home " >&2 exit 2 } @@ -189,6 +203,14 @@ case "${1:-}" in PROJ_ARG= SCOPE_NOUN="secondmate home" ;; + --lab-home) + [ "$#" -eq 2 ] || usage + MODE=lab-home + TARGET_ARG=$2 + SUB_ID= + PROJ_ARG= + SCOPE_NOUN="lab home" + ;; '' | -h | --help) usage ;; @@ -204,6 +226,9 @@ esac refuse() { echo "error: refusing to pre-register Claude trust: $1" >&2; exit 1; } +# shellcheck source=bin/fm-gate-refuse-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/fm-gate-refuse-lib.sh" + real_dir() { (cd -P -- "$1" 2>/dev/null && pwd -P); } # The fully resolved path of an existing file, or empty. Resolution runs in node @@ -303,6 +328,22 @@ if [ "$MODE" = worktree ]; then [ -n "$CANON_GIT_DIR" ] && [ "$CANON_GIT_DIR" = "$PROJ_COMMON" ] \ || refuse "project '$PROJ_REAL' is a linked worktree whose primary checkout could not be resolved" fi +elif [ "$MODE" = lab-home ]; then + LAB_MARKER="$TARGET_REAL/$FM_GATE_LAB_MARKER" + [ ! -L "$LAB_MARKER" ] || refuse "'$LAB_MARKER' is a symlink; a lab home carries the marker as a regular file" + if ! { [ -f "$LAB_MARKER" ] && [ -O "$LAB_MARKER" ] && fm_gate_lab_home "$TARGET_REAL"; }; then + refuse "'$TARGET_REAL' carries no lab-home marker owned by this user, so it is not a disposable lab home" + fi + [ -f "$TARGET_REAL/AGENTS.md" ] || refuse "'$TARGET_REAL' has no AGENTS.md, so it is not a firstmate home" + [ -d "$TARGET_REAL/bin" ] || refuse "'$TARGET_REAL' has no bin/, so it is not a firstmate home" + LAB_TOP=$(git -C "$TARGET_REAL" rev-parse --show-toplevel 2>/dev/null) || true + [ -n "$LAB_TOP" ] && [ "$(real_dir "$LAB_TOP")" = "$TARGET_REAL" ] \ + || refuse "'$TARGET_REAL' is not the top level of a git checkout" + LAB_GIT_DIR=$(git -C "$TARGET_REAL" rev-parse --absolute-git-dir 2>/dev/null) || true + LAB_GIT_DIR=$(real_dir "${LAB_GIT_DIR:-}") || true + LAB_COMMON=$(common_dir_of "$TARGET_REAL") || true + [ -n "$LAB_GIT_DIR" ] && [ "$LAB_GIT_DIR" = "$LAB_COMMON" ] \ + || refuse "'$TARGET_REAL' is a linked worktree, not the primary checkout a lab primary launches in" else # The seed evidence, in the order that names the most useful reason first: the # marker decides whether this is a secondmate home at all, the id decides diff --git a/bin/fm-live-lab.sh b/bin/fm-live-lab.sh new file mode 100755 index 00000000000..752bf25a0cf --- /dev/null +++ b/bin/fm-live-lab.sh @@ -0,0 +1,849 @@ +#!/usr/bin/env bash +# fm-live-lab.sh - stand up, check, drive, and tear down one disposable live +# supervision lab: a real lab main session on Claude or Pi, with the +# supervision host (Claude) or branch (Pi) wired as a real home runs it, +# optionally a real seeded local second mate and a real gated worker. +# +# Usage: +# fm-live-lab.sh up --harness claude|pi [--mate] [--worker] +# [--model ] [--effort ] +# [--supervision-host |none] [--expect-host yes|no] +# [--source ] [--ref ] [--timeout ] +# [] +# fm-live-lab.sh check +# fm-live-lab.sh say [--window ] +# fm-live-lab.sh pane [--window ] [--lines ] +# fm-live-lab.sh down +# +# up builds everything under (a fresh path; default a new +# /tmp/fmlab.XXXXXX), verifies readiness itself, and prints one line per check. +# It exits 0 only when every check passed; otherwise it exits 1 and leaves the +# lab up for inspection, so run down either way. check re-runs the same checks +# once. say types text into a lab window and presses Enter (window main, the +# lab primary, by default; mate and worker name the lab's own tasks). pane +# prints a window's recent scrollback. down stops every lab process, removes the +# lab's Claude trust entries by one atomic replace, removes , and exits +# non-zero if the recorded Pi trust store or ~/.treehouse gained changes. +# +# What up builds: +# home/ the lab main home: bin/fm-lab-home.sh create, then the +# committed tree of (default: HEAD of the +# checkout this script runs from) checked out as a genuine +# primary checkout, with FM_HOME at its root. +# config/ backend tmux, Claude crews and second mates, and +# supervision-host (default claude on Claude, absent +# on Pi; none leaves the file absent). +# tmux server private, through the lab home's bin/fm-lab-home.sh +# tmux-dir, with no user tmux config (its plugins never run +# in a lab), started from an empty environment so no inherited +# TMUX, Herdr, or Pi marker reaches a lab process. +# TREEHOUSE_ROOT points into , so a worker's pool +# never lands in ~/.treehouse, and DISABLE_AUTOUPDATER=1 +# keeps Claude Code from replacing the shared binary under +# a running lab, as every live run does (tests/lib.sh). +# A set CLAUDE_CONFIG_DIR (absolute) is passed to every lab +# process. up records the Claude store, Pi trust store, and +# ~/.treehouse it selected, so check and down use those same +# paths even from a later shell with another HOME. +# trust Claude: bin/fm-claude-trust.sh --lab-home for the primary +# and the spawn's own registration for the mate and worker. Pi: +# --approve, which trusts project-local files for this run +# only, so the Pi trust store is never written and all of +# .pi/extensions loads; sessions stay under +# /pi-sessions. +# task ids lab-mate and lab-worker, unique per lab, +# because a spawn keeps a task temp dir at /tmp/fm- +# that a fixed id would share with other labs and tasks. +# mate/ --mate: bin/fm-home-seed.sh /mate +# --no-projects (an explicit path cloned from the git lab +# home), launched by bin/fm-spawn.sh --secondmate. +# worker --worker: a lab project notes with a lab-private origin +# (local-only +yolo), a scaffolded brief, a backlog item, and +# a real bin/fm-spawn.sh worker that parks on +# /home/data//gate until that file +# exists; touch the gate and message the worker to resume. +# primary window main: claude --setting-sources project,local +# (default sonnet, medium, permission mode auto) or pi +# (default openai-codex/gpt-6-luna, medium), launched +# after the mate and worker so its first turn end arms +# supervision. up then sends one harmless probe prompt. +# +# Readiness checks (check prints "ok : ..." or "fail : ..."): +# primary window main is alive, in the lab home, which is a primary +# checkout, and the lab session lock names a live process. +# probe the primary answered the probe with its nonce (the model is +# accepted and a whole turn ran). +# trust Claude: the lab home carries registered trust. +# Pi: the Pi trust store is byte-identical to before up. +# mirror Claude with --expect-host yes: the tree's +# fm-host-mirror.sh verified claude and fm-host-mirror.sh check +# pass, and the mirror holds a captain and a main entry. +# extensions Pi: the watcher, turn-end guard, and branch extensions are +# loaded by the process holding the lab session lock, at the +# current on-disk builds. +# host Claude: with --expect-host yes (the default on Claude) the +# supervision host runs; with no, none runs. Skipped when the +# lab has no mate or worker, since an empty fleet arms nothing. +# watcher a live watcher with a fresh beacon holds this home's lock +# (skipped on an empty fleet). +# mate --mate: its window is alive and its own session lock names a +# live process, so it got past trust into its charter. +# worker --worker: its current crew state is paused on the gate. +# treehouse ~/.treehouse gained no entry since up began. +# +# down refuses any path without the lab record up writes. It kills only the +# lab's recorded private tmux server and launch pane PIDs, and their descendants; +# runs bin/fm-lab-home.sh teardown; removes the task temp and launch dirs the +# lab's spawns kept under /tmp, including a failed spawn's; removes every +# project entry at or under from the recorded Claude store, following +# a symlinked store to its target (compare-and-swap atomic replace, unrelated +# entries kept); reports a changed Pi trust store or a new +# ~/.treehouse entry without touching either; and removes . +# Transcripts under ~/.claude/projects are left as history. The lab never uses +# Herdr. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +BUILDER_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" +LAB_HOME_HELPER="$SCRIPT_DIR/fm-lab-home.sh" +CLAUDE_TRUST="$SCRIPT_DIR/fm-claude-trust.sh" +RECORD_NAME=.fm-live-lab +RECORD_TOKEN='fm-live-lab v1' +PI_TRUST_STORE="$HOME/.pi/agent/trust.json" +TREEHOUSE_DIR="$HOME/.treehouse" + +die() { echo "fm-live-lab: $*" >&2; exit 1; } +help_text() { sed -n '/^# Usage:/,/^# up builds/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; } +usage() { help_text >&2; exit 2; } + +real_dir() { (cd -P -- "$1" 2>/dev/null && pwd -P); } + +digest() { # -> sha256 of its bytes, or "absent" + [ -f "$1" ] || { echo absent; return; } + shasum -a 256 "$1" | awk '{print $1}' +} + +treehouse_listing() { ls -1A "$TREEHOUSE_DIR" 2>/dev/null || true; } + +rec_get() { # + sed -n "s/^$2=//p" "$1/$RECORD_NAME" | head -n 1 +} + +load_lab() { # : refuse anything up did not build, then load its record + local root=$1 + [ -n "$root" ] || usage + ROOT=$(real_dir "$root") || die "no lab at '$root'" + [ -f "$ROOT/$RECORD_NAME" ] && [ ! -L "$ROOT/$RECORD_NAME" ] && [ -O "$ROOT/$RECORD_NAME" ] \ + && [ "$(sed -n 1p "$ROOT/$RECORD_NAME")" = "$RECORD_TOKEN" ] \ + || die "refusing '$ROOT': it carries no lab record written by fm-live-lab.sh up" + HARNESS=$(rec_get "$ROOT" harness) + LAB=$(rec_get "$ROOT" home) + TMUX_DIR=$(rec_get "$ROOT" tmux_dir) + EXPECT_HOST=$(rec_get "$ROOT" expect_host) + WANT_MATE=$(rec_get "$ROOT" mate) + WANT_WORKER=$(rec_get "$ROOT" worker) + NONCE=$(rec_get "$ROOT" nonce) + MATE_ID=$(rec_get "$ROOT" mate_id) + WORKER_ID=$(rec_get "$ROOT" worker_id) + GATE=$(rec_get "$ROOT" gate) + PI_TRUST_BEFORE=$(rec_get "$ROOT" pi_trust) + CLAUDE_DIR=$(rec_get "$ROOT" claude_config_dir) + CLAUDE_STORE=$(rec_get "$ROOT" claude_store) + PI_TRUST_STORE=$(rec_get "$ROOT" pi_trust_store) + TREEHOUSE_DIR=$(rec_get "$ROOT" treehouse_dir) +} + +lab_tmux() { + [ -n "${TMUX_DIR:-}" ] || return 1 + env -u TMUX TMUX_TMPDIR="$TMUX_DIR" tmux "$@" +} + +# The empty-environment base every lab process starts from. +lab_env_base() { + printf '%s\n' "HOME=$HOME" "USER=${USER:-$(id -un)}" "LOGNAME=${USER:-$(id -un)}" \ + "PATH=$PATH" "SHELL=${SHELL:-/bin/zsh}" "TERM=xterm-256color" "LANG=${LANG:-en_US.UTF-8}" \ + "TMUX_TMPDIR=$TMUX_DIR" "TREEHOUSE_ROOT=$ROOT/treehouse" "FM_BACKEND=tmux" "DISABLE_AUTOUPDATER=1" + [ -z "${CLAUDE_DIR:-}" ] || printf '%s\n' "CLAUDE_CONFIG_DIR=$CLAUDE_DIR" +} + +lab_run() { # [NAME=VALUE...] : run in the lab's clean environment + local -a base=() + local line + while IFS= read -r line; do base+=("$line"); done < <(lab_env_base) + env -i "${base[@]}" "$@" +} + +# window_id : the tmux id of the lab window with exactly this name, or +# nothing. A name is matched here and never passed as a target, because tmux +# resolves a target it cannot find, even an exact =name, to the current window, +# while a stale window id fails. mate and worker name the lab's own tasks, whose +# windows the spawn recorded in their metadata. +window_id() { + local name=$1 window + case "$name" in + mate) name=$MATE_ID ;; + worker) name=$WORKER_ID ;; + esac + window=$(sed -n 's/^window=//p' "$LAB/state/$name.meta" 2>/dev/null) + [ -z "$window" ] || name=${window#*:} + lab_tmux list-windows -t firstmate -F "#{window_name}$(printf '\t')#{window_id}" 2>/dev/null \ + | awk -F '\t' -v n="$name" '$1 == n { print $2; exit }' +} + +window_field() { # + local id + id=$(window_id "$1") + [ -n "$id" ] || return 1 + lab_tmux display-message -p -t "$id" "$2" 2>/dev/null +} + +window_alive() { # + [ "$(window_field "$1" '#{pane_dead}')" = 0 ] +} + +pid_alive() { case "${1:-}" in ''|*[!0-9]*) return 1 ;; esac; kill -0 "$1" 2>/dev/null; } + +fleet_nonempty() { [ "$WANT_MATE" = yes ] || [ "$WANT_WORKER" = yes ]; } + +# ---- readiness checks ------------------------------------------------------- + +check_primary() { + local path gitdir common pid + window_alive main || { echo "fail primary: window main is not running"; return 1; } + path=$(window_field main '#{pane_current_path}') + [ "$(real_dir "$path")" = "$LAB" ] || { echo "fail primary: window main runs in '$path', not the lab home $LAB"; return 1; } + gitdir=$(real_dir "$(git -C "$LAB" rev-parse --absolute-git-dir 2>/dev/null)") + common=$(cd "$LAB" && real_dir "$(git rev-parse --git-common-dir 2>/dev/null)") + [ -n "$gitdir" ] && [ "$gitdir" = "$common" ] || { echo "fail primary: the lab home is not a primary checkout"; return 1; } + pid=$(sed -n 1p "$LAB/state/.lock" 2>/dev/null) + pid_alive "$pid" || { echo "fail primary: the lab session lock names no live process (session start has not run)"; return 1; } + echo "ok primary: $HARNESS pid $pid in $LAB" +} + +check_probe() { + local id + id=$(window_id main) + if [ -n "$id" ] && lab_tmux capture-pane -p -J -t "$id" -S -5000 2>/dev/null | grep -Fq "LABREADY-$NONCE"; then + echo "ok probe: the primary answered LABREADY-$NONCE" + else + echo "fail probe: no LABREADY-$NONCE reply in window main (model refused, turn still running, or a dialog is open)" + return 1 + fi +} + +lab_trust_present() { + node -e 'const [s,k]=process.argv.slice(1);const j=JSON.parse(require("node:fs").readFileSync(s,"utf8"));process.exit(j.projects?.[k]?.hasTrustDialogAccepted===true?0:1)' \ + "$CLAUDE_STORE" "$LAB" 2>/dev/null +} + +check_trust() { + if [ "$HARNESS" = pi ]; then + [ "$(digest "$PI_TRUST_STORE")" = "$PI_TRUST_BEFORE" ] \ + || { echo "fail trust: the Pi trust store changed since up began"; return 1; } + echo "ok trust: Pi trust store unchanged (session-only --approve)" + return 0 + fi + if lab_trust_present; then + echo "ok trust: $LAB is trusted in the Claude store" + else + echo "fail trust: $LAB has no registered Claude workspace trust" + return 1 + fi +} + +check_mirror() { + local out rc entries + out=$(cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-host-mirror.sh" verified claude 2>&1) + rc=$? + [ "$rc" -eq 0 ] || { echo "fail mirror: fm-host-mirror.sh verified claude exited $rc ${out:+($out)}"; return 1; } + out=$(cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-host-mirror.sh" check 2>&1) + rc=$? + [ "$rc" -eq 0 ] || { echo "fail mirror: fm-host-mirror.sh check exited $rc ${out:+($(printf '%s' "$out" | head -n 1))}"; return 1; } + entries=$(jq -rs '[.[].tag] | "captain=\(map(select(.=="captain"))|length) main=\(map(select(.=="main"))|length)"' \ + "$LAB/state/.host-mirror.jsonl" 2>/dev/null) + case "$entries" in + captain=0*|*main=0|'') echo "fail mirror: the dialog mirror has no captain and main entry yet (${entries:-no mirror file})"; return 1 ;; + esac + echo "ok mirror: verified writer, check passed, $entries" +} + +check_extensions() { + local pair source marker phase version out="" + for pair in fm-primary-pi-watch.ts:.pi-watch-extension-loaded:active fm-primary-turnend-guard.ts:.pi-turnend-extension-loaded; do + source=${pair%%:*}; marker=${pair#*:}; phase=${marker#*:}; marker=${marker%%:*} + [ "$phase" != "$marker" ] || phase= + # shellcheck disable=SC2016 # Expanded by the inner shell. + version=$(FM_HOME="$LAB" bash -c '. "$1/bin/fm-wake-lib.sh" && fm_pi_extension_version "$1/.pi/extensions/$2"' _ "$LAB" "$source" 2>/dev/null) + # shellcheck disable=SC2016 # Expanded by the inner shell. + FM_HOME="$LAB" bash -c '. "$1/bin/fm-wake-lib.sh" && fm_pi_extension_loaded "$1/state/$2" "$3" "$1/state/.lock" "$4"' \ + _ "$LAB" "$marker" "$version" "$phase" 2>/dev/null \ + || { echo "fail extensions: $source is not loaded at its current build by the lock holder"; return 1; } + out="$out ${source%.ts}" + done + [ "$(sed -n 1p "$LAB/state/.pi-branch-extension-loaded" 2>/dev/null)" = "$(sed -n 1p "$LAB/state/.lock" 2>/dev/null)" ] \ + || { echo "fail extensions: fm-branch-supervision.ts is not loaded by the lock holder"; return 1; } + echo "ok extensions:$out fm-branch-supervision" +} + +check_host() { + local pid + fleet_nonempty || { echo "ok host: skipped (empty fleet arms no supervision)"; return 0; } + pid=$(awk -F '\t' '$1=="host"{print $2; exit}' "$LAB/state/.supervision-host" 2>/dev/null) + if [ "$EXPECT_HOST" = yes ]; then + pid_alive "$pid" || { echo "fail host: no live supervision host (expected one)"; return 1; } + echo "ok host: supervision host pid $pid" + else + ! pid_alive "$pid" || { echo "fail host: supervision host pid $pid runs (expected none)"; return 1; } + echo "ok host: none running, as expected" + fi +} + +check_watcher() { + fleet_nonempty || { echo "ok watcher: skipped (empty fleet)"; return 0; } + # shellcheck disable=SC2016 # Expanded by the inner shell. + if FM_HOME="$LAB" bash -c '. "$1/bin/fm-wake-lib.sh" && fm_watcher_healthy "$1/state" "$1/bin/fm-watch.sh" 300 "$1"' _ "$LAB" 2>/dev/null; then + echo "ok watcher: live watcher with a fresh beacon" + else + echo "fail watcher: no live watcher with a fresh beacon holds the lab home" + return 1 + fi +} + +check_mate() { + local pid + window_alive mate || { echo "fail mate: the $MATE_ID window is not running"; return 1; } + pid=$(sed -n 1p "$ROOT/mate/state/.lock" 2>/dev/null) + pid_alive "$pid" || { echo "fail mate: the mate holds no session lock yet (wedged before its charter?)"; return 1; } + echo "ok mate: $MATE_ID pid $pid in $ROOT/mate" +} + +check_worker() { + local state + window_alive worker || { echo "fail worker: the $WORKER_ID window is not running"; return 1; } + state=$(cd "$LAB" && lab_run FM_HOME="$LAB" FM_CREW_STATE_NO_FORGE=1 "$LAB/bin/fm-crew-state.sh" "$WORKER_ID" 2>/dev/null) + case "$state" in + "state: paused ยท "*"$GATE"*) ;; + *) echo "fail worker: the worker is not currently parked on $GATE (${state:-no state})"; return 1 ;; + esac + echo "ok worker: $WORKER_ID parked on $GATE" +} + +check_treehouse() { + local added + added=$(comm -13 "$ROOT/.treehouse-before" <(treehouse_listing | sort) 2>/dev/null) + [ -z "$added" ] || { echo "fail treehouse: new ~/.treehouse entries: $(printf '%s' "$added" | tr '\n' ' ')"; return 1; } + echo "ok treehouse: ~/.treehouse unchanged" +} + +run_checks() { + local rc=0 + check_primary || rc=1 + check_probe || rc=1 + check_trust || rc=1 + if [ "$HARNESS" = claude ]; then + if [ "$EXPECT_HOST" = yes ]; then check_mirror || rc=1; fi + check_host || rc=1 + else + check_extensions || rc=1 + fi + check_watcher || rc=1 + if [ "$WANT_MATE" = yes ]; then check_mate || rc=1; fi + if [ "$WANT_WORKER" = yes ]; then check_worker || rc=1; fi + check_treehouse || rc=1 + return "$rc" +} + +# ---- up --------------------------------------------------------------------- + +say_text() { # + local id + id=$(window_id "$1") + [ -n "$id" ] || { echo "fm-live-lab: no lab window named '$1'" >&2; return 1; } + lab_tmux send-keys -t "$id" -l "$2" || return 1 + sleep 1 + lab_tmux send-keys -t "$id" Enter +} + +make_notes_project() { + local seed="$ROOT/origins/notes-seed" origin="$ROOT/origins/notes.git" + mkdir -p "$seed/notes" "$seed/tests" + git init -q -b main "$seed" + cat > "$seed/notes/__init__.py" <<'PY' +"""A tiny notes library used by the firstmate live lab.""" + +NOTES = [] + + +def add_note(text, tags=None): + NOTES.append({"text": text, "tags": list(tags or [])}) + return len(NOTES) - 1 + + +def list_notes(): + return list(NOTES) +PY + cat > "$seed/tests/test_notes.py" <<'PY' +import unittest + +import notes + + +class NotesTest(unittest.TestCase): + def setUp(self): + notes.NOTES.clear() + + def test_add_and_list(self): + notes.add_note("hello", ["a"]) + self.assertEqual(notes.list_notes(), [{"text": "hello", "tags": ["a"]}]) + + +if __name__ == "__main__": + unittest.main() +PY + cat > "$seed/README.md" <<'MD' +# notes + +A tiny notes library for lab work. +Run the checks with `python3 -m unittest discover -s tests`. +MD + git -C "$seed" add -A + git -C "$seed" -c user.name=lab -c user.email=lab@example.invalid commit -q -m "seed notes" + git clone -q --bare "$seed" "$origin" + rm -rf "$seed" + git clone -q "$origin" "$LAB/projects/notes" + printf '# Projects\n\n- notes [local-only +yolo] - tiny lab notes library\n' > "$LAB/data/projects.md" +} + +spawn_worker() { + local brief="$LAB/data/$WORKER_ID/brief.md" gate="$GATE" + make_notes_project || return 1 + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-brief.sh" "$WORKER_ID" notes --mode local-only) >/dev/null || return 1 + TASK_TEXT="Lab gated worker for a live supervision lab. Add count_notes(), which returns how many notes are stored, to notes/__init__.py with a unit test, but only after the gate file $gate exists and you receive a message to resume." \ + SPEC_TEXT="Right after setup, append one paused status line naming the gate file $gate and end your turn. Do not poll or sleep in a foreground command. When a later message resumes you, check that $gate exists before implementing count_notes() in notes/__init__.py and a test in tests/test_notes.py; if it is absent, remain paused and end your turn again. Once the gate exists, run python3 -m unittest discover -s tests, commit, and report done. Nothing else is in scope." \ + python3 - "$brief" <<'PY' || return 1 +import os, sys +path = sys.argv[1] +text = open(path, encoding="utf-8").read() +text = text.replace("{TASK}", os.environ["TASK_TEXT"], 1).replace("{FIRSTMATE_SPEC}", os.environ["SPEC_TEXT"], 1) +open(path, "w", encoding="utf-8").write(text) +PY + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-tasks-axi.sh" add "$WORKER_ID" "lab gated worker" --kind ship --repo notes) >/dev/null || return 1 + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-spawn.sh" "$WORKER_ID" "$LAB/projects/notes" \ + --mode local-only --yolo on --harness claude --model sonnet --effort low) +} + +spawn_mate() { + local charter='Provide an idle live-validation lab second mate. When explicitly steered for a synthetic test, report only honest local lab outcomes; do not claim external PR activity, merge, or retire yourself.' + (cd "$LAB" && lab_run FM_HOME="$LAB" FM_SECONDMATE_CHARTER="$charter" \ + FM_SECONDMATE_SCOPE='second-mate live validation synthetic status relay' \ + "$LAB/bin/fm-home-seed.sh" "$MATE_ID" "$ROOT/mate" --no-projects) || return 1 + (cd "$LAB" && lab_run FM_HOME="$LAB" "$LAB/bin/fm-spawn.sh" "$MATE_ID" --secondmate) +} + +cmd_up() { + local harness="" mate=no worker=no model="" effort=medium host_line=__default__ expect_host="" source="$BUILDER_ROOT" ref=HEAD timeout=600 + local root="" + while [ "$#" -gt 0 ]; do + case "$1" in + --harness) harness=${2:-}; shift 2 ;; + --mate) mate=yes; shift ;; + --worker) worker=yes; shift ;; + --model) model=${2:-}; shift 2 ;; + --effort) effort=${2:-}; shift 2 ;; + --supervision-host) host_line=${2:-}; shift 2 ;; + --expect-host) expect_host=${2:-}; shift 2 ;; + --source) source=${2:-}; shift 2 ;; + --ref) ref=${2:-}; shift 2 ;; + --timeout) timeout=${2:-}; shift 2 ;; + -h|--help) help_text; exit 0 ;; + -*) die "unknown option '$1'" ;; + *) [ -z "$root" ] || usage; root=$1; shift ;; + esac + done + case "$harness" in claude|pi) ;; *) die "--harness must be claude or pi" ;; esac + case "$timeout" in ''|*[!0-9]*) die "--timeout takes seconds" ;; esac + [ -n "$expect_host" ] || { [ "$harness" = claude ] && expect_host=yes || expect_host=no; } + case "$expect_host" in yes|no) ;; *) die "--expect-host takes yes or no" ;; esac + [ "$host_line" != __default__ ] || { [ "$harness" = claude ] && host_line=claude || host_line=none; } + [ -n "$model" ] || { [ "$harness" = claude ] && model=sonnet || model=openai-codex/gpt-6-luna; } + CLAUDE_DIR=${CLAUDE_CONFIG_DIR:-} + case "$CLAUDE_DIR" in ''|/*) ;; *) die "CLAUDE_CONFIG_DIR must be an absolute path" ;; esac + CLAUDE_STORE="${CLAUDE_DIR:-$HOME}/.claude.json" + [ -z "$root" ] || [ ! -e "$root" ] || die "refusing '$root': a lab root must not exist yet" + for tool in git tmux jq node python3 shasum "$harness"; do + command -v "$tool" >/dev/null 2>&1 || die "$tool is required and was not found on PATH" + done + + if [ -z "$root" ]; then + root=$(mktemp -d /tmp/fmlab.XXXXXX) || die "cannot create a lab root" + else + mkdir -p "$root" || die "cannot create '$root'" + fi + ROOT=$(real_dir "$root") + LAB="$ROOT/home" + HARNESS=$harness EXPECT_HOST=$expect_host WANT_MATE=$mate WANT_WORKER=$worker + NONCE=$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n') + MATE_ID="lab${NONCE:0:12}-mate" WORKER_ID="lab${NONCE:0:12}-worker" + GATE="$LAB/data/$WORKER_ID/gate" + PI_TRUST_BEFORE=$(digest "$PI_TRUST_STORE") + treehouse_listing | sort > "$ROOT/.treehouse-before" + { + echo "$RECORD_TOKEN" + echo "harness=$harness" + echo "home=$LAB" + echo "expect_host=$expect_host" + echo "mate=$mate" + echo "worker=$worker" + echo "nonce=$NONCE" + echo "mate_id=$MATE_ID" + echo "worker_id=$WORKER_ID" + echo "gate=$GATE" + echo "pi_trust=$PI_TRUST_BEFORE" + echo "claude_config_dir=$CLAUDE_DIR" + echo "claude_store=$CLAUDE_STORE" + echo "pi_trust_store=$PI_TRUST_STORE" + echo "treehouse_dir=$TREEHOUSE_DIR" + } > "$ROOT/$RECORD_NAME" + echo "lab: $ROOT (tear down with: $0 down $ROOT)" + + "$LAB_HOME_HELPER" create "$LAB" >/dev/null || die "cannot create the lab home" + git -C "$LAB" init -q -b main || die "cannot initialize the lab home" + git -C "$LAB" fetch -q "$source" "$ref" || die "cannot fetch $ref from $source" + git -C "$LAB" checkout -q -f -B main FETCH_HEAD || die "cannot check out $ref" + git -C "$LAB" config user.name lab && git -C "$LAB" config user.email lab@example.invalid + mkdir -p "$LAB/state" "$LAB/data" "$LAB/config" "$LAB/projects" "$ROOT/treehouse" + printf 'tmux\n' > "$LAB/config/backend" + printf 'claude\n' > "$LAB/config/crew-harness" + printf 'claude sonnet low\n' > "$LAB/config/secondmate-harness" + printf 'auto\n' > "$LAB/config/claude-permission-mode" + [ "$host_line" = none ] || printf '%s\n' "$host_line" > "$LAB/config/supervision-host" + echo "tree: $(git -C "$LAB" rev-parse HEAD) from $source" + + TMUX_DIR=$("$LAB_HOME_HELPER" tmux-dir "$LAB") || die "cannot create the private tmux directory" + echo "tmux_dir=$TMUX_DIR" >> "$ROOT/$RECORD_NAME" + lab_run tmux -f /dev/null new-session -d -s firstmate -n lab -x 220 -y 60 -c "$ROOT" || die "cannot start the lab tmux server" + record_launch_pid "$(lab_tmux display-message -p '#{pid}')" + + if [ "$mate" = yes ]; then + spawn_mate || die "cannot seed and launch the second mate" + record_launch_pid "$(window_field mate '#{pane_pid}')" + fi + if [ "$worker" = yes ]; then + spawn_worker || die "cannot launch the gated worker" + record_launch_pid "$(window_field worker '#{pane_pid}')" + echo "gate: $GATE (touch, then message the worker to resume)" + fi + + local -a primary=() + if [ "$harness" = claude ]; then + local settle=$(( $(date +%s) + 300 )) retry + until { [ "$mate" != yes ] || check_mate >/dev/null; } && { [ "$worker" != yes ] || [ -s "$LAB/state/$WORKER_ID.status" ]; }; do + [ "$(date +%s)" -lt "$settle" ] || break + sleep 2 + done + for (( retry=0; retry<3; retry++ )); do + lab_run "$CLAUDE_TRUST" --lab-home "$LAB" >/dev/null || die "cannot register Claude trust for the lab home" + sleep 1 + lab_trust_present && break + done + lab_trust_present || die "the lab home's Claude trust keeps disappearing from $CLAUDE_STORE" + primary=(claude --setting-sources "project,local" --model "$model" --effort "$effort" --permission-mode auto) + else + primary=(pi --approve --session-dir "$ROOT/pi-sessions" --model "$model" --thinking "$effort") + fi + lab_tmux new-window -d -t firstmate: -n main -c "$LAB" \ + env FM_HOME="$LAB" CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false "${primary[@]}" \ + || die "cannot launch the lab primary" + lab_tmux set-option -w -t "$(window_id main)" remain-on-exit on >/dev/null + record_launch_pid "$(window_field main '#{pane_pid}')" + echo "primary: ${primary[*]}" + + local deadline=$(( $(date +%s) + 180 )) + until [ -f "$LAB/state/.session-start-complete" ] || [ "$(date +%s)" -ge "$deadline" ]; do sleep 2; done + sleep 5 + say_text main "Lab readiness probe from bin/fm-live-lab.sh. Run no tool or command for this message. Reply with only the word LABREADY, a hyphen, and then $NONCE, with no spaces." \ + || die "cannot send the readiness probe" + + deadline=$(( $(date +%s) + timeout )) + local report + while :; do + report=$(run_checks) && { printf '%s\n' "$report"; echo "ready: $ROOT"; return 0; } + [ "$(date +%s)" -lt "$deadline" ] || break + sleep 5 + done + printf '%s\n' "$report" + echo "not ready after ${timeout}s; the lab is left up for inspection: $0 pane $ROOT, then $0 down $ROOT" >&2 + return 1 +} + +# ---- down ------------------------------------------------------------------- + +record_launch_pid() { + local start + case "${1:-}" in ''|*[!0-9]*) die "cannot record lab process: missing or invalid PID '${1:-}'" ;; esac + start=$(ps -o lstart= -p "$1" | awk '{$1=$1; print}') + [ -n "$start" ] || die "cannot record start time for lab process $1" + printf 'launch_pid=%s\nlaunch_start=%s\n' "$1" "$start" >> "$ROOT/$RECORD_NAME" +} + +# Resolve recorded roots only while their start times match, before tmux +# reparents their descendants. +lab_pids() { + ps -axo pid=,ppid=,lstart= | awk -v record="$ROOT/$RECORD_NAME" ' + BEGIN { + while ((getline line < record) > 0) { + if (line ~ /^launch_pid=[0-9]+$/) { sub(/^launch_pid=/, "", line); root=line } + else if (line ~ /^launch_start=/ && root != "") { + sub(/^launch_start=/, "", line); starts[root]=line; root="" + } + } + close(record) + } + { + pid[NR]=$1; ppid[$1]=$2 + start=$3 " " $4 " " $5 " " $6 " " $7 + if ($1 in starts && start == starts[$1]) roots[$1]=1 + } + END { + for (i = 1; i <= NR; i++) { + p = pid[i] + for (q = p; q > 1 && (q in ppid); q = ppid[q]) { + if (q in roots) { print p; break } + } + } + }' +} + +# Extend the pre-kill snapshot with descendants of still-matching processes +# and live members of captured lab process groups. Retain old pairs after reparenting. +expand_pairs() { + awk -v groups="$2" ' + BEGIN { split(groups, ids, /[[:space:]]+/); for (i in ids) if (ids[i] > 1) group[ids[i]]=1 } + NR==FNR { split($0, fields, "\t"); if (fields[1] ~ /^[0-9]+$/) saved[fields[1]]=fields[2]; next } + { + pid=$1; parent[pid]=$2; pgid[pid]=$3; state[pid]=$4 + start[pid]=$5 " " $6 " " $7 " " $8 " " $9 + if (pid in saved && start[pid] == saved[pid] && state[pid] !~ /^Z/) owned[pid]=1 + } + END { + for (pid in saved) print pid "\t" saved[pid] + for (pid in parent) { + if (pid in saved || state[pid] ~ /^Z/) continue + if (pgid[pid] in group) { print pid "\t" start[pid]; continue } + for (p=parent[pid]; p > 1 && (p in parent); p=parent[p]) { + if (p in owned) { print pid "\t" start[pid]; break } + } + } + }' <(printf '%s\n' "$1") <(printf '%s\n' "$3") +} + +# A group is eligible only while each scan still sees an identity-valid member. +# Once absent, it is removed from the caller's group list and cannot be rediscovered. +prune_groups() { + awk -v groups="$1" ' + BEGIN { n=split(groups, ids, /[[:space:]]+/) } + NR==FNR { split($0, fields, "\t"); if (fields[1] ~ /^[0-9]+$/) saved[fields[1]]=fields[2]; next } + { + pid=$1; pgid=$3; state=$4 + start=$5 " " $6 " " $7 " " $8 " " $9 + if (state !~ /^Z/ && (!(pid in saved) || saved[pid] == start)) live[pgid]=1 + } + END { for (i=1; i<=n; i++) if (ids[i] in live) printf "%s ", ids[i] } + ' <(printf '%s\n' "$2") <(printf '%s\n' "$3") +} + +refresh_pairs() { + local snapshot + snapshot=$(ps -axo pid=,ppid=,pgid=,stat=,lstart=) + pairs=$(expand_pairs "$pairs" "$groups" "$snapshot") + groups=$(prune_groups "$groups" "$pairs" "$snapshot") +} + +# pairs captured before tmux shutdown. Recheck identity even +# after a root exits and its children are reparented or a PID is reused. +live_pids() { + local pid start current + while IFS=$'\t' read -r pid start; do + [ -n "$pid" ] || continue + current=$(ps -o stat=,lstart= -p "$pid" 2>/dev/null | awk '{$1=$1; print}') + case "$current" in ''|Z*) ;; *) + [ "${current#* }" = "$start" ] && echo "$pid" + ;; + esac + done <<< "$1" +} + +forget_claude_entries() { # remove every project entry at or under ROOT; prints the count + [ -e "$CLAUDE_STORE" ] || { echo 0; return 0; } + node - "$CLAUDE_STORE" "$ROOT" "${ROOT#/private}" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const [link, ...roots] = process.argv.slice(2); +const store = fs.realpathSync(link); +const stat = fs.statSync(store); +if (!stat.isFile() || stat.uid !== process.getuid()) { + console.error(`error: ${store} is not a regular file this user owns`); process.exit(1); +} +const inLab = (key) => roots.some((r) => key === r || key.startsWith(`${r}/`)); +const fingerprint = (buf) => crypto.createHash("sha256").update(buf).digest("hex"); +for (let attempt = 0; attempt < 3; attempt += 1) { + const original = fs.readFileSync(store); + const root = JSON.parse(original.toString("utf8")); + const projects = root.projects; + if (projects === undefined) { console.log(0); process.exit(0); } + if (projects === null || typeof projects !== "object" || Array.isArray(projects)) { + console.error(`error: ${store} has a non-object "projects" value`); process.exit(1); + } + const removed = Object.keys(projects).filter(inLab); + if (removed.length === 0) { console.log(0); process.exit(0); } + const kept = Object.keys(projects).filter((k) => !inLab(k)); + for (const key of removed) delete projects[key]; + const tmp = path.join(path.dirname(store), `.claude.json.fm-live-lab.${process.pid}.${crypto.randomBytes(8).toString("hex")}`); + fs.writeFileSync(tmp, `${JSON.stringify(root, null, 2)}\n`, { mode: fs.statSync(store).mode & 0o777, flag: "wx" }); + let renamed = false; + try { + if (fingerprint(fs.readFileSync(store)) !== fingerprint(original)) continue; + fs.renameSync(tmp, store); + renamed = true; + } finally { + if (!renamed) fs.rmSync(tmp, { force: true }); + } + const back = Object.keys(JSON.parse(fs.readFileSync(store, "utf8")).projects || {}); + if (back.some(inLab) || kept.some((k) => !back.includes(k))) { + console.error(`error: ${store} did not keep exactly the non-lab entries`); process.exit(1); + } + console.log(removed.length); + process.exit(0); +} +console.error(`error: ${store} kept changing while lab entries were being removed`); +process.exit(1); +NODE +} + +cmd_down() { + load_lab "${1:-}" + local rc=0 pids pairs pid start survivors n removed added id meta dir home_hash groups pgid own_group caller_group details + local -a ids=() + pids=$(lab_pids) + pairs='' groups='' + own_group=$(ps -o pgid= -p "$$" | awk '{$1=$1; print}') + caller_group=$(ps -o pgid= -p "$PPID" | awk '{$1=$1; print}') + for pid in $pids; do + start=$(ps -o lstart= -p "$pid" 2>/dev/null | awk '{$1=$1; print}') + [ -n "$start" ] || continue + pairs+="$pid"$'\t'"$start"$'\n' + pgid=$(ps -o pgid=,lstart= -p "$pid" 2>/dev/null | awk -v start="$start" '{ if ($2 " " $3 " " $4 " " $5 " " $6 == start) print $1 }') + case "$pgid" in ''|0|1|*[!0-9]*) continue ;; esac + [ "$pgid" = "$own_group" ] || [ "$pgid" = "$caller_group" ] || groups+="$pgid " + done + lab_tmux kill-server 2>/dev/null || true + refresh_pairs + survivors=$(live_pids "$pairs") + if [ -n "$survivors" ]; then + # shellcheck disable=SC2086 # One identity-checked pid per word. + kill $survivors 2>/dev/null || true + fi + for n in {1..40}; do + refresh_pairs + survivors=$(live_pids "$pairs") + if [ -z "$survivors" ]; then + sleep 0.5 + refresh_pairs + survivors=$(live_pids "$pairs") + [ -n "$survivors" ] || break + fi + if [ "$n" -ge 20 ]; then + # shellcheck disable=SC2086 # One identity-checked pid per word. + kill -9 $survivors 2>/dev/null || true + fi + sleep 0.5 + done + refresh_pairs + survivors=$(live_pids "$pairs") + if [ -n "$survivors" ]; then + details='' + for pid in $survivors; do + details+="$(ps -o pid=,ppid=,pgid=,stat=,command= -p "$pid" 2>/dev/null)"$'\n' + done + die "refusing to remove the lab: its processes did not exit (pid ppid pgid state command): $details" + fi + echo "stopped: lab tmux server and lab processes" + # A spawn keeps /tmp/fm- and /tmp/fm-+. + # The second is scoped to this lab home for any task it spawned; the first is + # removed only for the lab's own unique ids, since another home may share it. + home_hash=$(printf '%s' "$LAB" | shasum -a 256 | awk '{print $1}') + ids=("$MATE_ID" "$WORKER_ID") + for meta in "$LAB"/state/*.meta; do + [ -f "$meta" ] && ids+=("$(basename "$meta" .meta)") + done + for id in "${ids[@]}"; do + [ -n "$id" ] || continue + for dir in "/tmp/fm-$id+$home_hash" "/tmp/fm-$id"; do + [ "$dir" != "/tmp/fm-$id" ] || [ "$id" = "$MATE_ID" ] || [ "$id" = "$WORKER_ID" ] || continue + if [ -d "$dir" ] && [ ! -L "$dir" ] && [ -O "$dir" ]; then + rm -rf "$dir" && echo "removed: task temp $dir" + fi + done + done + if [ -f "$LAB/.fm-lab-home" ]; then + "$LAB_HOME_HELPER" teardown "$LAB" || die "cannot remove the private tmux directory" + fi + removed=$(forget_claude_entries) || die "cannot remove the lab's Claude trust entries" + echo "removed: $removed Claude project entries under $ROOT" + if [ "$(digest "$PI_TRUST_STORE")" != "$PI_TRUST_BEFORE" ]; then + echo "warning: the Pi trust store changed since up began; left as is" >&2 + rc=1 + fi + added=$(comm -13 "$ROOT/.treehouse-before" <(treehouse_listing | sort) 2>/dev/null) + if [ -n "$added" ]; then + echo "warning: ~/.treehouse gained entries during the lab; left as is: $(printf '%s' "$added" | tr '\n' ' ')" >&2 + rc=1 + fi + chmod -R u+w "$ROOT" 2>/dev/null + rm -rf "$ROOT" || die "cannot remove $ROOT" + [ ! -e "$ROOT" ] || die "$ROOT is still present" + echo "removed: $ROOT" + return "$rc" +} + +# ---- say / pane / check ----------------------------------------------------- + +cmd_say() { + local window=main + load_lab "${1:-}"; shift + [ "${1:-}" = --window ] && { window=${2:-}; shift 2; } + [ "$#" -ge 1 ] || usage + say_text "$window" "$*" +} + +cmd_pane() { + local window=main lines=200 + load_lab "${1:-}"; shift + while [ "$#" -gt 0 ]; do + case "$1" in + --window) window=${2:-}; shift 2 ;; + --lines) lines=${2:-}; shift 2 ;; + *) usage ;; + esac + done + local id + id=$(window_id "$window") + [ -n "$id" ] || die "no lab window named '$window'" + lab_tmux capture-pane -p -J -t "$id" -S "-$lines" | grep -v '^[[:space:]]*$' +} + +cmd_check() { + load_lab "${1:-}" + run_checks +} + +case "${1:-}" in + up) shift; cmd_up "$@" ;; + check) shift; cmd_check "$@" ;; + say) shift; cmd_say "$@" ;; + pane) shift; cmd_pane "$@" ;; + down) shift; cmd_down "$@" ;; + -h|--help) help_text ;; + *) usage ;; +esac diff --git a/docs/scripts.md b/docs/scripts.md index 49d794bb533..56b4c08af11 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -39,6 +39,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `fm-herdr-lab.sh` | Provision and guardedly operate an isolated, never-default Herdr lab session | | `fm-herdr-lab-viewer.py` | The pty engine behind `fm-herdr-lab.sh viewer`: one real foreground Herdr client on a non-zero window grid | | `fm-lab-home.sh` | Mint disposable lab homes and manage their isolated tmux socket directories | +| `fm-live-lab.sh` | Build and operate a disposable live supervision lab; see its header for usage and readiness contract | | `fm-install-herdr.sh` | Install CI's exact-version Herdr pin with official asset URL, SHA-256, and protocol checks | | `fm-install-treehouse.sh`| Install CI's exact-version Treehouse pin for real-Herdr E2E that needs spawn worktrees | | `fm-herdr-ci-cleanup.sh` | Snapshot and tear down only job-owned `fm-lab-*` sessions in the Herdr CI lane | diff --git a/tests/fm-live-lab.test.sh b/tests/fm-live-lab.test.sh new file mode 100755 index 00000000000..1e5b957dd37 --- /dev/null +++ b/tests/fm-live-lab.test.sh @@ -0,0 +1,738 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-live-lab.sh (readiness checks and teardown) and +# bin/fm-claude-trust.sh --lab-home. +# +# Every readiness check is proven both ways against a real private tmux server +# and real processes, with no harness: it passes on a lab in the shape up builds +# and fails, by name, on the recorded lab miss it exists to catch. The live +# end-to-end run on the real harnesses is the builder's own `up`. +set -u + +# shellcheck source=tests/fixtures.sh +. "$(dirname "${BASH_SOURCE[0]}")/fixtures.sh" + +TMP_ROOT=$(fm_test_tmproot fm-live-lab) +: > "$TMP_ROOT/pids" +: > "$TMP_ROOT/tmux-dirs" +LIVE_LAB="$ROOT/bin/fm-live-lab.sh" +TRUST="$ROOT/bin/fm-claude-trust.sh" + +live_lab_cleanup() { + local dir pid marker + for marker in "$TMP_ROOT/orphan-child" "$TMP_ROOT/late-child" "$TMP_ROOT/reused-child"; do + [ ! -s "$marker" ] || printf '%s\n' "$(<"$marker")" >> "$TMP_ROOT/pids" + done + while read -r pid; do [ -n "$pid" ] && { pkill -P "$pid" 2>/dev/null || true; kill "$pid" 2>/dev/null || true; }; done < "$TMP_ROOT/pids" + while read -r dir; do + [ -n "$dir" ] || continue + env -u TMUX TMUX_TMPDIR="$dir" tmux kill-server 2>/dev/null + case "$dir" in /tmp/fml.*) rm -rf "$dir" ;; esac + done < "$TMP_ROOT/tmux-dirs" + rm -rf "/tmp/fm-labt$$-mate" "/tmp/fm-labt$$-worker" "/tmp/fm-labt$$-other" /tmp/fm-labt"$$"-*+* + fm_test_cleanup +} +trap live_lab_cleanup EXIT + +command -v tmux >/dev/null 2>&1 || { echo "ok - skipped: tmux is not installed"; exit 0; } + +FAKE_HOME="$TMP_ROOT/fakehome" +mkdir -p "$FAKE_HOME/.pi/agent" "$FAKE_HOME/.treehouse/existing-pool" +printf '{}\n' > "$FAKE_HOME/.pi/agent/trust.json" +export HOME="$FAKE_HOME" +unset CLAUDE_CONFIG_DIR TMUX + +MATE_ID="labt$$-mate" +WORKER_ID="labt$$-worker" +NONCE=abc12345 + +digest() { shasum -a 256 "$1" | awk '{print $1}'; } + +# make_lab []: a lab root in the shape up +# builds, with a live private tmux server. Every readiness input starts in its +# passing state. +make_lab() { + local root="$TMP_ROOT/$1" harness=$2 claude_dir=${3:-} home tmux_dir lock_pid + home="$root/home" + mkdir -p "$root" + "$ROOT/bin/fm-lab-home.sh" create "$home" >/dev/null || fail "lab home create" + cp -R "$ROOT/bin" "$home/bin" + cp "$ROOT/AGENTS.md" "$home/AGENTS.md" + mkdir -p "$home/.pi" "$home/data/$WORKER_ID" "$root/mate/state" "$root/treehouse" + cp -R "$ROOT/.pi/extensions" "$home/.pi/extensions" + git -C "$home" init -q -b main + git -C "$home" add -A bin AGENTS.md .pi + git -C "$home" -c user.name=t -c user.email=t@example.invalid commit -qm lab + tmux_dir=$("$ROOT/bin/fm-lab-home.sh" tmux-dir "$home") || fail "lab tmux dir" + printf '%s\n' "$tmux_dir" >> "$TMP_ROOT/tmux-dirs" + find "$HOME/.treehouse" -mindepth 1 -maxdepth 1 -exec basename {} \; | sort > "$root/.treehouse-before" + { + echo 'fm-live-lab v1' + echo "harness=$harness" + echo "home=$home" + echo "expect_host=yes" + echo "mate=yes" + echo "worker=yes" + echo "nonce=$NONCE" + echo "mate_id=$MATE_ID" + echo "worker_id=$WORKER_ID" + echo "gate=$home/data/$WORKER_ID/gate" + echo "pi_trust=$(digest "$HOME/.pi/agent/trust.json")" + echo "claude_config_dir=$claude_dir" + echo "claude_store=${claude_dir:-$HOME}/.claude.json" + echo "pi_trust_store=$HOME/.pi/agent/trust.json" + echo "treehouse_dir=$HOME/.treehouse" + echo "tmux_dir=$tmux_dir" + } > "$root/.fm-live-lab" + + lab_tmux "$root" new-session -d -s firstmate -n lab -c "$root" 'exec sleep 600' + record_pid "$root" "$(lab_tmux "$root" display-message -p '#{pid}')" + lab_tmux "$root" new-window -d -t firstmate: -n main -c "$home" "printf 'LABREADY-$NONCE\n'; exec sleep 600" + lab_tmux "$root" new-window -d -t firstmate: -n "fm-$MATE_ID" -c "$root/mate" 'exec sleep 600' + lab_tmux "$root" new-window -d -t firstmate: -n "fm-$WORKER_ID" -c "$root" 'exec sleep 600' + fm_write_meta "$home/state/$MATE_ID.meta" "window=firstmate:fm-$MATE_ID" "tasktmp=/tmp/fm-$MATE_ID" + fm_write_meta "$home/state/$WORKER_ID.meta" "window=firstmate:fm-$WORKER_ID" "worktree=$home/projects/notes" "kind=secondmate" "tasktmp=/tmp/fm-$WORKER_ID" + mkdir -p "$home/projects/notes" + printf 'paused [at=1]: waiting on gate file %s to exist\n' "$home/data/$WORKER_ID/gate" > "$home/state/$WORKER_ID.status" + + lock_pid=$(lab_tmux "$root" display-message -p -t firstmate:=main '#{pane_pid}') + printf '%s\n' "$lock_pid" > "$home/state/.lock" + printf '%s\n' "$(lab_tmux "$root" display-message -p -t "firstmate:=fm-$MATE_ID" '#{pane_pid}')" > "$root/mate/state/.lock" + start_watcher "$home" + printf 'host\t%s\tx\n' "$(start_sleeper)" > "$home/state/.supervision-host" + printf '%s\n' \ + '{"seq":1,"epoch":1,"key":"k","id":"a","tag":"captain","text":"probe"}' \ + '{"seq":2,"epoch":2,"key":"k","id":"b","tag":"main","text":"LABREADY"}' > "$home/state/.host-mirror.jsonl" + write_pi_markers "$home" "$lock_pid" + [ "$harness" = claude ] && node -e 'const fs=require("node:fs");const [s,h,r]=process.argv.slice(1);fs.writeFileSync(s,JSON.stringify({keep:1,projects:{[h]:{hasTrustDialogAccepted:true},[r+"/mate"]:{hasTrustDialogAccepted:true},"/elsewhere/project":{hasTrustDialogAccepted:true}}},null,2)+"\n")' \ + "${claude_dir:-$HOME}/.claude.json" "$home" "$root" + printf '%s\n' "$root" +} + +# Recorded fixture roots must not share the runner's process group. +start_group() { perl -e 'setpgrp(0,0); exec @ARGV' "$@" >/dev/null 2>&1 & } + +record_pid() { # + printf 'launch_pid=%s\nlaunch_start=%s\n' "$2" "$(ps -o lstart= -p "$2" | awk '{$1=$1; print}')" >> "$1/.fm-live-lab" +} + +lab_tmux() { # + local dir + dir=$(sed -n 's/^tmux_dir=//p' "$1/.fm-live-lab") + shift + env -u TMUX TMUX_TMPDIR="$dir" tmux "$@" +} + +start_sleeper() { + sleep 600 >/dev/null 2>&1 & + printf '%s\n' "$!" >> "$TMP_ROOT/pids" + printf '%s\n' "$!" +} + +start_watcher() { # : a live process holding a matching watcher lock + local home=$1 pid lock="$1/state/.watch.lock" + pid=$(start_sleeper) + mkdir -p "$lock" + printf '%s\n' "$pid" > "$lock/pid" + printf '%s\n' "$home" > "$lock/fm-home" + printf '%s\n' "$home/bin/fm-watch.sh" > "$lock/watcher-path" + fm_test_pid_identity "$pid" > "$lock/pid-identity" + touch "$home/state/.last-watcher-beat" +} + +write_pi_markers() { # + local home=$1 pid=$2 + v() { FM_HOME="$home" bash -c '. "$1/bin/fm-wake-lib.sh"; fm_pi_extension_version "$1/.pi/extensions/$2"' _ "$home" "$1"; } + printf '%s\n%s\ngeneration=1 phase=active\n' "$(v fm-primary-pi-watch.ts)" "$pid" > "$home/state/.pi-watch-extension-loaded" + printf '%s\n%s\n' "$(v fm-primary-turnend-guard.ts)" "$pid" > "$home/state/.pi-turnend-extension-loaded" + printf '%s\n' "$pid" > "$home/state/.pi-branch-extension-loaded" +} + +run_check() { # : sets CHECK_OUT and CHECK_RC + CHECK_OUT=$("$LIVE_LAB" check "$1" 2>&1) + CHECK_RC=$? +} + +set_record() { # + sed -i.bak "s|^$2=.*|$2=$3|" "$1/.fm-live-lab" && rm -f "$1/.fm-live-lab.bak" +} + +# ---- Claude lab: every check passes on the shape up builds ----------------- + +C=$(make_lab c claude) +CH="$C/home" +run_check "$C" +expect_code 0 "$CHECK_RC" "a Claude lab in up's shape is ready: $CHECK_OUT" +for name in primary probe trust mirror host watcher mate worker treehouse; do + assert_contains "$CHECK_OUT" "ok $name:" "the $name check passes on a ready Claude lab" +done +pass "a ready Claude lab passes every readiness check" + +# primary: the lab session lock must name a live process (session start ran). +printf '999999\n' > "$CH/state/.lock" +run_check "$C" +expect_code 1 "$CHECK_RC" "a dead session lock is not ready" +assert_contains "$CHECK_OUT" "fail primary: the lab session lock names no live process" "primary names the dead lock" +lab_tmux "$C" display-message -p -t firstmate:=main '#{pane_pid}' > "$CH/state/.lock" +pass "primary fails when session start never took the lab lock" + +# primary: a lab home that is a linked worktree is not the genuine primary +# checkout a mirrored Claude primary needs. +WT_CASE="$TMP_ROOT/wtcase" +fm_git_worktree "$WT_CASE/project" "$WT_CASE/wt" lab-wt +cp "$C/.fm-live-lab" "$WT_CASE/.fm-live-lab" +set_record "$WT_CASE" home "$WT_CASE/wt" +lab_tmux "$C" new-window -d -t firstmate: -n wtmain -c "$WT_CASE/wt" 'exec sleep 600' +lab_tmux "$C" kill-window -t firstmate:=main +lab_tmux "$C" rename-window -t firstmate:=wtmain main +run_check "$WT_CASE" +assert_contains "$CHECK_OUT" "fail primary: the lab home is not a primary checkout" "primary refuses a linked-worktree home" +lab_tmux "$C" kill-window -t firstmate:=main +lab_tmux "$C" new-window -d -t firstmate: -n main -c "$CH" "printf 'LABREADY-$NONCE\n'; exec sleep 600" +lab_tmux "$C" display-message -p -t firstmate:=main '#{pane_pid}' > "$CH/state/.lock" +pass "primary fails when the primary runs in a linked worktree instead of the lab's primary checkout" + +# probe: the nonce reply proves the model is accepted and a turn completed. +set_record "$C" nonce 00000000 +run_check "$C" +assert_contains "$CHECK_OUT" "fail probe: no LABREADY-00000000 reply" "probe names the missing reply" +set_record "$C" nonce "$NONCE" +pass "probe fails when the primary never answered its nonce" + +# trust: the workspace-trust prompt wedged the first lab. +cp "$HOME/.claude.json" "$TMP_ROOT/claude.json.keep" +node -e 'const fs=require("node:fs");const [s,h]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,"utf8"));delete j.projects[h];fs.writeFileSync(s,JSON.stringify(j))' "$HOME/.claude.json" "$CH" +run_check "$C" +assert_contains "$CHECK_OUT" "fail trust: $CH has no registered Claude workspace trust" "trust names the untrusted home" +cp "$TMP_ROOT/claude.json.keep" "$HOME/.claude.json" +pass "trust fails when the lab home has no registered Claude trust" + +# mirror: the dialog mirror feed must be wired and hold both sides. +cp "$CH/state/.host-mirror.jsonl" "$TMP_ROOT/mirror.keep" +head -n 1 "$TMP_ROOT/mirror.keep" > "$CH/state/.host-mirror.jsonl" +run_check "$C" +assert_contains "$CHECK_OUT" "fail mirror: the dialog mirror has no captain and main entry yet (captain=1 main=0)" "mirror needs a main entry" +rm -f "$CH/state/.host-mirror.jsonl" +run_check "$C" +assert_contains "$CHECK_OUT" "fail mirror: fm-host-mirror.sh check exited 1" "mirror fails without a mirror file" +cp "$TMP_ROOT/mirror.keep" "$CH/state/.host-mirror.jsonl" +pass "mirror fails when the feed is unwired or has not recorded a whole turn" + +# host: expected on Claude, refused when it should be absent. +host_pid=$(awk -F '\t' '{print $2}' "$CH/state/.supervision-host") +kill "$host_pid" 2>/dev/null +wait "$host_pid" 2>/dev/null +run_check "$C" +assert_contains "$CHECK_OUT" "fail host: no live supervision host (expected one)" "host names the missing host" +set_record "$C" expect_host no +run_check "$C" +assert_contains "$CHECK_OUT" "ok host: none running, as expected" "an opted-out lab expects no host" +assert_not_contains "$CHECK_OUT" "mirror" "an opted-out lab skips the mirror" +host_pid=$(start_sleeper) +printf 'host\t%s\tx\n' "$host_pid" > "$CH/state/.supervision-host" +run_check "$C" +assert_contains "$CHECK_OUT" "fail host: supervision host pid $host_pid runs (expected none)" "an opted-out lab refuses a host" +set_record "$C" expect_host yes +pass "host passes and fails according to --expect-host" + +# watcher: a stale beacon is not supervision. +touch -t 202001010000 "$CH/state/.last-watcher-beat" +run_check "$C" +assert_contains "$CHECK_OUT" "fail watcher: no live watcher with a fresh beacon" "watcher names the stale beacon" +touch "$CH/state/.last-watcher-beat" +pass "watcher fails on a stale beacon" + +# mate: its own window, targeted exactly. A missing window must not resolve to +# another one (tmux falls back to the current window for an unknown name). +lab_tmux "$C" kill-window -t "firstmate:=fm-$MATE_ID" +run_check "$C" +assert_contains "$CHECK_OUT" "fail mate: the $MATE_ID window is not running" "mate names its missing window" +lab_tmux "$C" new-window -d -t firstmate: -n "fm-$MATE_ID" -c "$C/mate" 'exec sleep 600' +rm -f "$C/mate/state/.lock" +run_check "$C" +assert_contains "$CHECK_OUT" "fail mate: the mate holds no session lock yet" "mate needs its own session lock" +lab_tmux "$C" display-message -p -t "firstmate:=fm-$MATE_ID" '#{pane_pid}' > "$C/mate/state/.lock" +pass "mate fails when its window is gone or it never reached its charter" + +# The current-state reader, not an old event, establishes the gate wait. +GATE="$CH/data/$WORKER_ID/gate" +assert_contains "$(sed -n 's/^gate=//p' "$C/.fm-live-lab")" "$CH/data/$WORKER_ID/" "operator can find the gate in the worker's task directory" +: > "$CH/state/$WORKER_ID.status" +run_check "$C" +assert_contains "$CHECK_OUT" "fail worker: the worker is not currently parked" "worker needs a current pause" +printf 'paused [at=1]: waiting on gate file %s to exist\n' "$GATE" > "$CH/state/$WORKER_ID.status" +printf 'working [at=2]: resumed\n' >> "$CH/state/$WORKER_ID.status" +run_check "$C" +assert_contains "$CHECK_OUT" "fail worker: the worker is not currently parked" "stale paused event cannot pass readiness" +printf 'paused [at=3]: waiting on gate file %s to exist\n' "$GATE" >> "$CH/state/$WORKER_ID.status" +run_check "$C" +assert_contains "$CHECK_OUT" "ok worker: $WORKER_ID parked on $GATE" "current pause passes readiness" +pass "worker readiness follows current crew state and the recorded accessible gate" + +# treehouse: a worker pool must land inside the lab, never in ~/.treehouse. +mkdir "$HOME/.treehouse/notes-leaked" +run_check "$C" +assert_contains "$CHECK_OUT" "fail treehouse: new ~/.treehouse entries: notes-leaked" "treehouse names the leaked pool" +rmdir "$HOME/.treehouse/notes-leaked" +LATER_HOME="$TMP_ROOT/later-home" +mkdir -p "$LATER_HOME" +CHECK_OUT=$(HOME="$LATER_HOME" "$LIVE_LAB" check "$C" 2>&1) +expect_code 0 "$?" "the Claude lab is ready again after every restore, even from a shell with another HOME: $CHECK_OUT" +pass "treehouse fails when a pool lands in ~/.treehouse" + +# ---- Pi lab: extensions and the session-only trust store ------------------- + +P=$(make_lab p pi) +PH="$P/home" +run_check "$P" +expect_code 0 "$CHECK_RC" "a Pi lab in up's shape is ready: $CHECK_OUT" +assert_contains "$CHECK_OUT" "ok extensions: fm-primary-pi-watch fm-primary-turnend-guard fm-branch-supervision" "all three extensions load" +assert_contains "$CHECK_OUT" "ok trust: Pi trust store unchanged" "the Pi trust store is untouched" +assert_not_contains "$CHECK_OUT" "mirror" "a Pi lab has no host mirror check" +rm -f "$PH/state/.pi-branch-extension-loaded" +run_check "$P" +assert_contains "$CHECK_OUT" "fail extensions: fm-branch-supervision.ts is not loaded by the lock holder" "the missing branch extension is named" +printf '%s\n' "$(sed -n 1p "$PH/state/.lock")" > "$PH/state/.pi-branch-extension-loaded" +printf 'stale\n' > "$PH/state/.pi-turnend-extension-loaded" +run_check "$P" +assert_contains "$CHECK_OUT" "fail extensions: fm-primary-turnend-guard.ts is not loaded at its current build" "a stale turn-end build is named" +pass "extensions fail when the Pi lab lacks the branch extension or loads a stale build" + +# ---- down ------------------------------------------------------------------- + +NOT_LAB="$TMP_ROOT/not-a-lab" +mkdir -p "$NOT_LAB/keep" +out=$("$LIVE_LAB" down "$NOT_LAB" 2>&1) +expect_code 1 "$?" "down refuses a path without a lab record" +assert_contains "$out" "carries no lab record" "the refusal names the missing record" +assert_present "$NOT_LAB/keep" "a refused down removes nothing" +pass "down refuses anything up did not build" + +C_HASH=$(printf '%s' "$CH" | shasum -a 256 | awk '{print $1}') +OTHER_ID="labt$$-other" +fm_write_meta "$CH/state/$OTHER_ID.meta" "window=firstmate:fm-$OTHER_ID" "tasktmp=/tmp/fm-$OTHER_ID" +mkdir -p "/tmp/fm-$WORKER_ID/gotmp" "/tmp/fm-$MATE_ID" "/tmp/fm-$WORKER_ID+$C_HASH" "/tmp/fm-$OTHER_ID+$C_HASH" "/tmp/fm-$OTHER_ID" +# An outsider opening a lab path is not owned by the lab. +printf 'sleep 600\n' > "$C/stray.sh" +bash "$C/stray.sh" >/dev/null 2>&1 & +STRAY=$! +printf '%s\n' "$STRAY" >> "$TMP_ROOT/pids" +until STRAY_CHILD=$(pgrep -P "$STRAY" sleep); do sleep 0.1; done +printf '%s\n' "$STRAY_CHILD" >> "$TMP_ROOT/pids" +# A launch-recorded process and its child must be stopped even when not in tmux. +start_group sleep 600 +OWNED=$! +printf '%s\n' "$OWNED" >> "$TMP_ROOT/pids" +record_pid "$C" "$OWNED" +# A process in the runner's group is not part of any recorded lab group. +sleep 600 >/dev/null 2>&1 & +UNRELATED=$! +printf '%s\n' "$UNRELATED" >> "$TMP_ROOT/pids" +[ "$(ps -o pgid= -p "$UNRELATED" | awk '{$1=$1; print}')" != "$(ps -o pgid= -p "$OWNED" | awk '{$1=$1; print}')" ] || fail "fixture roots must have their own group" +# A sibling lab root that shares this root as a string prefix is not this lab. +mkdir -p "${C}2" +printf 'sleep 600\n' > "${C}2/stray.sh" +bash "${C}2/stray.sh" 2>/dev/null & +SIBLING=$! +printf '%s\n' "$SIBLING" >> "$TMP_ROOT/pids" +# The worker spawn failed after keeping its task temp dirs, before its meta. +rm -f "$CH/state/$WORKER_ID.meta" +C_TMUX=$(sed -n 's/^tmux_dir=//p' "$C/.fm-live-lab") +out=$(HOME="$LATER_HOME" "$LIVE_LAB" down "$C" 2>&1) +expect_code 0 "$?" "down of a clean Claude lab succeeds from a shell with another HOME: $out" +kill -0 "$STRAY" 2>/dev/null || fail "down leaves unrelated processes opening the lab path alone" +kill -0 "$STRAY_CHILD" 2>/dev/null || fail "down leaves their descendants alone" +! kill -0 "$OWNED" 2>/dev/null || fail "down stops recorded launch processes" +kill -0 "$UNRELATED" 2>/dev/null || fail "down signalled an unrelated process outside recorded groups" +assert_absent "$C" "down removes the lab root" +kill -0 "$SIBLING" 2>/dev/null || fail "down leaves a sibling root's process running" +pkill -P "$SIBLING" 2>/dev/null +kill "$SIBLING" 2>/dev/null +assert_absent "$C_TMUX" "down removes the private tmux directory" +assert_absent "/tmp/fm-$WORKER_ID" "down removes the worker's task temp dir, even without its meta" +assert_absent "/tmp/fm-$MATE_ID" "down removes the mate's task temp dir" +assert_absent "/tmp/fm-$WORKER_ID+$C_HASH" "down removes the worker's launch dir" +assert_absent "/tmp/fm-$OTHER_ID+$C_HASH" "down removes a lab-spawned task's launch dir scoped to the lab home" +assert_present "/tmp/fm-$OTHER_ID" "down keeps a task temp dir another home could share" +kept=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(JSON.stringify([j.keep,Object.keys(j.projects).sort()]))' "$HOME/.claude.json") +assert_equals '[1,["/elsewhere/project"]]' "$kept" "down removes exactly the lab's Claude project entries" +assert_contains "$out" "removed: 2 Claude project entries" "down reports the removed entries" +kill "$STRAY" "$STRAY_CHILD" 2>/dev/null || true +pass "down stops only recorded lab processes, removes trust entries and task temp dirs" + +# The Claude store up selected is the one check and down use, even from a later +# shell with another CLAUDE_CONFIG_DIR, and a symlinked store stays a symlink. +SC="$TMP_ROOT/claude-config" +mkdir -p "$SC" +S=$(make_lab s claude "$SC") +mv "$SC/.claude.json" "$TMP_ROOT/claude-store-target.json" +ln -s "$TMP_ROOT/claude-store-target.json" "$SC/.claude.json" +HOME_STORE_BEFORE=$(digest "$HOME/.claude.json") +CHECK_OUT=$(CLAUDE_CONFIG_DIR="$TMP_ROOT/other-config" "$LIVE_LAB" check "$S" 2>&1) +assert_contains "$CHECK_OUT" "ok trust: $S/home is trusted in the Claude store" "check reads the recorded store" +out=$(CLAUDE_CONFIG_DIR="$TMP_ROOT/other-config" "$LIVE_LAB" down "$S" 2>&1) +expect_code 0 "$?" "down of a lab on a configured store succeeds: $out" +assert_contains "$out" "removed: 2 Claude project entries" "down removes the entries from the recorded store" +[ -L "$SC/.claude.json" ] || fail "down keeps a symlinked Claude store a symlink" +kept=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(JSON.stringify([j.keep,Object.keys(j.projects).sort()]))' "$TMP_ROOT/claude-store-target.json") +assert_equals '[1,["/elsewhere/project"]]' "$kept" "down rewrites the symlink's target" +assert_equals "$HOME_STORE_BEFORE" "$(digest "$HOME/.claude.json")" "down leaves the default store alone" +pass "check and down use the recorded Claude store and keep a symlinked store linked" + +# TERM handlers may write trust again, and an uncooperative lab process must +# be killed before the store or lab directory is removed. +X=$(make_lab x claude) +cat > "$TMP_ROOT/exit-rewriter.sh" <<'SH' +store=$1 key=$2 marker=$3 +trap 'sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$store" "$key"; echo rewrote > "$marker"; exit 0' TERM +while :; do sleep 0.1; done +SH +start_group bash "$TMP_ROOT/exit-rewriter.sh" "$HOME/.claude.json" "$X/home" "$TMP_ROOT/rewrote" +REWRITER=$! +start_group bash -c 'trap "" TERM; while :; do sleep 0.1; done' +STUBBORN=$! +sleep 0.2 +printf '%s\n%s\n' "$REWRITER" "$STUBBORN" >> "$TMP_ROOT/pids" +record_pid "$X" "$REWRITER" +record_pid "$X" "$STUBBORN" +out=$("$LIVE_LAB" down "$X" 2>&1) +expect_code 0 "$?" "down waits for lab processes: $out" +wait "$REWRITER" 2>/dev/null || true +assert_equals rewrote "$(cat "$TMP_ROOT/rewrote" 2>/dev/null)" "TERM handler rewrote its Claude key before down returned" +! kill -0 "$STUBBORN" 2>/dev/null || fail "down must kill a TERM-resistant lab process" +sleep 1.5 +lab_keys=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(Object.keys(j.projects).filter(k=>k.startsWith(process.argv[2])).join(" "))' "$HOME/.claude.json" "$X") +assert_equals "" "$lab_keys" "no exiting lab process re-adds Claude trust" +pass "down waits for TERM handlers and escalates before removing trust" + +# A pane root may exit on TERM while its child remains alive, reparented and +# still able to write Claude trust. Teardown must wait for the captured child. +ORPHAN=$(make_lab orphan claude) +cat > "$TMP_ROOT/orphan-rewriter.sh" <<'SH' +store=$1 key=$2 marker=$3 +trap 'sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$store" "$key"; echo rewrote > "$marker"; exit 0' TERM +while :; do sleep 0.1; done +SH +# shellcheck disable=SC2016 # Positional parameters expand in the launched shell. +start_group bash -c 'bash "$1" "$2" "$3" "$4" & echo $! > "$5"; while :; do sleep 0.1; done' _ \ + "$TMP_ROOT/orphan-rewriter.sh" "$HOME/.claude.json" "$ORPHAN/home" "$TMP_ROOT/orphan-rewrote" "$TMP_ROOT/orphan-child" +ORPHAN_ROOT=$! +until [ -s "$TMP_ROOT/orphan-child" ]; do sleep 0.1; done +ORPHAN_CHILD=$(cat "$TMP_ROOT/orphan-child") +printf '%s\n%s\n' "$ORPHAN_ROOT" "$ORPHAN_CHILD" >> "$TMP_ROOT/pids" +record_pid "$ORPHAN" "$ORPHAN_ROOT" +out=$("$LIVE_LAB" down "$ORPHAN" 2>&1) +expect_code 0 "$?" "down waits for a reparented child: $out" +assert_equals rewrote "$(cat "$TMP_ROOT/orphan-rewrote" 2>/dev/null)" "child finished its TERM handler before cleanup" +lab_keys=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(Object.keys(j.projects).filter(k=>k.startsWith(process.argv[2])).join(" "))' "$HOME/.claude.json" "$ORPHAN") +assert_equals "" "$lab_keys" "reparented child cannot re-add trust after down" +pass "down waits for captured descendants after their root exits" + +# A recorded process can create a new descendant only after TERM arrives. +LATE=$(make_lab late claude) +cat > "$TMP_ROOT/late-rewriter.sh" <<'SH' +store=$1 key=$2 marker=$3 +trap 'bash -c '\''sleep 1; node -e "const fs=require(\"node:fs\");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,\"utf8\"));j.projects[k]={hasTrustDialogAccepted:true};fs.writeFileSync(s,JSON.stringify(j))" "$1" "$2"'\'' _ "$store" "$key" >/dev/null 2>&1 & echo $! > "$marker"; exit 0' TERM +echo ready > "$marker.ready" +while :; do sleep 0.1; done +SH +start_group bash "$TMP_ROOT/late-rewriter.sh" "$HOME/.claude.json" "$LATE/home" "$TMP_ROOT/late-child" +LATE_ROOT=$! +printf '%s\n' "$LATE_ROOT" >> "$TMP_ROOT/pids" +for ((attempt=0; attempt<50; attempt++)); do + [ -s "$TMP_ROOT/late-child.ready" ] && break + sleep 0.1 +done +assert_present "$TMP_ROOT/late-child.ready" "TERM fixture installed its handler before down" +record_pid "$LATE" "$LATE_ROOT" +out=$("$LIVE_LAB" down "$LATE" 2>&1) +expect_code 0 "$?" "down waits for a child born during TERM: $out" +assert_present "$TMP_ROOT/late-child" "TERM handler spawned a child" +LATE_CHILD=$(cat "$TMP_ROOT/late-child") +printf '%s\n' "$LATE_CHILD" >> "$TMP_ROOT/pids" +case "$(ps -o stat= -p "$LATE_CHILD" 2>/dev/null | awk '{$1=$1; print}')" in ''|Z*) ;; *) fail "down leaves a TERM-spawned child alive" ;; esac +lab_keys=$(node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));console.log(Object.keys(j.projects).filter(k=>k.startsWith(process.argv[2])).join(" "))' "$HOME/.claude.json" "$LATE") +assert_equals "" "$lab_keys" "TERM-spawned child cannot re-add trust after down" +pass "down tracks descendants spawned during TERM" + +# A reused PID with a different start time must not own its new process tree. +Y=$(make_lab y claude) +# shellcheck disable=SC2016 # Positional parameters expand in the launched shell. +start_group bash -c 'sleep 600 & echo $! > "$1"; wait' _ "$TMP_ROOT/reused-child"; REUSED=$! +until [ -s "$TMP_ROOT/reused-child" ]; do sleep 0.1; done +REUSED_CHILD=$(cat "$TMP_ROOT/reused-child") +printf '%s\n%s\n' "$REUSED" "$REUSED_CHILD" >> "$TMP_ROOT/pids" +printf 'launch_pid=%s\nlaunch_start=Mon Jan 1 00:00:00 1990\n' "$REUSED" >> "$Y/.fm-live-lab" +out=$("$LIVE_LAB" down "$Y" 2>&1) +expect_code 0 "$?" "down skips the mismatched root: $out" +kill -0 "$REUSED" 2>/dev/null || fail "down killed a reused PID" +kill -0 "$REUSED_CHILD" 2>/dev/null || fail "down killed the reused PID's child" +pass "down ignores roots with mismatched start times" + +# A group observed empty must not be admitted again if its id is later reused. +# The ps shim hides the first group's only member on pass 2, then presents an +# unrelated process under that pgid on pass 3 while another lab group waits. +GROUP_REUSE=$(make_lab group-reuse claude) +start_group python3 -c 'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(600)' +GROUP_ROOT=$! +start_group python3 -c 'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(600)' +WAIT_ROOT=$! +sleep 0.2 +printf '%s\n%s\n' "$GROUP_ROOT" "$WAIT_ROOT" >> "$TMP_ROOT/pids" +record_pid "$GROUP_REUSE" "$GROUP_ROOT" +record_pid "$GROUP_REUSE" "$WAIT_ROOT" +sleep 600 >/dev/null 2>&1 & +GROUP_OUTSIDER=$! +printf '%s\n' "$GROUP_OUTSIDER" >> "$TMP_ROOT/pids" +GROUP_ID=$(ps -o pgid= -p "$GROUP_ROOT" | awk '{$1=$1; print}') +mkdir -p "$TMP_ROOT/group-ps-bin" +cat > "$TMP_ROOT/group-ps-bin/ps" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = -axo ] && [ "${2:-}" = 'pid=,ppid=,pgid=,stat=,lstart=' ]; then + count=$(cat "$PS_SCAN_COUNT" 2>/dev/null || echo 0) + count=$((count + 1)) + echo "$count" > "$PS_SCAN_COUNT" + "$REAL_PS" "$@" | awk -v scan="$count" -v root="$PS_GROUP_ROOT" -v outsider="$PS_OUTSIDER" -v group="$PS_GROUP_ID" ' + scan >= 2 && $1 == root { next } + scan >= 3 && $1 == outsider { $3=group } + { print } + ' +else + "$REAL_PS" "$@" +fi +SH +chmod +x "$TMP_ROOT/group-ps-bin/ps" +out=$(REAL_PS="$(command -v ps)" PS_SCAN_COUNT="$TMP_ROOT/group-scan-count" PS_GROUP_ROOT="$GROUP_ROOT" PS_OUTSIDER="$GROUP_OUTSIDER" PS_GROUP_ID="$GROUP_ID" PATH="$TMP_ROOT/group-ps-bin:$PATH" "$LIVE_LAB" down "$GROUP_REUSE" 2>&1) +expect_code 0 "$?" "down ignores a reused group id: $out" +[ "$(cat "$TMP_ROOT/group-scan-count")" -ge 3 ] || fail "fixture did not expose the reused group id" +kill -0 "$GROUP_OUTSIDER" 2>/dev/null || fail "down signalled an unrelated process with a reused group id" +pass "down drops empty groups permanently before their ids can be reused" + +# Simulate a recorded PID changing identity after TERM: the first process +# snapshot matches its start time, subsequent snapshots describe a reused PID. +Z=$(make_lab z claude) +start_group python3 -c 'import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(600)' +REPLACED=$! +printf '%s\n' "$REPLACED" >> "$TMP_ROOT/pids" +record_pid "$Z" "$REPLACED" +mkdir -p "$TMP_ROOT/ps-bin" +printf '%s\n' "$REPLACED" > "$TMP_ROOT/replaced-pid" +cat > "$TMP_ROOT/ps-bin/ps" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = -o ] && [ "${2:-}" = 'stat=,lstart=' ] && [ "${4:-}" = "$(cat "$PS_TARGET")" ]; then + count=$(cat "$PS_COUNT" 2>/dev/null || echo 0) + echo "$((count + 1))" > "$PS_COUNT" + if [ "$count" -gt 0 ]; then + echo 'S Mon Jan 1 00:00:00 1990' + else + "$REAL_PS" "$@" + fi +else + "$REAL_PS" "$@" +fi +SH +chmod +x "$TMP_ROOT/ps-bin/ps" +start=$(date +%s) +out=$(REAL_PS="$(command -v ps)" PS_COUNT="$TMP_ROOT/ps-count" PS_TARGET="$TMP_ROOT/replaced-pid" PATH="$TMP_ROOT/ps-bin:$PATH" "$LIVE_LAB" down "$Z" 2>&1) +expect_code 0 "$?" "down must not treat the changed PID as a survivor: $out" +[ "$(( $(date +%s) - start ))" -lt 8 ] || fail "down waited on a PID with a different start time" +kill -0 "$REPLACED" 2>/dev/null || fail "down killed a PID after its recorded identity changed" +kill "$REPLACED" 2>/dev/null || true +pass "down revalidates process identity during its bounded wait" + +printf '{"trusted":["/somewhere"]}\n' > "$HOME/.pi/agent/trust.json" +run_check "$P" +assert_contains "$CHECK_OUT" "fail trust: the Pi trust store changed since up began" "a written Pi trust store is caught" +pass "trust fails on Pi when the lab wrote the persistent Pi trust store" + +out=$("$LIVE_LAB" down "$P" 2>&1) +expect_code 1 "$?" "down reports a changed Pi trust store" +assert_contains "$out" "the Pi trust store changed since up began; left as is" "the Pi trust change is named" +assert_absent "$P" "the lab is still removed" +assert_equals '{"trusted":["/somewhere"]}' "$(cat "$HOME/.pi/agent/trust.json")" "down never rewrites the Pi trust store" +pass "down removes the lab but reports, without reverting, a written Pi trust store" + +# ---- up argument safety ----------------------------------------------------- + +EXISTING="$TMP_ROOT/existing" +mkdir -p "$EXISTING/keep" +out=$("$LIVE_LAB" up --harness claude "$EXISTING" 2>&1) +expect_code 1 "$?" "up refuses an existing lab root" +assert_contains "$out" "a lab root must not exist yet" "the refusal names the existing root" +assert_present "$EXISTING/keep" "a refused up touches nothing" +out=$("$LIVE_LAB" up --harness codex "$TMP_ROOT/new" 2>&1) +expect_code 1 "$?" "up refuses an unsupported harness" +assert_absent "$TMP_ROOT/new" "a refused harness creates nothing" +pass "up refuses an existing root and an unsupported harness" + +# up persists full-width, distinct task IDs even when checkout fails before +# launching a harness; down can still clean this partial lab. +PARTIAL="$TMP_ROOT/partial-lab" +mkdir -p "$TMP_ROOT/stub-bin" +cat > "$TMP_ROOT/stub-bin/claude" <<'SH' +#!/bin/sh +: > "$FM_HOME/state/.session-start-complete" +exec sleep 45 >/dev/null 2>&1 +SH +chmod +x "$TMP_ROOT/stub-bin/claude" +out=$(PATH="$TMP_ROOT/stub-bin:$PATH" "$LIVE_LAB" up --harness claude --source "$TMP_ROOT/missing-origin" "$PARTIAL" 2>&1) +expect_code 1 "$?" "an unavailable source stops up before launch" +assert_present "$PARTIAL/.fm-live-lab" "up recorded its selected task IDs" +ids=$(awk -F= '/^(mate_id|worker_id)=/ {print $2}' "$PARTIAL/.fm-live-lab") +if ! printf '%s\n' "$ids" | grep -Eq '^lab[0-9a-f]{12}-(mate|worker)$'; then fail "task IDs need twelve nonce hex digits: $ids"; fi +assert_equals 2 "$(printf '%s\n' "$ids" | grep -Ec '^lab[0-9a-f]{12}-(mate|worker)$')" "both mate and worker use twelve nonce digits" +out=$($LIVE_LAB down "$PARTIAL" 2>&1) +expect_code 0 "$?" "down cleans a lab whose checkout failed: $out" +assert_absent "$PARTIAL" "partial lab removed" +pass "up gives both task IDs a long nonce and down cleans partial setup" + +# A rival Claude writer drops the newly registered primary key once. The +# stand-in primary checks the store on startup, while the readiness probe fails. +UPSRC="$TMP_ROOT/up-source" +mkdir -p "$UPSRC" +cp -R "$ROOT/bin" "$UPSRC/bin" +cp "$ROOT/AGENTS.md" "$UPSRC/AGENTS.md" +git -C "$UPSRC" init -q -b main +git -C "$UPSRC" add -A +git -C "$UPSRC" -c user.name=t -c user.email=t@example.invalid commit -qm source +# A worker can have written its first status while still working. That must +# not hold up the Claude primary; the generated brief must ask it to end its +# turn on the gate instead of running a foreground polling command. +WORKSRC="$TMP_ROOT/worker-source" +cp -R "$UPSRC" "$WORKSRC" +cat > "$WORKSRC/bin/fm-brief.sh" <<'SH' +#!/usr/bin/env bash +mkdir -p "$FM_HOME/data/$1" +printf '{TASK}\n{FIRSTMATE_SPEC}\n' > "$FM_HOME/data/$1/brief.md" +SH +cat > "$WORKSRC/bin/fm-tasks-axi.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH +cat > "$WORKSRC/bin/fm-spawn.sh" <<'SH' +#!/usr/bin/env bash +id=$1 +tmux new-window -d -t firstmate: -n "fm-$id" -c "$FM_HOME" 'exec sleep 45 >/dev/null 2>&1' || exit 1 +# A missing =name can silently resolve to the current window: verify the name. +for (( n=0; n<30; n++ )); do + if tmux list-windows -t firstmate -F '#{window_name}' | grep -Fxq "fm-$id"; then + pid=$(tmux display-message -p -t "firstmate:=fm-$id" '#{pane_pid}') + [ -z "$pid" ] || break + fi + sleep 0.1 +done +[ -n "${pid:-}" ] || exit 1 +printf 'window=firstmate:fm-%s\n' "$id" > "$FM_HOME/state/$id.meta" +printf 'working [at=1]: setting up\n' > "$FM_HOME/state/$id.status" +SH +chmod +x "$WORKSRC/bin/"{fm-brief,fm-tasks-axi,fm-spawn}.sh +git -C "$WORKSRC" add -A +git -C "$WORKSRC" -c user.name=t -c user.email=t@example.invalid commit -qm stubs +W="$TMP_ROOT/worker-up" +out=$(SHELL=/bin/sh PATH="$TMP_ROOT/stub-bin:$PATH" "$LIVE_LAB" up --harness claude --worker --source "$WORKSRC" --timeout 0 "$W" 2>&1) +expect_code 1 "$?" "unanswered probe leaves worker lab for inspection: $out" +assert_contains "$out" "primary: claude" "the unparked worker did not block primary launch" +assert_contains "$out" "gate: $W/home/data/" "up shows the gate path" +assert_contains "$out" "then message the worker to resume" "up explains the release message" +worker_id=$(sed -n 's/^worker_id=//p' "$W/.fm-live-lab") +brief=$(<"$W/home/data/$worker_id/brief.md") +assert_contains "$brief" "append one paused status line naming the gate file" "worker declares its wait" +assert_contains "$brief" "and end your turn" "worker ends its waiting turn" +assert_contains "$brief" "Do not poll or sleep in a foreground command" "worker does not run a blocking wait" +assert_contains "$brief" "When a later message resumes you, check that" "worker checks the gate after a message" +assert_contains "$out" "fail worker: the worker is not currently parked" "final readiness remains strict" +node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.exit(j.projects?.[process.argv[2]]?.hasTrustDialogAccepted===true?0:1)' "$HOME/.claude.json" "$W/home" || fail "primary trust was not registered before launch" +out=$("$LIVE_LAB" down "$W" 2>&1) +expect_code 0 "$?" "down cleans the worker lab: $out" +pass "up launches primary after worker status without weakening final parked readiness" + +# If a spawn reports success without a pane, fail at the missing PID rather +# than invoking ps with an empty -p argument or waiting for readiness. +cat > "$WORKSRC/bin/fm-spawn.sh" <<'SH' +#!/usr/bin/env bash +id=$1 +printf 'window=firstmate:fm-%s\n' "$id" > "$FM_HOME/state/$id.meta" +printf 'working [at=1]: setting up\n' > "$FM_HOME/state/$id.status" +SH +git -C "$WORKSRC" add bin/fm-spawn.sh +git -C "$WORKSRC" -c user.name=t -c user.email=t@example.invalid commit -qm missing-pane +NO_PANE="$TMP_ROOT/no-pane" +out=$(SHELL=/bin/sh PATH="$TMP_ROOT/stub-bin:$PATH" "$LIVE_LAB" up --harness claude --worker --source "$WORKSRC" --timeout 0 "$NO_PANE" 2>&1) +expect_code 1 "$?" "up refuses a worker without a pane PID: $out" +assert_contains "$out" "cannot record lab process: missing or invalid PID ''" "missing pane PID fails at launch recording" +assert_not_contains "$out" "list of process IDs must follow -p" "ps never receives an empty PID" +out=$("$LIVE_LAB" down "$NO_PANE" 2>&1) +expect_code 0 "$?" "down cleans the missing-pane lab: $out" +pass "up fails immediately when a spawned worker has no pane PID" + +FAKEBIN="$TMP_ROOT/fakebin" +mkdir -p "$FAKEBIN" +cat > "$FAKEBIN/claude" <<'SH' +#!/usr/bin/env bash +sleep 1.5 +if node -e 'const [s,k]=process.argv.slice(1);const j=JSON.parse(require("node:fs").readFileSync(s,"utf8"));process.exit(j.projects?.[k]?.hasTrustDialogAccepted===true?0:1)' "$HOME/.claude.json" "$FM_HOME"; then + echo present > "$FM_HOME/../claude-launch-trust" +else + echo absent > "$FM_HOME/../claude-launch-trust" +fi +: > "$FM_HOME/state/.session-start-complete" +exec sleep 45 >/dev/null 2>&1 +SH +chmod +x "$FAKEBIN/claude" +U="$TMP_ROOT/up-lab" +( + end=$(( $(date +%s) + 120 )) + until node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.exit(j.projects?.[process.argv[2]]?1:0)' "$HOME/.claude.json" "$U/home" 2>/dev/null; do + [ "$(date +%s)" -lt "$end" ] || exit 1 + sleep 0.05 + done + sleep 0.5 + node -e 'const fs=require("node:fs");const [s,k]=process.argv.slice(1);const j=JSON.parse(fs.readFileSync(s,"utf8"));delete j.projects[k];fs.writeFileSync(s,JSON.stringify(j))' "$HOME/.claude.json" "$U/home" + echo dropped > "$TMP_ROOT/rival-dropped" +) & +RIVAL=$! +printf '%s\n' "$RIVAL" >> "$TMP_ROOT/pids" +out=$(SHELL=/bin/sh PATH="$FAKEBIN:$PATH" "$LIVE_LAB" up --harness claude --source "$UPSRC" --ref HEAD --timeout 1 "$U" 2>&1) +expect_code 1 "$?" "stand-in primary does not answer probe" +wait "$RIVAL" 2>/dev/null || true +assert_equals dropped "$(cat "$TMP_ROOT/rival-dropped" 2>/dev/null)" "rival dropped primary trust once" +assert_equals present "$(cat "$U/claude-launch-trust" 2>/dev/null)" "primary launched trusted after the rival write" +assert_contains "$out" "ok trust: $U/home is trusted in the Claude store" "readiness sees primary trust" +out=$("$LIVE_LAB" down "$U" 2>&1) +expect_code 0 "$?" "down removes the up-built lab: $out" +pass "up re-registers primary trust after a concurrent Claude write" + +# ---- fm-claude-trust.sh --lab-home ------------------------------------------- + +T="$TMP_ROOT/trust" +mkdir -p "$T/config" +"$ROOT/bin/fm-lab-home.sh" create "$T/home" >/dev/null +cp "$ROOT/AGENTS.md" "$T/home/AGENTS.md" +mkdir -p "$T/home/bin" +git -C "$T/home" init -q -b main +out=$(CLAUDE_CONFIG_DIR="$T/config" "$TRUST" --lab-home "$T/home" 2>&1) +expect_code 0 "$?" "a marked lab primary checkout is trusted: $out" +TH=$(cd -P "$T/home" && pwd -P) +node -e 'const j=JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"));process.exit(j.projects[process.argv[2]].hasTrustDialogAccepted===true&&!("hasClaudeMdExternalIncludesApproved" in j.projects[process.argv[2]])?0:1)' \ + "$T/config/.claude.json" "$TH" || fail "lab-home trust is trust-only" + +mkdir -p "$T/plain/bin" +cp "$ROOT/AGENTS.md" "$T/plain/AGENTS.md" +git -C "$T/plain" init -q -b main +out=$(CLAUDE_CONFIG_DIR="$T/config" "$TRUST" --lab-home "$T/plain" 2>&1) +expect_code 1 "$?" "an unmarked checkout is refused" +assert_contains "$out" "carries no lab-home marker" "the refusal names the missing marker" + +fm_git_worktree "$T/proj" "$T/wt" lab-trust-wt +printf 'fm-lab-home v1\n' > "$T/wt/.fm-lab-home" +cp "$ROOT/AGENTS.md" "$T/wt/AGENTS.md" +mkdir -p "$T/wt/bin" +out=$(CLAUDE_CONFIG_DIR="$T/config" "$TRUST" --lab-home "$T/wt" 2>&1) +expect_code 1 "$?" "a linked worktree is refused" +assert_contains "$out" "is a linked worktree" "the refusal names the linked worktree" + +rm "$T/home/.fm-lab-home" +ln -s "$T/wt/.fm-lab-home" "$T/home/.fm-lab-home" +out=$(CLAUDE_CONFIG_DIR="$T/config" "$TRUST" --lab-home "$T/home" 2>&1) +expect_code 1 "$?" "a symlinked marker is refused" +assert_contains "$out" "is a symlink" "the refusal names the symlink" +pass "fm-claude-trust.sh --lab-home trusts only a marked lab primary checkout"