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 6a0a272b8d1..9fd3cd1a649 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 @@ -271,6 +271,14 @@ Codex pool selection applies to the next request after clearing existing affinit ### `ocx account pause|resume anthropic [--json]` +Anthropic의 자동 대체 계정 선택은 기존 계정 순서를 유지하며, 일시 정지되었거나 재인증이 필요한 계정과 60초 이내에 만료되는 Claude Code 가져오기를 건너뜁니다. 출처가 기록되지 않은 레거시 계정도 선택할 수 있지만, 자체 저장 자격 증명과 일반적인 저장 토큰 갱신만 사용하며 CLI 디스크 자격 증명을 가져오지 않습니다. 60초 넘게 남은 유효한 Claude Code 계정은 선택할 수 있습니다. 이후 자동 가져오기는 비어 있지 않은 access 또는 refresh 토큰이 일치하면 허용됩니다. 두 토큰이 모두 바뀌면 저장된 bearer와 새 bearer에서 인증된 계정 UUID가 같아야 합니다. 로그인·갱신·프로필 조회에서 얻은 증명은 비공개로 저장하며, 계정 라벨·이메일·조직·파일 경로로 대체하지 않습니다. + +기존 bearer가 만료되었고 해당 토큰에 묶인 계정 증명이 없으면 자동 복구가 불가능할 수 있습니다. 프로필을 조회할 수 없거나 교체된 토큰의 계정을 확인하지 못하면 저장된 계정을 유지하고, 이미 소비되었을 수 있는 refresh 토큰을 다시 사용하지 않습니다. 현재 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]` 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 a491b1c350f..a5453b3f12c 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -503,6 +503,27 @@ 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 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 accepts a shared, nonempty access or refresh token. +If both tokens rotate, OpenCodex requires authenticated account UUID proof for both the stored +and imported bearer. It saves that proof privately when available from login, refresh, or profile +lookup; account labels, email, organization and credential-file location cannot substitute for it. + +When the old bearer has expired and no bound account proof was saved, automatic recovery may be +impossible. An unavailable profile or unverified rotated pair leaves the stored account unchanged +and does not replay a potentially consumed refresh token. Use explicit login to import the current +Claude Code credential. Import preserves unrelated identityless slots and may create a separate +account; select the intended account and remove obsolete slots only after checking them. A profile +lookup failure can leave a new import identityless, with the same automatic-recovery limitation. + +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/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 6a7d26dec4e..65b171af7b1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1215,6 +1215,7 @@ "oauth-account-id-collision.test.ts": "oauth", "oauth-account-quota-rank.test.ts": "oauth", "oauth-accounts-api.test.ts": "oauth", + "oauth-anthropic-identity.test.ts": "oauth", "oauth-callback-binds.test.ts": "oauth", "oauth-callback-server.test.ts": "oauth", "oauth-device-code-contract.test.ts": "oauth", diff --git a/src/oauth/anthropic-continuity.ts b/src/oauth/anthropic-continuity.ts new file mode 100644 index 00000000000..81e202a4249 --- /dev/null +++ b/src/oauth/anthropic-continuity.ts @@ -0,0 +1,65 @@ +import { detectClaudeCodeToken, hasClaudeCredentialContinuity } from "./local-token-detect"; +import { normalizeAnthropicIdentity, resolveAnthropicAccountIdentity, type AnthropicIdentityResolver } from "./anthropic-identity"; +import { credentialGeneration, type AuthStore } from "./store"; +import { REFRESH_SKEW_MS } from "./refresh-policy"; +import type { OAuthCredentials, ProviderAccountSet } from "./types"; + +export type ClaudeCredentialObservation = + | { kind: "absent" } + | { kind: "unresolved" | "different"; diskGeneration: string } + | { kind: "adopt"; credential: OAuthCredentials; diskGeneration: string }; + +export async function newerClaudeCredential( + stored: OAuthCredentials, now: number, signal?: AbortSignal, + resolveIdentity: AnthropicIdentityResolver = resolveAnthropicAccountIdentity, +): Promise { + if (stored.source !== "local-cli") return { kind: "absent" }; + const disk = detectClaudeCodeToken(); + if (!disk || !Number.isFinite(disk.expires) || disk.expires <= now + REFRESH_SKEW_MS + || credentialGeneration(disk) === credentialGeneration(stored)) return { kind: "absent" }; + const diskGeneration = credentialGeneration(disk); + const before = normalizeAnthropicIdentity(stored.anthropicIdentity, stored.access); + if (hasClaudeCredentialContinuity(stored, disk)) { + return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: { + ...stored, ...disk, anthropicIdentity: disk.access === stored.access ? before : undefined, + } }; + } + // Independent observations share a deadline through the caller signal; neither extends the other. + const [oldProof, newProof] = await Promise.all([ + before ?? resolveIdentity(stored.access, signal), resolveIdentity(disk.access, signal), + ]); + const oldIdentity = normalizeAnthropicIdentity(oldProof, stored.access); + const newIdentity = normalizeAnthropicIdentity(newProof, disk.access); + if (!oldIdentity || !newIdentity) return { kind: "unresolved", diskGeneration }; + if (oldIdentity.accountUuid !== newIdentity.accountUuid) return { kind: "different", diskGeneration }; + return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: { + ...stored, ...disk, accountId: newIdentity.accountUuid, anthropicIdentity: newIdentity, + } }; +} + +/** Capture primitives before network awaits; check them again inside the serialized writer. */ +export function captureAnthropicCredentialOwner(set: ProviderAccountSet, accountId: string) { + const row = set.accounts.find(account => account.id === accountId)!; + const loginId = row.loginId; + const generation = credentialGeneration(row.credential); + const metadata = identityMetadata(row.credential); + const needsReauth = row.needsReauth; + const selection = { id: set.activeAccountId, revision: set.selectionRevision }; + return (store: AuthStore, diskGeneration?: string, allowPaused = false): boolean => { + const current = store["anthropic"]; + const account = current?.accounts.find(candidate => candidate.id === accountId); + if (!account || (!allowPaused && account.paused) || account.loginId !== loginId + || account.needsReauth !== needsReauth || credentialGeneration(account.credential) !== generation + || identityMetadata(account.credential) !== metadata) return false; + if (diskGeneration !== undefined) { + if (current?.activeAccountId !== selection.id || current?.selectionRevision !== selection.revision) return false; + const disk = detectClaudeCodeToken(); + if (!disk || disk.expires <= Date.now() + REFRESH_SKEW_MS || credentialGeneration(disk) !== diskGeneration) return false; + } + return true; + }; +} + +function identityMetadata(credential: OAuthCredentials): string { + return JSON.stringify([credential.source, credential.accountId, credential.email, credential.anthropicIdentity]); +} diff --git a/src/oauth/anthropic-identity.ts b/src/oauth/anthropic-identity.ts new file mode 100644 index 00000000000..02fe9d75b7c --- /dev/null +++ b/src/oauth/anthropic-identity.ts @@ -0,0 +1,75 @@ +/** Private account proof from an authenticated Anthropic response, bound to its exact bearer. */ +import { hasClaudeCredentialContinuity } from "./local-token-detect"; +import { createHash } from "node:crypto"; +import { readBoundedResponseBody } from "../lib/bounded-body"; +import { CLAUDE_CLI_USER_AGENT } from "../providers/claude-cli-identity"; +import type { OAuthCredentials } from "./types"; + +const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile"; +const PROFILE_TIMEOUT_MS = 10_000; +const PROFILE_MAX_BYTES = 65_536; +export type AnthropicIdentity = NonNullable; +export type AnthropicIdentityResolver = (access: string, signal?: AbortSignal) => Promise; + +export function bindAnthropicIdentity(access: string, accountUuid: unknown): AnthropicIdentity | undefined { + if (!access || typeof accountUuid !== "string" || !accountUuid.length || accountUuid.length > 128 + || accountUuid !== accountUuid.trim() || /[\x00-\x1f\x7f]/.test(accountUuid)) return undefined; + return { v: 1, accountUuid, bearerSha256: createHash("sha256").update(access).digest("hex") }; +} + +export function normalizeAnthropicIdentity(value: unknown, access: string): AnthropicIdentity | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const proof = value as Partial; + const bound = bindAnthropicIdentity(access, proof.accountUuid); + return proof.v === 1 && bound && proof.bearerSha256 === bound.bearerSha256 ? bound : undefined; +} + +/** Fixed destination, no redirects, bounded body and deadline; failures never include response data. */ +export const resolveAnthropicAccountIdentity: AnthropicIdentityResolver = async (access, signal) => { + if (!access || signal?.aborted) return undefined; + const deadline = AbortSignal.timeout(PROFILE_TIMEOUT_MS); + const combined = signal ? AbortSignal.any([signal, deadline]) : deadline; + try { + const response = await fetch(PROFILE_URL, { + method: "GET", redirect: "error", signal: combined, + headers: { Authorization: `Bearer ${access}`, Accept: "application/json", + "anthropic-beta": "oauth-2025-04-20", + "Cache-Control": "no-cache", "User-Agent": CLAUDE_CLI_USER_AGENT }, + }); + if (!response.ok || response.redirected) { + void response.body?.cancel().catch(() => {}); + return undefined; + } + const body = await readBoundedResponseBody(response, { + signal: combined, maxBytes: PROFILE_MAX_BYTES, fatalUtf8: true, + totalTimeoutMs: PROFILE_TIMEOUT_MS, inactivityTimeoutMs: PROFILE_TIMEOUT_MS, + }); + if (body.truncated || body.oversized || body.timedOut || combined.aborted) return undefined; + const data: unknown = JSON.parse(body.text); + if (!data || typeof data !== "object" || !("account" in data)) return undefined; + const account = data.account; + return account && typeof account === "object" && "uuid" in account + ? bindAnthropicIdentity(access, account.uuid) : undefined; + } catch { + // Unavailable identity is an explicit unresolved result, never an ownership guess. + return undefined; + } +}; + +/** A changed access bearer cannot inherit the previous bearer's proof. */ +export function mergeAnthropicIdentity(fresh: OAuthCredentials, previous: OAuthCredentials): Pick { + const before = normalizeAnthropicIdentity(previous.anthropicIdentity, previous.access); + const after = normalizeAnthropicIdentity(fresh.anthropicIdentity, fresh.access); + if (before && after && before.accountUuid !== after.accountUuid) { + throw new Error("Anthropic credential identity changed; explicit account login required"); + } + return { anthropicIdentity: after ?? (fresh.access === previous.access ? before : undefined) }; +} + +/** Explicit imports enrich only a proven slot; labels and paths never establish ownership. */ +export function matchesAnthropicImport(stored: OAuthCredentials, imported: OAuthCredentials): boolean { + const before = normalizeAnthropicIdentity(stored.anthropicIdentity, stored.access); + const after = normalizeAnthropicIdentity(imported.anthropicIdentity, imported.access); + if (before && after) return before.accountUuid === after.accountUuid; + return hasClaudeCredentialContinuity(stored, imported); +} diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index 6daa0eadca0..0401bee2f98 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -18,6 +18,7 @@ import { anthropicFamilyRejected, anthropicFamilyRetryAt, anthropicModelExhauste * Token refresh failures retain the existing store needsReauth policy. */ import { anthropicRatePauseUntil, clearAnthropicRatePauses, classifyAnthropic429, anthropicRetryAfterMs, pauseAnthropicRateAdmission } from "./anthropic-rate-limit-policy"; +import { REFRESH_SKEW_MS } from "./refresh-policy"; import { createHash } from "node:crypto"; import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store"; import type { OAuthAccessSnapshot } from "./index"; @@ -353,7 +354,6 @@ function usageScore(config: OcxConfig, accountId: string, model?: string): numbe } } -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 { @@ -361,7 +361,7 @@ function isPoolCredentialUsable(accountId: string, now: number): boolean { 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(), model?: string): string[] { @@ -1093,7 +1093,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/anthropic.ts b/src/oauth/anthropic.ts index d4e0acdc037..8c4c2a61305 100644 --- a/src/oauth/anthropic.ts +++ b/src/oauth/anthropic.ts @@ -1,5 +1,6 @@ /** Anthropic OAuth flow (Claude Pro/Max). Ported from jawcode oauth/anthropic.ts. */ import { OAuthCallbackFlow } from "./callback-server"; +import { bindAnthropicIdentity, resolveAnthropicAccountIdentity } from "./anthropic-identity"; import { generatePKCE } from "./pkce"; import type { LocalTokenImportMode, OAuthController, OAuthCredentials } from "./types"; @@ -96,6 +97,7 @@ function credsFrom(data: AnthropicTokenResponse, refreshFallback?: string): OAut refresh: data.refresh_token || refreshFallback || "", access: data.access_token, expires, + anthropicIdentity: bindAnthropicIdentity(data.access_token, accountUuid), accountId: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined, email: typeof email === "string" && email.length > 0 ? email : undefined, }; @@ -159,7 +161,11 @@ export async function loginAnthropic( const local = detectClaudeCodeToken(); if (local) { ctrl.onProgress?.("Found Claude Code token, importing automatically"); - if (local.expires >= Date.now() + 60_000) return local; + if (local.expires >= Date.now() + 60_000) { + const identity = await resolveAnthropicAccountIdentity(local.access, ctrl.signal); + ctrl.signal?.throwIfAborted(); + return { ...local, ...(identity ? { accountId: identity.accountUuid, anthropicIdentity: identity } : {}) }; + } try { return { ...(await refreshAnthropicToken(local.refresh)), source: "local-cli" }; } catch (error) { diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 644cd6dd309..45e5113de46 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -52,7 +52,7 @@ import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys"; import { effectiveGoogleMode, getProviderRegistryEntry, mergeRegistryStaticHeaders, providerMatchesRegistryTransport } from "../providers/registry"; import { providerModelsUrl, resolveProviderModelDiscoveryUrl } from "../providers/model-discovery"; import { resolveProviderTransport } from "../providers/xai-transport"; -import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; +import { detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; import { logOAuthEvent } from "./log"; import { captureConfigGeneration, sweepExpiredOnWrite } from "../lib/state-store-sweeper"; import { clearManualCodeSlot, ensureManualCodeSlot, kiroLoginSettling, loginAbort, loginState, waitForManualLoginCode, type OAuthLoginHint } from "./login-flow-state"; @@ -82,7 +82,9 @@ export { export { OAUTH_REFRESH_LOCK_WAIT_MS, peekAuthStore, peekOAuthRefreshIntent } from "./store"; import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; -const REFRESH_SKEW_MS = 60_000; +import { REFRESH_SKEW_MS } from "./refresh-policy"; +import { newerClaudeCredential, captureAnthropicCredentialOwner } from "./anthropic-continuity"; +import { mergeAnthropicIdentity, type AnthropicIdentityResolver } from "./anthropic-identity"; export interface OAuthAccessSnapshot { provider: string; accountId: string; @@ -169,7 +171,7 @@ export function seedOAuthTokenRefreshFlightsForTests(rows: Array<{ key: string; const XAI_PERMANENT_FAILURE_TTL_MS=30_000; const permanentRefreshFailures=new Map(); interface XaiRefreshDeps { intentLock?:ReturnType; now?:()=>number; afterPrePersistRead?:()=>void|Promise; signal?: AbortSignal } -interface AnthropicRefreshDeps { intentLock?:ReturnType; now?:()=>number; afterPrePersistRead?:()=>void|Promise; signal?: AbortSignal; flight?: OAuthRefreshFlightEvidence; replacedStaleFlight?: OAuthRefreshFlightEvidence } +interface AnthropicRefreshDeps { resolveIdentity?: AnthropicIdentityResolver; intentLock?:ReturnType; now?:()=>number; afterPrePersistRead?:()=>void|Promise; signal?: AbortSignal; flight?: OAuthRefreshFlightEvidence; replacedStaleFlight?: OAuthRefreshFlightEvidence } interface GenericRefreshDeps { intentLock?:ReturnType; afterPrePersistRead?:()=>void|Promise; signal?: AbortSignal } function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${credentialGeneration(c)}`;} function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=verdictKey(p,a,c),u=permanentRefreshFailures.get(k);if(u===undefined)return false;if(u<=now()){permanentRefreshFailures.delete(k);return false;}return true;} @@ -866,6 +868,7 @@ function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OA function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCredentials { return { ...fresh, + ...mergeAnthropicIdentity(fresh, previous), // Shared: a refresh function returns "local-cli" only when the credential it hands back // still is the local CLI's (Devin re-reading the CLI file, Meta Muse echoing its durable // CLI key). Relabelling that "oauth" would stop the next forced refresh from re-reading it. @@ -880,12 +883,6 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede } export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise{const writerGeneration=captureConfigGeneration();const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const row=getAccountCredentialWithStatus(provider,accountId);if(!row)throw new OAuthLoginRequiredError(provider);if(row.paused)throw new OAuthAccountPausedError();const stored=row.credential;const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh,deps.signal),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(error instanceof OAuthMutationBusyError){permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));throw error;}if(!terminal(error))throw error;const failedAt=now();permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),failedAt+XAI_PERMANENT_FAILURE_TTL_MS);sweepExpiredOnWrite(failedAt);await markAccountNeedsReauthIfGeneration(provider,accountId,generation,writerGeneration);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}} -function newerClaudeCredential(stored: OAuthCredentials, now: number): OAuthCredentials | undefined { - if (stored.source !== "local-cli") return undefined; - const disk = detectClaudeCodeToken(); - if (!disk || disk.expires <= now + REFRESH_SKEW_MS) return undefined; - return credentialGeneration(disk) !== credentialGeneration(stored) ? disk : undefined; -} /** * Preserve an already-rotated Nous refresh token (RT-B) after a terminal refresh @@ -939,33 +936,42 @@ export async function refreshAnthropicAccountWithLock( const now = deps.now ?? Date.now; const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire(); try { - const row = getAccountCredentialWithStatus(provider, accountId); - if (!row) throw new OAuthLoginRequiredError(provider); - if (row.paused) throw new OAuthAccountPausedError(); - const stored = row.credential; - const account = getAccountSet(provider)?.accounts.find(candidate => candidate.id === accountId); + const accountSet = getAccountSet(provider); + const account = accountSet?.accounts.find(candidate => candidate.id === accountId); + if (!accountSet || !account) throw new OAuthLoginRequiredError(provider); + if (account.paused) throw new OAuthAccountPausedError(); + const stored = account.credential; + const owns = captureAnthropicCredentialOwner(accountSet, accountId); const generation = credentialGeneration(stored); let pendingIntent = readOAuthRefreshIntent(provider, accountId); - const disk = newerClaudeCredential(stored, now()); - if (disk) { - const outcome = await mergeAccountCredential(provider, accountId, disk, { - expectedGeneration: credentialGeneration(stored), + const observed = await newerClaudeCredential(stored, now(), deps.signal, deps.resolveIdentity); + const assertOwner = (store: AuthStore, checkDisk = true) => { + if (observed.kind !== "absent" && deps.signal?.aborted) throw new OAuthTokenRefreshStaleError(); + if (!owns(store, checkDisk && observed.kind !== "absent" ? observed.diskGeneration : undefined)) { + if (store[provider]?.accounts.find(a => a.id === accountId)?.paused) throw new OAuthAccountPausedError(); + throw new OAuthTokenRefreshStaleError(); + } + }; + assertOwner({ [provider]: getAccountSet(provider)! }, observed.kind !== "adopt"); + if (observed.kind === "adopt") { + const outcome = await mergeAccountCredential(provider, accountId, observed.credential, { + expectedGeneration: generation, afterPrePersistRead: deps.afterPrePersistRead, + assertOwnership: assertOwner, }); - if (outcome.superseded) { - // The disk credential is already durable here, so cleanup is secondary: an unlink - // failure must not mask a committed credential by throwing over the return below. - if (pendingIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, pendingIntent); - if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access; - throw new OAuthLoginRequiredError(provider); - } + // A superseding write did not adopt our candidate. Its pending intent remains owned by it. + if (outcome.superseded) throw new OAuthTokenRefreshStaleError(); if (pendingIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, pendingIntent); - return disk.access; + return observed.credential.access; } + // Unknown rotation may have consumed this refresh token. Refuse without replay or changing health. + if (observed.kind === "unresolved") throw new OAuthLoginRequiredError(provider); if (pendingIntent?.cleanupPending && pendingIntent.generation === generation) { resumeAnthropicRefreshIntentCleanup(provider, accountId, pendingIntent); pendingIntent = undefined; } + if (observed.kind === "different" + && (pendingIntent?.uncertain || pendingIntent?.generation === generation)) throw new OAuthLoginRequiredError(provider); if (!pendingIntent?.uncertain && pendingIntent?.generation === generation) { if (pendingIntent.staleOwner) throw new OAuthTokenRefreshStaleError(); if (deps.replacedStaleFlight && pendingIntent.flightId === deps.replacedStaleFlight.flightId) { @@ -1007,6 +1013,7 @@ export async function refreshAnthropicAccountWithLock( const outcome = await mergeAccountCredential(provider, accountId, fresh, { expectedGeneration: generation, afterPrePersistRead: deps.afterPrePersistRead, + assertOwnership: store => { if (!owns(store, undefined, true)) throw new OAuthTokenRefreshStaleError(); }, }); if (outcome.superseded) { if (attemptIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, attemptIntent); @@ -1717,7 +1724,7 @@ export async function runLogin( }); } else { const saveOptions = { - preserveIdentityless: opts?.forceLogin === true, + preserveIdentityless: opts?.forceLogin === true || (provider === "anthropic" && cred.source === "local-cli"), assertBeforePersist: deps.assertCurrentOwner, }; if (shouldRollbackKiroAccounts && !deps.saveCredential) { diff --git a/src/oauth/local-token-detect.ts b/src/oauth/local-token-detect.ts index aca20b82164..01ae71ab0a9 100644 --- a/src/oauth/local-token-detect.ts +++ b/src/oauth/local-token-detect.ts @@ -128,3 +128,10 @@ export function detectClaudeCodeToken(): OAuthCredentials | null { if (!raw) return null; return parseClaudeOauthPayload(raw); } + +/** Shared-token continuity for opaque Claude tokens; fully rotated pairs need authenticated account proof. */ +export function hasClaudeCredentialContinuity(stored: OAuthCredentials, disk: OAuthCredentials): boolean { + const sameToken = (left: string, right: string): boolean => + typeof left === "string" && left.trim().length > 0 && left === right; + return sameToken(stored.refresh, disk.refresh) || sameToken(stored.access, disk.access); +} diff --git a/src/oauth/refresh-policy.ts b/src/oauth/refresh-policy.ts new file mode 100644 index 00000000000..84044eb2786 --- /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 77e005653f7..86d7c10242e 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -19,6 +19,7 @@ * distinct one. Kimi extracts JWT `user_id`/`sub` as accountId; Cursor extracts JWT `sub` — * both append distinct identified accounts under multiauth. */ +import { normalizeAnthropicIdentity, matchesAnthropicImport, mergeAnthropicIdentity } from "./anthropic-identity"; import { createHash, randomUUID } from "node:crypto"; import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -34,6 +35,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"; @@ -531,6 +533,8 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null { }; if (typeof candidate.email === "string" && candidate.email.length > 0) normalized.email = candidate.email; if (typeof candidate.accountId === "string" && candidate.accountId.length > 0) normalized.accountId = candidate.accountId; + const identity = normalizeAnthropicIdentity(candidate.anthropicIdentity, candidate.access); + if (identity) normalized.anthropicIdentity = identity; if (isCredentialSource(candidate.source)) normalized.source = candidate.source; if (typeof candidate.projectId === "string" && candidate.projectId.length > 0) normalized.projectId = candidate.projectId; if (typeof candidate.apiBaseUrl === "string" && candidate.apiBaseUrl.length > 0) { @@ -868,6 +872,7 @@ export async function saveCredentialWithReceipt( const receipt = await mutateStore(store => { const set = store[provider]; const previousActiveAccountId = set?.activeAccountId; + const previousSelectionRevision = set?.selectionRevision; const previousAccounts = new Map( set?.accounts.map(account => [account.id, structuredClone(account)]) ?? [], ); @@ -879,6 +884,21 @@ export async function saveCredentialWithReceipt( const id = newAccountId(safe); store[provider] = { activeAccountId: id, accounts: [{ id, credential: safe, addedAt: Date.now() }] }; accountId = id; + } else if (provider === "anthropic" && safe.source === "local-cli") { + const existing = set.accounts.find(a => matchesAnthropicImport(a.credential, safe)); + if (existing) { + existing.credential = { ...safe, ...mergeAnthropicIdentity(safe, existing.credential), + accountId: safe.accountId ?? existing.credential.accountId, email: safe.email ?? existing.credential.email }; + set.selectionRevision = previousSelectionRevision; + delete existing.needsReauth; + delete existing.needsReauthReason; + accountId = existing.id; + } else { + const id = distinctAccountId(safe, set.accounts); + set.accounts.push({ id, credential: safe, addedAt: Date.now() }); + set.activeAccountId = id; + accountId = id; + } } else if (identity) { const existing = set.accounts.find(a => (a.credential.accountId ?? a.credential.email) === identity); if (existing) { @@ -1308,7 +1328,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() + REFRESH_SKEW_MS)); if (fallback) { set.activeAccountId = fallback.id; set.selectionRevision = randomUUID(); diff --git a/src/oauth/types.ts b/src/oauth/types.ts index 2be5ef5bb07..67d4cf8e79d 100644 --- a/src/oauth/types.ts +++ b/src/oauth/types.ts @@ -62,6 +62,8 @@ export type OAuthCredentials = { email?: string; accountId?: string; source?: OAuthCredentialSource; + /** Private authenticated account proof; never projected into account summaries. */ + anthropicIdentity?: { v: 1; accountUuid: string; bearerSha256: string }; /** Google Antigravity (Cloud Code Assist) discovered project id; injected into the CCA envelope. */ projectId?: string; /** diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index 28370fe7522..e7cc6b947ee 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -7,6 +7,14 @@ 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, 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. +Claude Code credential adoption follows the [bearer identity contract](#claude-credential-identity). +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, @@ -27,9 +35,42 @@ pause/prior-429 recovery uses `only-eligible`, and logs name the committed accou > Decision record: [ADR-6013](../decisions/ADR-6013-anthropic-account-pause.md) Regression coverage: `tests/adapters/anthropic/anthropic-account-pause.test.ts`, +`tests/oauth/local-token-detect.test.ts`, `tests/oauth/oauth-refresh.test.ts`, `tests/adapters/anthropic/anthropic-model-routes.test.ts`, `tests/oauth/oauth-accounts-api.test.ts`, `tests/cli/cli-account-pool-verbs.test.ts`, and `gui/tests/provider-quota-refresh-controls.test.tsx`. +## Claude credential identity + +`src/oauth/anthropic-identity.ts` observes only authenticated `account.uuid` from the exact +bearer's fixed-origin profile response or the token exchange in `src/oauth/anthropic.ts`. +Its private versioned proof binds the UUID to SHA-256 of the access bearer. Store normalization +in `src/oauth/store.ts` drops malformed or stale proofs; account summaries omit the entire field. +Profile observations reject redirects and use a ten-second deadline and 64 KiB body limit. +Organization, email, generic account ID, disk location and active selection do not establish proof. + +`src/oauth/anthropic-continuity.ts` permits usable changed local-CLI generations with a shared +nonempty token. A fully rotated pair instead requires matching authenticated account UUIDs. +The old bearer may use its stored bound proof or a fresh observation; independent old/new +observations run in parallel. Shared-refresh adoption cannot copy proof to a new access bearer. +A provider refresh also drops proof unless its new bearer carries fresh authenticated evidence; +conflicting authenticated UUIDs refuse persistence. Generic display metadata is not promoted to proof. + +The refresh owner captures login ID, token generation and identity metadata before observation. +It rechecks them, pause/removal, CLI generation and selection revision after observation and inside +serialized persistence. Superseding writes win. An unresolved full rotation leaves the row and +pending intent intact, without replaying a possibly consumed refresh or setting reauthentication +solely from the identity failure. A proven different account may use its own stored refresh only +when no pending intent blocks it. Intent cleanup follows successful durable adoption. +An already-expired identityless row whose old bearer no longer authenticates cannot establish +continuity to a fully rotated pair automatically; explicit import can create a separate slot. + +Explicit local import observes the bearer when usable and enriches only a shared-token or +verified-UUID slot, retaining that slot's ID and selection. It preserves unrelated identityless +slots. If profile evidence is unavailable, import remains identityless with the same automatic +recovery limitation. These rules do not authenticate the local host owner who can edit the store. + +Regression coverage: `tests/oauth/oauth-anthropic-identity.test.ts`, `tests/oauth/oauth-refresh.test.ts`. + ## Model routes For Anthropic OAuth, `src/oauth/anthropic-routing.ts` applies the first matching `anthropicAccountPool.routes` rule to every eligible pick. The declared account order is stable while its candidates remain eligible; active, manual, affinity, quota and strategy preferences only choose inside that set. A healthy session affinity outside a model route is ignored for that request and retained for later unrouted or differently routed models; the routed commit does not overwrite it. An explicit fallback widens an empty route to the ordinary pool, and fill-first then advances in ordinary pool order from the active account. A missing eligible route fails locally without that fallback. The rules are operator allowlists, not provider entitlement evidence. Request logs use `route:#` for the 1-based rule position, not the operator name. diff --git a/tests/adapters/anthropic/anthropic-account-pause.test.ts b/tests/adapters/anthropic/anthropic-account-pause.test.ts index c7d4239f392..16a25520d00 100644 --- a/tests/adapters/anthropic/anthropic-account-pause.test.ts +++ b/tests/adapters/anthropic/anthropic-account-pause.test.ts @@ -1,11 +1,16 @@ import { rotateAnthropicAccountOn429 } from "../../helpers/anthropic-shared-quota"; import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { createHash } from "node:crypto"; 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"; import { bindAnthropicSessionAffinity, clearAnthropicAccountPoolState, getAnthropicAccountHealthSnapshot, getAnthropicPoolAccessSnapshot, getEligibleAnthropicAccounts, hasAnthropicFailoverQuorum, @@ -13,27 +18,31 @@ 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"; import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConfig } from "../../../src/types"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; const previousHome = process.env.OPENCODEX_HOME; +const originalFetchForSuite = globalThis.fetch; let home: string; let ids: string[]; beforeEach(async () => { + globalThis.fetch = (async () => { throw new Error("Unexpected network request in account-pause test"); }) as typeof fetch; home = mkdtempSync(join(tmpdir(), "ocx-anthropic-pause-")); process.env.OPENCODEX_HOME = home; clearAnthropicAccountPoolState(); clearPoolRotationState(); clearAccountQuotaCache(); for (let i = 0; i < 3; i++) await saveCredential("anthropic", { access: `synthetic-access-${i}`, refresh: `synthetic-refresh-${i}`, - expires: Date.now() + 3_600_000, accountId: `pause-${i}`, + expires: Date.now() + 3_600_000, accountId: `pause-${i}`, source: "oauth", }); ids = getAccountSet("anthropic")!.accounts.map(account => account.id); await setActiveAccount("anthropic", ids[0]!); }); afterEach(() => { + globalThis.fetch = originalFetchForSuite; clearAnthropicAccountPoolState(); clearPoolRotationState(); clearAccountQuotaCache(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; @@ -76,6 +85,139 @@ test("pause invalidates quorum immediately; disabled-pool reactive failover skip expect(hasAnthropicFailoverQuorum()).toBe(true); }); +test("pause does not promote a background local-CLI credential within the refresh skew", 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); +}); + +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 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: b, + }); + expect(getAccountCredential("anthropic", b)?.access).toBe(background.access); + }); + + 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(); + } + }); + } +} + +test("a promoted local-CLI account cannot adopt another account after entering the refresh skew", async () => { + const [a, b] = ids as [string, string, string]; + const now = Date.now(); + const background = { ...getAccountCredential("anthropic", b)!, email: "synthetic-b@example.test", source: "local-cli" as const, expires: now + 120_000 }; + await saveAccountCredential("anthropic", b, background); + expect(await setAccountPaused("anthropic", a, true)).toMatchObject({ activeAccountId: b, activeAccountChanged: true }); + const pausedCredential = getAccountCredential("anthropic", a)!; + const detect = spyOn(localTokens, "detectClaudeCodeToken").mockReturnValue({ ...pausedCredential, source: "local-cli" }); + const refreshed = { access: "synthetic-b-refreshed", refresh: "synthetic-b-refresh", expires: now + 3_600_000 }; + const sent: string[] = []; + try { + expect(await refreshAnthropicAccountWithLock("anthropic", b, { + ...OAUTH_PROVIDERS.anthropic!, refresh: async token => { sent.push(token); return refreshed; }, + }, background, { now: () => now + 60_001, resolveIdentity: async access => ({ + v: 1, accountUuid: access === background.access ? "synthetic-account-b" : "synthetic-account-a", + bearerSha256: createHash("sha256").update(access).digest("hex"), + }) })).toBe(refreshed.access); + expect(sent).toEqual([background.refresh]); + expect(getAccountCredential("anthropic", b)).toMatchObject({ + ...refreshed, source: "oauth", accountId: background.accountId, email: background.email, + }); + expect(getAccountCredential("anthropic", a)).toEqual(pausedCredential); + expect(getAccountSet("anthropic")!.accounts.find(row => row.id === a)?.paused).toBe(true); + } finally { detect.mockRestore(); } +}); + +test("missing provenance does not change non-Anthropic pause fallback", async () => { + for (const accountId of ["synthetic-xai-a", "synthetic-xai-b"]) { + await saveCredential("xai", { access: accountId, refresh: accountId, expires: Date.now() + 3_600_000, accountId }); + } + const [a, b] = getAccountSet("xai")!.accounts; + await setActiveAccount("xai", a!.id); + expect(await setAccountPaused("xai", a!.id, true)).toMatchObject({ activeAccountId: b!.id, activeAccountChanged: true }); +}); + test("all-paused refusal and resume preserve credentials and cooldown, independent of pool enable", async () => { const a = ids[0]!; const credential = getAccountCredential("anthropic", a); diff --git a/tests/claude-integration/messages-native-oauth.test.ts b/tests/claude-integration/messages-native-oauth.test.ts index 9842711ce0e..64d591f3d11 100644 --- a/tests/claude-integration/messages-native-oauth.test.ts +++ b/tests/claude-integration/messages-native-oauth.test.ts @@ -80,6 +80,7 @@ function credential(index: number) { refresh: `synthetic-anthropic-refresh-${index}`, expires: Date.now() + 3_600_000, accountId: `synthetic-account-${index}`, + source: "oauth" as const, }; } diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 4bc48733b57..b163ec3b090 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1225,6 +1225,7 @@ "oauth-account-id-collision.test.ts": "oauth", "oauth-account-quota-rank.test.ts": "oauth", "oauth-accounts-api.test.ts": "oauth", + "oauth-anthropic-identity.test.ts": "oauth", "oauth-callback-binds.test.ts": "oauth", "oauth-callback-server.test.ts": "oauth", "oauth-device-code-contract.test.ts": "oauth", diff --git a/tests/oauth/local-token-detect.test.ts b/tests/oauth/local-token-detect.test.ts index c6a92f620be..a3171f4ecc3 100644 --- a/tests/oauth/local-token-detect.test.ts +++ b/tests/oauth/local-token-detect.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { detectGrokCliToken, + hasClaudeCredentialContinuity, parseClaudeOauthPayload, readClaudeCredentialsFile, shouldAdoptGrokGeneration, @@ -134,3 +135,24 @@ describe("shouldAdoptGrokGeneration with NaN/unknown expiries", () => { expect(shouldAdoptGrokGeneration(stored, disk, Date.now(), 60_000)).toBe(true); }); }); + +describe("Claude credential continuity", () => { + const stored = { access: "synthetic-access", refresh: "synthetic-refresh", expires: 1 }; + test("accepts a shared access or refresh token", () => { + expect(hasClaudeCredentialContinuity(stored, { ...stored, refresh: "synthetic-new-refresh" })).toBe(true); + expect(hasClaudeCredentialContinuity(stored, { ...stored, access: "synthetic-new-access" })).toBe(true); + }); + test("rejects a completely replaced pair even when metadata agrees", () => { + const metadata = { accountId: "synthetic-account", email: "synthetic@example.test" }; + expect(hasClaudeCredentialContinuity({ ...stored, ...metadata }, { + access: "synthetic-other-access", refresh: "synthetic-other-refresh", expires: 2, ...metadata, + })).toBe(false); + }); + test("empty or whitespace-only values do not prove continuity", () => { + for (const token of ["", " ", "\t"]) { + expect(hasClaudeCredentialContinuity({ access: token, refresh: token, expires: 1 }, { + access: token, refresh: token, expires: 2, + })).toBe(false); + } + }); +}); diff --git a/tests/oauth/oauth-accounts-api.test.ts b/tests/oauth/oauth-accounts-api.test.ts index c1d4a564d74..38c96c310d6 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 }); diff --git a/tests/oauth/oauth-anthropic-identity.test.ts b/tests/oauth/oauth-anthropic-identity.test.ts new file mode 100644 index 00000000000..d04d10c42cb --- /dev/null +++ b/tests/oauth/oauth-anthropic-identity.test.ts @@ -0,0 +1,521 @@ +import * as localTokens from "../../src/oauth/local-token-detect"; +import * as storeModule from "../../src/oauth/store"; +import { createHash } from "node:crypto"; +import { getLoginStatus, OAUTH_PROVIDERS, refreshAnthropicAccountWithLock } from "../../src/oauth"; +import { AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "../../src/oauth/anthropic"; +import { resolveAnthropicAccountIdentity } from "../../src/oauth/anthropic-identity"; +import { captureOAuthAccountSelection, loadAuthStore, mutateStore, removeAccount, saveAccountCredential, setAccountPaused } from "../../src/oauth/store"; +import type { OAuthCredentials } from "../../src/oauth/types"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getValidAccessToken, OAuthTokenRefreshStaleError } from "../../src/oauth"; +import { credentialGeneration, getAccountCredential, getAccountSet, readOAuthRefreshIntent, saveCredential, writeOAuthRefreshIntent } from "../../src/oauth/store"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalEnv = { HOME: process.env.HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR }; +const originalFetch = globalThis.fetch; +let tmp: string; +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "anthropic-identity-")); + process.env.HOME = tmp; + process.env.OPENCODEX_HOME = join(tmp, "ocx"); + process.env.CLAUDE_CONFIG_DIR = join(tmp, "claude"); + mkdirSync(process.env.CLAUDE_CONFIG_DIR); + writeFileSync(join(process.env.CLAUDE_CONFIG_DIR, ".credentials.json"), "{}"); + globalThis.fetch = (async () => new Response(null, { status: 503 })) as typeof fetch; +}); +afterEach(() => { + globalThis.fetch = originalFetch; + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + removeTreeWithRetry(tmp); +}); +function disk(access = "synthetic-other", refresh = "synthetic-other-refresh") { + writeFileSync(join(process.env.CLAUDE_CONFIG_DIR!, ".credentials.json"), JSON.stringify({ claudeAiOauth: { + accessToken: access, refreshToken: refresh, expiresAt: Date.now() + 3600_000, + } })); +} + +test("unknown fully rotated Claude pair preserves row and pending intent without refresh or reauth", async () => { + await saveCredential("anthropic", { access: "synthetic-old", refresh: "synthetic-consumed", expires: 1, source: "local-cli" }); + const id = getAccountSet("anthropic")!.activeAccountId; + const stored = getAccountCredential("anthropic", id)!; + const intent = writeOAuthRefreshIntent("anthropic", id, credentialGeneration(stored)); + disk(); + let posts = 0; + globalThis.fetch = (async (_url, init) => { + if (init?.method === "POST") posts++; + return new Response(null, { status: 503 }); + }) as typeof fetch; + await expect(getValidAccessToken("anthropic")).rejects.toThrow(); + expect(getAccountCredential("anthropic", id)).toEqual(stored); + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(intent); + expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBeUndefined(); + expect(posts).toBe(0); +}); + +function proof(access: string, accountUuid = "synthetic-account-a") { + return { v: 1 as const, accountUuid, bearerSha256: createHash("sha256").update(access).digest("hex") }; +} +async function seed(bound = true) { + const credential: OAuthCredentials = { access: "synthetic-old", refresh: "synthetic-consumed", expires: 1, + source: "local-cli", ...(bound ? { anthropicIdentity: proof("synthetic-old") } : {}) }; + await saveCredential("anthropic", credential); + const id = getAccountSet("anthropic")!.activeAccountId; + return { id, credential: getAccountCredential("anthropic", id)! }; +} +function profiles(accountUuid = "synthetic-account-a") { + globalThis.fetch = (async () => Response.json({ account: { uuid: accountUuid }, + organization: { uuid: "synthetic-shared-org" }, email: "shared@example.test" })) as typeof fetch; +} +function deferred() { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +const noRefresh = { ...OAUTH_PROVIDERS.anthropic!, refresh: async () => { throw new Error("refresh must not run"); } }; + +test("profile boundary uses fixed origin, exact bearer, no redirects and private proof", async () => { + let request: { url: string; init?: RequestInit } | undefined; + globalThis.fetch = (async (url, init) => { + request = { url: String(url), init }; + return Response.json({ account: { uuid: "synthetic-account-a" }, organization: { uuid: "other" } }); + }) as typeof fetch; + expect(await resolveAnthropicAccountIdentity("synthetic-access")).toEqual(proof("synthetic-access")); + expect(request!.url).toBe("https://api.anthropic.com/api/oauth/profile"); + expect(request!.init!.redirect).toBe("error"); + const headers = new Headers(request!.init!.headers); + expect(headers.get("authorization")).toBe("Bearer synthetic-access"); + expect(headers.get("anthropic-beta")).toBe("oauth-2025-04-20"); + expect(headers.get("cache-control")).toBe("no-cache"); + expect(request!.init!.signal).toBeInstanceOf(AbortSignal); +}); + +for (const payload of [{ organization: { uuid: "synthetic-account-a" } }, { account: { uuid: "" } }, + { account: { uuid: " x " } }, { account: { uuid: 123 } }, { account: { uuid: "x".repeat(129) } }, + { account: { uuid: "x\ny" } }]) { + test(`profile rejects malformed account shape ${JSON.stringify(payload).slice(0, 65)}`, async () => { + globalThis.fetch = (async () => Response.json(payload)) as typeof fetch; + expect(await resolveAnthropicAccountIdentity("synthetic-access")).toBeUndefined(); + }); +} +for (const mode of ["malformed", "oversized", "redirect", "error", "timeout", "abort"] as const) { + test(`profile refuses ${mode} without identity`, async () => { + const ctrl = new AbortController(); + globalThis.fetch = (async (_url, init) => { + if (mode === "timeout") throw new DOMException("synthetic", "TimeoutError"); + if (mode === "abort") { + const body = new ReadableStream({ start() { ctrl.abort(); } }); + return new Response(body); + } + if (mode === "redirect") return new Response(null, { status: 302, headers: { location: "https://example.test" } }); + if (mode === "error") return new Response(null, { status: 401 }); + return new Response(mode === "oversized" ? JSON.stringify({ account: { uuid: "a" }, pad: "x".repeat(65536) }) : "{"); + }) as typeof fetch; + expect(await resolveAnthropicAccountIdentity("synthetic-access", ctrl.signal)).toBeUndefined(); + }); +} + +test("private binding roundtrips, stays out of summaries and stale binding is dropped", async () => { + const { id, credential } = await seed(); + expect(loadAuthStore().anthropic!.accounts[0]!.credential.anthropicIdentity).toEqual(proof("synthetic-old")); + const summary = JSON.stringify(getLoginStatus("anthropic")); + expect(summary).not.toContain("anthropicIdentity"); + expect(summary).not.toContain("bearerSha256"); + await saveAccountCredential("anthropic", id, { ...credential, access: "synthetic-new" }); + expect(getAccountCredential("anthropic", id)!.anthropicIdentity).toBeUndefined(); + await saveAccountCredential("anthropic", id, { ...credential, anthropicIdentity: { ...proof(credential.access), v: 2 } as never }); + expect(getAccountCredential("anthropic", id)!.anthropicIdentity).toBeUndefined(); +}); + +for (const bound of [true, false]) { + test(`full rotation adopts same account with ${bound ? "stored" : "fresh old-bearer"} proof`, async () => { + const { id, credential } = await seed(bound); + const intent = writeOAuthRefreshIntent("anthropic", id, credentialGeneration(credential)); + const selection = captureOAuthAccountSelection("anthropic"); + disk(); + const observed: string[] = []; + const result = await refreshAnthropicAccountWithLock("anthropic", id, noRefresh, credential, { + resolveIdentity: async access => { observed.push(access); return proof(access); }, + afterPrePersistRead: () => { expect(readOAuthRefreshIntent("anthropic", id)).toEqual(intent); }, + }); + expect(result).toBe("synthetic-other"); + expect(observed.sort()).toEqual(bound ? ["synthetic-other"] : ["synthetic-old", "synthetic-other"]); + expect(getAccountCredential("anthropic", id)!.anthropicIdentity).toEqual(proof("synthetic-other")); + expect(captureOAuthAccountSelection("anthropic")).toEqual(selection); + expect(readOAuthRefreshIntent("anthropic", id)).toBeUndefined(); + }); +} + +test("different UUID with same organization, email and path cannot replace or clear intent", async () => { + const { id, credential } = await seed(); + const intent = writeOAuthRefreshIntent("anthropic", id, credentialGeneration(credential)); + disk(); profiles("synthetic-account-b"); + await expect(getValidAccessToken("anthropic")).rejects.toThrow(); + expect(getAccountCredential("anthropic", id)).toEqual(credential); + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(intent); + expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBeUndefined(); +}); + +test("verified different CLI account may refresh its own stored grant without pending intent", async () => { + const { id, credential } = await seed(); disk(); profiles("synthetic-account-b"); + const calls: string[] = []; + expect(await refreshAnthropicAccountWithLock("anthropic", id, { ...noRefresh, refresh: async rt => { + calls.push(rt); return { access: "synthetic-fresh", refresh: "synthetic-fresh-rt", expires: Date.now() + 3600_000 }; + } }, credential)).toBe("synthetic-fresh"); + expect(calls).toEqual(["synthetic-consumed"]); + expect(getAccountCredential("anthropic", id)!.anthropicIdentity).toBeUndefined(); +}); + +for (const shared of ["access", "refresh"] as const) { + test(`shared ${shared} preserves metadata without minting stale bearer proof`, async () => { + const { id, credential } = await seed(); + disk(shared === "access" ? credential.access : "synthetic-other", shared === "refresh" ? credential.refresh : "synthetic-other-rt"); + expect(await refreshAnthropicAccountWithLock("anthropic", id, noRefresh, credential)).toBe(shared === "access" ? credential.access : "synthetic-other"); + expect(getAccountCredential("anthropic", id)!.anthropicIdentity).toEqual(shared === "access" ? proof(credential.access) : undefined); + }); +} + +for (const change of ["replacement", "login", "identity", "disk", "removal", "pause", "selection"] as const) { + test(`identity await cannot commit after ${change}`, async () => { + const { id, credential } = await seed(); disk(); + const intent = writeOAuthRefreshIntent("anthropic", id, credentialGeneration(credential)); + const started = deferred(), release = deferred(); + const operation = refreshAnthropicAccountWithLock("anthropic", id, noRefresh, credential, { + resolveIdentity: async access => { started.resolve(); await release.promise; return proof(access); }, + }); + await started.promise; + if (change === "replacement") await saveAccountCredential("anthropic", id, { ...credential, access: "synthetic-winner", expires: Date.now() + 3600_000 }); + if (change === "login") await saveAccountCredential("anthropic", id, credential, { rotateLoginId: true }); + if (change === "identity") await saveAccountCredential("anthropic", id, { ...credential, accountId: "synthetic-changed" }); + if (change === "disk") disk("synthetic-newer", "synthetic-newer-rt"); + if (change === "removal") await removeAccount("anthropic", id); + if (change === "pause") await setAccountPaused("anthropic", id, true); + if (change === "selection") await mutateStore(store => { store.anthropic!.selectionRevision = "00000000-0000-4000-8000-000000000001"; }); + const before = getAccountSet("anthropic"); + release.resolve(); + await expect(operation).rejects.toThrow(); + expect(getAccountSet("anthropic")).toEqual(before); + // Removal owns intent cleanup; all other races preserve the consumed-token guard. + if (change !== "removal") expect(readOAuthRefreshIntent("anthropic", id)).toEqual(intent); + }); +} + +test("persistence failure retains intent and old row after verified observation", async () => { + const { id, credential } = await seed(); disk(); profiles(); + const intent = writeOAuthRefreshIntent("anthropic", id, credentialGeneration(credential)); + await expect(refreshAnthropicAccountWithLock("anthropic", id, noRefresh, credential, { + afterPrePersistRead: () => { throw new Error("synthetic persistence failure"); }, + })).rejects.toThrow("synthetic persistence failure"); + expect(getAccountCredential("anthropic", id)).toEqual(credential); + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(intent); +}); + +test("authenticated token response binds the returned bearer", async () => { + globalThis.fetch = (async () => Response.json({ access_token: "synthetic-access", refresh_token: "synthetic-rt", expires_in: 3600, + account: { uuid: "synthetic-account-a" } })) as typeof fetch; + expect((await refreshAnthropicToken("synthetic-old-rt")).anthropicIdentity).toEqual(proof("synthetic-access")); +}); + +test("conflicting authenticated refresh proof fails with durable replay guard", async () => { + const { id, credential } = await seed(); + await expect(refreshAnthropicAccountWithLock("anthropic", id, { ...noRefresh, refresh: async () => ({ + access: "synthetic-other", refresh: "synthetic-other-rt", expires: Date.now() + 3600_000, + anthropicIdentity: proof("synthetic-other", "synthetic-account-b"), + }) }, credential)).rejects.toThrow("identity changed"); + expect(getAccountCredential("anthropic", id)).toEqual(credential); + expect(readOAuthRefreshIntent("anthropic", id)).toBeDefined(); +}); + +test("explicit import preserves unrelated identityless slot and enriches only a continuous slot", async () => { + const { id, credential } = await seed(false); disk(); profiles(); + const imported = await loginAnthropic({}, { importLocal: "only" }); + expect(imported.anthropicIdentity).toEqual(proof("synthetic-other")); + await saveCredential("anthropic", imported, { preserveIdentityless: true }); + expect(getAccountCredential("anthropic", id)).toEqual(credential); + expect(getAccountSet("anthropic")!.accounts).toHaveLength(2); + const selection = captureOAuthAccountSelection("anthropic"); + const importId = selection!.accountId; + await saveCredential("anthropic", imported, { preserveIdentityless: true }); + expect(getAccountSet("anthropic")!.accounts).toHaveLength(2); + expect(getAccountSet("anthropic")!.activeAccountId).toBe(importId); + expect(captureOAuthAccountSelection("anthropic")).toEqual(selection); +}); + +test("explicit import enriches legacy shared-token slot without changing its id or active choice", async () => { + const { id } = await seed(false); + await saveCredential("anthropic", { access: "synthetic-secondary", refresh: "synthetic-secondary-rt", expires: 1, accountId: "secondary" }, { preserveIdentityless: true }); + const active = getAccountSet("anthropic")!.activeAccountId; + disk("synthetic-old"); profiles(); + const imported = await loginAnthropic({}, { importLocal: "only" }); + await saveCredential("anthropic", imported, { preserveIdentityless: true }); + expect(getAccountSet("anthropic")!.accounts).toHaveLength(2); + expect(getAccountSet("anthropic")!.activeAccountId).toBe(active); + expect(getAccountCredential("anthropic", id)!.anthropicIdentity).toEqual(proof("synthetic-old")); +}); + +for (const mutation of ["login", "identity", "pause", "disk"] as const) { + test(`serialized adoption guard catches queued ${mutation} after the profile recheck`, async () => { + const { id, credential } = await seed(); disk(); + const intent = writeOAuthRefreshIntent("anthropic", id, credentialGeneration(credential)); + const observed = deferred(), finishProfile = deferred(), writerEntered = deferred(), finishWriter = deferred(), mergeEntered = deferred(); + const originalMerge = storeModule.mergeAccountCredential; + const mergeSpy = spyOn(storeModule, "mergeAccountCredential").mockImplementation((...args) => { + mergeEntered.resolve(); return originalMerge(...args); + }); + const operation = refreshAnthropicAccountWithLock("anthropic", id, noRefresh, credential, { + resolveIdentity: async access => { observed.resolve(); await finishProfile.promise; return proof(access); }, + }); + await observed.promise; + const writer = mutateStore(async store => { + writerEntered.resolve(); await finishWriter.promise; + const row = store.anthropic!.accounts.find(a => a.id === id)!; + if (mutation === "login") row.loginId = "00000000-0000-4000-8000-000000000002"; + if (mutation === "identity") row.credential.accountId = "synthetic-owner-changed"; + if (mutation === "pause") row.paused = true; + if (mutation === "disk") disk("synthetic-newer-disk", "synthetic-newer-refresh"); + }); + try { + await writerEntered.promise; + finishProfile.resolve(); + await mergeEntered.promise; + finishWriter.resolve(); + await writer; + await expect(operation).rejects.toThrow(); + expect(getAccountCredential("anthropic", id)!.access).toBe(credential.access); + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(intent); + } finally { + finishProfile.resolve(); finishWriter.resolve(); mergeSpy.mockRestore(); + } + }); +} + +test("unavailable profile import remains identityless and cannot overwrite an unrelated slot", async () => { + const { id, credential } = await seed(false); disk(); + const imported = await loginAnthropic({}, { importLocal: "only" }); + expect(imported.anthropicIdentity).toBeUndefined(); + await saveCredential("anthropic", imported); + expect(getAccountSet("anthropic")!.accounts).toHaveLength(2); + expect(getAccountCredential("anthropic", id)).toEqual(credential); +}); + +test("both bearer observations start together and mismatched bearer hashes are never trusted", async () => { + const { id, credential } = await seed(false); disk(); + const both = deferred(); const calls: string[] = []; + await expect(refreshAnthropicAccountWithLock("anthropic", id, noRefresh, credential, { + resolveIdentity: async access => { + calls.push(access); if (calls.length === 2) both.resolve(); + await both.promise; + return proof("synthetic-wrong-bearer"); + }, + })).rejects.toThrow(); + expect(calls).toHaveLength(2); + expect(getAccountCredential("anthropic", id)).toEqual(credential); + expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBeUndefined(); +}); + +test("profile deadline is ten seconds and cancellation reaches a pending fetch", async () => { + const deadline = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(deadline.signal); + const entered = deferred(); + globalThis.fetch = (async (_url, init) => { + entered.resolve(); + return await new Promise((_resolve, reject) => { + init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true }); + }); + }) as typeof fetch; + try { + const result = resolveAnthropicAccountIdentity("synthetic-access"); + await entered.promise; + expect(timeout).toHaveBeenCalledWith(10_000); + deadline.abort(new DOMException("synthetic", "TimeoutError")); + expect(await result).toBeUndefined(); + } finally { timeout.mockRestore(); } +}); + +test("malformed UTF-8 profile and pre-aborted caller produce no identity", async () => { + globalThis.fetch = (async () => new Response(new Uint8Array([0xff]))) as typeof fetch; + expect(await resolveAnthropicAccountIdentity("synthetic-access")).toBeUndefined(); + let calls = 0; + globalThis.fetch = (async () => { calls++; throw new Error("unexpected"); }) as typeof fetch; + expect(await resolveAnthropicAccountIdentity("synthetic-access", AbortSignal.abort())).toBeUndefined(); + expect(calls).toBe(0); +}); + +test("unverified matching account metadata cannot stand in for the old bearer proof", async () => { + const { id, credential } = await seed(false); + await saveAccountCredential("anthropic", id, { ...credential, accountId: "synthetic-account-a", email: "shared@example.test" }); + disk(); + const stored = getAccountCredential("anthropic", id)!; + await expect(refreshAnthropicAccountWithLock("anthropic", id, noRefresh, stored, { + resolveIdentity: async access => access === stored.access ? undefined : proof(access), + })).rejects.toThrow(); + expect(getAccountCredential("anthropic", id)).toEqual(stored); + expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBeUndefined(); +}); + + +for (const mutation of ["relogin", "metadata", "health", "removal"] as const) { + test(`initial account snapshot cannot adopt across concurrent ${mutation}`, async () => { + const { id, credential } = await seed(); disk(); + const intent = writeOAuthRefreshIntent("anthropic", id, credentialGeneration(credential)); + const originalGet = storeModule.getAccountSet; + let injected = false; + let expected: ReturnType; + const read = spyOn(storeModule, "getAccountSet").mockImplementation(provider => { + const snapshot = originalGet(provider); + if (provider === "anthropic" && !injected) { + injected = true; + // Model another process committing just after this read's snapshot was obtained. + const replacement = loadAuthStore(); + const row = replacement.anthropic!.accounts.find(account => account.id === id)!; + if (mutation === "relogin") { + row.loginId = "00000000-0000-4000-8000-000000000099"; + row.credential.source = "oauth"; + row.credential.email = "new-login@example.test"; + } + if (mutation === "metadata") row.credential.accountId = "synthetic-new-identity"; + if (mutation === "health") row.needsReauth = true; + if (mutation === "removal") delete replacement.anthropic; + writeFileSync(storeModule.getAuthStorePath(), JSON.stringify(replacement)); + expected = originalGet(provider); + } + return snapshot; + }); + try { + await expect(refreshAnthropicAccountWithLock("anthropic", id, noRefresh, credential, { + resolveIdentity: async access => proof(access), + })).rejects.toBeInstanceOf(OAuthTokenRefreshStaleError); + expect(injected).toBe(true); + expect(originalGet("anthropic")).toEqual(expected!); + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(intent); + } finally { read.mockRestore(); } + }); +} + + +for (const cleanup of ["definitive-rejection", "pre-dispatch"] as const) { + for (const persistent of [false, true]) { + test(`different CLI account resumes ${cleanup} cleanup with ${persistent ? "persistent" : "temporary"} I/O failure`, async () => { + const { id, credential } = await seed(); + const observed = async (access: string) => proof(access, + access === credential.access ? "synthetic-account-a" : "synthetic-account-b"); + if (cleanup === "definitive-rejection") disk(); + const rejected = cleanup === "definitive-rejection" + ? new AnthropicTokenError("synthetic rejection", 503, undefined) : new Error("synthetic pre-dispatch abort"); + const controller = new AbortController(); + if (cleanup === "pre-dispatch") controller.abort(rejected); + const realClear = storeModule.clearOAuthRefreshIntentIfMatch; + let clears = 0; + const clear = spyOn(storeModule, "clearOAuthRefreshIntentIfMatch").mockImplementation((...args) => { + clears++; + if (persistent || clears === 1) throw new Error("synthetic intent unlink failure"); + return realClear(...args); + }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const refreshTokens: string[] = []; + try { + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, refresh: async token => { refreshTokens.push(token); throw rejected; }, + }, credential, { signal: controller.signal, resolveIdentity: observed })).rejects.toBe(rejected); + const pending = readOAuthRefreshIntent("anthropic", id)!; + expect(pending.cleanupPending).toBe(cleanup); + expect(clears).toBe(1); + disk(); + const retry = refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, refresh: async token => { + expect(clears).toBe(2); + expect(readOAuthRefreshIntent("anthropic", id)?.cleanupPending).toBeUndefined(); + expect(readOAuthRefreshIntent("anthropic", id)?.attemptId).not.toBe(pending.attemptId); + refreshTokens.push(token); + return { access: "synthetic-a-refreshed", refresh: "synthetic-a-refresh", expires: Date.now() + 3600_000 }; + }, + }, credential, { resolveIdentity: observed }); + if (persistent) { + await expect(retry).rejects.toMatchObject({ operation: "resume-cleanup", code: "OAUTH_REFRESH_INTENT_IO" }); + expect(clears).toBe(2); + expect(getAccountCredential("anthropic", id)).toEqual(credential); + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(pending); + expect(refreshTokens).toEqual(cleanup === "definitive-rejection" ? [credential.refresh] : []); + } else { + await expect(retry).resolves.toBe("synthetic-a-refreshed"); + expect(refreshTokens).toEqual(cleanup === "definitive-rejection" + ? [credential.refresh, credential.refresh] : [credential.refresh]); + expect(readOAuthRefreshIntent("anthropic", id)).toBeUndefined(); + expect(getAccountCredential("anthropic", id)?.access).toBe("synthetic-a-refreshed"); + } + expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBeUndefined(); + } finally { clear.mockRestore(); warn.mockRestore(); } + }); + } +} + +test("different CLI account never clears or replays an uncertain refresh intent", async () => { + const { id, credential } = await seed(); disk(); + writeFileSync(storeModule.getAuthRefreshIntentPath("anthropic", id), "synthetic malformed intent"); + const pending = readOAuthRefreshIntent("anthropic", id); + expect(pending?.uncertain).toBe(true); + const clear = spyOn(storeModule, "clearOAuthRefreshIntentIfMatch"); + try { + await expect(refreshAnthropicAccountWithLock("anthropic", id, noRefresh, credential, { + resolveIdentity: async access => proof(access, access === credential.access ? "synthetic-account-a" : "synthetic-account-b"), + })).rejects.toThrow(); + expect(clear).not.toHaveBeenCalled(); + expect(getAccountCredential("anthropic", id)).toEqual(credential); + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(pending); + } finally { clear.mockRestore(); } +}); + + +test("different CLI account can clear an obsolete intent after durable same-account adoption", async () => { + const { id, credential } = await seed(); disk(); + const pending = writeOAuthRefreshIntent("anthropic", id, credentialGeneration(credential)); + const realClear = storeModule.clearOAuthRefreshIntentIfMatch; + let clears = 0; + const clear = spyOn(storeModule, "clearOAuthRefreshIntentIfMatch").mockImplementation((...args) => { + if (++clears === 1) throw new Error("synthetic post-adoption unlink failure"); + return realClear(...args); + }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(refreshAnthropicAccountWithLock("anthropic", id, noRefresh, credential, { + resolveIdentity: async access => proof(access), + })).resolves.toBe("synthetic-other"); + const adopted = getAccountCredential("anthropic", id)!; + expect(readOAuthRefreshIntent("anthropic", id)).toEqual(pending); + expect(credentialGeneration(adopted)).not.toBe(pending.generation); + disk("synthetic-account-b-access", "synthetic-account-b-refresh"); + const sent: string[] = []; + await expect(refreshAnthropicAccountWithLock("anthropic", id, { + ...OAUTH_PROVIDERS.anthropic!, refresh: async token => { + sent.push(token); + return { access: "synthetic-a-fresh", refresh: "synthetic-a-fresh-refresh", expires: Date.now() + 3600_000 }; + }, + }, adopted, { resolveIdentity: async access => proof(access, "synthetic-account-b") })).resolves.toBe("synthetic-a-fresh"); + expect(sent).toEqual([adopted.refresh]); + expect(readOAuthRefreshIntent("anthropic", id)).toBeUndefined(); + expect(getAccountSet("anthropic")!.accounts[0]!.needsReauth).toBeUndefined(); + } finally { clear.mockRestore(); warn.mockRestore(); } +}); + + +test("adoption reads the CLI generation only at observation and inside persistence", async () => { + const { id, credential } = await seed(); disk(); + const originalDetect = localTokens.detectClaudeCodeToken; + const reads: boolean[] = []; + let insidePersistence = false; + const detect = spyOn(localTokens, "detectClaudeCodeToken").mockImplementation(() => { + reads.push(insidePersistence); + return originalDetect(); + }); + try { + await expect(refreshAnthropicAccountWithLock("anthropic", id, noRefresh, credential, { + resolveIdentity: async access => proof(access), + afterPrePersistRead: () => { insidePersistence = true; }, + })).resolves.toBe("synthetic-other"); + expect(reads).toEqual([false, true]); + } finally { detect.mockRestore(); } +}); diff --git a/tests/oauth/oauth-refresh.test.ts b/tests/oauth/oauth-refresh.test.ts index 6ec50816ba7..1a8362c1dcc 100644 --- a/tests/oauth/oauth-refresh.test.ts +++ b/tests/oauth/oauth-refresh.test.ts @@ -40,6 +40,7 @@ let tmp: string; beforeEach(() => { tmp = join(tmpdir(), `oauth-refresh-${Date.now()}-${Math.random().toString(16).slice(2)}`); mkdirSync(tmp, { recursive: true }); + globalThis.fetch = (async () => Response.json({ account: { uuid: "synthetic-account-a" } })) as typeof fetch; process.env.HOME = tmp; // Native kiro-cli store resolves per-platform (issue #710); win32 prefers these over HOME. process.env.LOCALAPPDATA = join(tmp, "AppData", "Local"); @@ -50,6 +51,8 @@ beforeEach(() => { delete process.env.KIROCLI_DB_PATH; delete process.env.KIROCLI_TOKEN_KEY; process.env.CLAUDE_CONFIG_DIR = join(tmp, ".claude"); + mkdirSync(process.env.CLAUDE_CONFIG_DIR); + writeFileSync(join(process.env.CLAUDE_CONFIG_DIR, ".credentials.json"), "{}"); }); afterEach(() => { @@ -140,7 +143,11 @@ function mockXaiRefreshFetch(access = "xai-fresh", refresh = "rt-fresh") { function mockRefreshFetch(responses: Array): { count: () => number } { let calls = 0; let i = 0; - globalThis.fetch = (async () => { + globalThis.fetch = (async (input) => { + // Profile proof is separate from the token endpoint; these assertions count refresh requests. + if (String(input) === "https://api.anthropic.com/api/oauth/profile") { + return Response.json({ account: { uuid: "synthetic-account-a" } }); + } calls++; const next = responses[i++] ?? responses[responses.length - 1]; if (next instanceof Error) throw next;