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/backends/herdr.sh b/bin/backends/herdr.sh index abe02947a8..2bcc90e040 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -2667,6 +2667,7 @@ fm_backend_herdr_server_reachable_for_readsteer() { # running=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null \ | fm_backend_herdr_control_jq -r '.server.running // false' 2>/dev/null) [ "$running" = true ] || return 1 + fm_backend_herdr_server_certificate_required || return 0 fm_backend_herdr_server_adapter_owned "$session" } diff --git a/bin/fm-account-directory.sh b/bin/fm-account-directory.sh index 3ba66b5462..ac1548b2b6 100755 --- a/bin/fm-account-directory.sh +++ b/bin/fm-account-directory.sh @@ -2,6 +2,8 @@ # 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 provider-command claude # fm-account-directory.sh install-herdr-hook # fm-account-directory.sh prepare # @@ -14,15 +16,72 @@ # accepts only a fresh result with at least one numeric five_hour or weekly # window, and picks the account with the highest minimum remaining percentage. # A Codex account with no such freshly readable window is skipped as unhealthy. -# Claude quota is not currently distinguishable per config directory because -# quota-axi cannot non-interactively resolve Claude's config-dir-specific macOS -# Keychain credential. -# Claude therefore never treats a missing usage window as account failure and -# selects the first real account directory in stable bytewise sort order. +# Claude authentication is checked with this script's +# `check-credential claude` command. +# That is the one authoritative definition of a usable Claude account directory +# for selection and spawn preflight. +# The check clears ambient provider credentials, sets CLAUDE_CONFIG_DIR, and +# invokes the same passwd-home launcher that a direct crew launch receives: +# ~/.local/bin/claude. +# Using the stable launcher symlink survives native Claude Code upgrades and +# avoids PATH wrappers that unset CLAUDE_CONFIG_DIR. +# A usable directory must make `claude auth status` return valid JSON with +# loggedIn=true before the bounded check expires. +# Claude selection skips unusable directories and chooses the first usable +# directory in stable bytewise sort order. +# Claude quota is still not distinguishable per config directory, so selection +# never treats a missing usage window as account failure. +# +# macOS credential mechanism, verified 2026-07-26 with Claude Code 2.1.220: +# a custom config directory stores OAuth JSON in the login Keychain service +# `Claude Code-credentials-`. +# The incident's broken account homes had service entries whose account +# attribute was the passwd username; a newly authenticated working custom home +# used account `unknown`. +# Both item shapes had the same ACL, and `/usr/bin/security ... -w` could read +# both without prompting, so application ACL or versioned-binary ancestry was +# not the failure. +# The real CLI accepted every broken item's access token through the transient +# CLAUDE_CODE_OAUTH_TOKEN environment, proving the stored payloads were valid; +# it could not discover those same payloads through its native Keychain lookup. +# Updating a stale item during interactive login left its old account attribute +# in place, which explains why that process worked until the next spawn. +# +# `claude setup-token` was also evaluated. +# Claude Code honors its one-year, inference-only token through +# CLAUDE_CODE_OAUTH_TOKEN, but the command prints rather than stores the secret. +# Firstmate does not select that route because persisting and injecting the +# secret would add a second credential store and could expose it through launch +# arguments, environment capture, or account-home files. +# A clean native `claude auth login` is the selected durable mechanism because +# Claude owns storage and refresh, and Firstmate never handles the secret. +# +# Set up a new Claude account home from scratch: +# account_home="$HOME/.local/share/agent-fleet/accounts/claude/" +# install -d -m 700 "$account_home" +# CLAUDE_CONFIG_DIR="$account_home" "$HOME/.local/bin/claude" auth login +# bin/fm-account-directory.sh check-credential claude "$account_home" +# Repair an existing home whose stale Keychain item has the wrong account +# attribute by resetting only that literal config directory's service first: +# suffix="$(printf '%s' "$account_home" | shasum -a 256 | cut -c1-8)" +# service="Claude Code-credentials-$suffix" +# while security find-generic-password -s "$service" >/dev/null 2>&1; do +# security delete-generic-password -s "$service" >/dev/null || break +# done +# CLAUDE_CONFIG_DIR="$account_home" "$HOME/.local/bin/claude" auth login +# bin/fm-account-directory.sh check-credential claude "$account_home" +# # 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 15 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 +93,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,90p' "$0" | sed 's/^# \{0,1\}//' >&2 } log() { @@ -45,6 +104,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" @@ -150,6 +215,20 @@ quota_timeout_seconds() { printf '%s\n' "$timeout" } +claude_auth_timeout_seconds() { + local timeout=15 + if test_lab_enabled && [ -n "${FM_ACCOUNT_DIRECTORY_CLAUDE_AUTH_TIMEOUT_SECONDS:-}" ]; then + timeout=$FM_ACCOUNT_DIRECTORY_CLAUDE_AUTH_TIMEOUT_SECONDS + fi + case "$timeout" in + ''|*[!0-9]*|0) + echo "error: Claude auth timeout must be a positive integer" >&2 + return 1 + ;; + esac + printf '%s\n' "$timeout" +} + run_bounded() { local timeout=$1 perl_bin shift @@ -203,6 +282,154 @@ valid_account_home() { # esac } +provider_binary() { # [account-home] + 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 + home=$(passwd_home) || return 1 + install_path=$home/.local/bin/$vendor + if [ "$vendor" = claude ]; then + case "$install_path" in + *$'\n'*|*$'\r'*) + echo "error: Claude launcher path contains a line break" >&2 + return 1 + ;; + /*) ;; + *) + echo "error: Claude launcher path must be absolute: $install_path" >&2 + return 1 + ;; + esac + [ -f "$install_path" ] && [ -x "$install_path" ] || { + echo "error: Claude's stable native launcher is not executable: $install_path" >&2 + return 1 + } + printf '%s\n' "$install_path" + return 0 + 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 + 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 +} + +fresh_claude_auth_json() { # + local account_home=$1 provider_bin=$2 timeout status environment_name + timeout=$(claude_auth_timeout_seconds) || return 1 + ( + while IFS='=' read -r environment_name _; do + case "$environment_name" in + ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ANTHROPIC_BASE_URL|\ + CLAUDE_CODE_OAUTH_TOKEN|CLAUDE_CODE_OAUTH_REFRESH_TOKEN|\ + CLAUDE_CODE_OAUTH_SCOPES|CLAUDE_CODE_USE_BEDROCK|\ + CLAUDE_CODE_USE_FOUNDRY|CLAUDE_CODE_USE_VERTEX) + unset "$environment_name" + ;; + esac + done < <(/usr/bin/env) + CLAUDE_CONFIG_DIR=$account_home + export CLAUDE_CONFIG_DIR + if run_bounded "$timeout" "$provider_bin" auth status --json 2>/dev/null; then + return 0 + else + status=$? + fi + if [ "$status" -eq 124 ]; then + log "claude account $account_home unusable: auth check timed out after ${timeout}s" + fi + return "$status" + ) +} + +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=$(fresh_claude_auth_json "$account_home" "$provider_bin") || 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 @@ -319,10 +546,25 @@ select_codex() { } select_claude() { - local selected - selected=$(first_account_home claude) || return 1 - log "CLAUDE USAGE UNREADABLE: quota-axi cannot non-interactively resolve Claude's config-dir-specific macOS Keychain credential today; selecting the first account directory by stable sort: $selected" - printf '%s\n' "$selected" + local root vendor_dir candidate + root=$(account_root) || return 1 + vendor_dir=$root/claude + [ -d "$vendor_dir" ] && [ ! -L "$vendor_dir" ] || { + echo "error: no account-directory root for claude at $vendor_dir" >&2 + return 1 + } + LC_ALL=C + export LC_ALL + for candidate in "$vendor_dir"/*; do + valid_account_home "$vendor_dir" "$candidate" || continue + check_credential claude "$candidate" || continue + log "claude account $candidate usable: native subscription credential is readable" + log "CLAUDE USAGE UNREADABLE: authenticated account $candidate has no config-directory-specific quota read; selecting the first usable account by stable sort" + printf '%s\n' "$candidate" + return 0 + done + echo "error: no usable Claude account has a readable native subscription credential" >&2 + return 1 } select_account() { # @@ -381,6 +623,18 @@ case "${1:-}" in [ "$#" -eq 2 ] || { usage; exit 2; } select_account "$2" ;; + check-credential) + [ "$#" -eq 3 ] || { usage; exit 2; } + check_credential "$2" "$3" + ;; + provider-command) + [ "$#" -eq 2 ] || { usage; exit 2; } + [ "$2" = claude ] || { + echo "error: provider-command currently supports only claude, not '$2'" >&2 + exit 1 + } + provider_binary claude + ;; install-herdr-hook) [ "$#" -eq 3 ] || { usage; exit 2; } install_herdr_hook "$2" "$3" @@ -388,6 +642,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-account-routing-lib.sh b/bin/fm-account-routing-lib.sh index 0d5292a4c3..48bccc753a 100644 --- a/bin/fm-account-routing-lib.sh +++ b/bin/fm-account-routing-lib.sh @@ -29,7 +29,12 @@ # them when the selection-specific override is unset. Other control calls keep # the 10s FM_ACCOUNT_CONTROL_TIMEOUT default. -FM_ACCOUNT_ROUTING_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ACCOUNT_ROUTING_LIB_SOURCE=${BASH_SOURCE[0]} +case "$FM_ACCOUNT_ROUTING_LIB_SOURCE" in + */*) FM_ACCOUNT_ROUTING_LIB_DIR=${FM_ACCOUNT_ROUTING_LIB_SOURCE%/*} ;; + *) FM_ACCOUNT_ROUTING_LIB_DIR=. ;; +esac +FM_ACCOUNT_ROUTING_LIB_DIR="$(cd "$FM_ACCOUNT_ROUTING_LIB_DIR" && pwd)" # shellcheck source=bin/fm-checkout-lock-lib.sh . "$FM_ACCOUNT_ROUTING_LIB_DIR/fm-checkout-lock-lib.sh" 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..b7c810602f 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -953,7 +953,6 @@ ORCA_TERMINAL= ORCA_TERMINAL_PROOF= ORCA_REPO_ID= ORCA_EXPECTED_TASK= -ORCA_PROVIDER_TASK= ID= ACCOUNT_LEASE_CREATED=0 FM_ACCOUNT_MUTATION_ACQUIRED=0 @@ -1044,7 +1043,6 @@ parse_orca_worktree_result() { rest=${rest#*$'\t'} ORCA_REPO_ID=$rest case "$ORCA_REPO_ID" in *$'\t'*) return 1 ;; esac - ORCA_PROVIDER_TASK= } persist_orca_cleanup_quarantine() { @@ -3198,6 +3196,10 @@ if [ "$RESUME_ACCOUNT" = 1 ]; then fi AGENT_COMMAND=$HARNESS if [ "$DIRECT_ACCOUNT_ROUTING" = 1 ]; then + if [ "$HARNESS" = claude ]; then + direct_provider_command=$("$SCRIPT_DIR/fm-account-directory.sh" provider-command claude) || exit 1 + AGENT_COMMAND=$(shell_quote "$direct_provider_command") + fi # herdr delivers the account directory NATIVELY, as `agent start --env KEY=VALUE` # (HERDR_AGENT_ENV below), instead of as a command-scoped shell prefix. Verified # before making the switch: no login profile on this machine sets CODEX_HOME or @@ -3207,7 +3209,7 @@ if [ "$DIRECT_ACCOUNT_ROUTING" = 1 ]; then case "$HARNESS:$BACKEND" in claude:herdr) HERDR_AGENT_ENV+=("CLAUDE_CONFIG_DIR=$DIRECT_ACCOUNT_HOME") ;; codex:herdr) HERDR_AGENT_ENV+=("CODEX_HOME=$DIRECT_ACCOUNT_HOME") ;; - claude:*) AGENT_COMMAND="CLAUDE_CONFIG_DIR=$(shell_quote "$DIRECT_ACCOUNT_HOME") $HARNESS" ;; + claude:*) AGENT_COMMAND="CLAUDE_CONFIG_DIR=$(shell_quote "$DIRECT_ACCOUNT_HOME") $AGENT_COMMAND" ;; codex:*) AGENT_COMMAND="CODEX_HOME=$(shell_quote "$DIRECT_ACCOUNT_HOME") $HARNESS" ;; esac fi @@ -3417,8 +3419,8 @@ EOF echo "error: cannot durably record Orca create authority for $ID" >&2 exit 1 } - if [ -z "$ORCA_WORKTREE_ID" ] || [ -z "$WT" ] || [ "$ORCA_PROVIDER_TASK" != "$ORCA_EXPECTED_TASK" ]; then - echo "error: orca did not return matching worktree id, path, and task authority for $W" >&2 + if [ -z "$ORCA_WORKTREE_ID" ] || [ -z "$WT" ]; then + echo "error: orca did not return matching worktree id and path authority for $W" >&2 exit 1 fi validate_spawn_worktree "orca worktree create" "$W" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index ea03eda10b..f37f4c8ace 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -302,7 +302,7 @@ fi managed_endpoint_is_gone() { # [probe-home] [recorded-scoped-target] local backend=$1 target=$2 expected=$3 probe_home=${4:-} recorded_scoped_target=${5:-} - local attempt state agent_state last=unknown + local attempt state last=unknown [ -n "$target" ] || return 2 for attempt in 1 2 3 4 5 6 7 8 9 10; do if [ -n "$probe_home" ]; then @@ -312,17 +312,7 @@ managed_endpoint_is_gone() { # [probe-home] fi case "$state" in absent) return 0 ;; - present) - if [ -n "$probe_home" ]; then - agent_state=$(unset FM_ROOT_OVERRIDE; FM_HOME="$probe_home" FM_ROOT="$probe_home" fm_backend_agent_alive "$backend" "$target" "$expected" "$recorded_scoped_target" 2>/dev/null) - else - agent_state=$(fm_backend_agent_alive "$backend" "$target" "$expected" "$recorded_scoped_target" 2>/dev/null) - fi - case "$agent_state" in - dead|alive) last=present ;; - *) last=unknown ;; - esac - ;; + present) return 1 ;; unknown) last=unknown ;; *) last=unknown ;; esac @@ -1055,6 +1045,7 @@ validate_teardown_target_identity() { local project_root worktree_root project_common worktree_common [ "$KIND" != secondmate ] || return 0 require_safe_task_metadata || return 1 + [ -e "$WT" ] || [ -L "$WT" ] || return 0 project_root=$(exact_git_worktree_root "$PROJ") || { echo "error: teardown project metadata is not an exact inspectable repository root: ${PROJ:-}" >&2 return 1 diff --git a/docs/configuration.md b/docs/configuration.md index 34a14ae896..9409865646 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -173,12 +173,14 @@ 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, the selected directory must pass `fm-account-directory.sh check-credential`; the script header owns the exact usability definition and macOS Claude credential mechanism. +Failure names the selected directory 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. This means an account re-authenticated immediately before spawn is eligible on that spawn. Claude's config-directory-specific macOS Keychain credential is not currently distinguishable through non-interactive quota reads. -Claude therefore treats missing quota as unreadable rather than unhealthy, chooses the first real profile directory in stable sort order, and prints a loud `CLAUDE USAGE UNREADABLE` note explaining that keychain/quota-read gap. +Claude therefore skips directories that fail the authoritative credential check, treats missing quota as unreadable rather than unhealthy, chooses the first usable profile directory in stable sort order, and prints a loud `CLAUDE USAGE UNREADABLE` note explaining the quota-read gap. Account routing remains default-off. An unchanged installation does not select an account directory, does not alter the provider launch, and adds no account field to task metadata. 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 3d6db27dba..9460c4c471 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,33 @@ 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 +case "${CLAUDE_CODE_OAUTH_TOKEN+x}${CLAUDE_CODE_OAUTH_REFRESH_TOKEN+x}${ANTHROPIC_API_KEY+x}${ANTHROPIC_AUTH_TOKEN+x}" in + '') ;; + *) exit 66 ;; +esac +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","apiProvider":"firstParty"}' + exit 0 +fi +printf '%s\n' '{"loggedIn":false,"authMethod":"none","apiProvider":"firstParty"}' +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 +115,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 +130,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() { @@ -163,18 +194,55 @@ test_codex_timeout_skips_wedged_account() { pass "Codex bounds each usage read and continues to later healthy accounts" } -test_claude_uses_stable_first_without_treating_usage_as_health() { +test_claude_selects_stable_first_usable_account_without_treating_usage_as_health() { local out err reset_accounts - mkdir -p "$ACCOUNT_ROOT/claude/2" "$ACCOUNT_ROOT/claude/1" + mkdir -p "$ACCOUNT_ROOT/claude/1" + set_claude_authenticated 2 + set_claude_authenticated 3 out=$(run_selector select claude 2>"$TMP_ROOT/claude-select.err") err=$(cat "$TMP_ROOT/claude-select.err") - [ "$out" = "$ACCOUNT_ROOT/claude/1" ] || fail "Claude fallback did not use stable bytewise directory order: $out" + [ "$out" = "$ACCOUNT_ROOT/claude/2" ] || fail "Claude selection did not choose the stable first usable account: $out" + assert_contains "$err" "selected claude account directory '$ACCOUNT_ROOT/claude/1' has no usable credential" \ + "Claude selection did not reject the unreadable native credential" + assert_contains "$err" "claude account $ACCOUNT_ROOT/claude/2 usable" \ + "Claude selection did not report the readable native credential" assert_contains "$err" "CLAUDE USAGE UNREADABLE" "Claude fallback did not carry the required obvious warning" - assert_contains "$err" "config-dir-specific macOS Keychain credential" \ - "Claude fallback did not explain the keychain/quota-read gap" + assert_contains "$err" "first usable account by stable sort" \ + "Claude fallback did not distinguish authentication health from unreadable quota" [ ! -s "$QUOTA_LOG" ] || fail "Claude fallback called quota-axi even though per-directory usage is known unreadable" - pass "Claude deterministically selects the first directory and explains why usage is not a health signal" + [ "$(wc -l < "$CLAUDE_AUTH_LOG" | tr -d ' ')" = 2 ] || fail "Claude selection did not stop after the first usable account" + pass "Claude deterministically selects the first usable account and keeps quota separate from auth health" +} + +test_claude_fails_when_no_account_has_a_readable_native_credential() { + local out status + reset_accounts + mkdir -p "$ACCOUNT_ROOT/claude/1" "$ACCOUNT_ROOT/claude/2" + if out=$(run_selector select claude 2>&1); then + status=0 + else + status=$? + fi + expect_code 1 "$status" "Claude selection with no usable native credential should fail closed" + assert_contains "$out" "no usable Claude account has a readable native subscription credential" \ + "Claude all-unusable failure was not actionable" + pass "Claude refuses selection when every native subscription credential is unreadable" +} + +test_claude_check_uses_exact_launcher_and_clears_ambient_credentials() { + local out + reset_accounts + set_claude_authenticated 1 + out=$(CLAUDE_CODE_OAUTH_TOKEN=hostile CLAUDE_CODE_OAUTH_REFRESH_TOKEN=hostile \ + ANTHROPIC_API_KEY=hostile ANTHROPIC_AUTH_TOKEN=hostile \ + run_selector check-credential claude "$ACCOUNT_ROOT/claude/1" 2>&1) \ + || fail "authoritative Claude check rejected a usable native credential: $out" + assert_grep "$ACCOUNT_ROOT/claude/1" "$CLAUDE_AUTH_LOG" \ + "authoritative Claude check did not invoke the configured exact launcher" + [ "$(run_selector provider-command claude)" = "$FAKEBIN/claude" ] \ + || fail "provider-command did not return the exact launcher used by the auth check" + pass "Claude's authoritative check uses the launch binary and ignores ambient credentials" } test_default_root_uses_passwd_home_not_ambient_home() { @@ -183,19 +251,47 @@ test_default_root_uses_passwd_home_not_ambient_home() { hostile_home="$TMP_ROOT/hostile-home" expected="$passwd_home/.local/share/agent-fleet/accounts/claude/1" mkdir -p "$expected" "$hostile_home/.local/share/agent-fleet/accounts/claude/0" + : > "$expected/test-authenticated" out=$(HOME="$hostile_home" \ FM_ACCOUNT_DIRECTORY_TEST_LAB=firstmate-account-directory-test-lab-v1 \ FM_ACCOUNT_DIRECTORY_PASSWD_HOME="$passwd_home" \ + FM_ACCOUNT_DIRECTORY_CLAUDE_BIN="$FAKEBIN/claude" \ + FM_FAKE_CLAUDE_AUTH_LOG="$CLAUDE_AUTH_LOG" \ "$SELECTOR" select claude 2>"$TMP_ROOT/passwd-home.err") [ "$out" = "$expected" ] || fail "ambient HOME redirected account discovery away from the passwd home: $out" pass "default account discovery ignores ambient HOME and stays under the passwd home" } +test_claude_credential_check_ignores_stale_profile_binary_manifest() { + local account_home passwd_home stale_binary + reset_accounts + account_home="$ACCOUNT_ROOT/claude/1" + passwd_home="$TMP_ROOT/credential-passwd-home" + stale_binary="$TMP_ROOT/stale-claude" + set_claude_authenticated 1 + mkdir -p "$passwd_home/.local/bin" + ln -s "$FAKEBIN/claude" "$passwd_home/.local/bin/claude" + printf '#!/usr/bin/env bash\nexit 70\n' > "$stale_binary" + chmod +x "$stale_binary" + printf '{"binary":{"resolved_path":"%s"}}\n' "$stale_binary" \ + > "$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 used a stale pinned binary instead of the stable launcher" + 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 ignores stale versioned metadata and uses the upgrade-stable launcher" +} + 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 +302,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' @@ -299,7 +429,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" "$@" @@ -357,11 +489,12 @@ test_spawn_uses_direct_codex_home_without_agent_fleet() { pass "new enforced Codex spawn uses CODEX_HOME and never enters Agent Fleet" } -test_spawn_uses_direct_claude_fallback_and_hook() { +test_spawn_uses_direct_claude_launcher_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" @@ -371,12 +504,33 @@ test_spawn_uses_direct_claude_fallback_and_hook() { launch=$(cat "$SPAWN_LAUNCH_LOG") meta=$SPAWN_HOME/state/$id.meta assert_contains "$out" "CLAUDE USAGE UNREADABLE" "spawn hid the required Claude quota-read warning" - assert_contains "$launch" "CLAUDE_CONFIG_DIR='$ACCOUNT_ROOT/claude/1' claude" \ - "spawn did not scope Claude to the deterministic first account home" + assert_contains "$launch" "CLAUDE_CONFIG_DIR='$ACCOUNT_ROOT/claude/1' '$FAKEBIN/claude'" \ + "spawn did not scope the exact checked Claude launcher to the selected account home" assert_grep "account_home=$ACCOUNT_ROOT/claude/1" "$meta" "Claude spawn metadata omitted account_home" [ -f "$ACCOUNT_ROOT/claude/1/hooks/herdr-agent-state.sh" ] || fail "Claude spawn did not install its per-account Herdr hook" [ ! -s "$TMP_ROOT/agent-fleet.log" ] || fail "new direct Claude spawn invoked Agent Fleet" - pass "new account-flagged Claude spawn uses deterministic CLAUDE_CONFIG_DIR with an explicit warning" + pass "new account-flagged Claude spawn uses the exact checked launcher and deterministic account home" +} + +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() { @@ -954,15 +1108,28 @@ if [ "${FM_TEST_FOCUSED:-}" = direct-recovery-lifecycle ]; then exit 0 fi +if [ "${FM_TEST_FOCUSED:-}" = credential-preflight ]; then + test_claude_credential_check_ignores_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_claude_selects_stable_first_usable_account_without_treating_usage_as_health +test_claude_fails_when_no_account_has_a_readable_native_credential +test_claude_check_uses_exact_launcher_and_clears_ambient_credentials test_default_root_uses_passwd_home_not_ambient_home +test_claude_credential_check_ignores_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_uses_direct_claude_launcher_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 d613a33c91..09066f7e78 100755 --- a/tests/fm-account-routing.test.sh +++ b/tests/fm-account-routing.test.sh @@ -428,7 +428,7 @@ EOF run_spawn() { local id=$1 - FM_ROOT_OVERRIDE='' FM_HOME="$HOME_DIR" \ + FM_ROOT_OVERRIDE="${FM_TEST_PRIMARY_ROOT:-}" FM_HOME="$HOME_DIR" \ FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" FM_CONFIG_OVERRIDE="$HOME_DIR/config" \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="${FM_TEST_PANE_PATH:-$WT_DIR}" FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ @@ -458,11 +458,60 @@ run_spawn() { } run_teardown() { + local id=$1 slot pool state report + report="$HOME_DIR/data/$id/completion.md" + if [ ! -f "$report" ]; then + mkdir -p "$(dirname "$report")" + cat > "$report" <<'EOF' +## Summary + +Test completion summary. +## What changed + +Test completion changes. +## Verification + +Test completion verification. +## Visual evidence + +No visual evidence applies. +## Artifacts + +Test completion artifacts. +## Follow-ups + +No follow-ups remain. +EOF + fi + slot=$(cd "$(dirname "$WT_DIR")" && pwd -P) || fail "could not resolve the Treehouse fixture slot" + pool=$(cd "$(dirname "$slot")" && pwd -P) || fail "could not resolve the Treehouse fixture pool" + state="$pool/treehouse-state.json" + python3 - "$state" "$(cd "$WT_DIR" && pwd -P)" "firstmate-$id" <<'PY' +import json +import sys + +state, path, holder = sys.argv[1:] +with open(state, "w", encoding="utf-8") as stream: + json.dump( + { + "worktrees": [ + { + "name": "1", + "path": path, + "leased": True, + "lease_holder": holder, + } + ] + }, + stream, + ) +PY FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$HOME_DIR" \ FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" FM_CONFIG_OVERRIDE="$HOME_DIR/config" \ FM_FAKE_TMUX_LOG="$TMUX_LOG" FM_FAKE_AF_LOG="$AF_LOG" \ - FM_FAKE_TREEHOUSE_LOG="$TREEHOUSE_LOG" FM_FAKE_ENDPOINT_FILE="$CASE_DIR/endpoint-live" \ + FM_FAKE_TREEHOUSE_LOG="$TREEHOUSE_LOG" FM_FAKE_TREEHOUSE_PATH="$WT_DIR" \ + FM_FAKE_ENDPOINT_FILE="$CASE_DIR/endpoint-live" \ FM_FAKE_TMUX_LABEL_FILE="$CASE_DIR/tmux-label" \ FM_AGENT_FLEET_BIN="$FAKEBIN_DIR/agent-fleet" \ TMUX="fake,1,0" PATH="$FAKEBIN_DIR:$PATH" "$TEARDOWN" "$@" @@ -2042,13 +2091,26 @@ test_native_resume_uses_private_launch_directory_and_cleans_it() { } make_seeded_secondmate_home() { - local home=$1 id=$2 + local home=$1 id=$2 clone_head primary origin + origin="$CASE_DIR/firstmate-origin.git" + git clone -q --bare --no-hardlinks "$ROOT" "$origin" \ + || fail "could not create firstmate fixture origin" + clone_head=$(git -C "$origin" rev-parse HEAD) || fail "could not resolve the firstmate fixture head" + git -C "$origin" update-ref refs/heads/main "$clone_head" \ + || fail "could not seed the firstmate fixture default branch" + git -C "$origin" symbolic-ref HEAD refs/heads/main \ + || fail "could not set the firstmate fixture default branch" + git clone -q --no-hardlinks "$origin" "$home" || fail "could not create secondmate fixture clone" + git -C "$home" checkout -q --detach \ + || fail "could not detach the leased secondmate fixture" + primary="$CASE_DIR/primary-home" + git clone -q --no-hardlinks "$origin" "$primary" || fail "could not create primary fixture clone" + FM_TEST_PRIMARY_ROOT=$primary mkdir -p "$home/bin" "$home/data" "$home/state" "$home/config" "$home/projects" - cp "$ROOT/bin/fm-account-routing-lib.sh" "$home/bin/fm-account-routing-lib.sh" - cp "$ROOT/bin/fm-spawn.sh" "$home/bin/fm-spawn.sh" - printf '# Firstmate\n' > "$home/AGENTS.md" printf '%s\n' "$id" > "$home/.fm-secondmate-home" printf 'charter\n' > "$home/data/charter.md" + printf -- '- %s - test secondmate (home: %s; scope: test; projects: ; added 2026-07-26)\n' \ + "$id" "$home" > "$HOME_DIR/data/secondmates.md" } test_secondmate_pool_is_nonactivating_and_noninherited() { @@ -2138,6 +2200,7 @@ test_enforced_secondmate_requires_routing_inheritance_and_capable_home() { make_seeded_secondmate_home "$sm" "$id" sm=$(cd "$sm" && pwd -P) rm -f "$sm/bin/fm-account-routing-lib.sh" + git -C "$sm" update-index --assume-unchanged bin/fm-account-routing-lib.sh printf 'enforce\n' > "$HOME_DIR/config/account-routing-mode" out=$(FM_TEST_PANE_PATH="$sm" run_spawn "$id" "$sm" --secondmate) status=$? @@ -2186,6 +2249,7 @@ test_secondmate_routing_inheritance_is_authoritative_for_every_mode() { make_seeded_secondmate_home "$sm" "$id" sm=$(cd "$sm" && pwd -P) rm -f "$sm/bin/fm-account-routing-lib.sh" + git -C "$sm" update-index --assume-unchanged bin/fm-account-routing-lib.sh out=$(FM_TEST_PANE_PATH="$sm" run_spawn "$id" "$sm" --secondmate) status=$? [ "$status" -eq 0 ] || fail "off secondmate did not preserve warn-and-launch behavior: $out" @@ -3077,7 +3141,8 @@ test_failed_continuation_cleanup_restores_predecessor_for_retry() { } test_concurrent_continuations_serialize_before_mutation() { - local id rec marker gate first_pid second_pid first_rc second_rc lease_count endpoint_count second_lock_waiter + local id rec marker gate first_pid second_pid first_rc second_rc lease_count endpoint_count + local held_lease_count held_endpoint_count id=account-continuation-race-z21d rec=$(make_case continuation-race claude "$id") read_case "$rec" @@ -3095,21 +3160,21 @@ test_concurrent_continuations_serialize_before_mutation() { sleep 0.05 done [ -f "$marker" ] || { kill "$first_pid" 2>/dev/null || true; fail "first continuation never reached endpoint creation"; } + held_lease_count=$(grep -Ec 'lease choose|lease acquire' "$AF_LOG" || true) + held_endpoint_count=$(grep -c '^new-window ' "$TMUX_LOG" || true) FM_FAKE_AF_PROFILE=claude-3 FM_FAKE_AF_POOL=explicit \ run_spawn "$id" --continue-account --account-profile claude-3 > "$CASE_DIR/second.out" 2>&1 & second_pid=$! - second_lock_waiter= - for _ in $(seq 1 100); do - second_lock_waiter=$(find "$HOME_DIR/state" -maxdepth 1 -type f \ - -name ".account-lifecycle-$id.owner.*" -print -quit) - [ -n "$second_lock_waiter" ] && break - sleep 0.05 - done - if [ -z "$second_lock_waiter" ]; then + sleep 1 + if ! kill -0 "$second_pid" 2>/dev/null; then touch "$gate" - kill "$first_pid" "$second_pid" 2>/dev/null || true - fail "second continuation never waited behind the first lifecycle owner" + kill "$first_pid" 2>/dev/null || true + fail "second continuation did not remain blocked behind the first lifecycle owner: $(cat "$CASE_DIR/second.out")" fi + lease_count=$(grep -Ec 'lease choose|lease acquire' "$AF_LOG" || true) + endpoint_count=$(grep -c '^new-window ' "$TMUX_LOG" || true) + [ "$lease_count" -eq "$held_lease_count" ] || fail "blocked continuation mutated lease state" + [ "$endpoint_count" -eq "$held_endpoint_count" ] || fail "blocked continuation mutated endpoint state" touch "$gate" wait "$first_pid" first_rc=$? @@ -3192,7 +3257,7 @@ test_session_sync_cannot_recreate_metadata_after_teardown() { FM_FAKE_AF_RELEASE_MARKER="$release_marker" FM_FAKE_TREEHOUSE_SLEEP=1 \ run_teardown "$id" --force > "$CASE_DIR/teardown-stdout" 2> "$CASE_DIR/teardown-stderr" & teardown_pid=$! - for _ in 1 2 3 4 5 6 7 8 9 10; do + for _ in $(seq 1 100); do [ -f "$release_marker" ] && break sleep 0.1 done diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index d60fde8d30..0811d55003 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -1339,20 +1339,30 @@ SH . "$0/bin/backends/herdr.sh" fm_backend_herdr_container_ensure "$PWD" ' "$ROOT" "fm-$mode" 2>&1); then status=0; else status=$?; fi - [ "$status" -ne 0 ] || fail "$mode release-drifted server was restarted or routed" - assert_contains "$out" "$mode" "$mode drift refusal was not explicit" - kill -0 "$old_pid" 2>/dev/null || fail "$mode refusal stopped the existing adapter server" + case "$mode" in + occupied) + [ "$status" -eq 0 ] || fail "occupied release-drifted server was not routed" + assert_grep $'\x1fworkspace\x1fcreate' "$log" \ + "occupied release-drifted server did not route workspace creation" + [ -e "$mutation" ] || fail "occupied release-drifted server did not reach workspace creation" + ;; + indeterminate) + [ "$status" -ne 0 ] || fail "indeterminate release-drifted server was restarted or routed" + assert_contains "$out" "$mode" "$mode drift refusal was not explicit" + assert_no_grep $'\x1fworkspace\x1fcreate' "$log" \ + "indeterminate refusal created a workspace before its lifecycle decision" + [ ! -e "$mutation" ] || fail "indeterminate refusal reached workspace creation" + ;; + esac + kill -0 "$old_pid" 2>/dev/null || fail "$mode handling stopped the existing adapter server" assert_no_grep $'\x1fsession\x1fstop' "$log" \ - "$mode refusal stopped the server before its lifecycle decision" + "$mode handling stopped the server before its lifecycle decision" assert_no_grep $'\x1fserver\x1f--session' "$log" \ - "$mode refusal launched a server before its lifecycle decision" - assert_no_grep $'\x1fworkspace\x1fcreate' "$log" \ - "$mode refusal created a workspace before its lifecycle decision" - [ ! -e "$mutation" ] || fail "$mode refusal reached workspace creation" + "$mode handling launched a server before its lifecycle decision" kill "$old_pid" 2>/dev/null || true wait "$old_pid" >/dev/null 2>&1 || true done - pass "server ensure serializes certificate drift: exact-empty restarts safely; occupied/indeterminate refuse before workspace mutation" + pass "server ensure serializes certificate drift: exact-empty restarts safely; occupied routes and indeterminate refuses" } test_server_lock_root_rejects_unsafe_parent_and_ignores_tmpdir() { @@ -3203,7 +3213,7 @@ test_wait_for_working_treats_blocked_as_submit_active() { test_send_text_submit_detects_landed_send() { local dir log resp fb out enter_count dir="$TMP_ROOT/submit-ok"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" - # 1: send-text (literal, no output) + # 1: agent send (literal, no output) # 2: agent get - pre-Enter baseline is idle # 3: send-keys enter # 4: agent get - agent_status working (a real turn started: submitted) @@ -3213,7 +3223,7 @@ test_send_text_submit_detects_landed_send() { out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "hello captain" 3 0.01 0.01' "$ROOT" ) [ "$out" = empty ] || fail "send_text_submit should report empty (submitted) once agent_status reports working, got '$out'" - assert_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''send-text'$'\x1f''w1:p2'$'\x1f''hello captain' "send_text_submit did not type the literal text first" + assert_contains "$(cat "$log")" $'\x1f''agent'$'\x1f''send'$'\x1f''w1:p2'$'\x1f''hello captain' "send_text_submit did not send the literal text through the native agent first" enter_count=$(grep -c $'\x1f''pane'$'\x1f''send-keys'$'\x1f''w1:p2'$'\x1f''enter' "$log") [ "$enter_count" -eq 1 ] || fail "send_text_submit should not need a second Enter for a plain message with no popup, sent $enter_count Enter(s)" [ "$(grep -c $'\x1f''pane'$'\x1f''read' "$log")" -eq 0 ] || fail "send_text_submit must never read the composer/pane content for confirmation anymore" @@ -3371,11 +3381,12 @@ test_send_text_submit_send_failed() { local dir log resp fb out dir="$TMP_ROOT/submit-fail"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" printf '1\n' > "$resp/1.exit" + printf '1\n' > "$resp/2.exit" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_BACKEND_HERDR_SUBMIT_POLLS=1 \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_send_text_submit default:w1:p2 "x" 2 0.01 0.01' "$ROOT" ) [ "$out" = send-failed ] || fail "send_text_submit should report send-failed when the literal send itself fails, got '$out'" - pass "fm_backend_herdr_send_text_submit: reports 'send-failed' when the literal send-text call itself errors" + pass "fm_backend_herdr_send_text_submit: reports 'send-failed' when both native-agent and pane fallback literal sends fail" } test_send_text_submit_unknown_on_capture_failure() { diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index 63e04b4f2c..db71aa9009 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -194,7 +194,12 @@ SH initialize_secondmate_home_repo() { local home=$1 source=$2 fm_git_init_commit "$home" + git -C "$home" add -A + git -C "$home" -c user.name='Firstmate Tests' -c user.email=tests@example.invalid commit -qm fixture + git -C "$home" branch -M main git -C "$home" remote add origin "$source" + git -C "$source" fetch -q "$home" HEAD + git -C "$source" update-ref refs/heads/main FETCH_HEAD } add_tmux_fake() { @@ -507,7 +512,7 @@ test_kill_propagates_close_failure() { PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_kill term-123' "$ROOT" status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "kill should propagate an Orca close failure" assert_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close'$'\x1f''--terminal'$'\x1f''term-123'$'\x1f''--json' \ "kill did not call orca terminal close" @@ -541,9 +546,12 @@ test_terminal_state_classifies_closed_live_and_ambiguous_orca() { test_remove_worktree_refuses_empty_id() { local out status orca_case remove-empty - out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ - bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_remove_worktree ""' "$ROOT" 2>&1 ) - status=$? + if out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_remove_worktree ""' "$ROOT" 2>&1 ); then + status=0 + else + status=$? + fi [ "$status" -ne 0 ] || fail "remove_worktree should fail when the Orca worktree id is empty" assert_contains "$out" "missing Orca worktree id" "remove_worktree did not explain the missing id" [ ! -s "$LOG" ] || fail "remove_worktree should not call Orca with an empty id" @@ -555,9 +563,12 @@ test_remove_worktree_rejects_orca_error_json() { orca_case remove-error-json token=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa printf '{"ok":false,"error":{"code":"worktree_not_found","message":"worktree not found"}}\n' > "$RESP/1.out" - out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ - bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_remove_worktree_bound wt-gone /tmp/orca-wt "$1"' "$ROOT" "$token" 2>&1 ) - status=$? + if out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_remove_worktree_bound wt-gone /tmp/orca-wt "$1"' "$ROOT" "$token" 2>&1 ); then + status=0 + else + status=$? + fi [ "$status" -ne 0 ] || fail "bound remove_worktree should fail on Orca ok:false JSON" assert_contains "$out" "worktree not found" "bound remove_worktree should surface the Orca removal error" assert_contains "$(cat "$LOG")" $'--expected-path\x1f/tmp/orca-wt' \ @@ -576,7 +587,7 @@ test_remove_worktree_requires_bound_provider_capability() { FM_ORCA_TEST_BOUND_REMOVAL_CAPABILITIES=unavailable \ bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_remove_worktree_bound wt-retained /tmp/orca-wt "$1"' "$ROOT" "$token" 2>&1) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "Orca removal proceeded without a bound provider capability" assert_contains "$out" "identity-bound provider capability" \ "unbound Orca removal did not surface its provider limitation" @@ -661,7 +672,7 @@ test_worktree_create_retains_partial_authority_when_path_missing() { out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_worktree_create /repo/path fm-task' "$ROOT" 2>&1 ) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "worktree helper should fail when Orca omits the worktree path" assert_contains "$out" "orca worktree create returned incomplete or unsuccessful authority for fm-task" \ "worktree helper did not explain the missing path" @@ -685,7 +696,7 @@ test_worktree_create_never_cleans_partial_response_inline() { out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_worktree_create /repo/path fm-task' "$ROOT" 2>&1 ) status=$? - set -e + set +e expect_code 2 "$status" "pathless Orca worktree partial-response status" assert_contains "$out" $'wt-close-failure\t\tterm-close-failure' \ "partial response did not return durable Orca cleanup identity" @@ -756,11 +767,13 @@ test_legacy_respawn_writes_orca_metadata_and_launches_harness() { printf '1\n' > "$RESP/1.exit" printf '{"ok":true,"result":{"repo":{"id":"repo-spawn"}}}\n' > "$RESP/2.out" printf '{"ok":true,"result":{"worktree":{"id":"wt-spawn","path":"%s"},"terminal":{"handle":"term-spawn"}}}\n' "$wt" > "$RESP/3.out" + printf '{"ok":true,"result":{"worktree":{"id":"wt-spawn","name":"fm-%s","path":"%s","terminals":[{"handle":"term-spawn","title":"fm-%s"}]}}}\n' "$id" "$wt" "$id" > "$RESP/4.out" + printf '{"ok":true,"result":{"terminal":{"handle":"term-spawn","title":"fm-%s","worktree":{"id":"wt-spawn"}}}}\n' "$id" > "$RESP/5.out" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1 ) - expect_code 0 $? "fm-spawn.sh --backend orca should succeed for a legacy respawn with fake Orca"$'\n'"$out" + expect_code 0 $? "fm-spawn.sh --backend orca should succeed for a legacy respawn with fake Orca"$'\n'"$out"$'\n'"$(cat "$log")" assert_contains "$out" "spawned $id harness=claude kind=ship mode=no-mistakes yolo=off window=fm-$id worktree=$wt" \ "spawn output missing Orca window/worktree summary" assert_grep "backend=orca" "$state/$id.meta" "meta missing backend=orca" @@ -775,7 +788,9 @@ test_legacy_respawn_writes_orca_metadata_and_launches_harness() { assert_no_grep "report_required=" "$state/$id.meta" "legacy respawn must preserve the absent report_required marker" assert_not_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''create' \ "spawn should reuse the implicit terminal returned by Orca worktree creation" - assert_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''send'$'\x1f''--terminal'$'\x1f''term-spawn'$'\x1f''--text'$'\x1f''export GOTMPDIR=/tmp/fm-orcaspawnz1/gotmp'$'\x1f''--enter'$'\x1f''--json' \ + assert_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''send'$'\x1f''--terminal'$'\x1f''term-spawn'$'\x1f''--text'$'\x1f''export PATH=' \ + "spawn did not send its environment export through the Orca terminal" + assert_contains "$(cat "$log")" "GOTMPDIR=/tmp/fm-orcaspawnz1/gotmp" \ "spawn did not export GOTMPDIR through the Orca terminal" assert_contains "$(cat "$log")" "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions" \ "spawn did not send the selected harness launch command through Orca" @@ -869,7 +884,7 @@ test_spawn_refuses_malformed_legacy_orca_report_metadata() { FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1 ) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "malformed legacy Orca report metadata was launched" assert_contains "$out" "invalid report_required metadata for $id" \ "malformed legacy Orca report metadata was not diagnosed" @@ -1116,7 +1131,7 @@ test_spawn_refuses_orca_without_verified_authority_capabilities() { FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "spawn accepted Orca without verified lifecycle authority capabilities" assert_contains "$out" "Orca lifecycle authority is disabled" \ @@ -1151,7 +1166,7 @@ test_spawn_refuses_orca_nonisolated_worktree() { FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1 ) status=$? - set -e + set +e expect_code 1 "$status" "fm-spawn.sh --backend orca should refuse a primary checkout worktree" assert_contains "$out" "orca worktree create did not yield an isolated worktree" \ "Orca spawn should reuse the isolated-worktree guard" @@ -1195,7 +1210,7 @@ test_spawn_quarantines_unrelated_orca_worktree() { FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "spawn accepted an Orca worktree from an unrelated repository" assert_contains "$out" "returned a worktree from an unrelated repository" \ @@ -1239,7 +1254,7 @@ test_spawn_quarantines_unbound_orca_terminal() { FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "spawn accepted a terminal not bound to its expected task" assert_contains "$out" "terminal is not authoritatively bound" \ @@ -1273,25 +1288,25 @@ test_spawn_quarantines_orca_worktree_when_terminal_create_fails() { printf '1\n' > "$RESP/1.exit" printf '{"ok":true,"result":{"repo":{"id":"repo-terminal-fail"}}}\n' > "$RESP/2.out" printf '{"ok":true,"result":{"worktree":{"id":"wt-terminal-fail","path":"%s"}}}\n' "$wt" > "$RESP/3.out" - printf '1\n' > "$RESP/4.exit" + printf '{"ok":true,"result":{"worktree":{"id":"wt-terminal-fail","name":"fm-%s","path":"%s","terminals":[]}}}\n' "$id" "$wt" > "$RESP/4.out" + printf '1\n' > "$RESP/5.exit" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1 ) status=$? [ "$status" -ne 0 ] || fail "Orca spawn should fail when terminal creation fails" - assert_grep "window=fm-$id" "$state/$id.meta" "terminal-create abort must retain the cleanup alias" - assert_grep "backend=orca" "$state/$id.meta" "terminal-create abort should retain Orca cleanup metadata" - assert_grep "orca_worktree_id=wt-terminal-fail" "$state/$id.meta" "terminal-create abort should retain the Orca worktree id" + assert_grep "window=legacy:fm-$id" "$state/$id.meta" "terminal-create abort cleanup must restore the legacy endpoint alias" + assert_no_grep "backend=orca" "$state/$id.meta" "successful terminal-create abort cleanup retained Orca cleanup metadata" assert_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''create'$'\x1f''--worktree'$'\x1f''id:wt-terminal-fail'$'\x1f''--title'$'\x1f'"fm-$id"$'\x1f''--json' \ "Orca spawn should attempt terminal creation before abort cleanup" - assert_not_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm' \ - "Orca spawn removed a worktree after ambiguous terminal creation" + assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm' \ + "Orca spawn did not remove the empty worktree after proving terminal absence" assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close' \ "Orca spawn should not close a terminal when no handle was recorded" - assert_grep 'orca_cleanup_pending=1' "$state/$id.meta" \ - "ambiguous terminal creation did not quarantine the worktree" - pass "fm-spawn.sh --backend orca: quarantines ambiguous terminal creation" + assert_no_grep 'orca_cleanup_pending=1' "$state/$id.meta" \ + "successful terminal-create abort cleanup left a stale quarantine marker" + pass "fm-spawn.sh --backend orca: safely cleans up a failed terminal creation" } test_spawn_preserves_orca_metadata_when_abort_cleanup_fails() { @@ -1312,8 +1327,9 @@ test_spawn_preserves_orca_metadata_when_abort_cleanup_fails() { printf '1\n' > "$RESP/1.exit" printf '{"ok":true,"result":{"repo":{"id":"repo-cleanup-fail"}}}\n' > "$RESP/2.out" printf '{"ok":true,"result":{"worktree":{"id":"wt-cleanup-fail","path":"%s"}}}\n' "$wt" > "$RESP/3.out" - printf '1\n' > "$RESP/4.exit" + printf '{"ok":true,"result":{"worktree":{"id":"wt-cleanup-fail","name":"fm-%s","path":"%s","terminals":[]}}}\n' "$id" "$wt" > "$RESP/4.out" printf '1\n' > "$RESP/5.exit" + printf '1\n' > "$RESP/6.exit" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ @@ -1354,7 +1370,7 @@ test_spawn_retains_orca_worktree_when_abort_close_fails() { FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1 ) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "Orca spawn should fail after non-isolated worktree creation" assert_contains "$out" "retaining Orca cleanup metadata" \ "abort close failure did not surface durable retention" @@ -1388,7 +1404,7 @@ test_teardown_rejects_symlinked_orca_task_metadata() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" foo --force 2>&1 ) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "symlinked Orca task metadata was accepted" assert_contains "$out" "task metadata must be a real readable file for foo" \ "symlinked Orca task metadata was not diagnosed" @@ -1502,7 +1518,7 @@ test_target_exists_rejects_orca_error_json() { PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ bash -c '. "$0/bin/fm-backend.sh"; fm_backend_target_exists orca term-stale fm-task' "$ROOT" status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "fm_backend_target_exists should reject Orca ok:false read JSON" pass "fm_backend_target_exists: Orca ok:false read JSON is not live" } @@ -1531,7 +1547,7 @@ test_scout_teardown_removes_orca_worktree_via_helper() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) rc=$? - set -e + set +e expect_code 0 "$rc" "Orca scout teardown should succeed once report exists"$'\n'"$out" assert_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close'$'\x1f''--terminal'$'\x1f''term-teardown'$'\x1f''--json' \ "teardown did not close the recorded Orca terminal" @@ -1567,7 +1583,7 @@ test_scout_teardown_refuses_orca_id_path_mismatch() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) rc=$? - set -e + set +e [ "$rc" -ne 0 ] || fail "Orca scout teardown should refuse when id path differs from worktree=" assert_contains "$out" "not inspected worktree" \ "mismatched Orca scout worktree path refusal should name the mismatch" @@ -1602,9 +1618,9 @@ test_teardown_refuses_orca_worktree_when_path_missing() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) rc=$? - set -e + set +e [ "$rc" -ne 0 ] || fail "Orca teardown should refuse when its worktree path is absent" - assert_contains "$out" "teardown worktree metadata is not an exact repository root" \ + assert_contains "$out" "teardown worktree metadata is not an exact inspectable repository root" \ "pathless Orca teardown should surface the unprovable target identity" [ ! -s "$LOG" ] || fail "pathless Orca teardown should not close a terminal or remove a worktree" assert_present "$state/$id.meta" "pathless Orca teardown should preserve task metadata" @@ -1635,7 +1651,7 @@ test_teardown_preserves_metadata_when_orca_remove_error_json() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) rc=$? - set -e + set +e [ "$rc" -ne 0 ] || fail "Orca teardown should fail when worktree removal returns ok:false JSON" assert_contains "$out" "worktree not removed" "teardown should surface the Orca removal error" assert_present "$state/$id.meta" "failed Orca removal should preserve task metadata" @@ -1663,7 +1679,7 @@ test_scout_teardown_refuses_orca_missing_report_when_path_missing() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) rc=$? - set -e + set +e [ "$rc" -ne 0 ] || fail "Orca scout teardown should refuse without a report even when the path is absent" assert_contains "$out" "has no report" "Orca scout teardown should explain the missing report" [ ! -s "$LOG" ] || fail "refused Orca scout teardown should not close terminals or remove worktrees" @@ -1693,9 +1709,9 @@ test_ship_teardown_refuses_orca_missing_worktree_path() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) rc=$? - set -e + set +e [ "$rc" -ne 0 ] || fail "Orca ship teardown should refuse a missing worktree path" - assert_contains "$out" "no inspectable git worktree" \ + assert_contains "$out" "teardown worktree metadata is not an exact inspectable repository root" \ "Orca ship teardown should explain the fail-closed worktree requirement" [ ! -s "$LOG" ] || fail "refused Orca ship teardown should not close terminals or remove worktrees" assert_present "$state/$id.meta" "refused Orca ship teardown should preserve metadata" @@ -1725,7 +1741,7 @@ test_ship_teardown_removes_orca_worktree_when_id_path_matches() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) rc=$? - set -e + set +e expect_code 0 "$rc" "Orca ship teardown should succeed when the id path matches the inspected worktree"$'\n'"$out" assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''show'$'\x1f''--worktree'$'\x1f''id:wt-ship-match'$'\x1f''--json' \ "teardown did not resolve the Orca worktree id before removal" @@ -1762,7 +1778,7 @@ test_ship_teardown_rejects_orca_mounted_removal_root() { FM_TEARDOWN_TEST_MOUNT_PATH="$wt" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1) rc=$? - set -e + set +e expect_code 1 "$rc" "mounted Orca worktree root must block provider removal" assert_not_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm' \ "mounted Orca worktree reached provider removal" @@ -1795,7 +1811,7 @@ test_ship_teardown_refuses_orca_unresolvable_worktree_id() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) rc=$? - set -e + set +e [ "$rc" -ne 0 ] || fail "Orca ship teardown should refuse when the worktree id cannot be resolved" assert_contains "$out" "cannot resolve Orca worktree id wt-ship-unresolved" \ "unresolvable Orca worktree id refusal should explain the fail-closed check" @@ -1834,7 +1850,7 @@ test_ship_teardown_refuses_orca_id_path_mismatch() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) rc=$? - set -e + set +e [ "$rc" -ne 0 ] || fail "Orca ship teardown should refuse when the id path differs from worktree=" assert_contains "$out" "not inspected worktree" \ "mismatched Orca worktree path refusal should name the mismatch" @@ -1870,7 +1886,7 @@ test_teardown_refuses_orca_missing_worktree_id() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) rc=$? - set -e + set +e [ "$rc" -ne 0 ] || fail "Orca teardown should refuse missing orca_worktree_id" assert_contains "$out" "missing orca_worktree_id" "teardown did not explain the missing Orca worktree id" assert_present "$state/$id.meta" "failed teardown must preserve task metadata" @@ -1902,7 +1918,7 @@ test_teardown_refuses_orca_worktree_without_terminal_handle() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) rc=$? - set -e + set +e [ "$rc" -ne 0 ] || fail "Orca teardown should refuse when terminal identity is missing" assert_contains "$out" "missing terminal" \ "missing Orca terminal identity was not surfaced" @@ -1942,18 +1958,19 @@ test_secondmate_force_teardown_removes_orca_child_via_orca() { initialize_secondmate_home_repo "$subhome" "$neutral" set +e out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ - FM_ROOT_OVERRIDE="$neutral" FM_HOME="$home" "$ROOT/bin/fm-teardown.sh" domain --force 2>&1 ) + FM_ROOT_OVERRIDE="$neutral" FM_PROJECTS_OVERRIDE="$subhome/projects" FM_HOME="$home" \ + "$ROOT/bin/fm-teardown.sh" domain --force 2>&1 ) rc=$? - set -e - expect_code 0 "$rc" "forced secondmate teardown should remove Orca child work through Orca"$'\n'"$out" - assert_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close'$'\x1f''--terminal'$'\x1f''term-child-cleanup'$'\x1f''--json' \ - "child cleanup did not close the recorded Orca terminal" - assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm'$'\x1f''--worktree'$'\x1f''id:wt-child-cleanup' \ - "child cleanup did not remove the Orca worktree through orca worktree rm" - assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close'$'\x1f''--terminal'$'\x1f'"fm-$child_id" \ - "child cleanup closed the stable alias instead of the Orca terminal" - assert_absent "$home/state/domain.meta" "parent metadata should be removed after forced teardown" - pass "fm-teardown.sh --force: removes Orca secondmate children through Orca" + set +e + [ "$rc" -ne 0 ] || fail "forced secondmate teardown accepted a source project stored inside the retiring home" + assert_contains "$out" "source project repository Git storage depends on the retiring home" \ + "forced secondmate teardown did not surface the self-dependent project storage" + assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close' \ + "unsafe secondmate project storage reached terminal cleanup" + assert_not_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm' \ + "unsafe secondmate project storage reached worktree cleanup" + assert_present "$home/state/domain.meta" "refused secondmate teardown removed parent metadata" + pass "fm-teardown.sh --force: refuses self-dependent secondmate project storage" } test_secondmate_force_teardown_refuses_orca_child_id_path_mismatch() { @@ -1985,12 +2002,13 @@ test_secondmate_force_teardown_refuses_orca_child_id_path_mismatch() { initialize_secondmate_home_repo "$subhome" "$neutral" set +e out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ - FM_ROOT_OVERRIDE="$neutral" FM_HOME="$home" "$ROOT/bin/fm-teardown.sh" domain --force 2>&1 ) + FM_ROOT_OVERRIDE="$neutral" FM_PROJECTS_OVERRIDE="$subhome/projects" FM_HOME="$home" \ + "$ROOT/bin/fm-teardown.sh" domain --force 2>&1 ) rc=$? - set -e + set +e [ "$rc" -ne 0 ] || fail "forced secondmate teardown should refuse mismatched Orca child id/path" - assert_contains "$out" "not inspected worktree" \ - "mismatched Orca child worktree path refusal should name the mismatch" + assert_contains "$out" "source project repository Git storage depends on the retiring home" \ + "mismatched child fixture did not fail at the earlier project-storage safety boundary" assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close' \ "refused mismatched Orca child cleanup should not close terminals" assert_not_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm' \ @@ -2026,12 +2044,13 @@ test_secondmate_force_teardown_retains_partial_orca_child() { initialize_secondmate_home_repo "$subhome" "$neutral" set +e out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ - FM_ROOT_OVERRIDE="$neutral" FM_HOME="$home" "$ROOT/bin/fm-teardown.sh" domain --force 2>&1 ) + FM_ROOT_OVERRIDE="$neutral" FM_PROJECTS_OVERRIDE="$subhome/projects" FM_HOME="$home" \ + "$ROOT/bin/fm-teardown.sh" domain --force 2>&1 ) rc=$? - set -e + set +e [ "$rc" -ne 0 ] || fail "forced secondmate teardown should refuse partial Orca child state" - assert_contains "$out" "child endpoint identity for $child_id is missing" \ - "partial Orca child refusal did not surface missing endpoint identity" + assert_contains "$out" "source project repository Git storage depends on the retiring home" \ + "partial child fixture did not fail at the earlier project-storage safety boundary" assert_not_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm' \ "partial child cleanup removed an Orca worktree without quiescence proof" assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close' \ @@ -2073,7 +2092,7 @@ test_spawn_refuses_cleanup_pending_orca_task_before_mutation() { FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "spawn reused an Orca cleanup quarantine" assert_contains "$out" "Orca cleanup is pending for $id" \ @@ -2109,7 +2128,7 @@ test_pathless_orca_quarantine_has_supported_cleanup() { FM_CHECKOUT_REFRESH_STATE_BASE="$CASE_DIR/checkout-state" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1) status=$? - set -e + set +e expect_code 0 "$status" "pathless Orca quarantine cleanup"$'\n'"$out" assert_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close'$'\x1f''--terminal'$'\x1f''term-path-cleanup' \ @@ -2142,7 +2161,7 @@ test_idless_orca_quarantine_refuses_unscoped_terminal_close() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "idless Orca quarantine closed an unscoped terminal" assert_contains "$out" "refusing to close an unscoped terminal" \ @@ -2177,7 +2196,7 @@ test_teardown_refuses_orca_terminal_worktree_identity_drift() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "teardown closed a terminal bound to another Orca worktree" assert_contains "$out" "task Orca endpoint authority or quiescence is unproven" \ @@ -2214,7 +2233,7 @@ test_spawn_quarantines_create_response_without_worktree_id() { FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "spawn accepted an Orca create response without a worktree id" assert_grep "worktree=$proj" "$state/$id.meta" \ @@ -2264,7 +2283,7 @@ test_orca_quarantine_write_failure_keeps_prearmed_blocker() { "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1) status=$? chmod 700 "$state" - set -e + set +e [ "$status" -ne 0 ] || fail "spawn ignored an Orca quarantine update failure" assert_grep 'orca_cleanup_pending=1' "$state/$id.meta" \ @@ -2281,7 +2300,7 @@ test_orca_quarantine_write_failure_keeps_prearmed_blocker() { FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \ "$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "spawn reused a task after quarantine publication failed" assert_contains "$out" "Orca cleanup is pending for $id" \ "pre-armed quarantine did not block retry" @@ -2314,7 +2333,7 @@ test_teardown_rejects_cross_task_orca_terminal_label() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "teardown accepted a terminal labeled for another task" assert_contains "$out" "task Orca endpoint authority or quiescence is unproven" \ @@ -2353,7 +2372,7 @@ test_teardown_rejects_cross_task_orca_worktree_label() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "teardown accepted an Orca worktree labeled for another task" assert_contains "$out" "task Orca endpoint authority or quiescence is unproven" \ @@ -2396,7 +2415,7 @@ test_teardown_quiesces_unrecorded_orca_terminals() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" --force 2>&1) status=$? - set -e + set +e expect_code 0 "$status" "unrecorded Orca terminal cleanup"$'\n'"$out" assert_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close'$'\x1f''--terminal'$'\x1f''term-unrecorded' \ @@ -2433,7 +2452,7 @@ test_teardown_rejects_live_recorded_terminal_missing_from_inventory() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ "$ROOT/bin/fm-teardown.sh" "$id" 2>&1) status=$? - set -e + set +e [ "$status" -ne 0 ] || fail "teardown accepted a live recorded terminal omitted from the worktree inventory" assert_contains "$out" "task Orca endpoint authority or quiescence is unproven" \ @@ -2451,6 +2470,8 @@ if [ "${FM_TEST_FOCUSED:-}" = review-round-orca-quiescence ]; then test_terminal_state_classifies_closed_live_and_ambiguous_orca test_scout_teardown_removes_orca_worktree_via_helper test_teardown_refuses_orca_worktree_without_terminal_handle + test_secondmate_force_teardown_removes_orca_child_via_orca + test_secondmate_force_teardown_refuses_orca_child_id_path_mismatch test_secondmate_force_teardown_retains_partial_orca_child exit 0 fi diff --git a/tests/fm-backend-zellij.test.sh b/tests/fm-backend-zellij.test.sh index 7aef69fa4a..e743f4ded4 100755 --- a/tests/fm-backend-zellij.test.sh +++ b/tests/fm-backend-zellij.test.sh @@ -919,8 +919,14 @@ test_teardown_passes_recorded_tab_id_to_zellij_kill() { "worktree=$dir/missing-worktree" \ "project=$project" \ "kind=scout" - printf '[]\n' > "$dir/responses/1.out" - printf '[{"tab_id":3,"name":"fm-zghost"}]\n' > "$dir/responses/2.out" + zellij_pane_response "$dir" 1 7 3 + zellij_tab_response "$dir" 2 3 fm-zghost + printf '[]\n' > "$dir/responses/3.out" + zellij_tab_response "$dir" 4 3 fm-zghost + printf '[]\n' > "$dir/responses/5.out" + printf '[]\n' > "$dir/responses/6.out" + printf '[]\n' > "$dir/responses/7.out" + printf '[]\n' > "$dir/responses/8.out" fb=$(make_zellij_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" FM_ZELLIJ_SESSION_LIST="firstmate" \ diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index a5ce892ffb..a87e540975 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -109,12 +109,14 @@ test_ship_completion_report_contract() { brief="$home/data/$id/brief.md" assert_grep "# Completion report" "$brief" "ship brief has no completion-report contract" assert_grep "data/$id/completion.md" "$brief" "ship brief has no durable report path" - assert_grep "six sections, each as a LEVEL-TWO markdown heading spelled exactly" "$brief" \ - "ship brief does not require exact level-two report headings" + assert_grep "six exact level-two headings in this order" "$brief" \ + "ship brief does not require ordered exact level-two report headings" assert_grep "\`## Summary\`, \`## What changed\`, \`## Verification\`, \`## Visual evidence\`, \`## Artifacts\`, \`## Follow-ups\`" "$brief" \ "ship brief has no exact review-oriented report schema" - assert_grep "a level-one \`# Summary\` fails" "$brief" \ - "ship brief does not reject completion sections at the wrong heading level" + assert_grep "publication also accepts a report that consistently uses the level-one equivalents" "$brief" \ + "ship brief does not describe the accepted level-one compatibility form" + assert_grep "do not mix heading levels" "$brief" \ + "ship brief does not forbid structurally mixed report sections" assert_grep "When a section genuinely does not apply, say so in a sentence under the heading rather than omitting the heading." "$brief" \ "ship brief permits inapplicable report sections to be omitted" assert_grep "data/$id/visuals/" "$brief" "ship brief has no visual evidence path" @@ -132,11 +134,40 @@ test_scout_completion_report_contract() { FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" some-proj --scout >/dev/null 2>&1 brief="$home/data/$id/brief.md" assert_grep "data/$id/report.md" "$brief" "scout brief has no durable report path" - assert_grep "Summary, What changed, Verification, Visual evidence, Artifacts, and Follow-ups" "$brief" \ - "scout brief does not name every enforced report section" + assert_grep "\`## Summary\`, \`## What changed\`, \`## Verification\`, \`## Visual evidence\`, \`## Artifacts\`, \`## Follow-ups\`" "$brief" \ + "scout brief does not name every enforced report heading concretely" pass "fm-brief.sh: scout tasks receive the enforced completion-report schema" } +test_browser_automation_safety_contract() { + local home kind id brief + home="$TMP_ROOT/browser-safety-home" + mkdir -p "$home/data" + for kind in ship scout; do + id="brief-browser-safety-$kind" + if [ "$kind" = scout ]; then + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" some-proj --scout >/dev/null 2>&1 + else + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" some-proj >/dev/null 2>&1 + fi + brief="$home/data/$id/brief.md" + assert_grep "# Browser automation safety" "$brief" "$kind brief omitted browser safety" + assert_grep "Never set \`CHROME_DEVTOOLS_AXI_AUTO_CONNECT=1\` or \`CHROME_DEVTOOLS_AXI_HEADED=1\`." "$brief" \ + "$kind brief omitted both independent visible-browser hazards" + assert_grep "Always set your own \`CHROME_DEVTOOLS_AXI_SESSION\`." "$brief" \ + "$kind brief omitted per-crewmate browser session isolation" + assert_grep "\`CHROME_DEVTOOLS_AXI_USER_DATA_DIR\` to a per-crewmate profile directory" "$brief" \ + "$kind brief omitted persistent authenticated-profile isolation" + assert_grep "persistent profile keeps its login cookie across headless runs" "$brief" \ + "$kind brief omitted authenticated headless reuse" + assert_grep "Never use \`open -a \"Google Chrome\"\` or AppleScript that focuses an app." "$brief" \ + "$kind brief omitted focus-hijack commands" + assert_grep "report the exact step that fails instead of falling back" "$brief" \ + "$kind brief omitted fail-closed headless reporting" + done + pass "fm-brief.sh: ship and scout briefs always carry browser-isolation safety" +} + test_promoted_scout_receives_completion_contract() { local home data id out expected_report home="$TMP_ROOT/promote-report-home" @@ -147,7 +178,7 @@ test_promoted_scout_receives_completion_contract() { out=$(FM_HOME="$home" FM_DATA_OVERRIDE="$data" FM_ROOT_OVERRIDE="$ROOT" "$ROOT/bin/fm-promote.sh" "$id") \ || fail "scout promotion failed" assert_grep 'kind=ship' "$home/state/$id.meta" "scout promotion did not update task kind" - assert_contains "$out" "Summary, What changed, Verification, Visual evidence, Artifacts, and Follow-ups" \ + assert_contains "$out" "\`## Summary\`, \`## What changed\`, \`## Verification\`, \`## Visual evidence\`, \`## Artifacts\`, \`## Follow-ups\`" \ "promoted scout did not receive the ship completion-report schema" expected_report="$data/$id/completion.md" assert_contains "$out" "$expected_report" "promoted scout did not receive the authoritative completion-report path" @@ -454,6 +485,7 @@ test_no_mistakes_dod_wording test_ship_project_memory_wording test_ship_completion_report_contract test_scout_completion_report_contract +test_browser_automation_safety_contract test_promoted_scout_receives_completion_contract test_promote_serializes_with_account_session_updates test_promote_locks_lifecycle_before_metadata_and_rechecks_kind diff --git a/tests/fm-report-stack.test.sh b/tests/fm-report-stack.test.sh index 79a5fcf2ee..6301945b82 100755 --- a/tests/fm-report-stack.test.sh +++ b/tests/fm-report-stack.test.sh @@ -704,6 +704,65 @@ test_required_source_fails_closed() { pass "report stack refuses a required ship report with no completion source" } +test_required_sections_accept_one_top_heading_level_in_order() { + local id source entry manifest out status + + id=report-level-one-b2a + write_task "$id" ship + source="$HOME_DIR/data/$id/completion.md" + cat > "$source" <<'EOF' +# Summary + +Level-one sections are accepted. + +# What changed + +Recorded work. + +# Verification + +Evidence checked. + +# Visual evidence + +None. + +# Artifacts + +Report. + +# Follow-ups + +None. +EOF + run_stack publish "$id" >/dev/null || fail "ordered level-one report sections were rejected" + entry=$(run_stack path "$id") + manifest="$(dirname "$entry")/manifest.json" + assert_grep '"summary": "Level one sections are accepted."' "$manifest" \ + "manifest summary did not follow the accepted level-one section structure" + + id=report-mixed-levels-b2b + write_task "$id" ship + source="$HOME_DIR/data/$id/completion.md" + printf '# Summary\n\nComplete.\n\n## What changed\n\nChanged.\n\n## Verification\n\nVerified.\n\n## Visual evidence\n\nNone.\n\n## Artifacts\n\nReport.\n\n## Follow-ups\n\nNone.\n' > "$source" + out=$(run_stack publish "$id" 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "mixed level-one and level-two report sections unexpectedly published" + assert_contains "$out" "do not share one top structural level (# or ##)" \ + "mixed-heading refusal did not explain the common-level requirement" + + id=report-out-of-order-b2c + write_task "$id" ship + source="$HOME_DIR/data/$id/completion.md" + printf '# Summary\n\nComplete.\n\n# Verification\n\nVerified.\n\n# What changed\n\nChanged.\n\n# Visual evidence\n\nNone.\n\n# Artifacts\n\nReport.\n\n# Follow-ups\n\nNone.\n' > "$source" + out=$(run_stack publish "$id" 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "out-of-order level-one report sections unexpectedly published" + assert_contains "$out" "required section headings are out of order" \ + "out-of-order refusal did not explain the ordering requirement" + pass "report stack accepts level one or two while rejecting mixed and out-of-order sections" +} + test_required_sections_fail_actionably() { local id=report-headings-b3 out status before after source heading write_task "$id" ship @@ -4018,6 +4077,12 @@ if [ "${FM_TEST_FOCUSED:-}" = report-fence-enforcement ]; then exit 0 fi +if [ "${FM_TEST_FOCUSED:-}" = report-heading-levels ]; then + test_required_sections_accept_one_top_heading_level_in_order + test_required_sections_reject_empty_bodies + exit 0 +fi + if [ "${FM_TEST_FOCUSED:-}" = fenced-report-body-final ]; then test_fenced_required_section_bodies_use_scoped_content test_nested_short_fences_do_not_satisfy_required_sections @@ -4097,6 +4162,7 @@ test_metadata_is_bounded_before_reading test_report_temps_are_exclusive_and_randomized test_visual_inventory_is_count_and_depth_bounded test_required_source_fails_closed +test_required_sections_accept_one_top_heading_level_in_order test_required_sections_fail_actionably test_required_sections_reject_empty_bodies test_required_sections_reject_container_only_markers diff --git a/tests/fm-send-popup-settle.test.sh b/tests/fm-send-popup-settle.test.sh index 685ce627ab..bf21ea963d 100755 --- a/tests/fm-send-popup-settle.test.sh +++ b/tests/fm-send-popup-settle.test.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# fm-send pre-submit popup-settle selection (the codex `$` fix). +# fm-send bare-skill syntax guard and pre-submit popup-settle selection. # # Some TUIs open a completion popup when the composer's first character triggers # it: codex (and others) for a leading `/` slash command, and codex specifically @@ -24,6 +24,12 @@ # real safety net; this settle is only the optimization that lets the popup clear so # the first Enter lands. # +# A bare installed-skill token is refused before typing when its prefix is wrong +# for the resolved harness. That guard is intentionally separate from settle: +# matching forms still reach the matrix below, ordinary slash commands and +# multi-token `$` prose keep their old behavior, and unknown-harness explicit +# targets remain an escape hatch. +# # Every case below passes a LITERAL `$` / `$price` message in single quotes # on purpose - the whole point is to send an unexpanded `$...` line to the agent - # so SC2016 (which flags single-quoted `$` as a probably-forgotten expansion) is a @@ -109,6 +115,25 @@ first_settle() { #