diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index 67c0c7ba731..1ba4c4b583c 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -276,6 +276,45 @@ dirty_status() { fi } +# True when an attributes stream activates, disables, or resets the external +# `filter` attribute. A startup pull must not run any candidate-selected +# clean/smudge/process command, so every declaration is a refusal rather than +# trying to decide whether the local filter configuration currently names a +# command. +ff_attributes_define_filter() { + LC_ALL=C awk ' + /^[[:space:]]*#/ || /^[[:space:]]*$/ { next } + { + for (i = 2; i <= NF; i++) { + if ($i ~ /^[!-]?filter($|=)/) { found = 1; exit } + } + } + END { exit found ? 0 : 1 } + ' +} + +# True when revision $2 or this checkout's repository-local attributes would +# activate an external checkout filter. System/global attributes are disabled +# on the actual startup merge; info/attributes cannot be disabled by config, so +# it is inspected explicitly here. +ff_external_filter_present() { # + local dir=$1 revision=$2 path info + info=$(git -C "$dir" rev-parse --git-path info/attributes 2>/dev/null) || return 0 + case "$info" in /*) ;; *) info="$dir/$info" ;; esac + if [ -e "$info" ] || [ -L "$info" ]; then + [ -f "$info" ] && [ ! -L "$info" ] && [ -r "$info" ] || return 0 + ff_attributes_define_filter < "$info" && return 0 + fi + while IFS= read -r -d '' path; do + case "$path" in .gitattributes|*/.gitattributes) ;; + *) continue ;; + esac + git -C "$dir" show "$revision:$path" 2>/dev/null \ + | ff_attributes_define_filter && return 0 + done < <(git -C "$dir" ls-tree -r -z --name-only "$revision" 2>/dev/null) + return 1 +} + secondmate_update_reconcile_marker_path() { # local state=$1 id=$2 case "$id" in *[!A-Za-z0-9._-]*|'') return 1 ;; esac @@ -383,10 +422,24 @@ FF_STATUS="" FF_INSTR="" ff_target() { local dir=$1 label=$2 base_mode=$3 allow_detached=${4:-no} ignore_seed_marker=${5:-no} - local secondmate_id=${6:-} reconciliation_state=${7:-} + local secondmate_id=${6:-} reconciliation_state=${7:-} operation=${8:-normal} FF_STATUS="skipped" FF_INSTR="" + case "$operation" in + normal) ;; + startup-check|startup-update) + if [ "$base_mode" = origin ] || [ -n "$secondmate_id$reconciliation_state" ]; then + echo "$label: skipped: startup pull requires one pinned local commit" + return 0 + fi + ;; + *) + echo "$label: skipped: unknown fast-forward operation" + return 0 + ;; + esac + if [ ! -d "$dir" ]; then echo "$label: skipped: not a directory" return 0 @@ -396,7 +449,7 @@ ff_target() { return 0 fi - local default base cur instr local_rev base_rev before after out + local default base cur instr local_rev base_rev before after out dirty default=$(default_branch "$dir") || { echo "$label: skipped: cannot determine default branch" return 0 @@ -432,7 +485,17 @@ ff_target() { return 0 fi - if [ -n "$(dirty_status "$dir" "$ignore_seed_marker")" ]; then + if [ "$operation" = normal ]; then + dirty=$(dirty_status "$dir" "$ignore_seed_marker") + else + dirty=$(GIT_ATTR_NOSYSTEM=1 git -C "$dir" \ + -c core.attributesFile=/dev/null -c core.hooksPath=/dev/null \ + -c core.fsmonitor=false status --porcelain --untracked-files=all 2>/dev/null) || { + echo "$label: skipped: cannot inspect working tree" + return 0 + } + fi + if [ -n "$dirty" ]; then echo "$label: skipped: dirty working tree" return 0 fi @@ -484,9 +547,30 @@ ff_target() { return 0 fi + if [ "$operation" != normal ]; then + if ff_external_filter_present "$dir" HEAD \ + || ff_external_filter_present "$dir" "$base"; then + echo "$label: skipped: external checkout filter declared" + return 0 + fi + if [ "$operation" = startup-check ]; then + FF_STATUS="updated" + FF_INSTR=$(changed_instr "$dir" "$base") + echo "$label: update available" + return 0 + fi + fi + instr=$(changed_instr "$dir" "$base") before=$(git -C "$dir" rev-parse --short HEAD) - if ! out=$(git -C "$dir" merge --ff-only "$base" 2>&1); then + if [ "$operation" = startup-update ]; then + out=$(GIT_ATTR_NOSYSTEM=1 git -C "$dir" \ + -c core.attributesFile=/dev/null -c core.hooksPath=/dev/null \ + merge --ff-only "$base" 2>&1) || { + echo "$label: skipped: fast-forward failed: $(first_line "$out")" + return 0 + } + elif ! out=$(git -C "$dir" merge --ff-only "$base" 2>&1); then echo "$label: skipped: fast-forward failed: $(first_line "$out")" return 0 fi diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 5c954599f99..a1c8decb2cb 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -149,6 +149,94 @@ publish_lock_session_or_die() { exit 1 } +PRELAUNCH_MATCHED=0 +PRELAUNCH_HOME=$(CDPATH='' cd -- "$FM_HOME" 2>/dev/null && pwd -P) || { + echo "error: cannot resolve the physical firstmate home; operate read-only until resolved" >&2 + exit 1 +} + +# Classify the pre-harness reservation while the ordinary claim mutex is held. +# A raw launch may proceed when none exists, but a live reservation belongs only +# to the authenticated child harness below its recorded launcher. Unknown +# state refuses, and a dead or PID-reused owner is the only reclaimable state. +prepare_prelaunch_reservation() { + local owner=${FM_PRELAUNCH_OWNER_PID:-} token=${FM_PRELAUNCH_TOKEN:-} head dirty + PRELAUNCH_MATCHED=0 + fm_prelaunch_reservation_inspect "$STATE" "$PRELAUNCH_HOME" + case "$FM_PRELAUNCH_INSPECT_STATE" in + free) return 0 ;; + stale) + rm -f -- "$STATE/$FM_PRELAUNCH_RESERVATION_FILE" || { + echo "error: cannot reclaim the dead prelaunch reservation; operate read-only until resolved" >&2 + exit 1 + } + return 0 + ;; + live) ;; + *) + echo "error: prelaunch reservation ownership is unreadable or unknown; operate read-only until resolved" >&2 + exit 1 + ;; + esac + case "$owner" in ''|*[!0-9]*|0) + echo "error: another live prelaunch owner reserved this home; operate read-only until resolved" >&2 + exit 1 + ;; + esac + if [ "$FM_PRELAUNCH_RECORD_OWNER_PID" != "$owner" ] \ + || ! fm_prelaunch_attached_child_authenticated "$STATE" "$PRELAUNCH_HOME" "$token"; then + echo "error: another live prelaunch owner reserved this home; operate read-only until resolved" >&2 + exit 1 + fi + case "$FM_PRELAUNCH_RECORD_UPDATE_STATUS" in current|updated) ;; + *) + echo "error: authenticated prelaunch child started before its source update completed; operate read-only until resolved" >&2 + exit 1 + ;; + esac + [ -n "$FM_PRELAUNCH_RECORD_TARGET_COMMIT" ] || { + echo "error: authenticated prelaunch child has no verified source target; operate read-only until resolved" >&2 + exit 1 + } + head=$(fm_prelaunch_git -C "$PRELAUNCH_HOME" rev-parse --verify 'HEAD^{commit}' 2>/dev/null) || { + echo "error: cannot verify prelaunch source HEAD; operate read-only until resolved" >&2 + exit 1 + } + [ "$head" = "$FM_PRELAUNCH_RECORD_TARGET_COMMIT" ] || { + echo "error: prelaunch source HEAD changed before session-lock handoff; operate read-only until resolved" >&2 + exit 1 + } + dirty=$(GIT_ATTR_NOSYSTEM=1 fm_prelaunch_git -C "$PRELAUNCH_HOME" \ + -c core.attributesFile=/dev/null -c core.hooksPath=/dev/null \ + -c core.fsmonitor=false status --porcelain --untracked-files=all 2>/dev/null) || { + echo "error: cannot verify prelaunch source cleanliness before session-lock handoff; operate read-only until resolved" >&2 + exit 1 + } + if [ -n "$dirty" ]; then + echo "error: prelaunch source became dirty before session-lock handoff; operate read-only until resolved" >&2 + exit 1 + fi + PRELAUNCH_MATCHED=1 +} + +complete_prelaunch_handoff() { # + local lock_pid=$1 owner=${FM_PRELAUNCH_OWNER_PID:-} token=${FM_PRELAUNCH_TOKEN:-} + [ "$PRELAUNCH_MATCHED" -eq 1 ] || return 0 + # Re-read under the still-held claim mutex immediately before publishing the + # exchange. This makes an interrupted prior attempt idempotent without ever + # trusting the environment alone. + fm_prelaunch_reservation_inspect "$STATE" "$PRELAUNCH_HOME" + if [ "$FM_PRELAUNCH_RECORD_OWNER_PID" != "$owner" ] \ + || ! fm_prelaunch_attached_child_authenticated "$STATE" "$PRELAUNCH_HOME" "$token"; then + echo "error: prelaunch reservation changed before session-lock handoff; operate read-only until resolved" >&2 + exit 1 + fi + fm_prelaunch_handoff_publish "$STATE" "$lock_pid" || { + echo "error: cannot publish the verified prelaunch session handoff; operate read-only until resolved" >&2 + exit 1 + } +} + # This session already holds the lock, recorded as pid $1. Line 1 stays exactly # as recorded while that pid is alive; only the sidecar is refreshed, under the # claim lock, so a /clear re-key inside the same process replaces the old id. @@ -167,7 +255,9 @@ confirm_own_lock() { # fi recorded=$(cat "$LOCK" 2>/dev/null || true) if [ "$recorded" = "$me" ] || fm_session_lock_owned_by_self "$STATE"; then + prepare_prelaunch_reservation publish_lock_session_or_die + complete_prelaunch_handoff "$recorded" commit_lock_session release_claim_lock echo "lock acquired: harness pid $recorded" @@ -227,6 +317,7 @@ if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then fi fi fi +prepare_prelaunch_reservation # The sidecar goes first: a fresh pid beside a previous session's id would let # that session's resume own this lock. If the sidecar changes before line 1 is # written, a failure restores the previous sidecar. If line 1 is written but @@ -270,6 +361,7 @@ if [ ! -f "$LOCK" ] || [ -L "$LOCK" ] || [ "$written" != "$me" ]; then echo "error: session lock ownership verification failed; operate read-only until resolved" >&2 exit 1 fi +complete_prelaunch_handoff "$me" commit_lock_session release_claim_lock echo "lock acquired: harness pid $me" diff --git a/bin/fm-prelaunch.sh b/bin/fm-prelaunch.sh new file mode 100755 index 00000000000..497dd0b2e61 --- /dev/null +++ b/bin/fm-prelaunch.sh @@ -0,0 +1,681 @@ +#!/usr/bin/env bash +# Trusted fresh-session startup-pull coordination. +# +# This command is copied as part of a verified frozen bundle by the dotfiles +# installer. Its own SCRIPT_DIR always names that trusted bundle, while +# --home names the independent primary checkout whose state and source may be +# inspected or advanced. It never fetches and never executes candidate code. +# +# Usage: +# fm-prelaunch.sh capabilities --home PHYSICAL_HOME +# fm-prelaunch.sh profile --home PHYSICAL_HOME --harness NAME -- ARGUMENTS... +# fm-prelaunch.sh reserve --home PHYSICAL_HOME --owner-pid PID --token TOKEN +# fm-prelaunch.sh validate --home PHYSICAL_HOME --owner-pid PID --token TOKEN +# fm-prelaunch.sh attach --home PHYSICAL_HOME --owner-pid PID --token TOKEN --child-pid PID +# fm-prelaunch.sh check-update --home PHYSICAL_HOME --owner-pid PID --token TOKEN --commit SHA +# fm-prelaunch.sh update --home PHYSICAL_HOME --owner-pid PID --token TOKEN --commit SHA +# fm-prelaunch.sh release --home PHYSICAL_HOME --owner-pid PID --token TOKEN +# fm-prelaunch.sh guard-write --home PHYSICAL_HOME [--owner-pid PID --token TOKEN] +# +# Every success is one JSON object on stdout. Diagnostics contain no token, +# process identity, or credential material and go to stderr. Any nonzero exit +# is a hard refusal for the launcher or guarded writer. +set -u + +# A caller's repository-local Git environment must not redirect any inspection +# or update away from --home or its real index/object store. This command owns +# a pinned local commit and supplies its own narrowly disabled hook/attribute +# configuration at the update boundary. +unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_NAMESPACE GIT_ATTR_SOURCE +unset GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT + +SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +BUNDLE_ROOT="$(CDPATH='' cd -- "$SCRIPT_DIR/.." && pwd -P)" + +die() { + printf 'fm-prelaunch: %s\n' "$1" >&2 + exit "${2:-1}" +} + +usage() { + sed -n '2,/^set -u$/p' "$SCRIPT_DIR/fm-prelaunch.sh" | sed 's/^# \{0,1\}//; $d' >&2 + exit 2 +} + +ACTION=${1:-} +[ -n "$ACTION" ] || usage +shift + +HOME_ARG= +OWNER_PID= +TOKEN= +COMMIT= +HARNESS= +CHILD_PID= +HOME_SEEN=0 +OWNER_SEEN=0 +TOKEN_SEEN=0 +COMMIT_SEEN=0 +HARNESS_SEEN=0 +CHILD_SEEN=0 +PROFILE_ARGS=() +PROFILE_DELIMITER=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --home) + [ "$#" -ge 2 ] && [ "$HOME_SEEN" -eq 0 ] || usage + HOME_ARG=$2 + HOME_SEEN=1 + shift 2 + ;; + --owner-pid) + [ "$#" -ge 2 ] && [ "$OWNER_SEEN" -eq 0 ] || usage + OWNER_PID=$2 + OWNER_SEEN=1 + shift 2 + ;; + --token) + [ "$#" -ge 2 ] && [ "$TOKEN_SEEN" -eq 0 ] || usage + TOKEN=$2 + TOKEN_SEEN=1 + shift 2 + ;; + --commit) + [ "$#" -ge 2 ] && [ "$COMMIT_SEEN" -eq 0 ] || usage + COMMIT=$2 + COMMIT_SEEN=1 + shift 2 + ;; + --harness) + [ "$#" -ge 2 ] && [ "$HARNESS_SEEN" -eq 0 ] || usage + HARNESS=$2 + HARNESS_SEEN=1 + shift 2 + ;; + --child-pid) + [ "$#" -ge 2 ] && [ "$CHILD_SEEN" -eq 0 ] || usage + CHILD_PID=$2 + CHILD_SEEN=1 + shift 2 + ;; + --) + [ "$ACTION" = profile ] || usage + shift + PROFILE_DELIMITER=1 + PROFILE_ARGS=("$@") + break + ;; + -h|--help) usage ;; + *) usage ;; + esac +done +[ "$HOME_SEEN" -eq 1 ] || usage + +case "$ACTION" in + capabilities) + [ "$OWNER_SEEN$TOKEN_SEEN$COMMIT_SEEN$HARNESS_SEEN$CHILD_SEEN" = 00000 ] || usage + ;; + profile) + [ "$OWNER_SEEN$TOKEN_SEEN$COMMIT_SEEN$HARNESS_SEEN$CHILD_SEEN" = 00010 ] || usage + [ "$PROFILE_DELIMITER" -eq 1 ] || usage + ;; + reserve|validate|release) + [ "$OWNER_SEEN$TOKEN_SEEN$COMMIT_SEEN$HARNESS_SEEN$CHILD_SEEN" = 11000 ] || usage + ;; + attach) + [ "$OWNER_SEEN$TOKEN_SEEN$COMMIT_SEEN$HARNESS_SEEN$CHILD_SEEN" = 11001 ] || usage + ;; + check-update|update) + [ "$OWNER_SEEN$TOKEN_SEEN$COMMIT_SEEN$HARNESS_SEEN$CHILD_SEEN" = 11100 ] || usage + ;; + guard-write) + [ "$COMMIT_SEEN$HARNESS_SEEN$CHILD_SEEN" = 000 ] || usage + [ "$OWNER_SEEN" -eq "$TOKEN_SEEN" ] || usage + ;; + *) usage ;; +esac + +# Load the shared identity parser before setting target-home variables. It has +# no source-time state mutation and resolves its own dependencies relative to +# this frozen bundle. +# shellcheck source=bin/fm-session-lock-lib.sh +. "$SCRIPT_DIR/fm-session-lock-lib.sh" + +HOME_PHYSICAL=$(fm_prelaunch_physical_home "$HOME_ARG") \ + || die "--home must be an existing absolute physical directory" +STATE="$HOME_PHYSICAL/state" +FM_ROOT="$HOME_PHYSICAL" +FM_HOME="$HOME_PHYSICAL" +FM_ROOT_OVERRIDE="$HOME_PHYSICAL" +FM_STATE_OVERRIDE="$STATE" +export FM_ROOT FM_HOME FM_ROOT_OVERRIDE FM_STATE_OVERRIDE STATE + +prelaunch_scope_check() { + local path resolved common + [ -f "$HOME_PHYSICAL/AGENTS.md" ] && [ ! -L "$HOME_PHYSICAL/AGENTS.md" ] \ + || die "home is not a safe Firstmate checkout" + [ -d "$HOME_PHYSICAL/bin" ] && [ ! -L "$HOME_PHYSICAL/bin" ] \ + || die "home is not a safe Firstmate checkout" + if [ -e "$HOME_PHYSICAL/.fm-secondmate-home" ] || [ -L "$HOME_PHYSICAL/.fm-secondmate-home" ]; then + die "secondmate homes cannot enroll in startup pull" + fi + if [ -e "$HOME_PHYSICAL/.fm-lab-home" ] || [ -L "$HOME_PHYSICAL/.fm-lab-home" ]; then + die "lab homes cannot enroll in startup pull" + fi + fm_prelaunch_source_identity "$HOME_PHYSICAL" >/dev/null \ + || die "home must be a standalone primary checkout, not a linked worktree" + common=$(git -C "$HOME_PHYSICAL" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \ + || die "cannot resolve source identity" + common=$(CDPATH='' cd -- "$common" 2>/dev/null && pwd -P) \ + || die "cannot resolve source identity" + case "$common" in */.no-mistakes/repos/*.git) die "no-mistakes worktrees cannot enroll in startup pull" ;; esac + [ "${NO_MISTAKES_GATE+x}" != x ] || die "no-mistakes phases cannot enroll in startup pull" + for path in data state config projects; do + if [ -e "$HOME_PHYSICAL/$path" ] || [ -L "$HOME_PHYSICAL/$path" ]; then + [ -d "$HOME_PHYSICAL/$path" ] && [ ! -L "$HOME_PHYSICAL/$path" ] \ + || die "$path must be a real directory inside the home" + resolved=$(CDPATH='' cd -- "$HOME_PHYSICAL/$path" 2>/dev/null && pwd -P) \ + || die "cannot resolve $path directory" + case "$resolved" in "$HOME_PHYSICAL"/*) ;; *) die "$path resolves outside the home" ;; esac + fi + done +} + +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \ + | tr '\n\r\t' ' ' +} + +json_text_safe() { + case "$1" in *[[:cntrl:]]*) return 1 ;; esac +} + +json_base() { # + printf '{"version":1,"home":"%s","status":"%s"' \ + "$(json_escape "$HOME_PHYSICAL")" "$(json_escape "$1")" +} + +# An explicit writer needs only proof that no reservation exists. Reservation +# creation itself requires the enrollment scope below, so a home without a +# record keeps its existing write authority even when it cannot enroll. +if [ "$ACTION" = guard-write ] && [ ! -e "$STATE/$FM_PRELAUNCH_RESERVATION_FILE" ] \ + && [ ! -L "$STATE/$FM_PRELAUNCH_RESERVATION_FILE" ]; then + json_base clear + printf '}\n' + exit 0 +fi + +prelaunch_scope_check + +file_link_count() { # + if [ "$(uname 2>/dev/null || true)" = Darwin ]; then + /usr/bin/stat -f %l "$1" 2>/dev/null + else + stat -c %h "$1" 2>/dev/null + fi +} + +BUNDLE_FILES='bin/fm-prelaunch.sh +bin/fm-session-lock-lib.sh +bin/fm-cursor-lib.sh +bin/fm-wake-lib.sh +bin/fm-path-lib.sh +bin/fm-supervision-lib.sh +bin/fm-ff-lib.sh +bin/fm-secondmate-registry-lib.sh +bin/fm-timeout-lib.sh' + +bundle_files_json() { + local rel path links out='' sep='' + while IFS= read -r rel; do + [ -n "$rel" ] || continue + path="$BUNDLE_ROOT/$rel" + [ -f "$path" ] && [ ! -L "$path" ] && [ -r "$path" ] \ + || die "trusted bundle dependency is unavailable: $rel" + links=$(file_link_count "$path") || die "cannot inspect trusted bundle dependency: $rel" + [ "$links" = 1 ] || die "trusted bundle dependency is multiply linked: $rel" + out="$out$sep\"$(json_escape "$rel")\"" + sep=, + done < [launch-env-json] + local name=$1 stable=$2 marker=$3 launch_env=${4:-'{}'} real version entry + [ -n "$stable" ] && [ -x "$stable" ] || return 0 + case "$stable" in /*) ;; *) return 0 ;; esac + real=$(fm_cursor_canonical_path "$stable" 2>/dev/null) || return 0 + [ -x "$real" ] || return 0 + json_text_safe "$real" || return 0 + version=$(fm_run_timed 5 "$stable" --version 2>/dev/null) || return 0 + version=${version%%$'\n'*} + [ -n "$version" ] || return 0 + json_text_safe "$version" || return 0 + case "$version" in *"$marker"*) ;; *) return 0 ;; esac + entry=$(printf '"%s":{"binary":"%s","harness_version":"%s","argv_grammar":{"kind":"exact","args":[],"session":"fresh-only"},"launch_env":%s}' \ + "$(json_escape "$name")" "$(json_escape "$real")" "$(json_escape "$version")" "$launch_env") + PROFILES_JSON="$PROFILES_JSON${PROFILES_JSON:+,}$entry" + if [ -n "${PROFILE_REQUEST:-}" ] && [ "$PROFILE_REQUEST" = "$name" ]; then + PROFILE_BINARY=$real + PROFILE_VERSION=$version + PROFILE_LAUNCH_ENV=$launch_env + fi +} + +build_profiles() { + local path stable + PROFILES_JSON= + PROFILE_BINARY= + PROFILE_VERSION= + PROFILE_LAUNCH_ENV= + PROFILE_REQUEST=${PROFILE_REQUEST:-} + # shellcheck source=bin/fm-timeout-lib.sh + . "$SCRIPT_DIR/fm-timeout-lib.sh" + path=$(command -v claude 2>/dev/null || true) + case "${path##*/}" in claude) profile_add claude "$path" 'Claude Code' '{}' ;; esac + path=$(command -v codex 2>/dev/null || true) + case "${path##*/}" in codex) profile_add codex "$path" 'codex-cli' '{}' ;; esac + path=$(fm_cursor_resolve_binary 2>/dev/null || true) + [ -z "$path" ] || profile_add cursor "$path" '' '{}' + for path in opencode grok kimi pi pi-signed omp; do + stable=$(command -v "$path" 2>/dev/null || true) + [ "${stable##*/}" = "$path" ] || continue + case "$path" in + pi-signed) profile_add "$path" "$stable" '' '{"FM_PI_HARNESS":"pi-signed"}' ;; + omp) profile_add "$path" "$stable" '' '{"FM_OMP_HARNESS":"omp"}' ;; + *) profile_add "$path" "$stable" '' '{}' ;; + esac + done +} + +capabilities() { + local default source_url source_ref + # shellcheck source=bin/fm-ff-lib.sh + . "$SCRIPT_DIR/fm-ff-lib.sh" + default=$(default_branch "$HOME_PHYSICAL") || die "cannot determine the default branch" + source_url=$(git -C "$HOME_PHYSICAL" remote get-url origin 2>/dev/null) \ + || die "source has no origin remote" + json_text_safe "$source_url" || die "source origin URL is unsafe" + source_ref="refs/heads/$default" + PROFILE_REQUEST= + build_profiles + [ -n "$PROFILES_JSON" ] || die "no supported fresh-session harness is installed" + json_base compatible + printf ',"bundle_files":%s,"source_remote":"origin","source_url":"%s","source_ref":"%s","profiles":{%s}}\n' \ + "$(bundle_files_json)" "$(json_escape "$source_url")" \ + "$(json_escape "$source_ref")" "$PROFILES_JSON" +} + +profile() { + case "$HARNESS" in claude|codex|cursor|opencode|grok|kimi|pi|pi-signed|omp) ;; + *) die "unknown or unsupported harness profile" ;; + esac + # The v1 grammar deliberately accepts no vendor arguments. That narrow + # profile proves a fresh session without trying to maintain a permissive + # denylist whose gaps could restore or fork saved state. + [ "${#PROFILE_ARGS[@]}" -eq 0 ] || die "this fresh-session profile accepts no arguments" + PROFILE_REQUEST=$HARNESS + build_profiles + [ -n "$PROFILE_BINARY" ] || die "requested harness is not installed with a verified version" + json_base fresh + printf ',"binary":"%s","harness_version":"%s","argv":[],"launch_env":%s}\n' \ + "$(json_escape "$PROFILE_BINARY")" "$(json_escape "$PROFILE_VERSION")" "$PROFILE_LAUNCH_ENV" +} + +case "$ACTION" in + capabilities) capabilities; exit 0 ;; + profile) profile; exit 0 ;; +esac + +mkdir -p "$STATE" 2>/dev/null || die "cannot create the reservation state directory" +[ -d "$STATE" ] && [ ! -L "$STATE" ] || die "reservation state directory is unsafe" +[ "$(CDPATH='' cd -- "$STATE" 2>/dev/null && pwd -P)" = "$STATE" ] \ + || die "reservation state directory is not physically inside the home" + +# The wake library owns the existing portable mutex and PID identity. Sourcing +# it is delayed until after capabilities so that read-only discovery never +# creates the target state directory. +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-supervision-lib.sh +. "$SCRIPT_DIR/fm-supervision-lib.sh" +# shellcheck source=bin/fm-ff-lib.sh +. "$SCRIPT_DIR/fm-ff-lib.sh" + +CLAIM_LOCK="$STATE/.lock.acquire" +QUEUE_LOCK="$STATE/.wake-queue.lock" +CLAIM_HELD=0 +QUEUE_HELD=0 +cleanup() { + [ "$QUEUE_HELD" -eq 0 ] || fm_lock_release "$QUEUE_LOCK" || true + [ "$CLAIM_HELD" -eq 0 ] || fm_lock_release "$CLAIM_LOCK" || true +} +trap cleanup EXIT +trap 'exit 1' HUP INT TERM + +acquire_claim() { + fm_lock_acquire_wait_max "$CLAIM_LOCK" 5 \ + || die "session claim mutex did not become available" + CLAIM_HELD=1 +} + +acquire_queue() { + fm_lock_acquire_wait_max "$QUEUE_LOCK" 5 \ + || die "wake queue mutex did not become available" + QUEUE_HELD=1 +} + +session_absent_or_reclaimed() { + fm_session_lock_inspect "$STATE" + case "$FM_LOCK_INSPECT_STATE" in + free) return 0 ;; + stale) + rm -f -- "$STATE/.lock" "$STATE/.lock-session" \ + || die "stale session lock could not be reclaimed" + [ ! -e "$STATE/.lock" ] && [ ! -L "$STATE/.lock" ] \ + || die "stale session lock could not be reclaimed" + ;; + held) die "a live primary session owns this home" ;; + *) die "session ownership is unreadable or unknown" ;; + esac +} + +fleet_idle() { + fm_supervision_status_strict "$STATE" \ + || die "fleet inventories are unreadable or changed during inspection" + [ "$FM_SUP_NEEDED" = false ] || die "active fleet work requires supervision" + [ "$FM_SUP_QUEUE_PENDING" = false ] || die "the wake queue has pending work" +} + +# Classify the existing dotfiles publication mutex without changing it. +# Reservation creation refuses a live owner and every ambiguous shape. A +# positively dead owner remains the dotfiles workflow's recovery concern; this +# helper never removes another subsystem's lock. +dotfiles_mutation_lock_state() { + local state lock pid + state=${HOME:-}/.local/state/dotfiles + lock="$state/lock" + DOTFILES_LOCK_STATE=unknown + case "$state" in /*) ;; *) return 0 ;; esac + if [ ! -e "$lock" ] && [ ! -L "$lock" ]; then + DOTFILES_LOCK_STATE=free + return 0 + fi + [ -d "$lock" ] && [ ! -L "$lock" ] || return 0 + [ -f "$lock/pid" ] && [ ! -L "$lock/pid" ] && [ -r "$lock/pid" ] || return 0 + pid=$(cat "$lock/pid" 2>/dev/null) || return 0 + case "$pid" in ''|*[!0-9]*|0|*$'\n'*|*$'\r'*) return 0 ;; esac + if kill -0 "$pid" 2>/dev/null; then + DOTFILES_LOCK_STATE=live + elif ps -o comm= -p "$pid" >/dev/null 2>&1; then + DOTFILES_LOCK_STATE=unknown + else + DOTFILES_LOCK_STATE=stale + fi +} + +require_dotfiles_writer_absent() { + dotfiles_mutation_lock_state + case "$DOTFILES_LOCK_STATE" in + free|stale) return 0 ;; + live) die "a live dotfiles publication owns the mutation lock" ;; + *) die "dotfiles publication ownership is unreadable or unknown" ;; + esac +} + +authenticate_owner_shape() { + case "$OWNER_PID" in ''|*[!0-9]*|0) die "owner pid must be a positive integer" ;; esac + fm_prelaunch_token_valid "$TOKEN" || die "token format is invalid" + fm_prelaunch_owner_is_ancestor "$OWNER_PID" \ + || die "caller is not the named launcher or its descendant" +} + +authenticate_reservation() { + authenticate_owner_shape + fm_prelaunch_reservation_authenticated "$STATE" "$HOME_PHYSICAL" "$OWNER_PID" "$TOKEN" \ + || die "reservation ownership could not be authenticated" +} + +commit_object() { + local resolved length=${#COMMIT} + [ "$length" -eq 40 ] || [ "$length" -eq 64 ] || die "commit must be a full object id" + case "$COMMIT" in *[!0-9a-f]*) die "commit must be a lowercase hexadecimal object id" ;; esac + resolved=$(git -C "$HOME_PHYSICAL" rev-parse --verify "$COMMIT^{commit}" 2>/dev/null) \ + || die "pinned commit is not available locally" + [ "$resolved" = "$COMMIT" ] || die "pinned commit did not resolve exactly" +} + +validated_idle_reservation() { + authenticate_reservation + session_absent_or_reclaimed + acquire_queue + fleet_idle +} + +reserve() { + local home_identity source_identity owner_identity owner_hash token_hash source_head created + authenticate_owner_shape + acquire_claim + session_absent_or_reclaimed + acquire_queue + fleet_idle + require_dotfiles_writer_absent + fm_prelaunch_reservation_inspect "$STATE" "$HOME_PHYSICAL" + case "$FM_PRELAUNCH_INSPECT_STATE" in + free) ;; + stale) + rm -f -- "$STATE/$FM_PRELAUNCH_RESERVATION_FILE" \ + || die "stale reservation could not be reclaimed" + ;; + live) + if fm_prelaunch_reservation_authenticated "$STATE" "$HOME_PHYSICAL" "$OWNER_PID" "$TOKEN"; then + json_base reserved + printf ',"source_head":"%s"}\n' "$FM_PRELAUNCH_RECORD_SOURCE_COMMIT" + return 0 + fi + die "another live startup reservation owns this home" + ;; + *) die "startup reservation ownership is unreadable or unknown" ;; + esac + if [ -e "$STATE/$FM_PRELAUNCH_HANDOFF_FILE" ] || [ -L "$STATE/$FM_PRELAUNCH_HANDOFF_FILE" ]; then + [ -f "$STATE/$FM_PRELAUNCH_HANDOFF_FILE" ] && [ ! -L "$STATE/$FM_PRELAUNCH_HANDOFF_FILE" ] \ + || die "prior startup handoff is unsafe" + rm -f -- "$STATE/$FM_PRELAUNCH_HANDOFF_FILE" \ + || die "prior startup handoff could not be cleared" + fi + home_identity=$(fm_prelaunch_directory_identity "$HOME_PHYSICAL") \ + || die "cannot bind physical home identity" + source_identity=$(fm_prelaunch_source_identity "$HOME_PHYSICAL") \ + || die "cannot bind source identity" + owner_identity=$(fm_pid_identity "$OWNER_PID") \ + || die "cannot bind launcher process identity" + owner_hash=$(fm_prelaunch_hash_text "$owner_identity") \ + || die "cannot hash launcher process identity" + token_hash=$(fm_prelaunch_hash_text "$TOKEN") || die "cannot hash invocation token" + source_head=$(git -C "$HOME_PHYSICAL" rev-parse --verify 'HEAD^{commit}' 2>/dev/null) \ + || die "cannot read source HEAD" + created=$(date +%s) || die "cannot read the system clock" + fm_prelaunch_reservation_write "$STATE" "$HOME_PHYSICAL" "$home_identity" \ + "$source_identity" "$OWNER_PID" "$owner_hash" "$token_hash" \ + '' '' "$source_head" '' pending "$created" \ + || die "reservation could not be published" + fm_prelaunch_reservation_authenticated "$STATE" "$HOME_PHYSICAL" "$OWNER_PID" "$TOKEN" \ + || die "reservation publication could not be verified" + # Close the cross-mutex check/write race with manual publication. A manual + # writer that acquired its lock before our record appeared was legitimately + # admitted by guard-write; if it is still present now, withdraw only this + # reservation and refuse before any startup mutation can begin. + dotfiles_mutation_lock_state + case "$DOTFILES_LOCK_STATE" in + free|stale) ;; + *) + rm -f -- "$STATE/$FM_PRELAUNCH_RESERVATION_FILE" \ + || die "dotfiles publication raced reservation and rollback failed" + die "dotfiles publication raced startup reservation" + ;; + esac + json_base reserved + printf ',"source_head":"%s"}\n' "$source_head" +} + +attach() { + local generation generation_hash + case "$CHILD_PID" in ''|*[!0-9]*|0) die "child pid must be a positive integer" ;; esac + [ "$CHILD_PID" != "$OWNER_PID" ] || die "attached child must differ from its launcher" + acquire_claim + validated_idle_reservation + [ -z "$FM_PRELAUNCH_RECORD_CHILD_PID" ] \ + || die "a child is already attached to this reservation" + fm_prelaunch_pid_is_descendant "$OWNER_PID" "$CHILD_PID" \ + || die "child is not a descendant of the reserved launcher" + generation=$(fm_prelaunch_pid_generation "$CHILD_PID") \ + || die "cannot bind child process generation" + generation_hash=$(fm_prelaunch_hash_text "$generation") \ + || die "cannot hash child process generation" + fm_prelaunch_reservation_write "$STATE" "$FM_PRELAUNCH_RECORD_HOME" \ + "$FM_PRELAUNCH_RECORD_HOME_IDENTITY" "$FM_PRELAUNCH_RECORD_SOURCE_IDENTITY" \ + "$FM_PRELAUNCH_RECORD_OWNER_PID" "$FM_PRELAUNCH_RECORD_OWNER_IDENTITY_HASH" \ + "$FM_PRELAUNCH_RECORD_TOKEN_HASH" "$CHILD_PID" "$generation_hash" \ + "$FM_PRELAUNCH_RECORD_SOURCE_COMMIT" "$FM_PRELAUNCH_RECORD_TARGET_COMMIT" \ + "$FM_PRELAUNCH_RECORD_UPDATE_STATUS" "$FM_PRELAUNCH_RECORD_CREATED_AT" \ + || die "attached child could not be published" + authenticate_reservation + fm_prelaunch_attached_child_inspect + [ "$FM_PRELAUNCH_CHILD_STATE" = live ] \ + || die "attached child publication could not be verified" + json_base attached + printf ',"child_pid":%s}\n' "$CHILD_PID" +} + +validate() { + local head + acquire_claim + validated_idle_reservation + head=$(git -C "$HOME_PHYSICAL" rev-parse --verify 'HEAD^{commit}' 2>/dev/null) \ + || die "cannot read source HEAD" + case "$FM_PRELAUNCH_RECORD_UPDATE_STATUS" in + pending) [ "$head" = "$FM_PRELAUNCH_RECORD_SOURCE_COMMIT" ] \ + || die "source HEAD changed after reservation" ;; + current|updated) [ "$head" = "$FM_PRELAUNCH_RECORD_TARGET_COMMIT" ] \ + || die "source HEAD changed after startup update" ;; + esac + json_base valid + printf ',"source_head":"%s"}\n' "$head" +} + +preflight_or_update() { # + local operation=$1 head before changed=false + acquire_claim + validated_idle_reservation + commit_object + head=$(git -C "$HOME_PHYSICAL" rev-parse --verify 'HEAD^{commit}' 2>/dev/null) \ + || die "cannot read source HEAD" + if [ "$FM_PRELAUNCH_RECORD_UPDATE_STATUS" != pending ]; then + [ "$FM_PRELAUNCH_RECORD_TARGET_COMMIT" = "$COMMIT" ] \ + && [ "$head" = "$COMMIT" ] \ + || die "a different startup update was already recorded" + json_base current + printf ',"source_head":"%s","changed":false}\n' "$head" + return 0 + fi + [ "$head" = "$FM_PRELAUNCH_RECORD_SOURCE_COMMIT" ] \ + || die "source HEAD changed after reservation" + before=$head + if [ "$operation" = check ]; then + ff_target "$HOME_PHYSICAL" 'firstmate startup' "$COMMIT" no no '' '' startup-check \ + >/dev/null 2>&1 + else + ff_target "$HOME_PHYSICAL" 'firstmate startup' "$COMMIT" no no '' '' startup-update \ + >/dev/null 2>&1 + fi + case "$FF_STATUS" in current|updated) ;; *) die "source update refused by the guarded fast-forward" ;; esac + if [ "$operation" = check ]; then + json_base "$FF_STATUS" + [ "$FF_STATUS" != updated ] || changed=true + printf ',"source_head":"%s","changed":%s}\n' "$COMMIT" "$changed" + return 0 + fi + head=$(git -C "$HOME_PHYSICAL" rev-parse --verify 'HEAD^{commit}' 2>/dev/null) \ + || die "cannot verify source HEAD after update" + [ "$head" = "$COMMIT" ] || die "source update did not reach the pinned commit" + [ "$before" = "$head" ] || changed=true + fm_prelaunch_reservation_write "$STATE" "$FM_PRELAUNCH_RECORD_HOME" \ + "$FM_PRELAUNCH_RECORD_HOME_IDENTITY" "$FM_PRELAUNCH_RECORD_SOURCE_IDENTITY" \ + "$FM_PRELAUNCH_RECORD_OWNER_PID" "$FM_PRELAUNCH_RECORD_OWNER_IDENTITY_HASH" \ + "$FM_PRELAUNCH_RECORD_TOKEN_HASH" "$FM_PRELAUNCH_RECORD_CHILD_PID" \ + "$FM_PRELAUNCH_RECORD_CHILD_GENERATION_HASH" "$FM_PRELAUNCH_RECORD_SOURCE_COMMIT" \ + "$COMMIT" "$FF_STATUS" "$FM_PRELAUNCH_RECORD_CREATED_AT" \ + || die "updated reservation could not be published" + fm_prelaunch_reservation_authenticated "$STATE" "$HOME_PHYSICAL" "$OWNER_PID" "$TOKEN" \ + || die "updated reservation could not be verified" + json_base "$FF_STATUS" + printf ',"source_head":"%s","changed":%s}\n' "$head" "$changed" +} + +release() { + acquire_claim + authenticate_owner_shape + fm_prelaunch_reservation_inspect "$STATE" "$HOME_PHYSICAL" + case "$FM_PRELAUNCH_INSPECT_STATE" in + live) + fm_prelaunch_reservation_authenticated "$STATE" "$HOME_PHYSICAL" "$OWNER_PID" "$TOKEN" \ + || die "reservation ownership could not be authenticated" + fm_prelaunch_attached_child_inspect + [ "$FM_PRELAUNCH_CHILD_STATE" != live ] \ + || die "an attached live child must exit or complete session-lock handoff before release" + [ "$FM_PRELAUNCH_CHILD_STATE" != unknown ] \ + || die "attached child ownership is unreadable or unknown" + rm -f -- "$STATE/$FM_PRELAUNCH_RESERVATION_FILE" \ + || die "reservation could not be released" + json_base released + printf '}\n' + ;; + free) + fm_prelaunch_handoff_authenticated "$STATE" "$HOME_PHYSICAL" "$OWNER_PID" "$TOKEN" \ + || die "no authenticated reservation or handoff exists" + json_base handed-off + printf '}\n' + ;; + stale) die "only the verified live reservation owner may release it" ;; + *) die "startup reservation ownership is unreadable or unknown" ;; + esac +} + +guard_write() { + acquire_claim + fm_prelaunch_reservation_inspect "$STATE" "$HOME_PHYSICAL" + case "$FM_PRELAUNCH_INSPECT_STATE" in + free) + json_base clear + printf '}\n' + ;; + stale) + rm -f -- "$STATE/$FM_PRELAUNCH_RESERVATION_FILE" \ + || die "stale reservation could not be reclaimed" + json_base clear + printf '}\n' + ;; + live) + [ "$OWNER_SEEN" -eq 1 ] \ + || die "a live startup reservation blocks this write" + fm_prelaunch_reservation_authenticated "$STATE" "$HOME_PHYSICAL" "$OWNER_PID" "$TOKEN" \ + || die "live startup reservation belongs to another owner" + json_base owned + printf '}\n' + ;; + *) die "startup reservation ownership is unreadable or unknown" ;; + esac +} + +case "$ACTION" in + reserve) reserve ;; + validate) validate ;; + attach) attach ;; + check-update) preflight_or_update check ;; + update) preflight_or_update update ;; + release) release ;; + guard-write) guard_write ;; +esac diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 58fc088fd59..d255e8f927a 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -344,7 +344,7 @@ fm_session_lock_inspect() { # # shellcheck disable=SC2034 # Output globals, read by lock status and inbox ready. FM_LOCK_INSPECT_LIVE_HARNESS=unknown lock="$state/.lock" - if [ ! -e "$lock" ]; then + if [ ! -e "$lock" ] && [ ! -L "$lock" ]; then FM_LOCK_INSPECT_STATE=free FM_LOCK_INSPECT_LIVE_HARNESS=false return 0 @@ -385,3 +385,538 @@ fm_session_lock_inspect() { # # shellcheck disable=SC2034 # Output global, read by lock status and inbox ready. FM_LOCK_INSPECT_LIVE_HARNESS=false } + +# --- fresh-launch reservation identity ------------------------------------- +# +# A startup pull runs before a harness exists, so it cannot manufacture the +# session lock above. It instead records a short-lived reservation beside that +# lock while holding the same state/.lock.acquire mutex. The record binds the +# physical home, its directory identity, the launcher's kernel process +# identity, and a random invocation token. bin/fm-prelaunch.sh is the only +# reservation writer; bin/fm-lock.sh is the only reservation-to-session-lock +# handoff writer. The helpers here own their shared parsing and identity rules. + +FM_PRELAUNCH_RESERVATION_FILE='.prelaunch-reservation' +FM_PRELAUNCH_HANDOFF_FILE='.prelaunch-handoff' + +# Run a reservation identity read against the named checkout, never a Git +# directory, index, object store, namespace, or config injected by the +# launcher's ambient shell and inherited by its child harness. +fm_prelaunch_git() { + ( + unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY + unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_NAMESPACE GIT_ATTR_SOURCE + unset GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT + command git "$@" + ) +} + +fm_prelaunch_hash_text() { # + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$1" | shasum -a 256 2>/dev/null | awk 'NF {print $1; exit}' + elif command -v sha256sum >/dev/null 2>&1; then + printf '%s' "$1" | sha256sum 2>/dev/null | awk 'NF {print $1; exit}' + else + return 1 + fi +} + +# Print the device:inode identity of a physical directory. +fm_prelaunch_directory_identity() { # + local dir=$1 + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + if [ "$(uname 2>/dev/null || true)" = Darwin ]; then + LC_ALL=C /usr/bin/stat -f '%d:%i' "$dir" 2>/dev/null + else + LC_ALL=C stat -c '%d:%i' "$dir" 2>/dev/null + fi +} + +# Print the device:inode identity of a standalone checkout's own git directory. +# A linked worktree has distinct git-dir/common-dir paths and is refused before +# any reservation can be written. +fm_prelaunch_source_identity() { # + local home=$1 top git_dir common_dir git_dir_physical common_physical + local directory_identity default_ref default_branch remote_url remote_hash branch + top=$(fm_prelaunch_git -C "$home" rev-parse --show-toplevel 2>/dev/null) || return 1 + top=$(CDPATH='' cd -- "$top" 2>/dev/null && pwd -P) || return 1 + [ "$top" = "$home" ] || return 1 + git_dir=$(fm_prelaunch_git -C "$home" rev-parse --absolute-git-dir 2>/dev/null) || return 1 + common_dir=$(fm_prelaunch_git -C "$home" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 1 + git_dir_physical=$(CDPATH='' cd -- "$git_dir" 2>/dev/null && pwd -P) || return 1 + common_physical=$(CDPATH='' cd -- "$common_dir" 2>/dev/null && pwd -P) || return 1 + [ "$git_dir_physical" = "$common_physical" ] || return 1 + directory_identity=$(fm_prelaunch_directory_identity "$common_physical") || return 1 + default_ref=$(fm_prelaunch_git -C "$home" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true) + if [ -n "$default_ref" ]; then + default_branch=${default_ref#origin/} + else + default_branch= + for branch in main master; do + if fm_prelaunch_git -C "$home" show-ref --verify --quiet "refs/heads/$branch"; then + default_branch=$branch + break + fi + done + fi + [ -n "$default_branch" ] || return 1 + case "$default_branch" in *$'\n'*|*$'\r'*|*[!A-Za-z0-9._/-]*) return 1 ;; esac + remote_url=$(fm_prelaunch_git -C "$home" remote get-url origin 2>/dev/null) || return 1 + remote_hash=$(fm_prelaunch_hash_text "$remote_url") || return 1 + printf '%s:default=%s:origin-sha256=%s\n' "$directory_identity" "$default_branch" "$remote_hash" +} + +# Require the caller's spelling to be the exact absolute physical home. +fm_prelaunch_physical_home() { # + local home=$1 physical + case "$home" in /*) ;; *) return 1 ;; esac + case "$home" in *[[:cntrl:]]*) return 1 ;; esac + physical=$(CDPATH='' cd -- "$home" 2>/dev/null && pwd -P) || return 1 + [ "$home" = "$physical" ] || return 1 + printf '%s\n' "$physical" +} + +fm_prelaunch_token_valid() { # + local token=$1 length=${#1} + [ "$length" -ge 32 ] && [ "$length" -le 256 ] || return 1 + case "$token" in *[!A-Za-z0-9._-]*) return 1 ;; esac +} + +# True when process $1 is this shell or an ancestor of it. This is generic +# process ancestry, not harness ancestry: reserve/update/release run before the +# child harness exists, while fm-lock.sh runs below that child. The kernel +# identity check below separately prevents a recycled ancestor PID from +# satisfying ownership. +fm_prelaunch_owner_is_ancestor() { # + local owner=$1 pid parent + case "$owner" in ''|*[!0-9]*|0) return 1 ;; esac + if command -v fm_current_pid >/dev/null 2>&1; then + fm_current_pid pid || return 1 + else + pid=$$ + fi + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 \ + 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 \ + 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 \ + 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64; do + [ "$pid" = "$owner" ] && return 0 + parent=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') + case "$parent" in ''|*[!0-9]*|0) return 1 ;; esac + [ "$parent" != "$pid" ] || return 1 + pid=$parent + done + return 1 +} + +# True when $2 is $1 or a descendant of it. attach uses this from the launcher +# side, where the blocked child is a sibling of the helper process rather than +# an ancestor of it. +fm_prelaunch_pid_is_descendant() { # + local ancestor=$1 pid=$2 parent + case "$ancestor:$pid" in *[!0-9:]*|:*|*:) return 1 ;; esac + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 \ + 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 \ + 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 \ + 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64; do + [ "$pid" = "$ancestor" ] && return 0 + parent=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') + case "$parent" in ''|*[!0-9]*|0) return 1 ;; esac + [ "$parent" != "$pid" ] || return 1 + pid=$parent + done + return 1 +} + +# Print the kernel process generation that survives exec. fm_pid_identity also +# binds argv, which is correct for the launcher but intentionally changes when +# the blocked bootstrap execs the vendor. The attached child therefore records +# only start time/generation plus PID, then verifies harness identity separately +# during the native lock handoff. +fm_prelaunch_pid_generation() { # + local pid=$1 proc_root stat_line starttime out + local -a stat_fields + case "$pid" in ''|*[!0-9]*|0) return 1 ;; esac + proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc} + if [ -r "$proc_root/$pid/stat" ]; then + stat_line=$(cat "$proc_root/$pid/stat" 2>/dev/null) || return 1 + read -r -a stat_fields <<< "${stat_line##*)}" + [ "${#stat_fields[@]}" -ge 20 ] || return 1 + starttime=${stat_fields[19]} + case "$starttime" in ''|*[!0-9]*) return 1 ;; esac + printf 'starttime=%s\n' "$starttime" + return 0 + fi + out=$(LC_ALL=C ps -p "$pid" -o lstart= 2>/dev/null) || return 1 + out=${out#"${out%%[![:space:]]*}"} + [ -n "$out" ] || return 1 + printf 'lstart=%s\n' "$out" +} + +# Reservation parser output. The record is deliberately an exact ordered +# format rather than shell source, so hostile bytes are data and extra fields +# make the state unknown instead of being silently ignored. +FM_PRELAUNCH_RECORD_HOME= +FM_PRELAUNCH_RECORD_HOME_IDENTITY= +FM_PRELAUNCH_RECORD_SOURCE_IDENTITY= +FM_PRELAUNCH_RECORD_OWNER_PID= +FM_PRELAUNCH_RECORD_OWNER_IDENTITY_HASH= +FM_PRELAUNCH_RECORD_TOKEN_HASH= +FM_PRELAUNCH_RECORD_CHILD_PID= +FM_PRELAUNCH_RECORD_CHILD_GENERATION_HASH= +FM_PRELAUNCH_RECORD_SOURCE_COMMIT= +FM_PRELAUNCH_RECORD_TARGET_COMMIT= +FM_PRELAUNCH_RECORD_UPDATE_STATUS= +FM_PRELAUNCH_RECORD_CREATED_AT= +fm_prelaunch_reservation_read() { # + local file="$1/$FM_PRELAUNCH_RESERVATION_FILE" l1 l2 l3 l4 l5 l6 l7 l8 l9 l10 l11 l12 l13 _extra + FM_PRELAUNCH_RECORD_HOME= + FM_PRELAUNCH_RECORD_HOME_IDENTITY= + FM_PRELAUNCH_RECORD_SOURCE_IDENTITY= + FM_PRELAUNCH_RECORD_OWNER_PID= + FM_PRELAUNCH_RECORD_OWNER_IDENTITY_HASH= + FM_PRELAUNCH_RECORD_TOKEN_HASH= + FM_PRELAUNCH_RECORD_CHILD_PID= + FM_PRELAUNCH_RECORD_CHILD_GENERATION_HASH= + FM_PRELAUNCH_RECORD_SOURCE_COMMIT= + FM_PRELAUNCH_RECORD_TARGET_COMMIT= + FM_PRELAUNCH_RECORD_UPDATE_STATUS= + FM_PRELAUNCH_RECORD_CREATED_AT= + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + { + IFS= read -r l1 && IFS= read -r l2 && IFS= read -r l3 \ + && IFS= read -r l4 && IFS= read -r l5 && IFS= read -r l6 \ + && IFS= read -r l7 && IFS= read -r l8 && IFS= read -r l9 \ + && IFS= read -r l10 && IFS= read -r l11 && IFS= read -r l12 \ + && IFS= read -r l13 && ! IFS= read -r _extra + } < "$file" || return 1 + [ "$l1" = 'schema=fm-prelaunch-reservation.v1' ] || return 1 + case "$l2" in home=*) FM_PRELAUNCH_RECORD_HOME=${l2#home=} ;; *) return 1 ;; esac + case "$l3" in home_identity=*) FM_PRELAUNCH_RECORD_HOME_IDENTITY=${l3#home_identity=} ;; *) return 1 ;; esac + case "$l4" in source_identity=*) FM_PRELAUNCH_RECORD_SOURCE_IDENTITY=${l4#source_identity=} ;; *) return 1 ;; esac + case "$l5" in owner_pid=*) FM_PRELAUNCH_RECORD_OWNER_PID=${l5#owner_pid=} ;; *) return 1 ;; esac + case "$l6" in owner_identity_sha256=*) FM_PRELAUNCH_RECORD_OWNER_IDENTITY_HASH=${l6#owner_identity_sha256=} ;; *) return 1 ;; esac + case "$l7" in token_sha256=*) FM_PRELAUNCH_RECORD_TOKEN_HASH=${l7#token_sha256=} ;; *) return 1 ;; esac + case "$l8" in child_pid=*) FM_PRELAUNCH_RECORD_CHILD_PID=${l8#child_pid=} ;; *) return 1 ;; esac + case "$l9" in child_generation_sha256=*) FM_PRELAUNCH_RECORD_CHILD_GENERATION_HASH=${l9#child_generation_sha256=} ;; *) return 1 ;; esac + case "$l10" in source_commit=*) FM_PRELAUNCH_RECORD_SOURCE_COMMIT=${l10#source_commit=} ;; *) return 1 ;; esac + case "$l11" in target_commit=*) FM_PRELAUNCH_RECORD_TARGET_COMMIT=${l11#target_commit=} ;; *) return 1 ;; esac + case "$l12" in update_status=*) FM_PRELAUNCH_RECORD_UPDATE_STATUS=${l12#update_status=} ;; *) return 1 ;; esac + case "$l13" in created_at=*) FM_PRELAUNCH_RECORD_CREATED_AT=${l13#created_at=} ;; *) return 1 ;; esac + case "$FM_PRELAUNCH_RECORD_OWNER_PID" in ''|*[!0-9]*|0) return 1 ;; esac + case "$FM_PRELAUNCH_RECORD_HOME_IDENTITY" in *:* ) ;; *) return 1 ;; esac + case "$FM_PRELAUNCH_RECORD_SOURCE_IDENTITY" in *:* ) ;; *) return 1 ;; esac + case "$FM_PRELAUNCH_RECORD_OWNER_IDENTITY_HASH$FM_PRELAUNCH_RECORD_TOKEN_HASH" in + *[!0-9a-f]*) return 1 ;; + esac + [ "${#FM_PRELAUNCH_RECORD_OWNER_IDENTITY_HASH}" -eq 64 ] || return 1 + [ "${#FM_PRELAUNCH_RECORD_TOKEN_HASH}" -eq 64 ] || return 1 + if [ -n "$FM_PRELAUNCH_RECORD_CHILD_PID$FM_PRELAUNCH_RECORD_CHILD_GENERATION_HASH" ]; then + case "$FM_PRELAUNCH_RECORD_CHILD_PID" in ''|*[!0-9]*|0) return 1 ;; esac + case "$FM_PRELAUNCH_RECORD_CHILD_GENERATION_HASH" in *[!0-9a-f]*) return 1 ;; esac + [ "${#FM_PRELAUNCH_RECORD_CHILD_GENERATION_HASH}" -eq 64 ] || return 1 + fi + case "$FM_PRELAUNCH_RECORD_SOURCE_COMMIT" in ''|*[!0-9a-f]*) return 1 ;; esac + case "$FM_PRELAUNCH_RECORD_TARGET_COMMIT" in ''|*[!0-9a-f]*) + [ -z "$FM_PRELAUNCH_RECORD_TARGET_COMMIT" ] || return 1 ;; + esac + case "$FM_PRELAUNCH_RECORD_UPDATE_STATUS" in pending|current|updated) ;; *) return 1 ;; esac + case "$FM_PRELAUNCH_RECORD_CREATED_AT" in ''|*[!0-9]*) return 1 ;; esac + return 0 +} + +# shellcheck disable=SC2034 # Output globals, read by reservation callers. +FM_PRELAUNCH_INSPECT_STATE=unknown +# shellcheck disable=SC2034 # Output global, useful in fail-closed diagnostics/tests. +FM_PRELAUNCH_INSPECT_REASON=unknown +FM_PRELAUNCH_CHILD_STATE=absent +fm_prelaunch_attached_child_inspect() { + local generation generation_hash + FM_PRELAUNCH_CHILD_STATE=absent + [ -n "$FM_PRELAUNCH_RECORD_CHILD_PID" ] || return 0 + if kill -0 "$FM_PRELAUNCH_RECORD_CHILD_PID" 2>/dev/null; then + generation=$(fm_prelaunch_pid_generation "$FM_PRELAUNCH_RECORD_CHILD_PID" 2>/dev/null) || { + FM_PRELAUNCH_CHILD_STATE=unknown + return 0 + } + generation_hash=$(fm_prelaunch_hash_text "$generation") || { + FM_PRELAUNCH_CHILD_STATE=unknown + return 0 + } + if [ "$generation_hash" = "$FM_PRELAUNCH_RECORD_CHILD_GENERATION_HASH" ]; then + FM_PRELAUNCH_CHILD_STATE=live + else + FM_PRELAUNCH_CHILD_STATE=stale + fi + return 0 + fi + if ps -o comm= -p "$FM_PRELAUNCH_RECORD_CHILD_PID" >/dev/null 2>&1; then + FM_PRELAUNCH_CHILD_STATE=unknown + else + FM_PRELAUNCH_CHILD_STATE=stale + fi +} + +fm_prelaunch_owner_gone_state() { + fm_prelaunch_attached_child_inspect + case "$FM_PRELAUNCH_CHILD_STATE" in + live) + FM_PRELAUNCH_INSPECT_STATE=live + FM_PRELAUNCH_INSPECT_REASON='verified-live-attached-child' + ;; + unknown) + FM_PRELAUNCH_INSPECT_STATE=unknown + FM_PRELAUNCH_INSPECT_REASON='attached-child-identity-unknown' + ;; + absent|stale) + FM_PRELAUNCH_INSPECT_STATE=stale + FM_PRELAUNCH_INSPECT_REASON='owner-and-attached-child-dead' + ;; + esac +} + +fm_prelaunch_reservation_inspect() { # + local state=$1 home=$2 file="$1/$FM_PRELAUNCH_RESERVATION_FILE" + local home_identity source_identity current_identity current_hash + FM_PRELAUNCH_INSPECT_STATE=unknown + FM_PRELAUNCH_INSPECT_REASON='unknown' + if [ ! -e "$file" ] && [ ! -L "$file" ]; then + FM_PRELAUNCH_INSPECT_STATE=free + FM_PRELAUNCH_INSPECT_REASON=absent + return 0 + fi + if ! fm_prelaunch_reservation_read "$state"; then + FM_PRELAUNCH_INSPECT_REASON='unreadable-or-malformed' + return 0 + fi + home_identity=$(fm_prelaunch_directory_identity "$home") || { + FM_PRELAUNCH_INSPECT_REASON='home-identity-unreadable' + return 0 + } + if [ "$FM_PRELAUNCH_RECORD_HOME" != "$home" ] \ + || [ "$FM_PRELAUNCH_RECORD_HOME_IDENTITY" != "$home_identity" ]; then + FM_PRELAUNCH_INSPECT_REASON='home-identity-mismatch' + return 0 + fi + source_identity=$(fm_prelaunch_source_identity "$home") || { + FM_PRELAUNCH_INSPECT_REASON='source-identity-unreadable' + return 0 + } + if [ "$FM_PRELAUNCH_RECORD_SOURCE_IDENTITY" != "$source_identity" ]; then + FM_PRELAUNCH_INSPECT_REASON='source-identity-mismatch' + return 0 + fi + if kill -0 "$FM_PRELAUNCH_RECORD_OWNER_PID" 2>/dev/null; then + current_identity=$(fm_pid_identity "$FM_PRELAUNCH_RECORD_OWNER_PID" 2>/dev/null) || { + FM_PRELAUNCH_INSPECT_REASON='owner-identity-unreadable' + return 0 + } + current_hash=$(fm_prelaunch_hash_text "$current_identity") || { + FM_PRELAUNCH_INSPECT_REASON='hash-unavailable' + return 0 + } + if [ "$current_hash" = "$FM_PRELAUNCH_RECORD_OWNER_IDENTITY_HASH" ]; then + FM_PRELAUNCH_INSPECT_STATE=live + FM_PRELAUNCH_INSPECT_REASON='verified-live-owner' + else + # The recorded owner is gone and its PID has been reused. The kernel + # identity mismatch is positive stale-owner evidence, never authorization + # for the process currently carrying that number. + fm_prelaunch_owner_gone_state + fi + return 0 + fi + if ps -o comm= -p "$FM_PRELAUNCH_RECORD_OWNER_PID" >/dev/null 2>&1; then + # shellcheck disable=SC2034 # Output global, useful to diagnostics/tests. + FM_PRELAUNCH_INSPECT_REASON='owner-liveness-unknown' + return 0 + fi + fm_prelaunch_owner_gone_state + return 0 +} + +fm_prelaunch_reservation_authenticated() { # + local state=$1 home=$2 owner=$3 token=$4 token_hash current_identity current_hash + fm_prelaunch_token_valid "$token" || return 1 + fm_prelaunch_reservation_inspect "$state" "$home" + [ "$FM_PRELAUNCH_INSPECT_STATE" = live ] || return 1 + [ "$FM_PRELAUNCH_RECORD_OWNER_PID" = "$owner" ] || return 1 + current_identity=$(fm_pid_identity "$owner" 2>/dev/null) || return 1 + current_hash=$(fm_prelaunch_hash_text "$current_identity") || return 1 + [ "$FM_PRELAUNCH_RECORD_OWNER_IDENTITY_HASH" = "$current_hash" ] || return 1 + token_hash=$(fm_prelaunch_hash_text "$token") || return 1 + [ "$FM_PRELAUNCH_RECORD_TOKEN_HASH" = "$token_hash" ] || return 1 + fm_prelaunch_owner_is_ancestor "$owner" +} + +# Authenticate the attached child after its bootstrap exec. Its PID generation +# is exec-stable; the caller still has to prove genuine harness ancestry before +# fm-lock exchanges the reservation for a session lock. +fm_prelaunch_attached_child_authenticated() { # + local state=$1 home=$2 token=$3 token_hash + fm_prelaunch_token_valid "$token" || return 1 + fm_prelaunch_reservation_inspect "$state" "$home" + [ "$FM_PRELAUNCH_INSPECT_STATE" = live ] || return 1 + token_hash=$(fm_prelaunch_hash_text "$token") || return 1 + [ "$FM_PRELAUNCH_RECORD_TOKEN_HASH" = "$token_hash" ] || return 1 + fm_prelaunch_attached_child_inspect + [ "$FM_PRELAUNCH_CHILD_STATE" = live ] || return 1 + fm_prelaunch_owner_is_ancestor "$FM_PRELAUNCH_RECORD_CHILD_PID" +} + +fm_prelaunch_reservation_write() { # + local state=$1 home=$2 home_identity=$3 source_identity=$4 owner=$5 owner_hash=$6 token_hash=$7 + local child_pid=$8 child_generation_hash=$9 source=${10} target=${11} + local update_status=${12} created_at=${13} file tmp + file="$state/$FM_PRELAUNCH_RESERVATION_FILE" + [ ! -L "$file" ] || return 1 + tmp=$(umask 077; mktemp "$state/.prelaunch-reservation.XXXXXX" 2>/dev/null) || return 1 + if ! { + printf 'schema=fm-prelaunch-reservation.v1\n' + printf 'home=%s\n' "$home" + printf 'home_identity=%s\n' "$home_identity" + printf 'source_identity=%s\n' "$source_identity" + printf 'owner_pid=%s\n' "$owner" + printf 'owner_identity_sha256=%s\n' "$owner_hash" + printf 'token_sha256=%s\n' "$token_hash" + printf 'child_pid=%s\n' "$child_pid" + printf 'child_generation_sha256=%s\n' "$child_generation_hash" + printf 'source_commit=%s\n' "$source" + printf 'target_commit=%s\n' "$target" + printf 'update_status=%s\n' "$update_status" + printf 'created_at=%s\n' "$created_at" + } > "$tmp" || ! chmod 600 "$tmp" || ! mv -f -- "$tmp" "$file"; then + rm -f -- "$tmp" + return 1 + fi +} + +# Publish the verified child handoff before removing the reservation. A crash +# between these operations leaves both records, which the same child can finish +# idempotently while holding the claim mutex; it never opens a takeover window. +fm_prelaunch_handoff_publish() { # + local state=$1 lock_pid=$2 file="$1/$FM_PRELAUNCH_HANDOFF_FILE" reservation tmp handed_at + reservation="$state/$FM_PRELAUNCH_RESERVATION_FILE" + [ ! -L "$file" ] || return 1 + case "$lock_pid" in ''|*[!0-9]*|0) return 1 ;; esac + handed_at=$(date +%s) || return 1 + tmp=$(umask 077; mktemp "$state/.prelaunch-handoff.XXXXXX" 2>/dev/null) || return 1 + if ! { + printf 'schema=fm-prelaunch-handoff.v1\n' + printf 'home=%s\n' "$FM_PRELAUNCH_RECORD_HOME" + printf 'home_identity=%s\n' "$FM_PRELAUNCH_RECORD_HOME_IDENTITY" + printf 'source_identity=%s\n' "$FM_PRELAUNCH_RECORD_SOURCE_IDENTITY" + printf 'owner_pid=%s\n' "$FM_PRELAUNCH_RECORD_OWNER_PID" + printf 'owner_identity_sha256=%s\n' "$FM_PRELAUNCH_RECORD_OWNER_IDENTITY_HASH" + printf 'token_sha256=%s\n' "$FM_PRELAUNCH_RECORD_TOKEN_HASH" + printf 'child_pid=%s\n' "$FM_PRELAUNCH_RECORD_CHILD_PID" + printf 'child_generation_sha256=%s\n' "$FM_PRELAUNCH_RECORD_CHILD_GENERATION_HASH" + printf 'lock_pid=%s\n' "$lock_pid" + printf 'source_commit=%s\n' "$FM_PRELAUNCH_RECORD_SOURCE_COMMIT" + printf 'target_commit=%s\n' "$FM_PRELAUNCH_RECORD_TARGET_COMMIT" + printf 'update_status=%s\n' "$FM_PRELAUNCH_RECORD_UPDATE_STATUS" + printf 'handed_at=%s\n' "$handed_at" + } > "$tmp" || ! chmod 600 "$tmp" || ! mv -f -- "$tmp" "$file"; then + rm -f -- "$tmp" + return 1 + fi + rm -f -- "$reservation" || return 1 +} + +FM_PRELAUNCH_HANDOFF_HOME= +FM_PRELAUNCH_HANDOFF_HOME_IDENTITY= +FM_PRELAUNCH_HANDOFF_SOURCE_IDENTITY= +FM_PRELAUNCH_HANDOFF_OWNER_PID= +FM_PRELAUNCH_HANDOFF_OWNER_IDENTITY_HASH= +FM_PRELAUNCH_HANDOFF_TOKEN_HASH= +FM_PRELAUNCH_HANDOFF_CHILD_PID= +FM_PRELAUNCH_HANDOFF_CHILD_GENERATION_HASH= +FM_PRELAUNCH_HANDOFF_LOCK_PID= +FM_PRELAUNCH_HANDOFF_SOURCE_COMMIT= +FM_PRELAUNCH_HANDOFF_TARGET_COMMIT= +FM_PRELAUNCH_HANDOFF_UPDATE_STATUS= +FM_PRELAUNCH_HANDOFF_HANDED_AT= +fm_prelaunch_handoff_read() { # + local file="$1/$FM_PRELAUNCH_HANDOFF_FILE" + local l1 l2 l3 l4 l5 l6 l7 l8 l9 l10 l11 l12 l13 l14 _extra + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + { + IFS= read -r l1 && IFS= read -r l2 && IFS= read -r l3 \ + && IFS= read -r l4 && IFS= read -r l5 && IFS= read -r l6 \ + && IFS= read -r l7 && IFS= read -r l8 && IFS= read -r l9 \ + && IFS= read -r l10 && IFS= read -r l11 && IFS= read -r l12 \ + && IFS= read -r l13 && IFS= read -r l14 \ + && ! IFS= read -r _extra + } < "$file" || return 1 + [ "$l1" = 'schema=fm-prelaunch-handoff.v1' ] || return 1 + case "$l2" in home=*) FM_PRELAUNCH_HANDOFF_HOME=${l2#home=} ;; *) return 1 ;; esac + case "$l3" in home_identity=*) FM_PRELAUNCH_HANDOFF_HOME_IDENTITY=${l3#home_identity=} ;; *) return 1 ;; esac + case "$l4" in source_identity=*) FM_PRELAUNCH_HANDOFF_SOURCE_IDENTITY=${l4#source_identity=} ;; *) return 1 ;; esac + case "$l5" in owner_pid=*) FM_PRELAUNCH_HANDOFF_OWNER_PID=${l5#owner_pid=} ;; *) return 1 ;; esac + case "$l6" in owner_identity_sha256=*) FM_PRELAUNCH_HANDOFF_OWNER_IDENTITY_HASH=${l6#owner_identity_sha256=} ;; *) return 1 ;; esac + case "$l7" in token_sha256=*) FM_PRELAUNCH_HANDOFF_TOKEN_HASH=${l7#token_sha256=} ;; *) return 1 ;; esac + case "$l8" in child_pid=*) FM_PRELAUNCH_HANDOFF_CHILD_PID=${l8#child_pid=} ;; *) return 1 ;; esac + case "$l9" in child_generation_sha256=*) FM_PRELAUNCH_HANDOFF_CHILD_GENERATION_HASH=${l9#child_generation_sha256=} ;; *) return 1 ;; esac + case "$l10" in lock_pid=*) FM_PRELAUNCH_HANDOFF_LOCK_PID=${l10#lock_pid=} ;; *) return 1 ;; esac + case "$l11" in source_commit=*) FM_PRELAUNCH_HANDOFF_SOURCE_COMMIT=${l11#source_commit=} ;; *) return 1 ;; esac + case "$l12" in target_commit=*) FM_PRELAUNCH_HANDOFF_TARGET_COMMIT=${l12#target_commit=} ;; *) return 1 ;; esac + case "$l13" in update_status=*) FM_PRELAUNCH_HANDOFF_UPDATE_STATUS=${l13#update_status=} ;; *) return 1 ;; esac + case "$l14" in handed_at=*) FM_PRELAUNCH_HANDOFF_HANDED_AT=${l14#handed_at=} ;; *) return 1 ;; esac + case "$FM_PRELAUNCH_HANDOFF_OWNER_PID:$FM_PRELAUNCH_HANDOFF_LOCK_PID" in + *[!0-9:]*|:*|*:) return 1 ;; + esac + case "$FM_PRELAUNCH_HANDOFF_OWNER_IDENTITY_HASH$FM_PRELAUNCH_HANDOFF_TOKEN_HASH" in + *[!0-9a-f]*) return 1 ;; + esac + [ "${#FM_PRELAUNCH_HANDOFF_OWNER_IDENTITY_HASH}" -eq 64 ] || return 1 + [ "${#FM_PRELAUNCH_HANDOFF_TOKEN_HASH}" -eq 64 ] || return 1 + case "$FM_PRELAUNCH_HANDOFF_CHILD_PID" in ''|*[!0-9]*|0) return 1 ;; esac + case "$FM_PRELAUNCH_HANDOFF_CHILD_GENERATION_HASH" in *[!0-9a-f]*) return 1 ;; esac + [ "${#FM_PRELAUNCH_HANDOFF_CHILD_GENERATION_HASH}" -eq 64 ] || return 1 + case "$FM_PRELAUNCH_HANDOFF_SOURCE_COMMIT$FM_PRELAUNCH_HANDOFF_TARGET_COMMIT" in + ''|*[!0-9a-f]*) return 1 ;; + esac + case "$FM_PRELAUNCH_HANDOFF_UPDATE_STATUS" in current|updated) ;; *) return 1 ;; esac + case "$FM_PRELAUNCH_HANDOFF_HANDED_AT" in ''|*[!0-9]*) return 1 ;; esac +} + +# Authenticate an already-published handoff for hook reporting or idempotent +# release. Success exposes only commit/status globals, never the token itself. +fm_prelaunch_handoff_authenticated() { # + local state=$1 home=$2 owner=$3 token=$4 home_identity source_identity current_identity current_hash token_hash lock_pid + local generation generation_hash owner_authenticated=0 child_authenticated=0 + fm_prelaunch_token_valid "$token" || return 1 + fm_prelaunch_handoff_read "$state" || return 1 + [ "$FM_PRELAUNCH_HANDOFF_HOME" = "$home" ] || return 1 + home_identity=$(fm_prelaunch_directory_identity "$home") || return 1 + [ "$FM_PRELAUNCH_HANDOFF_HOME_IDENTITY" = "$home_identity" ] || return 1 + source_identity=$(fm_prelaunch_source_identity "$home") || return 1 + [ "$FM_PRELAUNCH_HANDOFF_SOURCE_IDENTITY" = "$source_identity" ] || return 1 + [ "$FM_PRELAUNCH_HANDOFF_OWNER_PID" = "$owner" ] || return 1 + token_hash=$(fm_prelaunch_hash_text "$token") || return 1 + [ "$FM_PRELAUNCH_HANDOFF_TOKEN_HASH" = "$token_hash" ] || return 1 + [ -f "$state/.lock" ] && [ ! -L "$state/.lock" ] || return 1 + lock_pid=$(cat "$state/.lock" 2>/dev/null) || return 1 + [ "$FM_PRELAUNCH_HANDOFF_LOCK_PID" = "$lock_pid" ] || return 1 + + # The launcher normally stays above its child, so its full PID identity is + # the strongest receipt authentication and also supports the launcher's + # idempotent release after handoff. + if current_identity=$(fm_pid_identity "$owner" 2>/dev/null) \ + && current_hash=$(fm_prelaunch_hash_text "$current_identity") \ + && [ "$FM_PRELAUNCH_HANDOFF_OWNER_IDENTITY_HASH" = "$current_hash" ] \ + && fm_prelaunch_owner_is_ancestor "$owner"; then + owner_authenticated=1 + fi + + # If the launcher dies after attach, the recorded child generation is the + # durable recovery identity. Only that still-live attached harness ancestry + # may authenticate the receipt; the copied owner hint and token alone remain + # insufficient from an unrelated process. + if generation=$(fm_prelaunch_pid_generation "$FM_PRELAUNCH_HANDOFF_CHILD_PID" 2>/dev/null) \ + && generation_hash=$(fm_prelaunch_hash_text "$generation") \ + && [ "$FM_PRELAUNCH_HANDOFF_CHILD_GENERATION_HASH" = "$generation_hash" ] \ + && fm_prelaunch_owner_is_ancestor "$FM_PRELAUNCH_HANDOFF_CHILD_PID"; then + child_authenticated=1 + fi + [ "$owner_authenticated" -eq 1 ] || [ "$child_authenticated" -eq 1 ] +} diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 67f25704265..4e89a5d5424 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -700,6 +700,27 @@ if [ "$LOCK_RC" -ne 0 ]; then printf '%s\n' "$BAR" } fi + +# A native fresh open reports whether it arrived through the enforced launcher +# handoff. This is evidence about this session only; it never claims that a +# raw CLI/GUI entry was blocked, and it never substitutes for the launcher's +# separately owned whole-pull receipt. Context rebuilds and saved-session +# sources are not fresh launches, so they print no new pull claim. +case "$SESSION_SOURCE:$REEMIT" in + startup:0|new:0|:0) + subsection "STARTUP PULL" + PRELAUNCH_HOME=$(CDPATH='' cd -- "$FM_HOME" 2>/dev/null && pwd -P || true) + if [ "$LOCK_RC" -eq 0 ] && [ -n "$PRELAUNCH_HOME" ] \ + && fm_prelaunch_handoff_authenticated "$STATE" "$PRELAUNCH_HOME" \ + "${FM_PRELAUNCH_OWNER_PID:-}" "${FM_PRELAUNCH_TOKEN:-}"; then + printf 'STARTUP_PULL: verified launcher handoff; source %s -> %s (%s)\n' \ + "$FM_PRELAUNCH_HANDOFF_SOURCE_COMMIT" "$FM_PRELAUNCH_HANDOFF_TARGET_COMMIT" \ + "$FM_PRELAUNCH_HANDOFF_UPDATE_STATUS" + else + printf 'STARTUP_PULL: bypassed; no verified firstmate-start handoff reached this fresh session\n' + fi + ;; +esac REBUILDING_SESSION_PID=$(fm_harness_ancestry_pid 2>/dev/null || true) print_agents_refresh_if_required "$REBUILDING_SESSION_PID" diff --git a/bin/fm-supervision-lib.sh b/bin/fm-supervision-lib.sh index 1bbc5708834..fe29835d718 100644 --- a/bin/fm-supervision-lib.sh +++ b/bin/fm-supervision-lib.sh @@ -97,6 +97,71 @@ fm_supervision_status() { return 0 } +# Build a content-sensitive snapshot of every inventory that contributes to +# FM_SUP_NEEDED or FM_SUP_QUEUE_PENDING. Unlike fm_supervision_status, this +# helper fails when an inventory is unreadable or structurally unsafe; startup +# reservation must distinguish a proven idle home from one it merely failed to +# inspect. Callers serialize the wake queue with its existing queue lock while +# taking the two snapshots below. +fm_supervision_inventory_snapshot() { # + local state=$1 path rel id kind digest listing + [ -d "$state" ] && [ ! -L "$state" ] && [ -r "$state" ] && [ -x "$state" ] || return 1 + if [ -e "$state/procevent" ] || [ -L "$state/procevent" ]; then + [ -d "$state/procevent" ] && [ ! -L "$state/procevent" ] \ + && [ -r "$state/procevent" ] && [ -x "$state/procevent" ] || return 1 + fi + listing=$( + set -o pipefail + for path in "$state"/*.meta "$state"/*.check.sh "$state"/*.check-trust \ + "$state"/procevent/*.source "$state/.wake-queue"; do + [ -e "$path" ] || [ -L "$path" ] || continue + [ -f "$path" ] && [ ! -L "$path" ] && [ -r "$path" ] || exit 1 + rel=${path#"$state"/} + if [[ "$rel" = *.meta ]]; then + kind=meta + elif [[ "$rel" = *.check.sh ]]; then + id=${path##*/} + id=${id%.check.sh} + if [ "$id" != x-watch ] && [ ! -e "$state/$id.check-trust" ]; then + continue + fi + kind=check + elif [[ "$rel" = *.check-trust ]]; then + id=${path##*/} + id=${id%.check-trust} + [ -e "$state/$id.check.sh" ] || continue + kind=trust + elif [[ "$rel" = procevent/*.source ]]; then + kind=source + elif [ "$rel" = .wake-queue ]; then + kind=queue + else + exit 1 + fi + digest=$(LC_ALL=C cksum < "$path" 2>/dev/null) || exit 1 + printf '%s\t%s\t%s\n' "$kind" "${path#"$state"/}" "$digest" + done | LC_ALL=C sort + ) || return 1 + printf '%s\n' "$listing" +} + +# Strict reservation-time form of fm_supervision_status. The ordinary helper +# stays always-zero for reporting callers; this form succeeds only when the +# inventories were readable and byte-stable around that status calculation. +# Sets FM_SUP_SNAPSHOT to the verified snapshot on success. +# shellcheck disable=SC2034 # Output global, read by strict reservation callers. +FM_SUP_SNAPSHOT= +fm_supervision_status_strict() { # [grace-seconds] + local state=$1 grace=${2:-${FM_GUARD_GRACE:-300}} before after + FM_SUP_SNAPSHOT= + before=$(fm_supervision_inventory_snapshot "$state") || return 1 + fm_supervision_status "$state" "$grace" + after=$(fm_supervision_inventory_snapshot "$state") || return 1 + [ "$before" = "$after" ] || return 1 + # shellcheck disable=SC2034 # Output global, read by strict reservation callers. + FM_SUP_SNAPSHOT=$after +} + # fm_supervision_needed [grace-seconds] # Exit 0 (true) exactly when the home needs a watcher. fm_supervision_needed() { diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 340d57b48cf..c837c0247fe 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -347,7 +347,7 @@ family_for_basename() { ;; fm-backlog-atomicity.test.sh|\ fm-bootstrap.test.sh|fm-bootstrap-network-parallel.test.sh|fm-fleet-sync.test.sh|fm-gate-refuse.test.sh|fm-gotmp.test.sh|\ - fm-session-start.test.sh|fm-sessionstart-nudge.test.sh|fm-startup-network.test.sh|\ + fm-prelaunch.test.sh|fm-session-start.test.sh|fm-sessionstart-nudge.test.sh|fm-startup-network.test.sh|\ fm-tangle-guard.test.sh|fm-update.test.sh) printf '%s\n' session-bootstrap ;; @@ -798,6 +798,7 @@ tests/fm-pi-primary-live-e2e.test.sh 72 tests/fm-pi-seeded-home-trust-live-e2e.test.sh 45 tests/fm-pi-watch-extension.test.sh 56515 tests/fm-pi-windows-shell-invocation.test.sh 5121 +tests/fm-prelaunch.test.sh 67138 tests/fm-pr-check-security.test.sh 300675 tests/fm-pr-reviewers.test.sh 157 tests/fm-pr-state-live-e2e.test.sh 47 @@ -1505,10 +1506,23 @@ families_for_changed_path() { printf '%s\n' secondmate ;; bin/fm-session-start.sh|bin/fm-fleet-sync.sh|\ - bin/fm-sessionstart-nudge.sh|bin/fm-startup-network.sh|bin/fm-tangle*|bin/fm-update.sh|\ + bin/fm-prelaunch.sh|bin/fm-sessionstart-nudge.sh|bin/fm-startup-network.sh|bin/fm-tangle*|bin/fm-update.sh|\ bin/fm-gate-refuse*|bin/fm-lock*) printf '%s\n' session-bootstrap ;; + bin/fm-session-lock-lib.sh) + families_for_unmapped_bin "$path" + ;; + bin/fm-supervision-lib.sh) + printf '%s\n' __script__:fm-prelaunch.test.sh + printf '%s\n' pure-contract-unit + ;; + bin/fm-ff-lib.sh) + printf '%s\n' __script__:fm-prelaunch.test.sh + printf '%s\n' __script__:fm-update.test.sh + printf '%s\n' __script__:fm-secondmate-sync.test.sh + printf '%s\n' pure-contract-unit + ;; bin/fm-bootstrap.sh) printf '%s\n' session-bootstrap printf '%s\n' "__script__:fm-brief.test.sh" @@ -1657,7 +1671,7 @@ families_for_changed_path() { bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\ bin/fm-vendor-auth-probe.sh|\ bin/fm-primary-scope-lib.sh|bin/fm-project-mode.sh|bin/fm-forge-detect.sh|bin/fm-promote.sh|\ - bin/fm-ff-lib.sh|bin/fm-gotmp*|bin/*pretool*) + bin/fm-gotmp*|bin/*pretool*) printf '%s\n' pure-contract-unit ;; .agents/skills/quota-array-dispatch/SKILL.md) diff --git a/bin/fm-update.sh b/bin/fm-update.sh index 629dbaee868..a195e00d46f 100755 --- a/bin/fm-update.sh +++ b/bin/fm-update.sh @@ -21,6 +21,10 @@ # The fast-forward mechanics live in bin/fm-ff-lib.sh (base_mode "origin" here); # the same library drives local and remote parent-targeted secondmate sync, so # there is one ff implementation, not several. +# An independent primary additionally calls fm-prelaunch.sh guard-write before +# its first fetch. With no reservation this preserves the explicit update's +# existing authority; a live startup reservation permits only its authenticated +# launcher through the separate pinned-commit update action. # # It does NOT re-read AGENTS.md or nudge secondmates itself - those are LLM / # tmux actions the skill performs. The script's job is the safe git mechanics @@ -85,6 +89,17 @@ if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then fi [ $# -eq 0 ] || { usage; exit 1; } +ROOT_PHYSICAL=$(CDPATH='' cd -- "$FM_ROOT" 2>/dev/null && pwd -P || true) +HOME_PHYSICAL=$(CDPATH='' cd -- "$FM_HOME" 2>/dev/null && pwd -P || true) +if [ -n "$ROOT_PHYSICAL" ] && [ "$ROOT_PHYSICAL" = "$HOME_PHYSICAL" ] \ + && [ ! -e "$ROOT_PHYSICAL/.fm-secondmate-home" ] \ + && [ ! -L "$ROOT_PHYSICAL/.fm-secondmate-home" ] \ + && [ ! -e "$ROOT_PHYSICAL/.fm-lab-home" ] \ + && [ ! -L "$ROOT_PHYSICAL/.fm-lab-home" ] \ + && [ "${NO_MISTAKES_GATE+x}" != x ]; then + bash "$SCRIPT_DIR/fm-prelaunch.sh" guard-write --home "$ROOT_PHYSICAL" >/dev/null +fi + # --- main firstmate repo --------------------------------------------------- reread_firstmate="no" diff --git a/docs/architecture.md b/docs/architecture.md index 1f6b9cf31d9..0eb161f173b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -498,6 +498,30 @@ Dirty, uniquely diverged, offline, and off-default targets are reported and left Local homes share the guarded fast-forward helper, while remote updates delegate the same safety decision to the configured host through the generic transport. The procedure and outcome vocabulary are owned by the [`/updatefirstmate` skill](../.agents/skills/updatefirstmate/SKILL.md); the relevant script headers own the mechanics. +## Fresh primary startup pull + +An enrolled fresh primary launch uses the dotfiles-installed `firstmate-start` wrapper before any agent process exists. +The installed wrapper owns fetch, portable-preference publication, whole-pull evidence, and child creation, while the frozen trusted Firstmate closure owns primary scope, reservation/session coordination, supported fresh profiles, and the pinned source fast-forward. +It never invokes code from the fetched candidate. + +`bin/fm-prelaunch.sh` serializes its reservation with the existing `state/.lock.acquire` claim mutex and binds the exact physical home, standalone Git identity, launcher process identity, and invocation token. +Reservation also holds the existing wake-queue mutex while `fm_supervision_status_strict` proves that the task, event-source, registered-check, and queue inventories are readable, content-stable, idle, and empty. +A live or ambiguous session, launcher, dotfiles publication, fleet, or queue refuses startup mutation. + +The launcher attaches its pipe-blocked child before allowing the vendor exec. +The reservation records an exec-stable child generation, so launcher death does not make a still-live child look reclaimable. +Only a genuine attached harness descendant carrying the matching owner hint and token can exchange that reservation through `bin/fm-lock.sh`; raw and foreign harnesses remain denied until the owner exits or the verified child takes the ordinary lock. +No shell process is manufactured as a harness lock owner. + +The source leg passes one already fetched full commit ID to the existing fast-forward owner in startup mode. +That mode refuses dirty, detached, off-default, ahead, divergent, filtered, or unknown checkouts and disables external attributes and Git hooks for the update. +Every ordinary source or preference writer must call `guard-write` before its mutation; without a reservation the explicit workflow retains its existing authority, while a live reservation permits only its authenticated owner. +Secondmates, linked workers, no-mistakes phases, lab homes, and unsafe or unrecognized homes never enter automatic replacement. + +The synchronous digest performs no startup-pull network work. +For a true fresh open it reports the authenticated handoff commits and status when present, or names the launch as a bypass; re-emits and saved-session sources do not claim a new pull. +The operator-facing entry and bypass list lives in [`sessionstart-nudge.md`](sessionstart-nudge.md#fresh-primary-launcher-pull). + ## Restart-proof Fleet state lives in each task's session-provider backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected), no-mistakes run records, status event logs, local markdown under `data/` including `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`, and persistent secondmate homes. diff --git a/docs/scripts.md b/docs/scripts.md index e6e8da4bb6d..af7fdf43076 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -8,6 +8,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | Script | Purpose | | ------------------------ | ------------------------------------------------------------------------------------ | | `fm-session-start.sh` | Compose lock, bootstrap, and wake drain into the single ordered session-start digest | +| `fm-prelaunch.sh` | Coordinate an enrolled fresh primary launch through strict idleness, pinned source update, child attachment, and session-lock handoff | | `fm-sessionstart-nudge.sh` | Print the native session-start hook nudge when the primary has not already run the digest | | `fm-sessionstart-run.sh` | Route a native session-open hook to the full digest, a context re-emit, or the nudge | | `fm-operational-input.sh` | Construct and parse the canonical cross-language operational-input protocol | @@ -48,7 +49,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `fm-ensure-agents-md.sh` | Manually initialize project agent-memory files (see the helper's header and help) | | `fm-guard.sh` | Warn on primary-checkout tangles, main-session pending wakes, and unhealthy supervision | | `fm-primary-scope-lib.sh` | Shared marker-or-plain-checkout primary-home predicate for tracked hooks | -| `fm-session-lock-lib.sh` | Shared session-lock ownership from harness ancestry or a trusted Claude session id for fm-lock.sh and the Claude Stop auto-arm, plus the read-only lock inspection behind `fm-lock.sh status` and `fm-inbox.sh ready` | +| `fm-session-lock-lib.sh` | Share session-lock and authenticated prelaunch reservation ownership across lock acquisition, hook reporting, and read-only inspection | | `fm-claude-stop-autoarm.sh` | Claude Stop `asyncRewake` hook owning tokenless watcher continuity with single-flight exit-2 rewake (docs/watcher-continuity.md) | | `fm-turnend-guard.sh` | Shared primary turn-end guard predicate so no turn ends blind (docs/turnend-guard.md) | | `fm-turnend-guard-grok.sh` | Grok Stop-hook adapter for the primary turn-end guard | @@ -105,8 +106,8 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification | | `fm-timeout-lib.sh` | Single owner of hard-bounded command execution and its fallback watchdog | | `fm-timing-lib.sh` | Single owner of the deferred network stage's per-step elapsed-time records, inert unless a run asks for them | -| `fm-supervision-lib.sh` | Shared in-flight-work-without-fresh-watcher-beacon predicate | -| `fm-ff-lib.sh` | Shared guarded fast-forward/reconcile helper for origin pulls and secondmate syncs, with durable divergence markers | +| `fm-supervision-lib.sh` | Share the supervision predicate and its reservation-time readable stable-inventory proof | +| `fm-ff-lib.sh` | Share guarded fast-forward mechanics for origin pulls, secondmate syncs, and pinned no-candidate-execution startup updates | | `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync | | `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inherited local-material propagation and config-reread delivery | | `fm-tasks-axi.sh` | Run `tasks-axi` against this home's backlog from any working directory | diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index 2b111302985..298989cb4fd 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -12,6 +12,7 @@ One term recurs throughout: | Question | Section | | --- | --- | +| Which fresh launches receive the enforced startup pull | [Fresh primary launcher pull](#fresh-primary-launcher-pull) | | Which harness runs the digest and which only nudges it | [Tier by harness](#tier-by-harness) | | What each session-open source triggers | [Source routing](#source-routing) | | How long the digest may block and what happens when it runs out of time | [Runtime bound](#runtime-bound) | @@ -19,6 +20,30 @@ One term recurs throughout: | How one harness wires its session-open hook | [Harness transports](#harness-transports) | | Which tests prove each guarantee | [Regression coverage](#regression-coverage) | +## Fresh primary launcher pull + +The startup pull is a prelaunch layer installed separately by dotfiles as `firstmate-start`; it is not another session-open tier. +The existing `bash ./install.sh firstmate` selection remains configuration-only, while `bash ./install.sh firstmate-startup` owns launcher installation and enrollment. +Its frozen trusted bundle calls `bin/fm-prelaunch.sh` against one enrolled physical independent-primary home, pins the approved source candidate locally, and completes every source and portable-preference check before it creates the agent process. +The dotfiles launcher remains the child's real parent, blocks the child bootstrap on an inherited anonymous pipe until `attach` records its kernel generation, and passes one go byte only after that attachment succeeds. +The genuine child then acquires the ordinary Firstmate session lock through `bin/fm-lock.sh`, which atomically exchanges the authenticated reservation for the normal harness lock and a verified handoff receipt. + +The version 1 supported entry is `firstmate-start ` with no harness arguments and a harness profile that `capabilities` advertises and `profile` revalidates at launch time. +Firstmate can advertise installed no-argument fresh profiles for `claude`, `codex`, `cursor`, `opencode`, `grok`, `kimi`, `pi`, `pi-signed`, and `omp`; an absent binary or an unrecognized version is not advertised. +Any argument, including resume, continue, fork, or saved-session selection, is refused by this first version before fetch, reservation, publication, or child creation. + +These entries remain outside the enforced guarantee: + +- A raw vendor CLI invocation that bypasses `firstmate-start`. +- A direct GUI or reopened conversation. +- Codex's interactive TUI when opened directly. +- In-process new, clear, compact, resume, reload, or fork operations. + +Those bypasses are not globally blocked or replaced. +The native hook still runs its existing digest or nudge behavior, and a fresh full digest reports either a verified launcher handoff or an honest bypass. +Hook adapters keep their ordinary always-zero compatibility and never turn a notice into a claim that the process exited or refreshed itself. +The startup pull adds no fetch to `fm-session-start.sh`, no global agent-command replacement, and no daemon or scheduler. + ## Session-open tiers Firstmate ships two session-open tiers. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 11bf2525fb9..2e0b33bf3f1 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -6,6 +6,39 @@ This record supports current session-start, turn-end, watcher-continuity, superv Operator behavior and active limits remain in the linked current guides. Task-specific chronology, temporary paths, run identifiers, and delivery transcripts remain in private reports or PR evidence. +## Fresh primary startup-pull coordination + +The portable startup-pull suite ran on 2026-10-08 against disposable standalone Git homes and real operating-system processes. +It did not install into or drive the live primary home, and it is not native vendor lifecycle evidence. + +```sh +tests/fm-prelaunch.test.sh +``` + +Observed result: + +```text +ok - prelaunch: capabilities are read-only and expose a frozen closure plus exact fresh profiles +ok - prelaunch: profile launch_env equals the advertised allowlisted capability object +ok - prelaunch: unsafe fleet inventories fail the strict idleness proof +ok - prelaunch: guard-write refuses only reservations, not enrollment-ineligible primaries +ok - prelaunch: reservation validation, write guard, token binding and release are enforced +ok - prelaunch: pinned preflight/update are local-only, count SHA movement, and disable Git hooks +ok - prelaunch: filters, dirty state and wrong branches refuse without checkout mutation +ok - prelaunch: active fleet, pending queue, session/publication locks and malformed state refuse +ok - prelaunch: source origin/default identity remains bound after reservation +ok - prelaunch: races, death, PID reuse and copied-token ancestry are fail-closed +ok - prelaunch: attached child keeps occupancy and completes verified handoff after launcher death +ok - prelaunch: aliases, secondmates, no-mistakes phases and linked workers are excluded +ok - prelaunch: only the authenticated genuine child atomically exchanges into the session lock +``` + +The suite uses the executable JSON interface, local Git objects, a disabled-hook sentinel, concurrent launchers, process death, PID-generation tampering, physical-path aliases, an attached child that outlives its launcher, and a harness-shaped process that invokes the real `fm-lock.sh` handoff. +It also proves that live or ambiguous dotfiles publication state blocks reservation, that dangling session/reservation lock symlinks and unreadable fleet-inventory entries fail closed, that `guard-write` leaves explicit updates of unenrolled or enrollment-ineligible primaries alone, and that `profile` returns exactly the allowlisted `launch_env` object `capabilities` advertises. +The companion dotfiles suite owns network budget, committed portable-object validation, backup/publication failure recovery, whole-pull evidence, and exactly-one-child assertions. +Combined disposable-home acceptance must run both suites against the same committed Firstmate closure before enrollment. +Real supported harness launches remain an opt-in acceptance obligation and must record the exact vendor command, version, child-visible sentinel, and handoff result; no native lifecycle evidence was fabricated by this portable run. + ## Native session-start delivery The cross-harness transport pass ran on 2026-07-17 with Codex 0.144.4, Grok 0.2.103, OpenCode 1.17.18, Pi 0.80.10, and the tracked Claude hook wiring. diff --git a/tests/fm-prelaunch.test.sh b/tests/fm-prelaunch.test.sh new file mode 100755 index 00000000000..85e6f80fd82 --- /dev/null +++ b/tests/fm-prelaunch.test.sh @@ -0,0 +1,610 @@ +#!/usr/bin/env bash +# Executable/process regression tests for bin/fm-prelaunch.sh and its native +# session-lock handoff. Every home is a disposable standalone repository; no +# live Firstmate home, launcher, or lifecycle is touched. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +PRELAUNCH="$ROOT/bin/fm-prelaunch.sh" +LOCK="$ROOT/bin/fm-lock.sh" +TMP_ROOT=$(fm_test_tmproot fm-prelaunch) +TOKEN_A=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +TOKEN_B=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +TOKEN_C=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc + +fm_git_identity fmtest fmtest@example.invalid + +new_home() { # + local home="$TMP_ROOT/$1" + mkdir -p "$home/bin" + cp "$ROOT/AGENTS.md" "$home/AGENTS.md" + cp "$ROOT/.gitignore" "$home/.gitignore" + cp -R "$ROOT/bin/." "$home/bin/" + printf 'initial\n' > "$home/version.txt" + git init -q -b main "$home" + git -C "$home" add .gitignore AGENTS.md bin version.txt + git -C "$home" commit -qm initial + git -C "$home" remote add origin https://example.invalid/firstmate.git + printf '%s\n' "$home" +} + +run_pre() { # + local home=$1 + shift + bash "$PRELAUNCH" "$@" --home "$home" +} + +reserve_main() { # + run_pre "$1" reserve --owner-pid "$$" --token "$2" +} + +release_main() { # + run_pre "$1" release --owner-pid "$$" --token "$2" +} + +add_candidate() { # + local home=$1 content=$2 + printf '%s\n' "$content" > "$home/version.txt" + git -C "$home" add version.txt + git -C "$home" commit -qm "$content" + git -C "$home" rev-parse HEAD +} + +test_capabilities_are_read_only_and_explicit() { + local home out before after harness fakebin + home=$(new_home capabilities) + fakebin="$TMP_ROOT/capabilities-bin" + mkdir -p "$fakebin" + fm_fake_version_tool "$fakebin" claude FM_FAKE_CLAUDE_VERSION '1.0.0 (Claude Code)' + before=$(git -C "$home" status --porcelain) + out=$(PATH="$fakebin:/usr/bin:/bin:/usr/sbin:/sbin" run_pre "$home" capabilities) \ + || fail "capabilities refused a standalone primary: $out" + after=$(git -C "$home" status --porcelain) + [ "$before" = "$after" ] || fail "capabilities changed the checkout" + printf '%s\n' "$out" | jq -e ' + .version == 1 and .status == "compatible" + and .source_remote == "origin" + and .source_ref == "refs/heads/main" + and (.bundle_files | index("bin/fm-prelaunch.sh")) != null + and (.profiles | length) > 0 + and ([.profiles[].harness_version] | all(type == "string" and length > 0)) + and ([.profiles[].argv_grammar] | all(.kind == "exact" and .args == [] and .session == "fresh-only")) + ' >/dev/null || fail "capabilities JSON did not expose the v1 closure and fresh-only grammar: $out" + [ ! -e "$home/state" ] || fail "read-only capabilities created state" + harness=$(printf '%s\n' "$out" | jq -r '.profiles | keys[0]') + out=$(PATH="$fakebin:/usr/bin:/bin:/usr/sbin:/sbin" \ + bash "$PRELAUNCH" profile --home "$home" --harness "$harness" --) \ + || fail "profile refused its advertised harness: $out" + printf '%s\n' "$out" | jq -e --arg harness "$harness" ' + .version == 1 and .status == "fresh" and (.binary | startswith("/")) + and (.harness_version | length) > 0 and .argv == [] + ' >/dev/null || fail "profile did not return the validated fresh invocation: $out" + if PATH="$fakebin:/usr/bin:/bin:/usr/sbin:/sbin" \ + bash "$PRELAUNCH" profile --home "$home" --harness "$harness" -- --resume >/dev/null 2>&1; then + fail "profile accepted a saved-session selector" + fi + pass "prelaunch: capabilities are read-only and expose a frozen closure plus exact fresh profiles" +} + +test_profile_launch_env_matches_capabilities() { + local home fakebin caps out harness expected + home=$(new_home launch-env) + fakebin="$TMP_ROOT/launch-env-bin" + mkdir -p "$fakebin" + fm_fake_version_tool "$fakebin" claude FM_FAKE_CLAUDE_VERSION '1.0.0 (Claude Code)' + fm_fake_version_tool "$fakebin" pi-signed FM_FAKE_PI_SIGNED_VERSION 'pi-signed 1.0.0' + fm_fake_version_tool "$fakebin" omp FM_FAKE_OMP_VERSION 'omp 1.0.0' + caps=$(FM_PI_HARNESS=forged FM_OMP_HARNESS=forged PATH="$fakebin:/usr/bin:/bin:/usr/sbin:/sbin" \ + run_pre "$home" capabilities) || fail "capabilities refused the launch-env fixture: $caps" + for harness in claude pi-signed omp; do + case "$harness" in + pi-signed) expected='{"FM_PI_HARNESS":"pi-signed"}' ;; + omp) expected='{"FM_OMP_HARNESS":"omp"}' ;; + *) expected='{}' ;; + esac + out=$(FM_PI_HARNESS=forged FM_OMP_HARNESS=forged PATH="$fakebin:/usr/bin:/bin:/usr/sbin:/sbin" \ + bash "$PRELAUNCH" profile --home "$home" --harness "$harness" --) \ + || fail "profile refused advertised harness $harness: $out" + jq -en --argjson caps "$caps" --argjson out "$out" --argjson expected "$expected" --arg h "$harness" ' + $out.status == "fresh" + and $caps.profiles[$h].launch_env == $expected + and $out.launch_env == $expected + ' >/dev/null || fail "profile launch_env for $harness diverged from capabilities or its allowlist: $out" + done + pass "prelaunch: profile launch_env equals the advertised allowlisted capability object" +} + +test_strict_inventory_errors_refuse_reservation() { + local home out + home=$(new_home dangling-inventory) + mkdir -p "$home/state" + ln -s "$TMP_ROOT/missing-meta-target" "$home/state/w1.meta" + if reserve_main "$home" "$TOKEN_A" >/dev/null 2>&1; then + fail "a dangling fleet metadata symlink was treated as a proven idle home" + fi + [ ! -e "$home/state/.prelaunch-reservation" ] \ + || fail "an unreadable inventory left a reservation behind" + rm -f "$home/state/w1.meta" + mkdir -p "$home/state/procevent" + ln -s "$TMP_ROOT/missing-source-target" "$home/state/procevent/w1.source" + if reserve_main "$home" "$TOKEN_A" >/dev/null 2>&1; then + fail "a dangling procevent source symlink was treated as a proven idle home" + fi + rm -f "$home/state/procevent/w1.source" + out=$(reserve_main "$home" "$TOKEN_A") || fail "a clean inventory refused reservation: $out" + release_main "$home" "$TOKEN_A" >/dev/null || fail "release of the clean inventory reservation failed" + pass "prelaunch: unsafe fleet inventories fail the strict idleness proof" +} + +test_guard_write_admits_ineligible_unreserved_primaries() { + local home base linked out + home=$(new_home no-origin) + git -C "$home" remote remove origin + out=$(run_pre "$home" guard-write) || fail "guard-write refused an unreserved home without origin: $out" + assert_contains "$out" '"status":"clear"' "an ineligible unreserved home should stay clear" + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$home" "$home/bin/fm-update.sh" 2>&1) \ + || fail "the explicit updater refused an unreserved home without origin: $out" + mkdir -p "$TMP_ROOT/outside-data" + ln -s "$TMP_ROOT/outside-data" "$home/data" + out=$(run_pre "$home" guard-write) || fail "guard-write refused an unreserved home with symlinked data: $out" + assert_contains "$out" '"status":"clear"' "a symlinked data home without a reservation should stay clear" + mkdir -p "$home/state" + printf 'not-a-reservation\n' > "$home/state/.prelaunch-reservation" + if run_pre "$home" guard-write >/dev/null 2>&1; then + fail "an ineligible home with a reservation record failed open for a writer" + fi + if FM_HOME="$home" FM_ROOT_OVERRIDE="$home" "$home/bin/fm-update.sh" >/dev/null 2>&1; then + fail "the explicit updater crossed an ambiguous reservation on an ineligible home" + fi + base=$(new_home guard-linked-base) + linked="$TMP_ROOT/guard-linked-worker" + git -C "$base" worktree add -q --detach "$linked" HEAD + out=$(run_pre "$linked" guard-write) || fail "guard-write refused an unreserved linked worktree: $out" + assert_contains "$out" '"status":"clear"' "an unreserved linked worktree should stay clear" + pass "prelaunch: guard-write refuses only reservations, not enrollment-ineligible primaries" +} + +test_attached_child_survives_parent_death_as_occupancy() { + local home ready info stop launcher child owner clear=0 fakebin result + home=$(new_home parent-death) + fakebin="$TMP_ROOT/parent-death-bin" + mkdir -p "$fakebin" + ln -s /bin/bash "$fakebin/codex" + ready="$TMP_ROOT/parent-death.ready" + info="$TMP_ROOT/parent-death.info" + stop="$TMP_ROOT/parent-death.stop" + result="$TMP_ROOT/parent-death.result" + bash -c ' + pre=$1; home=$2; token=$3; ready=$4; info=$5; stop=$6; harness=$7; lock=$8; lib=$9; result=${10} + owner=$$ + head=$(git -C "$home" rev-parse HEAD) || exit 1 + bash "$pre" reserve --home "$home" --owner-pid "$owner" --token "$token" >/dev/null || exit 2 + bash "$pre" update --home "$home" --owner-pid "$owner" --token "$token" --commit "$head" >/dev/null || exit 3 + ( + n=0 + while [ ! -e "$stop" ] && [ "$n" -lt 500 ]; do sleep 0.02; n=$((n + 1)); done + exec env FM_HOME="$home" FM_ROOT_OVERRIDE="$home" FM_PRELAUNCH_OWNER_PID="$owner" \ + FM_PRELAUNCH_TOKEN="$token" "$harness" -c '\'' + "$1" > "$6.lock" 2>&1 || exit 1 + . "$2" + if fm_prelaunch_handoff_authenticated "$3/state" "$3" "$4" "$5"; then + printf "verified\n" > "$6" + else + printf "refused\n" > "$6" + exit 2 + fi + '\'' _ "$lock" "$lib" "$home" "$owner" "$token" "$result" + ) & + child=$! + bash "$pre" attach --home "$home" --owner-pid "$owner" --token "$token" --child-pid "$child" >/dev/null || exit 4 + printf "%s %s\n" "$owner" "$child" > "$info" + : > "$ready" + wait "$child" + ' _ "$PRELAUNCH" "$home" "$TOKEN_A" "$ready" "$info" "$stop" \ + "$fakebin/codex" "$LOCK" "$ROOT/bin/fm-session-lock-lib.sh" "$result" & + launcher=$! + for _ in $(seq 1 250); do [ -e "$ready" ] && break; sleep 0.02; done + [ -e "$ready" ] || fail "attached-child parent-death fixture did not become ready" + read -r owner child < "$info" + kill -TERM "$launcher" + wait "$launcher" 2>/dev/null || true + kill -0 "$child" 2>/dev/null || fail "the attached child did not outlive its launcher" + if run_pre "$home" guard-write >/dev/null 2>&1; then + fail "parent death opened a write window while the attached child lived" + fi + if run_pre "$home" release --owner-pid "$owner" --token "$TOKEN_A" >/dev/null 2>&1; then + fail "an unrelated process released a live attached child after parent death" + fi + : > "$stop" + for _ in $(seq 1 250); do + if [ -e "$result" ] && run_pre "$home" guard-write >/dev/null 2>&1; then clear=1; break; fi + sleep 0.02 + done + [ "$(cat "$result" 2>/dev/null || true)" = verified ] \ + || fail "the attached child could not authenticate its handoff after launcher death" + [ -f "$home/state/.prelaunch-handoff" ] \ + || fail "parent-death handoff did not publish its receipt" + [ "$clear" -eq 1 ] || fail "dead parent and child did not become reclaimable" + pass "prelaunch: attached child keeps occupancy and completes verified handoff after launcher death" +} + +test_reservation_validation_guard_and_release() { + local home out + home=$(new_home reservation) + out=$(reserve_main "$home" "$TOKEN_A") || fail "reserve failed: $out" + assert_contains "$out" '"status":"reserved"' "reserve should report its status" + [ -f "$home/state/.prelaunch-reservation" ] || fail "reserve did not publish its record" + out=$(run_pre "$home" validate --owner-pid "$$" --token "$TOKEN_A") \ + || fail "validate refused the owner: $out" + assert_contains "$out" '"status":"valid"' "validate should authenticate the owner" + if run_pre "$home" guard-write >/dev/null 2>&1; then + fail "an unauthenticated writer crossed a live reservation" + fi + if FM_HOME="$home" FM_ROOT_OVERRIDE="$home" "$home/bin/fm-update.sh" >/dev/null 2>&1; then + fail "the explicit source updater crossed a live reservation" + fi + out=$(run_pre "$home" guard-write --owner-pid "$$" --token "$TOKEN_A") \ + || fail "the authenticated writer was refused: $out" + assert_contains "$out" '"status":"owned"' "the authenticated guard should report owned" + if run_pre "$home" validate --owner-pid "$$" --token "$TOKEN_B" >/dev/null 2>&1; then + fail "a forged token authenticated the reservation" + fi + out=$(release_main "$home" "$TOKEN_A") || fail "release refused the owner: $out" + assert_contains "$out" '"status":"released"' "release should report release" + [ ! -e "$home/state/.prelaunch-reservation" ] || fail "release left the reservation" + out=$(run_pre "$home" guard-write) || fail "a clear home refused an explicit writer: $out" + assert_contains "$out" '"status":"clear"' "an unreserved guard should preserve explicit authority" + pass "prelaunch: reservation validation, write guard, token binding and release are enforced" +} + +test_pinned_update_preflight_and_no_candidate_execution() { + local home base candidate out hook + home=$(new_home update) + base=$(git -C "$home" rev-parse HEAD) + candidate=$(add_candidate "$home" candidate) + git -C "$home" reset -q --hard "$base" + hook="$home/hook-ran" + { + printf '#!/usr/bin/env bash\n' + printf 'touch %q\n' "$hook" + } > "$home/.git/hooks/post-merge" + chmod +x "$home/.git/hooks/post-merge" + + reserve_main "$home" "$TOKEN_A" >/dev/null || fail "update fixture reserve failed" + out=$(run_pre "$home" check-update --owner-pid "$$" --token "$TOKEN_A" --commit "$candidate") \ + || fail "check-update refused a safe descendant: $out" + printf '%s\n' "$out" | jq -e --arg candidate "$candidate" ' + .version == 1 and .status == "updated" and .source_head == $candidate + and .changed == true + and (keys | sort) == ["changed", "home", "source_head", "status", "version"] + ' >/dev/null || fail "preflight did not return the exact pinned-commit projection: $out" + [ "$(git -C "$home" rev-parse HEAD)" = "$base" ] || fail "check-update moved HEAD" + + out=$(run_pre "$home" update --owner-pid "$$" --token "$TOKEN_A" --commit "$candidate") \ + || fail "pinned update failed: $out" + assert_contains "$out" '"status":"updated"' "update should report the advance" + assert_contains "$out" '"changed":true' "a documentation-sized SHA advance must count as changed" + [ "$(git -C "$home" rev-parse HEAD)" = "$candidate" ] || fail "update missed the pinned commit" + [ ! -e "$hook" ] || fail "candidate movement executed a post-merge hook" + release_main "$home" "$TOKEN_A" >/dev/null || fail "updated reservation did not release" + + reserve_main "$home" "$TOKEN_B" >/dev/null || fail "current fixture reserve failed" + out=$(run_pre "$home" check-update --owner-pid "$$" --token "$TOKEN_B" --commit "$candidate") \ + || fail "current preflight failed: $out" + printf '%s\n' "$out" | jq -e --arg candidate "$candidate" ' + .version == 1 and .status == "current" and .source_head == $candidate + and .changed == false + and (keys | sort) == ["changed", "home", "source_head", "status", "version"] + ' >/dev/null || fail "current preflight did not return the exact pinned-commit projection: $out" + out=$(run_pre "$home" update --owner-pid "$$" --token "$TOKEN_B" --commit "$candidate") \ + || fail "current update failed: $out" + assert_contains "$out" '"status":"current"' "same-SHA update should be current" + assert_contains "$out" '"changed":false' "same-SHA update should be unchanged" + release_main "$home" "$TOKEN_B" >/dev/null || fail "current reservation did not release" + pass "prelaunch: pinned preflight/update are local-only, count SHA movement, and disable Git hooks" +} + +test_filter_dirty_branch_and_divergence_refuse_without_mutation() { + local home base filter_candidate head + home=$(new_home update-refusals) + base=$(git -C "$home" rev-parse HEAD) + printf '*.txt filter=untrusted\n' > "$home/.gitattributes" + printf 'candidate\n' > "$home/filtered.txt" + git -C "$home" add .gitattributes filtered.txt + git -C "$home" commit -qm filtered-candidate + filter_candidate=$(git -C "$home" rev-parse HEAD) + git -C "$home" reset -q --hard "$base" + + reserve_main "$home" "$TOKEN_A" >/dev/null || fail "filter fixture reserve failed" + if run_pre "$home" update --owner-pid "$$" --token "$TOKEN_A" --commit "$filter_candidate" >/dev/null 2>&1; then + fail "a candidate-declared checkout filter was accepted" + fi + [ "$(git -C "$home" rev-parse HEAD)" = "$base" ] || fail "filter refusal moved HEAD" + release_main "$home" "$TOKEN_A" >/dev/null || fail "filter fixture release failed" + + printf 'dirty\n' >> "$home/version.txt" + reserve_main "$home" "$TOKEN_B" >/dev/null || fail "dirty fixture reserve failed" + if run_pre "$home" update --owner-pid "$$" --token "$TOKEN_B" --commit "$filter_candidate" >/dev/null 2>&1; then + fail "a dirty checkout was updated" + fi + [ "$(git -C "$home" rev-parse HEAD)" = "$base" ] || fail "dirty refusal moved HEAD" + git -C "$home" checkout -q -- version.txt + release_main "$home" "$TOKEN_B" >/dev/null || fail "dirty fixture release failed" + + git -C "$home" checkout -qb feature + reserve_main "$home" "$TOKEN_C" >/dev/null || fail "wrong-branch fixture reserve failed" + if run_pre "$home" update --owner-pid "$$" --token "$TOKEN_C" --commit "$filter_candidate" >/dev/null 2>&1; then + fail "a non-default branch was updated" + fi + head=$(git -C "$home" rev-parse HEAD) + [ "$head" = "$base" ] || fail "wrong-branch refusal moved HEAD" + release_main "$home" "$TOKEN_C" >/dev/null || fail "wrong-branch fixture release failed" + pass "prelaunch: filters, dirty state and wrong branches refuse without checkout mutation" +} + +test_session_fleet_queue_and_unknown_state_refuse() { + local home user_home + home=$(new_home idle-refusals) + mkdir -p "$home/state" + printf 'kind=ship\n' > "$home/state/live.meta" + if reserve_main "$home" "$TOKEN_A" >/dev/null 2>&1; then + fail "active fleet metadata allowed a reservation" + fi + rm -f "$home/state/live.meta" + printf '1\tsignal:test\n' > "$home/state/.wake-queue" + if reserve_main "$home" "$TOKEN_A" >/dev/null 2>&1; then + fail "a pending wake queue allowed a reservation" + fi + rm -f "$home/state/.wake-queue" + printf '%s\n' "$$" > "$home/state/.lock" + if reserve_main "$home" "$TOKEN_A" >/dev/null 2>&1; then + fail "an unknown live session-lock owner allowed a reservation" + fi + rm -f "$home/state/.lock" + ln -s "$home/state/missing-lock" "$home/state/.lock" + if reserve_main "$home" "$TOKEN_A" >/dev/null 2>&1; then + fail "a dangling session-lock symlink allowed a reservation" + fi + rm -f "$home/state/.lock" + printf 'not-a-reservation\n' > "$home/state/.prelaunch-reservation" + if run_pre "$home" guard-write >/dev/null 2>&1; then + fail "a malformed reservation failed open for a writer" + fi + if reserve_main "$home" "$TOKEN_A" >/dev/null 2>&1; then + fail "a malformed reservation failed open for a launcher" + fi + rm -f "$home/state/.prelaunch-reservation" + ln -s "$home/state/missing-reservation" "$home/state/.prelaunch-reservation" + if run_pre "$home" guard-write >/dev/null 2>&1; then + fail "a dangling reservation symlink failed open for a writer" + fi + rm -f "$home/state/.prelaunch-reservation" + + user_home="$TMP_ROOT/dotfiles-lock-home" + mkdir -p "$user_home/.local/state/dotfiles/lock" + printf '%s\n' "$$" > "$user_home/.local/state/dotfiles/lock/pid" + if HOME="$user_home" bash "$PRELAUNCH" reserve --home "$home" \ + --owner-pid "$$" --token "$TOKEN_A" >/dev/null 2>&1; then + fail "a live dotfiles publication lock allowed a reservation" + fi + [ ! -e "$home/state/.prelaunch-reservation" ] \ + || fail "dotfiles publication refusal left a reservation" + rm -f "$user_home/.local/state/dotfiles/lock/pid" + if HOME="$user_home" bash "$PRELAUNCH" reserve --home "$home" \ + --owner-pid "$$" --token "$TOKEN_A" >/dev/null 2>&1; then + fail "an ambiguous dotfiles publication lock allowed a reservation" + fi + pass "prelaunch: active fleet, pending queue, session/publication locks and malformed state refuse" +} + +test_source_identity_change_refuses_validation() { + local home + home=$(new_home source-identity) + reserve_main "$home" "$TOKEN_A" >/dev/null || fail "source-identity fixture reserve failed" + git -C "$home" remote set-url origin https://example.invalid/replaced.git + if run_pre "$home" validate --owner-pid "$$" --token "$TOKEN_A" >/dev/null 2>&1; then + fail "changed enrolled source identity retained reservation authority" + fi + pass "prelaunch: source origin/default identity remains bound after reservation" +} + +test_race_dead_owner_pid_reuse_and_ancestry() { + local home result_a result_b ready_a ready_b stop_a stop_b successes + home=$(new_home ownership) + result_a="$TMP_ROOT/race-a" + result_b="$TMP_ROOT/race-b" + ready_a="$TMP_ROOT/ready-a" + ready_b="$TMP_ROOT/ready-b" + stop_a="$TMP_ROOT/stop-a" + stop_b="$TMP_ROOT/stop-b" + bash -c ' + pre=$1; home=$2; token=$3; result=$4; ready=$5; stop=$6 + rc=0 + bash "$pre" reserve --home "$home" --owner-pid "$$" --token "$token" >"$result.out" 2>"$result.err" || rc=$? + printf "%s\n" "$rc" > "$result" + : > "$ready" + if [ "$rc" -eq 0 ]; then + while [ ! -e "$stop" ]; do sleep 0.02; done + bash "$pre" release --home "$home" --owner-pid "$$" --token "$token" >/dev/null 2>&1 || true + fi + ' _ "$PRELAUNCH" "$home" "$TOKEN_A" "$result_a" "$ready_a" "$stop_a" & + pid_a=$! + bash -c ' + pre=$1; home=$2; token=$3; result=$4; ready=$5; stop=$6 + rc=0 + bash "$pre" reserve --home "$home" --owner-pid "$$" --token "$token" >"$result.out" 2>"$result.err" || rc=$? + printf "%s\n" "$rc" > "$result" + : > "$ready" + if [ "$rc" -eq 0 ]; then + while [ ! -e "$stop" ]; do sleep 0.02; done + bash "$pre" release --home "$home" --owner-pid "$$" --token "$token" >/dev/null 2>&1 || true + fi + ' _ "$PRELAUNCH" "$home" "$TOKEN_B" "$result_b" "$ready_b" "$stop_b" & + pid_b=$! + for _ in $(seq 1 250); do + [ -e "$ready_a" ] && [ -e "$ready_b" ] && break + sleep 0.02 + done + [ -e "$ready_a" ] && [ -e "$ready_b" ] || fail "racing launchers did not settle" + successes=0 + [ "$(cat "$result_a")" -ne 0 ] || successes=$((successes + 1)) + [ "$(cat "$result_b")" -ne 0 ] || successes=$((successes + 1)) + [ "$successes" -eq 1 ] || fail "racing launchers produced $successes owners" + : > "$stop_a" + : > "$stop_b" + wait "$pid_a" || true + wait "$pid_b" || true + + # A launcher that dies without release is reclaimed only after kernel + # identity proves it is gone. + bash -c ' + bash "$1" reserve --home "$2" --owner-pid "$$" --token "$3" >/dev/null + ' _ "$PRELAUNCH" "$home" "$TOKEN_A" || fail "dead-owner fixture could not reserve" + reserve_main "$home" "$TOKEN_B" >/dev/null || fail "a verified dead owner was not reclaimed" + # A live PID with a mismatched recorded identity models PID reuse. It is + # stale evidence, never authority for the process now carrying that PID. + sed 's/^owner_identity_sha256=.*/owner_identity_sha256=0000000000000000000000000000000000000000000000000000000000000000/' \ + "$home/state/.prelaunch-reservation" > "$home/state/.prelaunch-reservation.next" + mv "$home/state/.prelaunch-reservation.next" "$home/state/.prelaunch-reservation" + reserve_main "$home" "$TOKEN_C" >/dev/null || fail "a PID-reuse identity mismatch was not safely reclaimed" + release_main "$home" "$TOKEN_C" >/dev/null || fail "PID-reuse fixture release failed" + + # A sibling process knows the token and PID but is not below that launcher. + ready_a="$TMP_ROOT/ancestry-ready" + stop_a="$TMP_ROOT/ancestry-stop" + result_a="$TMP_ROOT/ancestry-owner" + bash -c ' + printf "%s\n" "$$" > "$4" + bash "$1" reserve --home "$2" --owner-pid "$$" --token "$3" >/dev/null || exit 1 + : > "$5" + while [ ! -e "$6" ]; do sleep 0.02; done + bash "$1" release --home "$2" --owner-pid "$$" --token "$3" >/dev/null + ' _ "$PRELAUNCH" "$home" "$TOKEN_A" "$result_a" "$ready_a" "$stop_a" & + pid_a=$! + for _ in $(seq 1 250); do [ -e "$ready_a" ] && break; sleep 0.02; done + [ -e "$ready_a" ] || fail "ancestry owner did not reserve" + if run_pre "$home" validate --owner-pid "$(cat "$result_a")" --token "$TOKEN_A" >/dev/null 2>&1; then + fail "a sibling process authenticated with copied PID and token" + fi + : > "$stop_a" + wait "$pid_a" || fail "ancestry owner could not release" + pass "prelaunch: races, death, PID reuse and copied-token ancestry are fail-closed" +} + +test_scope_and_physical_home_exclusions() { + local home linked alias base + home=$(new_home scope) + alias="$TMP_ROOT/scope-alias" + ln -s "$home" "$alias" + if run_pre "$alias" capabilities >/dev/null 2>&1; then + fail "a symlink spelling enrolled instead of the exact physical home" + fi + printf 'mate\n' > "$home/.fm-secondmate-home" + if run_pre "$home" capabilities >/dev/null 2>&1; then + fail "a secondmate home exposed startup-pull capabilities" + fi + rm -f "$home/.fm-secondmate-home" + if NO_MISTAKES_GATE=1 run_pre "$home" capabilities >/dev/null 2>&1; then + fail "a no-mistakes phase exposed startup-pull capabilities" + fi + + base=$(new_home linked-base) + linked="$TMP_ROOT/linked-worker" + git -C "$base" worktree add -q --detach "$linked" HEAD + if run_pre "$linked" capabilities >/dev/null 2>&1; then + fail "a linked worker worktree exposed startup-pull capabilities" + fi + pass "prelaunch: aliases, secondmates, no-mistakes phases and linked workers are excluded" +} + +test_native_child_handoff_and_foreign_refusal() { + local home fakebin out launcher_result dirty_result + home=$(new_home handoff) + fakebin="$TMP_ROOT/handoff-bin" + mkdir -p "$fakebin" + ln -s /bin/bash "$fakebin/codex" + + reserve_main "$home" "$TOKEN_A" >/dev/null || fail "foreign-child fixture reserve failed" + if FM_HOME="$home" FM_ROOT_OVERRIDE="$home" "$fakebin/codex" "$LOCK" >/dev/null 2>&1; then + fail "a harness child without the reservation environment crossed the handoff" + fi + release_main "$home" "$TOKEN_A" >/dev/null || fail "foreign-child fixture release failed" + + dirty_result="$TMP_ROOT/handoff-dirty-result" + bash -c ' + pre=$1; lock=$2; harness=$3; home=$4; token=$5; result=$6 + owner=$$ + head=$(git -C "$home" rev-parse HEAD) || exit 1 + bash "$pre" reserve --home "$home" --owner-pid "$owner" --token "$token" >/dev/null || exit 2 + bash "$pre" update --home "$home" --owner-pid "$owner" --token "$token" --commit "$head" >/dev/null || exit 3 + ( + while [ ! -e "$result.go" ]; do sleep 0.02; done + exec env FM_HOME="$home" FM_ROOT_OVERRIDE="$home" FM_PRELAUNCH_OWNER_PID="$owner" \ + FM_PRELAUNCH_TOKEN="$token" GIT_DIR="$result.ambient-git-dir" \ + GIT_INDEX_FILE="$result.ambient-index" "$harness" "$lock" + ) >"$result.lock" 2>&1 & + child=$! + bash "$pre" attach --home "$home" --owner-pid "$owner" --token "$token" \ + --child-pid "$child" >/dev/null || exit 4 + printf "changed after update\n" >> "$home/version.txt" + : > "$result.go" + rc=0 + wait "$child" || rc=$? + printf "%s\n" "$rc" > "$result" + git -C "$home" checkout -q -- version.txt || exit 5 + bash "$pre" release --home "$home" --owner-pid "$owner" --token "$token" >/dev/null || exit 6 + ' _ "$PRELAUNCH" "$LOCK" "$fakebin/codex" "$home" "$TOKEN_B" "$dirty_result" \ + || fail "dirty-before-handoff launcher fixture failed" + [ "$(cat "$dirty_result")" -ne 0 ] || fail "a dirty source completed session-lock handoff" + assert_contains "$(cat "$dirty_result.lock")" 'source became dirty before session-lock handoff' \ + "dirty handoff refusal did not name the source change" + [ ! -e "$home/state/.lock" ] || fail "dirty handoff refusal published a session lock" + + launcher_result="$TMP_ROOT/handoff-result" + bash -c ' + pre=$1; lock=$2; harness=$3; home=$4; token=$5; result=$6 + owner=$$ + head=$(git -C "$home" rev-parse HEAD) || exit 1 + bash "$pre" reserve --home "$home" --owner-pid "$owner" --token "$token" >/dev/null || exit 2 + bash "$pre" update --home "$home" --owner-pid "$owner" --token "$token" --commit "$head" >/dev/null || exit 3 + ( + while [ ! -e "$result.go" ]; do sleep 0.02; done + exec env FM_HOME="$home" FM_ROOT_OVERRIDE="$home" FM_PRELAUNCH_OWNER_PID="$owner" \ + FM_PRELAUNCH_TOKEN="$token" GIT_DIR="$result.ambient-git-dir" \ + GIT_INDEX_FILE="$result.ambient-index" "$harness" "$lock" + ) >"$result.lock" 2>&1 & + child=$! + bash "$pre" attach --home "$home" --owner-pid "$owner" --token "$token" \ + --child-pid "$child" >"$result.attach" 2>&1 || exit 4 + : > "$result.go" + rc=0 + wait "$child" || rc=$? + printf "%s\n" "$rc" > "$result" + bash "$pre" release --home "$home" --owner-pid "$owner" --token "$token" >"$result.release" 2>&1 || exit 5 + ' _ "$PRELAUNCH" "$LOCK" "$fakebin/codex" "$home" "$TOKEN_C" "$launcher_result" \ + || fail "authenticated child handoff launcher failed" + [ "$(cat "$launcher_result")" -eq 0 ] || fail "authenticated child lock failed: $(cat "$launcher_result.lock")" + assert_contains "$(cat "$launcher_result.lock")" 'lock acquired: harness pid' \ + "the genuine harness process should own the session lock" + [ ! -e "$home/state/.prelaunch-reservation" ] || fail "handoff left the reservation live" + [ -f "$home/state/.prelaunch-handoff" ] || fail "handoff did not publish its receipt" + assert_contains "$(cat "$launcher_result.release")" '"status":"handed-off"' \ + "release should recognize an already completed handoff" + pass "prelaunch: only the authenticated genuine child atomically exchanges into the session lock" +} + +test_capabilities_are_read_only_and_explicit +test_profile_launch_env_matches_capabilities +test_strict_inventory_errors_refuse_reservation +test_guard_write_admits_ineligible_unreserved_primaries +test_reservation_validation_guard_and_release +test_pinned_update_preflight_and_no_candidate_execution +test_filter_dirty_branch_and_divergence_refuse_without_mutation +test_session_fleet_queue_and_unknown_state_refuse +test_source_identity_change_refuses_validation +test_race_dead_owner_pid_reuse_and_ancestry +test_attached_child_survives_parent_death_as_occupancy +test_scope_and_physical_home_exclusions +test_native_child_handoff_and_foreign_refusal diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 729edd36055..42b2af42e02 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -2444,6 +2444,9 @@ EOF startup=$(FM_FAKE_HARNESS=pi run_pi_session_start "$home" "$root" "$fakebin:$BASE_PATH" --source startup) assert_contains "$startup" "SESSION START - $home" "true startup did not run the full digest" + assert_contains "$startup" "STARTUP PULL" "true startup omitted startup-pull receipt reporting" + assert_contains "$startup" "STARTUP_PULL: bypassed; no verified firstmate-start handoff reached this fresh session" \ + "a raw fresh start did not disclose its startup-pull bypass" assert_present "$home/state/.session-start-agents-baseline" "true startup did not record an AGENTS baseline" baseline=$(cat "$home/state/.session-start-agents-baseline") expected_hash=$(hash_file_for_test "$root/AGENTS.md") @@ -2451,6 +2454,8 @@ EOF || fail "true startup baseline did not record the original AGENTS hash: $baseline" compact_equal=$(FM_FAKE_HARNESS=pi run_pi_session_start "$home" "$root" "$fakebin:$BASE_PATH" --reemit --source compact) + assert_not_contains "$compact_equal" "STARTUP PULL" \ + "a compact re-emit claimed a new startup pull" assert_not_contains "$compact_equal" "CURRENT AGENTS.md - INSTRUCTION REFRESH" \ "an unchanged AGENTS file was unnecessarily re-emitted" [ "$(cat "$home/state/.session-start-agents-baseline")" = "$baseline" ] \ @@ -2461,6 +2466,8 @@ FIRSTMATE_TEST_INSTRUCTION=updated The complete updated instruction must survive every stale rebuild. EOF resume_out=$(FM_FAKE_HARNESS=pi run_pi_session_start "$home" "$root" "$fakebin:$BASE_PATH" --source resume) + assert_not_contains "$resume_out" "STARTUP PULL" \ + "a saved-session continuation claimed a new startup pull" assert_not_contains "$resume_out" "CURRENT AGENTS.md - INSTRUCTION REFRESH" \ "a context-preserving continuation emitted a replacement contract" [ "$(cat "$home/state/.session-start-agents-baseline")" = "$baseline" ] \ diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index 75f8a4291b0..cf985d9bc23 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -516,6 +516,9 @@ test_lock_steal_reap_cannot_remove_successor() { fakebin="$dir/fakebin" out="$dir/competitor" leave_dead_link_locks "$state" "$steal" + # The killed fixture owner may have its PID reused during a loaded serial + # shard. Pin a verified-dead PID so the race exercises reaping, not liveness. + printf '%s\n' "$(dead_pid)" > "$steal/pid" cat > "$fakebin/rm" <<'SH' #!/usr/bin/env bash last= @@ -562,32 +565,51 @@ SH } test_lock_stale_steal_single_winner_under_concurrency() { - local dir state lockdir dead marker i pids pid wins + local dir state lockdir dead marker gate hold i pids pid waiter winner wins dir=$(make_case lock-stale-concurrency) state="$dir/state" lockdir="$state/.contend.lock" marker="$dir/wins" + gate="$dir/first-winner" + hold="$dir/winner-release" dead=$(dead_pid) mkdir "$lockdir" printf '%s\n' "$dead" > "$lockdir/pid" : > "$marker" + mkfifo "$gate" "$hold" + # try_acquire is nonblocking: all one-shot candidates may lose to a transient + # steal mutex. A waiting contender guarantees eventual acquisition, and the + # FIFO keeps the winner alive until every other contender has finished. + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1" + fm_lock_acquire_wait "$2" || exit 1 + printf "%s\n" "${BASHPID:-$$}" >> "$3" + printf "%s\n" "${BASHPID:-$$}" > "$4" + IFS= read -r _ < "$5" + ' _ "$LIB" "$lockdir" "$marker" "$gate" "$hold" & + waiter=$! pids= i=1 - while [ "$i" -le 40 ]; do + while [ "$i" -le 39 ]; do FM_STATE_OVERRIDE="$state" bash -c ' . "$1" if fm_lock_try_acquire "$2"; then printf "%s\n" "${BASHPID:-$$}" >> "$3" - sleep 1 + printf "%s\n" "${BASHPID:-$$}" > "$4" + IFS= read -r _ < "$5" fi - ' _ "$LIB" "$lockdir" "$marker" & + ' _ "$LIB" "$lockdir" "$marker" "$gate" "$hold" & pids="$pids $!" i=$((i + 1)) done + IFS= read -r winner < "$gate" + [ "$winner" = "$waiter" ] || { kill "$waiter" 2>/dev/null || true; wait "$waiter" 2>/dev/null || true; } for pid in $pids; do - wait "$pid" 2>/dev/null || true + [ "$pid" = "$winner" ] || wait "$pid" 2>/dev/null || true done wins=$(awk 'NF { c++ } END { print c + 0 }' "$marker") + printf 'release\n' > "$hold" + wait "$winner" 2>/dev/null || true [ "$wins" -eq 1 ] || fail "expected exactly one stale-lock stealer, got $wins" pass "concurrent stale-lock steal yields exactly one winner" }