From 1c00dd73e4ee5ed3d502ba9c6bbd4b11a7712e16 Mon Sep 17 00:00:00 2001 From: Crew Agent Date: Tue, 21 Jul 2026 02:01:56 +0000 Subject: [PATCH 1/8] feat(session-start): arm direct Telegram receiver --- AGENTS.md | 7 +- bin/fm-session-start.sh | 36 ++++++-- bin/fm-tg-recv-arm.sh | 151 +++++++++++++++++++++++++++++++++ tests/fm-session-start.test.sh | 35 +++++++- tests/fm-tg-recv-arm.test.sh | 73 ++++++++++++++++ 5 files changed, 288 insertions(+), 14 deletions(-) create mode 100755 bin/fm-tg-recv-arm.sh create mode 100755 tests/fm-tg-recv-arm.test.sh diff --git a/AGENTS.md b/AGENTS.md index c291135c0..091c379d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,8 @@ config/backend runtime session-provider backend override for new tasks; LOCAL, config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present +config/telegram.env optional direct Telegram receive credentials; LOCAL, gitignored; when present, the locked session-start digest emits the `bin/fm-tg-recv-arm.sh` tracked-background arm step +config/fm-tg-recv.sh optional local direct Telegram receiver implementation; LOCAL, gitignored; `bin/fm-tg-recv-arm.sh` owns only the tracked arm/attach wrapper config/bridge-vessel Bridge inbox vessel name; LOCAL, gitignored; used only when FM_BRIDGE_VESSEL is unset, and absent (with FM_BRIDGE_VESSEL also unset) disables Bridge inbox scans (docs/configuration.md "Bridge inbox check (FM_BRIDGE_*)") data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history @@ -139,9 +141,9 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai A file that does not exist prints an explicit `ABSENT` marker, never confused with an empty-but-present file: absence is meaningful (`captain.md` absent means use the firstmate repo's built-in defaults, `projects.md` absent means rebuild it from the clones under `projects/`, etc.). 5. **Fleet-state digest** - the compact backlog listing owned by `bin/fm-session-start.sh`; every `state/.meta`; a bounded tail of each task's `state/.status` (labeled as wake-EVENT history, not current state, with the full log path printed for a deeper read); the `state/.afk` flag; and one cheap alive/dead read of each task's recorded backend endpoint. That liveness line is a fast presence check only, not a full state read - when you need a crew's actual current state (a run-step, not just "is the pane there"), read it with `bin/fm-crew-state.sh ` as before; the digest deliberately skips that deeper, slower read for every task so it stays fast and bounded. -6. **Supervision operating instructions and next step** - after the wake queue and before context, the digest emits exactly one operating block for the detected primary harness. +6. **Supervision operating instructions and next step** - after the wake queue and before context, the digest emits exactly one operating block for the detected primary harness plus the optional direct Telegram receiver arm step when `config/telegram.env` exists. The closing reminder points back to that emitted block and preserves only the lock, afk, X-mode, and read-once reminders. - The script itself never starts supervision; the emitted harness protocol owns the exact wait or wake mechanism. + The script itself never starts long-lived polls; the emitted harness protocol and `bin/fm-tg-recv-arm.sh` own the exact tracked-background wait or wake mechanisms. Bootstrap detects first, asks for consent, and installs only after the captain approves in the current session. Do not dispatch until the required tools are present and GitHub authentication is good. @@ -322,6 +324,7 @@ Fleet supervision is an always-loaded operational contract; `docs/architecture.m Whenever work is under way, keep exactly one live supervision cycle using the emitted protocol for this primary harness. X mode may require that same live cycle with no fleet work. Do not substitute another harness's wait shape, use shell `&`, or create a second cycle when a healthy one already exists. +When the session-start digest reports direct Telegram receive as active, keep `bin/fm-tg-recv-arm.sh` armed as its own separate tracked background task; it starts or attaches to the receiver for this home. After every actionable wake, resume the emitted protocol as the final action before ending the turn. No turn ends blind while work is under way, including turns described as holding or waiting. diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index cdeb03f04..55d0d36cb 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -40,9 +40,10 @@ # every state/*.meta, a bounded state/*.status tail, # state/.afk, and a cheap per-task endpoint-liveness read: # read-only, always runs. -# 6. closing reminder - prints the context-specific watcher next step; this -# script points back to the emitted harness supervision -# block and deliberately never arms the watcher itself. +# 6. closing reminder - prints the context-specific watcher and optional +# Telegram receiver next steps; this script points back +# to the emitted harness supervision block and deliberately +# never runs long-lived polls itself. # # On a Pi primary, the supervision-block step also checks whether Pi's two # tracked primary extensions are loaded and prints a PI_WATCH_EXTENSION @@ -299,7 +300,22 @@ else fi fi -# --- 4. supervision operating instructions ---------------------------------- +# --- 4. direct Telegram receiver --------------------------------------------- +TELEGRAM_PRESENT=0 +[ -f "$CONFIG/telegram.env" ] && TELEGRAM_PRESENT=1 + +subsection "TELEGRAM RECEIVER" +if [ "$TELEGRAM_PRESENT" -eq 0 ]; then + printf '%s\n' 'inactive (config/telegram.env absent)' +elif [ "$READ_ONLY" -eq 1 ]; then + printf '%s\n' 'skipped (read-only session) - the session holding the lock owns Telegram receiver arming.' +elif [ ! -x "$CONFIG/fm-tg-recv.sh" ]; then + printf '%s\n' 'TELEGRAM_RECEIVER: config/telegram.env exists but config/fm-tg-recv.sh is missing or not executable; direct Telegram receive is not armed' +else + printf '%s\n' "TELEGRAM_RECEIVER: active - run bin/fm-tg-recv-arm.sh as its own tracked background task, never shell &; it starts or attaches to this home's receiver" +fi + +# --- 5. supervision operating instructions ---------------------------------- AFK_PRESENT=0 [ -e "$STATE/.afk" ] && AFK_PRESENT=1 X_MODE_PRESENT=0 @@ -324,7 +340,7 @@ fi --afk "$AFK_PRESENT" \ --x-mode "$X_MODE_PRESENT" -# --- 4. context digest ----------------------------------------------------- +# --- 6. context digest ----------------------------------------------------- section "CONTEXT" print_file_or_absent "$DATA/projects.md" "data/projects.md" print_file_or_absent "$DATA/secondmates.md" "data/secondmates.md" @@ -332,7 +348,7 @@ print_file_or_absent "$DATA/captain.md" "data/captain.md" print_file_or_absent "$DATA/captain-shared.md" "data/captain-shared.md (shared, main-authoritative, read-only in secondmate homes)" print_file_or_absent "$DATA/learnings.md" "data/learnings.md" -# --- 5. fleet-state digest --------------------------------------------- +# --- 7. fleet-state digest --------------------------------------------- section "FLEET STATE" print_backlog_compact "$DATA/backlog.md" "data/backlog.md" @@ -386,7 +402,7 @@ else printf 'absent\n' fi -# --- 6. closing reminder ----------------------------------------------- +# --- 8. closing reminder ----------------------------------------------- section "NEXT STEP" if [ "$READ_ONLY" -eq 1 ]; then cat <<'EOF' @@ -406,13 +422,15 @@ elif [ -f "$CONFIG/x-mode.env" ]; then cat <&2 +} + +case "${1:-}" in + '') ;; + -h|--help) usage; exit 0 ;; + *) usage; exit 2 ;; +esac + +if [ ! -f "$ENV_FILE" ]; then + printf 'telegram receiver: inactive (config/telegram.env absent)\n' + exit 0 +fi + +if [ ! -x "$RECV" ]; then + printf 'telegram receiver: FAILED - config/fm-tg-recv.sh missing or not executable\n' + exit 1 +fi + +TG_HEALTHY_PID= +tg_receiver_lock_matches_pid() { + local pid=$1 lock_home lock_path lock_identity current_identity + lock_home=$(cat "$RECV_LOCK/fm-home" 2>/dev/null || true) + lock_path=$(cat "$RECV_LOCK/receiver-path" 2>/dev/null || true) + lock_identity=$(cat "$RECV_LOCK/pid-identity" 2>/dev/null || true) + [ "$lock_home" = "$FM_HOME" ] || return 1 + [ "$lock_path" = "$RECV" ] || return 1 + [ -n "$lock_identity" ] || return 1 + current_identity=$(fm_pid_identity "$pid") || return 1 + [ "$current_identity" = "$lock_identity" ] +} + +healthy_receiver() { + local pid + TG_HEALTHY_PID= + pid=$(cat "$RECV_LOCK/pid" 2>/dev/null || true) + fm_pid_alive "$pid" || return 1 + tg_receiver_lock_matches_pid "$pid" || return 1 + TG_HEALTHY_PID=$pid + return 0 +} + +clear_dead_recorded_receiver_lock() { + local lock_home lock_path pid + lock_home=$(cat "$RECV_LOCK/fm-home" 2>/dev/null || true) + lock_path=$(cat "$RECV_LOCK/receiver-path" 2>/dev/null || true) + pid=$(cat "$RECV_LOCK/pid" 2>/dev/null || true) + [ "$lock_home" = "$FM_HOME" ] || return 0 + [ "$lock_path" = "$RECV" ] || return 0 + fm_pid_alive "$pid" && return 0 + fm_lock_remove_path "$RECV_LOCK" || true +} + +attach_and_wait() { + while :; do + if healthy_receiver; then + sleep "$ATTACH_POLL" + continue + fi + exit 0 + done +} + +if healthy_receiver; then + printf 'telegram receiver: attached pid=%s\n' "$TG_HEALTHY_PID" + attach_and_wait +fi + +clear_dead_recorded_receiver_lock + +ownerdir= +if ! fm_lock_try_create "$RECV_LOCK"; then + if healthy_receiver; then + printf 'telegram receiver: attached pid=%s\n' "$TG_HEALTHY_PID" + attach_and_wait + fi + printf 'telegram receiver: FAILED - receiver lock is held but no live matching receiver was confirmed\n' + exit 1 +fi +ownerdir=$FM_LOCK_OWNER_DIR + +child= +child_out= +cleanup() { + if [ -n "$child" ] && fm_pid_alive "$child"; then + kill -TERM "$child" 2>/dev/null || true + fi + fm_lock_remove_path "$RECV_LOCK" 2>/dev/null || true + [ -n "$child_out" ] && rm -f "$child_out" 2>/dev/null || true +} +trap 'cleanup; exit 129' HUP +trap 'cleanup; exit 143' TERM INT + +child_out=$(mktemp "$STATE/.tg-recv-output.XXXXXX") || { + cleanup + printf 'telegram receiver: FAILED - could not create output capture\n' + exit 1 +} + +"$RECV" >"$child_out" & +child=$! +identity=$(fm_pid_identity "$child" 2>/dev/null || true) +if [ -z "$identity" ]; then + cleanup + printf 'telegram receiver: FAILED - could not identify receiver process\n' + exit 1 +fi + +{ + printf '%s\n' "$child" > "$ownerdir/pid" + printf '%s\n' "$FM_HOME" > "$ownerdir/fm-home" + printf '%s\n' "$identity" > "$ownerdir/pid-identity" + printf '%s\n' "$RECV" > "$ownerdir/receiver-path" +} 2>/dev/null || { + cleanup + printf 'telegram receiver: FAILED - could not record receiver lock metadata\n' + exit 1 +} + +printf 'telegram receiver: started pid=%s\n' "$child" +wait "$child" +rc=$? +[ -s "$child_out" ] && cat "$child_out" +fm_lock_remove_path "$RECV_LOCK" 2>/dev/null || true +rm -f "$child_out" 2>/dev/null || true +trap - HUP TERM INT +exit "$rc" diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index fa2b3038c..06d6cae28 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -9,8 +9,8 @@ # skipped (including bootstrap's five mutating sweeps, verified by their # ABSENCE), the digest still completes # - output section ordering: diagnostics/banners lead, bulk file dumps follow -# - context-aware next-step guidance for read-only, AFK, X mode, and normal -# watcher ownership +# - context-aware next-step guidance for read-only, AFK, X mode, direct +# Telegram receiver arming, and normal watcher ownership # - status-tail bounding, default and FM_SESSION_START_STATUS_TAIL override # - orphan status logs whose task meta has already disappeared # - per-task endpoint-liveness lines for a live and a dead recorded target, @@ -743,8 +743,10 @@ EOF out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") assert_contains "$out" "(none)" "empty fleet did not report (none) for in-flight tasks" assert_contains "$out" "absent" "empty fleet's AFK section did not report absent" + assert_contains "$out" "TELEGRAM RECEIVER" "Telegram receiver section missing" + assert_contains "$out" "inactive (config/telegram.env absent)" "Telegram receiver section did not report inactive without config" - pass "an empty fleet reports (none) for in-flight tasks and an absent AFK flag" + pass "an empty fleet reports (none), inactive Telegram receive, and an absent AFK flag" } test_next_step_sources_x_mode_cadence() { @@ -768,6 +770,32 @@ EOF pass "session start emits X-mode cadence guidance in the harness supervision block" } +test_telegram_receiver_guidance() { + local rec root home fakebin out + rec=$(new_world next-step-telegram) + IFS='|' read -r root home fakebin < "$home/config/telegram.env" + cat > "$home/config/fm-tg-recv.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$home/config/fm-tg-recv.sh" + + out=$(run_session_start "$home" "$root" "$fakebin:$BASE_PATH") + + assert_contains "$out" "TELEGRAM RECEIVER" "Telegram receiver section missing" + assert_contains "$out" "TELEGRAM_RECEIVER: active - run bin/fm-tg-recv-arm.sh as its own tracked background task, never shell &" "session-start did not emit the tracked Telegram background arm step" + assert_contains "$out" "If the Telegram receiver section is active, keep that separate background task armed too." "next step did not preserve Telegram receiver follow-up" + + pass "locked session-start emits the direct Telegram receiver arm step when configured" +} + test_next_step_afk_delegates_to_daemon() { local rec root home fakebin out rec=$(new_world next-step-afk) @@ -934,6 +962,7 @@ test_backlog_compact_manual_backend_skips_indented_bodies test_backlog_compact_tasks_axi_unavailable_uses_manual_fallback test_fleet_digest_empty_fleet test_next_step_sources_x_mode_cadence +test_telegram_receiver_guidance test_next_step_afk_delegates_to_daemon test_supervision_block_exactly_one_and_pi_diagnostic test_pi_diagnostic_rejects_stale_loaded_marker diff --git a/tests/fm-tg-recv-arm.test.sh b/tests/fm-tg-recv-arm.test.sh new file mode 100755 index 000000000..98567644c --- /dev/null +++ b/tests/fm-tg-recv-arm.test.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# tests/fm-tg-recv-arm.test.sh - direct Telegram receiver arm wrapper behavior. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ARM="$ROOT/bin/fm-tg-recv-arm.sh" +fm_test_tmproot TMP_ROOT fm-tg-recv-arm-tests + +home="$TMP_ROOT/home" +mkdir -p "$home/config" "$home/state" + +out=$(FM_HOME="$home" "$ARM" 2>&1) +case "$out" in + *'telegram receiver: inactive (config/telegram.env absent)'*) : ;; + *) fail "expected inactive output without telegram.env, got: $out" ;; +esac + +printf 'BOT_TOKEN=x\nCHAT_ID=y\n' > "$home/config/telegram.env" +out=$(FM_HOME="$home" "$ARM" 2>&1) +case "$out" in + *'telegram receiver: FAILED - config/fm-tg-recv.sh missing or not executable'*) : ;; + *) fail "expected missing receiver failure, got: $out" ;; +esac + +cat > "$home/config/fm-tg-recv.sh" <<'SH' +#!/usr/bin/env bash +set -u +printf '%s\n' "$$" > "$FM_HOME/state/receiver.pid" +sleep 0.2 +printf 'CAPTAIN-TELEGRAM: test\n' +SH +chmod +x "$home/config/fm-tg-recv.sh" + +out=$(FM_HOME="$home" "$ARM" 2>&1) +case "$out" in + *'telegram receiver: started pid='*'CAPTAIN-TELEGRAM: test'*) : ;; + *) fail "expected started output and receiver payload, got: $out" ;; +esac + +cat > "$home/config/fm-tg-recv.sh" <<'SH' +#!/usr/bin/env bash +set -u +printf '%s\n' "$$" > "$FM_HOME/state/receiver.pid" +while [ ! -f "$FM_HOME/state/stop-receiver" ]; do + sleep 0.1 +done +SH +chmod +x "$home/config/fm-tg-recv.sh" +rm -f "$home/state/receiver.pid" "$home/state/stop-receiver" + +FM_HOME="$home" "$ARM" > "$home/state/arm1.out" 2>&1 & +arm1=$! +for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -s "$home/state/receiver.pid" ] && break + sleep 0.1 +done +[ -s "$home/state/receiver.pid" ] || fail "receiver did not start" + +FM_HOME="$home" "$ARM" > "$home/state/arm2.out" 2>&1 & +arm2=$! +for _ in 1 2 3 4 5 6 7 8 9 10; do + if grep -q 'telegram receiver: attached pid=' "$home/state/arm2.out" 2>/dev/null; then + break + fi + sleep 0.1 +done +grep -q 'telegram receiver: attached pid=' "$home/state/arm2.out" || fail "second arm did not attach" + +touch "$home/state/stop-receiver" +wait "$arm1" +wait "$arm2" From 1b5d78ac828597cd934f505528eded76d326a181 Mon Sep 17 00:00:00 2001 From: Crew Agent Date: Tue, 21 Jul 2026 02:12:12 +0000 Subject: [PATCH 2/8] no-mistakes(review): Captain, fix Telegram arm regressions --- bin/fm-arm-command-policy.mjs | 5 +++-- bin/fm-arm-pretool-check.sh | 23 ++++++++++++----------- bin/fm-tg-recv-arm.sh | 9 +++++++++ tests/fm-arm-pretool-check.test.sh | 13 ++++++++++++- tests/fm-tg-recv-arm.test.sh | 27 +++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 14 deletions(-) diff --git a/bin/fm-arm-command-policy.mjs b/bin/fm-arm-command-policy.mjs index 846965fa9..a7b9b2102 100755 --- a/bin/fm-arm-command-policy.mjs +++ b/bin/fm-arm-command-policy.mjs @@ -44,7 +44,7 @@ function parseArguments(argv) { } function rawMentionsProtected(command) { - return /(?:^|[/\s'"`(])fm-watch(?:-(?:arm|checkpoint))?\.sh\b/.test(normalizeLineContinuations(command)); + return /(?:^|[/\s'"`(])(?:fm-watch(?:-(?:arm|checkpoint))?|fm-tg-recv-arm)\.sh\b/.test(normalizeLineContinuations(command)); } function rawMentionsBroadKill(command) { @@ -597,6 +597,7 @@ export function commandPosition(tokens) { const PROTECTED_SCRIPTS = [ { relative: "bin/fm-watch-arm.sh", kind: "arm" }, + { relative: "bin/fm-tg-recv-arm.sh", kind: "arm" }, { relative: "bin/fm-watch-checkpoint.sh", kind: "checkpoint" }, { relative: "bin/fm-watch.sh", kind: "watch" }, ]; @@ -611,7 +612,7 @@ function protectedIdentity(value, root) { function hasUnclassifiableProtectedExpansion(word, root) { if (!word?.unquotedExpansion || protectedIdentity(word.value, root)) return false; - return /(?:^|\/)fm-watch/.test(word.value); + return /(?:^|\/)(?:fm-watch|fm-tg-recv-arm)/.test(word.value); } function shellInvocation(position) { diff --git a/bin/fm-arm-pretool-check.sh b/bin/fm-arm-pretool-check.sh index 6ac8941b9..deab5ea52 100755 --- a/bin/fm-arm-pretool-check.sh +++ b/bin/fm-arm-pretool-check.sh @@ -104,23 +104,23 @@ fi [ -n "$CMD" ] || exit 0 # Strict-superset prefilter (transport only; owns zero classification semantics). -# Every protected watcher execution and every broad watcher kill resolves to the -# fm-watch byte sequence AFTER the classifier's byte normalization, so a command -# that cannot contain fm-watch even after that normalization can never be a -# deniable watcher command and is fast-allowed without the Node policy owner. +# Every protected arm/watch execution and every broad watcher kill resolves to a +# protected byte sequence AFTER the classifier's byte normalization, so a command +# that cannot contain one even after that normalization can never be a deniable +# command and is fast-allowed without the Node policy owner. # We mirror the classifier's cheapest byte transforms here (drop line- # continuation and escape backslashes, quotes, and newlines) so obfuscated -# protected paths such as fm-watc\h-arm.sh or fm-"watch"-arm.sh still -# delegate. Stripping only these non-alphanumeric bytes can never destroy an -# existing fm-watch run. +# protected paths such as fm-watc\h-arm.sh, fm-"watch"-arm.sh, or +# fm-tg-recv-"arm".sh still delegate. Stripping only these non-alphanumeric +# bytes can never destroy an existing protected run. # # The fast path may allow ONLY when BOTH hold: (a) the stripped/normalized text -# lacks the fm-watch watcher substring, AND (b) the raw command carries no +# lacks every protected substring, AND (b) the raw command carries no # quoting-decoder marker - a $ immediately followed by a single quote (ANSI-C # $'...') or a double quote (bash locale $"..."), both of which the classifier -# decodes and can therefore reconstruct fm-watch from bytes this cheap byte -# strip cannot. This marker set is COUPLED to the classifier's decoder set in -# bin/fm-arm-command-policy.mjs: adding any new quote/expansion form the +# decodes and can therefore reconstruct protected paths from bytes this cheap +# byte strip cannot. This marker set is COUPLED to the classifier's decoder set +# in bin/fm-arm-command-policy.mjs: adding any new quote/expansion form the # classifier decodes REQUIRES extending this marker set in the same change, or # the prefilter stops being a strict superset. Otherwise the command always # delegates to the classifier - the single owner of every decision. Any deeper @@ -137,6 +137,7 @@ case "$CMD" in *) case "$PREFILTER" in *fm-watch*) ;; + *fm-tg-recv-arm*) ;; *) exit 0 ;; esac ;; diff --git a/bin/fm-tg-recv-arm.sh b/bin/fm-tg-recv-arm.sh index 87568665e..657d48be5 100755 --- a/bin/fm-tg-recv-arm.sh +++ b/bin/fm-tg-recv-arm.sh @@ -125,6 +125,15 @@ child_out=$(mktemp "$STATE/.tg-recv-output.XXXXXX") || { child=$! identity=$(fm_pid_identity "$child" 2>/dev/null || true) if [ -z "$identity" ]; then + if [ -s "$child_out" ] || ! fm_pid_alive "$child"; then + wait "$child" + rc=$? + [ -s "$child_out" ] && cat "$child_out" + fm_lock_remove_path "$RECV_LOCK" 2>/dev/null || true + rm -f "$child_out" 2>/dev/null || true + trap - HUP TERM INT + exit "$rc" + fi cleanup printf 'telegram receiver: FAILED - could not identify receiver process\n' exit 1 diff --git a/tests/fm-arm-pretool-check.test.sh b/tests/fm-arm-pretool-check.test.sh index 738ed27af..9360d9fc0 100755 --- a/tests/fm-arm-pretool-check.test.sh +++ b/tests/fm-arm-pretool-check.test.sh @@ -43,6 +43,8 @@ matrix_case A14 allow "[ -f 'config/x-mode.env' ] && source 'config/x-mode.env'; matrix_case A15 allow "cd $ROOT && exec bin/fm-watch-arm.sh" matrix_case A16 allow "export FM_HOME=$ROOT && bin/fm-watch-checkpoint.sh --seconds 180" matrix_case A17 allow $'source "config/x-mode.env"\nbin/fm-watch-checkpoint.sh --seconds 180' +matrix_case A18 allow 'bin/fm-tg-recv-arm.sh' +matrix_case A19 allow 'exec bin/fm-tg-recv-arm.sh' matrix_case R01 allow "pgrep -fl '/bin/fm-watch.sh' || true" matrix_case R02 allow "ps aux | rg '/bin/fm-watch.sh'" @@ -63,6 +65,7 @@ matrix_case R16 allow $'# bin/fm-watch-arm.sh &\necho ok' matrix_case R17 allow "printf '%s\\n' 'fm-watch.sh; a && b || c > out' | sed -n '1p'" matrix_case R18 allow "sh -c 'tmux send-keys -t lab \"bin/fm-watch-arm.sh &\" Enter'" matrix_case R19 allow "eval 'printf \"%s\\n\" \"bin/fm-watch-arm.sh &\"'" +matrix_case R20 allow "rg -n 'fm-tg-recv-arm.sh &' docs tests" matrix_case D01 deny 'bin/fm-watch-arm.sh &' matrix_case D02 deny 'nohup bin/fm-watch-arm.sh' @@ -122,6 +125,10 @@ matrix_case D55 deny 'while true; do pkill -f fm-watch; done' matrix_case D56 deny 'for x in 1; do pkill -f fm-watch; done' matrix_case D57 deny 'case x in x) pkill -f fm-watch ;; esac' matrix_case D58 deny 'until false; do kill $(pgrep -f fm-watch); done' +matrix_case D59 deny 'bin/fm-tg-recv-arm.sh &' +matrix_case D60 deny 'bin/fm-tg-recv-arm.sh | cat' +matrix_case D61 deny 'echo before; bin/fm-tg-recv-arm.sh' +matrix_case D62 deny "bash -lc 'bin/fm-tg-recv-arm.sh &'" matrix_case E01 allow "bin/fm-watch-checkpoint.sh --seconds '180;still-one-arg'" matrix_case E02 allow "bin/fm-watch-checkpoint.sh --label 'fm-watch-arm.sh; literal argument'" @@ -230,6 +237,10 @@ test_direct_policy_contract() { assert_policy direct-expanded-arm-blessed allow '$FM_HOME/bin/fm-watch-arm.sh' assert_policy direct-expanded-arm-background $'deny\twatcher-background' '$FM_HOME/bin/fm-watch-arm.sh &' assert_policy direct-expanded-arm-pipeline $'deny\twatcher-pipeline' '$HOME/firstmate/bin/fm-watch-arm.sh | cat' + assert_policy direct-tg-arm-blessed allow 'bin/fm-tg-recv-arm.sh' + assert_policy direct-tg-arm-background $'deny\twatcher-background' 'bin/fm-tg-recv-arm.sh &' + assert_policy direct-tg-arm-pipeline $'deny\twatcher-pipeline' 'bin/fm-tg-recv-arm.sh | cat' + assert_policy direct-tg-arm-bundled $'deny\twatcher-bundled' 'echo before; bin/fm-tg-recv-arm.sh' assert_policy direct-watch-not-blessed $'deny\twatcher-direct' 'bin/fm-watch.sh' assert_policy direct-watch-expanded $'deny\twatcher-direct' '$FM_HOME/bin/fm-watch.sh' assert_policy direct-watch-safe-shape $'deny\twatcher-direct' 'cd /tmp; bin/fm-watch.sh' @@ -348,7 +359,7 @@ test_prefilter_is_strict_superset() { "$CHECK" --command "echo 'pkill -f fm-watch'" >/dev/null 2>&1 rc=$? [ "$rc" -eq 0 ] || fail "a benign fm-watch-substring command must be classified and allowed, got exit $rc" - pass "transport prefilter is a strict superset: non-fm-watch fast-allows, every fm-watch and quoting-decoder-marker command reaches the classifier" + pass "transport prefilter is a strict superset: non-protected fast-allows, protected and quoting-decoder-marker commands reach the classifier" } # --- fail-open ---------------------------------------------------------------- diff --git a/tests/fm-tg-recv-arm.test.sh b/tests/fm-tg-recv-arm.test.sh index 98567644c..5cd08c471 100755 --- a/tests/fm-tg-recv-arm.test.sh +++ b/tests/fm-tg-recv-arm.test.sh @@ -39,6 +39,33 @@ case "$out" in *) fail "expected started output and receiver payload, got: $out" ;; esac +cat > "$home/config/fm-tg-recv.sh" <<'SH' +#!/usr/bin/env bash +set -u +printf 'CAPTAIN-TELEGRAM: already pending\n' +sleep 0.1 +SH +chmod +x "$home/config/fm-tg-recv.sh" +fakebin="$TMP_ROOT/fakebin" +mkdir -p "$fakebin" +cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +sleep 0.05 +exit 1 +SH +chmod +x "$fakebin/ps" + +out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$ARM" 2>&1) +case "$out" in + *'CAPTAIN-TELEGRAM: already pending'*) + case "$out" in + *'could not identify receiver process'*) fail "fast-exit receiver output was replayed with an identity failure: $out" ;; + *) : ;; + esac + ;; + *) fail "expected fast-exit receiver payload, got: $out" ;; +esac + cat > "$home/config/fm-tg-recv.sh" <<'SH' #!/usr/bin/env bash set -u From b003120ec9a72e0edc470ed034fd7f4cd384c55f Mon Sep 17 00:00:00 2001 From: Crew Agent Date: Tue, 21 Jul 2026 02:17:35 +0000 Subject: [PATCH 3/8] no-mistakes(review): Captain, fix Telegram receiver lock races --- bin/fm-tg-recv-arm.sh | 25 ++++++++++++++++++++----- bin/fm-wake-lib.sh | 1 + tests/fm-tg-recv-arm.test.sh | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/bin/fm-tg-recv-arm.sh b/bin/fm-tg-recv-arm.sh index 657d48be5..4bb187d22 100755 --- a/bin/fm-tg-recv-arm.sh +++ b/bin/fm-tg-recv-arm.sh @@ -20,6 +20,7 @@ RECV="$CONFIG/fm-tg-recv.sh" ENV_FILE="$CONFIG/telegram.env" RECV_LOCK="$STATE/.tg-recv.lock" ATTACH_POLL=${FM_TG_RECV_ATTACH_POLL:-0.5} +ATTACH_CONFIRM_TIMEOUT=${FM_TG_RECV_ATTACH_CONFIRM_TIMEOUT:-2} usage() { printf 'usage: %s\n' "$(basename "$0")" >&2 @@ -85,6 +86,21 @@ attach_and_wait() { done } +attach_if_receiver_becomes_healthy() { + local deadline now + deadline=$(($(date +%s) + ATTACH_CONFIRM_TIMEOUT)) + while [ -e "$RECV_LOCK" ] || [ -L "$RECV_LOCK" ]; do + if healthy_receiver; then + printf 'telegram receiver: attached pid=%s\n' "$TG_HEALTHY_PID" + attach_and_wait + fi + now=$(date +%s) + [ "$now" -lt "$deadline" ] || return 1 + sleep "$ATTACH_POLL" + done + return 1 +} + if healthy_receiver; then printf 'telegram receiver: attached pid=%s\n' "$TG_HEALTHY_PID" attach_and_wait @@ -94,12 +110,11 @@ clear_dead_recorded_receiver_lock ownerdir= if ! fm_lock_try_create "$RECV_LOCK"; then - if healthy_receiver; then - printf 'telegram receiver: attached pid=%s\n' "$TG_HEALTHY_PID" - attach_and_wait + attach_if_receiver_becomes_healthy + if ! fm_lock_try_create "$RECV_LOCK"; then + printf 'telegram receiver: FAILED - receiver lock is held but no live matching receiver was confirmed\n' + exit 1 fi - printf 'telegram receiver: FAILED - receiver lock is held but no live matching receiver was confirmed\n' - exit 1 fi ownerdir=$FM_LOCK_OWNER_DIR diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index c68327ba9..d7e428a5a 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -89,6 +89,7 @@ fm_lock_clean_known_files() { "$lockdir/fm-home" \ "$lockdir/pid-identity" \ "$lockdir/watcher-path" \ + "$lockdir/receiver-path" \ 2>/dev/null || true } diff --git a/tests/fm-tg-recv-arm.test.sh b/tests/fm-tg-recv-arm.test.sh index 5cd08c471..65df08daf 100755 --- a/tests/fm-tg-recv-arm.test.sh +++ b/tests/fm-tg-recv-arm.test.sh @@ -38,6 +38,8 @@ case "$out" in *'telegram receiver: started pid='*'CAPTAIN-TELEGRAM: test'*) : ;; *) fail "expected started output and receiver payload, got: $out" ;; esac +owner_leaks=$(find "$home/state" -maxdepth 1 -type d -name '.tg-recv.lock.owner.*' -print) +[ -z "$owner_leaks" ] || fail "receiver arm left lock owner directories behind: $owner_leaks" cat > "$home/config/fm-tg-recv.sh" <<'SH' #!/usr/bin/env bash @@ -98,3 +100,33 @@ grep -q 'telegram receiver: attached pid=' "$home/state/arm2.out" || fail "secon touch "$home/state/stop-receiver" wait "$arm1" wait "$arm2" + +rm -f "$home/state/.tg-recv.lock" +rm -rf "$home/state"/.tg-recv.lock.owner.* +sleep 5 & +race_pid=$! +race_identity=$(FM_HOME="$home" bash -c '. "$1"; fm_pid_identity "$2"' sh "$ROOT/bin/fm-wake-lib.sh" "$race_pid") +race_owner="$home/state/.tg-recv.lock.owner.race" +mkdir "$race_owner" +printf '%s\n' "$race_pid" > "$race_owner/pid" +ln -s "$race_owner" "$home/state/.tg-recv.lock" +( + sleep 0.3 + printf '%s\n' "$home" > "$race_owner/fm-home" + printf '%s\n' "$race_identity" > "$race_owner/pid-identity" + printf '%s\n' "$home/config/fm-tg-recv.sh" > "$race_owner/receiver-path" +) & +publisher=$! +FM_TG_RECV_ATTACH_CONFIRM_TIMEOUT=3 FM_TG_RECV_ATTACH_POLL=0.1 FM_HOME="$home" "$ARM" > "$home/state/race.out" 2>&1 & +race_arm=$! +for _ in 1 2 3 4 5 6 7 8 9 10; do + if grep -q 'telegram receiver: attached pid=' "$home/state/race.out" 2>/dev/null; then + break + fi + sleep 0.2 +done +grep -q 'telegram receiver: attached pid=' "$home/state/race.out" || fail "second arm did not wait for receiver lock metadata: $(cat "$home/state/race.out")" +kill "$race_pid" 2>/dev/null || true +wait "$race_pid" 2>/dev/null || true +wait "$publisher" +wait "$race_arm" From 0954293c0190f290408b54c67d506cdcd4a46bcb Mon Sep 17 00:00:00 2001 From: Crew Agent Date: Tue, 21 Jul 2026 02:23:39 +0000 Subject: [PATCH 4/8] no-mistakes(review): Captain, fix Telegram receiver cleanup race --- bin/fm-tg-recv-arm.sh | 60 +++++++++++++++++++++++++++++++++--- tests/fm-tg-recv-arm.test.sh | 41 ++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/bin/fm-tg-recv-arm.sh b/bin/fm-tg-recv-arm.sh index 4bb187d22..96096d947 100755 --- a/bin/fm-tg-recv-arm.sh +++ b/bin/fm-tg-recv-arm.sh @@ -21,6 +21,8 @@ ENV_FILE="$CONFIG/telegram.env" RECV_LOCK="$STATE/.tg-recv.lock" ATTACH_POLL=${FM_TG_RECV_ATTACH_POLL:-0.5} ATTACH_CONFIRM_TIMEOUT=${FM_TG_RECV_ATTACH_CONFIRM_TIMEOUT:-2} +TERM_WAIT_CYCLES=${FM_TG_RECV_TERM_WAIT_CYCLES:-30} +TERM_WAIT_POLL=${FM_TG_RECV_TERM_WAIT_POLL:-0.1} usage() { printf 'usage: %s\n' "$(basename "$0")" >&2 @@ -120,11 +122,59 @@ ownerdir=$FM_LOCK_OWNER_DIR child= child_out= -cleanup() { - if [ -n "$child" ] && fm_pid_alive "$child"; then +release_lock_if_owned() { + [ -n "$ownerdir" ] || return 0 + if fm_lock_points_to_owner "$RECV_LOCK" "$ownerdir"; then + fm_lock_remove_path "$RECV_LOCK" 2>/dev/null || true + fi +} + +record_child_lock_metadata_if_possible() { + local current_identity + [ -n "$ownerdir" ] || return 0 + [ -n "$child" ] || return 0 + fm_lock_points_to_owner "$RECV_LOCK" "$ownerdir" || return 0 + current_identity=$(fm_pid_identity "$child" 2>/dev/null) || return 0 + { + printf '%s\n' "$child" > "$ownerdir/pid" + printf '%s\n' "$FM_HOME" > "$ownerdir/fm-home" + printf '%s\n' "$current_identity" > "$ownerdir/pid-identity" + printf '%s\n' "$RECV" > "$ownerdir/receiver-path" + } 2>/dev/null || true +} + +child_still_running() { + local stat + fm_pid_alive "$child" || return 1 + stat=$(ps -p "$child" -o stat= 2>/dev/null | sed 's/^[[:space:]]*//') || return 1 + case "$stat" in + Z*) return 1 ;; + esac + return 0 +} + +terminate_child_bounded() { + local i=0 + [ -n "$child" ] || return 0 + if child_still_running; then kill -TERM "$child" 2>/dev/null || true fi - fm_lock_remove_path "$RECV_LOCK" 2>/dev/null || true + while child_still_running && [ "$i" -lt "$TERM_WAIT_CYCLES" ]; do + sleep "$TERM_WAIT_POLL" + i=$((i + 1)) + done + if child_still_running; then + return 1 + fi + wait "$child" 2>/dev/null || true + return 0 +} + +cleanup() { + record_child_lock_metadata_if_possible + if terminate_child_bounded; then + release_lock_if_owned + fi [ -n "$child_out" ] && rm -f "$child_out" 2>/dev/null || true } trap 'cleanup; exit 129' HUP @@ -144,7 +194,7 @@ if [ -z "$identity" ]; then wait "$child" rc=$? [ -s "$child_out" ] && cat "$child_out" - fm_lock_remove_path "$RECV_LOCK" 2>/dev/null || true + release_lock_if_owned rm -f "$child_out" 2>/dev/null || true trap - HUP TERM INT exit "$rc" @@ -169,7 +219,7 @@ printf 'telegram receiver: started pid=%s\n' "$child" wait "$child" rc=$? [ -s "$child_out" ] && cat "$child_out" -fm_lock_remove_path "$RECV_LOCK" 2>/dev/null || true +release_lock_if_owned rm -f "$child_out" 2>/dev/null || true trap - HUP TERM INT exit "$rc" diff --git a/tests/fm-tg-recv-arm.test.sh b/tests/fm-tg-recv-arm.test.sh index 65df08daf..b3faf2a2f 100755 --- a/tests/fm-tg-recv-arm.test.sh +++ b/tests/fm-tg-recv-arm.test.sh @@ -101,6 +101,47 @@ touch "$home/state/stop-receiver" wait "$arm1" wait "$arm2" +cat > "$home/config/fm-tg-recv.sh" <<'SH' +#!/usr/bin/env bash +set -u +trap ':' TERM +printf '%s\n' "$$" > "$FM_HOME/state/receiver.pid" +printf 'start\n' >> "$FM_HOME/state/receiver.starts" +while [ ! -f "$FM_HOME/state/stop-receiver" ]; do + sleep 0.1 +done +SH +chmod +x "$home/config/fm-tg-recv.sh" +rm -f "$home/state/receiver.pid" "$home/state/receiver.starts" "$home/state/stop-receiver" + +FM_HOME="$home" "$ARM" > "$home/state/term-arm1.out" 2>&1 & +term_arm1=$! +for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -s "$home/state/receiver.pid" ] && break + sleep 0.1 +done +[ -s "$home/state/receiver.pid" ] || fail "signal cleanup receiver did not start" +receiver_pid=$(cat "$home/state/receiver.pid") + +kill -TERM "$term_arm1" +wait "$term_arm1" 2>/dev/null +fm_pid_alive=$(FM_HOME="$home" bash -c '. "$1"; fm_pid_alive "$2"; printf "%s\n" "$?"' sh "$ROOT/bin/fm-wake-lib.sh" "$receiver_pid") +[ "$fm_pid_alive" = 0 ] || fail "signal cleanup killed slow receiver before bounded wait check" +[ -L "$home/state/.tg-recv.lock" ] || fail "signal cleanup dropped live receiver lock" + +FM_HOME="$home" "$ARM" > "$home/state/term-arm2.out" 2>&1 & +term_arm2=$! +for _ in 1 2 3 4 5 6 7 8 9 10; do + if grep -q 'telegram receiver: attached pid=' "$home/state/term-arm2.out" 2>/dev/null; then + break + fi + sleep 0.1 +done +grep -q 'telegram receiver: attached pid=' "$home/state/term-arm2.out" || fail "second arm did not attach after signal cleanup: $(cat "$home/state/term-arm2.out")" +[ "$(wc -l < "$home/state/receiver.starts")" -eq 1 ] || fail "signal cleanup allowed duplicate receiver start" +touch "$home/state/stop-receiver" +wait "$term_arm2" + rm -f "$home/state/.tg-recv.lock" rm -rf "$home/state"/.tg-recv.lock.owner.* sleep 5 & From 7245701ff347c33c02a973a26b7f47020b9415ff Mon Sep 17 00:00:00 2001 From: Crew Agent Date: Tue, 21 Jul 2026 02:29:18 +0000 Subject: [PATCH 5/8] no-mistakes(review): Captain, preserve Telegram receiver output relay --- bin/fm-tg-recv-arm.sh | 27 +++++++++++++++++++++++++-- bin/fm-wake-lib.sh | 1 + tests/fm-tg-recv-arm.test.sh | 5 +++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/bin/fm-tg-recv-arm.sh b/bin/fm-tg-recv-arm.sh index 96096d947..f92f2a10b 100755 --- a/bin/fm-tg-recv-arm.sh +++ b/bin/fm-tg-recv-arm.sh @@ -78,12 +78,31 @@ clear_dead_recorded_receiver_lock() { fm_lock_remove_path "$RECV_LOCK" || true } +relay_output_file_once() { + local output_path=$1 relay_path + [ -n "$output_path" ] || return 0 + [ -e "$output_path" ] || return 0 + relay_path="$output_path.relay.$$" + if mv "$output_path" "$relay_path" 2>/dev/null; then + [ -s "$relay_path" ] && cat "$relay_path" + rm -f "$relay_path" 2>/dev/null || true + fi +} + +relay_recorded_receiver_output_once() { + local output_path + output_path=$(cat "$RECV_LOCK/output-path" 2>/dev/null || true) + relay_output_file_once "$output_path" +} + attach_and_wait() { while :; do if healthy_receiver; then sleep "$ATTACH_POLL" continue fi + relay_recorded_receiver_output_once + clear_dead_recorded_receiver_lock exit 0 done } @@ -140,6 +159,7 @@ record_child_lock_metadata_if_possible() { printf '%s\n' "$FM_HOME" > "$ownerdir/fm-home" printf '%s\n' "$current_identity" > "$ownerdir/pid-identity" printf '%s\n' "$RECV" > "$ownerdir/receiver-path" + [ -n "$child_out" ] && printf '%s\n' "$child_out" > "$ownerdir/output-path" } 2>/dev/null || true } @@ -174,8 +194,10 @@ cleanup() { record_child_lock_metadata_if_possible if terminate_child_bounded; then release_lock_if_owned + [ -n "$child_out" ] && relay_output_file_once "$child_out" + [ -n "$child_out" ] && rm -f "$child_out" 2>/dev/null || true + return fi - [ -n "$child_out" ] && rm -f "$child_out" 2>/dev/null || true } trap 'cleanup; exit 129' HUP trap 'cleanup; exit 143' TERM INT @@ -209,6 +231,7 @@ fi printf '%s\n' "$FM_HOME" > "$ownerdir/fm-home" printf '%s\n' "$identity" > "$ownerdir/pid-identity" printf '%s\n' "$RECV" > "$ownerdir/receiver-path" + printf '%s\n' "$child_out" > "$ownerdir/output-path" } 2>/dev/null || { cleanup printf 'telegram receiver: FAILED - could not record receiver lock metadata\n' @@ -218,7 +241,7 @@ fi printf 'telegram receiver: started pid=%s\n' "$child" wait "$child" rc=$? -[ -s "$child_out" ] && cat "$child_out" +relay_output_file_once "$child_out" release_lock_if_owned rm -f "$child_out" 2>/dev/null || true trap - HUP TERM INT diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index d7e428a5a..90ab7340b 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -90,6 +90,7 @@ fm_lock_clean_known_files() { "$lockdir/pid-identity" \ "$lockdir/watcher-path" \ "$lockdir/receiver-path" \ + "$lockdir/output-path" \ 2>/dev/null || true } diff --git a/tests/fm-tg-recv-arm.test.sh b/tests/fm-tg-recv-arm.test.sh index b3faf2a2f..d2fd85dc9 100755 --- a/tests/fm-tg-recv-arm.test.sh +++ b/tests/fm-tg-recv-arm.test.sh @@ -110,6 +110,7 @@ printf 'start\n' >> "$FM_HOME/state/receiver.starts" while [ ! -f "$FM_HOME/state/stop-receiver" ]; do sleep 0.1 done +printf 'CAPTAIN-TELEGRAM: after abandoned wrapper\n' SH chmod +x "$home/config/fm-tg-recv.sh" rm -f "$home/state/receiver.pid" "$home/state/receiver.starts" "$home/state/stop-receiver" @@ -128,6 +129,9 @@ wait "$term_arm1" 2>/dev/null fm_pid_alive=$(FM_HOME="$home" bash -c '. "$1"; fm_pid_alive "$2"; printf "%s\n" "$?"' sh "$ROOT/bin/fm-wake-lib.sh" "$receiver_pid") [ "$fm_pid_alive" = 0 ] || fail "signal cleanup killed slow receiver before bounded wait check" [ -L "$home/state/.tg-recv.lock" ] || fail "signal cleanup dropped live receiver lock" +capture_path=$(cat "$home/state/.tg-recv.lock/output-path" 2>/dev/null || true) +[ -n "$capture_path" ] || fail "signal cleanup did not preserve output capture metadata" +[ -e "$capture_path" ] || fail "signal cleanup removed live receiver output capture" FM_HOME="$home" "$ARM" > "$home/state/term-arm2.out" 2>&1 & term_arm2=$! @@ -141,6 +145,7 @@ grep -q 'telegram receiver: attached pid=' "$home/state/term-arm2.out" || fail " [ "$(wc -l < "$home/state/receiver.starts")" -eq 1 ] || fail "signal cleanup allowed duplicate receiver start" touch "$home/state/stop-receiver" wait "$term_arm2" +grep -q 'CAPTAIN-TELEGRAM: after abandoned wrapper' "$home/state/term-arm2.out" || fail "attached arm did not relay abandoned receiver output: $(cat "$home/state/term-arm2.out")" rm -f "$home/state/.tg-recv.lock" rm -rf "$home/state"/.tg-recv.lock.owner.* From c591baac27e5950a14c5cabaaaaa1bde4cbf5b36 Mon Sep 17 00:00:00 2001 From: Crew Agent Date: Tue, 21 Jul 2026 02:33:34 +0000 Subject: [PATCH 6/8] no-mistakes(review): Captain, export Telegram receiver home env --- bin/fm-tg-recv-arm.sh | 2 +- tests/fm-tg-recv-arm.test.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/bin/fm-tg-recv-arm.sh b/bin/fm-tg-recv-arm.sh index f92f2a10b..0e21eefeb 100755 --- a/bin/fm-tg-recv-arm.sh +++ b/bin/fm-tg-recv-arm.sh @@ -208,7 +208,7 @@ child_out=$(mktemp "$STATE/.tg-recv-output.XXXXXX") || { exit 1 } -"$RECV" >"$child_out" & +FM_HOME="$FM_HOME" FM_CONFIG_OVERRIDE="$CONFIG" FM_STATE_OVERRIDE="$STATE" "$RECV" >"$child_out" & child=$! identity=$(fm_pid_identity "$child" 2>/dev/null || true) if [ -z "$identity" ]; then diff --git a/tests/fm-tg-recv-arm.test.sh b/tests/fm-tg-recv-arm.test.sh index d2fd85dc9..111ac56ee 100755 --- a/tests/fm-tg-recv-arm.test.sh +++ b/tests/fm-tg-recv-arm.test.sh @@ -41,6 +41,34 @@ esac owner_leaks=$(find "$home/state" -maxdepth 1 -type d -name '.tg-recv.lock.owner.*' -print) [ -z "$owner_leaks" ] || fail "receiver arm left lock owner directories behind: $owner_leaks" +plain_home="$TMP_ROOT/plain-home" +mkdir -p "$plain_home/config" "$plain_home/state" +printf 'BOT_TOKEN=x\nCHAT_ID=y\n' > "$plain_home/config/telegram.env" +cat > "$plain_home/config/fm-tg-recv.sh" <<'SH' +#!/usr/bin/env bash +set -u +[ "$FM_HOME" = "$EXPECTED_FM_HOME" ] || { + printf 'bad FM_HOME: %s\n' "${FM_HOME-}" + exit 7 +} +[ "$FM_CONFIG_OVERRIDE" = "$EXPECTED_FM_HOME/config" ] || { + printf 'bad FM_CONFIG_OVERRIDE: %s\n' "${FM_CONFIG_OVERRIDE-}" + exit 8 +} +[ "$FM_STATE_OVERRIDE" = "$EXPECTED_FM_HOME/state" ] || { + printf 'bad FM_STATE_OVERRIDE: %s\n' "${FM_STATE_OVERRIDE-}" + exit 9 +} +printf 'CAPTAIN-TELEGRAM: env ok\n' +SH +chmod +x "$plain_home/config/fm-tg-recv.sh" + +out=$(EXPECTED_FM_HOME="$plain_home" FM_ROOT_OVERRIDE="$plain_home" env -u FM_HOME -u FM_CONFIG_OVERRIDE -u FM_STATE_OVERRIDE "$ARM" 2>&1) +case "$out" in + *'CAPTAIN-TELEGRAM: env ok'*) : ;; + *) fail "expected receiver to inherit resolved home env, got: $out" ;; +esac + cat > "$home/config/fm-tg-recv.sh" <<'SH' #!/usr/bin/env bash set -u From 08a71ada4e6545365cac6740e92e95eda413c754 Mon Sep 17 00:00:00 2001 From: Crew Agent Date: Tue, 21 Jul 2026 02:39:06 +0000 Subject: [PATCH 7/8] no-mistakes(review): Captain, harden Telegram receiver lock recovery --- bin/fm-tg-recv-arm.sh | 5 +++-- tests/fm-tg-recv-arm.test.sh | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/bin/fm-tg-recv-arm.sh b/bin/fm-tg-recv-arm.sh index 0e21eefeb..6185549d2 100755 --- a/bin/fm-tg-recv-arm.sh +++ b/bin/fm-tg-recv-arm.sh @@ -75,6 +75,7 @@ clear_dead_recorded_receiver_lock() { [ "$lock_home" = "$FM_HOME" ] || return 0 [ "$lock_path" = "$RECV" ] || return 0 fm_pid_alive "$pid" && return 0 + relay_recorded_receiver_output_once fm_lock_remove_path "$RECV_LOCK" || true } @@ -130,9 +131,9 @@ fi clear_dead_recorded_receiver_lock ownerdir= -if ! fm_lock_try_create "$RECV_LOCK"; then +if ! fm_lock_try_acquire "$RECV_LOCK"; then attach_if_receiver_becomes_healthy - if ! fm_lock_try_create "$RECV_LOCK"; then + if ! fm_lock_try_acquire "$RECV_LOCK"; then printf 'telegram receiver: FAILED - receiver lock is held but no live matching receiver was confirmed\n' exit 1 fi diff --git a/tests/fm-tg-recv-arm.test.sh b/tests/fm-tg-recv-arm.test.sh index 111ac56ee..5005dc56b 100755 --- a/tests/fm-tg-recv-arm.test.sh +++ b/tests/fm-tg-recv-arm.test.sh @@ -204,3 +204,42 @@ kill "$race_pid" 2>/dev/null || true wait "$race_pid" 2>/dev/null || true wait "$publisher" wait "$race_arm" + +cat > "$home/config/fm-tg-recv.sh" <<'SH' +#!/usr/bin/env bash +set -u +printf 'CAPTAIN-TELEGRAM: fresh receiver\n' +SH +chmod +x "$home/config/fm-tg-recv.sh" +rm -f "$home/state/.tg-recv.lock" +rm -rf "$home/state"/.tg-recv.lock.owner.* +orphan_owner="$home/state/.tg-recv.lock.owner.orphan" +orphan_capture="$home/state/.tg-recv-output.orphan" +mkdir "$orphan_owner" +printf '999999\n' > "$orphan_owner/pid" +printf '%s\n' "$home" > "$orphan_owner/fm-home" +printf '%s\n' "$home/config/fm-tg-recv.sh" > "$orphan_owner/receiver-path" +printf '%s\n' "$orphan_capture" > "$orphan_owner/output-path" +printf 'CAPTAIN-TELEGRAM: orphaned message\n' > "$orphan_capture" +ln -s "$orphan_owner" "$home/state/.tg-recv.lock" +out=$(FM_HOME="$home" "$ARM" 2>&1) +case "$out" in + *'CAPTAIN-TELEGRAM: orphaned message'*) : ;; + *) fail "dead recorded receiver output was not relayed before lock cleanup: $out" ;; +esac +[ ! -e "$orphan_capture" ] || fail "dead recorded receiver output capture was left after relay" + +rm -f "$home/state/.tg-recv.lock" +rm -rf "$home/state"/.tg-recv.lock.owner.* +partial_owner="$home/state/.tg-recv.lock.owner.partial" +mkdir "$partial_owner" +printf '999999\n' > "$partial_owner/pid" +ln -s "$partial_owner" "$home/state/.tg-recv.lock" +out=$(FM_TG_RECV_ATTACH_CONFIRM_TIMEOUT=0 FM_HOME="$home" "$ARM" 2>&1) +case "$out" in + *'CAPTAIN-TELEGRAM: fresh receiver'*) : ;; + *) fail "stale partial receiver lock was not reclaimed: $out" ;; +esac +if [ -e "$home/state/.tg-recv.lock" ] || [ -L "$home/state/.tg-recv.lock" ]; then + fail "stale partial receiver lock was left after reclaimed receiver exited" +fi From 896e9c6c8ca6136b8d79d7c09bfeb9f082a37f3b Mon Sep 17 00:00:00 2001 From: Crew Agent Date: Tue, 21 Jul 2026 03:07:55 +0000 Subject: [PATCH 8/8] no-mistakes(document): Captain: sync Telegram receiver docs --- .agents/skills/harness-adapters/SKILL.md | 2 +- bin/fm-arm-command-policy.mjs | 2 +- bin/fm-arm-pretool-check.sh | 2 +- docs/architecture.md | 2 ++ docs/arm-pretool-check.md | 33 ++++++++++++------------ docs/cd-guard.md | 8 +++--- docs/configuration.md | 21 +++++++++++++-- docs/scripts.md | 5 ++-- docs/sessionstart-nudge.md | 2 +- docs/turnend-guard.md | 2 +- 10 files changed, 50 insertions(+), 29 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 86c684f7c..aedb0fcd8 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -58,7 +58,7 @@ When changing any primary turn-end hook, validate the real harness behavior in a ## Primary pre-arm (PreToolUse) seatbelt -Every verified primary harness also has a wired PreToolUse-equivalent hook that denies a watcher-arm anti-pattern (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before it runs. +Every verified primary harness also has a wired PreToolUse-equivalent hook that denies supervision-arm anti-patterns (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before they run. `claude` and `codex` block directly through PreToolUse hooks; `grok` blocks the same way but requires every `$VAR` reference in its hook `command` string to carry an inline `:-default` or it fails to launch the hook entirely. `opencode` and `pi` block by throwing from `tool.execute.before` / returning `{block: true}` from `tool_call`. The exact hook files, commands, output-shaping quirks (Claude Code only honors the deny when stdout is empty), and validation transcripts are owned by `docs/arm-pretool-check.md`. diff --git a/bin/fm-arm-command-policy.mjs b/bin/fm-arm-command-policy.mjs index a7b9b2102..bbf43e8bf 100755 --- a/bin/fm-arm-command-policy.mjs +++ b/bin/fm-arm-command-policy.mjs @@ -9,7 +9,7 @@ // The tokenizer and command-position analysis (Lexer, splitProgram, // commandPosition) are exported so the sibling cd-guard policy // (bin/fm-cd-command-policy.mjs) reuses the same proven parser instead of -// duplicating shell lexing; see docs/cd-guard.md. The watcher-arm decision +// duplicating shell lexing; see docs/cd-guard.md. The supervision-arm decision // procedure below stays private to this file. The CLI entry point at the bottom // runs only when this module is invoked directly, never on import. diff --git a/bin/fm-arm-pretool-check.sh b/bin/fm-arm-pretool-check.sh index deab5ea52..57c815178 100755 --- a/bin/fm-arm-pretool-check.sh +++ b/bin/fm-arm-pretool-check.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Stable PreToolUse transport for the watcher-arm command policy. +# Stable PreToolUse transport for the supervision-arm command policy. # # A firstmate primary must arm the watcher or run a Codex checkpoint as a # standalone verified harness call. diff --git a/docs/architecture.md b/docs/architecture.md index ebe463d37..d11e46d83 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,6 +52,8 @@ That block owns the live wait shape for the running primary harness: Claude and `bin/fm-watch-arm.sh` remains the verified arm wrapper for protocols that call it; it forks the watcher as a tracked child, verifies it is genuinely alive with a fresh liveness beacon, and prints exactly one honest status line (`started` / `attached` / restart-only `healthy` / `FAILED`, the last exiting non-zero). On `attached` it stays live until that existing cycle ends so background-notify harnesses do not get an empty false wake from a healthy no-op exit. Its `--restart` mode signals only the watcher recorded in the current home's `state/.watch.lock`, so restarting one home cannot kill sibling secondmate watchers. +Optional direct Telegram receive is armed separately from watcher supervision when `config/telegram.env` and an executable `config/fm-tg-recv.sh` exist. +`bin/fm-tg-recv-arm.sh` starts or attaches to one home-scoped receiver through `state/.tg-recv.lock`, while the local receiver script owns the Telegram protocol and emitted `CAPTAIN-TELEGRAM` line. A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if the primary checkout is tangled, or if tasks are in flight and that watcher stops running or queued wakes are waiting to be drained. The drain script calls that guard after emptying the queue, which avoids repeating the queued-wakes warning for records it just consumed while still warning on stale watcher liveness. It leads with a prominent bordered tangle banner, while `bin/fm-guard.sh` owns the stale-watcher banner/reminder policy so repeated guarded commands stay noisy without reprinting the full watcher-down banner in the same episode. diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md index 14fdf714a..fc40b7d8d 100644 --- a/docs/arm-pretool-check.md +++ b/docs/arm-pretool-check.md @@ -1,6 +1,6 @@ -# Watcher arm PreToolUse seatbelt +# Supervision arm PreToolUse seatbelt -This document is the authoritative human-readable contract for the watcher arm PreToolUse seatbelt. +This document is the authoritative human-readable contract for the supervision arm PreToolUse seatbelt. `bin/fm-arm-command-policy.mjs` is the single semantic owner. `bin/fm-arm-pretool-check.sh` is only the stable harness transport and output renderer. The tracked harness adapters forward command text without classifying it. @@ -8,12 +8,12 @@ The tracked harness adapters forward command text without classifying it. ## Purpose and boundary -A firstmate primary must arm `bin/fm-watch-arm.sh` or run `bin/fm-watch-checkpoint.sh` through an observable harness call. -A shell background operator, pipeline, redirection, wrapper, or unrelated command list can hide failure or let the watcher child die with the tool call. +A firstmate primary must arm `bin/fm-watch-arm.sh`, run `bin/fm-watch-checkpoint.sh`, or arm `bin/fm-tg-recv-arm.sh` through an observable harness call. +A shell background operator, pipeline, redirection, wrapper, or unrelated command list can hide failure or let the supervised child die with the tool call. The seatbelt rejects those command shapes before execution. This policy is not a post-arm liveness guarantee. -`bin/fm-guard.sh`, `bin/fm-turnend-guard.sh`, the watcher lock, and the watcher beacon still prove whether supervision is healthy after an allowed call. +`bin/fm-guard.sh`, `bin/fm-turnend-guard.sh`, the watcher lock, the watcher beacon, and the Telegram receiver lock still prove whether supervision is healthy after an allowed call. The classifier never executes, sources, evaluates, or expands any part of the submitted command. It tokenizes the bytes and classifies lexical execution positions only. @@ -32,19 +32,19 @@ The wrapper discovers the code root from its own location. The active firstmate home is `${FM_HOME:-}`. It passes both roots and the exact command string to the Node policy owner. -The wrapper fast-allows a command without invoking the Node policy owner only when the command cannot contain the `fm-watch` byte sequence even after the classifier's decoders run. +The wrapper fast-allows a command without invoking the Node policy owner only when the command cannot contain any protected byte sequence even after the classifier's decoders run. The fast path may allow only when both of these hold: -1. The stripped text lacks the `fm-watch` watcher substring, after mirroring the classifier's cheapest byte normalizations - dropping line-continuation and escape backslashes, quotes, and newlines. +1. The stripped text lacks the `fm-watch` and `fm-tg-recv-arm` protected substrings, after mirroring the classifier's cheapest byte normalizations - dropping line-continuation and escape backslashes, quotes, and newlines. 2. The raw command carries no quoting-decoder marker: a `$` immediately followed by a single quote (ANSI-C `$'...'`) or a double quote (bash locale `$"..."`). -Any `fm-watch` match or any quoting-decoder marker delegates to the classifier. -Normalizing first keeps this a strict superset: a protected watcher path obfuscated as `fm-watc\h-arm.sh` or `fm-"watch"-arm.sh` still delegates, and stripping only those non-alphanumeric bytes can never destroy an existing `fm-watch` run. -The quoting-decoder marker closes the case the byte strip cannot: `bin/fm-$'\x77'atch-arm.sh` and `bin/fm-$"watch"-arm.sh` both resolve to `bin/fm-watch-arm.sh` only after the classifier decodes the encoded character, so a cheap byte strip would otherwise lose the `fm-watch` bytes and fast-allow them. +Any protected substring match or any quoting-decoder marker delegates to the classifier. +Normalizing first keeps this a strict superset: a protected path obfuscated as `fm-watc\h-arm.sh`, `fm-"watch"-arm.sh`, or `fm-tg-recv-"arm".sh` still delegates, and stripping only those non-alphanumeric bytes can never destroy an existing protected run. +The quoting-decoder marker closes the case the byte strip cannot: `bin/fm-$'\x77'atch-arm.sh`, `bin/fm-$"watch"-arm.sh`, and equivalent encoded `fm-tg-recv-arm` forms resolve to protected scripts only after the classifier decodes the encoded character, so a cheap byte strip would otherwise lose the protected bytes and fast-allow them. This marker set is coupled to the classifier's decoder set in `bin/fm-arm-command-policy.mjs`: adding any new quote or expansion form the classifier decodes requires extending this marker set in the same change, or the prefilter stops being a strict superset. -The prefilter owns no semantic exception: it can only ever fast-allow a command that is definitely not a watcher command, so it never flips a classification and the classifier remains the single owner of every decision. +The prefilter owns no semantic exception: it can only ever fast-allow a command that is definitely not protected, so it never flips a classification and the classifier remains the single owner of every decision. -The seatbelt's threat model is agent mistakes: no one accidentally writes an ANSI-C- or locale-obfuscated watcher path, and deliberate obfuscation is the post-arm liveness guard's territory. +The seatbelt's threat model is agent mistakes: no one accidentally writes an ANSI-C- or locale-obfuscated protected path, and deliberate obfuscation is the post-arm liveness guard's territory. The marker guard closes the static gap anyway because it is cheap and provable per encoding class. Tripwire: if a third strict-superset gap is ever found after this marker generalization, that falsifies the "provable per encoding class" claim and the decision flips to Option B - drop the prefilter and always invoke the classifier. Deeper decode-required obfuscation beyond the coupled marker set stays the classifier's and the post-arm liveness guards' responsibility. @@ -58,12 +58,13 @@ Malformed or unsupported shell syntax that contains a protected command is a sem The tokenizer recognizes cooked words with quote provenance, comments, heredoc bodies, shell list operators, pipelines, redirections, command and process substitutions, parenthesized subshells, brace groups, and literal nested execution payloads. Quoted text, comments, heredoc bodies, and later argument words are data positions unless a recognized execution sink recursively executes them. -A command word in executed position is a protected execution when its normalized path suffix matches one of the protected watcher scripts: +A command word in executed position is a protected execution when its normalized path suffix matches one of the protected scripts: ```text bin/fm-watch-arm.sh (arm; blessed entry point) bin/fm-watch-checkpoint.sh (checkpoint; blessed entry point) bin/fm-watch.sh (watch; protected but never blessed) +bin/fm-tg-recv-arm.sh (arm; blessed entry point) ``` The relative form, the ``-anchored absolute form, and any word ending in `/bin/