diff --git a/README.md b/README.md index cedf95df7..e76f47bae 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,15 @@ grok --trust pi ``` +**Codex** + +```sh +bin/fm-primary-codex.sh +``` + +Codex can execute tools outside the interactive process's ancestry. +The launcher gives that session a private fleet-lock identity and releases its lock when Codex exits. + For Grok, `--trust` is needed once per clone so project hooks and the turn-end guard load; `/hooks-trust` inside Grok works too. For Pi, approve the project trust prompt once per clone on first launch so both tracked `.pi/extensions/*.ts` files auto-load. diff --git a/bin/fm-bridge-lib.sh b/bin/fm-bridge-lib.sh new file mode 100755 index 000000000..5e4d34702 --- /dev/null +++ b/bin/fm-bridge-lib.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Shared helpers for the fm-bridge.v1 machine contract. + +fm_bridge_fail() { # + jq -n --arg code "$1" --arg message "$2" \ + '{accepted:false,error:{code:$code,message:$message}}' +} + +fm_bridge_digest() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + else + sha256sum | awk '{print $1}' + fi +} + +fm_bridge_task_revision() { # + printf '%s' "$1" | jq -cS . | fm_bridge_digest +} + +fm_bridge_state() { # + case "$1" in + parked|needs-decision|awaiting-captain) printf 'awaitingCaptain' ;; + blocked|failed) printf 'blocked' ;; + validating|reviewing|ci|checks-running) printf 'verifying' ;; + pr-ready|ready) printf 'prReady' ;; + working|running|fixing) printf 'working' ;; + paused|idle) printf 'paused' ;; + approved|signed-off) printf 'approved' ;; + done|merged|completed) printf 'completed' ;; + *) printf 'unknown' ;; + esac +} + +fm_bridge_capabilities() { # + local state=$1 + jq -n --arg state "$state" ' + ["feedback","defer"] + + (if ($state == "awaitingCaptain" or $state == "prReady") then ["sign-off"] else [] end) + ' +} + +fm_bridge_evidence() { # + local task=$1 revision=$2 report pr + report=$(printf '%s' "$task" | jq -r '.paths.report.path // empty') + pr=$(printf '%s' "$task" | jq -r '.pr.url // empty') + jq -n --arg report "$report" --arg pr "$pr" --arg revision "$revision" ' + [ + if $report != "" then { + id:"report",kind:"Report",status:"present",summary:"Shipmate report is available", + source:"FirstMate",taskRevision:$revision,reference:$report + } else empty end, + if $pr != "" then { + id:"pull-request",kind:"Pull request",status:"present",summary:"Pull request is available", + source:"GitHub",taskRevision:$revision,reference:$pr + } else empty end + ] + ' +} + +fm_bridge_snapshot() { + local raw captured snapshot_revision + raw=$("$SCRIPT_DIR/fm-fleet-snapshot.sh" --json) || return + captured=$(printf '%s' "$raw" | jq -r '.generated') + snapshot_revision=$(printf '%s' "$raw" | jq -cS '{tasks,backlog,scout_reports,secondmate_current}' | fm_bridge_digest) + printf '%s' "$raw" | jq -c \ + --arg protocol "fm-bridge.v1" \ + --arg snapshot_revision "$snapshot_revision" --arg captured "$captured" ' + { + protocolVersion:$protocol, + snapshotRevision:$snapshot_revision, + capturedAt:.generated, + freshness:"fresh", + tasks:[.tasks[] | { + id:.id, + title:(.backlog.title // .id), + project:(.project // "unknown"), + shipmate:(.harness // "shipmate"), + rawState:(.current_state.state // "unknown"), + updatedAt:$captured, + attentionReason:(.current_state.detail // null), + summary:(.backlog.body_excerpt // null), + pr:(.pr.url // ""), + source:. + }] + }' +} + +fm_bridge_project_snapshot() { + local base tasks='[]' task source revision state evidence evidence_revision capabilities projected + base=$(fm_bridge_snapshot) || return + while IFS= read -r task; do + source=$(printf '%s' "$task" | jq -c '.source | { + id, + current_state:{ + state:.current_state.state, + source:.current_state.source, + detail:.current_state.detail + }, + pr, + backlog, + hints, + report:.paths.report + }') + revision=$(fm_bridge_task_revision "$source") + state=$(fm_bridge_state "$(printf '%s' "$task" | jq -r '.rawState')") + evidence=$(fm_bridge_evidence "$(printf '%s' "$task" | jq -c '.source')" "$revision" | jq -c .) + evidence_revision=$(printf '%s' "$evidence" | jq -cS . | fm_bridge_digest) + capabilities=$(fm_bridge_capabilities "$state" "$(printf '%s' "$task" | jq -r '.pr')" | jq -c .) + projected=$(printf '%s' "$task" | jq -c \ + --arg revision "$revision" --arg state "$state" \ + --arg evidence_revision "$evidence_revision" \ + --argjson evidence "$evidence" --argjson capabilities "$capabilities" ' + del(.rawState,.pr,.source) + { + state:$state, + taskRevision:$revision, + capabilities:$capabilities, + evidenceRevision:$evidence_revision, + evidence:$evidence + }') + tasks=$(jq -c --argjson tasks "$tasks" --argjson task "$projected" '$tasks + [$task]' <<< '{}') + done < <(printf '%s' "$base" | jq -c '.tasks[]') + printf '%s' "$base" | jq -c --argjson tasks "$tasks" '.tasks=$tasks' +} diff --git a/bin/fm-bridge.sh b/bin/fm-bridge.sh new file mode 100755 index 000000000..786d48334 --- /dev/null +++ b/bin/fm-bridge.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# fm-bridge.sh - versioned machine boundary for Captain's Log and local clients. +# +# Reads one JSON request from stdin. +# Supported operations: +# {"protocolVersion":"fm-bridge.v1","operation":"snapshot"} +# {"protocolVersion":"fm-bridge.v1","operation":"command","command":{...}} +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" + +# shellcheck source=bin/fm-bridge-lib.sh +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/fm-bridge-lib.sh" + +command -v jq >/dev/null 2>&1 || { + echo "fm-bridge: jq not found" >&2 + exit 1 +} + +request=$(head -c 65537) +[ "${#request}" -le 65536 ] || { + fm_bridge_fail request_too_large "Bridge requests are limited to 64 KiB" + exit 2 +} +printf '%s' "$request" | jq -e . >/dev/null 2>&1 || { + fm_bridge_fail malformed_request "Request must be valid JSON" + exit 2 +} +version=$(printf '%s' "$request" | jq -r '.protocolVersion // empty') +[ "$version" = "fm-bridge.v1" ] || { + fm_bridge_fail unsupported_version "Only fm-bridge.v1 is supported" + exit 2 +} +operation=$(printf '%s' "$request" | jq -r '.operation // empty') +case "$operation" in + snapshot) + fm_bridge_project_snapshot + ;; + command) + command_json=$(printf '%s' "$request" | jq -c '.command // empty') + command_id=$(printf '%s' "$command_json" | jq -r '.commandId // empty') + action=$(printf '%s' "$command_json" | jq -r '.action // empty') + task_id=$(printf '%s' "$command_json" | jq -r '.taskId // empty') + expected=$(printf '%s' "$command_json" | jq -r '.expectedRevision // empty') + [ -n "$command_id" ] && [ -n "$task_id" ] && [ -n "$expected" ] || { + fm_bridge_fail malformed_command "commandId, taskId, and expectedRevision are required" + exit 2 + } + case "$command_id" in + *[!A-Za-z0-9._-]*|'') fm_bridge_fail malformed_command "commandId contains unsafe characters"; exit 2 ;; + esac + [ "${#command_id}" -le 128 ] || { + fm_bridge_fail malformed_command "commandId is too long" + exit 2 + } + case "$action" in sign-off|defer|feedback|merge) ;; *) + fm_bridge_fail illegal_action "Unsupported Bridge action" + exit 2 + esac + journal="$STATE/bridge-command-journal" + mkdir -p "$journal" + lock="$journal/$command_id.lock" + mkdir "$lock" 2>/dev/null || { + fm_bridge_fail command_busy "This command is already being processed" + exit 2 + } + trap 'rmdir "$lock" 2>/dev/null || true' EXIT + digest=$(printf '%s' "$command_json" | jq -cS . | fm_bridge_digest) + record="$journal/$command_id.json" + if [ -f "$record" ]; then + prior_digest=$(jq -r '.requestDigest' "$record") + [ "$prior_digest" = "$digest" ] || { + fm_bridge_fail command_id_conflict "commandId was already used for a different request" + exit 2 + } + jq -c '.outcome + {replayed:true}' "$record" + exit 0 + fi + snapshot=$(fm_bridge_project_snapshot) || exit 1 + task=$(printf '%s' "$snapshot" | jq -c --arg id "$task_id" '.tasks[] | select(.id==$id)') + [ -n "$task" ] || { + fm_bridge_fail task_not_found "Task is not present in the current fleet" + exit 2 + } + current=$(printf '%s' "$task" | jq -r '.taskRevision') + [ "$current" = "$expected" ] || { + jq -n --arg current "$current" \ + '{accepted:false,error:{code:"stale_revision",message:"Task revision changed",currentRevision:$current}}' + exit 2 + } + capability=$(printf '%s' "$task" | jq -e --arg action "$action" '.capabilities | index($action) != null') + [ "$capability" = true ] || { + fm_bridge_fail capability_absent "Action is not legal for the current task state" + exit 2 + } + if [ "$action" = merge ]; then + fm_bridge_fail merge_requires_guarded_mode "Merge is unavailable until a guarded project mode is configured" + exit 2 + fi + if [ "$action" = sign-off ]; then + reviewed_evidence=$(printf '%s' "$command_json" | jq -r '.evidenceRevision // empty') + current_evidence=$(printf '%s' "$task" | jq -r '.evidenceRevision') + [ "$reviewed_evidence" = "$current_evidence" ] || { + fm_bridge_fail stale_evidence "Evidence changed before sign-off" + exit 2 + } + fi + case "$action" in + feedback) + feedback=$(printf '%s' "$command_json" | jq -r '.feedback // empty') + [ -n "$feedback" ] || { + fm_bridge_fail malformed_command "Feedback text is required" + exit 2 + } + FM_HOME="$FM_HOME" "$SCRIPT_DIR/fm-send.sh" "$task_id" "$feedback" >/dev/null || { + fm_bridge_fail endpoint_unavailable "Feedback could not be delivered to the shipmate" + exit 2 + } + ;; + sign-off|defer) + review_dir="$FM_HOME/data/$task_id" + mkdir -p "$review_dir" + review_record="$review_dir/bridge-review.json" + review_tmp="$review_record.$$" + jq -n --arg action "$action" --arg revision "$current" \ + --arg evidenceRevision "$(printf '%s' "$task" | jq -r '.evidenceRevision')" \ + --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{action:$action,taskRevision:$revision,evidenceRevision:$evidenceRevision,recordedAt:$at}' \ + > "$review_tmp" + mv "$review_tmp" "$review_record" + ;; + esac + outcome=$(jq -n --arg action "$action" --arg task_id "$task_id" \ + '{accepted:true,message:("FirstMate accepted " + $action + " for " + $task_id)}') + tmp="$record.$$" + jq -n --arg requestDigest "$digest" --argjson outcome "$outcome" \ + '{requestDigest:$requestDigest,outcome:$outcome}' > "$tmp" + mv "$tmp" "$record" + printf '%s' "$outcome" + ;; + *) + fm_bridge_fail unsupported_operation "Operation must be snapshot or command" + exit 2 + ;; +esac diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 3267e9220..af9dded19 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -30,6 +30,7 @@ CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" detect_own() { # Layer 1: environment markers for verified harnesses. [ "${CLAUDECODE:-}" = "1" ] && { echo claude; return; } + [ "${CODEX_SHELL:-}" = "1" ] && { echo codex; return; } [ "${PI_CODING_AGENT:-}" = "true" ] && { echo pi; return; } # grok sets GROK_AGENT=1 for its child/tool processes (verified, grok 0.2.73). # It does NOT set CLAUDECODE despite being Claude-Code-compatible, so this marker @@ -39,7 +40,7 @@ detect_own() { local pid=$$ comm args for _ in 1 2 3 4 5 6 7 8; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break - case "$(basename "$comm")" in + case "$(basename -- "$comm")" in *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 33e4b0d27..8dde8d673 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -3,26 +3,58 @@ # Writes the harness (agent) process PID found by walking the shell's ancestry, # which lives as long as the firstmate session - unlike the transient subshell # PID of any one tool call, which is dead moments after it is written. -# Usage: fm-lock.sh acquire; exit 1 if another live session holds it +# Usage: fm-lock.sh acquire; exit 2 if another live session holds it, +# or exit 1 for another acquisition failure # fm-lock.sh status print holder and liveness; always exits 0 +# Sandboxed Codex sessions use FM_CODEX_SESSION_TOKEN plus the long-lived +# launcher PID in FM_HARNESS_OWNER_PID. bin/fm-primary-codex.sh owns both. set -u +umask 077 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" LOCK="$STATE/.lock" +TOKEN_LOCK="$STATE/.lock-token" +TOKEN_CLAIM="$STATE/.lock-claim" mkdir -p "$STATE" # Known harness command names; extend when a new adapter is verified. HARNESS_RE='claude|codex|opencode|grok|^pi$' +process_is_harness() { + local pid=$1 expected=${2:-$HARNESS_RE} comm args + comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 + if printf '%s' "$(basename -- "$comm")" | grep -qE "$expected"; then + return 0 + fi + case "$comm" in + *node*|*python*) + args=$(ps -o args= -p "$pid" 2>/dev/null) + printf '%s' "$args" | grep -qE "$expected" + ;; + *) return 1 ;; + esac +} + harness_pid() { local pid=$$ comm args + if [ -n "${FM_HARNESS_OWNER_PID:-}" ]; then + case "$FM_HARNESS_OWNER_PID" in + *[!0-9]*|'') echo "error: FM_HARNESS_OWNER_PID must be a process id" >&2; return 1 ;; + esac + if [ "$FM_HARNESS_OWNER_PID" -le 1 ] || ! process_is_harness "$FM_HARNESS_OWNER_PID" codex; then + echo "error: FM_HARNESS_OWNER_PID is not a live Codex harness process" >&2 + return 1 + fi + echo "$FM_HARNESS_OWNER_PID" + return 0 + fi for _ in 1 2 3 4 5 6 7 8; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 args=$(ps -o args= -p "$pid" 2>/dev/null) - if printf '%s' "$(basename "$comm")" | grep -qE "$HARNESS_RE"; then + if printf '%s' "$(basename -- "$comm")" | grep -qE "$HARNESS_RE"; then echo "$pid"; return 0 fi # Bare interpreter (e.g. node): match the harness name in its script path. @@ -35,26 +67,78 @@ harness_pid() { return 1 } -holder_alive() { # true if $1 is a live process that looks like a harness - local pid=$1 comm - kill -0 "$pid" 2>/dev/null || return 1 - comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - printf '%s' "$(basename "$comm") $(ps -o args= -p "$pid" 2>/dev/null)" | grep -qE "$HARNESS_RE" +valid_codex_token() { + case "${FM_CODEX_SESSION_TOKEN:-}" in + ''|*[!0-9a-f]*) return 1 ;; + esac + [ "${#FM_CODEX_SESSION_TOKEN}" -eq 32 ] } +if [ "${1:-}" = release ]; then + valid_codex_token || { echo "error: release requires a valid Codex session token" >&2; exit 1; } + [ -f "$TOKEN_LOCK" ] && [ "$(cat "$TOKEN_LOCK")" = "$FM_CODEX_SESSION_TOKEN" ] \ + || { echo "error: Codex session token does not own the fleet lock" >&2; exit 1; } + [ -f "$LOCK" ] && [ "$(cat "$LOCK")" = "${FM_HARNESS_OWNER_PID:-}" ] \ + || { echo "error: Codex launcher PID does not own the fleet lock" >&2; exit 1; } + rm -f "$LOCK" "$TOKEN_LOCK" + rmdir "$TOKEN_CLAIM" 2>/dev/null || true + echo "lock released: Codex launcher pid $FM_HARNESS_OWNER_PID" + exit 0 +fi + if [ "${1:-}" = "status" ]; then if [ ! -f "$LOCK" ]; then echo "lock: free"; exit 0; fi old=$(cat "$LOCK") - if holder_alive "$old"; then echo "lock: held by live harness pid $old"; else echo "lock: stale (pid $old dead or not a harness)"; fi + if [ -f "$TOKEN_LOCK" ] && [ -d "$TOKEN_CLAIM" ]; then + echo "lock: held by Codex launcher pid $old" + exit 0 + fi + if process_is_harness "$old"; then echo "lock: held by live harness pid $old"; else echo "lock: stale (pid $old dead or not a harness)"; fi exit 0 fi -me=$(harness_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; } +if [ -n "${FM_CODEX_SESSION_TOKEN:-}" ]; then + valid_codex_token || { echo "error: invalid Codex session token" >&2; exit 1; } + case "${FM_HARNESS_OWNER_PID:-}" in + ''|*[!0-9]*) echo "error: Codex launcher PID must be a process id" >&2; exit 1 ;; + esac + [ "$FM_HARNESS_OWNER_PID" -gt 1 ] \ + || { echo "error: Codex launcher PID must be greater than one" >&2; exit 1; } + claim_error="" + if ! claim_error=$(mkdir "$TOKEN_CLAIM" 2>&1); then + if [ -d "$TOKEN_CLAIM" ]; then + echo "error: another live firstmate session holds the lock; operate read-only until resolved" >&2 + exit 2 + fi + echo "error: could not create Codex fleet-lock claim: $claim_error" >&2 + exit 1 + fi + if ! printf '%s\n' "$FM_HARNESS_OWNER_PID" > "$LOCK" \ + || ! printf '%s\n' "$FM_CODEX_SESSION_TOKEN" > "$TOKEN_LOCK"; then + rm -f "$LOCK" "$TOKEN_LOCK" + rmdir "$TOKEN_CLAIM" 2>/dev/null || true + echo "error: could not persist Codex fleet-lock ownership" >&2 + exit 1 + fi + echo "lock acquired: Codex launcher pid $FM_HARNESS_OWNER_PID" + exit 0 +fi + +if [ -f "$TOKEN_LOCK" ] || [ -d "$TOKEN_CLAIM" ]; then + echo "error: another live firstmate session holds the lock; operate read-only until resolved" >&2 + exit 2 +fi + +if ! me=$(harness_pid); then + [ -z "${FM_HARNESS_OWNER_PID:-}" ] && echo "error: cannot locate harness process in ancestry" >&2 + exit 1 +fi if [ -f "$LOCK" ]; then old=$(cat "$LOCK") - if [ "$old" != "$me" ] && holder_alive "$old"; then + if process_is_harness "$old" \ + && { [ "$old" != "$me" ] || [ -n "${FM_HARNESS_OWNER_PID:-}" ]; }; then echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2 - exit 1 + exit 2 fi fi echo "$me" > "$LOCK" diff --git a/bin/fm-primary-codex.sh b/bin/fm-primary-codex.sh new file mode 100755 index 000000000..6936084e6 --- /dev/null +++ b/bin/fm-primary-codex.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Launch a primary Codex session with sandbox-independent fleet-lock ownership. +# Usage: fm-primary-codex.sh [codex arguments...] +set -u +umask 077 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +export FM_HOME="${FM_HOME:-$FM_ROOT}" +export FM_HARNESS_OWNER_PID=$$ +export FM_CODEX_SESSION_TOKEN +if ! FM_CODEX_SESSION_TOKEN=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n') \ + || [ "${#FM_CODEX_SESSION_TOKEN}" -ne 32 ]; then + echo "error: could not create a Codex session token" >&2 + exit 1 +fi + +cleanup() { + "$SCRIPT_DIR/fm-lock.sh" release >/dev/null 2>&1 || true +} + +codex_bin=${FM_CODEX_BIN:-} +if [ -z "$codex_bin" ]; then + codex_bin=$(command -v codex 2>/dev/null || true) +fi +if [ -z "$codex_bin" ] && [ -x /Applications/ChatGPT.app/Contents/Resources/codex ]; then + codex_bin=/Applications/ChatGPT.app/Contents/Resources/codex +fi +if [ ! -x "$codex_bin" ]; then + echo "error: Codex executable not found; install ChatGPT or set FM_CODEX_BIN" >&2 + exit 127 +fi + +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +"$codex_bin" --add-dir "$FM_HOME" "$@" diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index a229956ac..1048de540 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -244,12 +244,18 @@ LOCK_OUT=$("$SCRIPT_DIR/fm-lock.sh" 2>&1) LOCK_RC=$? printf '%s\n' "$LOCK_OUT" READ_ONLY=0 +LOCK_HELD_BY_OTHER=0 if [ "$LOCK_RC" -ne 0 ]; then READ_ONLY=1 + [ "$LOCK_RC" -eq 2 ] && LOCK_HELD_BY_OTHER=1 BAR='●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' { printf '%s\n' "$BAR" - printf '● READ-ONLY SESSION - ANOTHER LIVE FIRSTMATE SESSION HOLDS THE FLEET LOCK\n' + if [ "$LOCK_HELD_BY_OTHER" -eq 1 ]; then + printf '● READ-ONLY SESSION - ANOTHER LIVE FIRSTMATE SESSION HOLDS THE FLEET LOCK\n' + else + printf '● READ-ONLY SESSION - FLEET LOCK COULD NOT BE ACQUIRED\n' + fi printf '● %s\n' "$LOCK_OUT" printf '● Skipping every mutating step: PR-check migration, secondmate sync,\n' printf '● X-mode artifacts, fleet sync, and wake-queue drain. Detect-only bootstrap\n' @@ -285,7 +291,11 @@ subsection "WAKE QUEUE" if [ "$READ_ONLY" -eq 1 ]; then QLEN=0 [ -s "$STATE/.wake-queue" ] && QLEN=$(grep -c . "$STATE/.wake-queue" 2>/dev/null || printf '0') - printf 'skipped (read-only session) - %s record(s) remain queued for the session holding the lock.\n' "$QLEN" + if [ "$LOCK_HELD_BY_OTHER" -eq 1 ]; then + printf 'skipped (read-only session) - %s record(s) remain queued for the session holding the lock.\n' "$QLEN" + else + printf 'skipped (read-only session) - %s record(s) remain queued until lock acquisition succeeds.\n' "$QLEN" + fi GUARD_OUT=$(FM_GUARD_READ_ONLY=1 "$SCRIPT_DIR/fm-guard.sh" 2>&1) [ -n "$GUARD_OUT" ] && printf '%s\n' "$GUARD_OUT" else @@ -386,12 +396,21 @@ fi # --- 6. closing reminder ----------------------------------------------- section "NEXT STEP" if [ "$READ_ONLY" -eq 1 ]; then - cat <<'EOF' + if [ "$LOCK_HELD_BY_OTHER" -eq 1 ]; then + cat <<'EOF' This session did not acquire the fleet lock. Stay read-only: do not arm, drain, spawn, steer, merge, or repair fleet state from here. The session holding the lock owns mutable follow-up. EOF + else + cat <<'EOF' +This session did not acquire the fleet lock. Stay read-only: do not arm, +drain, spawn, steer, merge, or repair fleet state from here. Resolve the +lock-acquisition diagnostic above, then start a fresh session. + +EOF + fi elif [ "$AFK_PRESENT" -eq 1 ]; then cat <<'EOF' Away mode is active. Follow the supervision operating instructions block above: diff --git a/docs/architecture.md b/docs/architecture.md index 044149810..586ab778d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -246,3 +246,10 @@ Use `/stow` before an intentional reset when the conversation may hold durable k The current watcher reliability work combines always-on bash triage with a durable queue for actionable wakes, a race-proof singleton lock, duplicate self-eviction, drain-time liveness assertion, and a self-verifying tracked-child arm wrapper. The presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) provides walk-away supervision via the `/afk` skill while reusing the same shared wake classifier as the always-on watcher. +# Bridge protocol boundary + +The `fm-bridge.v1` protocol is an anti-corruption layer over FirstMate's authoritative fleet state. +It reuses the read-only fleet snapshot and derives client-facing revisions without making terminal prose authoritative. +Command outcomes are journaled atomically under the active FirstMate home so connectivity retries cannot duplicate an accepted action. +Clients must refresh after stale revisions and must treat absent capabilities as unavailable actions. +Review sign-off and delivery merge are deliberately separate commands. diff --git a/docs/residual-review-findings/codex-bridge-protocol.md b/docs/residual-review-findings/codex-bridge-protocol.md new file mode 100644 index 000000000..b21e397f3 --- /dev/null +++ b/docs/residual-review-findings/codex-bridge-protocol.md @@ -0,0 +1,8 @@ +# Residual Review Findings + +Source: LFG review of the `fm-bridge.v1` walking skeleton on 2026-07-16. + +- P2 - `bin/fm-bridge.sh` - A process crash can leave a per-command directory lock behind. + Replace the narrow atomic directory lock with FirstMate's owner-aware lock primitive and stale-owner recovery. +- P2 - `bin/fm-bridge.sh` - Guarded merge is intentionally unsupported in the first protocol slice. + Add it only for a project mode that can return an authoritative structured outcome. diff --git a/docs/scripts.md b/docs/scripts.md index b346d73ff..113a06e9a 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -80,3 +80,12 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm | `fm-x-dismiss.sh` | Dismiss a skipped X-mode mention at the relay without replying | | `fm-x-link.sh` | Link a spawned task to its originating X-mode mention in task meta | | `fm-x-followup.sh` | Detect, post, and cap completion follow-ups for an X-mode-linked task | +# Captain's Log Bridge + +`bin/fm-bridge.sh` is the supported local machine boundary for voice-first fleet clients. +It reads one `fm-bridge.v1` JSON request from standard input. +The `snapshot` operation projects stable task identities, revisions, evidence pointers, freshness, and legal capabilities from `fm-fleet-snapshot.v1`. +The `command` operation requires a command ID, opaque task ID, and expected task revision. +Repeated identical command IDs replay their original outcome, while reuse with a different request fails closed. +Sign-off is a review decision and never implies merge. +Merge remains unavailable unless FirstMate can route it through a guarded project mode. diff --git a/tests/fm-bridge.test.sh b/tests/fm-bridge.test.sh new file mode 100755 index 000000000..1c9d99bdb --- /dev/null +++ b/tests/fm-bridge.test.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +BRIDGE="$ROOT/bin/fm-bridge.sh" +TMP_ROOT=$(fm_test_tmproot fm-bridge) +HOME_DIR="$TMP_ROOT/home" +mkdir -p "$HOME_DIR/state" "$HOME_DIR/data" "$HOME_DIR/projects" "$HOME_DIR/config" + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } + +snapshot=$(printf '{"protocolVersion":"fm-bridge.v1","operation":"snapshot"}' | FM_HOME="$HOME_DIR" "$BRIDGE") +printf '%s' "$snapshot" | jq -e ' + .protocolVersion == "fm-bridge.v1" + and (.snapshotRevision | length > 10) + and .freshness == "fresh" + and (.tasks | length == 0) +' >/dev/null || fail "empty Bridge snapshot contract is invalid" +pass "Bridge snapshot is versioned and read-only" + +set +e +bad=$(printf '{"protocolVersion":"future","operation":"snapshot"}' | FM_HOME="$HOME_DIR" "$BRIDGE") +status=$? +set -e +[ "$status" -ne 0 ] || fail "unsupported versions must fail" +printf '%s' "$bad" | jq -e '.error.code == "unsupported_version"' >/dev/null || + fail "unsupported version must return a typed error" +pass "Bridge rejects unsupported versions" + +set +e +malformed=$(printf 'not-json' | FM_HOME="$HOME_DIR" "$BRIDGE") +status=$? +set -e +[ "$status" -ne 0 ] || fail "malformed JSON must fail" +printf '%s' "$malformed" | jq -e '.error.code == "malformed_request"' >/dev/null || + fail "malformed JSON must return a typed error" +pass "Bridge rejects malformed requests" + +mkdir -p "$HOME_DIR/projects/task-one" +fm_write_meta "$HOME_DIR/state/task-one.meta" \ + "window=firstmate:fm-task-one" \ + "worktree=$HOME_DIR/projects/task-one" \ + "project=alpha" \ + "harness=codex" \ + "kind=ship" \ + "mode=ship" +snapshot=$(printf '{"protocolVersion":"fm-bridge.v1","operation":"snapshot"}' | FM_HOME="$HOME_DIR" "$BRIDGE") +revision=$(printf '%s' "$snapshot" | jq -r '.tasks[] | select(.id=="task-one") | .taskRevision') +request=$(jq -n --arg revision "$revision" '{ + protocolVersion:"fm-bridge.v1",operation:"command", + command:{ + protocolVersion:"fm-bridge.v1",commandId:"command-1",action:"defer", + taskId:"task-one",expectedRevision:$revision + } +}') +first=$(printf '%s' "$request" | FM_HOME="$HOME_DIR" "$BRIDGE") +second=$(printf '%s' "$request" | FM_HOME="$HOME_DIR" "$BRIDGE") +printf '%s' "$first" | jq -e '.accepted == true' >/dev/null || + fail "current revision feedback must be accepted" +printf '%s' "$second" | jq -e '.accepted == true and .replayed == true' >/dev/null || + fail "identical command ID must replay the original outcome" +pass "Bridge commands are revisioned and idempotent" + +set +e +stale=$(printf '%s' "$request" | jq '.command.commandId="command-2" | .command.expectedRevision="stale"' | + FM_HOME="$HOME_DIR" "$BRIDGE") +status=$? +set -e +[ "$status" -ne 0 ] || fail "stale revisions must fail" +printf '%s' "$stale" | jq -e '.error.code == "stale_revision"' >/dev/null || + fail "stale revision must return a typed error" +pass "Bridge rejects stale commands without mutation" + +set +e +conflict=$(printf '%s' "$request" | jq '.command.extra="Different request"' | + FM_HOME="$HOME_DIR" "$BRIDGE") +status=$? +set -e +[ "$status" -ne 0 ] || fail "command ID reuse with a different digest must fail" +printf '%s' "$conflict" | jq -e '.error.code == "command_id_conflict"' >/dev/null || + fail "command ID conflict must return a typed error" +pass "Bridge binds command IDs to request digests" + +set +e +unsafe=$(printf '%s' "$request" | jq '.command.commandId="../../config/escape"' | + FM_HOME="$HOME_DIR" "$BRIDGE") +status=$? +set -e +[ "$status" -ne 0 ] || fail "unsafe command IDs must fail" +printf '%s' "$unsafe" | jq -e '.error.code == "malformed_command"' >/dev/null || + fail "unsafe command ID must return a typed error" +pass "Bridge rejects command ID path traversal" diff --git a/tests/fm-primary-codex.test.sh b/tests/fm-primary-codex.test.sh new file mode 100755 index 000000000..76815867f --- /dev/null +++ b/tests/fm-primary-codex.test.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +fail() { echo "FAIL: $*" >&2; exit 1; } + +mkdir -p "$TMP/bin" "$TMP/home" +cat > "$TMP/bin/codex" <<'SH' +#!/usr/bin/env bash +set -u +case "$FM_CODEX_SESSION_TOKEN" in + *[!0-9a-f]*|'') exit 10 ;; +esac +[ "${#FM_CODEX_SESSION_TOKEN}" -eq 32 ] || exit 11 +printf '%s\n' "$FM_CODEX_SESSION_TOKEN" > "$FM_HOME/token-seen" +printf '%s\n' "$FM_HARNESS_OWNER_PID" > "$FM_HOME/pid-seen" +printf '%s\n' "$*" > "$FM_HOME/args-seen" +"$FM_TEST_ROOT/bin/fm-lock.sh" +SH +chmod +x "$TMP/bin/codex" + +FM_TEST_ROOT="$ROOT" FM_HOME="$TMP/home" FM_CODEX_BIN="$TMP/bin/codex" PATH="/usr/bin:/bin" \ + "$ROOT/bin/fm-primary-codex.sh" >/dev/null + +[ -s "$TMP/home/token-seen" ] || fail "launcher did not pass a session token" +[ -s "$TMP/home/pid-seen" ] || fail "launcher did not pass its owner PID" +grep -Fq -- "--add-dir $TMP/home" "$TMP/home/args-seen" || fail "launcher did not grant access to FM_HOME" +[ ! -e "$TMP/home/state/.lock" ] || fail "launcher did not release the PID lock on exit" +[ ! -e "$TMP/home/state/.lock-token" ] || fail "launcher did not release the token lock on exit" +[ ! -e "$TMP/home/state/.lock-claim" ] || fail "launcher did not release the atomic claim on exit" + +echo "PASS: Codex launcher propagates ownership and cleans up on exit" diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 7d48deba8..2c2e16c3f 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -237,17 +237,230 @@ SH # run_session_start # Drop every harness env marker from bin/fm-harness.sh detect_own so the # surrounding interactive shell cannot leak past the suite's fake ps harness. -# Markers today: CLAUDECODE (claude), PI_CODING_AGENT (pi), GROK_AGENT (grok). -# codex and opencode have no env markers (ancestry only). Without this, a local -# claude/pi/grok session fails cases that pin a different fake harness while CI +# Markers today: CLAUDECODE (claude), CODEX_SHELL (codex), +# PI_CODING_AGENT (pi), GROK_AGENT (grok). Without this, a local harness +# session fails cases that pin a different fake harness while CI # (no ambient markers) still passes. run_session_start() { local home=$1 root=$2 path=$3 - env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT \ + env -u CLAUDECODE -u CODEX_SHELL -u PI_CODING_AGENT -u GROK_AGENT \ FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$path" \ "$SESSION_START" } +test_explicit_codex_owner_acquires_lock_without_harness_ancestry() { + local rec root home fakebin out owner_pid status + rec=$(new_world explicit-codex-owner) + IFS='|' read -r root home fakebin < "$fakebin/ps" <&1) || status=$? + expect_code 2 "$status" "a second caller reusing the explicit owner PID must be refused" + assert_contains "$out" "another live firstmate session holds the lock" "reused explicit owner PID did not report contention" + pass "an explicit live Codex owner acquires the lock without process ancestry" +} + +test_explicit_harness_owner_rejects_invalid_processes() { + local rec root home fakebin out status + rec=$(new_world invalid-explicit-owner) + IFS='|' read -r root home fakebin < "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +pid="" +prev="" +for arg in "$@"; do + [ "$prev" = "-p" ] && pid="$arg" + prev="$arg" +done +[ "$pid" = 999999 ] && exit 1 +case "$*" in + *"comm="*) printf '%s\n' '/bin/zsh'; exit 0 ;; + *"args="*) printf '%s\n' 'zsh'; exit 0 ;; +esac +exit 1 +SH + chmod +x "$fakebin/ps" + + for owner in not-a-pid 1 999999 "$$"; do + status=0 + out=$(FM_HOME="$home" FM_HARNESS_OWNER_PID="$owner" PATH="$fakebin:$BASE_PATH" "$ROOT/bin/fm-lock.sh" 2>&1) || status=$? + expect_code 1 "$status" "invalid explicit harness owner '$owner' must fail acquisition" + assert_contains "$out" "FM_HARNESS_OWNER_PID" "invalid explicit harness owner '$owner' did not explain the failure" + assert_not_contains "$out" "cannot locate harness process in ancestry" "invalid explicit harness owner '$owner' added a false ancestry diagnosis" + assert_absent "$home/state/.lock" "invalid explicit harness owner '$owner' created a fleet lock" + done + + pass "malformed, system, missing, and live non-harness owner PIDs fail closed" +} + +test_codex_owner_rejects_a_different_harness_pid() { + local rec root home fakebin out status + rec=$(new_world codex-owner-mismatch) + IFS='|' read -r root home fakebin <&1) || status=$? + expect_code 1 "$status" "Codex must reject an explicit owner from another harness" + assert_contains "$out" "not a live Codex harness process" "cross-harness owner mismatch was not diagnosed" + assert_absent "$home/state/.lock" "cross-harness owner mismatch created a fleet lock" + + pass "Codex explicit ownership rejects another harness PID" +} + +test_explicit_owner_tracks_a_real_process_lifecycle() { + local rec root home fakebin owner_pid out + rec=$(new_world real-explicit-owner) + IFS='|' read -r root home fakebin < {}, 300000)' codex-owner /dev/null 2>&1 & + owner_pid=$! + + out=$(FM_HOME="$home" FM_HARNESS_OWNER_PID="$owner_pid" "$ROOT/bin/fm-lock.sh") + assert_contains "$out" "lock acquired: harness pid $owner_pid" "real explicit owner process did not acquire the fleet lock" + out=$(FM_HOME="$home" "$ROOT/bin/fm-lock.sh" status) + assert_contains "$out" "lock: held by live harness pid $owner_pid" "real explicit owner process was not reported live" + + kill "$owner_pid" 2>/dev/null || true + wait "$owner_pid" 2>/dev/null || true + out=$(FM_HOME="$home" "$ROOT/bin/fm-lock.sh" status) + assert_contains "$out" "lock: stale" "exited explicit owner process did not make the lock stale" + + pass "explicit ownership follows a real harness-named process lifecycle" +} + +test_codex_token_owns_and_releases_lock_without_process_visibility() { + local rec root home fakebin token other_token out status + rec=$(new_world codex-token-owner) + IFS='|' read -r root home fakebin < "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +exit 126 +SH + chmod +x "$fakebin/ps" + + out=$(CODEX_SHELL=1 FM_CODEX_SESSION_TOKEN="$token" FM_HARNESS_OWNER_PID="$$" \ + FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$fakebin:$BASE_PATH" \ + "$SESSION_START") + assert_contains "$out" "lock acquired: Codex launcher pid $$" "Codex token did not acquire without process visibility" + assert_contains "$out" "SUPERVISION OPERATING INSTRUCTIONS - primary harness: codex" "Codex token session was not identified as Codex" + [ "$(cat "$home/state/.lock-token")" = "$token" ] || fail "fleet lock did not record the Codex token" + [ -d "$home/state/.lock-claim" ] || fail "fleet lock did not create its atomic claim" + + status=0 + out=$(FM_CODEX_SESSION_TOKEN="$other_token" FM_HARNESS_OWNER_PID="$$" \ + FM_HOME="$home" PATH="$fakebin:$BASE_PATH" "$ROOT/bin/fm-lock.sh" 2>&1) || status=$? + expect_code 2 "$status" "a second Codex token must be refused" + assert_contains "$out" "another live firstmate session holds the lock" "second Codex token did not report contention" + + status=0 + out=$(FM_CODEX_SESSION_TOKEN="$other_token" FM_HARNESS_OWNER_PID="$$" \ + FM_HOME="$home" "$ROOT/bin/fm-lock.sh" release 2>&1) || status=$? + expect_code 1 "$status" "a different Codex token must not release the lock" + [ -f "$home/state/.lock" ] || fail "a different Codex token removed the lock" + + out=$(FM_CODEX_SESSION_TOKEN="$token" FM_HARNESS_OWNER_PID="$$" \ + FM_HOME="$home" "$ROOT/bin/fm-lock.sh" release) + assert_contains "$out" "lock released: Codex launcher pid $$" "owning Codex token did not release the lock" + assert_absent "$home/state/.lock" "Codex release left the PID lock behind" + assert_absent "$home/state/.lock-token" "Codex release left the token behind" + assert_absent "$home/state/.lock-claim" "Codex release left the claim behind" + + pass "Codex token ownership works without process visibility and releases safely" +} + +test_codex_claim_creation_failure_is_not_reported_as_contention() { + local rec root home fakebin token out status + rec=$(new_world codex-claim-failure) + IFS='|' read -r root home fakebin < "$home/state/.lock-claim" + + status=0 + out=$(FM_CODEX_SESSION_TOKEN="$token" FM_HARNESS_OWNER_PID="$$" \ + FM_HOME="$home" "$ROOT/bin/fm-lock.sh" 2>&1) || status=$? + expect_code 1 "$status" "a non-contention claim failure must be an acquisition error" + assert_contains "$out" "could not create Codex fleet-lock claim" "claim creation failure was not diagnosed" + assert_not_contains "$out" "another live firstmate session" "claim creation failure was mislabeled as contention" + + pass "Codex claim creation failures are distinct from lock contention" +} + +test_harness_detection_failure_is_not_reported_as_live_lock_holder() { + local rec root home fakebin out + rec=$(new_world harness-detection-failure) + IFS='|' read -r root home fakebin < "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +case "$*" in + *"comm="*|*"args="*) printf '%s\n' '-zsh'; exit 0 ;; + *"ppid="*) printf '%s\n' 1; exit 0 ;; +esac +exit 1 +SH + chmod +x "$fakebin/ps" + + out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") + + assert_contains "$out" "READ-ONLY SESSION - FLEET LOCK COULD NOT BE ACQUIRED" "harness detection failure did not get the generic lock failure heading" + assert_contains "$out" "cannot locate harness process in ancestry" "harness detection failure was not preserved" + assert_not_contains "$out" "ANOTHER LIVE FIRSTMATE SESSION" "harness detection failure was mislabeled as a live lock holder" + assert_not_contains "$out" "session holding the lock owns" "next step invented a competing lock owner" + pass "a harness detection failure is distinct from a live lock refusal" +} + hash_file_for_test() { local file=$1 if command -v shasum >/dev/null 2>&1; then @@ -900,6 +1113,13 @@ EOF } test_context_digest_absent_empty_present +test_explicit_codex_owner_acquires_lock_without_harness_ancestry +test_explicit_harness_owner_rejects_invalid_processes +test_codex_owner_rejects_a_different_harness_pid +test_explicit_owner_tracks_a_real_process_lifecycle +test_codex_token_owns_and_releases_lock_without_process_visibility +test_codex_claim_creation_failure_is_not_reported_as_contention +test_harness_detection_failure_is_not_reported_as_live_lock_holder test_lock_refusal_read_only_path test_output_ordering_diagnostics_lead test_herdr_backend_diagnostics_follow_real_session_start