diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 3233921e071..1aa7d4b7826 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -761,6 +761,24 @@ fm_backend_kill() { # esac } +# fm_backend_current_path: the live occupied working directory of a running +# target, or nonzero when the backend cannot prove it without interacting with +# the agent. tmux and Herdr expose the foreground process cwd passively. Zellij +# and cmux only support an active shell probe, which is safe during pre-agent +# spawn discovery but would submit `pwd` into a running agent during relaunch; +# Orca exposes no corresponding proof. +fm_backend_current_path() { # + local backend=$1 + shift + fm_backend_source "$backend" || return 1 + case "$backend" in + tmux) fm_backend_tmux_current_path "$@" ;; + herdr) fm_backend_herdr_current_path "$@" ;; + zellij|cmux|orca) return 1 ;; + *) echo "error: no current-path implementation for backend '$backend'" >&2; return 1 ;; + esac +} + fm_backend_remove_worktree() { # local backend=$1 shift diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 5cb0a80f5e3..6784f552af9 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -48,6 +48,20 @@ # to launch a ship task whose explicit --mode disagrees, so an adjusted brief and the # recorded task metadata cannot drift apart. # Ship briefs begin with a worktree-isolation assertion before the branch step. +# No path is baked in here: bin/fm-spawn.sh appends the exact copy and the +# repository primary to the launch brief as `# Worktree isolation`, and owns +# both, so a scaffold made in one place can never contradict the project the +# spawn actually launched against. The assertion in this scaffold points at +# that section and compares physical paths, because a repo LABEL is not +# comparable with `pwd -P`, equality of pwd and git-toplevel does not prove +# isolation, and git-dir vs git-common-dir equality only proves the copy is not +# a linked worktree, which an ordinary clone and an Orca-managed copy also +# satisfy. +# A project given as exactly `.` - and nothing else, since every other spelling +# is a repo LABEL rather than a path - resolves to its repository's primary +# working tree (fm-tangle-lib.sh) for the worktree LABEL alone, so `.` from a +# linked firstmate worktree reads as the repository's name rather than a dot; +# any other label is rendered verbatim. # --mode is refused on scout and secondmate scaffolds: a scout's deliverable is a # report rather than a merge, and a charter is not a delivery contract. # There is no --yolo flag here. The worker never owns merge decisions, so yolo is @@ -90,6 +104,8 @@ esac . "$SCRIPT_DIR/fm-classify-lib.sh" # shellcheck source=bin/fm-dod-lib.sh . "$SCRIPT_DIR/fm-dod-lib.sh" +# shellcheck source=bin/fm-tangle-lib.sh +. "$SCRIPT_DIR/fm-tangle-lib.sh" PAUSED_VERB=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} resolve_directory_input() { @@ -310,6 +326,11 @@ exit 0 fi REPO=${POS[1]} +REPO_LABEL=$REPO +if [ "$REPO" = . ] && REPO_ABS=$(pwd -P 2>/dev/null); then + REPO_PRIMARY=$(fm_git_primary_workdir "$REPO_ABS" 2>/dev/null) || REPO_PRIMARY=$REPO_ABS + REPO_LABEL=$(basename "$REPO_PRIMARY") +fi if [ "$HERDR_LAB" -eq 1 ]; then HERDR_LAB_HELPER=$(shell_quote "$FM_ROOT/bin/fm-herdr-lab.sh") @@ -362,7 +383,7 @@ $TASK_SECTION $HERDR_SECTION # Setup -You are in a disposable git worktree of $REPO, at a detached HEAD on a clean default branch. +You are in a disposable git worktree of $REPO_LABEL, at a detached HEAD on a clean default branch. This is a SCOUT task: the deliverable is a written report, not a PR. The worktree is your laboratory - install, run, edit, and make scratch commits freely; all of it is discarded at teardown. The report is the only thing that survives, so anything worth keeping must be in it. @@ -446,11 +467,11 @@ $TASK_SECTION $HERDR_SECTION # Setup -You are in a disposable git worktree of $REPO, at a detached HEAD on a clean default branch. +You are in a disposable git worktree of $REPO_LABEL, at a detached HEAD on a clean default branch. -**Verify isolation before anything else.** Run \`pwd -P\` and \`git rev-parse --show-toplevel\`; both must resolve to the disposable task worktree you were launched in, such as a treehouse pool path or an Orca-managed worktree, not the primary checkout firstmate operates from. -The path check is authoritative: \`git rev-parse --git-dir\` and \`git rev-parse --git-common-dir\` can help inspect the repo, but they do not prove you are outside the primary checkout. -If the top-level path is the primary checkout or not the worktree you were launched in, STOP - do not branch or commit here - append \`blocked: launched in primary checkout, not an isolated worktree\` to the status file and stop. +**Verify isolation before anything else.** Run \`pwd -P\`. It must be exactly the path named in this brief's \`# Worktree isolation\` section, which firstmate renders at launch: your own disposable copy (a treehouse pool path, an Orca-managed worktree, or another isolated worktree), never the project's primary checkout. +Equality of \`pwd\` and \`git rev-parse --show-toplevel\` does not prove isolation: both name the current worktree root in the primary checkout and in a linked worktree alike. Compare the physical paths instead. +If \`pwd -P\` is not that exact worktree path, STOP - do not branch or commit here - append \`blocked: launched in primary checkout, not an isolated worktree\` to the status file and stop. 1. First action: create your branch: \`git checkout -b fm/$ID\`$SETUP2 diff --git a/bin/fm-control.sh b/bin/fm-control.sh index 21146188416..ca9f710be4a 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -46,12 +46,15 @@ # inherits the local copy but none of the conversation; a # secondmate reconciles its own home's records at startup, so its # standing charter is never rewritten. -# Records a durable checkpoint and that note, exits the old agent, -# then delegates the launch to its single owner, -# bin/fm-spawn.sh --relaunch. A failure before publication keeps -# the prior durable record in place and reports the concrete -# state; it never leaves a half-transitioned task claiming to be -# running. +# Records a durable checkpoint and that note, verifies the live +# occupied directory is the recorded worktree (Herdr's frozen +# launch cwd is not that proof), exits the old agent, then +# delegates the launch to its single owner, +# bin/fm-spawn.sh --relaunch. An unverifiable or mismatched live +# cwd refuses before the agent is stopped. A failure before +# publication keeps the prior durable record in place and reports +# the concrete state; it never leaves a half-transitioned task +# claiming to be running. # # Teardown and discard are NOT verbs here and never will be. `exit` stops an # agent and preserves everything else; removing a worktree, killing an @@ -684,6 +687,35 @@ resolve_relaunch_profile() { fi } +# require_relaunch_occupied_worktree: prove, before the running agent is +# stopped, that the live shell occupies the recorded worktree. Herdr's +# pane.cwd is the launch directory and does not update; the backend current- +# path read is the foreground process. A read that comes back empty is +# retried a bounded number of times, as the launch owner does, so one hiccup +# in the backend CLI does not abort a relaunch; a cwd that is still +# unreadable after those attempts preserves the agent. Path identity uses the +# physical directory. +require_relaunch_occupied_worktree() { + local seen seen_real wt_real attempt + wt_real=$(CDPATH='' cd -- "$WT" 2>/dev/null && pwd -P) \ + || die "task $ID's recorded worktree $WT cannot be resolved" + seen= + for attempt in $(seq 1 10); do + seen=$(fm_backend_current_path "$BACKEND" "$T" 2>/dev/null) || seen= + seen=$(printf '%s' "$seen" | tr -d '\r') + [ -z "$seen" ] || break + if [ "$attempt" -lt 10 ]; then sleep 0.5; fi + done + if [ -z "$seen" ]; then + die "task $ID's live working directory cannot be verified; refusing to stop the agent without proof it occupies $WT" + fi + seen_real=$(CDPATH='' cd -- "$seen" 2>/dev/null && pwd -P) || seen_real= + if [ -n "$seen_real" ] && [ "$seen_real" = "$wt_real" ]; then + return 0 + fi + die "task $ID's live shell is in ${seen_real:-$seen}, not its recorded worktree $WT; refusing to stop the agent" +} + # safe_checkpoint: prove, before anything is stopped, that the work a relaunch # must preserve is actually there and recoverable afterwards. Fills # CHECKPOINT_LINES with the journal lines describing what it proved, and @@ -814,6 +846,7 @@ do_relaunch() { note_line="note=none" fi safe_checkpoint + require_relaunch_occupied_worktree cp -p "$META" "$META_PRIOR" || die "could not preserve task $ID's durable record before relaunching" RELAUNCH_ACTIVE=1 journal_write checkpoint "${CHECKPOINT_LINES[@]}" "$note_line" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 1c27df82883..d83c7ba39da 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -39,9 +39,12 @@ # model, and effort may change, which is what makes a harness switch one # ordinary relaunch. It refuses unless the recorded endpoint is positively # agent-free on a backend with a recovery-grade agent-state classifier (tmux -# or herdr), refuses unless the endpoint's shell is sitting in the recorded -# worktree, and clears the previous harness's per-task wiring before arming -# the new incarnation. +# or herdr). The control plane verifies the live occupied directory before +# stopping the previous agent; this script independently refuses unless the +# endpoint's shell is still sitting in the recorded worktree, because a +# ship/scout copy nothing occupies is a copy the pool may already have handed +# to someone else. It clears the previous harness's per-task wiring before +# arming the new incarnation. # --harness is the explicit per-spawn harness/profile adapter. The old # positional harness arg still works for back-compat. # --model and --effort are concrete profile @@ -175,6 +178,14 @@ # Ship/scout spawns refuse to launch unless the resolved task path is a real # git worktree root distinct from both the spawning project and its repository's # primary checkout, including when the spawning project is a linked worktree. +# A project path of `.` resolves to the caller's own PHYSICAL directory, so +# the isolation comparisons cannot be defeated by a symlinked spelling and a +# genuine treehouse copy is not refused. It is never rebound to the +# repository primary: a secondmate spawning from its own leased home stays +# bound to that home. +# The same two paths the guard proved - the resolved copy and the repository +# primary - are appended to every ship/scout launch brief, because the brief's +# repo positional is a label the worker cannot compare with its own `pwd -P`. # On the backends that discover that path by reading the task pane's own cwd, # the same isolation test screens every read: a pane still showing the project # or the repository primary while `treehouse get` prepares the slot is waited @@ -423,6 +434,8 @@ fm_backlog_directory_present "$STATE" "state directory" || { . "$SCRIPT_DIR/fm-pr-lib.sh" # shellcheck source=bin/fm-dod-lib.sh . "$SCRIPT_DIR/fm-dod-lib.sh" +# shellcheck source=bin/fm-tangle-lib.sh +. "$SCRIPT_DIR/fm-tangle-lib.sh" # shellcheck source=bin/fm-trace-context-lib.sh . "$SCRIPT_DIR/fm-trace-context-lib.sh" # shellcheck source=bin/fm-remote-readiness-lib.sh @@ -2142,7 +2155,7 @@ if [ "$KIND" = secondmate ]; then BRIEF="$DATA/$ID/brief.md" fi else - PROJ_ABS="$(cd "$(resolve_project_dir_arg "$PROJ")" && pwd)" + PROJ_ABS="$(CDPATH='' cd -- "$(resolve_project_dir_arg "$PROJ")" && pwd -P)" WT="" BRIEF="$DATA/$ID/brief.md" fi @@ -2996,7 +3009,9 @@ if [ "$RELAUNCH" -eq 1 ]; then # No worktree is acquired: the recorded one is reused as-is. What must be # proven instead is that the adopted endpoint's shell is actually sitting in # that worktree, so the replacement agent starts where the work is rather - # than wherever the pane happened to drift. + # than wherever the pane happened to drift. A shell that is no longer in the + # copy is not corrected into it: for a pooled slot, nothing holding the copy + # is exactly the state in which the pool may hand it to another task. relaunch_wt_real=$(real_path_or_raw "$WT") relaunch_seen= for _ in $(seq 1 10); do @@ -3075,6 +3090,33 @@ if [ "$RELAUNCH" -eq 0 ] && [ "$KIND" != secondmate ]; then freshen_spawn_worktree_base "$WT" || exit 1 fi +# The worker's isolation check needs paths, not names: the brief's repo +# positional is a label, and no label can be compared with `pwd -P`. Both paths +# exist only here, once the copy is resolved and validated, so they are rendered +# into the launch brief the agent is about to read - whatever the project +# spelling was, and whether the copy is a treehouse slot, an Orca worktree, or +# any other accepted isolated copy. +append_worktree_isolation_contract() { + local wt_real primary + wt_real=$(cd "$WT" && pwd -P) || return 1 + primary=$(fm_git_primary_workdir "$PROJ_ABS" 2>/dev/null) || primary= + { + printf '\n# Worktree isolation\n' + # shellcheck disable=SC2016 # Backticks are literal Markdown, not command substitutions. + printf 'Your task worktree is `%s`.\n' "$wt_real" + [ -z "$primary" ] \ + || printf "The project's primary checkout is \`%s\`; it is never yours to work in.\n" "$primary" + # shellcheck disable=SC2016 # Backticks are literal worker instructions. + printf 'Before anything else run `pwd -P`. If it is not exactly `%s`, STOP - do not branch or commit here - append `blocked: launched in primary checkout, not an isolated worktree` to the status file and stop.\n' "$wt_real" + } >> "$BRIEF" +} +if [ "$KIND" = ship ] || [ "$KIND" = scout ]; then + append_worktree_isolation_contract || { + echo "error: could not render task $ID's worktree-isolation contract into $BRIEF" >&2 + exit 1 + } +fi + # Pre-register Claude's workspace trust for the worktree, at the first point the # worktree is known and before any per-task state is created below. The dialog # gates the pane before the brief is ever read, and it also gates loading the diff --git a/bin/fm-tangle-lib.sh b/bin/fm-tangle-lib.sh index a8554fbd60a..13b9dc4ab6d 100644 --- a/bin/fm-tangle-lib.sh +++ b/bin/fm-tangle-lib.sh @@ -35,6 +35,23 @@ fm_default_branch() { return 1 } +# The primary working tree of the repository that belongs to. +# For a linked worktree this is the directory whose git dir is the common dir, +# not itself. Equality of pwd and git-toplevel cannot find that primary: +# both names are the current worktree root in the primary and in a linked copy. +# Echoes an absolute physical path, or returns 1 if it cannot be resolved. +fm_git_primary_workdir() { + local abs common parent parent_git + abs=$(CDPATH='' cd -- "$1" 2>/dev/null && pwd -P) || return 1 + common=$(git -C "$abs" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 1 + common=$(CDPATH='' cd -- "$common" 2>/dev/null && pwd -P) || return 1 + parent=$(dirname "$common") + parent_git=$(git -C "$parent" rev-parse --path-format=absolute --absolute-git-dir 2>/dev/null) || return 1 + parent_git=$(CDPATH='' cd -- "$parent_git" 2>/dev/null && pwd -P) || return 1 + [ "$parent_git" = "$common" ] || return 1 + printf '%s\n' "$parent" +} + # If the git checkout at is tangled - on a NAMED branch that is not its # default branch - echo the offending branch name and return 0. For every healthy # state (not a git work tree, detached HEAD, or already on the default branch) diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index fd2db419806..76aeeea4d6c 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -74,14 +74,20 @@ # name a slot a DIFFERENT live task now holds. Cleanup kills every process under # that path and hard-resets it before returning it, so releasing a slot that is # not genuinely this task's destroys another worker's live work. Before the first -# cleanup step, teardown verifies record exclusivity: no OTHER task record in -# this home or any locally registered Firstmate home may name the same live path -# in its worktree= or home=. One live path with two task records is the reuse -# collision itself, whichever record is stale. The recorded endpoint's exact -# task identity and the record's spawn incarnation are validated separately -# before cleanup. Its current working directory is only incidental process -# state: the same worker remains the owner after changing directory, so cwd can -# never veto teardown of that exact recorded endpoint. +# cleanup step, teardown verifies copy exclusivity against every other live +# claim: no OTHER task record in this home or any locally registered Firstmate +# home may name the same copy in its worktree= or home=. Identity is the +# canonical physical directory, so two spellings of one copy are one claim. +# The check runs for any ship or scout worktree being removed, whatever its +# backend and whether or not it is a recognized pool slot, and on the +# forced-teardown descendant path as well, so a shared ordinary worktree is +# refused before any process is signalled. One live copy with two +# task records is the reuse collision itself, whichever record is stale. The +# recorded endpoint's exact task identity and the record's spawn incarnation +# are validated separately before cleanup. Its current working directory is +# only incidental process state: the same worker remains the owner after +# changing directory, so cwd can never veto teardown of that exact recorded +# endpoint. # The scan and destructive return hold a project-identity lock in the local root # Firstmate home's state directory, as resolved by bin/fm-wake-lib.sh's # fm_firstmate_root_home; a home seeded from another machine is its own local @@ -202,9 +208,18 @@ # hours with no live task meta to attribute them to once teardown had # already removed it). reap_task_worktree_processes finds every process # whose CURRENT WORKING DIRECTORY is this task's own worktree or tasktmp -# root via `lsof -a -d cwd` (cheap: bounded by process count, not by -# walking the worktree's file tree) and sends TERM, then KILL after a short -# grace period to any survivor whose process identity still matches. Both +# root. The scan is bounded by process count, never by walking the +# worktree's file tree, and never selects by process name. `lsof -a -d cwd` +# is the scan; a host WITHOUT lsof reads `/proc//cwd` (or +# FM_PROC_ROOT_OVERRIDE in tests) instead, so a leaked descendant is still +# identified and reaped on the first cleanup rather than forcing a failed +# return. A present-but-failing lsof still refuses: an unreliable scan is +# not evidence that nothing is there. Either scan refuses, rather than +# signalling, when the process it found in the copy is a shell from this +# teardown's own invocation chain: killing the operator's session and +# calling an occupied copy free are both wrong, and which of the two scans +# ran must not change that answer. A host with neither cwd source uses +# the backend process-group fallback. Both # roots are unique per task and never # shared, so this can never reach another task's or the primary's # processes. Idempotent: nothing left to find is a silent no-op. @@ -1816,15 +1831,88 @@ conclude_task_no_mistakes_run() { # return 1 } +# True when a Linux-compatible /proc cwd scan can identify processes without +# lsof. FM_PROC_ROOT_OVERRIDE is the test seam; production uses /proc. +proc_cwd_scan_available() { + local proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc} here probe seen seen_real + [ -d "$proc_root" ] || return 1 + here=$(pwd -P 2>/dev/null) || return 1 + probe="$proc_root/$$/cwd" + [ -L "$probe" ] || return 1 + seen=$(readlink "$probe" 2>/dev/null) || return 1 + seen_real=$(CDPATH='' cd -- "$seen" 2>/dev/null && pwd -P) || return 1 + [ "$seen_real" = "$here" ] +} + +# The exact pid chain that invoked this teardown. A shell in that chain is the +# cleanup control plane, so finding one INSIDE the copy is a refusal, never a +# reap and never a silent exemption. Descendants remain ordinary leftovers. +teardown_ancestor_pids() { + local current=$$ parent hops=0 + while [ "$hops" -lt 64 ]; do + parent=$(ps -o ppid= -p "$current" 2>/dev/null) || return 0 + parent=$(printf '%s' "$parent" | tr -d '[:space:]') + case "$parent" in ''|*[!0-9]*|0|1) return 0 ;; esac + printf '%s\n' "$parent" + current=$parent + hops=$((hops + 1)) + done +} + +# One matched pid, from either scan: this teardown's own pid is not a leftover, +# and neither is a shell from its own invocation chain - but that shell cannot +# be reaped without killing the operator's session, and calling the copy free +# would hand a still-occupied worktree to destructive cleanup, so it refuses. +# Whichever scan found it, the same pid gets the same answer. +emit_reapable_task_pid() { # + local pid=$1 dir=$2 ancestors=$3 + [ -n "$pid" ] && [ "$pid" != "$$" ] || return 0 + if printf '%s\n' "$ancestors" | grep -Fxq "$pid"; then + echo "REFUSED: teardown was invoked from inside $dir (process $pid still has it as its working directory); leave that copy and re-run." >&2 + return 1 + fi + printf '%s\n' "$pid" +} + +# Bounded /proc//cwd scan: process count, never a file-tree walk, never +# a process-name match, never another home's processes. Only pids whose cwd +# is exactly or under it. +pids_with_cwd_under_proc() { # + local dir=$1 ancestors=$2 proc_root pid pid_dir cwd cwd_real + proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc} + [ -n "$dir" ] && [ -d "$dir" ] || return 0 + dir=$(cd "$dir" && pwd -P) || return 1 + for pid_dir in "$proc_root"/[0-9]*; do + [ -e "$pid_dir" ] || continue + pid=${pid_dir##*/} + case "$pid" in ''|*[!0-9]*) continue ;; esac + cwd=$(readlink "$pid_dir/cwd" 2>/dev/null) || continue + cwd_real=$(CDPATH='' cd -- "$cwd" 2>/dev/null && pwd -P) || cwd_real=$cwd + case "$cwd_real" in + "$dir"|"$dir"/*) emit_reapable_task_pid "$pid" "$dir" "$ancestors" || return 1 ;; + esac + done +} + # Fix 2 (see script header): pids of every process whose CURRENT WORKING # DIRECTORY is exactly $1 or under it, from one bounded system-wide `lsof -a # -d cwd` scan (never the recursive +D file-tree walk, which lsof itself -# documents as slow). Never $$ (this script's own pid). Empty output when -# nothing matches; failure means the scan could not establish a safe result. +# documents as slow). Only a host without lsof falls back to the +# Linux-compatible /proc//cwd read; a present lsof that fails still +# refuses rather than being second-guessed by another scan. Never $$, and +# never this teardown's own invoker chain - whichever scan runs. Empty +# output when nothing matches; failure means the scan could not establish a +# safe result. pids_with_cwd_under() { # - local dir=$1 out pid path line + local dir=$1 out pid path line ancestors [ -n "$dir" ] && [ -d "$dir" ] || return 0 dir=$(cd "$dir" && pwd -P) || return 1 + ancestors=$(teardown_ancestor_pids) + if ! command -v lsof >/dev/null 2>&1; then + proc_cwd_scan_available || return 1 + pids_with_cwd_under_proc "$dir" "$ancestors" + return + fi out=$(lsof -a -d cwd -Fpn 2>/dev/null) || return 1 [ -n "$out" ] || return 0 pid= @@ -1840,7 +1928,7 @@ pids_with_cwd_under() { # path=${line#n} case "$path" in "$dir"|"$dir"/*) - [ -n "$pid" ] && [ "$pid" != "$$" ] && printf '%s\n' "$pid" + emit_reapable_task_pid "$pid" "$dir" "$ancestors" || return 1 ;; esac ;; @@ -1882,6 +1970,14 @@ task_pid_list_contains() { # printf '%s\n' "$1" | grep -Fxq "$2" } +# One wording for every way the cwd scan can fail to establish a safe result: +# a broken lsof, a host with neither cwd source, or a copy this teardown's own +# invoker occupies. The scan prints its own specific cause when it has one, so +# naming a single tool here would send the operator after the wrong remedy. +refuse_unresolved_pid_scan() { + echo "REFUSED: cannot determine leaked processes under ${TASK_PIDS_FAILED_DIR:-} for $ID (the cwd scan could not establish a safe result); preserving the worktree/tasktmp for manual inspection or retry." >&2 +} + task_pids_under_roots() { # ... TASK_PIDS= TASK_PIDS_FAILED_DIR= @@ -1946,19 +2042,31 @@ reap_task_backend_process_group() { #