diff --git a/cco b/cco index e1e5e00..91a9815 100755 --- a/cco +++ b/cco @@ -290,45 +290,202 @@ claude_args_include_permission_mode() { return 1 } -read_claude_permission_setting() { - local settings_file="$1" - local setting_name="$2" +# bjq is a bashy hack for the tiny bit of jq we need here: simple dot keys +# plus numeric array indexes. It is intentionally not a jq clone. +bjq_query_to_path_json() { + local raw="$1" + local path_json="[" + local separator="" - if [[ ! -f "$settings_file" ]]; then + if [[ "$raw" == .* ]]; then + raw="${raw#.}" + fi + + if [[ -z "$raw" ]]; then return 1 fi - if command -v python3 >/dev/null 2>&1; then - python3 - "$settings_file" "$setting_name" <<'PY' 2>/dev/null + while [[ -n "$raw" ]]; do + if [[ "$raw" =~ ^([A-Za-z_][A-Za-z0-9_-]*)(.*)$ ]]; then + path_json+="${separator}\"${BASH_REMATCH[1]}\"" + separator="," + raw="${BASH_REMATCH[2]}" + elif [[ "$raw" =~ ^\[([0-9]+)\](.*)$ ]]; then + path_json+="${separator}${BASH_REMATCH[1]}" + separator="," + raw="${BASH_REMATCH[2]}" + else + return 1 + fi + + if [[ "$raw" == .* ]]; then + raw="${raw#.}" + if [[ -z "$raw" || "$raw" == .* ]]; then + return 1 + fi + fi + done + + path_json+="]" + printf '%s\n' "$path_json" +} + +bjq() { + local query="$1" + local json_file="${2:-}" + local temp_json_file="" + local output_mode="${BJQ_OUTPUT:-value}" + local path_json + + path_json=$(bjq_query_to_path_json "$query") || return 2 + + if [[ -n "$json_file" && ! -f "$json_file" ]]; then + return 1 + fi + + if command -v jq >/dev/null 2>&1; then + if [[ -z "$json_file" ]]; then + temp_json_file=$(mktemp) + cat >"$temp_json_file" + json_file="$temp_json_file" + fi + + if ! jq . "$json_file" >/dev/null 2>&1; then + [[ -n "$temp_json_file" ]] && rm -f "$temp_json_file" + return 2 + fi + + local jq_program + if [[ "$output_mode" == "type" ]]; then + # shellcheck disable=SC2016 + jq_program=' + def path_lookup($p): + reduce $p[] as $part ({ok: true, v: .}; + if (.ok | not) then . + elif ($part | type) == "number" then + if ((.v | type) == "array") and ($part >= 0) and ($part < (.v | length)) then {ok: true, v: .v[$part]} else {ok: false, v: null} end + else + if ((.v | type) == "object") and (.v | has($part)) then {ok: true, v: .v[$part]} else {ok: false, v: null} end + end); + path_lookup($path) as $result + | if $result.ok then $result.v else empty end + | type + ' + else + # shellcheck disable=SC2016 + jq_program=' + def path_lookup($p): + reduce $p[] as $part ({ok: true, v: .}; + if (.ok | not) then . + elif ($part | type) == "number" then + if ((.v | type) == "array") and ($part >= 0) and ($part < (.v | length)) then {ok: true, v: .v[$part]} else {ok: false, v: null} end + else + if ((.v | type) == "object") and (.v | has($part)) then {ok: true, v: .v[$part]} else {ok: false, v: null} end + end); + path_lookup($path) as $result + | if $result.ok then $result.v else empty end + | if type == "string" then . + elif type == "boolean" or type == "number" or type == "null" then tostring + else tojson + end + ' + fi + + if [[ -n "$json_file" ]]; then + jq -er --argjson path "$path_json" "$jq_program" "$json_file" + else + jq -er --argjson path "$path_json" "$jq_program" + fi + local status=$? + [[ -n "$temp_json_file" ]] && rm -f "$temp_json_file" + [[ $status -eq 4 ]] && return 1 + return "$status" + fi + + if ! command -v python3 >/dev/null 2>&1; then + return 127 + fi + + if [[ -z "$json_file" ]]; then + temp_json_file=$(mktemp) + cat >"$temp_json_file" + json_file="$temp_json_file" + fi + + python3 - "$path_json" "$json_file" "$output_mode" <<'PY' import json import sys +path = json.loads(sys.argv[1]) +json_file = sys.argv[2] +output_mode = sys.argv[3] + try: - with open(sys.argv[1], "r", encoding="utf-8") as handle: - settings = json.load(handle) + with open(json_file, "r", encoding="utf-8") as handle: + value = json.load(handle) except Exception: - sys.exit(1) + sys.exit(2) -permissions = settings.get("permissions") -if not isinstance(permissions, dict): +try: + for part in path: + if isinstance(part, int): + if not isinstance(value, list) or part >= len(value): + sys.exit(1) + value = value[part] + else: + if not isinstance(value, dict) or part not in value: + sys.exit(1) + value = value[part] +except Exception: sys.exit(1) -value = permissions.get(sys.argv[2]) -if isinstance(value, str): +if output_mode == "type": + if isinstance(value, str): + print("string") + elif value is True or value is False: + print("boolean") + elif value is None: + print("null") + elif isinstance(value, (int, float)): + print("number") + elif isinstance(value, list): + print("array") + elif isinstance(value, dict): + print("object") + else: + sys.exit(1) +elif isinstance(value, str): print(value) - sys.exit(0) - -sys.exit(1) +elif value is True: + print("true") +elif value is False: + print("false") +elif value is None: + print("null") +elif isinstance(value, (int, float)): + print(value) +else: + print(json.dumps(value, separators=(",", ":"))) PY - return - fi + local status=$? + [[ -n "$temp_json_file" ]] && rm -f "$temp_json_file" + return "$status" +} - if command -v jq >/dev/null 2>&1; then - jq -er --arg name "$setting_name" '.permissions[$name] // empty | strings' "$settings_file" 2>/dev/null - return +bjq_type() { + local BJQ_OUTPUT=type + bjq "$@" +} + +read_claude_permission_setting() { + local settings_file="$1" + local setting_name="$2" + + if [[ ! -f "$settings_file" ]]; then + return 1 fi - return 1 + bjq "permissions.$setting_name" "$settings_file" 2>/dev/null } trusted_claude_settings_paths() { @@ -336,6 +493,31 @@ trusted_claude_settings_paths() { printf '%s\n' "$(find_claude_config_dir)/settings.json" } +claude_managed_settings_path() { + case "$(uname -s)" in + Darwin) + printf '%s\n' "/Library/Application Support/ClaudeCode/managed-settings.json" + ;; + Linux) + printf '%s\n' "/etc/claude-code/managed-settings.json" + ;; + esac +} + +claude_external_auth_settings_paths() { + local user_settings + user_settings="$(find_claude_config_dir)/settings.json" + + # Docker sessions only mount user/project settings into the container, so + # managed host settings cannot be trusted to satisfy auth inside Docker. + if [[ "$SANDBOX_BACKEND" != "docker" ]]; then + claude_managed_settings_path || true + fi + printf '%s\n' "$PWD/.claude/settings.local.json" + printf '%s\n' "$PWD/.claude/settings.json" + printf '%s\n' "$user_settings" +} + claude_auto_mode_disabled() { local settings_file value @@ -527,84 +709,92 @@ handle_add_dir_entry() { esac } -# Parse additionalDirectories from .claude/settings.local.json -# Prefer python3 for portability and fall back to jq when available. -load_additional_directories_from_settings() { - local settings_file="$PWD/.claude/settings.local.json" - if [[ ! -f "$settings_file" ]]; then +load_project_env_file_into_environment() { + if [[ ! -f ".env" ]]; then return fi - local parser="" - if command -v python3 &>/dev/null; then - parser="python3" - elif command -v jq &>/dev/null; then - parser="jq" - else - warn "python3/jq not found; skipping additionalDirectories from $settings_file" - return - fi + while IFS= read -r line || [[ -n "$line" ]]; do + # Match Docker-style comment handling by ignoring leading whitespace. + local trimmed_line="${line#"${line%%[![:space:]]*}"}" + [[ -z "$trimmed_line" || "$trimmed_line" == \#* ]] && continue + [[ "$trimmed_line" != *"="* ]] && continue + local env_key="${line%%=*}" + local env_val="${line#*=}" + export "${env_key}=${env_val}" + done <".env" +} - local dirs parse_output parse_error parse_status - if [[ "$parser" == "python3" ]]; then - if parse_output=$( - python3 - "$settings_file" 2>&1 <<'PY' -import json -import sys +apply_custom_env_vars_to_environment() { + local custom_env env_key env_val -try: - with open(sys.argv[1], encoding="utf-8") as handle: - data = json.load(handle) -except Exception as exc: - print(f"Unable to parse settings.local.json: {exc}", file=sys.stderr) - sys.exit(1) + if ! declare -p custom_env_vars >/dev/null 2>&1; then + return + fi -directories = data.get("additionalDirectories", []) -if directories is None: - sys.exit(0) -if not isinstance(directories, list): - print("Unable to parse settings.local.json: additionalDirectories must be an array", file=sys.stderr) - sys.exit(1) + if ((${#custom_env_vars[@]} == 0)); then + return + fi -for entry in directories: - if isinstance(entry, str): - print(entry) -PY - ); then - parse_status=0 + for custom_env in "${custom_env_vars[@]}"; do + if [[ "$custom_env" == *"="* ]]; then + env_key="${custom_env%%=*}" + env_val="${custom_env#*=}" + export "${env_key}=${env_val}" else - parse_status=$? + # KEY-only format: pass through from host environment + if [[ -n "${!custom_env}" ]]; then + export "${custom_env}=${!custom_env}" + fi fi + done +} + +apply_preflight_environment() { + load_project_env_file_into_environment + apply_custom_env_vars_to_environment +} + +# Parse additionalDirectories from .claude/settings.local.json. +load_additional_directories_from_settings() { + local settings_file="$PWD/.claude/settings.local.json" + if [[ ! -f "$settings_file" ]]; then + return + fi + + local dirs_type parse_status + if dirs_type=$(bjq_type "additionalDirectories" "$settings_file" 2>/dev/null); then + parse_status=0 else - if parse_output=$(jq -r ' - (.additionalDirectories // []) as $dirs - | if ($dirs | type) != "array" then - error("Unable to parse settings.local.json: additionalDirectories must be an array") - else - $dirs[] - end - | select(type == "string") - ' "$settings_file" 2>&1); then - parse_status=0 - else - parse_status=$? + parse_status=$? + if [[ $parse_status -eq 127 ]]; then + warn "python3/jq not found; skipping additionalDirectories from $settings_file" + elif [[ $parse_status -ne 1 ]]; then + warn "Skipping additionalDirectories from $settings_file: Unable to parse settings.local.json" fi + return fi - if [[ $parse_status -ne 0 ]]; then - parse_error=$(printf '%s' "$parse_output" | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g; s/^ //; s/ $//') - if [[ -z "$parse_error" ]]; then - parse_error="Unable to parse settings.local.json" - fi - warn "Skipping additionalDirectories from $settings_file: $parse_error" + if [[ "$dirs_type" == "null" ]]; then + return + fi + + if [[ "$dirs_type" != "array" ]]; then + warn "Skipping additionalDirectories from $settings_file: Unable to parse settings.local.json: additionalDirectories must be an array" return fi - dirs="$parse_output" + local dir dir_type index=0 + while dir_type=$(bjq_type "additionalDirectories[$index]" "$settings_file" 2>/dev/null); do + if [[ "$dir_type" != "string" ]]; then + index=$((index + 1)) + continue + fi - local dir - while IFS= read -r dir; do + dir=$(bjq "additionalDirectories[$index]" "$settings_file" 2>/dev/null || true) + index=$((index + 1)) [[ -z "$dir" ]] && continue + local resolved resolved=$(resolve_path "$dir") if [[ -n "$resolved" && -d "$resolved" ]]; then @@ -615,7 +805,7 @@ PY else warn "Skipping additionalDirectories entry (not a directory): $dir" fi - done <<<"$dirs" + done } # Check if Docker is available (only when using Docker backend) @@ -676,6 +866,51 @@ find_claude_config_dir() { echo "$primary_dir" } +read_claude_settings_string_if_present() { + local settings_file="$1" + local query="$2" + local value value_type + if [[ ! -f "$settings_file" ]]; then + return 1 + fi + + value_type=$(bjq_type "$query" "$settings_file" 2>/dev/null || true) + if [[ "$value_type" != "string" ]]; then + return 1 + fi + + value=$(bjq "$query" "$settings_file" 2>/dev/null || true) + printf '%s\n' "$value" +} + +claude_settings_has_external_auth() { + local settings_file query value + local auth_queries=( + "apiKeyHelper" + "env.ANTHROPIC_API_KEY" + "env.ANTHROPIC_AUTH_TOKEN" + "env.CLAUDE_CODE_OAUTH_TOKEN" + ) + + for query in "${auth_queries[@]}"; do + while IFS= read -r settings_file; do + if value=$(read_claude_settings_string_if_present "$settings_file" "$query"); then + [[ "$value" =~ [^[:space:]] ]] && return 0 + break + fi + done < <(claude_external_auth_settings_paths) + done + + return 1 +} + +using_external_claude_auth() { + [[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" || + -n "${ANTHROPIC_API_KEY:-}" || + -n "${ANTHROPIC_AUTH_TOKEN:-}" ]] || + claude_settings_has_external_auth +} + get_claude_keychain_account() { if [[ -n "${USER:-}" ]]; then printf '%s\n' "$USER" @@ -797,70 +1032,27 @@ get_claude_credentials_payload() { extract_oauth_expiry_ms() { local credentials_payload="$1" - local expires_at="" parse_status=0 + local expires_at="" query value value_type # Prefer the Claude OAuth expiry field, but tolerate the older flat payload shape. - if command -v python3 &>/dev/null; then - if expires_at=$( - python3 - "$credentials_payload" 2>/dev/null <<'PY' -import json -import sys - -try: - data = json.loads(sys.argv[1]) -except Exception: - sys.exit(1) + for query in "claudeAiOauth.expiresAt" "expiresAt"; do + value_type=$(printf '%s' "$credentials_payload" | bjq_type "$query" 2>/dev/null || true) + if [[ "$value_type" != "number" && "$value_type" != "string" ]]; then + continue + fi -candidates = [] -claude_oauth = data.get("claudeAiOauth") -if isinstance(claude_oauth, dict): - candidates.append(claude_oauth.get("expiresAt")) -candidates.append(data.get("expiresAt")) - -for value in candidates: - if isinstance(value, bool): - continue - if isinstance(value, int): - print(value) - sys.exit(0) - if isinstance(value, float) and value.is_integer(): - print(int(value)) - sys.exit(0) - if isinstance(value, str) and value.isdigit(): - print(value) - sys.exit(0) - -sys.exit(1) -PY - ); then - parse_status=0 - else - parse_status=$? - fi - elif command -v jq &>/dev/null; then - if expires_at=$(printf '%s' "$credentials_payload" | jq -r ' - [ - .claudeAiOauth.expiresAt?, - .expiresAt? - ] - | map( - if type == "number" then tostring - elif type == "string" and test("^[0-9]+$") then . - else null - end - ) - | map(select(. != null)) - | .[0] // empty - ' 2>/dev/null); then - parse_status=0 - else - parse_status=$? + value=$(printf '%s' "$credentials_payload" | bjq "$query" 2>/dev/null || true) + if [[ "$value_type" == "number" && "$value" =~ ^[0-9]+([.]0+)?$ ]]; then + expires_at="${value%%.*}" + break fi - else - return 1 - fi + if [[ "$value_type" == "string" && "$value" =~ ^[0-9]+$ ]]; then + expires_at="$value" + break + fi + done - if [[ $parse_status -ne 0 || -z "$expires_at" || ! "$expires_at" =~ ^[0-9]+$ ]]; then + if [[ -z "$expires_at" || ! "$expires_at" =~ ^[0-9]+$ ]]; then return 1 fi @@ -1108,8 +1300,8 @@ ensure_accessible_macos_keychain_credentials() { } ensure_refreshable_oauth_credentials() { - # Token from environment is managed externally; nothing to refresh - if [[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then + # Externally managed auth has no local OAuth credential to refresh. + if using_external_claude_auth; then return 0 fi @@ -1164,9 +1356,8 @@ ensure_refreshable_oauth_credentials() { verify_claude_authentication() { log "Verifying Claude Code authentication..." - # If CLAUDE_CODE_OAUTH_TOKEN is set, Claude Code will use it directly - if [[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then - log "Using CLAUDE_CODE_OAUTH_TOKEN from environment" + if using_external_claude_auth; then + log "Using externally managed Claude authentication" return 0 fi @@ -1864,31 +2055,9 @@ run_native_sandbox() { fi # Load .env file first (matching Docker backend behavior) - if [[ -f ".env" ]]; then - while IFS= read -r line || [[ -n "$line" ]]; do - # Match Docker-style comment handling by ignoring leading whitespace. - local trimmed_line="${line#"${line%%[![:space:]]*}"}" - [[ -z "$trimmed_line" || "$trimmed_line" == \#* ]] && continue - [[ "$trimmed_line" != *"="* ]] && continue - local env_key="${line%%=*}" - local env_val="${line#*=}" - export "${env_key}=${env_val}" - done <".env" - fi - + load_project_env_file_into_environment # Apply custom environment variables after .env so -e flags take precedence - for custom_env in "${custom_env_vars[@]}"; do - if [[ "$custom_env" == *"="* ]]; then - local env_key="${custom_env%%=*}" - local env_val="${custom_env#*=}" - export "${env_key}=${env_val}" - else - # KEY-only format: pass through from host environment - if [[ -n "${!custom_env}" ]]; then - export "${custom_env}=${!custom_env}" - fi - fi - done + apply_custom_env_vars_to_environment # Execute in sandbox with full environment exec "${cmd[@]}" @@ -2158,6 +2327,7 @@ run_container() { # Anthropic / Claude Code "ANTHROPIC_API_KEY" "ANTHROPIC_BASE_URL" + "ANTHROPIC_AUTH_TOKEN" "CLAUDE_CODE_OAUTH_TOKEN" "CLAUDE_CONFIG_DIR" # OpenAI / Codex @@ -3341,6 +3511,7 @@ if [[ $# -gt 0 ]]; then echo " Trust external git common dirs for worktree support" echo " CCO_SANDBOX_ARGS_FILE Persistent sandbox args file (one arg per line)" echo " ANTHROPIC_API_KEY Passed through automatically" + echo " ANTHROPIC_AUTH_TOKEN Passed through automatically" echo " CLAUDE_CODE_OAUTH_TOKEN Passed through (skips credential file check)" echo " OPENAI_API_KEY Passed through automatically" echo " GEMINI_API_KEY Passed through automatically" @@ -3395,6 +3566,7 @@ main() { # Only verify Claude authentication if we need it if needs_claude_authentication; then + apply_preflight_environment verify_claude_authentication ensure_refreshable_oauth_credentials fi diff --git a/tests/test_additional_directories.sh b/tests/test_additional_directories.sh index d4b4e5a..785a0b8 100755 --- a/tests/test_additional_directories.sh +++ b/tests/test_additional_directories.sh @@ -196,6 +196,9 @@ eval "$(sed -n ' /^remove_path_from_array()/,/^}/p /^resolve_path()/,/^}/p /^add_rw_path()/,/^}/p + /^bjq_query_to_path_json()/,/^}/p + /^bjq()/,/^}/p + /^bjq_type()/,/^}/p /^needs_claude_authentication()/,/^}/p /^load_additional_directories_from_settings()/,/^}/p ' "$CCO_BIN")" diff --git a/tests/test_startup_preflights.sh b/tests/test_startup_preflights.sh index 113ba77..9ac014d 100644 --- a/tests/test_startup_preflights.sh +++ b/tests/test_startup_preflights.sh @@ -2,7 +2,7 @@ # Regression tests for startup recovery preflights. # Covers OAuth refresh prompting, macOS-over-SSH keychain recovery, # and the global --yes flag. -# shellcheck disable=SC1090,SC2030,SC2031,SC2034,SC2317 +# shellcheck disable=SC1090,SC2016,SC2030,SC2031,SC2034,SC2317 set -euo pipefail @@ -74,6 +74,7 @@ echo "Test: --help documents --yes" if output=$("$CCO_BIN" --help 2>&1); then assert_contains "$output" "--yes, -y Auto-accept startup recovery prompts" "--help shows --yes flag" assert_contains "$output" "CLAUDE_CODE_OAUTH_TOKEN" "--help documents external Claude token" + assert_contains "$output" "ANTHROPIC_AUTH_TOKEN" "--help documents Anthropic auth token passthrough" else echo " output:" printf '%s\n' "$output" | sed 's/^/ /' @@ -99,6 +100,68 @@ else fail "Claude auth helpers prefer ~/.claude and hash custom config dirs" fi +echo "" +echo "Test: bjq reads simple paths and array indexes" +if ( + source "$FUNCTIONS_ONLY" + json_file="$TEST_ROOT/bjq.json" + printf '%s\n' '{"permissions":{"defaultMode":"auto"},"items":[{"name":"first"},{"name":"second"}],"count":42,"enabled":false,"empty":null,"object":{"x":1},"array":["x","y"]}' >"$json_file" + [[ "$(bjq "permissions.defaultMode" "$json_file")" == "auto" ]] + [[ "$(bjq ".items[1].name" "$json_file")" == "second" ]] + [[ "$(bjq "count" "$json_file")" == "42" ]] + [[ "$(bjq "enabled" "$json_file")" == "false" ]] + [[ "$(bjq "empty" "$json_file")" == "null" ]] + [[ "$(bjq "object" "$json_file")" == '{"x":1}' ]] + [[ "$(bjq "array" "$json_file")" == '["x","y"]' ]] + [[ "$(bjq_type "items" "$json_file")" == "array" ]] + [[ "$(printf '{"stdin":["a","b"]}' | bjq "stdin[1]")" == "b" ]] + ! bjq "missing.key" "$json_file" >/dev/null 2>&1 + ! bjq "items[-1]" "$json_file" >/dev/null 2>&1 + ! bjq "items..name" "$json_file" >/dev/null 2>&1 + bad_json_file="$TEST_ROOT/bjq-bad.json" + printf '{"broken":\n' >"$bad_json_file" + if bjq "broken" "$bad_json_file" >/dev/null 2>&1; then + false + else + [[ $? -eq 2 ]] + fi +); then + pass "bjq reads simple paths and array indexes" +else + fail "bjq reads simple paths and array indexes" +fi + +echo "" +echo "Test: bjq falls back to python3 when jq is absent" +python_path=$(command -v python3 2>/dev/null || true) +if [[ -z "$python_path" ]]; then + skip "python3 unavailable for bjq fallback" +elif ( + source "$FUNCTIONS_ONLY" + fallback_bin="$TEST_ROOT/bjq-python-bin" + mkdir -p "$fallback_bin" + ln -s "$python_path" "$fallback_bin/python3" + ln -s "$(command -v mktemp)" "$fallback_bin/mktemp" + ln -s "$(command -v cat)" "$fallback_bin/cat" + ln -s "$(command -v rm)" "$fallback_bin/rm" + json_file="$TEST_ROOT/bjq-python.json" + printf '%s\n' '{"items":[{"name":"first"},{"name":"second"}]}' >"$json_file" + PATH="$fallback_bin" + [[ "$(bjq "items[1].name" "$json_file")" == "second" ]] + [[ "$(bjq_type "items[0]" "$json_file")" == "object" ]] + bad_json_file="$TEST_ROOT/bjq-python-bad.json" + printf '{"broken":\n' >"$bad_json_file" + if bjq "broken" "$bad_json_file" >/dev/null 2>&1; then + false + else + [[ $? -eq 2 ]] + fi +); then + pass "bjq falls back to python3 when jq is absent" +else + fail "bjq falls back to python3 when jq is absent" +fi + echo "" echo "Test: login keychain paths are trimmed before reuse" if ( @@ -271,6 +334,33 @@ else fail "disabled auto mode keeps default bypass flag" fi +echo "" +echo "Test: additionalDirectories is silent when key is absent" +if output=$( + TEST_ROOT="$TEST_ROOT" FUNCTIONS_ONLY="$FUNCTIONS_ONLY" bash <<'EOF' 2>&1 +set -euo pipefail +source "$FUNCTIONS_ONLY" +project_dir="$TEST_ROOT/no-additional-directories-project" +mkdir -p "$project_dir/.claude" +printf '{"permissions":{"defaultMode":"auto"}}\n' >"$project_dir/.claude/settings.local.json" +cd "$project_dir" +additional_dirs=() +load_additional_directories_from_settings +EOF +); then + if [[ "$output" == *"Skipping additionalDirectories"* ]]; then + echo " output:" + printf '%s\n' "$output" | sed 's/^/ /' + fail "missing additionalDirectories key produces no warning" + else + pass "missing additionalDirectories key produces no warning" + fi +else + echo " output:" + printf '%s\n' "$output" | sed 's/^/ /' + fail "missing additionalDirectories key does not fail" +fi + echo "" echo "Test: OAuth preflight repairs expired credentials before startup" if ( @@ -436,6 +526,107 @@ else fail "OAuth preflight skips refresh when external token is provided" fi +echo "" +echo "Test: OAuth preflight skips refresh when ANTHROPIC_API_KEY is set" +if ( + PATH="$FAKE_BIN:$PATH" + source "$FUNCTIONS_ONLY" + export ANTHROPIC_API_KEY="test-api-key" + payload_reads=0 + get_claude_credentials_payload() { + payload_reads=$((payload_reads + 1)) + return 0 + } + ensure_refreshable_oauth_credentials + [[ "$payload_reads" -eq 0 ]] +); then + pass "OAuth preflight skips refresh when Anthropic API key is provided" +else + fail "OAuth preflight skips refresh when Anthropic API key is provided" +fi + +echo "" +echo "Test: OAuth preflight skips refresh when settings env has ANTHROPIC_AUTH_TOKEN" +if ( + PATH="$FAKE_BIN:$PATH" + source "$FUNCTIONS_ONLY" + project_dir="$TEST_ROOT/settings-env-auth-project" + mkdir -p "$project_dir/.claude" + printf '{"env":{"ANTHROPIC_AUTH_TOKEN":"test-auth-token"}}\n' >"$project_dir/.claude/settings.local.json" + cd "$project_dir" + payload_reads=0 + get_claude_credentials_payload() { + payload_reads=$((payload_reads + 1)) + return 0 + } + ensure_refreshable_oauth_credentials + [[ "$payload_reads" -eq 0 ]] +); then + pass "OAuth preflight skips refresh when settings env has Anthropic auth token" +else + fail "OAuth preflight skips refresh when settings env has Anthropic auth token" +fi + +echo "" +echo "Test: OAuth preflight does not use managed settings in Docker backend" +if ( + PATH="$FAKE_BIN:$PATH" + source "$FUNCTIONS_ONLY" + unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN + export HOME="$TEST_ROOT/docker-managed-home" + unset CLAUDE_CONFIG_DIR + project_dir="$TEST_ROOT/docker-managed-project" + mkdir -p "$project_dir/.claude" "$HOME/.claude" + cd "$project_dir" + SANDBOX_BACKEND="docker" + managed_settings="$TEST_ROOT/docker-managed-settings.json" + printf '{"env":{"ANTHROPIC_API_KEY":"managed-api-key"}}\n' >"$managed_settings" + claude_managed_settings_path() { + printf '%s\n' "$managed_settings" + } + counter_file="$TEST_ROOT/docker-managed-payload-reads" + get_claude_credentials_payload() { + printf 'read\n' >>"$counter_file" + return 0 + } + ensure_refreshable_oauth_credentials + [[ "$(wc -l <"$counter_file")" -eq 1 ]] +); then + pass "Docker backend ignores host managed settings for auth preflight" +else + fail "Docker backend ignores host managed settings for auth preflight" +fi + +echo "" +echo "Test: OAuth preflight honors higher-priority blank settings auth override" +if ( + PATH="$FAKE_BIN:$PATH" + source "$FUNCTIONS_ONLY" + unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN + export HOME="$TEST_ROOT/blank-auth-override-home" + unset CLAUDE_CONFIG_DIR + mkdir -p "$HOME/.claude" + claude_managed_settings_path() { + printf '%s\n' "$TEST_ROOT/no-managed-settings.json" + } + printf '{"env":{"ANTHROPIC_API_KEY":"user-api-key"}}\n' >"$HOME/.claude/settings.json" + project_dir="$TEST_ROOT/blank-auth-override-project" + mkdir -p "$project_dir/.claude" + printf '{"env":{"ANTHROPIC_API_KEY":" "}}\n' >"$project_dir/.claude/settings.local.json" + cd "$project_dir" + counter_file="$TEST_ROOT/blank-auth-override-payload-reads" + get_claude_credentials_payload() { + printf 'read\n' >>"$counter_file" + return 0 + } + ensure_refreshable_oauth_credentials + [[ "$(wc -l <"$counter_file")" -eq 1 ]] +); then + pass "higher-priority blank settings auth overrides lower-priority value" +else + fail "higher-priority blank settings auth overrides lower-priority value" +fi + echo "" echo "Test: OAuth expiry extraction prefers claudeAiOauth over mcpOAuth entries" if ( @@ -511,6 +702,162 @@ else fail "auth file checks are skipped when external token is provided" fi +echo "" +echo "Test: auth file checks are skipped when ANTHROPIC_API_KEY is set" +if ( + PATH="$FAKE_BIN:$PATH" + source "$FUNCTIONS_ONLY" + export ANTHROPIC_API_KEY="test-api-key" + export HOME="$TEST_ROOT/external-api-key-home" + unset CLAUDE_CONFIG_DIR + keychain_attempts=0 + find_claude_config_dir() { + printf '%s\n' "$TEST_ROOT/missing-api-key-claude-config" + } + capture_macos_keychain_credentials() { + keychain_attempts=$((keychain_attempts + 1)) + return 1 + } + verify_claude_authentication + [[ "$keychain_attempts" -eq 0 ]] +); then + pass "auth file checks are skipped when Anthropic API key is provided" +else + fail "auth file checks are skipped when Anthropic API key is provided" +fi + +echo "" +echo "Test: auth file checks are skipped when .env provides ANTHROPIC_API_KEY" +if ( + PATH="$FAKE_BIN:$PATH" + source "$FUNCTIONS_ONLY" + unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN + export HOME="$TEST_ROOT/dotenv-api-key-home" + unset CLAUDE_CONFIG_DIR + project_dir="$TEST_ROOT/dotenv-api-key-project" + mkdir -p "$project_dir" + printf 'ANTHROPIC_API_KEY=dotenv-api-key\n' >"$project_dir/.env" + cd "$project_dir" + custom_env_vars=() + claude_managed_settings_path() { + printf '%s\n' "$TEST_ROOT/no-managed-settings.json" + } + keychain_attempts=0 + capture_macos_keychain_credentials() { + keychain_attempts=$((keychain_attempts + 1)) + return 1 + } + apply_preflight_environment + verify_claude_authentication + [[ "$keychain_attempts" -eq 0 ]] +); then + pass "auth file checks are skipped when .env provides Anthropic API key" +else + fail "auth file checks are skipped when .env provides Anthropic API key" +fi + +echo "" +echo "Test: auth file checks are skipped when --env provides ANTHROPIC_API_KEY" +if ( + PATH="$FAKE_BIN:$PATH" + source "$FUNCTIONS_ONLY" + unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN + export HOME="$TEST_ROOT/custom-env-api-key-home" + unset CLAUDE_CONFIG_DIR + project_dir="$TEST_ROOT/custom-env-api-key-project" + mkdir -p "$project_dir" + cd "$project_dir" + custom_env_vars=("ANTHROPIC_API_KEY=custom-env-api-key") + claude_managed_settings_path() { + printf '%s\n' "$TEST_ROOT/no-managed-settings.json" + } + keychain_attempts=0 + capture_macos_keychain_credentials() { + keychain_attempts=$((keychain_attempts + 1)) + return 1 + } + apply_preflight_environment + verify_claude_authentication + [[ "$keychain_attempts" -eq 0 ]] +); then + pass "auth file checks are skipped when --env provides Anthropic API key" +else + fail "auth file checks are skipped when --env provides Anthropic API key" +fi + +echo "" +echo "Test: auth file checks are skipped when project settings env has ANTHROPIC_API_KEY" +if ( + PATH="$FAKE_BIN:$PATH" + source "$FUNCTIONS_ONLY" + export HOME="$TEST_ROOT/settings-env-api-key-home" + unset CLAUDE_CONFIG_DIR + project_dir="$TEST_ROOT/settings-env-api-key-project" + mkdir -p "$project_dir/.claude" + printf '{"env":{"ANTHROPIC_API_KEY":"test-api-key"}}\n' >"$project_dir/.claude/settings.json" + cd "$project_dir" + keychain_attempts=0 + capture_macos_keychain_credentials() { + keychain_attempts=$((keychain_attempts + 1)) + return 1 + } + verify_claude_authentication + [[ "$keychain_attempts" -eq 0 ]] +); then + pass "auth file checks are skipped when project settings env has Anthropic API key" +else + fail "auth file checks are skipped when project settings env has Anthropic API key" +fi + +echo "" +echo "Test: auth file checks are skipped when settings.json has apiKeyHelper" +if ( + PATH="$FAKE_BIN:$PATH" + source "$FUNCTIONS_ONLY" + export HOME="$TEST_ROOT/api-key-helper-home" + claude_dir="$TEST_ROOT/api-key-helper-claude-config" + mkdir -p "$claude_dir" + printf '{"apiKeyHelper":"op read op://claude/api-key"}\n' >"$claude_dir/settings.json" + find_claude_config_dir() { + printf '%s\n' "$claude_dir" + } + keychain_attempts=0 + capture_macos_keychain_credentials() { + keychain_attempts=$((keychain_attempts + 1)) + return 1 + } + verify_claude_authentication + [[ "$keychain_attempts" -eq 0 ]] +); then + pass "auth file checks are skipped when apiKeyHelper is configured" +else + fail "auth file checks are skipped when apiKeyHelper is configured" +fi + +echo "" +echo "Test: auth file checks are skipped when project settings has apiKeyHelper" +if ( + PATH="$FAKE_BIN:$PATH" + source "$FUNCTIONS_ONLY" + export HOME="$TEST_ROOT/project-api-key-helper-home" + unset CLAUDE_CONFIG_DIR + project_dir="$TEST_ROOT/project-api-key-helper" + mkdir -p "$project_dir/.claude" + printf '{"apiKeyHelper":"op read op://claude/api-key"}\n' >"$project_dir/.claude/settings.json" + cd "$project_dir" + keychain_attempts=0 + capture_macos_keychain_credentials() { + keychain_attempts=$((keychain_attempts + 1)) + return 1 + } + verify_claude_authentication + [[ "$keychain_attempts" -eq 0 ]] +); then + pass "auth file checks are skipped when project apiKeyHelper is configured" +else + fail "auth file checks are skipped when project apiKeyHelper is configured" +fi + echo "" echo "Test: file-backed credentials still work when macOS keychain lookup misses" if (