From dd4bddc9f55f7b60f1857fe676be770b77b38bb3 Mon Sep 17 00:00:00 2001 From: Nik V Date: Tue, 28 Apr 2026 23:07:35 +0700 Subject: [PATCH 01/14] Allow API-key Claude auth without OAuth credentials - Detect externally managed Claude auth from API-key environment variables and settings.json apiKeyHelper - Skip local OAuth refresh and credential-file checks when external auth is available - Pass ANTHROPIC_AUTH_TOKEN through Docker sessions and document it in --help This lets API-key based Claude setups start through cco without forcing a local OAuth login path. --- cco | 54 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/cco b/cco index e1e5e00..9366a47 100755 --- a/cco +++ b/cco @@ -676,6 +676,49 @@ find_claude_config_dir() { echo "$primary_dir" } +claude_settings_has_api_key_helper() { + local settings_file + settings_file="$(find_claude_config_dir)/settings.json" + + if [[ ! -f "$settings_file" ]]; then + return 1 + fi + + if command -v python3 >/dev/null 2>&1; then + python3 - "$settings_file" <<'PY' 2>/dev/null +import json +import sys + +try: + with open(sys.argv[1], "r", encoding="utf-8") as handle: + settings = json.load(handle) +except Exception: + sys.exit(1) + +api_key_helper = settings.get("apiKeyHelper") +if isinstance(api_key_helper, str) and api_key_helper.strip(): + sys.exit(0) + +sys.exit(1) +PY + return + fi + + if command -v jq >/dev/null 2>&1; then + jq -e '(.apiKeyHelper // "") | strings | length > 0' "$settings_file" >/dev/null 2>&1 + return + fi + + return 1 +} + +using_external_claude_auth() { + [[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" || + -n "${ANTHROPIC_API_KEY:-}" || + -n "${ANTHROPIC_AUTH_TOKEN:-}" ]] || + claude_settings_has_api_key_helper +} + get_claude_keychain_account() { if [[ -n "${USER:-}" ]]; then printf '%s\n' "$USER" @@ -1108,8 +1151,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 +1207,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 @@ -2158,6 +2200,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 +3384,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" From dbcd918556d59bc3936684f99b214d9cd5ebb84c Mon Sep 17 00:00:00 2001 From: Nik V Date: Tue, 28 Apr 2026 23:08:13 +0700 Subject: [PATCH 02/14] Cover API-key Claude auth startup paths - Assert API-key auth skips OAuth refresh preflight work - Assert credential checks are bypassed for API-key and apiKeyHelper configurations - Cover ANTHROPIC_AUTH_TOKEN help text for the new passthrough This locks in API-key based Claude startup behavior without exercising full sandbox launches. --- tests/test_startup_preflights.sh | 69 ++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/test_startup_preflights.sh b/tests/test_startup_preflights.sh index 113ba77..4e0749a 100644 --- a/tests/test_startup_preflights.sh +++ b/tests/test_startup_preflights.sh @@ -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/^/ /' @@ -436,6 +437,25 @@ 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 expiry extraction prefers claudeAiOauth over mcpOAuth entries" if ( @@ -511,6 +531,55 @@ 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 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: file-backed credentials still work when macOS keychain lookup misses" if ( From d694b41248014ba760d9278abb915630726746bf Mon Sep 17 00:00:00 2001 From: Nik V Date: Thu, 30 Apr 2026 22:27:25 +0700 Subject: [PATCH 03/14] Consolidate JSON settings reads through a small bjq helper - Add bjq for simple dotted keys and numeric array indexes, using jq first and python3 as a fallback - Route Claude permission settings, additionalDirectories, apiKeyHelper, and OAuth expiry reads through the helper - Keep unsupported jq features out of the helper so Bash callers get a narrow, predictable lookup surface This removes repeated JSON parser snippets while keeping the existing startup preflight behavior focused on simple lookups. --- cco | 374 +++++++++++++++++++++++++++++++++--------------------------- 1 file changed, 204 insertions(+), 170 deletions(-) diff --git a/cco b/cco index 9366a47..784bc83 100755 --- a/cco +++ b/cco @@ -290,45 +290,185 @@ 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 + + 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 python3 >/dev/null 2>&1; then - python3 - "$settings_file" "$setting_name" <<'PY' 2>/dev/null + if command -v jq >/dev/null 2>&1; then + local jq_program + if [[ "$output_mode" == "type" ]]; then + 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 + 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 + return + 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() { + 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() { @@ -527,84 +667,44 @@ handle_add_dir_entry() { esac } -# Parse additionalDirectories from .claude/settings.local.json -# Prefer python3 for portability and fall back to jq when available. +# 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 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" + local dirs_type parse_status + if ! dirs_type=$(bjq_type "additionalDirectories" "$settings_file" 2>/dev/null); then + parse_status=$? + if [[ $parse_status -eq 127 ]]; then + warn "jq/python3 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 - 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 - -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) - -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) - -for entry in directories: - if isinstance(entry, str): - print(entry) -PY - ); then - parse_status=0 - else - parse_status=$? - fi - 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=$? - fi + if [[ "$dirs_type" == "null" ]]; then + 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" != "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 +715,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) @@ -684,32 +784,9 @@ claude_settings_has_api_key_helper() { return 1 fi - if command -v python3 >/dev/null 2>&1; then - python3 - "$settings_file" <<'PY' 2>/dev/null -import json -import sys - -try: - with open(sys.argv[1], "r", encoding="utf-8") as handle: - settings = json.load(handle) -except Exception: - sys.exit(1) - -api_key_helper = settings.get("apiKeyHelper") -if isinstance(api_key_helper, str) and api_key_helper.strip(): - sys.exit(0) - -sys.exit(1) -PY - return - fi - - if command -v jq >/dev/null 2>&1; then - jq -e '(.apiKeyHelper // "") | strings | length > 0' "$settings_file" >/dev/null 2>&1 - return - fi - - return 1 + local api_key_helper + api_key_helper=$(bjq "apiKeyHelper" "$settings_file" 2>/dev/null || true) + [[ -n "$api_key_helper" ]] } using_external_claude_auth() { @@ -840,70 +917,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 From 14b60cf2dc30173a625b2c8b40ca2ac6afdb80a7 Mon Sep 17 00:00:00 2001 From: Nik V Date: Thu, 30 Apr 2026 22:27:36 +0700 Subject: [PATCH 04/14] Cover bjq lookup behavior and python fallback - Assert bjq reads simple dotted keys, optional leading dots, array indexes, and stdin JSON - Cover scalar, object, array, missing path, and unsupported syntax results - Exercise the python3 fallback with jq hidden from PATH This locks in the narrow JSON lookup contract before using it for broader Claude auth settings detection. --- tests/test_startup_preflights.sh | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/test_startup_preflights.sh b/tests/test_startup_preflights.sh index 4e0749a..6daea2f 100644 --- a/tests/test_startup_preflights.sh +++ b/tests/test_startup_preflights.sh @@ -100,6 +100,54 @@ 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 +); 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" ]] +); 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 ( From cb44d0f8ad8d7593773bc8f2f5fb1e936a1f1ec3 Mon Sep 17 00:00:00 2001 From: Nik V Date: Thu, 30 Apr 2026 22:29:26 +0700 Subject: [PATCH 05/14] Detect Claude settings-based external auth before OAuth checks - Scan managed, project local, project shared, and user settings for external Claude auth configuration - Treat non-empty apiKeyHelper and settings env auth keys as externally managed authentication - Keep shell-provided Claude and Anthropic auth variables on the fast path This lets cco follow Claude Code's documented settings hierarchy before requiring local OAuth credentials. --- cco | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/cco b/cco index 784bc83..5297783 100755 --- a/cco +++ b/cco @@ -476,6 +476,28 @@ 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 managed_settings user_settings + managed_settings=$(claude_managed_settings_path || true) + user_settings="$(find_claude_config_dir)/settings.json" + + [[ -n "$managed_settings" ]] && printf '%s\n' "$managed_settings" + 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 @@ -776,24 +798,48 @@ find_claude_config_dir() { echo "$primary_dir" } -claude_settings_has_api_key_helper() { - local settings_file - settings_file="$(find_claude_config_dir)/settings.json" - +claude_settings_has_nonempty_string() { + local settings_file="$1" + local query="$2" + local value value_type if [[ ! -f "$settings_file" ]]; then return 1 fi - local api_key_helper - api_key_helper=$(bjq "apiKeyHelper" "$settings_file" 2>/dev/null || true) - [[ -n "$api_key_helper" ]] + 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) + [[ "$value" =~ [^[:space:]] ]] +} + +claude_settings_has_external_auth() { + local settings_file query + local auth_queries=( + "apiKeyHelper" + "env.ANTHROPIC_API_KEY" + "env.ANTHROPIC_AUTH_TOKEN" + "env.CLAUDE_CODE_OAUTH_TOKEN" + ) + + while IFS= read -r settings_file; do + for query in "${auth_queries[@]}"; do + if claude_settings_has_nonempty_string "$settings_file" "$query"; then + return 0 + fi + done + done < <(claude_external_auth_settings_paths) + + return 1 } using_external_claude_auth() { [[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" || -n "${ANTHROPIC_API_KEY:-}" || -n "${ANTHROPIC_AUTH_TOKEN:-}" ]] || - claude_settings_has_api_key_helper + claude_settings_has_external_auth } get_claude_keychain_account() { From f8e897cca31a0043ae418083ba543cf2beca6ace Mon Sep 17 00:00:00 2001 From: Nik V Date: Thu, 30 Apr 2026 22:29:35 +0700 Subject: [PATCH 06/14] Cover Claude settings-based external auth preflights - Assert settings env ANTHROPIC_AUTH_TOKEN skips OAuth refresh work - Assert project settings env ANTHROPIC_API_KEY bypasses local credential-file checks - Assert project apiKeyHelper also counts as externally managed Claude auth This locks in the documented settings-based auth paths without invoking Docker-backed sessions. --- tests/test_startup_preflights.sh | 70 ++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/test_startup_preflights.sh b/tests/test_startup_preflights.sh index 6daea2f..825ad6a 100644 --- a/tests/test_startup_preflights.sh +++ b/tests/test_startup_preflights.sh @@ -504,6 +504,28 @@ 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 expiry extraction prefers claudeAiOauth over mcpOAuth entries" if ( @@ -603,6 +625,30 @@ else fail "auth file checks are skipped when Anthropic API key is provided" 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 ( @@ -628,6 +674,30 @@ 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 ( From 3a866bdadd0b896aca635b746e950f63b7f6259d Mon Sep 17 00:00:00 2001 From: Nik V Date: Thu, 30 Apr 2026 22:42:41 +0700 Subject: [PATCH 07/14] Clean up bjq helper shellcheck warnings - Mark literal jq programs as intentional single-quoted strings - Use a local BJQ_OUTPUT override so bjq_type avoids command-assignment ambiguity - Leave the helper behavior unchanged while making shellcheck pass cleanly This keeps the JSON lookup helper lint-clean without broadening its supported query surface. --- cco | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cco b/cco index 5297783..e47e73f 100755 --- a/cco +++ b/cco @@ -346,6 +346,7 @@ bjq() { if command -v jq >/dev/null 2>&1; then local jq_program if [[ "$output_mode" == "type" ]]; then + # shellcheck disable=SC2016 jq_program=' def path_lookup($p): reduce $p[] as $part ({ok: true, v: .}; @@ -360,6 +361,7 @@ bjq() { | type ' else + # shellcheck disable=SC2016 jq_program=' def path_lookup($p): reduce $p[] as $part ({ok: true, v: .}; @@ -457,7 +459,8 @@ PY } bjq_type() { - BJQ_OUTPUT=type bjq "$@" + local BJQ_OUTPUT=type + bjq "$@" } read_claude_permission_setting() { From a7b50197a391ec0d8fd944ce344d9601e3f35932 Mon Sep 17 00:00:00 2001 From: Nik V Date: Thu, 30 Apr 2026 22:42:47 +0700 Subject: [PATCH 08/14] Silence intentional shellcheck literal in startup preflight tests - Add SC2016 to the startup preflight test suppressions for literal shell snippets - Keep the Docker passthrough assertion unchanged - Preserve the existing test behavior while allowing shellcheck to run cleanly This documents the intentional single-quoted test command instead of leaving shellcheck noisy. --- tests/test_startup_preflights.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_startup_preflights.sh b/tests/test_startup_preflights.sh index 825ad6a..c2750b5 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 From 5895340b903188d5518c1c6a4625e9c9400ccb81 Mon Sep 17 00:00:00 2001 From: Nik V Date: Thu, 30 Apr 2026 22:55:56 +0700 Subject: [PATCH 09/14] Tighten settings auth detection for effective runtime behavior - Normalize bjq empty lookup status so missing keys are handled consistently - Skip managed host settings for Docker auth preflights because they are not mounted into the container - Read settings auth by effective key precedence so blank higher-priority values override lower-priority values This prevents cco from skipping local credential checks when Claude would not actually see usable external auth. --- cco | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/cco b/cco index e47e73f..18759b0 100755 --- a/cco +++ b/cco @@ -385,7 +385,9 @@ bjq() { else jq -er --argjson path "$path_json" "$jq_program" fi - return + local status=$? + [[ $status -eq 4 ]] && return 1 + return "$status" fi if ! command -v python3 >/dev/null 2>&1; then @@ -491,11 +493,14 @@ claude_managed_settings_path() { } claude_external_auth_settings_paths() { - local managed_settings user_settings - managed_settings=$(claude_managed_settings_path || true) + local user_settings user_settings="$(find_claude_config_dir)/settings.json" - [[ -n "$managed_settings" ]] && printf '%s\n' "$managed_settings" + # 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" @@ -700,7 +705,9 @@ load_additional_directories_from_settings() { fi local dirs_type parse_status - if ! dirs_type=$(bjq_type "additionalDirectories" "$settings_file" 2>/dev/null); then + if dirs_type=$(bjq_type "additionalDirectories" "$settings_file" 2>/dev/null); then + parse_status=0 + else parse_status=$? if [[ $parse_status -eq 127 ]]; then warn "jq/python3 not found; skipping additionalDirectories from $settings_file" @@ -801,7 +808,7 @@ find_claude_config_dir() { echo "$primary_dir" } -claude_settings_has_nonempty_string() { +read_claude_settings_string_if_present() { local settings_file="$1" local query="$2" local value value_type @@ -815,11 +822,11 @@ claude_settings_has_nonempty_string() { fi value=$(bjq "$query" "$settings_file" 2>/dev/null || true) - [[ "$value" =~ [^[:space:]] ]] + printf '%s\n' "$value" } claude_settings_has_external_auth() { - local settings_file query + local settings_file query value local auth_queries=( "apiKeyHelper" "env.ANTHROPIC_API_KEY" @@ -827,13 +834,14 @@ claude_settings_has_external_auth() { "env.CLAUDE_CODE_OAUTH_TOKEN" ) - while IFS= read -r settings_file; do - for query in "${auth_queries[@]}"; do - if claude_settings_has_nonempty_string "$settings_file" "$query"; then - return 0 + 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 - done < <(claude_external_auth_settings_paths) + done < <(claude_external_auth_settings_paths) + done return 1 } From 291e6705174cc1d9278697bbbbfe16558b1f1901 Mon Sep 17 00:00:00 2001 From: Nik V Date: Thu, 30 Apr 2026 22:56:01 +0700 Subject: [PATCH 10/14] Cover final settings auth review regressions - Assert missing additionalDirectories settings stay silent - Assert Docker preflight ignores host managed settings that are not mounted - Assert higher-priority blank auth settings override lower-priority auth values This locks in the review fixes without requiring Docker-backed execution. --- tests/test_startup_preflights.sh | 87 ++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/test_startup_preflights.sh b/tests/test_startup_preflights.sh index c2750b5..e0b59cd 100644 --- a/tests/test_startup_preflights.sh +++ b/tests/test_startup_preflights.sh @@ -320,6 +320,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 ( @@ -526,6 +553,66 @@ 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 ( From 87d805e6a9b98d5277e7ccdcb1491800cca872e0 Mon Sep 17 00:00:00 2001 From: Nik V Date: Thu, 30 Apr 2026 23:02:10 +0700 Subject: [PATCH 11/14] Load cco env inputs before Claude auth preflight - Add shared helpers to load project .env values and --env values into the current process - Apply those env inputs before Claude credential verification and OAuth refresh checks - Reuse the same helpers in native launch setup so preflight and runtime env handling stay aligned This lets API-key auth supplied through .env or --env skip local OAuth credential checks before startup. --- cco | 73 +++++++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/cco b/cco index 18759b0..b701850 100755 --- a/cco +++ b/cco @@ -697,6 +697,52 @@ handle_add_dir_entry() { esac } +load_project_env_file_into_environment() { + if [[ ! -f ".env" ]]; then + 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" +} + +apply_custom_env_vars_to_environment() { + local custom_env env_key env_val + + if ! declare -p custom_env_vars >/dev/null 2>&1; then + return + fi + + if ((${#custom_env_vars[@]} == 0)); then + return + fi + + 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 + # 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" @@ -1997,31 +2043,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[@]}" @@ -3530,6 +3554,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 From 6a8f8e93d2f6e8fc9094080ef1a7bfb9c02036ad Mon Sep 17 00:00:00 2001 From: Nik V Date: Thu, 30 Apr 2026 23:02:17 +0700 Subject: [PATCH 12/14] Cover preflight auth from cco env inputs - Assert .env-provided ANTHROPIC_API_KEY bypasses local Claude credential checks - Assert --env-provided ANTHROPIC_API_KEY also bypasses local credential checks - Keep the coverage in startup preflights without invoking Docker-backed sessions This prevents preflight regressions for API-key auth supplied through cco's own env surfaces. --- tests/test_startup_preflights.sh | 59 ++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_startup_preflights.sh b/tests/test_startup_preflights.sh index e0b59cd..2d6048b 100644 --- a/tests/test_startup_preflights.sh +++ b/tests/test_startup_preflights.sh @@ -712,6 +712,65 @@ 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 ( From 11f7b00868f24577d0fc7201ebadda9d13a768a2 Mon Sep 17 00:00:00 2001 From: Nik V Date: Thu, 30 Apr 2026 23:46:33 +0700 Subject: [PATCH 13/14] Keep bjq parse failures distinct from missing lookup paths - Validate JSON with jq before running the path lookup program - Return parse status for invalid JSON instead of collapsing jq status 4 to missing key - Preserve the existing missing-parser warning text expected by callers This keeps invalid Claude settings files warning correctly across jq versions and platforms. --- cco | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/cco b/cco index b701850..91a9815 100755 --- a/cco +++ b/cco @@ -344,6 +344,17 @@ bjq() { 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 @@ -386,6 +397,7 @@ bjq() { 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 @@ -756,7 +768,7 @@ load_additional_directories_from_settings() { else parse_status=$? if [[ $parse_status -eq 127 ]]; then - warn "jq/python3 not found; skipping additionalDirectories from $settings_file" + 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 From 34d90891a14a7c7a1de4fa0f64a04494cdfb94ef Mon Sep 17 00:00:00 2001 From: Nik V Date: Thu, 30 Apr 2026 23:46:38 +0700 Subject: [PATCH 14/14] Cover bjq parser selection in extracted test loaders - Include bjq helper functions in the additionalDirectories extracted loader harness - Assert invalid JSON remains a parse failure for the jq-backed helper path - Assert the python fallback keeps the same invalid JSON behavior This makes the parser-selection tests exercise the real helper code and protects the Linux invalid-settings warning path. --- tests/test_additional_directories.sh | 3 +++ tests/test_startup_preflights.sh | 14 ++++++++++++++ 2 files changed, 17 insertions(+) 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 2d6048b..9ac014d 100644 --- a/tests/test_startup_preflights.sh +++ b/tests/test_startup_preflights.sh @@ -118,6 +118,13 @@ if ( ! 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 @@ -142,6 +149,13 @@ elif ( 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