Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
10 changes: 10 additions & 0 deletions conformance/vectors/claude_auth_rejected/oauth-token-revoked.json
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 2 additions & 1 deletion conformance/vectors/claude_isolation_seed/account-only.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"op": "claude_isolation_seed",
"input": {
"account_json": "{\"oauthAccount\": {\"accountUuid\": \"c\"}}",
"credentials_json": null
"credentials_json": [],
"rejected_tokens": []
},
"expected": {
"files": [
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
3 changes: 2 additions & 1 deletion conformance/vectors/claude_isolation_seed/both-null.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"op": "claude_isolation_seed",
"input": {
"account_json": null,
"credentials_json": null
"credentials_json": [],
"rejected_tokens": []
},
"expected": {
"files": [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
18 changes: 14 additions & 4 deletions go/coreops.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
43 changes: 33 additions & 10 deletions go/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions go/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading