From 53a0c80ec021e68fc5ca197b8ecefa3a633ca71f Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Wed, 30 Sep 2026 02:54:37 +0000 Subject: [PATCH 1/7] fix(oauth): keep expired CLI slots out of pause fallback --- src/oauth/store.ts | 4 +++- structure/providers/anthropic-account-pool.md | 2 ++ .../adapters/anthropic/anthropic-account-pause.test.ts | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 0d5233f912..9442011109 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -1288,7 +1288,9 @@ export async function setAccountPaused( if (paused && set.activeAccountId === accountId) { const start = set.accounts.findIndex(candidate => candidate.id === accountId); const ring = [...set.accounts.slice(start + 1), ...set.accounts.slice(0, start)]; - const fallback = ring.find(candidate => candidate.paused !== true && candidate.needsReauth !== true); + const fallback = ring.find(candidate => candidate.paused !== true && candidate.needsReauth !== true + && !(provider === "anthropic" && candidate.credential.source === "local-cli" + && candidate.credential.expires <= Date.now() + 60_000)); if (fallback) { set.activeAccountId = fallback.id; set.selectionRevision = randomUUID(); diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index 54564e418a..4905a66bee 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -7,6 +7,8 @@ OAuth, not a second list in provider config. `setAccountPaused` serializes pause with credential and selection writes, advances the selection revision, and only publishes invalidation after persistence. Removing an account removes its pause; reauthentication preserves it. The store moves active selection to an unpaused, non-reauth row if available. +For Anthropic, that automatic fallback also skips expired background local-CLI rows so making +one active cannot authorize it to adopt another account's current Claude Code credential. Pause does not clear cooldowns, quota, or credentials and does not cancel an already-sent turn. `src/oauth/anthropic-routing.ts` excludes paused rows from quota, round-robin, fill-first, diff --git a/tests/adapters/anthropic/anthropic-account-pause.test.ts b/tests/adapters/anthropic/anthropic-account-pause.test.ts index 631ecb15e6..5e3405c080 100644 --- a/tests/adapters/anthropic/anthropic-account-pause.test.ts +++ b/tests/adapters/anthropic/anthropic-account-pause.test.ts @@ -75,6 +75,16 @@ test("pause invalidates quorum immediately; disabled-pool reactive failover skip expect(hasAnthropicFailoverQuorum()).toBe(true); }); +test("pause does not promote an expired background local-CLI credential", async () => { + const [a, b, c] = ids as [string, string, string]; + const background = getAccountCredential("anthropic", b)!; + await saveAccountCredential("anthropic", b, { ...background, source: "local-cli", expires: Date.now() + 30_000 }); + const result = await setAccountPaused("anthropic", a, true); + expect(result).toMatchObject({ status: "updated", activeAccountChanged: true, activeAccountId: c }); + expect(getAccountSet("anthropic")!.accounts.find(row => row.id === b)?.paused).not.toBe(true); + expect(getAccountCredential("anthropic", b)?.access).toBe(background.access); +}); + test("all-paused refusal and resume preserve credentials and cooldown, independent of pool enable", async () => { const a = ids[0]!; const credential = getAccountCredential("anthropic", a); From 90537887f4ad35946355f3135e313c084f6268aa Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:24:50 +0000 Subject: [PATCH 2/7] ci: retrigger (flaky multi-file batch wedge in test 2/4) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> From ba908c91ace78b15c172296541294e2e635be3cd Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:11:26 +0900 Subject: [PATCH 3/7] Verify explicit legacy account selection without relaxing pause fallback --- .../docs/reference/cli/providers-accounts.md | 5 ++ structure/providers/anthropic-account-pool.md | 4 ++ .../anthropic/anthropic-account-pause.test.ts | 70 +++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 0c42421c02..bedd7883b6 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -493,6 +493,11 @@ token; the stored account ID and email are preserved. If Claude Code replaces bo OpenCodex cannot establish credential continuity and uses its stored-token refresh or existing reauthentication path instead. Use explicit login to import a completely replaced Claude Code pair. +If no permitted fallback remains, quota and live model discovery wait for a usable active account. +You can explicitly select an existing unpaused legacy account with +`ocx account use anthropic `; its own valid credential and normal stored-token +refresh remain available even when its original credential source was not recorded. + ```bash ocx account pause google-antigravity ocx account resume google-antigravity diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index 5fc2ab18b7..69bb0b3281 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -15,6 +15,10 @@ supplies this credential-continuity check; a shared path, active selection or ma does not prove continuity. Fully replaced opaque token pairs are not adopted, even if they may belong to the same account. Stored-token refresh and explicit login remain available through the existing refresh-intent/reauth rules, so promotion cannot authorize another account's CLI pair. +The provenance restriction governs automatic pause fallback, not explicit selection of an existing +unpaused account. A selected legacy row can use its own valid bearer for quota/model discovery and +refresh its stored token normally. With no permitted fallback, active-account probes stay closed +until a usable account is selected or resumed; they do not silently select an unknown-origin row. Pause does not clear cooldowns, quota, or credentials and does not cancel an already-sent turn. `src/oauth/anthropic-routing.ts` excludes paused rows from quota, round-robin, fill-first, diff --git a/tests/adapters/anthropic/anthropic-account-pause.test.ts b/tests/adapters/anthropic/anthropic-account-pause.test.ts index 8eca86b83c..b76e2bd452 100644 --- a/tests/adapters/anthropic/anthropic-account-pause.test.ts +++ b/tests/adapters/anthropic/anthropic-account-pause.test.ts @@ -3,6 +3,9 @@ import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { clearPoolRotationState } from "../../../src/codex/pool-rotation"; +import { fetchProviderModels } from "../../../src/codex/catalog/provider-models"; +import { clearModelCache } from "../../../src/codex/model-cache"; +import * as outbound from "../../../src/lib/provider-outbound"; import { OAuthAccountPausedError, OAuthLoginRequiredError, OAUTH_PROVIDERS, refreshAnthropicAccountWithLock } from "../../../src/oauth"; import { AnthropicTokenError } from "../../../src/oauth/anthropic"; import * as localTokens from "../../../src/oauth/local-token-detect"; @@ -16,6 +19,7 @@ import { removeAccount, saveAccountCredential, saveCredential, setAccountPaused, setActiveAccount, } from "../../../src/oauth/store"; import { clearAccountQuotaCache, setCachedProviderAccountQuotaForTests } from "../../../src/providers/quota"; +import { fetchAnthropicQuota } from "../../../src/providers/quota/vendor-probes-oauth"; import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConfig } from "../../../src/types"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; @@ -97,6 +101,72 @@ for (const source of [undefined, "invalid-source"] as const) { }); expect(getAccountCredential("anthropic", b)?.access).toBe(background.access); }); + + test(`explicitly selected ${source ?? "source-less"} legacy account keeps quota, discovery and stored refresh`, async () => { + const [a, b, c] = ids as [string, string, string]; + const legacy = { ...getAccountCredential("anthropic", b)!, source: source as "oauth" | undefined }; + await saveAccountCredential("anthropic", b, legacy); + await setAccountPaused("anthropic", c, true); + const originalFetch = globalThis.fetch; + const usageBearers: Array = []; + const modelBearers: Array = []; + const refreshTokens: string[] = []; + const detect = spyOn(localTokens, "detectClaudeCodeToken").mockImplementation(() => { + throw new Error("legacy accounts must not read external Claude credentials"); + }); + const refreshed = { access: "synthetic-legacy-refreshed", refresh: "synthetic-legacy-rotated", expires: Date.now() + 3_600_000 }; + const refresh = spyOn(OAUTH_PROVIDERS.anthropic!, "refresh").mockImplementation(async token => { + refreshTokens.push(token); + return refreshed; + }); + globalThis.fetch = (async (input, init) => { + // Every possible fetch stays inside this fixture, including unexpected URLs. + expect(String(input)).toBe("https://api.anthropic.com/api/oauth/usage"); + usageBearers.push(new Headers(init?.headers).get("authorization")); + return Response.json({ five_hour: { utilization: 25 } }); + }) as typeof fetch; + const models = spyOn(outbound, "providerOutboundGet").mockImplementation(async (_name, _provider, url, init, deps) => { + expect(url).toBe("https://api.anthropic.com/v1/models?limit=1000"); + expect(await deps?.beforeSend?.()).toBe(true); + modelBearers.push(new Headers(init?.headers).get("authorization")); + return Response.json({ data: [{ id: "claude-fixture-legacy" }] }); + }); + const provider = config().providers.anthropic!; + try { + clearModelCache(); + // Unknown provenance cannot silently acquire active-account authority on pause. + expect(await setAccountPaused("anthropic", a, true)).toMatchObject({ activeAccountChanged: false, activeAccountId: a }); + expect(await fetchAnthropicQuota("anthropic")).toBeNull(); + await fetchProviderModels("anthropic", provider, 0); + expect(usageBearers).toEqual([]); + expect(modelBearers).toEqual([]); + expect(refreshTokens).toEqual([]); + + // Explicit selection of the existing unpaused row authorizes its own credential. + expect(await setActiveAccount("anthropic", b)).toBe(true); + expect((await fetchAnthropicQuota("anthropic"))?.quota.fiveHourPercent).toBe(25); + expect((await fetchProviderModels("anthropic", provider, 0)).map(model => model.id)).toContain("claude-fixture-legacy"); + expect(usageBearers).toEqual([`Bearer ${legacy.access}`]); + expect(modelBearers).toEqual([`Bearer ${legacy.access}`]); + expect(getAccountCredential("anthropic", b)?.source).toBeUndefined(); + expect(refreshTokens).toEqual([]); + + await saveAccountCredential("anthropic", b, { ...legacy, expires: 1 }); + clearModelCache(); + expect((await fetchProviderModels("anthropic", provider, 0)).map(model => model.id)).toContain("claude-fixture-legacy"); + expect((await fetchAnthropicQuota("anthropic"))?.quota.fiveHourPercent).toBe(25); + expect(refreshTokens).toEqual([legacy.refresh]); + expect(modelBearers).toEqual([`Bearer ${legacy.access}`, `Bearer ${refreshed.access}`]); + expect(usageBearers).toEqual([`Bearer ${legacy.access}`, `Bearer ${refreshed.access}`]); + expect(detect).not.toHaveBeenCalled(); + expect(getAccountCredential("anthropic", b)).toMatchObject({ ...refreshed, accountId: legacy.accountId, source: "oauth" }); + expect(getAccountSet("anthropic")!.accounts.find(account => account.id === a)?.paused).toBe(true); + } finally { + models.mockRestore(); refresh.mockRestore(); detect.mockRestore(); + globalThis.fetch = originalFetch; + clearModelCache(); + } + }); } test("a promoted local-CLI account cannot adopt another account after entering the refresh skew", async () => { From d42848f5c6804a168e33391cb439b94a1c09cbe9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:18:18 +0000 Subject: [PATCH 4/7] test(claude): give native-oauth fixtures credential provenance Anthropic pause fallback now skips rows without credential source, so the seeded fixture accounts were never eligible rotation targets and the paused account stayed selected (403 instead of 401/429). Matches the provenance the login path writes and the pause spec's own seed. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/claude-integration/messages-native-oauth.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/claude-integration/messages-native-oauth.test.ts b/tests/claude-integration/messages-native-oauth.test.ts index 18f03268a7..4904d69c86 100644 --- a/tests/claude-integration/messages-native-oauth.test.ts +++ b/tests/claude-integration/messages-native-oauth.test.ts @@ -79,6 +79,7 @@ function credential(index: number) { refresh: `synthetic-anthropic-refresh-${index}`, expires: Date.now() + 3_600_000, accountId: `synthetic-account-${index}`, + source: "oauth" as const, }; } From 2bb62e55b575fd3d31270f8010bedb1ca5386549 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:27:44 +0900 Subject: [PATCH 5/7] test(oauth): declare provenance for pause API fixtures --- tests/oauth/oauth-accounts-api.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/oauth/oauth-accounts-api.test.ts b/tests/oauth/oauth-accounts-api.test.ts index c1d4a564d7..38c96c310d 100644 --- a/tests/oauth/oauth-accounts-api.test.ts +++ b/tests/oauth/oauth-accounts-api.test.ts @@ -69,8 +69,8 @@ function writeAccounts(): void { anthropic: { activeAccountId: "aaaa1111", accounts: [ - { id: "aaaa1111", credential: { access: "t1", refresh: "r1", expires: 9999999999999, email: "first@example.com", accountId: "acct-1" } }, - { id: "bbbb2222", credential: { access: "t2", refresh: "r2", expires: 9999999999999, email: "second@example.com", accountId: "acct-2" } }, + { id: "aaaa1111", credential: { access: "t1", refresh: "r1", expires: 9999999999999, email: "first@example.com", accountId: "acct-1", source: "oauth" } }, + { id: "bbbb2222", credential: { access: "t2", refresh: "r2", expires: 9999999999999, email: "second@example.com", accountId: "acct-2", source: "oauth" } }, ], }, }), { mode: 0o600 }); From 219207505f5f70fcb92766a5d6784ae42226be5d Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Thu, 1 Oct 2026 18:54:23 -0700 Subject: [PATCH 6/7] docs(ko): explain Anthropic no-fallback recovery --- .../src/content/docs/ko/reference/cli/providers-accounts.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 4a167c673d..76c009e4f9 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -260,6 +260,10 @@ Codex pool selection applies to the next request after clearing existing affinit Anthropic의 자동 대체 계정 선택은 출처가 불명확한 자격 증명과 60초 이내에 만료되는 Claude Code 가져오기를 건너뜁니다. 60초 넘게 남은 유효한 Claude Code 계정은 선택할 수 있습니다. 이후 CLI 자격 증명을 자동으로 다시 가져올 때는 비어 있지 않은 access 또는 refresh 토큰 중 하나가 저장된 값과 같아야 하며, 기존 계정 ID와 이메일을 보존합니다. 두 토큰이 모두 바뀌면 자격 증명의 연속성을 확인할 수 없으므로 저장된 토큰의 갱신 또는 기존 재인증 경로를 사용합니다. 완전히 교체된 Claude Code 토큰을 가져오려면 명시적으로 로그인하세요. +허용된 대체 계정이 없으면 사용 가능한 활성 계정이 선택될 때까지 할당량 조회와 실시간 모델 검색이 대기합니다. +`ocx account use anthropic `로 일시 정지되지 않은 기존 레거시 계정을 명시적으로 선택할 수 있습니다. +원래 자격 증명의 출처가 기록되지 않았더라도 해당 계정의 유효한 자격 증명과 일반적인 저장 토큰 갱신을 계속 사용할 수 있습니다. + CLI 명령은 Anthropic OAuth 계정을 id 또는 유일한 alias로 일시 정지하거나 재개합니다. alias는 정확히 일치하는 값을 먼저 찾고, 없으면 대소문자를 구분하지 않고 찾습니다. 대시보드와 같은 `PUT /api/oauth/accounts/pause`에 `{ provider: "anthropic", accountId, paused }`를 보냅니다. 계정에 저장되는 `paused` 상태는 `GET /api/oauth/accounts`에도 표시됩니다. 사전 계정 전환 풀이 꺼져 있어도 정지된 계정은 선택, 세션 바인딩, 429 대체 후보에서 제외됩니다. 모든 계정이 정지되면 하나를 재개할 때까지 요청은 403을 반환합니다. 이미 전송한 요청은 유지하며 자격 증명과 건강 상태를 지우지 않습니다. 재시작·재로그인 후에도 정지는 유지되고, 계정을 삭제하면 함께 제거됩니다. 계정별 전환 임계값은 이 기능에 포함되지 않습니다. ### `ocx account clear [--json]` From 3db2ab44ab6b1c170dd68dc818ad2f526eaec5c2 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 2 Oct 2026 17:11:05 -0700 Subject: [PATCH 7/7] fix(oauth): restore stored-credential legacy pause fallback --- .../ko/reference/cli/providers-accounts.md | 2 +- .../docs/reference/cli/providers-accounts.md | 6 +- src/oauth/anthropic-routing.ts | 7 +- src/oauth/index.ts | 2 +- src/oauth/refresh-policy.ts | 2 + src/oauth/store.ts | 5 +- structure/providers/anthropic-account-pool.md | 13 +- .../anthropic/anthropic-account-pause.test.ts | 143 ++++++++++-------- 8 files changed, 97 insertions(+), 83 deletions(-) create mode 100644 src/oauth/refresh-policy.ts diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 76c009e4f9..6792dede9b 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -258,7 +258,7 @@ Codex pool selection applies to the next request after clearing existing affinit ### `ocx account pause|resume anthropic [--json]` -Anthropic의 자동 대체 계정 선택은 출처가 불명확한 자격 증명과 60초 이내에 만료되는 Claude Code 가져오기를 건너뜁니다. 60초 넘게 남은 유효한 Claude Code 계정은 선택할 수 있습니다. 이후 CLI 자격 증명을 자동으로 다시 가져올 때는 비어 있지 않은 access 또는 refresh 토큰 중 하나가 저장된 값과 같아야 하며, 기존 계정 ID와 이메일을 보존합니다. 두 토큰이 모두 바뀌면 자격 증명의 연속성을 확인할 수 없으므로 저장된 토큰의 갱신 또는 기존 재인증 경로를 사용합니다. 완전히 교체된 Claude Code 토큰을 가져오려면 명시적으로 로그인하세요. +Anthropic의 자동 대체 계정 선택은 기존 계정 순서를 유지하며, 일시 정지되었거나 재인증이 필요한 계정과 60초 이내에 만료되는 Claude Code 가져오기를 건너뜁니다. 출처가 기록되지 않은 레거시 계정도 선택할 수 있지만, 자체 저장 자격 증명과 일반적인 저장 토큰 갱신만 사용하며 CLI 디스크 자격 증명을 가져오지 않습니다. 60초 넘게 남은 유효한 Claude Code 계정은 선택할 수 있습니다. 이후 CLI 자격 증명을 자동으로 다시 가져올 때는 비어 있지 않은 access 또는 refresh 토큰 중 하나가 저장된 값과 같아야 하며, 기존 계정 ID와 이메일을 보존합니다. 두 토큰이 모두 바뀌면 자격 증명의 연속성을 확인할 수 없으므로 저장된 토큰의 갱신 또는 기존 재인증 경로를 사용합니다. 완전히 교체된 Claude Code 토큰을 가져오려면 명시적으로 로그인하세요. 허용된 대체 계정이 없으면 사용 가능한 활성 계정이 선택될 때까지 할당량 조회와 실시간 모델 검색이 대기합니다. `ocx account use anthropic `로 일시 정지되지 않은 기존 레거시 계정을 명시적으로 선택할 수 있습니다. diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index bedd7883b6..b9cbb4b4d6 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -486,8 +486,10 @@ Anthropic pause applies even when proactive pooling is disabled, including sessi and does not interrupt a turn already sent. Removing the account removes its pause state. Per-account Anthropic auto-switch thresholds are not part of this control. -Anthropic's automatic pause fallback skips credentials with unknown provenance and Claude Code -imports expiring within 60 seconds. A still-valid Claude Code import with more time remaining +Anthropic's automatic pause fallback keeps account order, skips paused accounts and accounts +requiring reauthentication, and excludes Claude Code imports expiring within 60 seconds. +Legacy accounts without a recorded source remain eligible using only their own stored credentials +and normal stored-token refresh; they never adopt CLI-disk credentials. A still-valid Claude Code import with more time remaining can be selected. Later automatic re-adoption requires an unchanged, nonempty access or refresh token; the stored account ID and email are preserved. If Claude Code replaces both tokens, OpenCodex cannot establish credential continuity and uses its stored-token refresh or existing diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index 3e324178f2..080afbe09c 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -16,6 +16,7 @@ * Token refresh failures retain the existing store needsReauth policy. */ import { createHash } from "node:crypto"; +import { REFRESH_SKEW_MS } from "./refresh-policy"; import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store"; import type { OAuthAccessSnapshot } from "./index"; import { getCachedProviderAccountQuota } from "../providers/quota"; @@ -337,15 +338,13 @@ function usageScore(config: OcxConfig, accountId: string): number { } } -const TOKEN_SKEW_MS = 60_000; - /** Background `local-cli` slots with expired access are not pool-eligible (identity adoption risk). */ function isPoolCredentialUsable(accountId: string, now: number): boolean { const cred = getAccountCredential(PROVIDER, accountId); if (!cred) return false; if (cred.source !== "local-cli") return true; if (canRefreshAnthropicPoolAccount(accountId)) return true; - return cred.expires > now + TOKEN_SKEW_MS; + return cred.expires > now + REFRESH_SKEW_MS; } export function getEligibleAnthropicAccounts(now = Date.now()): string[] { @@ -1056,7 +1055,7 @@ export async function getAnthropicPoolAccessToken(accountId: string): Promise Date.now() + TOKEN_SKEW_MS) return stored.access; + if (stored.expires > Date.now() + REFRESH_SKEW_MS) return stored.access; if (!canRefreshAnthropicPoolAccount(accountId)) { throw new Error("background local-cli token expired; refuse CLI-adopting refresh for pool"); } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 796d7790dc..3633ca8386 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -80,8 +80,8 @@ export { } from "./health"; export { OAUTH_REFRESH_LOCK_WAIT_MS, peekAuthStore, peekOAuthRefreshIntent } from "./store"; import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; +import { REFRESH_SKEW_MS } from "./refresh-policy"; -const REFRESH_SKEW_MS = 60_000; export interface OAuthAccessSnapshot { provider: string; accountId: string; diff --git a/src/oauth/refresh-policy.ts b/src/oauth/refresh-policy.ts new file mode 100644 index 0000000000..84044eb278 --- /dev/null +++ b/src/oauth/refresh-policy.ts @@ -0,0 +1,2 @@ +/** Refresh early enough to avoid dispatching a bearer that expires in flight. */ +export const REFRESH_SKEW_MS = 60_000; diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 8d9f640e35..3780c05595 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -34,6 +34,7 @@ import { } from "../lib/state-store-sweeper"; import { validateCopilotApiBaseUrl } from "./github-copilot"; import { validateDevinApiBaseUrl } from "./devin/api-base"; +import { REFRESH_SKEW_MS } from "./refresh-policy"; import { parseAnthropicAccountThreshold } from "./anthropic-account-threshold"; import type { OAuthAccountSelection, OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types"; @@ -1289,8 +1290,8 @@ export async function setAccountPaused( const start = set.accounts.findIndex(candidate => candidate.id === accountId); const ring = [...set.accounts.slice(start + 1), ...set.accounts.slice(0, start)]; const fallback = ring.find(candidate => candidate.paused !== true && candidate.needsReauth !== true - && !(provider === "anthropic" && (!candidate.credential.source - || (candidate.credential.source === "local-cli" && candidate.credential.expires <= Date.now() + 60_000)))); + && !(provider === "anthropic" && candidate.credential.source === "local-cli" + && candidate.credential.expires <= Date.now() + REFRESH_SKEW_MS)); if (fallback) { set.activeAccountId = fallback.id; set.selectionRevision = randomUUID(); diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index 6d8e0064bf..5306164607 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -7,18 +7,19 @@ OAuth, not a second list in provider config. `setAccountPaused` serializes pause with credential and selection writes, advances the selection revision, and only publishes invalidation after persistence. Removing an account removes its pause; reauthentication preserves it. The store moves active selection to an unpaused, non-reauth row if available. -For Anthropic, that automatic fallback skips rows with missing or invalid credential provenance -and background local-CLI rows expiring within 60 seconds, including still-valid credentials. +For Anthropic, automatic fallback preserves ring order, including source-less legacy rows, +and skips background local-CLI rows expiring within 60 seconds, including still-valid credentials. +`src/oauth/refresh-policy.ts` shares that skew between pause fallback, routing and token refresh. `src/oauth/index.ts` permits Claude Code re-adoption only when a nonempty access or refresh token matches the stored credential, preserving its account ID/email. `src/oauth/local-token-detect.ts` supplies this credential-continuity check; a shared path, active selection or matching metadata does not prove continuity. Fully replaced opaque token pairs are not adopted, even if they may belong to the same account. Stored-token refresh and explicit login remain available through the existing refresh-intent/reauth rules, so promotion cannot authorize another account's CLI pair. -The provenance restriction governs automatic pause fallback, not explicit selection of an existing -unpaused account. A selected legacy row can use its own valid bearer for quota/model discovery and -refresh its stored token normally. With no permitted fallback, active-account probes stay closed -until a usable account is selected or resumed; they do not silently select an unknown-origin row. +A legacy row selected by pause fallback or explicitly can use its own valid bearer for quota/model +discovery and refresh its stored token normally. Missing or invalid provenance normalizes to no +source, which never permits CLI-disk adoption. With no permitted fallback, active-account probes +stay closed until a usable account is selected or resumed. Pause does not clear cooldowns, quota, or credentials and does not cancel an already-sent turn. `src/oauth/anthropic-routing.ts` excludes paused rows from quota, round-robin, fill-first, diff --git a/tests/adapters/anthropic/anthropic-account-pause.test.ts b/tests/adapters/anthropic/anthropic-account-pause.test.ts index b76e2bd452..8848ed4ef6 100644 --- a/tests/adapters/anthropic/anthropic-account-pause.test.ts +++ b/tests/adapters/anthropic/anthropic-account-pause.test.ts @@ -16,7 +16,7 @@ import { } from "../../../src/oauth/anthropic-routing"; import { captureOAuthAccountSelection, createOAuthRefreshIntentLock, getAccountCredential, getAccountSet, - removeAccount, saveAccountCredential, saveCredential, setAccountPaused, setActiveAccount, + markAccountNeedsReauth, removeAccount, saveAccountCredential, saveCredential, setAccountPaused, setActiveAccount, } from "../../../src/oauth/store"; import { clearAccountQuotaCache, setCachedProviderAccountQuotaForTests } from "../../../src/providers/quota"; import { fetchAnthropicQuota } from "../../../src/providers/quota/vendor-probes-oauth"; @@ -90,83 +90,92 @@ test("pause does not promote a background local-CLI credential within the refres expect(getAccountCredential("anthropic", b)?.access).toBe(background.access); }); +for (const excluded of ["paused", "needsReauth"] as const) { + test(`legacy pause fallback skips ${excluded} rows and wraps in ring order`, async () => { + const [a, b, c] = ids as [string, string, string]; + for (const id of ids) await saveAccountCredential("anthropic", id, { ...getAccountCredential("anthropic", id)!, source: undefined }); + await setActiveAccount("anthropic", c); + if (excluded === "paused") await setAccountPaused("anthropic", a, true); + else await markAccountNeedsReauth("anthropic", a, true); + expect(await setAccountPaused("anthropic", c, true)).toMatchObject({ activeAccountChanged: true, activeAccountId: b }); + }); +} + for (const source of [undefined, "invalid-source"] as const) { - test(`pause skips Anthropic credentials with ${source ?? "missing"} provenance`, async () => { + test(`pause preserves ring order for Anthropic credentials with ${source ?? "missing"} provenance`, async () => { const [a, b, c] = ids as [string, string, string]; const background = getAccountCredential("anthropic", b)!; await saveAccountCredential("anthropic", b, { ...background, source: source as typeof background.source }); expect(getAccountCredential("anthropic", b)?.source).toBeUndefined(); expect(await setAccountPaused("anthropic", a, true)).toMatchObject({ - status: "updated", activeAccountChanged: true, activeAccountId: c, + status: "updated", activeAccountChanged: true, activeAccountId: b, }); expect(getAccountCredential("anthropic", b)?.access).toBe(background.access); }); - test(`explicitly selected ${source ?? "source-less"} legacy account keeps quota, discovery and stored refresh`, async () => { - const [a, b, c] = ids as [string, string, string]; - const legacy = { ...getAccountCredential("anthropic", b)!, source: source as "oauth" | undefined }; - await saveAccountCredential("anthropic", b, legacy); - await setAccountPaused("anthropic", c, true); - const originalFetch = globalThis.fetch; - const usageBearers: Array = []; - const modelBearers: Array = []; - const refreshTokens: string[] = []; - const detect = spyOn(localTokens, "detectClaudeCodeToken").mockImplementation(() => { - throw new Error("legacy accounts must not read external Claude credentials"); - }); - const refreshed = { access: "synthetic-legacy-refreshed", refresh: "synthetic-legacy-rotated", expires: Date.now() + 3_600_000 }; - const refresh = spyOn(OAUTH_PROVIDERS.anthropic!, "refresh").mockImplementation(async token => { - refreshTokens.push(token); - return refreshed; - }); - globalThis.fetch = (async (input, init) => { - // Every possible fetch stays inside this fixture, including unexpected URLs. - expect(String(input)).toBe("https://api.anthropic.com/api/oauth/usage"); - usageBearers.push(new Headers(init?.headers).get("authorization")); - return Response.json({ five_hour: { utilization: 25 } }); - }) as typeof fetch; - const models = spyOn(outbound, "providerOutboundGet").mockImplementation(async (_name, _provider, url, init, deps) => { - expect(url).toBe("https://api.anthropic.com/v1/models?limit=1000"); - expect(await deps?.beforeSend?.()).toBe(true); - modelBearers.push(new Headers(init?.headers).get("authorization")); - return Response.json({ data: [{ id: "claude-fixture-legacy" }] }); - }); - const provider = config().providers.anthropic!; - try { - clearModelCache(); - // Unknown provenance cannot silently acquire active-account authority on pause. - expect(await setAccountPaused("anthropic", a, true)).toMatchObject({ activeAccountChanged: false, activeAccountId: a }); - expect(await fetchAnthropicQuota("anthropic")).toBeNull(); - await fetchProviderModels("anthropic", provider, 0); - expect(usageBearers).toEqual([]); - expect(modelBearers).toEqual([]); - expect(refreshTokens).toEqual([]); - - // Explicit selection of the existing unpaused row authorizes its own credential. - expect(await setActiveAccount("anthropic", b)).toBe(true); - expect((await fetchAnthropicQuota("anthropic"))?.quota.fiveHourPercent).toBe(25); - expect((await fetchProviderModels("anthropic", provider, 0)).map(model => model.id)).toContain("claude-fixture-legacy"); - expect(usageBearers).toEqual([`Bearer ${legacy.access}`]); - expect(modelBearers).toEqual([`Bearer ${legacy.access}`]); - expect(getAccountCredential("anthropic", b)?.source).toBeUndefined(); - expect(refreshTokens).toEqual([]); + for (const selection of ["fallback", "explicit"] as const) { + test(`${selection} ${source ?? "source-less"} legacy account keeps quota, discovery and stored refresh without CLI adoption`, async () => { + const [a, b, c] = ids as [string, string, string]; + const legacy = { ...getAccountCredential("anthropic", b)!, source: source as "oauth" | undefined }; + await saveAccountCredential("anthropic", b, legacy); + await setAccountPaused("anthropic", c, true); + const originalFetch = globalThis.fetch; + const usageBearers: Array = []; + const modelBearers: Array = []; + const refreshTokens: string[] = []; + const detect = spyOn(localTokens, "detectClaudeCodeToken").mockImplementation(() => { + throw new Error("legacy accounts must not read external Claude credentials"); + }); + const refreshed = { access: "synthetic-legacy-refreshed", refresh: "synthetic-legacy-rotated", expires: Date.now() + 3_600_000 }; + const refresh = spyOn(OAUTH_PROVIDERS.anthropic!, "refresh").mockImplementation(async token => { + refreshTokens.push(token); + return refreshed; + }); + globalThis.fetch = (async (input, init) => { + // Every possible fetch stays inside this fixture, including unexpected URLs. + expect(String(input)).toBe("https://api.anthropic.com/api/oauth/usage"); + usageBearers.push(new Headers(init?.headers).get("authorization")); + return Response.json({ five_hour: { utilization: 25 } }); + }) as typeof fetch; + const models = spyOn(outbound, "providerOutboundGet").mockImplementation(async (_name, _provider, url, init, deps) => { + expect(url).toBe("https://api.anthropic.com/v1/models?limit=1000"); + expect(await deps?.beforeSend?.()).toBe(true); + modelBearers.push(new Headers(init?.headers).get("authorization")); + return Response.json({ data: [{ id: "claude-fixture-legacy" }] }); + }); + const provider = config().providers.anthropic!; + try { + clearModelCache(); + if (selection === "fallback") { + expect(await setAccountPaused("anthropic", a, true)).toMatchObject({ activeAccountChanged: true, activeAccountId: b }); + } else { + expect(await setActiveAccount("anthropic", b)).toBe(true); + await setAccountPaused("anthropic", a, true); + } + expect((await fetchAnthropicQuota("anthropic"))?.quota.fiveHourPercent).toBe(25); + expect((await fetchProviderModels("anthropic", provider, 0)).map(model => model.id)).toContain("claude-fixture-legacy"); + expect(usageBearers).toEqual([`Bearer ${legacy.access}`]); + expect(modelBearers).toEqual([`Bearer ${legacy.access}`]); + expect(getAccountCredential("anthropic", b)?.source).toBeUndefined(); + expect(refreshTokens).toEqual([]); - await saveAccountCredential("anthropic", b, { ...legacy, expires: 1 }); - clearModelCache(); - expect((await fetchProviderModels("anthropic", provider, 0)).map(model => model.id)).toContain("claude-fixture-legacy"); - expect((await fetchAnthropicQuota("anthropic"))?.quota.fiveHourPercent).toBe(25); - expect(refreshTokens).toEqual([legacy.refresh]); - expect(modelBearers).toEqual([`Bearer ${legacy.access}`, `Bearer ${refreshed.access}`]); - expect(usageBearers).toEqual([`Bearer ${legacy.access}`, `Bearer ${refreshed.access}`]); - expect(detect).not.toHaveBeenCalled(); - expect(getAccountCredential("anthropic", b)).toMatchObject({ ...refreshed, accountId: legacy.accountId, source: "oauth" }); - expect(getAccountSet("anthropic")!.accounts.find(account => account.id === a)?.paused).toBe(true); - } finally { - models.mockRestore(); refresh.mockRestore(); detect.mockRestore(); - globalThis.fetch = originalFetch; - clearModelCache(); - } - }); + await saveAccountCredential("anthropic", b, { ...legacy, expires: 1 }); + clearModelCache(); + expect((await fetchProviderModels("anthropic", provider, 0)).map(model => model.id)).toContain("claude-fixture-legacy"); + expect((await fetchAnthropicQuota("anthropic"))?.quota.fiveHourPercent).toBe(25); + expect(refreshTokens).toEqual([legacy.refresh]); + expect(modelBearers).toEqual([`Bearer ${legacy.access}`, `Bearer ${refreshed.access}`]); + expect(usageBearers).toEqual([`Bearer ${legacy.access}`, `Bearer ${refreshed.access}`]); + expect(detect).not.toHaveBeenCalled(); + expect(getAccountCredential("anthropic", b)).toMatchObject({ ...refreshed, accountId: legacy.accountId, source: "oauth" }); + expect(getAccountSet("anthropic")!.accounts.find(account => account.id === a)?.paused).toBe(true); + } finally { + models.mockRestore(); refresh.mockRestore(); detect.mockRestore(); + globalThis.fetch = originalFetch; + clearModelCache(); + } + }); + } } test("a promoted local-CLI account cannot adopt another account after entering the refresh skew", async () => {