diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 86dbfd2ee5..8dae80da56 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -105,6 +105,7 @@ This preserves launch success instead of passing a known-bad value. Send the validation skill using the target harness's skill invocation form. Natural language is acceptable if uncertain. +`fm-send` refuses a bare installed-skill token whose prefix conflicts with the resolved harness, and names the correct form instead of silently rewriting a caller bug. - claude: `/`, for example `/no-mistakes`. - codex: `$`, for example `$no-mistakes`; `/` is claude-only and codex rejects it as "Unrecognized command". diff --git a/bin/fm-account-continuation.sh b/bin/fm-account-continuation.sh index 103bf349f8..15d4d8f65f 100755 --- a/bin/fm-account-continuation.sh +++ b/bin/fm-account-continuation.sh @@ -245,6 +245,26 @@ cd "$WORKTREE_REAL" \ exec 9< . \ || { echo "error: continuation worktree cannot be pinned for $ID" >&2; exit 1; } cd "$ORIGINAL_CWD" || exit 1 + +if [ "$KIND" = secondmate ]; then + BRIEF_SNAPSHOT_TMP=$(mktemp "$STATE/.continuation-brief-$ID.XXXXXX") \ + || { echo "error: cannot stage original brief or charter for continuation of $ID" >&2; exit 1; } + if python3 "$SCRIPT_DIR/fm-contained-read.py" cat-optional-fd data/charter.md "$MAX_PACKET_BYTES" 3<&9 \ + > "$BRIEF_SNAPSHOT_TMP" 2>/dev/null; then + [ -s "$BRIEF_SNAPSHOT_TMP" ] \ + || { echo "error: secondmate charter is empty for continuation of $ID" >&2; exit 1; } + else + charter_status=$? + if [ "$charter_status" -eq 3 ]; then + rm -f "$BRIEF_SNAPSHOT_TMP" + BRIEF_SNAPSHOT_TMP= + else + echo "error: secondmate charter is present but unsafe for continuation of $ID" >&2 + exit 1 + fi + fi +fi + git_pinned() { python3 "$SCRIPT_DIR/fm-contained-read.py" git-fd "$@" 3<&9 } @@ -293,24 +313,7 @@ if ! node "$CONTAINED_READ" snapshot "$TASK_DIR" "$TASK_SNAPSHOT_DIR" 1048576 "$ exit 1 fi -if [ "$KIND" = secondmate ]; then - BRIEF_SNAPSHOT_TMP=$(mktemp "$STATE/.continuation-brief-$ID.XXXXXX") \ - || { echo "error: cannot stage original brief or charter for continuation of $ID" >&2; exit 1; } - if python3 "$SCRIPT_DIR/fm-contained-read.py" cat-optional-fd data/charter.md "$MAX_PACKET_BYTES" 3<&9 \ - > "$BRIEF_SNAPSHOT_TMP" 2>/dev/null; then - [ -s "$BRIEF_SNAPSHOT_TMP" ] \ - || { echo "error: secondmate charter is empty for continuation of $ID" >&2; exit 1; } - else - charter_status=$? - if [ "$charter_status" -eq 3 ]; then - rm -f "$BRIEF_SNAPSHOT_TMP" - BRIEF_SNAPSHOT_TMP="$TASK_SNAPSHOT_DIR/0.snapshot" - else - echo "error: secondmate charter is present but unsafe for continuation of $ID" >&2 - exit 1 - fi - fi -else +if [ "$KIND" != secondmate ] || [ -z "$BRIEF_SNAPSHOT_TMP" ]; then BRIEF_SNAPSHOT_TMP="$TASK_SNAPSHOT_DIR/0.snapshot" fi [ -s "$BRIEF_SNAPSHOT_TMP" ] \ diff --git a/bin/fm-account-directory.sh b/bin/fm-account-directory.sh index 3ba66b5462..72b9732831 100755 --- a/bin/fm-account-directory.sh +++ b/bin/fm-account-directory.sh @@ -2,6 +2,7 @@ # Select and prepare direct Claude or Codex account-directory launches. # Usage: # fm-account-directory.sh select +# fm-account-directory.sh check-credential # fm-account-directory.sh install-herdr-hook # fm-account-directory.sh prepare # @@ -21,8 +22,15 @@ # selects the first real account directory in stable bytewise sort order. # Selection prints only the chosen absolute account home on stdout and logs # health, fallback, and choice diagnostics on stderr. -# prepare selects the account and idempotently runs Herdr's own integration -# installer with CODEX_HOME or CLAUDE_CONFIG_DIR set to the chosen home. +# prepare selects the account, verifies a usable credential before endpoint +# creation can begin, and idempotently runs Herdr's own integration installer +# with CODEX_HOME or CLAUDE_CONFIG_DIR set to the chosen home. +# Codex uses a cheap read-only auth.json credential-material check. +# Claude has no sufficient on-disk credential marker on macOS, so it uses the +# CLI's local `auth status --json` check, bounded to two seconds and never making +# a model call. +# A failed check names the selected account home and prints the exact scoped +# provider login command a human can run. # It verifies the installed per-profile hook before printing the chosen home. # # Credential state is read-only. @@ -34,7 +42,7 @@ set -u TEST_LAB_TOKEN=firstmate-account-directory-test-lab-v1 usage() { - sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//' >&2 + sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' >&2 } log() { @@ -45,6 +53,12 @@ test_lab_enabled() { [ "${FM_ACCOUNT_DIRECTORY_TEST_LAB:-}" = "$TEST_LAB_TOKEN" ] } +shell_quote() { + printf "'" + printf '%s' "$1" | sed "s/'/'\\\\''/g" + printf "'" +} + system_perl() { if test_lab_enabled && [ -n "${FM_ACCOUNT_DIRECTORY_PERL_BIN:-}" ]; then printf '%s\n' "$FM_ACCOUNT_DIRECTORY_PERL_BIN" @@ -203,6 +217,108 @@ valid_account_home() { # esac } +provider_binary() { # + local vendor=$1 account_home=$2 override manifest binary install_path home perl_bin + if test_lab_enabled; then + case "$vendor" in + claude) override=${FM_ACCOUNT_DIRECTORY_CLAUDE_BIN:-} ;; + codex) override=${FM_ACCOUNT_DIRECTORY_CODEX_BIN:-} ;; + *) return 1 ;; + esac + if [ -n "$override" ]; then + [ -f "$override" ] && [ ! -L "$override" ] && [ -x "$override" ] || { + echo "error: test $vendor provider binary is not a real executable: $override" >&2 + return 1 + } + printf '%s\n' "$override" + return 0 + fi + fi + manifest=$account_home/.agent-fleet-provider-binary.json + if [ -f "$manifest" ] && [ ! -L "$manifest" ]; then + binary=$(jq -er '.binary.resolved_path | select(type == "string" and startswith("/"))' "$manifest" 2>/dev/null) || binary= + if [ -n "$binary" ] && [ -f "$binary" ] && [ ! -L "$binary" ] && [ -x "$binary" ]; then + printf '%s\n' "$binary" + return 0 + fi + fi + home=$(passwd_home) || return 1 + install_path=$home/.local/bin/$vendor + perl_bin=$(system_perl) || return 1 + # shellcheck disable=SC2016 # Perl source is intentionally single-quoted. + binary=$("$perl_bin" -MCwd=realpath -e ' + my $path = realpath($ARGV[0]); + exit 1 unless defined $path && $path =~ m{^/} && $path !~ /[\x00-\x1f\x7f]/; + print $path; + ' "$install_path" 2>/dev/null) || binary= + if [ -n "$binary" ] && [ -f "$binary" ] && [ ! -L "$binary" ] && [ -x "$binary" ]; then + printf '%s\n' "$binary" + return 0 + fi + echo "error: cannot resolve the $vendor provider binary for account credential verification at $account_home" >&2 + return 1 +} + +credential_login_command() { # [provider-binary] + local vendor=$1 account_home=$2 provider_bin=${3:-$1} + case "$vendor" in + codex) + printf 'CODEX_HOME=%s %s login' "$(shell_quote "$account_home")" "$(shell_quote "$provider_bin")" + ;; + claude) + printf 'CLAUDE_CONFIG_DIR=%s %s auth login' "$(shell_quote "$account_home")" "$(shell_quote "$provider_bin")" + ;; + esac +} + +check_codex_credential() { # + local account_home=$1 credential provider_bin login + credential=$account_home/auth.json + provider_bin=$(provider_binary codex "$account_home" 2>/dev/null || printf 'codex') + login=$(credential_login_command codex "$account_home" "$provider_bin") + if [ ! -f "$credential" ] || [ -L "$credential" ] || ! jq -e ' + ((.tokens.access_token? | type) == "string" and (.tokens.access_token | length) > 0 + and (.tokens.refresh_token? | type) == "string" and (.tokens.refresh_token | length) > 0) + or ((.OPENAI_API_KEY? | type) == "string" and (.OPENAI_API_KEY | length) > 0) + ' "$credential" >/dev/null 2>&1; then + echo "error: selected codex account directory '$account_home' has no usable on-disk credential; run: $login" >&2 + return 1 + fi +} + +check_claude_credential() { # + local account_home=$1 provider_bin status_json login + provider_bin=$(provider_binary claude "$account_home") || return 1 + login=$(credential_login_command claude "$account_home" "$provider_bin") + status_json=$(run_bounded 2 /usr/bin/env CLAUDE_CONFIG_DIR="$account_home" \ + "$provider_bin" auth status --json 2>/dev/null) || status_json= + if ! jq -e '.loggedIn == true' >/dev/null 2>&1 <&2 + return 1 + fi +} + +check_credential() { # + local vendor=$1 account_home=$2 root vendor_dir + root=$(account_root) || return 1 + vendor_dir=$root/$vendor + valid_account_home "$vendor_dir" "$account_home" || { + echo "error: unsafe $vendor account home for credential verification: $account_home" >&2 + return 1 + } + case "$vendor" in + codex) check_codex_credential "$account_home" ;; + claude) check_claude_credential "$account_home" ;; + *) + echo "error: account credential verification supports only claude or codex, not '$vendor'" >&2 + return 1 + ;; + esac +} + first_account_home() { # local vendor=$1 root vendor_dir candidate root=$(account_root) || return 1 @@ -381,6 +497,10 @@ case "${1:-}" in [ "$#" -eq 2 ] || { usage; exit 2; } select_account "$2" ;; + check-credential) + [ "$#" -eq 3 ] || { usage; exit 2; } + check_credential "$2" "$3" + ;; install-herdr-hook) [ "$#" -eq 3 ] || { usage; exit 2; } install_herdr_hook "$2" "$3" @@ -388,6 +508,7 @@ case "${1:-}" in prepare) [ "$#" -eq 2 ] || { usage; exit 2; } selected_home=$(select_account "$2") || exit 1 + check_credential "$2" "$selected_home" || exit 1 install_herdr_hook "$2" "$selected_home" || exit 1 printf '%s\n' "$selected_home" ;; diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index eb0ba38faf..e443e5468f 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -223,7 +223,18 @@ EOF ) fi +BROWSER_SECTION=$(cat <<'EOF' +# Browser automation safety +Never set `CHROME_DEVTOOLS_AXI_AUTO_CONNECT=1` or `CHROME_DEVTOOLS_AXI_HEADED=1`. +Always set your own `CHROME_DEVTOOLS_AXI_SESSION`. +When an authenticated dashboard is needed, set `CHROME_DEVTOOLS_AXI_USER_DATA_DIR` to a per-crewmate profile directory; the persistent profile keeps its login cookie across headless runs. +Never use `open -a "Google Chrome"` or AppleScript that focuses an app. +If a check genuinely cannot run headlessly, report the exact step that fails instead of falling back to a visible or auto-connected browser. +EOF +) + if [ "$KIND" = scout ]; then +REPORT_HEADINGS=$(fm_completion_report_required_headings) cat > "$BRIEF" < - local data=$1 task=$2 + local data=$1 task=$2 headings + headings=$(fm_completion_report_required_headings) printf '%s\n' \ '# Completion report' \ - "Before the final \`done:\` status, write \`$data/$task/completion.md\` with these six sections, each as a LEVEL-TWO markdown heading spelled exactly: \`## Summary\`, \`## What changed\`, \`## Verification\`, \`## Visual evidence\`, \`## Artifacts\`, \`## Follow-ups\`." \ - "Publication rejects the report if any of those headings is missing, spelled differently, or written at another level - a level-one \`# Summary\` fails. Each section needs substantive prose directly under it; if you use sub-headings inside a section, make them \`###\` so they nest rather than ending the section." \ + "Before the final \`done:\` status, write \`$data/$task/completion.md\` with these six exact level-two headings in this order: $headings." \ + "Keep all six at level two; publication also accepts a report that consistently uses the level-one equivalents as its section structure, but do not mix heading levels. Each section needs substantive prose directly under it; if you use sub-headings inside a section, make them \`###\` so they nest rather than ending the section." \ 'When a section genuinely does not apply, say so in a sentence under the heading rather than omitting the heading.' \ 'Make it stand alone for the captain: explain the outcome, name important files or links, record the validation performed, and call out remaining risk or decisions.' \ "Put screenshots, diagrams, or other visual artifacts under \`$data/$task/visuals/\` and reference them from the report when they materially help review." \ diff --git a/bin/fm-report-stack.mjs b/bin/fm-report-stack.mjs index de47121baa..accf86a775 100755 --- a/bin/fm-report-stack.mjs +++ b/bin/fm-report-stack.mjs @@ -123,12 +123,47 @@ function lastStatus(status) { const { markdownStructure } = markdownModule; +function completionSectionStructure(markdown, options = {}) { + const structure = markdownStructure(markdown, options); + const requiredIndex = new Map(requiredSections.map((section, index) => [section.toLowerCase(), index])); + const headings = structure + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => entry.heading?.level <= 2 && requiredIndex.has(entry.heading.content.toLowerCase())); + const level = headings[0]?.entry.heading.level; + const mixedLevels = headings.some(({ entry }) => entry.heading.level !== level); + let lastRequiredIndex = -1; + let outOfOrder = false; + for (const { entry } of headings) { + const index = requiredIndex.get(entry.heading.content.toLowerCase()); + if (index <= lastRequiredIndex) outOfOrder = true; + lastRequiredIndex = index; + } + const firstHeadingIndex = headings[0]?.index; + const lastHeadingIndex = headings[headings.length - 1]?.index; + const interrupted = level === 2 && structure.some(({ heading }, index) => ( + index > firstHeadingIndex + && index < lastHeadingIndex + && heading?.level === 1 + )); + return { + structure, + level, + mixedLevels, + outOfOrder, + interrupted, + }; +} + function firstSummary(markdown, fallback) { - const structure = markdownStructure(markdown); - const summaryStart = structure.findIndex(({ heading }) => heading?.level === 2 && heading.content.toLowerCase() === "summary"); + const { structure, level } = completionSectionStructure(markdown); + const summaryStart = structure.findIndex(({ heading }) => ( + heading && heading.level === level && heading.content.toLowerCase() === "summary" + )); let summaryLines; if (summaryStart >= 0) { - const followingHeading = structure.findIndex(({ heading }, index) => index > summaryStart && heading?.level === 2); + const followingHeading = structure.findIndex(({ heading }, index) => ( + index > summaryStart && heading?.level <= level + )); summaryLines = structure.slice(summaryStart + 1, followingHeading < 0 ? undefined : followingHeading).map(({ line }) => line); } else { summaryLines = structure.map(({ line }) => line); @@ -145,10 +180,19 @@ function firstSummary(markdown, fallback) { function requireCompletionSections(markdown, sourceFile, taskId) { const sections = new Map(requiredSections.map((section) => [section.toLowerCase(), { present: false, body: [] }])); + const { + structure, + level, + mixedLevels, + outOfOrder, + interrupted, + } = completionSectionStructure(markdown, { includeFenceContent: true }); let currentSection; - for (const entry of markdownStructure(markdown, { includeFenceContent: true })) { - if (entry.heading?.level === 2) { - currentSection = sections.get(entry.heading.content.toLowerCase()); + for (const entry of structure) { + if (entry.heading?.level <= level) { + currentSection = entry.heading.level === level + ? sections.get(entry.heading.content.toLowerCase()) + : undefined; if (currentSection) currentSection.present = true; } else if (currentSection) { currentSection.body.push(entry); @@ -169,16 +213,20 @@ function requireCompletionSections(markdown, sourceFile, taskId) { const state = sections.get(section.toLowerCase()); return state.present && !state.body.some(substantive); }); - if (missing.length === 0 && empty.length === 0) return; + if (missing.length === 0 && empty.length === 0 && !mixedLevels && !outOfOrder && !interrupted) return; const required = requiredSections.map((section) => `## ${section}`).join(", "); const absent = missing.map((section) => `## ${section}`).join(", "); const blank = empty.map((section) => `## ${section}`).join(", "); const problems = []; if (missing.length > 0) problems.push(`missing required section headings: ${absent}`); if (empty.length > 0) problems.push(`required sections have no substantive content: ${blank}`); + if (mixedLevels || interrupted) { + problems.push("required section headings do not share one top structural level (# or ##)"); + } + if (outOfOrder) problems.push("required section headings are out of order"); throw new Error( `completion report at ${sourceFile} ${problems.join("; ")}. ` - + `Update ${sourceFile} to include these level-two headings with substantive content: ${required}. ` + + `Update ${sourceFile} to include these headings at one common top structural level, in order, with substantive content (level two recommended): ${required}. ` + `Then rerun ${fmRoot}/bin/fm-report-stack.mjs publish ${taskId} or ${fmRoot}/bin/fm-teardown.sh ${taskId}. ` + "This attempt did not replace the durable report, and teardown remains stopped before destructive cleanup.", ); diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 7ad920b052..fef80bf353 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -20,6 +20,12 @@ # Tune with FM_SEND_RETRIES (default 3) / FM_SEND_SLEEP (0.4). # Slash commands, and codex `$...` skill invocations resolved through harness # meta, get a longer pre-Enter settle so completion popups do not swallow Enter. +# Before that shared settle path, a bare installed-skill token is refused when +# its `/` or `$` prefix conflicts with the resolved harness form recorded in +# .agents/skills/harness-adapters/SKILL.md. Refusal is deliberate: silently +# rewriting would hide a supervisor caller bug. Ordinary slash commands, +# `$`-prefixed prose, multi-token messages, keys, and unknown-harness explicit +# targets keep their existing behavior. # # From-firstmate marker: when the resolved target is a task selector whose meta # records kind=secondmate, the text is prefixed with the from-firstmate marker @@ -95,6 +101,60 @@ fm_send_count_colons() { # printf '%s' $(( ${#s} - ${#no_colons} )) } +fm_send_bare_skill_name() { # + local message=$1 name root + case "$message" in + /*|\$*) ;; + *) return 1 ;; + esac + case "$message" in + *[[:space:]]*) return 1 ;; + esac + name=${message:1} + case "$name" in + ''|*[!A-Za-z0-9._-]*) return 1 ;; + esac + case "$name" in + no-mistakes) + printf '%s' "$name" + return 0 + ;; + esac + for root in \ + "$FM_HOME/.agents/skills" \ + "$FM_ROOT/.agents/skills" \ + "$FM_ROOT/skills" \ + "${HOME:-}/.agents/skills" \ + "${HOME:-}/.claude/skills" \ + "${HOME:-}/.codex/skills"; do + [ -n "$root" ] || continue + [ -d "$root/$name" ] || continue + printf '%s' "$name" + return 0 + done + return 1 +} + +fm_send_validate_skill_form() { # + local harness=$1 message=$2 skill expected corrected + skill=$(fm_send_bare_skill_name "$message") || return 0 + case "$harness" in + claude|grok) expected=/ ;; + codex) expected='$' ;; + opencode|pi) + echo "error: refusing bare skill invocation '$message' for harness '$harness'; it has no verified bare skill form, so send natural language such as 'Run the $skill skill.'" >&2 + return 1 + ;; + *) return 0 ;; + esac + case "$message" in + "$expected"*) return 0 ;; + esac + corrected=$expected$skill + echo "error: refusing bare skill invocation '$message' for harness '$harness'; use '$corrected'" >&2 + return 1 +} + fm_send_resolve_target() { # local raw=$1 meta pane_meta target backend assumed colons id session hint @@ -188,6 +248,11 @@ shift fm_backend_validate "$TARGET_BACKEND" || exit 1 +if [ "${1:-}" != "--key" ]; then + MESSAGE=$* + fm_send_validate_skill_form "$TARGET_HARNESS" "$MESSAGE" || exit 1 +fi + # Classify a from-firstmate -> secondmate request. Only a task selector resolved # through this home's meta whose authoritative kind is secondmate is marked: the # secondmate then routes its reply via the status path (see fm-marker-lib.sh). @@ -198,10 +263,10 @@ if [ -n "$TARGET_SELECTOR" ] && [ -n "$TARGET_META" ] && [ "$(fm_meta_get "$TARG MARK_FROM_FIRSTMATE=1 fi -# Resolve the target's harness from its meta (recorded by fm-spawn), used only to -# scope the codex `$` popup-settle below. A task selector carries -# meta; an explicit backend-target escape hatch has none, so its harness is -# unknown and treated as non-codex (the safe default that keeps the fast path). +# The target's harness came from its meta (recorded by fm-spawn), and scopes both +# the bare-skill form guard above and the codex `$` popup-settle below. +# A task selector carries meta; an explicit backend-target escape hatch has none, +# so its harness is unknown and retains the existing unguarded fast path. # The target's BACKEND comes from selector meta, from matching an explicit target # back to recorded meta, or from strict explicit-target shape validation. # Do not add a separate passive liveness preflight here. Active send paths own @@ -257,7 +322,6 @@ if [ "${1:-}" = "--key" ]; then fi fi else - MESSAGE=$* if [ "$MARK_FROM_FIRSTMATE" = 1 ]; then fm_message_mark_from_firstmate "$MESSAGE" MESSAGE fi diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index e66d231827..68f872ce31 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -697,6 +697,7 @@ if [ "$RECOVERY_ACCOUNT" = 1 ]; then exit 1 fi KIND=$recorded_kind + RECOVERY_RECORDED_KIND=$recorded_kind if [ "$DIRECT_ACCOUNT_RECOVERY" = 1 ]; then case "$KIND" in ship|scout) ;; @@ -1932,8 +1933,7 @@ if [ -e "$STATE/$ID.grok-turnend-token" ] || [ -L "$STATE/$ID.grok-turnend-token if [ -e "/tmp/fm-$ID" ] || [ -L "/tmp/fm-$ID" ]; then ORIGINAL_TASK_TMP_PRESENT=1; else ORIGINAL_TASK_TMP_PRESENT=0; fi if [ "$RECOVERY_ACCOUNT" = 1 ]; then - RECORDED_KIND=$(fm_meta_get "$RESUME_META" kind) - [ -n "$RECORDED_KIND" ] || RECORDED_KIND=ship + RECORDED_KIND=$RECOVERY_RECORDED_KIND if [ "$KIND" != ship ] && [ "$KIND" != "$RECORDED_KIND" ]; then echo "error: account recovery kind '$KIND' does not match recorded kind '$RECORDED_KIND'" >&2 exit 1 @@ -2629,7 +2629,7 @@ else [ -n "$PROJ_ABS" ] && [ -d "$PROJ_ABS" ] || { echo "error: recorded project is unavailable for managed recovery: ${PROJ_ABS:-}" >&2; exit 1; } [ -n "$WT" ] && [ -d "$WT" ] || { echo "error: recorded worktree is unavailable for managed recovery: ${WT:-}" >&2; exit 1; } else - PROJ_ABS="$(cd "$(resolve_project_dir_arg "$PROJ")" && pwd)" + PROJ_ABS="$(cd "$(resolve_project_dir_arg "$PROJ")" && pwd -P)" WT="" fi fi diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index ea03eda10b..7c073e610d 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1849,6 +1849,7 @@ if not stat.S_ISDIR(os.lstat(base).st_mode): print(base) PY ) || return 1 + [ -e "$base/fm-$ID" ] || [ -L "$base/fm-$ID" ] || return 0 removal_tree_operation "$base/fm-$ID" "task temp root" remove } diff --git a/bin/fm-watch-checkpoint.sh b/bin/fm-watch-checkpoint.sh index d754301fa4..f016226419 100755 --- a/bin/fm-watch-checkpoint.sh +++ b/bin/fm-watch-checkpoint.sh @@ -88,13 +88,13 @@ run_with_perl_timeout() { set +e if command -v timeout >/dev/null 2>&1; then - timeout "$SECONDS_ARG" "$SCRIPT_DIR/fm-watch.sh" >"$OUT" 2>"$ERR" + FM_WATCH_CHECKPOINT=1 timeout "$SECONDS_ARG" "$SCRIPT_DIR/fm-watch.sh" >"$OUT" 2>"$ERR" RC=$? elif command -v gtimeout >/dev/null 2>&1; then - gtimeout "$SECONDS_ARG" "$SCRIPT_DIR/fm-watch.sh" >"$OUT" 2>"$ERR" + FM_WATCH_CHECKPOINT=1 gtimeout "$SECONDS_ARG" "$SCRIPT_DIR/fm-watch.sh" >"$OUT" 2>"$ERR" RC=$? else - run_with_perl_timeout >"$OUT" 2>"$ERR" + FM_WATCH_CHECKPOINT=1 run_with_perl_timeout >"$OUT" 2>"$ERR" RC=$? fi set -e diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 9b4495edb6..c9d21bc9d2 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -923,7 +923,9 @@ while :; do # alive. Supervision scripts warn when this goes stale with tasks in flight. safe_touch_marker_or_log "$STATE/.last-watcher-beat" "watcher beacon" || true - prune_reports_if_due + if [ "${FM_WATCH_CHECKPOINT:-0}" != 1 ]; then + prune_reports_if_due + fi # A managed provider's SessionStart hook may race the initial spawn return. # Reconcile only metas still missing provider_session_id; failures stay diff --git a/docs/configuration.md b/docs/configuration.md index ba90b4a845..c9d0027548 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -173,6 +173,7 @@ The selected provider command receives `CLAUDE_CONFIG_DIR=` or `CODEX_HOME New ship/scout launches never ask Agent Fleet to enable a profile, establish identity, install a bundle, or acquire a lease. They invoke Herdr's own integration installer against the selected profile directory and verify its per-profile hook file before launching. Account credentials remain captain-owned and read-only to Firstmate; selection never authenticates, logs in, or invokes a model. +Before installing that hook or creating an endpoint, spawn preflight fails closed when the selected directory lacks a usable credential and prints the exact provider-scoped login command for a human. Codex health and usage are genuinely readable per account. Every selection performs a fresh per-account quota read instead of trusting a prior cache, and a Codex directory with no fresh general usage window is skipped. diff --git a/docs/report-stack.md b/docs/report-stack.md index 947f486422..f9df53041b 100644 --- a/docs/report-stack.md +++ b/docs/report-stack.md @@ -19,7 +19,9 @@ Expired entries are renamed to deletion tombstones before the index changes, and New task metadata carries `report_required=1`. A ship task writes `data//completion.md`, while a scout keeps using `data//report.md`. Both may attach screenshots, diagrams, or other review artifacts under `data//visuals/`. -Every post-cutover ship and scout report must use the level-two sections Summary, What changed, Verification, Visual evidence, Artifacts, and Follow-ups. +Every post-cutover ship and scout report must use the sections Summary, What changed, Verification, Visual evidence, Artifacts, and Follow-ups in that order. +All six section headings must use one common top structural level: either level one, or level two beneath an optional level-one report title. +The brief scaffold requests the level-two form so reports are consistent, while publication accepts the level-one form because heading depth is formatting rather than substance. Every required section must contain substantive body content, with an explicit `None.` accepted when the section has nothing to report. Within a real required section, meaningful fenced transcript or literal-code lines count as body content, but an empty fence body, whitespace, a bare Markdown blockquote or list marker, or Unicode control and format characters alone do not. Fence delimiters never count as body content, and heading-like lines inside a fence never satisfy a required section heading. diff --git a/tests/fm-account-directory.test.sh b/tests/fm-account-directory.test.sh index 512c6db17a..dc07c90725 100755 --- a/tests/fm-account-directory.test.sh +++ b/tests/fm-account-directory.test.sh @@ -11,6 +11,7 @@ ACCOUNT_ROOT="$TMP_ROOT/accounts" FAKEBIN=$(fm_fakebin "$TMP_ROOT") QUOTA_LOG="$TMP_ROOT/quota.log" HERDR_LOG="$TMP_ROOT/herdr.log" +CLAUDE_AUTH_LOG="$TMP_ROOT/claude-auth.log" TREEHOUSE_LOG="$TMP_ROOT/treehouse.log" mkdir -p "$ACCOUNT_ROOT/codex" "$ACCOUNT_ROOT/claude" @@ -79,12 +80,29 @@ fi SH chmod +x "$FAKEBIN/herdr" +cat > "$FAKEBIN/claude" <<'SH' +#!/usr/bin/env bash +set -u +[ "${1:-}" = auth ] && [ "${2:-}" = status ] && [ "${3:-}" = --json ] || exit 64 +[ -n "${CLAUDE_CONFIG_DIR:-}" ] || exit 65 +printf '%s\n' "$CLAUDE_CONFIG_DIR" >> "$FM_FAKE_CLAUDE_AUTH_LOG" +if [ -f "$CLAUDE_CONFIG_DIR/test-authenticated" ]; then + printf '%s\n' '{"loggedIn":true,"authMethod":"claude.ai"}' + exit 0 +fi +printf '%s\n' '{"loggedIn":false,"authMethod":"none"}' +exit 1 +SH +chmod +x "$FAKEBIN/claude" + run_selector() { FM_ACCOUNT_DIRECTORY_TEST_LAB=firstmate-account-directory-test-lab-v1 \ FM_ACCOUNT_DIRECTORY_ROOT="$ACCOUNT_ROOT" \ FM_ACCOUNT_DIRECTORY_QUOTA_AXI="$FAKEBIN/quota-axi" \ FM_ACCOUNT_DIRECTORY_HERDR="$FAKEBIN/herdr" \ + FM_ACCOUNT_DIRECTORY_CLAUDE_BIN="$FAKEBIN/claude" \ FM_FAKE_QUOTA_LOG="$QUOTA_LOG" FM_FAKE_HERDR_LOG="$HERDR_LOG" \ + FM_FAKE_CLAUDE_AUTH_LOG="$CLAUDE_AUTH_LOG" \ "$SELECTOR" "$@" } @@ -93,6 +111,14 @@ set_remaining() { mkdir -p "$ACCOUNT_ROOT/codex/$account/.agent-fleet-quota-cache/quota-axi" printf '%s\n' "$remaining" > "$ACCOUNT_ROOT/codex/$account/test-remaining" printf '{"stale":true}\n' > "$ACCOUNT_ROOT/codex/$account/.agent-fleet-quota-cache/quota-axi/quotas.json" + printf '{"auth_mode":"chatgpt","tokens":{"access_token":"test-access","refresh_token":"test-refresh"}}\n' \ + > "$ACCOUNT_ROOT/codex/$account/auth.json" +} + +set_claude_authenticated() { + local account=$1 + mkdir -p "$ACCOUNT_ROOT/claude/$account" + : > "$ACCOUNT_ROOT/claude/$account/test-authenticated" } reset_accounts() { @@ -100,6 +126,7 @@ reset_accounts() { mkdir -p "$ACCOUNT_ROOT/codex" "$ACCOUNT_ROOT/claude" : > "$QUOTA_LOG" : > "$HERDR_LOG" + : > "$CLAUDE_AUTH_LOG" } test_codex_picks_highest_fresh_minimum_and_skips_no_window() { @@ -191,11 +218,33 @@ test_default_root_uses_passwd_home_not_ambient_home() { pass "default account discovery ignores ambient HOME and stays under the passwd home" } +test_claude_credential_check_recovers_from_stale_profile_binary_manifest() { + local account_home passwd_home + reset_accounts + account_home="$ACCOUNT_ROOT/claude/1" + passwd_home="$TMP_ROOT/credential-passwd-home" + set_claude_authenticated 1 + mkdir -p "$passwd_home/.local/bin" + ln -s "$FAKEBIN/claude" "$passwd_home/.local/bin/claude" + printf '{"binary":{"resolved_path":"/missing/claude"}}\n' \ + > "$account_home/.agent-fleet-provider-binary.json" + + FM_ACCOUNT_DIRECTORY_TEST_LAB=firstmate-account-directory-test-lab-v1 \ + FM_ACCOUNT_DIRECTORY_ROOT="$ACCOUNT_ROOT" \ + FM_ACCOUNT_DIRECTORY_PASSWD_HOME="$passwd_home" \ + FM_FAKE_CLAUDE_AUTH_LOG="$CLAUDE_AUTH_LOG" \ + "$SELECTOR" check-credential claude "$account_home" \ + || fail "Claude credential check did not recover from a stale pinned binary" + assert_grep "$account_home" "$CLAUDE_AUTH_LOG" \ + "Claude credential check did not scope the current provider binary to the selected account" + pass "Claude credential preflight resolves the current install when profile binary metadata is stale" +} + test_prepare_installs_and_verifies_per_account_herdr_hooks() { local codex_home claude_home reset_accounts set_remaining 1 90,80 - mkdir -p "$ACCOUNT_ROOT/claude/1" + set_claude_authenticated 1 codex_home=$(run_selector prepare codex 2>"$TMP_ROOT/prepare-codex.err") claude_home=$(run_selector prepare claude 2>"$TMP_ROOT/prepare-claude.err") @@ -206,6 +255,40 @@ test_prepare_installs_and_verifies_per_account_herdr_hooks() { pass "prepare uses Herdr's own installer and verifies each selected profile hook" } +test_prepare_rejects_missing_credentials_before_hook_install() { + local out status codex_home claude_home + + reset_accounts + set_remaining 1 90,80 + codex_home="$ACCOUNT_ROOT/codex/1" + rm -f "$codex_home/auth.json" + out=$(run_selector prepare codex 2>&1) + status=$? + expect_code 1 "$status" "Codex prepare without auth.json should fail closed" + assert_contains "$out" "selected codex account directory '$codex_home' has no usable on-disk credential" \ + "Codex credential refusal omitted the selected account directory" + assert_contains "$out" "CODEX_HOME='$codex_home'" \ + "Codex credential refusal omitted the exact scoped login command" + [ ! -s "$HERDR_LOG" ] || fail "Codex credential refusal still installed a Herdr hook" + + reset_accounts + claude_home="$ACCOUNT_ROOT/claude/1" + mkdir -p "$claude_home" + out=$(run_selector prepare claude 2>&1) + status=$? + expect_code 1 "$status" "Claude prepare without a usable login should fail closed" + assert_contains "$out" "selected claude account directory '$claude_home' has no usable credential" \ + "Claude credential refusal omitted the selected account directory" + assert_contains "$out" "CLAUDE_CONFIG_DIR='$claude_home'" \ + "Claude credential refusal omitted the exact scoped login command" + assert_contains "$out" "auth login" \ + "Claude credential refusal omitted the provider login command" + assert_grep "$claude_home" "$CLAUDE_AUTH_LOG" \ + "Claude credential preflight did not query the selected config directory" + [ ! -s "$HERDR_LOG" ] || fail "Claude credential refusal still installed a Herdr hook" + pass "prepare refuses unauthenticated account directories before hook installation" +} + make_spawn_fakebin() { local fakebin=$1 cat > "$fakebin/tmux" <<'SH' @@ -301,7 +384,9 @@ run_direct_spawn() { FM_ACCOUNT_DIRECTORY_ROOT="$ACCOUNT_ROOT" \ FM_ACCOUNT_DIRECTORY_QUOTA_AXI="$FAKEBIN/quota-axi" \ FM_ACCOUNT_DIRECTORY_HERDR="$FAKEBIN/herdr" \ + FM_ACCOUNT_DIRECTORY_CLAUDE_BIN="$FAKEBIN/claude" \ FM_FAKE_QUOTA_LOG="$QUOTA_LOG" FM_FAKE_HERDR_LOG="$HERDR_LOG" \ + FM_FAKE_CLAUDE_AUTH_LOG="$CLAUDE_AUTH_LOG" \ FM_AGENT_FLEET_BIN="$FAKEBIN/forbidden-agent-fleet" \ FM_FAKE_AGENT_FLEET_LOG="$TMP_ROOT/agent-fleet.log" \ "$ROOT/bin/fm-spawn.sh" "$@" @@ -364,7 +449,8 @@ test_spawn_uses_direct_claude_fallback_and_hook() { local record id out launch meta reset_accounts : > "$TMP_ROOT/agent-fleet.log" - mkdir -p "$ACCOUNT_ROOT/claude/2" "$ACCOUNT_ROOT/claude/1" + mkdir -p "$ACCOUNT_ROOT/claude/2" + set_claude_authenticated 1 id=direct-claude-z2 record=$(make_spawn_case direct-claude claude "$id") read_spawn_case "$record" @@ -382,6 +468,27 @@ test_spawn_uses_direct_claude_fallback_and_hook() { pass "new account-flagged Claude spawn uses deterministic CLAUDE_CONFIG_DIR with an explicit warning" } +test_spawn_refuses_unauthenticated_account_before_endpoint_creation() { + local record id out status + reset_accounts + mkdir -p "$ACCOUNT_ROOT/claude/1" + id=direct-claude-unauth-z2a + record=$(make_spawn_case direct-claude-unauth claude "$id") + read_spawn_case "$record" + + out=$(run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \ + "$id" "$SPAWN_PROJECT" --account-pool legacy-claude-pool 2>&1) + status=$? + expect_code 1 "$status" "unauthenticated direct Claude spawn should fail closed" + assert_contains "$out" "selected claude account directory '$ACCOUNT_ROOT/claude/1' has no usable credential" \ + "spawn credential refusal omitted the selected account directory" + assert_contains "$out" "auth login" "spawn credential refusal omitted the exact login command" + assert_absent "$SPAWN_HOME/state/.fake-endpoint" \ + "unauthenticated direct Claude spawn created an endpoint" + [ ! -s "$SPAWN_LAUNCH_LOG" ] || fail "unauthenticated direct Claude spawn typed a launch command" + pass "fm-spawn refuses an unauthenticated direct account before endpoint creation" +} + test_observe_spawn_uses_direct_directory_without_agent_fleet() { local record id out launch meta reset_accounts @@ -957,15 +1064,26 @@ if [ "${FM_TEST_FOCUSED:-}" = direct-recovery-lifecycle ]; then exit 0 fi +if [ "${FM_TEST_FOCUSED:-}" = credential-preflight ]; then + test_claude_credential_check_recovers_from_stale_profile_binary_manifest + test_prepare_installs_and_verifies_per_account_herdr_hooks + test_prepare_rejects_missing_credentials_before_hook_install + test_spawn_refuses_unauthenticated_account_before_endpoint_creation + exit 0 +fi + test_codex_picks_highest_fresh_minimum_and_skips_no_window test_codex_rechecks_health_on_every_selection test_codex_fails_when_no_account_has_a_fresh_window test_codex_timeout_skips_wedged_account test_claude_uses_stable_first_without_treating_usage_as_health test_default_root_uses_passwd_home_not_ambient_home +test_claude_credential_check_recovers_from_stale_profile_binary_manifest test_prepare_installs_and_verifies_per_account_herdr_hooks +test_prepare_rejects_missing_credentials_before_hook_install test_spawn_uses_direct_codex_home_without_agent_fleet test_spawn_uses_direct_claude_fallback_and_hook +test_spawn_refuses_unauthenticated_account_before_endpoint_creation test_observe_spawn_uses_direct_directory_without_agent_fleet test_direct_spawn_and_recovery_support_detached_worktree test_direct_recovery_preserves_recorded_task_context diff --git a/tests/fm-account-routing.test.sh b/tests/fm-account-routing.test.sh index 57e870f344..2d51920b24 100755 --- a/tests/fm-account-routing.test.sh +++ b/tests/fm-account-routing.test.sh @@ -17,6 +17,8 @@ TEARDOWN="$ROOT/bin/fm-teardown.sh" SESSION_SYNC="$ROOT/bin/fm-account-session-sync.sh" CONTINUATION="$ROOT/bin/fm-account-continuation.sh" TMP_ROOT=$(fm_test_tmproot fm-account-routing-tests) +REAL_GIT=$(command -v git) +export FM_TEST_REAL_GIT="$REAL_GIT" assert_not_grep() { local pattern=$1 file=$2 label=$3 @@ -187,6 +189,19 @@ case "${1:-}" in esac exit 0 SH + cat > "$fakebin/git" <<'SH' +#!/usr/bin/env bash +if [ -n "${FM_FAKE_LIVE_DEFAULT_BRANCH:-}" ] && [ -n "${FM_FAKE_LIVE_DEFAULT_TIP:-}" ]; then + case " $* " in + *" ls-remote --symref origin HEAD "*|*" ls-remote --symref origin HEAD") + printf 'ref: refs/heads/%s\tHEAD\n%s\tHEAD\n' "$FM_FAKE_LIVE_DEFAULT_BRANCH" "$FM_FAKE_LIVE_DEFAULT_TIP" + exit 0 + ;; + esac +fi +exec "$FM_TEST_REAL_GIT" "$@" +SH + chmod +x "$fakebin/git" chmod +x "$fakebin/tmux" cat > "$fakebin/treehouse" <<'SH' #!/usr/bin/env bash @@ -451,6 +466,9 @@ run_spawn() { FM_FAKE_TREEHOUSE_PATH="$WT_DIR" FM_TREEHOUSE_ROOT="$CASE_DIR/treehouse-pools" \ FM_FAKE_TREEHOUSE_SLEEP="${FM_FAKE_TREEHOUSE_SLEEP:-}" \ FM_FAKE_TREEHOUSE_RETURN_SLEEP="${FM_FAKE_TREEHOUSE_RETURN_SLEEP:-}" \ + FM_TEST_REAL_GIT="$REAL_GIT" \ + FM_FAKE_LIVE_DEFAULT_BRANCH="${FM_FAKE_LIVE_DEFAULT_BRANCH:-}" \ + FM_FAKE_LIVE_DEFAULT_TIP="${FM_FAKE_LIVE_DEFAULT_TIP:-}" \ FM_FAKE_TREEHOUSE_RETURN_CHILD_PID_FILE="${FM_FAKE_TREEHOUSE_RETURN_CHILD_PID_FILE:-}" \ FM_FAKE_TREEHOUSE_RETURN_MARKER="${FM_FAKE_TREEHOUSE_RETURN_MARKER:-}" \ FM_TEST_REAL_PS="${FM_TEST_REAL_PS:-}" \ @@ -478,6 +496,7 @@ run_teardown() { FM_FAKE_TREEHOUSE_LOG="$TREEHOUSE_LOG" FM_FAKE_ENDPOINT_FILE="$CASE_DIR/endpoint-live" \ FM_FAKE_TREEHOUSE_RETURN_SLEEP="${FM_FAKE_TREEHOUSE_RETURN_SLEEP:-}" \ FM_FAKE_TMUX_LABEL_FILE="$CASE_DIR/tmux-label" \ + FM_TEST_REAL_GIT="$REAL_GIT" \ FM_AGENT_FLEET_BIN="$FAKEBIN_DIR/agent-fleet" \ TMUX="fake,1,0" PATH="$FAKEBIN_DIR:$PATH" "$TEARDOWN" "$@" } @@ -676,7 +695,7 @@ test_changed_acquisition_is_retained_during_unmanaged_rollback() { FM_FAKE_TMUX_FAIL_SEND_MATCH=GOTMPDIR \ run_spawn "$id" "$PROJ_DIR" > "$out_file" & spawn_pid=$! - for _ in $(seq 1 100); do [ -f "$marker" ] && break; sleep 0.05; done + for _ in $(seq 1 300); do [ -f "$marker" ] && break; sleep 0.05; done [ -f "$marker" ] \ || { kill "$spawn_pid" 2>/dev/null || true; fail "changed-acquisition test never reached the post-install failure"; } printf '%s\n' retained-commit > "$WT_DIR/retained-commit.txt" @@ -2079,7 +2098,7 @@ make_seeded_secondmate_home() { "$id" "$home_abs" > "$HOME_DIR/data/secondmates.md" } -test_secondmate_pool_is_nonactivating_and_noninherited() { +test_pool_only_secondmate_stays_routing_off() { local id rec sm out status id=account-secondmate-off-z10 rec=$(make_case secondmate-off claude) @@ -2090,14 +2109,14 @@ test_secondmate_pool_is_nonactivating_and_noninherited() { out=$(FM_TEST_PANE_PATH="$sm" run_spawn "$id" "$sm" --secondmate) status=$? - [ "$status" -eq 0 ] || fail "pool-only secondmate spawn should stay legacy/off (exit $status): $out" - [ ! -s "$AF_LOG" ] || fail "secondmate pool activated Agent Fleet while routing was off: $(cat "$AF_LOG")" - assert_not_grep '^account_' "$HOME_DIR/state/$id.meta" "off secondmate wrote account metadata" + [ "$status" -eq 0 ] || fail "pool-only secondmate spawn should keep account routing off (exit $status): $out" + [ ! -s "$AF_LOG" ] || fail "pool-only secondmate spawn activated legacy Agent Fleet while routing was off: $(cat "$AF_LOG")" + assert_not_grep '^account_' "$HOME_DIR/state/$id.meta" "pool-only secondmate spawn entered an account-routing path" assert_absent "$sm/config/secondmate-account-pool" "secondmate account pool leaked into the child home" - pass "the primary secondmate pool is non-inherited and does not activate routing by itself" + pass "a primary secondmate pool stays non-inherited and does not activate routing by itself" } -test_secondmate_pool_routes_when_mode_is_enforced_and_mode_inherits() { +test_enabled_secondmate_uses_legacy_agent_fleet_not_direct_account_directory() { local id rec sm out status id=account-secondmate-enforce-z11 rec=$(make_case secondmate-enforce claude) @@ -2113,12 +2132,12 @@ test_secondmate_pool_routes_when_mode_is_enforced_and_mode_inherits() { run_spawn "$id" "$sm" --secondmate) status=$? [ "$status" -eq 0 ] || fail "enforced secondmate spawn should succeed (exit $status): $out" - assert_regex "lease choose --pool claude-captains --task .*-$id-.* --provider claude" "$AF_LOG" "secondmate did not use its primary-owned account pool" + assert_regex "lease choose --pool claude-captains --task .*-$id-.* --provider claude" "$AF_LOG" "enabled secondmate routing did not use legacy Agent Fleet" assert_grep 'account_pool=claude-captains' "$HOME_DIR/state/$id.meta" "secondmate meta lost its account pool" - assert_not_grep '^account_home=' "$HOME_DIR/state/$id.meta" "secondmate entered direct account-directory routing" + assert_not_grep '^account_home=' "$HOME_DIR/state/$id.meta" "enabled secondmate routing silently entered the direct account-directory path" [ "$(cat "$sm/config/account-routing-mode" 2>/dev/null)" = enforce ] || fail "account routing mode did not inherit into the secondmate home" assert_absent "$sm/config/secondmate-account-pool" "primary-only secondmate pool leaked into the child home" - pass "secondmate routing uses the primary pool while the mode, but not that pool, inherits" + pass "enabled secondmate routing uses legacy Agent Fleet and never direct account directories" } test_explicit_secondmate_route_preserves_ambient_primary_enforce() { @@ -3198,7 +3217,7 @@ test_continuation_fails_closed_without_original_brief() { clear_case_logs out=$(FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$HOME_DIR/state" \ FM_DATA_OVERRIDE="$HOME_DIR/data" FM_FAKE_ENDPOINT_FILE="$CASE_DIR/endpoint-live" \ - FM_FAKE_TMUX_LOG="$TMUX_LOG" PATH="$FAKEBIN_DIR:$PATH" \ + FM_FAKE_TMUX_LOG="$TMUX_LOG" FM_TEST_REAL_GIT="$REAL_GIT" PATH="$FAKEBIN_DIR:$PATH" \ "$CONTINUATION" "$id" missing-brief 2>&1) status=$? [ "$status" -ne 0 ] || fail "continuation without original brief unexpectedly succeeded" @@ -6060,7 +6079,8 @@ if [ "${FM_TEST_FOCUSED:-}" = explicit-secondmate-route ]; then fi if [ "${FM_TEST_FOCUSED:-}" = secondmate-direct-scope ]; then - run_isolated_test test_secondmate_pool_routes_when_mode_is_enforced_and_mode_inherits + run_isolated_test test_pool_only_secondmate_stays_routing_off + run_isolated_test test_enabled_secondmate_uses_legacy_agent_fleet_not_direct_account_directory exit 0 fi @@ -6357,8 +6377,8 @@ run_isolated_test test_native_resume_migrates_legacy_session_mapping_after_launc run_isolated_test test_native_resume_rejects_zero_sequence_on_current_schema run_isolated_test test_native_resume_accepts_agent_fleet_utc_offset_timestamps run_isolated_test test_native_resume_uses_private_launch_directory_and_cleans_it -run_isolated_test test_secondmate_pool_is_nonactivating_and_noninherited -run_isolated_test test_secondmate_pool_routes_when_mode_is_enforced_and_mode_inherits +run_isolated_test test_pool_only_secondmate_stays_routing_off +run_isolated_test test_enabled_secondmate_uses_legacy_agent_fleet_not_direct_account_directory run_isolated_test test_explicit_secondmate_route_preserves_ambient_primary_enforce run_isolated_test test_enforced_secondmate_requires_routing_inheritance_and_capable_home run_isolated_test test_secondmate_routing_inheritance_is_authoritative_for_every_mode diff --git a/tests/fm-backend-zellij.test.sh b/tests/fm-backend-zellij.test.sh index 7aef69fa4a..f49d9ca133 100755 --- a/tests/fm-backend-zellij.test.sh +++ b/tests/fm-backend-zellij.test.sh @@ -908,21 +908,33 @@ test_kill_is_noop_when_session_absent() { } test_teardown_passes_recorded_tab_id_to_zellij_kill() { - local dir state data config project fb out status - dir="$TMP_ROOT/teardown-zellij-ghost"; state="$dir/state"; data="$dir/data"; config="$dir/config"; project="$dir/project" - mkdir -p "$state" "$data/zghost" "$config" "$project" "$dir/responses" + local dir state data config project worktree fb out status + dir="$TMP_ROOT/teardown-zellij-ghost"; state="$dir/state"; data="$dir/data"; config="$dir/config"; project="$dir/project"; worktree="$dir/pool/1/project" + mkdir -p "$state" "$data/zghost" "$config" "$project" "$dir/pool/1" "$dir/responses" + fm_git_worktree "$project" "$worktree" zghost-worktree + printf '{"worktrees":[{"name":"1","path":"%s","leased":true,"lease_holder":"firstmate-zghost"}]}\n' \ + "$worktree" > "$dir/pool/treehouse-state.json" printf 'report\n' > "$data/zghost/report.md" fm_write_meta "$state/zghost.meta" \ "window=firstmate:7" \ "backend=zellij" \ "zellij_tab_id=3" \ - "worktree=$dir/missing-worktree" \ + "worktree=$worktree" \ "project=$project" \ "kind=scout" printf '[]\n' > "$dir/responses/1.out" printf '[{"tab_id":3,"name":"fm-zghost"}]\n' > "$dir/responses/2.out" fb=$(make_zellij_fakebin "$dir") - out=$( PATH="$fb:$PATH" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ + cat > "$fb/treehouse" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = return ]; then + target=${!#} + git -C "$target" worktree remove --force "$target" +fi +exit 0 +SH + chmod +x "$fb/treehouse" + out=$( PATH="$fb:$PATH" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" FM_TREEHOUSE_ROOT="$dir" \ FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" FM_ZELLIJ_SESSION_LIST="firstmate" \ "$ROOT/bin/fm-teardown.sh" zghost 2>&1 ) status=$? diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index a09459b60c..97a76d9caa 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -983,7 +983,11 @@ esac exit 0 SH chmod +x "$fb/tmux" - fm_fake_exit0 "$fb" treehouse + cat > "$fb/treehouse" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "${FM_FAKE_TREEHOUSE_PATH:?}" +SH + chmod +x "$fb/treehouse" printf '%s\n' "$fb" } @@ -994,6 +998,8 @@ run_spawn_case() { # env PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$bin" \ FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" \ + FM_TREEHOUSE_ROOT="${FM_TREEHOUSE_ROOT:-}" \ + FM_FAKE_TREEHOUSE_PATH="${FM_FAKE_TREEHOUSE_PATH:-}" \ FM_SPAWN_NO_GUARD=1 TMUX="fake,1,0" FM_TMUX_LOG="$log" \ "$bin/bin/fm-spawn.sh" "$@" } @@ -1082,10 +1088,10 @@ run_spawn_symlink_case() { #