diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index ed030a08a8..6cdf9879ac 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -450,7 +450,15 @@ launch_template() { # does NOT suppress the interactive ghost text (verified empirically), so the env # var is the correct control. The dim-aware composer reader in fm-tmux-lib.sh is # the defense-in-depth backstop for any pane this flag cannot reach. - claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + # env -u ANTHROPIC_API_KEY scopes out any ambient key (e.g. one set on the + # herdr server process this pane inherits from) so claude's own auth-source + # check always prefers the claude.ai OAuth login over API-key billing. + # Verified empirically (claude doctor) that an assignment to empty + # (ANTHROPIC_API_KEY=) is also treated as absent by claude's own check, but + # env -u is used here as the unambiguous true-unset idiom. Scoped to this + # one launch command; never touches the captain's own environment or + # dotfiles. + claude) printf '%s' 'env -u ANTHROPIC_API_KEY CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; codex) if [ "$kind" = secondmate ]; then printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 7e57655218..d099fe782f 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -123,7 +123,7 @@ test_no_profile_keeps_claude_profile_defaults() { assert_meta_profile "$HOME_DIR/state/$id.meta" claude default default launch=$(cat "$LAUNCH_LOG") - expected="FM_HOME_BINDING= CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions \"\$('${ROOT}/bin/fm-operational-input.sh' encode launch-brief < '$HOME_DIR/data/$id/brief.md')\"" + expected="FM_HOME_BINDING= env -u ANTHROPIC_API_KEY CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions \"\$('${ROOT}/bin/fm-operational-input.sh' encode launch-brief < '$HOME_DIR/data/$id/brief.md')\"" [ "$launch" = "$expected" ] || fail "no-profile claude launch did not use the canonical launch kind"$'\n'"expected: $expected"$'\n'"actual: $launch" pass "no --model/--effort records defaults and types the claude launch instructions" } @@ -599,7 +599,7 @@ test_claude_forwards_firstmate_config_dir_when_set() { status=$? expect_code 0 "$status" "claude spawn with CLAUDE_CONFIG_DIR set should succeed" launch=$(cat "$LAUNCH_LOG") - assert_contains "$launch" "CLAUDE_CONFIG_DIR='/opt/test/claude-work' CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude" \ + assert_contains "$launch" "CLAUDE_CONFIG_DIR='/opt/test/claude-work' env -u ANTHROPIC_API_KEY CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude" \ "claude launch did not forward firstmate's CLAUDE_CONFIG_DIR to the crewmate pane" pass "claude forwards firstmate's CLAUDE_CONFIG_DIR so the crewmate uses the same credential store" } @@ -637,6 +637,36 @@ test_non_claude_harness_ignores_config_dir() { pass "non-claude harnesses do not receive the claude CLAUDE_CONFIG_DIR prefix" } +test_claude_scopes_out_ambient_anthropic_api_key() { + local rec id out status launch + id=profile-claude-authkey-z20 + rec=$(make_spawn_case profile-claude-authkey claude "$id") + read_case_record "$rec" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "claude spawn should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "env -u ANTHROPIC_API_KEY CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude" \ + "claude launch must scope out any ambient ANTHROPIC_API_KEY so OAuth login wins over API-key billing" + pass "claude launch unsets ANTHROPIC_API_KEY ahead of the claude binary" +} + +test_non_claude_harness_does_not_scope_anthropic_api_key() { + local rec id out status launch + id=profile-codex-authkey-z21 + rec=$(make_spawn_case profile-codex-authkey codex "$id") + read_case_record "$rec" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "codex spawn should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_not_contains "$launch" "ANTHROPIC_API_KEY" \ + "non-claude harness launch must not be touched by the claude-specific ANTHROPIC_API_KEY scoping" + pass "non-claude harnesses are unaffected by the claude ANTHROPIC_API_KEY fix" +} + test_active_dispatch_profile_does_not_block_secondmate_launch() { local rec id sm out status id=profile-secondmate-z16 @@ -680,6 +710,8 @@ test_batch_forwards_shared_profile_flags test_claude_forwards_firstmate_config_dir_when_set test_claude_omits_config_dir_prefix_when_unset test_non_claude_harness_ignores_config_dir +test_claude_scopes_out_ambient_anthropic_api_key +test_non_claude_harness_does_not_scope_anthropic_api_key test_active_dispatch_profile_does_not_block_secondmate_launch echo "# all fm-spawn-dispatch-profile tests passed"