diff --git a/CHANGELOG.md b/CHANGELOG.md index 7db88d6..1d6b954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 p50 and 1.15s at p95. The Rust `OpenAiEndpoint` struct gains a public field, so a struct literal that names every field must add it. +### Fixed +- **Isolated Claude runs read the macOS Keychain before + `~/.claude/.credentials.json`.** Claude Code reads its Keychain item first + and the plaintext file only when the item is missing; every host read them + the other way round, so a stale file shadowed the live login and runs + failed with `Failed to authenticate: OAuth token revoked`. A run whose + token the CLI rejects now retries once per remaining source and skips that + token for the rest of the process. The new `claude_auth_rejected` core op + recognizes the rejection, and `claude_isolation_seed` takes an ordered + `credentials_json` list plus `rejected_tokens`. + ## [0.13.4] - 2026-09-17 ### Security diff --git a/conformance/vectors/claude_auth_rejected/access-token-has-been-revoked.json b/conformance/vectors/claude_auth_rejected/access-token-has-been-revoked.json new file mode 100644 index 0000000..c924197 --- /dev/null +++ b/conformance/vectors/claude_auth_rejected/access-token-has-been-revoked.json @@ -0,0 +1,10 @@ +{ + "name": "access-token-has-been-revoked", + "op": "claude_auth_rejected", + "input": { + "error_msg": "API Error: 401 {\"type\":\"error\",\"error\":{\"type\":\"permission_error\",\"message\":\"OAuth access token has been revoked\"}}" + }, + "expected": { + "rejected": true + } +} diff --git a/conformance/vectors/claude_auth_rejected/api-401-authentication-error.json b/conformance/vectors/claude_auth_rejected/api-401-authentication-error.json new file mode 100644 index 0000000..f141b6a --- /dev/null +++ b/conformance/vectors/claude_auth_rejected/api-401-authentication-error.json @@ -0,0 +1,10 @@ +{ + "name": "api-401-authentication-error", + "op": "claude_auth_rejected", + "input": { + "error_msg": "API Error: 401 {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"Invalid bearer token\"}}" + }, + "expected": { + "rejected": true + } +} diff --git a/conformance/vectors/claude_auth_rejected/nonzero-exit-is-not-rejected.json b/conformance/vectors/claude_auth_rejected/nonzero-exit-is-not-rejected.json new file mode 100644 index 0000000..7e184de --- /dev/null +++ b/conformance/vectors/claude_auth_rejected/nonzero-exit-is-not-rejected.json @@ -0,0 +1,10 @@ +{ + "name": "nonzero-exit-is-not-rejected", + "op": "claude_auth_rejected", + "input": { + "error_msg": "claude exited 1: tool use failed" + }, + "expected": { + "rejected": false + } +} diff --git a/conformance/vectors/claude_auth_rejected/oauth-session-expired.json b/conformance/vectors/claude_auth_rejected/oauth-session-expired.json new file mode 100644 index 0000000..2581317 --- /dev/null +++ b/conformance/vectors/claude_auth_rejected/oauth-session-expired.json @@ -0,0 +1,10 @@ +{ + "name": "oauth-session-expired", + "op": "claude_auth_rejected", + "input": { + "error_msg": "claude exited 1: Failed to authenticate: OAuth session expired and could not be refreshed" + }, + "expected": { + "rejected": true + } +} diff --git a/conformance/vectors/claude_auth_rejected/oauth-token-revoked.json b/conformance/vectors/claude_auth_rejected/oauth-token-revoked.json new file mode 100644 index 0000000..8b252bd --- /dev/null +++ b/conformance/vectors/claude_auth_rejected/oauth-token-revoked.json @@ -0,0 +1,10 @@ +{ + "name": "oauth-token-revoked", + "op": "claude_auth_rejected", + "input": { + "error_msg": "claude exited 1: Failed to authenticate: OAuth token revoked. Please log in again or contact your administrator." + }, + "expected": { + "rejected": true + } +} diff --git a/conformance/vectors/claude_auth_rejected/overloaded-is-not-rejected.json b/conformance/vectors/claude_auth_rejected/overloaded-is-not-rejected.json new file mode 100644 index 0000000..f281ed0 --- /dev/null +++ b/conformance/vectors/claude_auth_rejected/overloaded-is-not-rejected.json @@ -0,0 +1,10 @@ +{ + "name": "overloaded-is-not-rejected", + "op": "claude_auth_rejected", + "input": { + "error_msg": "API Error: 529 {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}" + }, + "expected": { + "rejected": false + } +} diff --git a/conformance/vectors/claude_isolation_seed/account-only.json b/conformance/vectors/claude_isolation_seed/account-only.json index de5b8be..6a5fae2 100644 --- a/conformance/vectors/claude_isolation_seed/account-only.json +++ b/conformance/vectors/claude_isolation_seed/account-only.json @@ -3,7 +3,8 @@ "op": "claude_isolation_seed", "input": { "account_json": "{\"oauthAccount\": {\"accountUuid\": \"b\"}, \"mcpServers\": {\"s\": {}}}", - "credentials_json": null + "credentials_json": [], + "rejected_tokens": [] }, "expected": { "files": [ diff --git a/conformance/vectors/claude_isolation_seed/account-without-mcp-servers.json b/conformance/vectors/claude_isolation_seed/account-without-mcp-servers.json index 4c5a841..c4e7fb9 100644 --- a/conformance/vectors/claude_isolation_seed/account-without-mcp-servers.json +++ b/conformance/vectors/claude_isolation_seed/account-without-mcp-servers.json @@ -3,7 +3,8 @@ "op": "claude_isolation_seed", "input": { "account_json": "{\"oauthAccount\": {\"accountUuid\": \"c\"}}", - "credentials_json": null + "credentials_json": [], + "rejected_tokens": [] }, "expected": { "files": [ diff --git a/conformance/vectors/claude_isolation_seed/all-rejected-keeps-first.json b/conformance/vectors/claude_isolation_seed/all-rejected-keeps-first.json new file mode 100644 index 0000000..1fdb2f3 --- /dev/null +++ b/conformance/vectors/claude_isolation_seed/all-rejected-keeps-first.json @@ -0,0 +1,21 @@ +{ + "name": "all-rejected-keeps-first", + "op": "claude_isolation_seed", + "input": { + "account_json": null, + "credentials_json": [ + "{\"claudeAiOauth\": {\"accessToken\": \"kc-tok\"}}", + "{\"claudeAiOauth\": {\"accessToken\": \"file-tok\"}}" + ], + "rejected_tokens": [ + "file-tok", + "kc-tok" + ] + }, + "expected": { + "files": [], + "env": { + "CLAUDE_CODE_OAUTH_TOKEN": "kc-tok" + } + } +} diff --git a/conformance/vectors/claude_isolation_seed/both-files-mcp-popped.json b/conformance/vectors/claude_isolation_seed/both-files-mcp-popped.json index 5a486a8..9035930 100644 --- a/conformance/vectors/claude_isolation_seed/both-files-mcp-popped.json +++ b/conformance/vectors/claude_isolation_seed/both-files-mcp-popped.json @@ -3,7 +3,10 @@ "op": "claude_isolation_seed", "input": { "account_json": "{\"oauthAccount\": {\"accountUuid\": \"a\"}, \"mcpServers\": {\"semble\": {\"command\": \"x\"}}}", - "credentials_json": "{\"claudeAiOauth\": {\"accessToken\": \"tok\"}}" + "credentials_json": [ + "{\"claudeAiOauth\": {\"accessToken\": \"tok\"}}" + ], + "rejected_tokens": [] }, "expected": { "files": [ diff --git a/conformance/vectors/claude_isolation_seed/both-null.json b/conformance/vectors/claude_isolation_seed/both-null.json index ef4ee7b..1182d47 100644 --- a/conformance/vectors/claude_isolation_seed/both-null.json +++ b/conformance/vectors/claude_isolation_seed/both-null.json @@ -3,7 +3,8 @@ "op": "claude_isolation_seed", "input": { "account_json": null, - "credentials_json": null + "credentials_json": [], + "rejected_tokens": [] }, "expected": { "files": [], diff --git a/conformance/vectors/claude_isolation_seed/credentials-only.json b/conformance/vectors/claude_isolation_seed/credentials-only.json index 2261a11..f61fbf5 100644 --- a/conformance/vectors/claude_isolation_seed/credentials-only.json +++ b/conformance/vectors/claude_isolation_seed/credentials-only.json @@ -3,7 +3,10 @@ "op": "claude_isolation_seed", "input": { "account_json": null, - "credentials_json": "{\"claudeAiOauth\": {\"accessToken\": \"kc-tok\"}}" + "credentials_json": [ + "{\"claudeAiOauth\": {\"accessToken\": \"kc-tok\"}}" + ], + "rejected_tokens": [] }, "expected": { "files": [], diff --git a/conformance/vectors/claude_isolation_seed/credentials-without-oauth-skipped.json b/conformance/vectors/claude_isolation_seed/credentials-without-oauth-skipped.json new file mode 100644 index 0000000..438cf52 --- /dev/null +++ b/conformance/vectors/claude_isolation_seed/credentials-without-oauth-skipped.json @@ -0,0 +1,18 @@ +{ + "name": "credentials-without-oauth-skipped", + "op": "claude_isolation_seed", + "input": { + "account_json": null, + "credentials_json": [ + "{\"mcpOAuth\": {}}", + "{\"claudeAiOauth\": {\"accessToken\": \"file-tok\"}}" + ], + "rejected_tokens": [] + }, + "expected": { + "files": [], + "env": { + "CLAUDE_CODE_OAUTH_TOKEN": "file-tok" + } + } +} diff --git a/conformance/vectors/claude_isolation_seed/first-credentials-win.json b/conformance/vectors/claude_isolation_seed/first-credentials-win.json new file mode 100644 index 0000000..57e95e9 --- /dev/null +++ b/conformance/vectors/claude_isolation_seed/first-credentials-win.json @@ -0,0 +1,18 @@ +{ + "name": "first-credentials-win", + "op": "claude_isolation_seed", + "input": { + "account_json": null, + "credentials_json": [ + "{\"claudeAiOauth\": {\"accessToken\": \"kc-tok\"}}", + "{\"claudeAiOauth\": {\"accessToken\": \"file-tok\"}}" + ], + "rejected_tokens": [] + }, + "expected": { + "files": [], + "env": { + "CLAUDE_CODE_OAUTH_TOKEN": "kc-tok" + } + } +} diff --git a/conformance/vectors/claude_isolation_seed/rejected-token-falls-through.json b/conformance/vectors/claude_isolation_seed/rejected-token-falls-through.json new file mode 100644 index 0000000..0813533 --- /dev/null +++ b/conformance/vectors/claude_isolation_seed/rejected-token-falls-through.json @@ -0,0 +1,20 @@ +{ + "name": "rejected-token-falls-through", + "op": "claude_isolation_seed", + "input": { + "account_json": null, + "credentials_json": [ + "{\"claudeAiOauth\": {\"accessToken\": \"kc-tok\"}}", + "{\"claudeAiOauth\": {\"accessToken\": \"file-tok\"}}" + ], + "rejected_tokens": [ + "kc-tok" + ] + }, + "expected": { + "files": [], + "env": { + "CLAUDE_CODE_OAUTH_TOKEN": "file-tok" + } + } +} diff --git a/go/coreops.go b/go/coreops.go index 9d5300e..771eae1 100644 --- a/go/coreops.go +++ b/go/coreops.go @@ -225,11 +225,21 @@ func coreIsolationSources(apiAuth bool) (isolationSources, error) { }{Host: host, APIAuth: apiAuth}) } -func coreIsolationSeed(accountJSON, credentialsJSON *string) (isolationSeed, error) { +func coreIsolationSeed(accountJSON *string, credentialsJSON, rejectedTokens []string) (isolationSeed, error) { return coreInto[isolationSeed]("claude_isolation_seed", struct { - AccountJSON *string `json:"account_json"` - CredentialsJSON *string `json:"credentials_json"` - }{AccountJSON: accountJSON, CredentialsJSON: credentialsJSON}) + AccountJSON *string `json:"account_json"` + CredentialsJSON []string `json:"credentials_json"` + RejectedTokens []string `json:"rejected_tokens"` + }{AccountJSON: accountJSON, CredentialsJSON: credentialsJSON, RejectedTokens: rejectedTokens}) +} + +func coreAuthRejected(errorMsg string) (bool, error) { + out, err := coreInto[struct { + Rejected bool `json:"rejected"` + }]("claude_auth_rejected", struct { + ErrorMsg string `json:"error_msg"` + }{ErrorMsg: errorMsg}) + return out.Rejected, err } func coreStrictSchema(dialect string, schema json.RawMessage) (json.RawMessage, error) { diff --git a/go/exec.go b/go/exec.go index feedc66..ff62511 100644 --- a/go/exec.go +++ b/go/exec.go @@ -22,7 +22,35 @@ func (b *cliBackend) execute(ctx context.Context, spec RunSpec, wantsValue bool) if kind != "exec" { return nil, fmt.Errorf("spawnllm: provider %q planned a %s invocation, want exec", b.provider, kind) } - output, returncode, stderr, timedOut, err := runExecPlan(ctx, plan, spec) + if !plan.NeedsClaudeIsolation { + return b.runAttempt(ctx, plan, spec, nil, wantsValue) + } + isolation, err := seedClaudeIsolation(spec.APIAuth) + if err != nil { + return nil, err + } + for { + att, err := b.runAttempt(ctx, plan, spec, isolation, wantsValue) + isolation.cleanup() + if err != nil || att.resp.Err == nil { + return att, err + } + rejected, err := isolation.rejectCredentials(att.resp.Err.Msg) + if err != nil || !rejected { + return att, err + } + if isolation, err = seedClaudeIsolation(spec.APIAuth); err != nil { + return nil, err + } + if isolation.tokenRejected() { + isolation.cleanup() + return att, nil + } + } +} + +func (b *cliBackend) runAttempt(ctx context.Context, plan execPlan, spec RunSpec, isolation *claudeIsolation, wantsValue bool) (*attempt, error) { + output, returncode, stderr, timedOut, err := runExecPlan(ctx, plan, spec, isolation) if err != nil { return nil, err } @@ -32,7 +60,7 @@ func (b *cliBackend) execute(ctx context.Context, spec RunSpec, wantsValue bool) return finishAttempt(spec, b.provider, output, returncode, stderr, wantsValue) } -func runExecPlan(ctx context.Context, plan execPlan, spec RunSpec) (output string, returncode int, stderr string, timedOut bool, err error) { +func runExecPlan(ctx context.Context, plan execPlan, spec RunSpec, isolation *claudeIsolation) (output string, returncode int, stderr string, timedOut bool, err error) { var cleanups []func() defer func() { for _, c := range cleanups { @@ -51,14 +79,9 @@ func runExecPlan(ctx context.Context, plan execPlan, spec RunSpec) (output strin } env := plan.Env - if plan.NeedsClaudeIsolation { - dir, seedEnv, cleanup, e := seedClaudeIsolation(spec.APIAuth) - if e != nil { - return "", 0, "", false, e - } - cleanups = append(cleanups, cleanup) - env = substituteIsolationDir(plan.Env, dir) - maps.Copy(env, seedEnv) + if isolation != nil { + env = substituteIsolationDir(plan.Env, isolation.dir) + maps.Copy(env, isolation.env) } argv := substituteFiles(plan.Argv, paths) diff --git a/go/exec_test.go b/go/exec_test.go index 41d1c42..f8bb96a 100644 --- a/go/exec_test.go +++ b/go/exec_test.go @@ -477,6 +477,51 @@ func TestClaudeIsolationKeychainMissSeedsNoCredentials(t *testing.T) { } } +func TestClaudeIsolationFallsThroughARejectedKeychainToken(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("the Keychain runs only on darwin") + } + withFakeBin(t) + home := t.TempDir() + t.Setenv("HOME", home) + clearHostEnv(t, "CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR", "CLAUDE_CODE_CUSTOM_OAUTH_URL", "CLAUDE_CODE_OAUTH_TOKEN") + writeAccountPointer(t, home) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".claude", ".credentials.json"), + []byte(`{"claudeAiOauth":{"accessToken":"fallthrough-file-tok"}}`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("FAKE_KEYCHAIN_SERVICE", "Claude Code-credentials") + t.Setenv("FAKE_KEYCHAIN_CREDENTIAL", `{"claudeAiOauth":{"accessToken":"fallthrough-kc-tok"}}`) + t.Setenv("FAKE_REJECTED_TOKEN", "fallthrough-kc-tok") + + resp, err := RunOn(context.Background(), ClaudeBackend(), RunSpec{Prompt: "iso", Model: "haiku", MaxAttempts: 1}) + if err != nil { + t.Fatalf("RunOn: %v", err) + } + if resp.Err != nil { + t.Fatalf("a rejected Keychain token must fall through to the credentials file: %v", resp.Err) + } + var out claudeOutput + if err := json.Unmarshal([]byte(resp.Output), &out); err != nil { + t.Fatalf("decode output %q: %v", resp.Output, err) + } + if out.OauthToken != "fallthrough-file-tok" { + t.Fatalf("CLAUDE_CODE_OAUTH_TOKEN = %q, want the credentials file's token", out.OauthToken) + } + + t.Setenv("FAKE_REJECTED_TOKEN", "fallthrough-file-tok") + resp, err = RunOn(context.Background(), ClaudeBackend(), RunSpec{Prompt: "iso", Model: "haiku", MaxAttempts: 1}) + if err != nil { + t.Fatalf("RunOn: %v", err) + } + if resp.Err == nil || !strings.Contains(resp.Err.Msg, "OAuth token revoked") { + t.Fatalf("with every source rejected, want the rejection, got %+v", resp.Err) + } +} + func TestClaudeIsolationInheritedTokenReadsNoCredentialSource(t *testing.T) { withFakeBin(t) home := t.TempDir() diff --git a/go/isolate.go b/go/isolate.go index 689f932..8039ab7 100644 --- a/go/isolate.go +++ b/go/isolate.go @@ -2,57 +2,106 @@ package spawnllm import ( "fmt" + "maps" "os" "os/exec" "path/filepath" + "slices" "strconv" "strings" + "sync" ) -func seedClaudeIsolation(apiAuth bool) (string, map[string]string, func(), error) { +const seededAuthEnv = "CLAUDE_CODE_OAUTH_TOKEN" + +var rejectedTokens = struct { + sync.Mutex + set map[string]bool +}{set: map[string]bool{}} + +type claudeIsolation struct { + dir string + env map[string]string + cleanup func() +} + +func seedClaudeIsolation(apiAuth bool) (*claudeIsolation, error) { sources, err := coreIsolationSources(apiAuth) if err != nil { - return "", nil, nil, err + return nil, err } var accountJSON *string if sources.AccountPath != nil { accountJSON = readFileOpt(*sources.AccountPath) } - var credentialsJSON *string - if sources.CredentialsPath != nil { - credentialsJSON = readFileOpt(*sources.CredentialsPath) + credentialsJSON := []string{} + if sources.KeychainService != nil { + if credentials := keychainCredentials(*sources.KeychainService); credentials != nil { + credentialsJSON = append(credentialsJSON, *credentials) + } } - if credentialsJSON == nil && sources.KeychainService != nil { - credentialsJSON = keychainCredentials(*sources.KeychainService) + if sources.CredentialsPath != nil { + if credentials := readFileOpt(*sources.CredentialsPath); credentials != nil { + credentialsJSON = append(credentialsJSON, *credentials) + } } - seed, err := coreIsolationSeed(accountJSON, credentialsJSON) + seed, err := coreIsolationSeed(accountJSON, credentialsJSON, rejectedTokenList()) if err != nil { - return "", nil, nil, err + return nil, err } dir, err := os.MkdirTemp("", "spawnllm-claude-config-") if err != nil { - return "", nil, nil, err + return nil, err } cleanup := func() { _ = os.RemoveAll(dir) } for _, f := range seed.Files { mode, err := parseMode(f.Mode) if err != nil { cleanup() - return "", nil, nil, err + return nil, err } path := filepath.Join(dir, f.Name) if err := os.WriteFile(path, []byte(f.Content), mode); err != nil { cleanup() - return "", nil, nil, err + return nil, err } if err := os.Chmod(path, mode); err != nil { cleanup() - return "", nil, nil, err + return nil, err } } - return dir, seed.Env, cleanup, nil + return &claudeIsolation{dir: dir, env: seed.Env, cleanup: cleanup}, nil +} + +func rejectedTokenList() []string { + rejectedTokens.Lock() + defer rejectedTokens.Unlock() + tokens := slices.AppendSeq([]string{}, maps.Keys(rejectedTokens.set)) + slices.Sort(tokens) + return tokens +} + +func (i *claudeIsolation) rejectCredentials(errMsg string) (bool, error) { + token, ok := i.env[seededAuthEnv] + if !ok { + return false, nil + } + rejected, err := coreAuthRejected(errMsg) + if err != nil || !rejected { + return false, err + } + rejectedTokens.Lock() + defer rejectedTokens.Unlock() + rejectedTokens.set[token] = true + return true, nil +} + +func (i *claudeIsolation) tokenRejected() bool { + rejectedTokens.Lock() + defer rejectedTokens.Unlock() + return rejectedTokens.set[i.env[seededAuthEnv]] } func substituteIsolationDir(env map[string]string, dir string) map[string]string { diff --git a/go/testdata/bin/claude b/go/testdata/bin/claude index a09315b..73cf84f 100755 --- a/go/testdata/bin/claude +++ b/go/testdata/bin/claude @@ -1,7 +1,8 @@ #!/bin/sh # Fake claude CLI for spawnllm integration tests. Reads the prompt from stdin, # reports whether its stdout fd is a regular file, echoes its isolated config -# home, and supports a one-shot transient failure and a sleep for timeout tests. +# home, and supports a one-shot transient failure, a revoked OAuth token, and a +# sleep for timeout tests. prompt="$(cat)" if [ -n "$FAKE_SLEEP" ]; then @@ -51,5 +52,10 @@ if [ -n "$FAKE_TRANSIENT_COUNTER" ]; then fi fi +if [ -n "$FAKE_REJECTED_TOKEN" ] && [ "$oauth_token" = "$FAKE_REJECTED_TOKEN" ]; then + echo "Failed to authenticate: OAuth token revoked. Please log in again or contact your administrator." + exit 1 +fi + printf '{"type":"result","is_error":false,"result":"%s","stdout_regular":%s,"config_dir":"%s","oauth_token":"%s","seeded":%s,"account_has_mcp":%s,"creds_present":%s,"config_dir_mode":"%s","creds_mode":"%s"}\n' \ "$prompt" "$stdout_regular" "$config_dir" "$oauth_token" "$seeded" "$account_has_mcp" "$creds_present" "$config_dir_mode" "$creds_mode" diff --git a/rust/conformance-gen/src/cases.rs b/rust/conformance-gen/src/cases.rs index 834a362..9aa153d 100644 --- a/rust/conformance-gen/src/cases.rs +++ b/rust/conformance-gen/src/cases.rs @@ -1191,14 +1191,26 @@ fn iso_sources_cases() -> Vec { ] } -fn iso_seed_case(name: &str, account_json: Option<&str>, credentials_json: Option<&str>) -> Case { +fn iso_seed_case( + name: &str, + account_json: Option<&str>, + credentials_json: &[&str], + rejected_tokens: &[&str], +) -> Case { Case { op: "claude_isolation_seed", name: name.to_owned(), - input: json!({"account_json": account_json, "credentials_json": credentials_json}), + input: json!({ + "account_json": account_json, + "credentials_json": credentials_json, + "rejected_tokens": rejected_tokens, + }), } } +const KEYCHAIN_CREDENTIALS: &str = r#"{"claudeAiOauth": {"accessToken": "kc-tok"}}"#; +const FILE_CREDENTIALS: &str = r#"{"claudeAiOauth": {"accessToken": "file-tok"}}"#; + fn iso_seed_cases() -> Vec { vec![ iso_seed_case( @@ -1206,23 +1218,83 @@ fn iso_seed_cases() -> Vec { Some( r#"{"oauthAccount": {"accountUuid": "a"}, "mcpServers": {"semble": {"command": "x"}}}"#, ), - Some(r#"{"claudeAiOauth": {"accessToken": "tok"}}"#), + &[r#"{"claudeAiOauth": {"accessToken": "tok"}}"#], + &[], ), iso_seed_case( "account-only", Some(r#"{"oauthAccount": {"accountUuid": "b"}, "mcpServers": {"s": {}}}"#), + &[], + &[], + ), + iso_seed_case("credentials-only", None, &[KEYCHAIN_CREDENTIALS], &[]), + iso_seed_case("both-null", None, &[], &[]), + iso_seed_case( + "account-without-mcp-servers", + Some(r#"{"oauthAccount": {"accountUuid": "c"}}"#), + &[], + &[], + ), + iso_seed_case( + "first-credentials-win", None, + &[KEYCHAIN_CREDENTIALS, FILE_CREDENTIALS], + &[], ), iso_seed_case( - "credentials-only", + "rejected-token-falls-through", None, - Some(r#"{"claudeAiOauth": {"accessToken": "kc-tok"}}"#), + &[KEYCHAIN_CREDENTIALS, FILE_CREDENTIALS], + &["kc-tok"], ), - iso_seed_case("both-null", None, None), iso_seed_case( - "account-without-mcp-servers", - Some(r#"{"oauthAccount": {"accountUuid": "c"}}"#), + "all-rejected-keeps-first", + None, + &[KEYCHAIN_CREDENTIALS, FILE_CREDENTIALS], + &["file-tok", "kc-tok"], + ), + iso_seed_case( + "credentials-without-oauth-skipped", None, + &[r#"{"mcpOAuth": {}}"#, FILE_CREDENTIALS], + &[], + ), + ] +} + +fn auth_rejected_case(name: &str, error_msg: &str) -> Case { + Case { + op: "claude_auth_rejected", + name: name.to_owned(), + input: json!({"error_msg": error_msg}), + } +} + +fn auth_rejected_cases() -> Vec { + vec![ + auth_rejected_case( + "oauth-token-revoked", + "claude exited 1: Failed to authenticate: OAuth token revoked. Please log in again or contact your administrator.", + ), + auth_rejected_case( + "oauth-session-expired", + "claude exited 1: Failed to authenticate: OAuth session expired and could not be refreshed", + ), + auth_rejected_case( + "api-401-authentication-error", + r#"API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token"}}"#, + ), + auth_rejected_case( + "access-token-has-been-revoked", + r#"API Error: 401 {"type":"error","error":{"type":"permission_error","message":"OAuth access token has been revoked"}}"#, + ), + auth_rejected_case( + "overloaded-is-not-rejected", + r#"API Error: 529 {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#, + ), + auth_rejected_case( + "nonzero-exit-is-not-rejected", + "claude exited 1: tool use failed", ), ] } @@ -1483,6 +1555,7 @@ pub fn all_cases() -> Vec { cases.extend(capabilities_cases()); cases.extend(iso_sources_cases()); cases.extend(iso_seed_cases()); + cases.extend(auth_rejected_cases()); cases.extend(decide_plan_cases()); cases.extend(decide_resolve_cases()); cases diff --git a/rust/spawnllm-core/src/isolate.rs b/rust/spawnllm-core/src/isolate.rs index dcacf95..36c281c 100644 --- a/rust/spawnllm-core/src/isolate.rs +++ b/rust/spawnllm-core/src/isolate.rs @@ -1,6 +1,8 @@ use std::collections::BTreeMap; use std::io; +use std::sync::LazyLock; +use regex_lite::Regex; use serde::{Deserialize, Serialize}; use serde_json::ser::Formatter; use serde_json::{Map, Value}; @@ -9,6 +11,10 @@ use unicode_normalization::UnicodeNormalization; use crate::{OpError, OpResult, from_input, unimplemented}; +static AUTH_REJECTION: LazyLock = LazyLock::new(|| { + Regex::new(r"Failed to authenticate|OAuth (?:access )?token (?:has been )?revoked|authentication_error|API Error: 401\b").unwrap() +}); + #[derive(Debug, Deserialize)] struct IsolationSourcesInput { host: IsolationHost, @@ -39,7 +45,18 @@ struct IsolationSources { #[derive(Debug, Deserialize)] struct IsolationSeedInput { account_json: Option, - credentials_json: Option, + credentials_json: Vec, + rejected_tokens: Vec, +} + +#[derive(Debug, Deserialize)] +struct AuthRejectedInput { + error_msg: String, +} + +#[derive(Debug, Serialize)] +struct AuthRejected { + rejected: bool, } #[derive(Debug, Deserialize)] @@ -181,15 +198,29 @@ fn isolation_seed(input: IsolationSeedInput) -> Result(&credentials_json)?; - if let Some(oauth) = credentials.claude_ai_oauth { - env.insert("CLAUDE_CODE_OAUTH_TOKEN", oauth.access_token); - } + if let Some(token) = access_token(&input.credentials_json, &input.rejected_tokens)? { + env.insert("CLAUDE_CODE_OAUTH_TOKEN", token); } Ok(IsolationSeed { files, env }) } +fn access_token( + credentials_json: &[String], + rejected_tokens: &[String], +) -> Result, serde_json::Error> { + let mut first_rejected = None; + for credentials in credentials_json { + let Some(oauth) = serde_json::from_str::(credentials)?.claude_ai_oauth else { + continue; + }; + if !rejected_tokens.contains(&oauth.access_token) { + return Ok(Some(oauth.access_token)); + } + first_rejected.get_or_insert(oauth.access_token); + } + Ok(first_rejected) +} + pub(crate) fn dispatch(op: &str, input: Value) -> OpResult { match op { "claude_isolation_sources" => { @@ -201,6 +232,13 @@ pub(crate) fn dispatch(op: &str, input: Value) -> OpResult { let seed = isolation_seed(input).map_err(OpError::internal)?; serde_json::to_value(seed).map_err(OpError::internal) } + "claude_auth_rejected" => { + let input = from_input::(input)?; + serde_json::to_value(AuthRejected { + rejected: AUTH_REJECTION.is_match(&input.error_msg), + }) + .map_err(OpError::internal) + } other => Err(unimplemented(other)), } } diff --git a/rust/spawnllm-core/src/lib.rs b/rust/spawnllm-core/src/lib.rs index 3a075d8..7e2f62e 100644 --- a/rust/spawnllm-core/src/lib.rs +++ b/rust/spawnllm-core/src/lib.rs @@ -96,7 +96,9 @@ fn run(op: &str, input: Value) -> OpResult { "resolve" => resolve::dispatch(input), "strict_schema" => schema::dispatch(input), "auth_probes" => probe::dispatch(input), - "claude_isolation_sources" | "claude_isolation_seed" => isolate::dispatch(op, input), + "claude_isolation_sources" | "claude_isolation_seed" | "claude_auth_rejected" => { + isolate::dispatch(op, input) + } "decide_plan" | "decide_resolve" => decide::dispatch(op, input), other => Err(OpError { kind: "unknown_op", diff --git a/rust/spawnllm/src/isolate.rs b/rust/spawnllm/src/isolate.rs index e6f4e34..2ac9ae1 100644 --- a/rust/spawnllm/src/isolate.rs +++ b/rust/spawnllm/src/isolate.rs @@ -1,9 +1,10 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::Path; #[cfg(any(target_os = "macos", test))] use std::process::Output; +use std::sync::Mutex; #[cfg(any(target_os = "macos", test))] use std::time::Duration; @@ -15,6 +16,10 @@ use crate::core_io::core_op; use crate::error::Error; use crate::host::{home, platform}; +const OAUTH_TOKEN_ENV: &str = "CLAUDE_CODE_OAUTH_TOKEN"; + +static REJECTED_TOKENS: Mutex> = Mutex::new(BTreeSet::new()); + #[derive(Debug, Deserialize)] struct Sources { account_path: Option, @@ -35,11 +40,36 @@ struct SeedFile { mode: String, } +#[derive(Debug, Deserialize)] +struct AuthRejected { + rejected: bool, +} + pub(crate) struct Isolation { pub(crate) dir: TempDir, pub(crate) env: BTreeMap, } +impl Isolation { + pub(crate) fn reject_credentials(&self, error_msg: &str) -> Result { + let Some(token) = self.env.get(OAUTH_TOKEN_ENV) else { + return Ok(false); + }; + let verdict: AuthRejected = + core_op("claude_auth_rejected", json!({ "error_msg": error_msg }))?; + if verdict.rejected { + REJECTED_TOKENS.lock().unwrap().insert(token.clone()); + } + Ok(verdict.rejected) + } + + pub(crate) fn token_rejected(&self) -> bool { + self.env + .get(OAUTH_TOKEN_ENV) + .is_some_and(|token| REJECTED_TOKENS.lock().unwrap().contains(token)) + } +} + pub(crate) async fn seed_isolation(api_auth: bool) -> Result { let sources: Sources = core_op( "claude_isolation_sources", @@ -57,21 +87,24 @@ pub(crate) async fn seed_isolation(api_auth: bool) -> Result { .account_path .as_deref() .and_then(|path| std::fs::read_to_string(path).ok()); - let credentials_json = match sources + let keychain_json = match &sources.keychain_service { + Some(service) => keychain_credentials(service).await, + None => None, + }; + let file_json = sources .credentials_path .as_deref() - .and_then(|path| std::fs::read_to_string(path).ok()) - { - Some(text) => Some(text), - None => match &sources.keychain_service { - Some(service) => keychain_credentials(service).await, - None => None, - }, - }; + .and_then(|path| std::fs::read_to_string(path).ok()); + let credentials_json: Vec = keychain_json.into_iter().chain(file_json).collect(); + let rejected_tokens: Vec = REJECTED_TOKENS.lock().unwrap().iter().cloned().collect(); let seed: Seed = core_op( "claude_isolation_seed", - json!({ "account_json": account_json, "credentials_json": credentials_json }), + json!({ + "account_json": account_json, + "credentials_json": credentials_json, + "rejected_tokens": rejected_tokens, + }), )?; let dir = private_tempdir()?; diff --git a/rust/spawnllm/src/run.rs b/rust/spawnllm/src/run.rs index 592cf37..6a86e89 100644 --- a/rust/spawnllm/src/run.rs +++ b/rust/spawnllm/src/run.rs @@ -150,7 +150,7 @@ async fn exec_loop( provider: &'static str, wants_value: bool, ) -> Response { - let isolation = if plan.needs_claude_isolation { + let mut isolation = if plan.needs_claude_isolation { match crate::isolate::seed_isolation(spec.api_auth).await { Ok(isolation) => Some(isolation), Err(error) => return error_response(spec, error, Vec::new()), @@ -162,12 +162,30 @@ async fn exec_loop( let mut discarded = Vec::new(); let max = spec.max_attempts.max(1); for attempt in 0..max { - let outcome = - crate::exec::exec_attempt(&plan, &spec, provider, isolation.as_ref(), wants_value) - .await; - let att = match outcome { - Ok(att) => att, - Err(error) => return error_response(spec, error.into(), discarded), + let att = loop { + let outcome = + crate::exec::exec_attempt(&plan, &spec, provider, isolation.as_ref(), wants_value) + .await; + let att = match outcome { + Ok(att) => att, + Err(error) => return error_response(spec, error.into(), discarded), + }; + let (Some(current), AttemptKind::Error { msg, .. }) = (&isolation, &att.kind) else { + break att; + }; + match current.reject_credentials(msg) { + Ok(true) => {} + Ok(false) => break att, + Err(error) => return error_response(spec, error, discarded), + } + let next = match crate::isolate::seed_isolation(spec.api_auth).await { + Ok(next) => next, + Err(error) => return error_response(spec, error, discarded), + }; + if next.token_rejected() { + break att; + } + isolation = Some(next); }; if let Some((output, outcome)) = settle(provider, attempt, max, att, &mut discarded).await { return Response { diff --git a/rust/spawnllm/tests/common/mod.rs b/rust/spawnllm/tests/common/mod.rs index 8095e23..50deaf7 100644 --- a/rust/spawnllm/tests/common/mod.rs +++ b/rust/spawnllm/tests/common/mod.rs @@ -41,6 +41,7 @@ if [ -n "$SPAWNLLM_FAKE_MARKER" ]; then if [ -f /dev/stdout ]; then printf 'regular' > "$SPAWNLLM_FAKE_MARKER"; else printf 'pipe' > "$SPAWNLLM_FAKE_MARKER"; fi fi if [ -n "$SPAWNLLM_FAKE_CRED_OUT" ]; then printf '%s' "${CLAUDE_CODE_OAUTH_TOKEN-}" > "$SPAWNLLM_FAKE_CRED_OUT"; fi +if [ -n "$SPAWNLLM_FAKE_REJECTED_TOKEN" ] && [ "${CLAUDE_CODE_OAUTH_TOKEN-}" = "$SPAWNLLM_FAKE_REJECTED_TOKEN" ]; then printf 'Failed to authenticate: OAuth token revoked.'; exit 1; fi if [ -n "$SPAWNLLM_FAKE_ACCOUNT_OUT" ]; then cat "$CLAUDE_CONFIG_DIR/.claude.json" > "$SPAWNLLM_FAKE_ACCOUNT_OUT" 2>/dev/null || true; fi if [ -n "$SPAWNLLM_FAKE_MODES_OUT" ]; then { ls -ld "$CLAUDE_CONFIG_DIR" | cut -c1-10; ls -A "$CLAUDE_CONFIG_DIR"; } > "$SPAWNLLM_FAKE_MODES_OUT"; fi if [ -n "$SPAWNLLM_FAKE_EXIT" ]; then printf 'boom' >&2; exit "$SPAWNLLM_FAKE_EXIT"; fi diff --git a/rust/spawnllm/tests/isolation.rs b/rust/spawnllm/tests/isolation.rs index d7428ad..1c39b4c 100644 --- a/rust/spawnllm/tests/isolation.rs +++ b/rust/spawnllm/tests/isolation.rs @@ -408,3 +408,47 @@ async fn inherited_oauth_token_reads_no_credential_source() { "an inherited token must skip the Keychain" ); } + +#[cfg(target_os = "macos")] +#[allow(clippy::await_holding_lock)] +#[tokio::test] +async fn rejected_keychain_token_falls_through_to_the_credentials_file() { + common::fixtures(); + let _guard = common::ENV_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + + let home = tempfile::tempdir().unwrap(); + std::fs::create_dir(home.path().join(".claude")).unwrap(); + std::fs::write(home.path().join(".claude.json"), r#"{"account": "me"}"#).unwrap(); + std::fs::write( + home.path().join(".claude/.credentials.json"), + r#"{"claudeAiOauth": {"accessToken": "file-token"}}"#, + ) + .unwrap(); + let cred_out = tempfile::NamedTempFile::new().unwrap(); + let cred_path = cred_out.path().to_str().unwrap().to_owned(); + let original_home = std::env::var_os("HOME"); + unsafe { + for var in HOST_KEYCHAIN_VARS { + std::env::remove_var(var); + } + std::env::set_var("HOME", home.path()); + std::env::set_var("SPAWNLLM_FAKE_KEYCHAIN_SERVICE", "Claude Code-credentials"); + } + let spec = RunSpec::new("hi", "haiku").max_attempts(1).env(env(&[ + ("SPAWNLLM_FAKE_CRED_OUT", &cred_path), + ("SPAWNLLM_FAKE_REJECTED_TOKEN", "keychain-token-xyz"), + ])); + let response = spawnllm::run_on(&Backend::Claude, spec).await; + unsafe { std::env::remove_var("SPAWNLLM_FAKE_KEYCHAIN_SERVICE") }; + match original_home { + Some(value) => unsafe { std::env::set_var("HOME", value) }, + None => unsafe { std::env::remove_var("HOME") }, + } + + response + .outcome + .expect("a rejected Keychain token falls through to the credentials file"); + assert_eq!(std::fs::read_to_string(&cred_path).unwrap(), "file-token"); +} diff --git a/spawnllm/backends/base.py b/spawnllm/backends/base.py index 85e1877..4c4b8c2 100644 --- a/spawnllm/backends/base.py +++ b/spawnllm/backends/base.py @@ -500,14 +500,17 @@ def timed_out(self, spec: RunSpec) -> Response: return Response(spec=spec, output=Output(""), error=Error(msg, TimeoutError(msg))) async def aexecute(self, spec: RunSpec) -> Response: - inv = self.invocation(spec) + return await self.aexecute_invocation(spec, self.invocation(spec), self.env(spec)) + + async def aexecute_invocation(self, spec: RunSpec, inv: Invocation, env: dict[str, str]) -> Response: + """Run a materialized `inv` asynchronously under `env` and resolve it, removing its temp files.""" try: try: rr = await acapture_cli( inv.argv, input=inv.stdin, env={key: value for key, value in os.environ.items() if key not in inv.env_unset} - | self.env(spec) + | env | (spec.env or {}), cwd=spec.cwd, timeout=spec.timeout, @@ -522,14 +525,17 @@ async def aexecute(self, spec: RunSpec) -> Response: return self.to_response(raw, returncode=rr.returncode, stderr=rr.stderr, spec=spec) def execute(self, spec: RunSpec) -> Response: - inv = self.invocation(spec) + return self.execute_invocation(spec, self.invocation(spec), self.env(spec)) + + def execute_invocation(self, spec: RunSpec, inv: Invocation, env: dict[str, str]) -> Response: + """Run a materialized `inv` under `env` and resolve it, removing its temp files.""" try: try: rr = capture_cli( inv.argv, input=inv.stdin, env={key: value for key, value in os.environ.items() if key not in inv.env_unset} - | self.env(spec) + | env | (spec.env or {}), cwd=spec.cwd, timeout=spec.timeout, diff --git a/spawnllm/backends/claude.py b/spawnllm/backends/claude.py index 72a7045..580e121 100644 --- a/spawnllm/backends/claude.py +++ b/spawnllm/backends/claude.py @@ -15,6 +15,8 @@ from spawnllm.backends.base import ClaudeIsolation, CliBackend if TYPE_CHECKING: + from spawnllm.response import Response + from spawnllm.spec import RunSpec from spawnllm.types import ProviderName, TModel CLAUDE_MODELS: dict[TModel, str] = {"small": "haiku", "medium": "sonnet", "large": "opus"} @@ -64,6 +66,7 @@ class ClaudeCliBackend(CliBackend): _isolated_config_dir: str | None = None _api_config_dir: str | None = None + _rejected_tokens: frozenset[str] = frozenset() def claude_isolation(self, api_auth: bool) -> ClaudeIsolation: """Return the isolation for one run: the process-lifetime config home and the env resolved now. @@ -71,13 +74,14 @@ def claude_isolation(self, api_auth: bool) -> ClaudeIsolation: The core's `claude_isolation_sources` op resolves the account pointer, credentials file, and Keychain service from the caller's effective config home, naming no credential source when the process already carries - `CLAUDE_CODE_OAUTH_TOKEN`; this host reads those sources (falling back to - the Keychain when the credentials file is absent) and hands them to - `claude_isolation_seed` for the exact files-and-modes to write and the env - to set. The files land in a fresh temp dir removed at interpreter exit, - created on the first call and cached on the backend; the env is resolved - on every call so a renewed Keychain token reaches the next run, and it - only ever lives in memory. An `api_auth` run names no source at all, so it + `CLAUDE_CODE_OAUTH_TOKEN`; this host reads those sources (the Keychain + before the credentials file, the order Claude Code itself reads them) and + hands them, with every token a run saw rejected, to `claude_isolation_seed` + for the exact files-and-modes to write and the env to set. The files land + in a fresh temp dir removed at interpreter exit, created on the first + call and cached on the backend; the env is resolved on every call so a + renewed Keychain token reaches the next run, and it only ever lives in + memory. An `api_auth` run names no source at all, so it reads no account, credentials file, or Keychain item and gets an empty home of its own. """ @@ -96,11 +100,21 @@ def claude_isolation(self, api_auth: bool) -> ClaudeIsolation: }, ) account_json = read_file_opt(sources["account_path"]) if sources["account_path"] else None - credentials_json = read_file_opt(sources["credentials_path"]) if sources["credentials_path"] else None - if credentials_json is None and sources["keychain_service"] is not None: - credentials_json = keychain_credentials(sources["keychain_service"]) + credentials_json = [ + credentials + for credentials in ( + keychain_credentials(sources["keychain_service"]) if sources["keychain_service"] else None, + read_file_opt(sources["credentials_path"]) if sources["credentials_path"] else None, + ) + if credentials is not None + ] seed = _core.dispatch( - "claude_isolation_seed", {"account_json": account_json, "credentials_json": credentials_json} + "claude_isolation_seed", + { + "account_json": account_json, + "credentials_json": credentials_json, + "rejected_tokens": sorted(self._rejected_tokens), + }, ) cached = self._api_config_dir if api_auth else self._isolated_config_dir if cached is None: @@ -116,3 +130,33 @@ def claude_isolation(self, api_auth: bool) -> ClaudeIsolation: else: self._isolated_config_dir = cached return ClaudeIsolation(cached, seed["env"]) + + def reject_credentials(self, spec: RunSpec, env: dict[str, str], response: Response) -> bool: + """Record the OAuth token in a run's `env` as rejected when the core reads `response` as an auth failure. + + Returns: + `True` when another credential source remains to retry the run with. + """ + token = env.get("CLAUDE_CODE_OAUTH_TOKEN") + if token is None or response.error is None: + return False + if not _core.dispatch("claude_auth_rejected", {"error_msg": response.error.msg})["rejected"]: + return False + self._rejected_tokens |= {token} + return self.claude_isolation(spec.api_auth).env.get("CLAUDE_CODE_OAUTH_TOKEN") not in self._rejected_tokens + + async def aexecute(self, spec: RunSpec) -> Response: + env = self.env(spec) + response = await self.aexecute_invocation(spec, self.invocation(spec), env) + while self.reject_credentials(spec, env, response): + env = self.env(spec) + response = await self.aexecute_invocation(spec, self.invocation(spec), env) + return response + + def execute(self, spec: RunSpec) -> Response: + env = self.env(spec) + response = self.execute_invocation(spec, self.invocation(spec), env) + while self.reject_credentials(spec, env, response): + env = self.env(spec) + response = self.execute_invocation(spec, self.invocation(spec), env) + return response diff --git a/tests/test_backends.py b/tests/test_backends.py index e590f65..8153d2c 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -305,6 +305,7 @@ class TestClaudeIsolation: @pytest.fixture(autouse=True) def no_inherited_token(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.setattr("spawnllm.backends.claude.sys.platform", "linux") def test_env_isolates_and_seeds_config_dir_from_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) @@ -383,6 +384,85 @@ def fake_run(argv: list[str], **kwargs: object) -> object: assert env["CLAUDE_CODE_OAUTH_TOKEN"] == "kc-tok" assert not (Path(env["CLAUDE_CONFIG_DIR"]) / ".credentials.json").exists() + @staticmethod + def darwin_home_with_both_sources(home: Path, monkeypatch: pytest.MonkeyPatch) -> list[list[str]]: + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + monkeypatch.setenv("HOME", str(home)) + (home / ".claude").mkdir() + (home / ".claude" / ".credentials.json").write_text('{"claudeAiOauth": {"accessToken": "file-tok"}}') + monkeypatch.setattr("spawnllm.backends.claude.sys.platform", "darwin") + calls: list[list[str]] = [] + + def fake_run(argv: list[str], **kwargs: object) -> object: + calls.append(argv) + return type("P", (), {"returncode": 0, "stdout": '{"claudeAiOauth": {"accessToken": "kc-tok"}}\n'})() + + monkeypatch.setattr("spawnllm.backends.claude.subprocess.run", fake_run) + return calls + + def test_env_prefers_the_keychain_over_the_credentials_file( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + calls = self.darwin_home_with_both_sources(tmp_path, monkeypatch) + env = ClaudeCliBackend().env(RunSpec(prompt="hi", model="haiku")) + assert calls == [["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"]] + assert env["CLAUDE_CODE_OAUTH_TOKEN"] == "kc-tok" + + @staticmethod + def rejecting(*rejected: str) -> tuple[list[str], Callable[..., RunResult]]: + tokens: list[str] = [] + + def fake_capture_cli(argv: list[str], **kwargs: object) -> RunResult: + env = kwargs["env"] + assert isinstance(env, dict) + tokens.append(env["CLAUDE_CODE_OAUTH_TOKEN"]) + if env["CLAUDE_CODE_OAUTH_TOKEN"] in rejected: + revoked = ( + "Failed to authenticate: OAuth token revoked. Please log in again or contact your administrator." + ) + return RunResult(revoked, "", 1) + return RunResult(json.dumps({"type": "result", "is_error": False, "result": "ok"}), "", 0) + + return tokens, fake_capture_cli + + def test_execute_falls_through_a_rejected_token_to_the_next_source( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + self.darwin_home_with_both_sources(tmp_path, monkeypatch) + tokens, fake_capture_cli = self.rejecting("kc-tok") + monkeypatch.setattr(base, "capture_cli", fake_capture_cli) + backend = ClaudeCliBackend() + first = backend.execute(RunSpec(prompt="hi", model="haiku")) + second = backend.execute(RunSpec(prompt="hi", model="haiku")) + assert first.error is None + assert second.error is None + assert tokens == ["kc-tok", "file-tok", "file-tok"] + + async def test_aexecute_falls_through_a_rejected_token_to_the_next_source( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + self.darwin_home_with_both_sources(tmp_path, monkeypatch) + tokens, fake_capture_cli = self.rejecting("kc-tok") + + async def fake_acapture_cli(argv: list[str], **kwargs: object) -> RunResult: + return fake_capture_cli(argv, **kwargs) + + monkeypatch.setattr(base, "acapture_cli", fake_acapture_cli) + response = await ClaudeCliBackend().aexecute(RunSpec(prompt="hi", model="haiku")) + assert response.error is None + assert tokens == ["kc-tok", "file-tok"] + + def test_execute_returns_the_rejection_once_every_source_is_rejected( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + self.darwin_home_with_both_sources(tmp_path, monkeypatch) + tokens, fake_capture_cli = self.rejecting("kc-tok", "file-tok") + monkeypatch.setattr(base, "capture_cli", fake_capture_cli) + response = ClaudeCliBackend().execute(RunSpec(prompt="hi", model="haiku")) + assert response.error is not None + assert "OAuth token revoked" in response.error.msg + assert tokens == ["kc-tok", "file-tok"] + def test_env_config_dir_falls_back_to_the_suffixed_keychain_item( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: