From 9b8752baa68a3fc39254a9a613ff2c09696b54c4 Mon Sep 17 00:00:00 2001 From: JGP Date: Sun, 30 Aug 2026 11:04:54 +0900 Subject: [PATCH 1/7] fix(auth): preserve selected Claude OAuth slots --- .../builtin/claude-sdk-oauth/changes.md | 27 +++++++++++++++++++ .../builtin/claude-sdk-oauth/oauth-login.ts | 6 ++++- .../suite/claude-sdk-oauth-extension.test.ts | 22 +++++++-------- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md index f3ec8e3443..baf68ab069 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md @@ -1,5 +1,32 @@ # claude-sdk-oauth +## 2026-08-30 - Preserve selected OAuth slots during provider preflight + +### What changed + +- `oauth-login.ts`: readiness now recognizes a concrete OAuth credential selected by the shared credential-rotation + layer, while still excluding the provider's synthetic managed sentinel. +- `test/suite/claude-sdk-oauth-extension.test.ts`: the stream preflight coverage now uses two stored logins so it + exercises the selected-slot request path. + +### Why + +- With two or more Claude logins, shared credential rotation passes one selected OAuth slot to provider auth + resolution. The Claude readiness predicate counted only the parent credential's `accounts` array, so the selected + slot appeared empty and the request failed with `Provider is not configured: claude-sdk-oauth` even though both + accounts were valid. + +### Why an extension could not handle it + +- The predicate is part of the builtin provider's private OAuth configuration and runs after the shared runtime has + selected a credential slot. An external extension cannot repair that credential interpretation without replacing + the provider registration. + +### Expected merge conflict zones + +- LOW in `oauth-login.ts` around `configuredFor` account counting. +- LOW in `test/suite/claude-sdk-oauth-extension.test.ts` around the stored-login stream preflight. + ## 2026-08-21 - Cache provider settings loads by mtime+size to cut lock convoy ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts index 1766ee58fc..93628ee0f9 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/oauth-login.ts @@ -109,9 +109,13 @@ export function createOAuthConfig(deps: { environment?: Record, ): Promise => { const storedAccounts = stored?.type === "oauth" && Array.isArray(stored.accounts) ? stored.accounts : []; + const selectedStoredAccount = + stored?.type === "oauth" && + stored.access !== SENTINEL_OAUTH_FIELDS.access && + stored.refresh !== SENTINEL_OAUTH_FIELDS.refresh; const effectiveEnvironment = environment ?? (await claudeEnvironment(ctx)); const environmentTokenCount = Object.values(effectiveEnvironment).filter(Boolean).length; - const accountCount = storedAccounts.length + environmentTokenCount; + const accountCount = storedAccounts.length + (selectedStoredAccount ? 1 : 0) + environmentTokenCount; const settings = deps.readSettings?.(); const lane = settings?.tokenInjection ?? (accountCount > 0 ? "oauth-slots" : "ambient"); if (lane === "ambient") { diff --git a/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts b/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts index 7e2da39d41..bb68ba34b8 100644 --- a/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts +++ b/packages/coding-agent/test/suite/claude-sdk-oauth-extension.test.ts @@ -55,19 +55,17 @@ async function createRuntimeWithProvider(config: ProviderConfigInput, storage = return runtime; } -function authenticatedStorage(): AuthStorage { +function authenticatedStorage(accountCount = 1): AuthStorage { return AuthStorage.inMemory({ [CLAUDE_SDK_OAUTH_PROVIDER_ID]: { ...emptyCredential(), - accounts: [ - { - name: "test", - refresh: "test-refresh", - access: "test-access", - expires: Date.now() + 60_000, - source: "login", - }, - ], + accounts: Array.from({ length: accountCount }, (_, index) => ({ + name: `test-${index + 1}`, + refresh: `test-refresh-${index + 1}`, + access: `test-access-${index + 1}`, + expires: Date.now() + 60_000, + source: "login", + })), }, }); } @@ -146,7 +144,7 @@ describe("claude-sdk-oauth builtin provider", () => { }); }); - it("preflight reaches streamSimple with a stored login", async () => { + it("preflight reaches streamSimple with multiple stored logins", async () => { const { registration } = captureRegistration(); let called = false; const config: ProviderConfigInput = { @@ -156,7 +154,7 @@ describe("claude-sdk-oauth builtin provider", () => { return fakeStreamSimple()(model, context); }, }; - const runtime = await createRuntimeWithProvider(config, authenticatedStorage()); + const runtime = await createRuntimeWithProvider(config, authenticatedStorage(2)); const model = (await runtime.getAvailable(CLAUDE_SDK_OAUTH_PROVIDER_ID))[0]; expect(model).toBeDefined(); const stream = runtime.streamSimple(model as Model, { messages: [], tools: [] } as unknown as Context); From ce4369962045986cc01c7d4affc549f2ab142e65 Mon Sep 17 00:00:00 2001 From: JGP Date: Sun, 30 Aug 2026 11:37:34 +0900 Subject: [PATCH 2/7] fix(auth): preserve provider-owned credential pools --- packages/ai/src/auth/pool/slots.ts | 4 +++ packages/ai/src/changes.md | 26 +++++++++++++++++++ .../test/credential-pool-resolve-slot.test.ts | 15 ++++++++++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/auth/pool/slots.ts b/packages/ai/src/auth/pool/slots.ts index a9eda87ff8..f6885f7b68 100644 --- a/packages/ai/src/auth/pool/slots.ts +++ b/packages/ai/src/auth/pool/slots.ts @@ -146,6 +146,10 @@ function nextLoginSlotName(credential: PooledCredential): string { * user's stored bytes change until a second credential actually exists. */ export function appendLoginSlot(current: PooledCredential | undefined, flat: Credential): Credential { + const providerOwned = flat as PooledCredential; + if (Array.isArray(providerOwned.accounts) && providerOwned.accounts.length > 0) { + return flat; + } if (!current || !Array.isArray(current.accounts) || current.accounts.length === 0) { return flat; } diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index b086600a7b..47744ffd32 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,3 +1,29 @@ +## Preserve provider-owned credential pools during login (2026-08-30) + +### What changed + +- `packages/ai/src/auth/pool/slots.ts`: `appendLoginSlot` now accepts an OAuth credential whose provider already + returned a populated `accounts` pool as the complete post-login credential instead of appending its flat + compatibility fields as another generated slot. +- `packages/ai/test/credential-pool-resolve-slot.test.ts`: covers a provider-owned named account pool and rejects the + duplicate `login-2` slot that previously copied the flat compatibility sentinel. + +### Why + +- The shared login layer automatically appends ordinary flat credentials. The Claude SDK OAuth provider already + returns its current pool plus the newly named account, so applying the generic append a second time stored the + provider's managed top-level sentinel as a fake account. Session affinity could select that fake account and fail + otherwise valid requests as `Provider is not configured`. + +### Why an extension could not handle it + +- The double append happens after the provider login returns, inside the shared credential-pool write path. Providers + cannot prevent the runtime from reinterpreting their completed pool as a flat credential. + +### Expected merge conflict zones + +- LOW: `auth/pool/slots.ts` at the start of `appendLoginSlot`. + ## Measure Cursor history at the wire representation (2026-08-29) ### What changed diff --git a/packages/ai/test/credential-pool-resolve-slot.test.ts b/packages/ai/test/credential-pool-resolve-slot.test.ts index 934930d775..fa51a994da 100644 --- a/packages/ai/test/credential-pool-resolve-slot.test.ts +++ b/packages/ai/test/credential-pool-resolve-slot.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; import { envApiKeyAuth } from "../src/auth/helpers.ts"; -import { listSlots, type PooledCredential } from "../src/auth/pool/slots.ts"; +import { appendLoginSlot, listSlots, type PooledCredential } from "../src/auth/pool/slots.ts"; import { resolveProviderAuth } from "../src/auth/resolve.ts"; import type { OAuthCredential } from "../src/auth/types.ts"; import { createProvider, type Provider } from "../src/models.ts"; @@ -63,6 +63,19 @@ function oauthProvider(refreshed: (credential: OAuthCredential) => OAuthCredenti } describe("slot-scoped auth resolution", () => { + test("login preserves a provider-owned pooled credential instead of double-appending its flat sentinel", () => { + const current = pooledOAuthEntry(); + const providerOwned: PooledCredential = { + ...current, + accounts: [ + ...(current.accounts ?? []), + { name: "named", access: "named-access", refresh: "r-named", expires: FUTURE, source: "login" }, + ], + }; + + expect(appendLoginSlot(current, providerOwned)).toEqual(providerOwned); + }); + test("slotName resolves the named api_key slot instead of the flat projection", async () => { const store = new InMemoryCredentialStore(); await store.modify("slottest", async () => pooledApiKeyEntry()); From fdf8ed36a9eb7facc902e7c0e17662baafa3bb89 Mon Sep 17 00:00:00 2001 From: JGP Date: Sun, 30 Aug 2026 18:07:02 +0900 Subject: [PATCH 3/7] fix(auth): keep OAuth network failures transient --- packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 3 ++ .../builtin/claude-sdk-oauth/auth-lane.ts | 5 +++- .../builtin/claude-sdk-oauth/changes.md | 30 +++++++++++++++++++ .../builtin/claude-sdk-oauth/errors.ts | 17 +++++++++++ .../builtin/claude-sdk-oauth/guidance.ts | 2 +- ...uth-refresh-network-classification.test.ts | 19 ++++++++++++ 7 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/claude-oauth-refresh-network-classification.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b81c2d7c45..6b3aec0182 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- Provider-owned OAuth account pools are preserved during login instead of being appended again as a duplicate slot + from their flat compatibility credential. - Preserve GLM-5.3 Flash and Highspeed reasoning effort mappings and Z.AI thinking serialization. - Coalesced adjacent Anthropic user and tool-result turns without changing standalone string user-message content. - Anthropic prompt caching now retains the previous checkpoint while tool loops append a new result, avoiding repeated prefix reprocessing for API-key and OAuth requests. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d65ebadf74..4347d1d4a3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,9 @@ ### Fixed +- Claude SDK OAuth no longer treats DNS, connection, timeout, socket, or fetch failures during token refresh as a + rejected token that blocks the account until re-login; real OAuth rejection signals remain persistent auth errors. + - Bash callback settlement is bounded on direct, shared-host, and harness execution paths so never-settling callbacks cannot hang commands or silently lose spill cleanup failures. - Deterministic compaction fallback now supports replay-safe Gemini opaque provider state (thoughtSignature, thinkingSignature, textSignature, and empty visible text blocks) and recovers earlier safe boundaries without breaking atomic tool-call chains ([#947](https://github.com/code-yeongyu/senpi/pull/947)). diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/auth-lane.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/auth-lane.ts index c95885ec8f..3e19ff614a 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/auth-lane.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/auth-lane.ts @@ -143,7 +143,10 @@ async function prepareSlot( Object.assign(slot, updated); } catch (error) { const detail = error instanceof Error ? error.message : String(error); - throw new Error(`authentication_failed: ${detail}`); + const classification = classifySdkError(error); + const code = + classification.kind === "other" && classification.retryable ? "server_error" : "authentication_failed"; + throw new Error(`${code}: ${detail}`); } } const access = slot.source === "env" ? envSlotToken((name) => environment[name], slot.name) : slot.access; diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md index baf68ab069..d606acf338 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md @@ -1,5 +1,35 @@ # claude-sdk-oauth +## 2026-08-30 - Keep transient OAuth refresh failures temporary + +### What changed + +- `errors.ts`: DNS, connection, timeout, socket, and fetch transport failures classify as retryable transient errors + before the generic `authentication_failed` SDK code is considered. Explicit `invalid_grant`, `invalid_token`, + revoked-token, HTTP 401, and unauthorized signals remain persistent `auth_error` classifications. +- `auth-lane.ts`: recognized transient refresh failures surface as `server_error` instead of claiming that the OAuth + token was rejected. +- `guidance.ts`: all-account guidance now names transient provider failures among temporary block causes. +- `test/suite/regressions/claude-oauth-refresh-network-classification.test.ts`: locks the transient/auth boundary with + the observed `ENOTFOUND platform.claude.com` and Undici timeout shapes. + +### Why + +- A temporary DNS outage made the Anthropic refresh request fail before the server could inspect the token. The auth + lane wrapped every refresh exception as `authentication_failed`; failover therefore persisted `auth_error`, which + never expires and incorrectly required re-login. Other external APIs failed DNS in the same window, confirming a + transport outage rather than token rejection. + +### Why an extension could not handle it + +- Refresh executes inside this builtin's managed account lane before the Claude subprocess starts. Only this layer + can preserve the distinction between transport failure and an OAuth server rejection before failover persists the + account state. + +### Expected merge conflict zones + +- LOW in `errors.ts` before SDK-code matching, `auth-lane.ts` refresh error wrapping, and `guidance.ts` blocked text. + ## 2026-08-30 - Preserve selected OAuth slots during provider preflight ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts index 0cad7c0d6f..4e85f67582 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts @@ -18,6 +18,8 @@ const SDK_ERROR_CLASSIFICATIONS: Partial | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -40,9 +42,24 @@ function errorText(error: unknown): string { /** Classifies Claude SDK OAuth error codes and HTTP-shaped fallback text in one place. */ export function classifySdkError(error: unknown): SdkErrorClassification { const text = errorText(error).toLowerCase(); + // Transport failures during token refresh do not say anything about token + // validity. Match them before `authentication_failed` because the auth lane + // wraps every refresh exception with that SDK-compatible prefix. + if ( + /\b(?:enotfound|eai_again|econnreset|econnrefused|etimedout|enetworkdown|enetunreach|ehostunreach|und_err_connect_timeout|und_err_socket)\b|fetch failed|network(?: request)? (?:failed|error)|socket hang up/.test( + text, + ) + ) { + return TRANSIENT_NETWORK_ERROR; + } for (const [code, classification] of Object.entries(SDK_ERROR_CLASSIFICATIONS)) { if (new RegExp(`\\b${code}\\b`).test(text)) return classification; } + if ( + /\binvalid_grant\b|\binvalid_token\b|\btoken\b[^.]*\brevoked\b|\b(?:http\s*)?401\b|\bunauthorized\b/.test(text) + ) { + return AUTH_ERROR; + } if (/\b(?:http\s*)?429\b|too many requests|rate[ _-]?limit/.test(text)) { return { kind: "rate_limit", retryable: true }; } diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts index 5407361237..fcf8fa57bc 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/guidance.ts @@ -23,7 +23,7 @@ export function allAccountsBlockedGuidance(soonestUnblockAt: number | undefined) ? new Date(soonestUnblockAt).toISOString() : "after re-login"; return [ - `All Claude accounts for ${PROVIDER} are currently blocked (rate limit or auth errors).`, + `All Claude accounts for ${PROVIDER} are currently blocked (rate limit, transient provider, or auth errors).`, ` Soonest automatic retry: ${eta}.`, ` /claude-account list - inspect account states`, ` /login ${PROVIDER} - add another account`, diff --git a/packages/coding-agent/test/suite/regressions/claude-oauth-refresh-network-classification.test.ts b/packages/coding-agent/test/suite/regressions/claude-oauth-refresh-network-classification.test.ts new file mode 100644 index 0000000000..c084f5b8ab --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/claude-oauth-refresh-network-classification.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { classifySdkError } from "../../../src/core/extensions/builtin/claude-sdk-oauth/errors.ts"; + +describe("Claude OAuth refresh failure classification", () => { + it.each([ + "fetch failed; cause=Error: getaddrinfo ENOTFOUND platform.claude.com; code=ENOTFOUND", + "authentication_failed: Anthropic token refresh request failed; cause=getaddrinfo EAI_AGAIN platform.claude.com", + "TypeError: fetch failed; cause=ConnectTimeoutError; code=UND_ERR_CONNECT_TIMEOUT", + ])("keeps transient network failures retryable without classifying the token as invalid: %s", (message) => { + expect(classifySdkError(new Error(message))).toEqual({ kind: "other", retryable: true }); + }); + + it.each([ + "authentication_failed: invalid_grant: refresh token revoked", + "OAuth refresh failed with HTTP 401 unauthorized", + ])("continues to classify actual refresh-token rejection as an auth error: %s", (message) => { + expect(classifySdkError(new Error(message))).toEqual({ kind: "auth_error", retryable: true }); + }); +}); From f83edddb8a216ac5eb4df2060a2a2d029ba8ea52 Mon Sep 17 00:00:00 2001 From: JGP Date: Sun, 30 Aug 2026 18:10:47 +0900 Subject: [PATCH 4/7] test(auth): cover connection reset refresh failures --- .../src/core/extensions/builtin/claude-sdk-oauth/errors.ts | 2 +- packages/coding-agent/test/claude-sdk-oauth-failover.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts index 4e85f67582..37c8d4fd36 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/errors.ts @@ -46,7 +46,7 @@ export function classifySdkError(error: unknown): SdkErrorClassification { // validity. Match them before `authentication_failed` because the auth lane // wraps every refresh exception with that SDK-compatible prefix. if ( - /\b(?:enotfound|eai_again|econnreset|econnrefused|etimedout|enetworkdown|enetunreach|ehostunreach|und_err_connect_timeout|und_err_socket)\b|fetch failed|network(?: request)? (?:failed|error)|socket hang up/.test( + /\b(?:enotfound|eai_again|econnreset|econnrefused|etimedout|enetworkdown|enetunreach|ehostunreach|und_err_connect_timeout|und_err_socket)\b|fetch failed|network(?: request)? (?:failed|error)|socket hang up|connection reset by peer/.test( text, ) ) { diff --git a/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts b/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts index e25ac2fc55..05c0aea37e 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts @@ -85,13 +85,13 @@ describe("Claude SDK OAuth failover", () => { }); }); - it("still treats unrelated errors as non-retryable", () => { + it("keeps unrelated errors non-retryable while treating connection resets as transient", () => { // The prose matcher must not swallow ordinary failures into the retry path. expect(classifySdkError("context window exceeded for this request")).toEqual({ kind: "other", retryable: false, }); - expect(classifySdkError("connection reset by peer")).toEqual({ kind: "other", retryable: false }); + expect(classifySdkError("connection reset by peer")).toEqual({ kind: "other", retryable: true }); }); it("walks HRW order after a rate limit, persists the cooldown, and emits failover", async () => { From 592ec80992405329f68e02c5a46ece6b7452f3c3 Mon Sep 17 00:00:00 2001 From: JGP Date: Sun, 30 Aug 2026 18:22:01 +0900 Subject: [PATCH 5/7] test(auth): verify temporary refresh cooldown --- ...uth-refresh-network-classification.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/coding-agent/test/suite/regressions/claude-oauth-refresh-network-classification.test.ts b/packages/coding-agent/test/suite/regressions/claude-oauth-refresh-network-classification.test.ts index c084f5b8ab..b1aabadf04 100644 --- a/packages/coding-agent/test/suite/regressions/claude-oauth-refresh-network-classification.test.ts +++ b/packages/coding-agent/test/suite/regressions/claude-oauth-refresh-network-classification.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; +import { AuthStorage } from "../../../src/core/auth-storage.ts"; +import { + type ClaudeSdkOauthCredential, + emptyCredential, +} from "../../../src/core/extensions/builtin/claude-sdk-oauth/accounts.ts"; import { classifySdkError } from "../../../src/core/extensions/builtin/claude-sdk-oauth/errors.ts"; +import { runFailover } from "../../../src/core/extensions/builtin/claude-sdk-oauth/failover.ts"; + +const FUTURE = 4_102_444_800_000; describe("Claude OAuth refresh failure classification", () => { it.each([ @@ -16,4 +24,43 @@ describe("Claude OAuth refresh failure classification", () => { ])("continues to classify actual refresh-token rejection as an auth error: %s", (message) => { expect(classifySdkError(new Error(message))).toEqual({ kind: "auth_error", retryable: true }); }); + + it("persists only a temporary cooldown before failing over after a transport reset", async () => { + const accounts = [ + { name: "primary", access: "access-a", refresh: "refresh-a", expires: FUTURE, source: "login" as const }, + { name: "backup", access: "access-b", refresh: "refresh-b", expires: FUTURE, source: "login" as const }, + ]; + const store = AuthStorage.inMemory({ + "claude-sdk-oauth": { ...emptyCredential(), accounts }, + }); + const attempts: string[] = []; + const output: string[] = []; + const stream = runFailover({ + accounts, + selectFn: (pool) => pool.find((account) => account.blockedUntil === undefined) ?? pool[0]!, + runAttempt: async function* (account) { + attempts.push(account.name); + if (account.name === "primary") throw new Error("connection reset by peer"); + yield "ok"; + }, + classify: classifySdkError, + store, + providerId: "claude-sdk-oauth", + now: () => 1_000, + baseBlockMs: 500, + }); + + for await (const event of stream) output.push(event); + + expect(attempts).toEqual(["primary", "backup"]); + expect(output).toEqual(["ok"]); + const credential = store.get("claude-sdk-oauth"); + expect(credential?.type).toBe("oauth"); + expect( + (credential as ClaudeSdkOauthCredential).accounts?.find((account) => account.name === "primary"), + ).toMatchObject({ + blockReason: "other", + blockedUntil: 1_500, + }); + }); }); From 726f6982cc186019863e0705f5e14af284765f39 Mon Sep 17 00:00:00 2001 From: JGP Date: Sun, 30 Aug 2026 18:35:13 +0900 Subject: [PATCH 6/7] test(auth): keep refresh coverage shard-stable --- .../builtin/claude-sdk-oauth/changes.md | 4 +- .../test/claude-sdk-oauth-failover.test.ts | 10 +++ ...uth-refresh-network-classification.test.ts | 66 ------------------- 3 files changed, 12 insertions(+), 68 deletions(-) delete mode 100644 packages/coding-agent/test/suite/regressions/claude-oauth-refresh-network-classification.test.ts diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md index d606acf338..74d6695417 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md @@ -10,8 +10,8 @@ - `auth-lane.ts`: recognized transient refresh failures surface as `server_error` instead of claiming that the OAuth token was rejected. - `guidance.ts`: all-account guidance now names transient provider failures among temporary block causes. -- `test/suite/regressions/claude-oauth-refresh-network-classification.test.ts`: locks the transient/auth boundary with - the observed `ENOTFOUND platform.claude.com` and Undici timeout shapes. +- `test/claude-sdk-oauth-failover.test.ts`: locks the transient/auth boundary with the observed + `ENOTFOUND platform.claude.com`, Undici timeout, connection-reset, invalid-grant, and HTTP 401 shapes. ### Why diff --git a/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts b/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts index 05c0aea37e..11ada052f0 100644 --- a/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts +++ b/packages/coding-agent/test/claude-sdk-oauth-failover.test.ts @@ -52,6 +52,16 @@ describe("Claude SDK OAuth failover", () => { expect(classifySdkError("HTTP 529 overloaded")).toEqual({ kind: "overloaded", retryable: true }); }); + it.each([ + ["fetch failed; getaddrinfo ENOTFOUND platform.claude.com", "other", true], + ["authentication_failed: getaddrinfo EAI_AGAIN platform.claude.com", "other", true], + ["fetch failed; UND_ERR_CONNECT_TIMEOUT", "other", true], + ["authentication_failed: invalid_grant token revoked", "auth_error", true], + ["OAuth refresh HTTP 401 unauthorized", "auth_error", true], + ] as const)("classifies OAuth refresh failure %s", (message, kind, retryable) => { + expect(classifySdkError(message)).toEqual({ kind, retryable }); + }); + it("treats Claude Code's prose subscription limits as rate limits", () => { // Real message from the CLI on an exhausted Pro/Max plan: no SDK error code // and no HTTP status, so without prose matching it classified as diff --git a/packages/coding-agent/test/suite/regressions/claude-oauth-refresh-network-classification.test.ts b/packages/coding-agent/test/suite/regressions/claude-oauth-refresh-network-classification.test.ts deleted file mode 100644 index b1aabadf04..0000000000 --- a/packages/coding-agent/test/suite/regressions/claude-oauth-refresh-network-classification.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { AuthStorage } from "../../../src/core/auth-storage.ts"; -import { - type ClaudeSdkOauthCredential, - emptyCredential, -} from "../../../src/core/extensions/builtin/claude-sdk-oauth/accounts.ts"; -import { classifySdkError } from "../../../src/core/extensions/builtin/claude-sdk-oauth/errors.ts"; -import { runFailover } from "../../../src/core/extensions/builtin/claude-sdk-oauth/failover.ts"; - -const FUTURE = 4_102_444_800_000; - -describe("Claude OAuth refresh failure classification", () => { - it.each([ - "fetch failed; cause=Error: getaddrinfo ENOTFOUND platform.claude.com; code=ENOTFOUND", - "authentication_failed: Anthropic token refresh request failed; cause=getaddrinfo EAI_AGAIN platform.claude.com", - "TypeError: fetch failed; cause=ConnectTimeoutError; code=UND_ERR_CONNECT_TIMEOUT", - ])("keeps transient network failures retryable without classifying the token as invalid: %s", (message) => { - expect(classifySdkError(new Error(message))).toEqual({ kind: "other", retryable: true }); - }); - - it.each([ - "authentication_failed: invalid_grant: refresh token revoked", - "OAuth refresh failed with HTTP 401 unauthorized", - ])("continues to classify actual refresh-token rejection as an auth error: %s", (message) => { - expect(classifySdkError(new Error(message))).toEqual({ kind: "auth_error", retryable: true }); - }); - - it("persists only a temporary cooldown before failing over after a transport reset", async () => { - const accounts = [ - { name: "primary", access: "access-a", refresh: "refresh-a", expires: FUTURE, source: "login" as const }, - { name: "backup", access: "access-b", refresh: "refresh-b", expires: FUTURE, source: "login" as const }, - ]; - const store = AuthStorage.inMemory({ - "claude-sdk-oauth": { ...emptyCredential(), accounts }, - }); - const attempts: string[] = []; - const output: string[] = []; - const stream = runFailover({ - accounts, - selectFn: (pool) => pool.find((account) => account.blockedUntil === undefined) ?? pool[0]!, - runAttempt: async function* (account) { - attempts.push(account.name); - if (account.name === "primary") throw new Error("connection reset by peer"); - yield "ok"; - }, - classify: classifySdkError, - store, - providerId: "claude-sdk-oauth", - now: () => 1_000, - baseBlockMs: 500, - }); - - for await (const event of stream) output.push(event); - - expect(attempts).toEqual(["primary", "backup"]); - expect(output).toEqual(["ok"]); - const credential = store.get("claude-sdk-oauth"); - expect(credential?.type).toBe("oauth"); - expect( - (credential as ClaudeSdkOauthCredential).accounts?.find((account) => account.name === "primary"), - ).toMatchObject({ - blockReason: "other", - blockedUntil: 1_500, - }); - }); -}); From c25b8815d878f556946d871aa5dff96848ea0399 Mon Sep 17 00:00:00 2001 From: JGP Date: Sun, 30 Aug 2026 18:47:17 +0900 Subject: [PATCH 7/7] test(rpc): stabilize host event readiness --- packages/coding-agent/test/interactive-host-runtime.test.ts | 6 +++++- packages/coding-agent/test/rpc-socket-host.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/test/interactive-host-runtime.test.ts b/packages/coding-agent/test/interactive-host-runtime.test.ts index a38ae2ad60..31d77b0f1b 100644 --- a/packages/coding-agent/test/interactive-host-runtime.test.ts +++ b/packages/coding-agent/test/interactive-host-runtime.test.ts @@ -1739,7 +1739,11 @@ describe("interactive host runtime", () => { ensureHost: async () => undefined, }); try { - await runtime.switchSession(target.getSessionFile()!); + await runtime.switchSession(target.getSessionFile()!, { + withSession: async (ctx) => { + expect(ctx.sessionManager.getSessionFile()).toBe(target.getSessionFile()); + }, + }); await runtime.session.compact(); expect(runtime.session.messages).toContainEqual({ role: "user", diff --git a/packages/coding-agent/test/rpc-socket-host.test.ts b/packages/coding-agent/test/rpc-socket-host.test.ts index 92e259116e..1d7d07319e 100644 --- a/packages/coding-agent/test/rpc-socket-host.test.ts +++ b/packages/coding-agent/test/rpc-socket-host.test.ts @@ -262,11 +262,11 @@ describe("RPC Unix-socket multi-connection host", () => { value.type === "extension_ui_request" && value.method === "setWidget" && value.widgetKey === "array-widget", - 1_000, + 10_000, ); const unsupported = peer.peer.waitFor( (value) => value.type === "extension_ui_request" && value.method === "custom_unsupported", - 1_000, + 10_000, ); const opened = await peer.peer.request({ id: "open", type: "open_session", cwd: qa.cwd }); const sessionId = openedSessionId(opened);