From 6bcf4d71d6ae34439e58e9929394f70f31723407 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 30 Oct 2026 02:07:47 +0000 Subject: [PATCH 1/8] fix(antigravity): stop sibling discovery after revocation --- src/codex/catalog/antigravity-discovery.ts | 8 +++---- src/codex/catalog/provider-models.ts | 2 +- structure/catalog.md | 2 +- structure/providers-and-adapters.md | 2 +- .../google/antigravity-routing-carry.test.ts | 22 +++++++++++++++++++ 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/codex/catalog/antigravity-discovery.ts b/src/codex/catalog/antigravity-discovery.ts index 97c78fe9e02..220ef7c9327 100644 --- a/src/codex/catalog/antigravity-discovery.ts +++ b/src/codex/catalog/antigravity-discovery.ts @@ -12,15 +12,15 @@ import { materializeCapturedHeaders, type CapturedModelsRequest } from "./gather export async function discoverAntigravitySiblings( name: string, provider: OcxProviderConfig, activeAccountId: string, request: CapturedModelsRequest, maxBytes: number, maxModels: number, - root: string, + root: string, authorityCurrent: () => boolean, ): Promise<() => AntigravityAvailableModel[]> { - if (root !== getConfigDir()) return () => []; + if (root !== getConfigDir() || !authorityCurrent()) return () => []; const results: Array<{ snapshot: OAuthAccessSnapshot; models: AntigravityAvailableModel[]; observation: ReturnType; }> = []; const current = (snapshot: OAuthAccessSnapshot): boolean => { - if (getConfigDir() !== root) return false; + if (getConfigDir() !== root || !authorityCurrent()) return false; const row = getAccountCredentialWithStatus(name, snapshot.accountId); return !!row && !row.paused && !row.needsReauth && row.credential.expires > Date.now() && credentialGeneration(row.credential) === snapshot.generation @@ -30,7 +30,7 @@ export async function discoverAntigravitySiblings( for (const sibling of getAccountSet(name)?.accounts ?? []) { if (sibling.id === activeAccountId || sibling.paused || sibling.needsReauth) continue; try { - if (getConfigDir() !== root) break; + if (getConfigDir() !== root || !authorityCurrent()) break; const snapshot = await getValidAccessSnapshotForAccount(name, sibling.id, { requireUsableAccount: true }); const project = provider.project ?? snapshot.projectId; if (!project || !current(snapshot)) continue; diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 04238755718..7557ffd8499 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -795,7 +795,7 @@ export async function fetchProviderModelsWithAuth( if (getConfigDir() !== metadataConfigDir) return observed(withConfiguredRetention(configured), "degraded"); const admitSiblings = resolveAuth.kind === "refreshing" && name === "google-antigravity" && prov.authMode === "oauth" && auth.oauthAccountId - ? await discoverAntigravitySiblings(name, prov, auth.oauthAccountId, request, discovery.maxResponseBytes, discovery.maxModels, metadataConfigDir) + ? await discoverAntigravitySiblings(name, prov, auth.oauthAccountId, request, discovery.maxResponseBytes, discovery.maxModels, metadataConfigDir, isCurrentCacheGeneration) : () => []; if (!isCurrentCacheGeneration() || getConfigDir() !== metadataConfigDir) return observed(withConfiguredRetention(configured), "degraded"); const activeAccount = auth.oauthAccountId ? getAccountCredentialWithStatus(name, auth.oauthAccountId) : null; diff --git a/structure/catalog.md b/structure/catalog.md index 70b2219487e..e85ab0b52a4 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -25,7 +25,7 @@ validation-pending. Import alone supplies no entitlement evidence for the model `src/server/models-capabilities.ts` preserves the legacy `/v1/models` negative reasoning signal for an explicit empty effort ladder when no reasoning, thinking-toggle or effort capability is declared. Explicit capability axes take precedence; a declared thinking toggle or reasoning support remains available with no effort rungs. This compatibility projection does not change Codex ladders, Claude thinking discovery, client exports or request wire policy. Vision-sidecar attachment metadata is projected after provider discovery/cache reads in `src/codex/catalog/routed-gather.ts`. The gather captures `visionSidecar.enabled !== false` before awaiting discovery, and `src/codex/catalog/gather-capture.ts` includes that effective flag in the flight key. Provider TTL/stale rows exclude synthetic sidecar image input; custom rows and synthesized combo members use the same captured switch. Explicit/native image modalities remain intact, and enabled advertisement retains the existing config-based consumer predicate without probing credential availability. An effective Vision switch change through `src/server/management/config-routes.ts` invokes the catalog-only management funnel after persistence to publish both catalog and cache; incomplete work is exposed as `catalogRefreshPending` without claiming client reload. Coverage: `tests/codex-integration/catalog-vision-sidecar-disabled.test.ts` and `tests/vision/sidecar-settings-vision-catalog.test.ts`. Custom-provider missing windows use [source-labelled vendor estimates](catalog-vendor-context.md) only when the provider opts in with `inferContextWindow: true`; these remain distinct from asserted routing capacity. Registry context seed capture preserves [configured window precedence](catalog-vendor-context.md): provider-wide limits bound seed defaults, explicit per-model windows win, and smaller observed limits remain authoritative. -Static policy and observed catalog evidence are separate authorities. Antigravity grouping and selection projection follow the [discovered effort-family contract](providers-and-adapters.md#antigravity-effort-families). Refreshing Antigravity discovery unions enabled models from usable accounts through `src/codex/catalog/antigravity-discovery.ts`; final-send and publication checks retain each sibling's config-root and full credential snapshot authority. Each account publishes its own roster; active and sibling discovery capture observation order before sending, so delayed publication cannot erase a later inference denial. Active publication also rechecks the captured credential generation. An observed-only gather does not probe siblings. +Static policy and observed catalog evidence are separate authorities. Antigravity grouping and selection projection follow the [discovered effort-family contract](providers-and-adapters.md#antigravity-effort-families). Refreshing Antigravity discovery unions enabled models from usable accounts through `src/codex/catalog/antigravity-discovery.ts`; credential resolution, final-send, and publication checks retain each sibling's config-root, provider cache-generation, and full credential snapshot authority. Each account publishes its own roster; active and sibling discovery capture observation order before sending, so delayed publication cannot erase a later inference denial. Active publication also rechecks the captured credential generation. An observed-only gather does not probe siblings. `src/providers/resolved-model-policy.ts` resolves and freezes only registry/operator static facts, hard wire pins, aliases, and explicit false/empty declarations. Discovery responses, generated metadata, cache freshness, availability, credentials, account state, quota and health never enter diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index f1e40687249..2745a817663 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -581,7 +581,7 @@ Antigravity discovery in `src/providers/antigravity-models.ts` collapses complet Antigravity catalog rows carry the observed complete effort map from `src/providers/antigravity-models.ts` through the in-memory cache. `src/providers/antigravity-effort-families.ts` uses that evidence to group public rows and project suffix allowlists into the base selection, including both final catalog merge callers. Internal retained suffix rows remain available to combo resolution. Authoritative new-model reconciliation normalizes both the prior baseline and policy input before detecting arrivals: an existing suffix family is not a new model. All-disabled tiers transfer a base disable once; a known base keeps the operator's later choice. Genuinely new families still follow new-model policy. Saved defaults, combo targets, suffix disables and provider selections remain unchanged. A discovered ladder advertises only its wire-backed efforts unless explicitly overridden; unknown single-wire models gain no invented ladder. -Known Claude Sonnet and Opus 5.5 base and low/medium/high IDs share a deterministic usage identity in `src/providers/antigravity-models.ts`, independently of live discovery. This accounting map does not migrate saved routing selections; Claude 4.6 and unknown suffix identities remain unchanged. Antigravity model entitlement is per account: one account may list Claude 4.6, another only the Claude 5.5 suffix families, and Gemini 4 Argon only on Ultra/Enterprise. `ANTIGRAVITY_MODELS` is therefore a static superset (Gemini 3.8 Flash, Gemini 4 Argon, Claude Opus 5.5, Claude Sonnet 5.5), and `ANTIGRAVITY_KNOWN_PICKER_MODELS` keeps dropped bases known to routing, discovery collapse and usage identity. Refreshing discovery exposes the union of usable accounts' enabled models. `src/codex/catalog/antigravity-discovery.ts` uses full account snapshots and checks config root, account presence, pause/reauth state and credential generation at final send and again before roster/union publication; failed siblings contribute nothing. Active and sibling discovery capture observation order before sending; delayed active publication retains newer inference denials and rechecks its captured credential generation. Roster publication rejects observations older than the stored same-generation observation before changing model IDs or clearing denials. Enabled entries alone consume the configured model limit, with a separate raw-entry bound. `src/providers/antigravity-account-roster.ts` records each account's discovered roster (in memory, six-hour freshness, scoped to config home and credential generation); `src/oauth/generic-account-failover.ts` gates Antigravity accounts by it: a listing account wins, an account without a fresh roster is unknown and stays usable, and an account whose fresh roster omits the model is never selected, so preference, 429 rotation and auth-refusal rotation answer no account instead of falling back to it (Kiro listings stay a preference). Before credential resolution, `routeAntigravityModel` lets `src/server/responses/request-transport.ts` replace an omitting planned account with a usable listing, then unknown, sibling when proactive preference is enabled; otherwise, and when no usable account may serve the model, the transport refuses locally with 403 `permission_denied` before any send. The shared admission step (`commitResolvedOAuthSelection`) re-checks every account it admits, so a resolution fallback, 401 recovery or failover rebind onto an omitting account is abandoned instead of sent. `tests/server/server-antigravity-model-roster-routing.test.ts` covers that boundary. Gemini 4 Argon requests and Antigravity model discovery send the Antigravity CLI User-Agent; every other model keeps the IDE User-Agent. +Known Claude Sonnet and Opus 5.5 base and low/medium/high IDs share a deterministic usage identity in `src/providers/antigravity-models.ts`, independently of live discovery. This accounting map does not migrate saved routing selections; Claude 4.6 and unknown suffix identities remain unchanged. Antigravity model entitlement is per account: one account may list Claude 4.6, another only the Claude 5.5 suffix families, and Gemini 4 Argon only on Ultra/Enterprise. `ANTIGRAVITY_MODELS` is therefore a static superset (Gemini 3.8 Flash, Gemini 4 Argon, Claude Opus 5.5, Claude Sonnet 5.5), and `ANTIGRAVITY_KNOWN_PICKER_MODELS` keeps dropped bases known to routing, discovery collapse and usage identity. Refreshing discovery exposes the union of usable accounts' enabled models. `src/codex/catalog/antigravity-discovery.ts` uses full account snapshots and checks config root, provider cache-generation, account presence, pause/reauth state and credential generation before credential resolution, at final send and again before roster/union publication; failed siblings contribute nothing. Active and sibling discovery capture observation order before sending; delayed active publication retains newer inference denials and rechecks its captured credential generation. Roster publication rejects observations older than the stored same-generation observation before changing model IDs or clearing denials. Enabled entries alone consume the configured model limit, with a separate raw-entry bound. `src/providers/antigravity-account-roster.ts` records each account's discovered roster (in memory, six-hour freshness, scoped to config home and credential generation); `src/oauth/generic-account-failover.ts` gates Antigravity accounts by it: a listing account wins, an account without a fresh roster is unknown and stays usable, and an account whose fresh roster omits the model is never selected, so preference, 429 rotation and auth-refusal rotation answer no account instead of falling back to it (Kiro listings stay a preference). Before credential resolution, `routeAntigravityModel` lets `src/server/responses/request-transport.ts` replace an omitting planned account with a usable listing, then unknown, sibling when proactive preference is enabled; otherwise, and when no usable account may serve the model, the transport refuses locally with 403 `permission_denied` before any send. The shared admission step (`commitResolvedOAuthSelection`) re-checks every account it admits, so a resolution fallback, 401 recovery or failover rebind onto an omitting account is abandoned instead of sent. `tests/server/server-antigravity-model-roster-routing.test.ts` covers that boundary. Gemini 4 Argon requests and Antigravity model discovery send the Antigravity CLI User-Agent; every other model keeps the IDE User-Agent. `retrieveUserQuotaSummary` reports Gemini 4 Argon as its own group; `src/providers/quota/antigravity.ts` decides each bucket's family from its own id/name (Argon before Gemini, then Claude/3P/GPT), and only for an inconclusive bucket falls back to the group: Argon from the group display name alone (never its description), Gemini and Claude/3P/GPT from the existing display-name-plus-description rules. Argon buckets become `Argon`/`Argon (Weekly)` windows tagged with the canonical pool model `gemini-4-argon`, so they never fill or replace Gem/Cla in either group order and stay diagnostic like the model windows below; an Argon-only summary is a display report with no routing evidence, and the compact GUI summary and tray aggregation treat it like other diagnostic-only data. When the summary endpoint has no usable result, the same file preserves bounded per-model `quotaInfo` measurements from `fetchAvailableModels` alongside the existing first-seen Gem/Cla family summaries, where an Argon model never seeds Gem. Model windows carry the exact CCA wire ID and distinct labels of at most 128 characters for display and diagnostics; GUI summaries and native compact rows preserve the discriminator, retain details, and exclude model readings from headline and severity selection; account and provider routing ignore these rows and continue to use family summaries, and `src/quota/window-mapping.ts` keeps them out of quota-reset observation so one pool reset notifies once. A missing model row or failed probe is unknown evidence, not a model eligibility verdict. Per-account cache entries are bound to the current credential and project identity. Paused reads retain only current last-good measurements without probing; `src/providers/quota/account-row.ts` preserves private currentness callbacks through account-row projection until management publication. Account-row projection is loaded with `import()` after each account probe in `src/providers/quota.ts`; its cache dependencies do not create a static return edge into the quota facade. diff --git a/tests/adapters/google/antigravity-routing-carry.test.ts b/tests/adapters/google/antigravity-routing-carry.test.ts index 860d8a909a1..60995286f71 100644 --- a/tests/adapters/google/antigravity-routing-carry.test.ts +++ b/tests/adapters/google/antigravity-routing-carry.test.ts @@ -78,6 +78,28 @@ test("a root change during active discovery cannot start sibling probes in the n expect(antigravityAccountRosterModelIds(provider, "b")).toBeUndefined(); }); +test("revoking discovery authority stops later sibling bearer sends", async () => { + const { cfg } = setup(); + let releaseSibling!: () => void; + let siblingSent!: () => void; + const atSibling = new Promise(resolve => { siblingSent = resolve; }); + const held = new Promise(resolve => { releaseSibling = resolve; }); + const sends: string[] = []; + globalThis.fetch = (async (_input, init) => { + const auth = new Headers(init?.headers).get("authorization")!; + sends.push(auth); + if (auth.endsWith("-b")) { siblingSent(); await held; } + return Response.json(payload("model-before-revocation")); + }) as typeof fetch; + const pending = gatherRoutedModels(cfg); + await atSibling; + clearModelCache(provider); + releaseSibling(); + await pending; + expect(sends).toEqual(["Bearer synthetic-a", "Bearer synthetic-b"]); + expect(antigravityAccountRosterModelIds(provider, "c")).toBeUndefined(); +}); + test("active discovery held by a sibling cannot erase a newer inference denial", async () => { const { cfg } = setup(); let releaseSibling!: () => void; From f739613761ec7b9eb0b35b6585fef89cbe9810e5 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 31 Oct 2026 06:20:04 +0900 Subject: [PATCH 2/8] test(antigravity): revoke discovery authority before bearer send --- tests/adapters/google/antigravity-routing-carry.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/adapters/google/antigravity-routing-carry.test.ts b/tests/adapters/google/antigravity-routing-carry.test.ts index 60995286f71..8c9fd04b9e1 100644 --- a/tests/adapters/google/antigravity-routing-carry.test.ts +++ b/tests/adapters/google/antigravity-routing-carry.test.ts @@ -169,12 +169,15 @@ test("late D1 publication cannot roll back D2 before a delayed inference denial" }); for (const timing of ["before-send", "before-publication"] as const) { - for (const mutation of ["paused", "removed", "reauth", "replaced", "root"] as const) { + for (const mutation of ["paused", "removed", "reauth", "replaced", "root", "authority"] as const) { test(`sibling ${mutation} ${timing} contributes no stale discovery`, async () => { const { cfg, store, home } = setup(); store[provider].accounts.splice(2); writeFileSync(join(home, "auth.json"), JSON.stringify(store)); + let mutationApplied = false; const mutate = () => { + mutationApplied = true; + if (mutation === "authority") { clearModelCache(provider); return; } const b = store[provider].accounts[1]!; if (mutation === "removed") store[provider].accounts.splice(1); if (mutation === "paused") Object.assign(b, { paused: true }); @@ -199,6 +202,7 @@ for (const timing of ["before-send", "before-publication"] as const) { }); try { const rows = await gatherRoutedModels(cfg); + expect(mutationApplied).toBe(true); expect(rows.some(row => row.id === "model-sibling")).toBe(false); expect(antigravityAccountRosterModelIds(provider, "b")).toBeUndefined(); if (timing === "before-send") expect(sends).toEqual(["Bearer synthetic-a"]); From b0c048e9e9f7bba69b1a5a6523af607c95d87da3 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 31 Oct 2026 07:49:52 +0900 Subject: [PATCH 3/8] fix(antigravity): recheck discovery authority after refresh lock --- src/codex/catalog/antigravity-discovery.ts | 4 +- src/oauth/index.ts | 13 ++-- structure/catalog.md | 2 +- structure/providers/generic-account-pool.md | 5 ++ .../google/antigravity-routing-carry.test.ts | 65 +++++++++++++++++-- 5 files changed, 75 insertions(+), 14 deletions(-) diff --git a/src/codex/catalog/antigravity-discovery.ts b/src/codex/catalog/antigravity-discovery.ts index 220ef7c9327..3792e807dc6 100644 --- a/src/codex/catalog/antigravity-discovery.ts +++ b/src/codex/catalog/antigravity-discovery.ts @@ -31,7 +31,9 @@ export async function discoverAntigravitySiblings( if (sibling.id === activeAccountId || sibling.paused || sibling.needsReauth) continue; try { if (getConfigDir() !== root || !authorityCurrent()) break; - const snapshot = await getValidAccessSnapshotForAccount(name, sibling.id, { requireUsableAccount: true }); + const snapshot = await getValidAccessSnapshotForAccount(name, sibling.id, { + requireUsableAccount: true, authorityCurrent: () => getConfigDir() === root && authorityCurrent(), + }); const project = provider.project ?? snapshot.projectId; if (!project || !current(snapshot)) continue; const observation = captureAntigravityModelObservation(); diff --git a/src/oauth/index.ts b/src/oauth/index.ts index fab4ef78aa8..a72c90ebedf 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -188,7 +188,7 @@ 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 { resolveIdentity?: AnthropicIdentityResolver; intentLock?:ReturnType; now?:()=>number; afterPrePersistRead?:()=>void|Promise; signal?: AbortSignal; flight?: OAuthRefreshFlightEvidence; replacedStaleFlight?: OAuthRefreshFlightEvidence } -interface GenericRefreshDeps { intentLock?:ReturnType; afterPrePersistRead?:()=>void|Promise; signal?: AbortSignal } +interface GenericRefreshDeps { intentLock?:ReturnType; afterPrePersistRead?:()=>void|Promise; signal?: AbortSignal; authorityCurrent?: () => boolean } 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;} export function sweepExpiredXaiPermanentFailureVerdicts(now=Date.now()):number{let removed=0;for(const[key,until]of permanentRefreshFailures){if(until>now)continue;permanentRefreshFailures.delete(key);removed+=1;}return removed;} @@ -631,6 +631,7 @@ async function resolveAccessSnapshotForAccount( rejectedGeneration?: string, requireUsableAccount = false, oauthProvider = provider, + authorityCurrent?: () => boolean, ): Promise { const def = OAUTH_PROVIDERS[oauthProvider]; if (!def) throw new UnsupportedOAuthProviderError(oauthProvider); @@ -692,7 +693,7 @@ async function resolveAccessSnapshotForAccount( dispatched: false, }; const refresh = (async (): Promise => { - const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred, abort.signal, flight, replacedStaleFlight); + const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred, abort.signal, flight, replacedStaleFlight, authorityCurrent); const persistedRow = getAccountCredentialWithStatus(provider, accountId); if (!persistedRow) throw new OAuthLoginRequiredError(provider); if (persistedRow.paused === true) throw new OAuthAccountPausedError(); @@ -772,9 +773,9 @@ export async function getValidAccessTokenForAccount(provider: string, accountId: export async function getValidAccessSnapshotForAccount( provider: string, accountId: string, - opts: { requireUsableAccount?: boolean } = {}, + opts: { requireUsableAccount?: boolean; authorityCurrent?: () => boolean } = {}, ): Promise { - return resolveAccessSnapshotForAccount(provider, accountId, undefined, opts.requireUsableAccount === true); + return resolveAccessSnapshotForAccount(provider, accountId, undefined, opts.requireUsableAccount === true, provider, opts.authorityCurrent); } /** Terminal refresh failures (revoked/rotated-away grants) — retrying cannot succeed. */ @@ -1039,6 +1040,7 @@ export async function refreshGenericAccountWithLock( } const generation = credentialGeneration(stored); try { + if (deps.authorityCurrent && !deps.authorityCurrent()) throw new OAuthTokenRefreshStaleError(); const refreshed = await def.refresh(stored.refresh, deps.signal, stored, accountId); const fresh = merged(refreshed, stored); const outcome = await mergeAccountCredential(provider, accountId, fresh, { @@ -1137,10 +1139,11 @@ async function refreshAndPersistAccessToken( signal?: AbortSignal, flight?: OAuthRefreshFlightEvidence, replacedStaleFlight?: OAuthRefreshFlightEvidence, + authorityCurrent?: () => boolean, ): Promise { if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred, { signal }); if (isAnthropicOAuthInstance(provider)) return refreshAnthropicAccountWithLock(provider, accountId, def, cred, { signal, flight, replacedStaleFlight }); - return refreshGenericAccountWithLock(provider, accountId, def, cred, { signal }); + return refreshGenericAccountWithLock(provider, accountId, def, cred, { signal, authorityCurrent }); } /** Resolve discovery credentials only for the caller-owned configured OAuth row. */ diff --git a/structure/catalog.md b/structure/catalog.md index e85ab0b52a4..7615e70b6ef 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -25,7 +25,7 @@ validation-pending. Import alone supplies no entitlement evidence for the model `src/server/models-capabilities.ts` preserves the legacy `/v1/models` negative reasoning signal for an explicit empty effort ladder when no reasoning, thinking-toggle or effort capability is declared. Explicit capability axes take precedence; a declared thinking toggle or reasoning support remains available with no effort rungs. This compatibility projection does not change Codex ladders, Claude thinking discovery, client exports or request wire policy. Vision-sidecar attachment metadata is projected after provider discovery/cache reads in `src/codex/catalog/routed-gather.ts`. The gather captures `visionSidecar.enabled !== false` before awaiting discovery, and `src/codex/catalog/gather-capture.ts` includes that effective flag in the flight key. Provider TTL/stale rows exclude synthetic sidecar image input; custom rows and synthesized combo members use the same captured switch. Explicit/native image modalities remain intact, and enabled advertisement retains the existing config-based consumer predicate without probing credential availability. An effective Vision switch change through `src/server/management/config-routes.ts` invokes the catalog-only management funnel after persistence to publish both catalog and cache; incomplete work is exposed as `catalogRefreshPending` without claiming client reload. Coverage: `tests/codex-integration/catalog-vision-sidecar-disabled.test.ts` and `tests/vision/sidecar-settings-vision-catalog.test.ts`. Custom-provider missing windows use [source-labelled vendor estimates](catalog-vendor-context.md) only when the provider opts in with `inferContextWindow: true`; these remain distinct from asserted routing capacity. Registry context seed capture preserves [configured window precedence](catalog-vendor-context.md): provider-wide limits bound seed defaults, explicit per-model windows win, and smaller observed limits remain authoritative. -Static policy and observed catalog evidence are separate authorities. Antigravity grouping and selection projection follow the [discovered effort-family contract](providers-and-adapters.md#antigravity-effort-families). Refreshing Antigravity discovery unions enabled models from usable accounts through `src/codex/catalog/antigravity-discovery.ts`; credential resolution, final-send, and publication checks retain each sibling's config-root, provider cache-generation, and full credential snapshot authority. Each account publishes its own roster; active and sibling discovery capture observation order before sending, so delayed publication cannot erase a later inference denial. Active publication also rechecks the captured credential generation. An observed-only gather does not probe siblings. +Static policy and observed catalog evidence are separate authorities. Antigravity grouping and selection projection follow the [discovered effort-family contract](providers-and-adapters.md#antigravity-effort-families). Refreshing Antigravity discovery unions enabled models from usable accounts through `src/codex/catalog/antigravity-discovery.ts`; credential resolution, final-send, and publication checks retain each sibling's config-root, provider cache-generation, and full credential snapshot authority. A sibling waiting for its OAuth refresh lock rechecks root/cache authority before token dispatch; an already-dispatched rotation still persists under the credential CAS. Each account publishes its own roster; active and sibling discovery capture observation order before sending, so delayed publication cannot erase a later inference denial. Active publication also rechecks the captured credential generation. An observed-only gather does not probe siblings. `src/providers/resolved-model-policy.ts` resolves and freezes only registry/operator static facts, hard wire pins, aliases, and explicit false/empty declarations. Discovery responses, generated metadata, cache freshness, availability, credentials, account state, quota and health never enter diff --git a/structure/providers/generic-account-pool.md b/structure/providers/generic-account-pool.md index 52905b19b79..5bf0c676468 100644 --- a/structure/providers/generic-account-pool.md +++ b/structure/providers/generic-account-pool.md @@ -26,6 +26,11 @@ are excluded. `tests/oauth/generic-session-affinity.test.ts` and ## Combo sibling probes +Antigravity sibling discovery passes its root/cache authority through `src/oauth/index.ts` to +generic refresh dispatch. After the account lock is acquired and newer credentials are adopted, +stale discovery stops before the token request without marking the account for reauthentication. +A rotation already dispatched still persists its result under the existing credential CAS. + Antigravity model routing in `src/oauth/generic-account-failover.ts` ranks siblings by configured strategy or family headroom only within the best readiness/model-support tier. A known omission stays excluded, and unknown support stays usable. Reactive model-404 rotation excludes accounts already attempted in the request and prefers positive support before unknown support without changing account-wide auth or cooldown state. `src/server/responses/core-combo.ts` asks `src/oauth/generic-account-failover.ts` whether a refused diff --git a/tests/adapters/google/antigravity-routing-carry.test.ts b/tests/adapters/google/antigravity-routing-carry.test.ts index 8c9fd04b9e1..b1ff17883d6 100644 --- a/tests/adapters/google/antigravity-routing-carry.test.ts +++ b/tests/adapters/google/antigravity-routing-carry.test.ts @@ -5,7 +5,8 @@ import { join } from "node:path"; import { gatherRoutedModels } from "../../../src/codex/catalog"; import { clearModelCache } from "../../../src/codex/model-cache"; import * as outbound from "../../../src/lib/provider-outbound"; -import { credentialGeneration, getAccountCredentialWithStatus } from "../../../src/oauth/store"; +import * as oauth from "../../../src/oauth"; +import { createOAuthRefreshIntentLock, credentialGeneration, getAccountCredentialWithStatus } from "../../../src/oauth/store"; import { preferredInitialAccount, routeAntigravityModel, clearGenericFailoverHealth } from "../../../src/oauth/generic-account-failover"; import { ANTIGRAVITY_MODEL_DENIAL_TTL_MS, antigravityAccountSupportsModel, antigravityAccountRosterModelIds, @@ -80,6 +81,7 @@ test("a root change during active discovery cannot start sibling probes in the n test("revoking discovery authority stops later sibling bearer sends", async () => { const { cfg } = setup(); + const resolver = spyOn(oauth, "getValidAccessSnapshotForAccount"); let releaseSibling!: () => void; let siblingSent!: () => void; const atSibling = new Promise(resolve => { siblingSent = resolve; }); @@ -92,12 +94,61 @@ test("revoking discovery authority stops later sibling bearer sends", async () = return Response.json(payload("model-before-revocation")); }) as typeof fetch; const pending = gatherRoutedModels(cfg); - await atSibling; - clearModelCache(provider); - releaseSibling(); - await pending; - expect(sends).toEqual(["Bearer synthetic-a", "Bearer synthetic-b"]); - expect(antigravityAccountRosterModelIds(provider, "c")).toBeUndefined(); + try { + await atSibling; + clearModelCache(provider); + releaseSibling(); + await pending; + expect(sends).toEqual(["Bearer synthetic-a", "Bearer synthetic-b"]); + expect(resolver.mock.calls.some(([, accountId]) => accountId === "c")).toBe(false); + expect(antigravityAccountRosterModelIds(provider, "c")).toBeUndefined(); + } finally { releaseSibling(); await pending; resolver.mockRestore(); } +}); + +for (const revoke of [true, false]) test(`sibling refresh rechecks discovery authority after waiting for its lock (revoke=${revoke})`, async () => { + const { cfg, store, home } = setup(); + store[provider].accounts[2]!.credential.expires = Date.now() - 1; + writeFileSync(join(home, "auth.json"), JSON.stringify(store)); + const before = getAccountCredentialWithStatus(provider, "c")!; + const guard = await createOAuthRefreshIntentLock(provider, "c").acquire(); + let entered!: () => void; + const atResolver = new Promise(resolve => { entered = resolve; }); + const originalResolver = oauth.getValidAccessSnapshotForAccount; + const resolver = spyOn(oauth, "getValidAccessSnapshotForAccount").mockImplementation((...args) => { + const pending = originalResolver(...args); + if (args[1] === "c") entered(); + return pending; + }); + let refreshes = 0; + globalThis.fetch = (async (input) => { + const url = String(input); + if (url === "https://oauth2.googleapis.com/token") { + refreshes++; + return Response.json({ access_token: "synthetic-c-new", refresh_token: "synthetic-refresh-c-new", expires_in: 3600 }); + } + if (url.includes("/userinfo")) return Response.json({ email: "fixture@example.test" }); + if (url.includes("loadCodeAssist")) return Response.json({ cloudaicompanionProject: "project-c" }); + return Response.json(payload("model-after-lock")); + }) as typeof fetch; + const pending = gatherRoutedModels(cfg); + try { + await atResolver; + expect(refreshes).toBe(0); + if (revoke) clearModelCache(provider); + guard.release(); + await pending; + expect(refreshes).toBe(revoke ? 0 : 1); + const after = getAccountCredentialWithStatus(provider, "c")!; + expect(after.needsReauth).toBe(false); + if (revoke) { + expect(after.credential).toEqual(before.credential); + expect(antigravityAccountRosterModelIds(provider, "c")).toBeUndefined(); + } else { + expect(after.credential.access).toBe("synthetic-c-new"); + expect(after.credential.refresh).toBe("synthetic-refresh-c-new"); + expect(antigravityAccountRosterModelIds(provider, "c")).toEqual(["model-after-lock"]); + } + } finally { guard.release(); await pending; resolver.mockRestore(); } }); test("active discovery held by a sibling cannot erase a newer inference denial", async () => { From fff8e00f1b70a564867fc949ea5627383b4b8047 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 31 Oct 2026 08:18:14 +0900 Subject: [PATCH 4/8] fix(oauth): recover independent callers after discovery cancellation --- src/oauth/index.ts | 8 +++- structure/providers/generic-account-pool.md | 3 ++ structure/runtime.md | 2 +- .../oauth-refresh-stale-capacity.test.ts | 36 +++++++++++++- ...r-antigravity-model-roster-routing.test.ts | 48 ++++++++++++++++++- 5 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index a72c90ebedf..281e1306482 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -155,6 +155,8 @@ export class OAuthTokenRefreshStaleError extends Error { readonly retryable = true; constructor() { super("OAuth token refresh owner became stale"); this.name = "OAuthTokenRefreshStaleError"; } } +/** Known pre-dispatch cancellation; independent waiters may acquire a new owner. */ +class OAuthDiscoveryRefreshCancelledError extends OAuthTokenRefreshStaleError {} /** Focused owner-identity tests only. Synthetic owners retain no account data. */ export function seedOAuthTokenRefreshFlightsForTests(rows: Array<{ key: string; startedAt?: number; flightId?: string; dispatched?: boolean }>): { @@ -632,6 +634,7 @@ async function resolveAccessSnapshotForAccount( requireUsableAccount = false, oauthProvider = provider, authorityCurrent?: () => boolean, + retryCancelledDiscovery = true, ): Promise { const def = OAUTH_PROVIDERS[oauthProvider]; if (!def) throw new UnsupportedOAuthProviderError(oauthProvider); @@ -715,7 +718,10 @@ async function resolveAccessSnapshotForAccount( flight.promise = refresh; tokenRefreshes.set(key, flight); return await refresh; + } catch (error) { + if (authorityCurrent || !retryCancelledDiscovery || !(error instanceof OAuthDiscoveryRefreshCancelledError)) throw error; } finally { waiter.release(); } + return resolveAccessSnapshotForAccount(provider, accountId, rejectedGeneration, requireUsableAccount, oauthProvider, undefined, false); } export async function getValidAccessTokenSnapshot( @@ -1040,7 +1046,7 @@ export async function refreshGenericAccountWithLock( } const generation = credentialGeneration(stored); try { - if (deps.authorityCurrent && !deps.authorityCurrent()) throw new OAuthTokenRefreshStaleError(); + if (deps.authorityCurrent && !deps.authorityCurrent()) throw new OAuthDiscoveryRefreshCancelledError(); const refreshed = await def.refresh(stored.refresh, deps.signal, stored, accountId); const fresh = merged(refreshed, stored); const outcome = await mergeAccountCredential(provider, accountId, fresh, { diff --git a/structure/providers/generic-account-pool.md b/structure/providers/generic-account-pool.md index 5bf0c676468..abe7bb9f37a 100644 --- a/structure/providers/generic-account-pool.md +++ b/structure/providers/generic-account-pool.md @@ -30,6 +30,9 @@ Antigravity sibling discovery passes its root/cache authority through `src/oauth generic refresh dispatch. After the account lock is acquired and newer credentials are adopted, stale discovery stops before the token request without marking the account for reauthentication. A rotation already dispatched still persists its result under the existing credential CAS. +An independent consumer sharing a discovery-cancelled flight releases its waiter permit before +one new resolution attempt. Callers carrying discovery authority keep the cancellation; unknown +stale outcomes, including post-dispatch uncertainty, never receive this automatic retry. Antigravity model routing in `src/oauth/generic-account-failover.ts` ranks siblings by configured strategy or family headroom only within the best readiness/model-support tier. A known omission stays excluded, and unknown support stays usable. Reactive model-404 rotation excludes accounts already attempted in the request and prefers positive support before unknown support without changing account-wide auth or cooldown state. diff --git a/structure/runtime.md b/structure/runtime.md index 24fd3ad5035..1fd99c32ccd 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -30,7 +30,7 @@ Responses admission and finalization are composed through the [core module owner Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI model lists expose shared catalog estimates as `price`; `models price` retains the saved override in `cost` and adds `effectiveCost`. Explicit zero overrides win; unknown prices remain null, and automatic defaults are never persisted to `modelCosts`. Covered by `tests/cli/cli-models.test.ts` and `tests/cli/cli-models-price.test.ts`. Ordinary model discovery, startup sync, and explicit sync apply the [new-arrival catalog contract](catalog.md#shared-catalog) before publishing rows. The scoped persisted decision also updates the live config merge baseline, so subsequent visibility writes preserve manual re-enablement. A file-loaded inventory that already differs from disk can publish HTTP rows through a detached policy projection without adding unsaved live disables; retained catalog writes still refuse stale inventory, and its load provenance follows the [config contract](config.md). -OAuth refresh resolution in `src/oauth/index.ts` admits at most 256 pending consumers across the process, including callers joining an existing refresh. The separate 32-flight ceiling remains. Consumers retain their admission until the shared promise settles, even if their client already cancelled; fresh credential snapshots do not take this admission. Exhaustion raises the existing retryable, account-free refresh-capacity error, and initial Responses preparation and upstream-401 forced-refresh consumers answer 503 with a short Retry-After before response commitment; cancellation takes priority. An already-committed runTurn stream emits retryable capacity rather than an authentication failure. Stale owners are aborted even under waiter pressure; rejected admission retains their registry evidence, and admitted replacements wait for settlement and durable retirement before taking the registry slot. Post-dispatch uncertainty remains retryable without replay or false reauthentication; pre-dispatch intent can be cleared exactly. Retirement IO failure retains the bounded slot for a later retry, including after a backward clock adjustment. Deferred durable retirement logs once per flight with a closed reason and provider only; no account or credential identity is emitted. Retirement waits for definitive provider-result cleanup, preserving late successful rotation or explicit rejection. This bounds pending resolution count, not request bytes, refresh duration, or arbitrary cancelled work. The release-on-cancel turn contract is unchanged. `tests/oauth/oauth-refresh-generic-lock.test.ts` covers cancelled consumers, rejection cleanup and cached credentials under pressure. `tests/oauth/oauth-refresh-stale-capacity.test.ts` covers saturated and unsaturated stale-owner retirement and late settlement; transport coverage is in `tests/responses/responses-oauth-refresh-capacity.test.ts`. OAuth refresh coordination follows the [refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a fresh unreadable lock remains held, and release requires matching descriptor identity. A failed path-identity probe preserves the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. +OAuth refresh resolution in `src/oauth/index.ts` admits at most 256 pending consumers across the process, including callers joining an existing refresh. The separate 32-flight ceiling remains. Known pre-dispatch discovery cancellation follows the [independent-consumer retry contract](providers/generic-account-pool.md#combo-sibling-probes): one new resolution starts only after its previous waiter permit is released. Consumers retain their admission until the shared promise settles, even if their client already cancelled; fresh credential snapshots do not take this admission. Exhaustion raises the existing retryable, account-free refresh-capacity error, and initial Responses preparation and upstream-401 forced-refresh consumers answer 503 with a short Retry-After before response commitment; cancellation takes priority. An already-committed runTurn stream emits retryable capacity rather than an authentication failure. Stale owners are aborted even under waiter pressure; rejected admission retains their registry evidence, and admitted replacements wait for settlement and durable retirement before taking the registry slot. Post-dispatch uncertainty remains retryable without replay or false reauthentication; pre-dispatch intent can be cleared exactly. Retirement IO failure retains the bounded slot for a later retry, including after a backward clock adjustment. Deferred durable retirement logs once per flight with a closed reason and provider only; no account or credential identity is emitted. Retirement waits for definitive provider-result cleanup, preserving late successful rotation or explicit rejection. This bounds pending resolution count, not request bytes, refresh duration, or arbitrary cancelled work. The release-on-cancel turn contract is unchanged. `tests/oauth/oauth-refresh-generic-lock.test.ts` covers cancelled consumers, rejection cleanup and cached credentials under pressure. `tests/oauth/oauth-refresh-stale-capacity.test.ts` covers saturated and unsaturated stale-owner retirement and late settlement; transport coverage is in `tests/responses/responses-oauth-refresh-capacity.test.ts`. OAuth refresh coordination follows the [refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a fresh unreadable lock remains held, and release requires matching descriptor identity. A failed path-identity probe preserves the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Cursor's localized native-shell names follow the [routing-commentary guard contract](providers/cursor.md#cursor-native-exec). diff --git a/tests/oauth/oauth-refresh-stale-capacity.test.ts b/tests/oauth/oauth-refresh-stale-capacity.test.ts index 5a0706c80a6..a13dbc22e3c 100644 --- a/tests/oauth/oauth-refresh-stale-capacity.test.ts +++ b/tests/oauth/oauth-refresh-stale-capacity.test.ts @@ -4,13 +4,14 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { getValidAccessTokenForAccount, + getValidAccessSnapshotForAccount, MAX_OAUTH_TOKEN_REFRESH_WAITERS, OAuthTokenRefreshBusyError, OAuthTokenRefreshStaleError, OAUTH_PROVIDERS, seedOAuthTokenRefreshFlightsForTests, } from "../../src/oauth"; -import { credentialGeneration, getAccountCredential, getAccountSet, readOAuthRefreshIntent, +import { createOAuthRefreshIntentLock, credentialGeneration, getAccountCredential, getAccountSet, readOAuthRefreshIntent, saveCredential, writeOAuthRefreshIntent } from "../../src/oauth/store"; import { AnthropicTokenError } from "../../src/oauth/anthropic"; import * as store from "../../src/oauth/store"; @@ -43,6 +44,39 @@ async function seed(provider: "anthropic" | "kimi") { return getAccountSet(provider)!.activeAccountId; } +test("an independent resolver does not retry an unknown stale refresh outcome", async () => { + const id = await seed("kimi"); + const unknown = new OAuthTokenRefreshStaleError(); + let calls = 0; + OAUTH_PROVIDERS.kimi!.refresh = async () => { calls++; throw unknown; }; + await expect(getValidAccessSnapshotForAccount("kimi", id)).rejects.toBe(unknown); + expect(calls).toBe(1); + expect(getAccountCredential("kimi", id)?.access).toBe("fixture-old"); + expect(getAccountSet("kimi")!.accounts.find(a => a.id === id)?.needsReauth).not.toBe(true); +}); + +test("saturated independent waiters release admission before replacing a cancelled discovery owner", async () => { + const id = await seed("kimi"); + const guard = await createOAuthRefreshIntentLock("kimi", id).acquire(); + let current = true, calls = 0; + OAUTH_PROVIDERS.kimi!.refresh = async () => { + calls++; + return { access: "fixture-independent-new", refresh: "fixture-independent-next", expires: Date.now() + 3_600_000 }; + }; + const owner = getValidAccessSnapshotForAccount("kimi", id, { authorityCurrent: () => current }).catch(error => error); + const waiters = Array.from({ length: MAX_OAUTH_TOKEN_REFRESH_WAITERS - 1 }, () => + getValidAccessSnapshotForAccount("kimi", id).catch(error => error)); + try { + await expect(getValidAccessSnapshotForAccount("kimi", id)).rejects.toBeInstanceOf(OAuthTokenRefreshBusyError); + current = false; guard.release(); + expect(await owner).toBeInstanceOf(OAuthTokenRefreshStaleError); + const snapshots = await Promise.all(waiters); + expect(snapshots.every(row => row.accessToken === "fixture-independent-new" && row.accountId === id)).toBe(true); + expect(calls).toBe(1); + expect(getAccountCredential("kimi", id)?.refresh).toBe("fixture-independent-next"); + } finally { guard.release(); await Promise.allSettled([owner, ...waiters]); } +}); + // A rejected admission must not discard the stale owner's generation-bound intent evidence. test("saturated stale post-dispatch owner remains retryable after all 256 consumers settle", async () => { const id = await seed("anthropic"); diff --git a/tests/server/server-antigravity-model-roster-routing.test.ts b/tests/server/server-antigravity-model-roster-routing.test.ts index 1dc070b6ee0..540f1844cda 100644 --- a/tests/server/server-antigravity-model-roster-routing.test.ts +++ b/tests/server/server-antigravity-model-roster-routing.test.ts @@ -3,12 +3,14 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; +import { clearModelCache } from "../../src/codex/model-cache"; +import * as oauth from "../../src/oauth"; import { antigravityModelUnlistedMessage, antigravitySelectedAccountUnlistedMessage, clearGenericFailoverHealth, } from "../../src/oauth/generic-account-failover"; -import { getAccountSet, saveCredential, setAccountPaused, setActiveAccount, getAccountCredentialWithStatus, mergeAccountCredential } from "../../src/oauth/store"; +import { createOAuthRefreshIntentLock, getAccountSet, saveCredential, setAccountPaused, setActiveAccount, getAccountCredentialWithStatus, mergeAccountCredential } from "../../src/oauth/store"; import { clearPoolRotationState } from "../../src/oauth/pool-kernel"; import { antigravityAccountSupportsModel, clearAntigravityAccountRosters, recordAntigravityAccountRoster } from "../../src/providers/antigravity-account-roster"; import { handleResponses } from "../../src/server/responses"; @@ -108,6 +110,50 @@ async function turn(cfg: OcxConfig, options: Parameters[ const bearer = (index: number) => `Bearer synthetic-${["one", "two", "three"][index]}`; +test("an independent request retries a discovery-owned refresh cancelled before dispatch", async () => { + const cfg = config(true); + const id = ids[1]!; + const prior = getAccountCredentialWithStatus(provider, id)!.credential; + await mergeAccountCredential(provider, id, { ...prior, expires: Date.now() - 1 }); + rosters(false, true, false); + const guard = await createOAuthRefreshIntentLock(provider, id).acquire(); + let current = true, joined!: () => void; + const atJoin = new Promise(resolve => { joined = resolve; }); + const discovery = oauth.getValidAccessSnapshotForAccount(provider, id, { + requireUsableAccount: true, authorityCurrent: () => current, + }).catch(error => error); + const originalResolve = oauth.getValidAccessSnapshotForAccount; + const resolver = spyOn(oauth, "getValidAccessSnapshotForAccount").mockImplementation((...args) => { + const result = originalResolve(...args); + if (args[1] === id && !args[2]?.authorityCurrent) joined(); + return result; + }); + let tokenRequests = 0; + const projects: unknown[] = []; + const fixtureFetch = globalThis.fetch; + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.href === "https://oauth2.googleapis.com/token") tokenRequests++; + if (url.pathname.endsWith(":generateContent")) projects.push(JSON.parse(String(init?.body)).project); + return fixtureFetch(input, init); + }) as typeof fetch; + const request = turn(cfg); + try { + await atJoin; + expect(tokenRequests).toBe(0); + current = false; + clearModelCache(provider); + expect(antigravityAccountSupportsModel(provider, ids[0]!, model)).toBe(false); + guard.release(); + expect(await discovery).toBeInstanceOf(oauth.OAuthTokenRefreshStaleError); + expect((await request).status).toBe(200); + expect(tokenRequests).toBe(1); + expect(sends).toEqual(["Bearer synthetic-refreshed"]); + expect(projects).toEqual([prior.projectId]); + expect(getAccountCredentialWithStatus(provider, id)!.needsReauth).toBe(false); + } finally { guard.release(); await Promise.allSettled([request, discovery]); resolver.mockRestore(); clearModelCache(); } +}); + test("an active account that lists the model, or has no roster yet, is used unchanged", async () => { rosters(undefined, true, true); expect((await turn(config(true))).status).toBe(200); From 024c236c79398c43ec275d146817b311e8e47c76 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 15:26:39 +0900 Subject: [PATCH 5/8] refactor(oauth): move OAuth error classes into oauth-errors module Pure move to keep src/oauth/index.ts under the file-size ratchet; index re-exports the public classes. --- src/oauth/index.ts | 66 ++------------------------------------- src/oauth/oauth-errors.ts | 64 +++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 64 deletions(-) create mode 100644 src/oauth/oauth-errors.ts diff --git a/src/oauth/index.ts b/src/oauth/index.ts index df784361d80..6b12440e27d 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1,4 +1,3 @@ -import { errorDestinationText, providerErrorDestinationLabel } from "../lib/error-destination"; import { MuseLoginError, museLoginErrorMessage } from "./meta-muse-errors"; import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types"; import { mapFill } from "../providers/resolved-model-policy-merge"; @@ -451,69 +450,8 @@ export function getOAuthCredentialApiBaseUrl(provider: string): string | undefin export function listOAuthProviders(): string[] { return Object.keys(OAUTH_PROVIDERS).filter(isPublicOAuthProvider); } -export class UnsupportedOAuthProviderError extends Error { - constructor(provider: string) { - super(`Unsupported OAuth provider in config: ${errorDestinationText(providerErrorDestinationLabel(provider))}`); - this.name = "UnsupportedOAuthProviderError"; - } -} - -export class OAuthLoginRequiredError extends Error { - readonly provider: string; - - constructor(provider: string) { - super(`Not logged in to ${errorDestinationText(providerErrorDestinationLabel(provider))}. Run: ocx login ${errorDestinationText(providerErrorDestinationLabel(provider))}`); - this.name = "OAuthLoginRequiredError"; - this.provider = provider; - } -} - -/** An operator-paused account is temporarily unavailable, not an invalid login. */ -export class OAuthAccountPausedError extends Error { - constructor() { - super("OAuth account is paused. Resume it in account settings and retry."); - this.name = "OAuthAccountPausedError"; - } -} - -export class OAuthProviderPublicationError extends Error { - constructor(provider?: string, credentialWritten = true) { - super(provider !== "anthropic2" ? "OAuth credential was saved, but the provider entry was not written. Resolve the account namespace collision, then retry login." - : credentialWritten ? "Anthropic Pool 2 credential was saved as an orphan auth row, but the provider entry was not written. Repair the persisted config or resolve the provider/account namespace collision, then retry login." - : "Anthropic Pool 2 login did not start because the config file is missing or could not be written; no credential was saved. Repair the persisted config, then retry login."); - this.name = "OAuthProviderPublicationError"; - } -} - -/** A local configuration conflict found before any credential is written; its text is fixed. */ -export class OAuthProviderNamespaceCollisionError extends Error { - constructor(message: string) { - super(message); - this.name = "OAuthProviderNamespaceCollisionError"; - } -} - -export class OAuthReauthIdentityMismatchError extends Error { - constructor() { - super("Signed-in account does not match the selected account. Sign in with the same account."); - this.name = "OAuthReauthIdentityMismatchError"; - } -} - -export class OAuthReauthIdentityUnverifiedError extends Error { - constructor() { - super("Could not verify signed-in account identity for reauth."); - this.name = "OAuthReauthIdentityUnverifiedError"; - } -} - -class OAuthLoginSupersededError extends Error { - constructor() { - super("OAuth login was superseded before credential persistence"); - this.name = "OAuthLoginSupersededError"; - } -} - +import { OAuthLoginSupersededError, OAuthAccountPausedError, OAuthLoginRequiredError, OAuthProviderNamespaceCollisionError, OAuthProviderPublicationError, OAuthReauthIdentityMismatchError, OAuthReauthIdentityUnverifiedError, UnsupportedOAuthProviderError } from "./oauth-errors"; +export { OAuthAccountPausedError, OAuthLoginRequiredError, OAuthProviderNamespaceCollisionError, OAuthProviderPublicationError, OAuthReauthIdentityMismatchError, OAuthReauthIdentityUnverifiedError, UnsupportedOAuthProviderError } from "./oauth-errors"; /** Project arbitrary OAuth failures onto the small, stable public error vocabulary. */ export function publicOAuthAuthenticationErrorMessage(error: unknown): string { if (error instanceof AntigravityProjectDiscoveryError) return error.publicMessage; diff --git a/src/oauth/oauth-errors.ts b/src/oauth/oauth-errors.ts new file mode 100644 index 00000000000..6538f535fc6 --- /dev/null +++ b/src/oauth/oauth-errors.ts @@ -0,0 +1,64 @@ +import { errorDestinationText, providerErrorDestinationLabel } from "../lib/error-destination"; + +export class UnsupportedOAuthProviderError extends Error { + constructor(provider: string) { + super(`Unsupported OAuth provider in config: ${errorDestinationText(providerErrorDestinationLabel(provider))}`); + this.name = "UnsupportedOAuthProviderError"; + } +} + +export class OAuthLoginRequiredError extends Error { + readonly provider: string; + + constructor(provider: string) { + super(`Not logged in to ${errorDestinationText(providerErrorDestinationLabel(provider))}. Run: ocx login ${errorDestinationText(providerErrorDestinationLabel(provider))}`); + this.name = "OAuthLoginRequiredError"; + this.provider = provider; + } +} + +/** An operator-paused account is temporarily unavailable, not an invalid login. */ +export class OAuthAccountPausedError extends Error { + constructor() { + super("OAuth account is paused. Resume it in account settings and retry."); + this.name = "OAuthAccountPausedError"; + } +} + +export class OAuthProviderPublicationError extends Error { + constructor(provider?: string, credentialWritten = true) { + super(provider !== "anthropic2" ? "OAuth credential was saved, but the provider entry was not written. Resolve the account namespace collision, then retry login." + : credentialWritten ? "Anthropic Pool 2 credential was saved as an orphan auth row, but the provider entry was not written. Repair the persisted config or resolve the provider/account namespace collision, then retry login." + : "Anthropic Pool 2 login did not start because the config file is missing or could not be written; no credential was saved. Repair the persisted config, then retry login."); + this.name = "OAuthProviderPublicationError"; + } +} + +/** A local configuration conflict found before any credential is written; its text is fixed. */ +export class OAuthProviderNamespaceCollisionError extends Error { + constructor(message: string) { + super(message); + this.name = "OAuthProviderNamespaceCollisionError"; + } +} + +export class OAuthReauthIdentityMismatchError extends Error { + constructor() { + super("Signed-in account does not match the selected account. Sign in with the same account."); + this.name = "OAuthReauthIdentityMismatchError"; + } +} + +export class OAuthReauthIdentityUnverifiedError extends Error { + constructor() { + super("Could not verify signed-in account identity for reauth."); + this.name = "OAuthReauthIdentityUnverifiedError"; + } +} + +export class OAuthLoginSupersededError extends Error { + constructor() { + super("OAuth login was superseded before credential persistence"); + this.name = "OAuthLoginSupersededError"; + } +} From e502956213126e35a350373fa34c099833017e65 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 15:56:06 +0900 Subject: [PATCH 6/8] fix(antigravity): stop sibling discovery when the active account loses authority Sibling discovery now rechecks the active account's needsReauth, pause, expiry and credential generation before each sibling refresh and send, not only the model-cache generation. --- src/codex/catalog/antigravity-discovery.ts | 9 ++++++ src/codex/catalog/provider-models.ts | 14 ++++---- structure/catalog.md | 2 +- .../google/antigravity-routing-carry.test.ts | 32 +++++++++++++++++++ 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/codex/catalog/antigravity-discovery.ts b/src/codex/catalog/antigravity-discovery.ts index 3792e807dc6..0fefcc1052d 100644 --- a/src/codex/catalog/antigravity-discovery.ts +++ b/src/codex/catalog/antigravity-discovery.ts @@ -8,6 +8,15 @@ import { captureAntigravityModelObservation, recordAntigravityAccountRoster } fr import type { OcxProviderConfig } from "../../types"; import { materializeCapturedHeaders, type CapturedModelsRequest } from "./gather-capture"; +/** True while the active account still holds the exact usable credential discovery sent. */ +export function antigravityActiveAccountCurrent( + name: string, accountId: string, accessToken: string | undefined, generation: string | undefined, +): boolean { + const row = getAccountCredentialWithStatus(name, accountId); + return !!row && !row.paused && !row.needsReauth && row.credential.expires > Date.now() + && row.credential.access === accessToken && credentialGeneration(row.credential) === generation; +} + /** A sibling contributes only while the exact snapshot sent is still usable. */ export async function discoverAntigravitySiblings( name: string, provider: OcxProviderConfig, activeAccountId: string, diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 9cdeee5b011..a7f3dfe6f9d 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -54,7 +54,7 @@ import type { FastPolicyAuthority } from "../../providers/fastwire"; import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../providers/registry"; import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models"; import { captureAntigravityModelObservation, recordAntigravityAccountRoster } from "../../providers/antigravity-account-roster"; -import { discoverAntigravitySiblings } from "./antigravity-discovery"; +import { antigravityActiveAccountCurrent, discoverAntigravitySiblings } from "./antigravity-discovery"; import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; import { effectiveModelAliases } from "../../providers/default-aliases"; @@ -794,16 +794,14 @@ export async function fetchProviderModelsWithAuth( } if (antigravity) { if (getConfigDir() !== metadataConfigDir) return observed(withConfiguredRetention(configured), "degraded"); + // A needsReauth/replacement of the active account stops every later sibling refresh and send. + const activeCurrent = () => isCurrentCacheGeneration() && (!auth.oauthAccountId + || antigravityActiveAccountCurrent(name, auth.oauthAccountId, apiKey, auth.oauthGeneration)); const admitSiblings = resolveAuth.kind === "refreshing" && name === "google-antigravity" && prov.authMode === "oauth" && auth.oauthAccountId - ? await discoverAntigravitySiblings(name, prov, auth.oauthAccountId, request, discovery.maxResponseBytes, discovery.maxModels, metadataConfigDir, isCurrentCacheGeneration) + ? await discoverAntigravitySiblings(name, prov, auth.oauthAccountId, request, discovery.maxResponseBytes, discovery.maxModels, metadataConfigDir, activeCurrent) : () => []; - if (!isCurrentCacheGeneration() || getConfigDir() !== metadataConfigDir) return observed(withConfiguredRetention(configured), "degraded"); - const activeAccount = auth.oauthAccountId ? getAccountCredentialWithStatus(name, auth.oauthAccountId) : null; - if (auth.oauthAccountId && (!activeAccount || activeAccount.paused || activeAccount.needsReauth - || activeAccount.credential.expires <= Date.now() || activeAccount.credential.access !== apiKey - || credentialGeneration(activeAccount.credential) !== auth.oauthGeneration)) - return observed(withConfiguredRetention(configured), "degraded"); + if (!activeCurrent() || getConfigDir() !== metadataConfigDir) return observed(withConfiguredRetention(configured), "degraded"); const byId = new Map(antigravity.map(model => [model.id, model])); for (const model of admitSiblings()) if (!byId.has(model.id)) byId.set(model.id, model); const merged = [...byId.values()]; diff --git a/structure/catalog.md b/structure/catalog.md index 7615e70b6ef..2a8fc809bd8 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -25,7 +25,7 @@ validation-pending. Import alone supplies no entitlement evidence for the model `src/server/models-capabilities.ts` preserves the legacy `/v1/models` negative reasoning signal for an explicit empty effort ladder when no reasoning, thinking-toggle or effort capability is declared. Explicit capability axes take precedence; a declared thinking toggle or reasoning support remains available with no effort rungs. This compatibility projection does not change Codex ladders, Claude thinking discovery, client exports or request wire policy. Vision-sidecar attachment metadata is projected after provider discovery/cache reads in `src/codex/catalog/routed-gather.ts`. The gather captures `visionSidecar.enabled !== false` before awaiting discovery, and `src/codex/catalog/gather-capture.ts` includes that effective flag in the flight key. Provider TTL/stale rows exclude synthetic sidecar image input; custom rows and synthesized combo members use the same captured switch. Explicit/native image modalities remain intact, and enabled advertisement retains the existing config-based consumer predicate without probing credential availability. An effective Vision switch change through `src/server/management/config-routes.ts` invokes the catalog-only management funnel after persistence to publish both catalog and cache; incomplete work is exposed as `catalogRefreshPending` without claiming client reload. Coverage: `tests/codex-integration/catalog-vision-sidecar-disabled.test.ts` and `tests/vision/sidecar-settings-vision-catalog.test.ts`. Custom-provider missing windows use [source-labelled vendor estimates](catalog-vendor-context.md) only when the provider opts in with `inferContextWindow: true`; these remain distinct from asserted routing capacity. Registry context seed capture preserves [configured window precedence](catalog-vendor-context.md): provider-wide limits bound seed defaults, explicit per-model windows win, and smaller observed limits remain authoritative. -Static policy and observed catalog evidence are separate authorities. Antigravity grouping and selection projection follow the [discovered effort-family contract](providers-and-adapters.md#antigravity-effort-families). Refreshing Antigravity discovery unions enabled models from usable accounts through `src/codex/catalog/antigravity-discovery.ts`; credential resolution, final-send, and publication checks retain each sibling's config-root, provider cache-generation, and full credential snapshot authority. A sibling waiting for its OAuth refresh lock rechecks root/cache authority before token dispatch; an already-dispatched rotation still persists under the credential CAS. Each account publishes its own roster; active and sibling discovery capture observation order before sending, so delayed publication cannot erase a later inference denial. Active publication also rechecks the captured credential generation. An observed-only gather does not probe siblings. +Static policy and observed catalog evidence are separate authorities. Antigravity grouping and selection projection follow the [discovered effort-family contract](providers-and-adapters.md#antigravity-effort-families). Refreshing Antigravity discovery unions enabled models from usable accounts through `src/codex/catalog/antigravity-discovery.ts`; credential resolution, final-send, and publication checks retain each sibling's config-root, provider cache-generation, and full credential snapshot authority. That authority also requires the active account to remain usable with the credential discovery sent, so a needsReauth, pause, expiry or replacement of the active account stops later sibling refreshes and sends (`tests/adapters/google/antigravity-routing-carry.test.ts`). A sibling waiting for its OAuth refresh lock rechecks root/cache authority before token dispatch; an already-dispatched rotation still persists under the credential CAS. Each account publishes its own roster; active and sibling discovery capture observation order before sending, so delayed publication cannot erase a later inference denial. Active publication also rechecks the captured credential generation. An observed-only gather does not probe siblings. `src/providers/resolved-model-policy.ts` resolves and freezes only registry/operator static facts, hard wire pins, aliases, and explicit false/empty declarations. Discovery responses, generated metadata, cache freshness, availability, credentials, account state, quota and health never enter diff --git a/tests/adapters/google/antigravity-routing-carry.test.ts b/tests/adapters/google/antigravity-routing-carry.test.ts index b1ff17883d6..fdefb292e14 100644 --- a/tests/adapters/google/antigravity-routing-carry.test.ts +++ b/tests/adapters/google/antigravity-routing-carry.test.ts @@ -6,6 +6,7 @@ import { gatherRoutedModels } from "../../../src/codex/catalog"; import { clearModelCache } from "../../../src/codex/model-cache"; import * as outbound from "../../../src/lib/provider-outbound"; import * as oauth from "../../../src/oauth"; +import type { OAuthProviderDef } from "../../../src/oauth"; import { createOAuthRefreshIntentLock, credentialGeneration, getAccountCredentialWithStatus } from "../../../src/oauth/store"; import { preferredInitialAccount, routeAntigravityModel, clearGenericFailoverHealth } from "../../../src/oauth/generic-account-failover"; import { @@ -105,6 +106,37 @@ test("revoking discovery authority stops later sibling bearer sends", async () = } finally { releaseSibling(); await pending; resolver.mockRestore(); } }); +test("a terminal refresh failure on the active account stops later sibling bearer sends", async () => { + const { cfg } = setup(); + const resolver = spyOn(oauth, "getValidAccessSnapshotForAccount"); + let releaseSibling!: () => void; + let siblingSent!: () => void; + const atSibling = new Promise(resolve => { siblingSent = resolve; }); + const held = new Promise(resolve => { releaseSibling = resolve; }); + const sends: string[] = []; + globalThis.fetch = (async (_input, init) => { + const auth = new Headers(init?.headers).get("authorization")!; + sends.push(auth); + if (auth.endsWith("-b")) { siblingSent(); await held; } + return Response.json(payload("model-before-reauth")); + }) as typeof fetch; + const pending = gatherRoutedModels(cfg); + try { + await atSibling; + // The real terminal-refresh path marks needsReauth without touching the model cache. + const revoked = { refresh: async () => { throw new Error("invalid_grant"); } } as unknown as OAuthProviderDef; + const active = getAccountCredentialWithStatus(provider, "a")!.credential; + await expect(oauth.refreshGenericAccountWithLock(provider, "a", revoked, active)).rejects.toBeInstanceOf(oauth.OAuthLoginRequiredError); + expect(getAccountCredentialWithStatus(provider, "a")!.needsReauth).toBe(true); + releaseSibling(); + const rows = await pending; + expect(sends).toEqual(["Bearer synthetic-a", "Bearer synthetic-b"]); + expect(resolver.mock.calls.some(([, accountId]) => accountId === "c")).toBe(false); + expect(rows.some(row => row.id === "model-before-reauth")).toBe(false); + expect(antigravityAccountRosterModelIds(provider, "c")).toBeUndefined(); + } finally { releaseSibling(); await pending; resolver.mockRestore(); } +}); + for (const revoke of [true, false]) test(`sibling refresh rechecks discovery authority after waiting for its lock (revoke=${revoke})`, async () => { const { cfg, store, home } = setup(); store[provider].accounts[2]!.credential.expires = Date.now() - 1; From 109a712a9150a3ff2bd22566ef7e15610d651697 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 16:06:22 +0900 Subject: [PATCH 7/8] fix(oauth): recheck needsReauth under the refresh lock for usable-account callers A caller that requires a usable account checked needsReauth only before waiting for the per-account refresh lock. A sibling marked needsReauth while waiting could still send its stored refresh token, and a successful refresh then cleared the mark and returned a bearer for model discovery. The generic and xAI lock paths now recheck needsReauth for such callers, the resolver rechecks before adopting a refresh result, and an unrestricted caller that joined a usable-only refresh retries on its own. --- src/oauth/index.ts | 33 +++++++++----- src/oauth/oauth-errors.ts | 3 ++ structure/catalog.md | 2 +- .../google/antigravity-routing-carry.test.ts | 44 ++++++++++++++++++- .../oauth/oauth-refresh-generic-lock.test.ts | 17 ++++++- 5 files changed, 86 insertions(+), 13 deletions(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 6b12440e27d..ea3f4bcc7aa 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -144,7 +144,7 @@ export type OAuthActiveTokenObservation = const MAX_OAUTH_TOKEN_REFRESH_FLIGHTS = 32; const OAUTH_TOKEN_REFRESH_FLIGHT_STALE_MS = 120_000; -interface OAuthTokenRefreshFlight extends OAuthRefreshFlightEvidence { promise: Promise; startedAt: number; abort: AbortController; settled?: boolean; retirementDeferredLogged?: boolean } +interface OAuthTokenRefreshFlight extends OAuthRefreshFlightEvidence { promise: Promise; startedAt: number; abort: AbortController; settled?: boolean; retirementDeferredLogged?: boolean; requireUsable?: boolean } const tokenRefreshes = new Map(); export class OAuthTokenRefreshBusyError extends Error { readonly code = "OAUTH_TOKEN_REFRESH_BUSY"; @@ -189,9 +189,9 @@ 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 XaiRefreshDeps { intentLock?:ReturnType; now?:()=>number; afterPrePersistRead?:()=>void|Promise; signal?: AbortSignal; requireUsableAccount?: boolean } 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; authorityCurrent?: () => boolean } +interface GenericRefreshDeps { intentLock?:ReturnType; afterPrePersistRead?:()=>void|Promise; signal?: AbortSignal; authorityCurrent?: () => boolean; requireUsableAccount?: boolean } 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;} export function sweepExpiredXaiPermanentFailureVerdicts(now=Date.now()):number{let removed=0;for(const[key,until]of permanentRefreshFailures){if(until>now)continue;permanentRefreshFailures.delete(key);removed+=1;}return removed;} @@ -450,7 +450,7 @@ export function getOAuthCredentialApiBaseUrl(provider: string): string | undefin export function listOAuthProviders(): string[] { return Object.keys(OAUTH_PROVIDERS).filter(isPublicOAuthProvider); } -import { OAuthLoginSupersededError, OAuthAccountPausedError, OAuthLoginRequiredError, OAuthProviderNamespaceCollisionError, OAuthProviderPublicationError, OAuthReauthIdentityMismatchError, OAuthReauthIdentityUnverifiedError, UnsupportedOAuthProviderError } from "./oauth-errors"; +import { OAuthAccountUnusableError, OAuthLoginSupersededError, OAuthAccountPausedError, OAuthLoginRequiredError, OAuthProviderNamespaceCollisionError, OAuthProviderPublicationError, OAuthReauthIdentityMismatchError, OAuthReauthIdentityUnverifiedError, UnsupportedOAuthProviderError } from "./oauth-errors"; export { OAuthAccountPausedError, OAuthLoginRequiredError, OAuthProviderNamespaceCollisionError, OAuthProviderPublicationError, OAuthReauthIdentityMismatchError, OAuthReauthIdentityUnverifiedError, UnsupportedOAuthProviderError } from "./oauth-errors"; /** Project arbitrary OAuth failures onto the small, stable public error vocabulary. */ export function publicOAuthAuthenticationErrorMessage(error: unknown): string { @@ -603,6 +603,7 @@ async function resolveAccessSnapshotForAccount( // no replacement starts until the old consumers have actually released enough capacity. const waiter = tokenRefreshWaiters.tryAcquire(); if (!waiter) throw new OAuthTokenRefreshBusyError(); + let unrestrictedRetry = false; try { if (existing && replacedStaleFlight) { // A replacement would wait for this owner's lock anyway. Keep its registry slot @@ -617,7 +618,10 @@ async function resolveAccessSnapshotForAccount( } if (existing) { logOAuthEvent("OAuth refresh joined existing operation", { provider, accountId }); - return await existing.promise; + const joined = await existing.promise; + // The owner may not have required usability; a mark that landed meanwhile still rejects us. + if (requireUsableAccount && getAccountCredentialWithStatus(provider, accountId)?.needsReauth) throw new OAuthAccountUnusableError(provider); + return joined; } if (tokenRefreshes.size >= MAX_OAUTH_TOKEN_REFRESH_FLIGHTS) { for (const [staleKey, stale] of tokenRefreshes) { @@ -633,12 +637,14 @@ async function resolveAccessSnapshotForAccount( abort, flightId: randomUUID(), dispatched: false, + requireUsable: requireUsableAccount, }; const refresh = (async (): Promise => { - const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred, abort.signal, flight, replacedStaleFlight, authorityCurrent); + const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred, abort.signal, flight, replacedStaleFlight, authorityCurrent, requireUsableAccount); const persistedRow = getAccountCredentialWithStatus(provider, accountId); if (!persistedRow) throw new OAuthLoginRequiredError(provider); if (persistedRow.paused === true) throw new OAuthAccountPausedError(); + if (requireUsableAccount && persistedRow.needsReauth) throw new OAuthAccountUnusableError(provider); const persisted = persistedRow.credential; if (persisted.access !== accessToken) { throw new Error(`OAuth refresh persisted an unexpected access token for ${provider}`); @@ -658,8 +664,11 @@ async function resolveAccessSnapshotForAccount( tokenRefreshes.set(key, flight); return await refresh; } catch (error) { - if (authorityCurrent || !retryCancelledDiscovery || !(error instanceof OAuthDiscoveryRefreshCancelledError)) throw error; + // An unrestricted caller that joined a usable-only owner is not bound by that owner's rejection. + if (!requireUsableAccount && error instanceof OAuthAccountUnusableError) unrestrictedRetry = true; + else if (authorityCurrent || !retryCancelledDiscovery || !(error instanceof OAuthDiscoveryRefreshCancelledError)) throw error; } finally { waiter.release(); } + if (unrestrictedRetry) return resolveAccessSnapshotForAccount(provider, accountId, rejectedGeneration, false, oauthProvider, authorityCurrent, retryCancelledDiscovery); return resolveAccessSnapshotForAccount(provider, accountId, rejectedGeneration, requireUsableAccount, oauthProvider, undefined, false); } @@ -784,7 +793,7 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede ...(fresh.kiro === undefined && previous.kiro ? { kiro: previous.kiro } : {}), }; } -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();}} +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();if(deps.requireUsableAccount&&row.needsReauth)throw new OAuthAccountUnusableError(provider);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();}} /** @@ -975,6 +984,9 @@ export async function refreshGenericAccountWithLock( const row = getAccountCredentialWithStatus(provider, accountId); if (!row) throw new OAuthLoginRequiredError(provider); if (row.paused) throw new OAuthAccountPausedError(); + // A usable-account caller rechecks needsReauth here: a mark committed while it waited must + // stop the refresh before the stored refresh token is sent. + if (deps.requireUsableAccount && row.needsReauth) throw new OAuthAccountUnusableError(provider); const stored = row.credential; if ( credentialGeneration(stored) !== credentialGeneration(callerCredential) @@ -1085,10 +1097,11 @@ async function refreshAndPersistAccessToken( flight?: OAuthRefreshFlightEvidence, replacedStaleFlight?: OAuthRefreshFlightEvidence, authorityCurrent?: () => boolean, + requireUsableAccount = false, ): Promise { - if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred, { signal }); + if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred, { signal, requireUsableAccount }); if (isAnthropicOAuthInstance(provider)) return refreshAnthropicAccountWithLock(provider, accountId, def, cred, { signal, flight, replacedStaleFlight }); - return refreshGenericAccountWithLock(provider, accountId, def, cred, { signal, authorityCurrent }); + return refreshGenericAccountWithLock(provider, accountId, def, cred, { signal, authorityCurrent, requireUsableAccount }); } /** Resolve discovery credentials only for the caller-owned configured OAuth row. */ diff --git a/src/oauth/oauth-errors.ts b/src/oauth/oauth-errors.ts index 6538f535fc6..de4dc91aea4 100644 --- a/src/oauth/oauth-errors.ts +++ b/src/oauth/oauth-errors.ts @@ -17,6 +17,9 @@ export class OAuthLoginRequiredError extends Error { } } +/** A usable-account caller found needsReauth under the refresh lock; unrestricted joiners retry alone. */ +export class OAuthAccountUnusableError extends OAuthLoginRequiredError {} + /** An operator-paused account is temporarily unavailable, not an invalid login. */ export class OAuthAccountPausedError extends Error { constructor() { diff --git a/structure/catalog.md b/structure/catalog.md index 2a8fc809bd8..b918d2ba096 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -25,7 +25,7 @@ validation-pending. Import alone supplies no entitlement evidence for the model `src/server/models-capabilities.ts` preserves the legacy `/v1/models` negative reasoning signal for an explicit empty effort ladder when no reasoning, thinking-toggle or effort capability is declared. Explicit capability axes take precedence; a declared thinking toggle or reasoning support remains available with no effort rungs. This compatibility projection does not change Codex ladders, Claude thinking discovery, client exports or request wire policy. Vision-sidecar attachment metadata is projected after provider discovery/cache reads in `src/codex/catalog/routed-gather.ts`. The gather captures `visionSidecar.enabled !== false` before awaiting discovery, and `src/codex/catalog/gather-capture.ts` includes that effective flag in the flight key. Provider TTL/stale rows exclude synthetic sidecar image input; custom rows and synthesized combo members use the same captured switch. Explicit/native image modalities remain intact, and enabled advertisement retains the existing config-based consumer predicate without probing credential availability. An effective Vision switch change through `src/server/management/config-routes.ts` invokes the catalog-only management funnel after persistence to publish both catalog and cache; incomplete work is exposed as `catalogRefreshPending` without claiming client reload. Coverage: `tests/codex-integration/catalog-vision-sidecar-disabled.test.ts` and `tests/vision/sidecar-settings-vision-catalog.test.ts`. Custom-provider missing windows use [source-labelled vendor estimates](catalog-vendor-context.md) only when the provider opts in with `inferContextWindow: true`; these remain distinct from asserted routing capacity. Registry context seed capture preserves [configured window precedence](catalog-vendor-context.md): provider-wide limits bound seed defaults, explicit per-model windows win, and smaller observed limits remain authoritative. -Static policy and observed catalog evidence are separate authorities. Antigravity grouping and selection projection follow the [discovered effort-family contract](providers-and-adapters.md#antigravity-effort-families). Refreshing Antigravity discovery unions enabled models from usable accounts through `src/codex/catalog/antigravity-discovery.ts`; credential resolution, final-send, and publication checks retain each sibling's config-root, provider cache-generation, and full credential snapshot authority. That authority also requires the active account to remain usable with the credential discovery sent, so a needsReauth, pause, expiry or replacement of the active account stops later sibling refreshes and sends (`tests/adapters/google/antigravity-routing-carry.test.ts`). A sibling waiting for its OAuth refresh lock rechecks root/cache authority before token dispatch; an already-dispatched rotation still persists under the credential CAS. Each account publishes its own roster; active and sibling discovery capture observation order before sending, so delayed publication cannot erase a later inference denial. Active publication also rechecks the captured credential generation. An observed-only gather does not probe siblings. +Static policy and observed catalog evidence are separate authorities. Antigravity grouping and selection projection follow the [discovered effort-family contract](providers-and-adapters.md#antigravity-effort-families). Refreshing Antigravity discovery unions enabled models from usable accounts through `src/codex/catalog/antigravity-discovery.ts`; credential resolution, final-send, and publication checks retain each sibling's config-root, provider cache-generation, and full credential snapshot authority. That authority also requires the active account to remain usable with the credential discovery sent, so a needsReauth, pause, expiry or replacement of the active account stops later sibling refreshes and sends (`tests/adapters/google/antigravity-routing-carry.test.ts`). A sibling waiting for its OAuth refresh lock rechecks root/cache authority before token dispatch; an already-dispatched rotation still persists under the credential CAS. Because discovery resolves siblings with `requireUsableAccount`, the generic and xAI refresh paths in `src/oauth/index.ts` also recheck `needsReauth` under that lock, and the resolver rechecks it before adopting a refresh result, so a sibling marked for reauthentication while it waited sends neither its refresh token nor a bearer. Callers that do not require a usable account (explicit refresh, login, the token guardian) keep refreshing such an account; one that joined a usable-only refresh retries as its own owner. Each account publishes its own roster; active and sibling discovery capture observation order before sending, so delayed publication cannot erase a later inference denial. Active publication also rechecks the captured credential generation. An observed-only gather does not probe siblings. `src/providers/resolved-model-policy.ts` resolves and freezes only registry/operator static facts, hard wire pins, aliases, and explicit false/empty declarations. Discovery responses, generated metadata, cache freshness, availability, credentials, account state, quota and health never enter diff --git a/tests/adapters/google/antigravity-routing-carry.test.ts b/tests/adapters/google/antigravity-routing-carry.test.ts index fdefb292e14..052740119b5 100644 --- a/tests/adapters/google/antigravity-routing-carry.test.ts +++ b/tests/adapters/google/antigravity-routing-carry.test.ts @@ -7,7 +7,7 @@ import { clearModelCache } from "../../../src/codex/model-cache"; import * as outbound from "../../../src/lib/provider-outbound"; import * as oauth from "../../../src/oauth"; import type { OAuthProviderDef } from "../../../src/oauth"; -import { createOAuthRefreshIntentLock, credentialGeneration, getAccountCredentialWithStatus } from "../../../src/oauth/store"; +import { createOAuthRefreshIntentLock, credentialGeneration, getAccountCredentialWithStatus, markAccountNeedsReauth } from "../../../src/oauth/store"; import { preferredInitialAccount, routeAntigravityModel, clearGenericFailoverHealth } from "../../../src/oauth/generic-account-failover"; import { ANTIGRAVITY_MODEL_DENIAL_TTL_MS, antigravityAccountSupportsModel, antigravityAccountRosterModelIds, @@ -183,6 +183,48 @@ for (const revoke of [true, false]) test(`sibling refresh rechecks discovery aut } finally { guard.release(); await pending; resolver.mockRestore(); } }); +test("a sibling marked needsReauth while waiting for its refresh lock is neither refreshed nor sent", async () => { + const { cfg, store, home } = setup(); + store[provider].accounts[2]!.credential.expires = Date.now() - 1; + writeFileSync(join(home, "auth.json"), JSON.stringify(store)); + const before = getAccountCredentialWithStatus(provider, "c")!; + const guard = await createOAuthRefreshIntentLock(provider, "c").acquire(); + let entered!: () => void; + const atResolver = new Promise(resolve => { entered = resolve; }); + const originalResolver = oauth.getValidAccessSnapshotForAccount; + const resolver = spyOn(oauth, "getValidAccessSnapshotForAccount").mockImplementation((...args) => { + const pending = originalResolver(...args); + if (args[1] === "c") entered(); + return pending; + }); + let refreshes = 0; + const sends: string[] = []; + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url === "https://oauth2.googleapis.com/token") { + refreshes++; + return Response.json({ access_token: "synthetic-c-new", refresh_token: "synthetic-refresh-c-new", expires_in: 3600 }); + } + if (url.includes("/userinfo")) return Response.json({ email: "fixture@example.test" }); + if (url.includes("loadCodeAssist")) return Response.json({ cloudaicompanionProject: "project-c" }); + sends.push(new Headers(init?.headers).get("authorization")!); + return Response.json(payload("model-after-reauth")); + }) as typeof fetch; + const pending = gatherRoutedModels(cfg); + try { + await atResolver; + await markAccountNeedsReauth(provider, "c", true); + guard.release(); + await pending; + expect(refreshes).toBe(0); + expect(sends).toEqual(["Bearer synthetic-a", "Bearer synthetic-b"]); + const after = getAccountCredentialWithStatus(provider, "c")!; + expect(after.needsReauth).toBe(true); + expect(after.credential).toEqual(before.credential); + expect(antigravityAccountRosterModelIds(provider, "c")).toBeUndefined(); + } finally { guard.release(); await pending; resolver.mockRestore(); } +}); + test("active discovery held by a sibling cannot erase a newer inference denial", async () => { const { cfg } = setup(); let releaseSibling!: () => void; diff --git a/tests/oauth/oauth-refresh-generic-lock.test.ts b/tests/oauth/oauth-refresh-generic-lock.test.ts index c857b4a6cfa..d6804768606 100644 --- a/tests/oauth/oauth-refresh-generic-lock.test.ts +++ b/tests/oauth/oauth-refresh-generic-lock.test.ts @@ -15,7 +15,7 @@ import { } from "../../src/oauth"; import type { OAuthCredentials } from "../../src/oauth/types"; import { ChatGptTokenError, ChatGptTokenRequestError, refreshChatGPTToken } from "../../src/oauth/chatgpt"; -import { getAccountCredential, getAccountSet, saveCredential } from "../../src/oauth/store"; +import { createOAuthRefreshIntentLock, getAccountCredential, getAccountSet, markAccountNeedsReauth, saveCredential } from "../../src/oauth/store"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { getActiveTurnCount, tryAdmitTurn } from "../../src/server/lifecycle"; import { handleResponses } from "../../src/server/responses"; @@ -86,6 +86,21 @@ describe("generic OAuth refresh lock + CAS", () => { expect(getAccountCredential("kimi", accountId)?.refresh).toBe("rotated-refresh"); }); + test("a needsReauth mark while waiting for the lock rejects usable callers but not an unrestricted joiner", async () => { + const accountId = await seedExpiredKimi(); + const tracker = stubKimiRefresh(async () => ({ access: "kimi-fresh", refresh: "rotated-refresh", expires: Date.now() + 3_600_000 })); + const guard = await createOAuthRefreshIntentLock("kimi", accountId).acquire(); + const usable = getValidAccessSnapshotForAccount("kimi", accountId, { requireUsableAccount: true }); + const unrestricted = getValidAccessTokenForAccount("kimi", accountId); + await markAccountNeedsReauth("kimi", accountId, true); + guard.release(); + await expect(usable).rejects.toBeInstanceOf(OAuthLoginRequiredError); + // The unrestricted joiner retries as its own owner; a successful refresh clears the mark. + await expect(unrestricted).resolves.toBe("kimi-fresh"); + expect(tracker.calls()).toBe(1); + expect(getAccountSet("kimi")?.accounts.find(row => row.id === accountId)?.needsReauth).not.toBe(true); + }); + test("failed refresh clears single-flight so a later call can retry", async () => { const accountId = await seedExpiredKimi(); let refreshCalls = 0; From f9f9f26bdac38b9680ecbf4c56f6dc2bf8648156 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 3 Nov 2026 16:11:04 +0900 Subject: [PATCH 8/8] fix(oauth): keep a needsReauth mark set during an in-flight refresh --- src/oauth/index.ts | 4 +++- src/oauth/store.ts | 2 +- .../oauth/oauth-refresh-generic-lock.test.ts | 22 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index ea3f4bcc7aa..4e8e6393a71 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -793,7 +793,7 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede ...(fresh.kiro === undefined && previous.kiro ? { kiro: previous.kiro } : {}), }; } -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();if(deps.requireUsableAccount&&row.needsReauth)throw new OAuthAccountUnusableError(provider);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();}} +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();if(deps.requireUsableAccount&&row.needsReauth)throw new OAuthAccountUnusableError(provider);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,keepReauthMarkedAfterDispatch:!row.needsReauth});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();}} /** @@ -1003,6 +1003,8 @@ export async function refreshGenericAccountWithLock( const outcome = await mergeAccountCredential(provider, accountId, fresh, { expectedGeneration: generation, afterPrePersistRead: deps.afterPrePersistRead, + // A mark that landed after this flight read the row is newer than the refresh: keep it. + keepReauthMarkedAfterDispatch: !row.needsReauth, ...(provider === "devin" ? { assertOwnership: (store: AuthStore) => { if (store[provider]?.accounts.find(row => row.id === accountId)?.paused) throw new OAuthAccountPausedError(); assertDevinCliAdoptionOwnership(store, provider, accountId, refreshed); diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 20a4b418dbf..aebc881cc1c 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -1610,7 +1610,7 @@ export async function markAccountNeedsReauth( }, [provider, accountId]); } -export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise;assertOwnership?: (store: AuthStore) => void}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");if(isAnthropicInstanceId(provider))assertAnthropicCredentialSource(provider,safe);return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};opts.assertOwnership?.(store);account.credential=safe;delete account.refreshAttentionGeneration;if(account.needsReauthReason!=="verify_account"){delete account.needsReauth;delete account.needsReauthReason;}return{superseded:false};},[provider,accountId,safe,opts.expectedGeneration]);} +export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise;assertOwnership?: (store: AuthStore) => void;keepReauthMarkedAfterDispatch?:boolean}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");if(isAnthropicInstanceId(provider))assertAnthropicCredentialSource(provider,safe);return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};opts.assertOwnership?.(store);account.credential=safe;delete account.refreshAttentionGeneration;if(account.needsReauthReason!=="verify_account"&&!(opts.keepReauthMarkedAfterDispatch&&account.needsReauth)){delete account.needsReauth;delete account.needsReauthReason;}return{superseded:false};},[provider,accountId,safe,opts.expectedGeneration]);} // A late refresh failure must not change an operator-paused account's health. Check under // the mutation lock, not before awaiting it, so a concurrent pause cannot be overwritten. export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string,writerGeneration=captureConfigGeneration(),reason?:"verify_account",isCurrent?:(store:AuthStore)=>boolean):Promise{const key=oauthAccountKey(provider,accountId);if(writerGeneration{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||account.paused||credentialGeneration(account.credential)!==generation)return false;if(writerGeneration { expect(getAccountSet("kimi")?.accounts.find(row => row.id === accountId)?.needsReauth).not.toBe(true); }); + test("a needsReauth mark during a usable-only provider refresh survives the rotation and rejects the caller", async () => { + const accountId = await seedExpiredKimi(); + let entered!: () => void; + const inFlight = new Promise(resolve => { entered = resolve; }); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + stubKimiRefresh(async () => { + entered(); + await held; + return { access: "kimi-fresh", refresh: "rotated-refresh", expires: Date.now() + 3_600_000 }; + }); + const usable = getValidAccessSnapshotForAccount("kimi", accountId, { requireUsableAccount: true }); + await inFlight; + await markAccountNeedsReauth("kimi", accountId, true); + release(); + await expect(usable).rejects.toBeInstanceOf(OAuthLoginRequiredError); + const row = getAccountSet("kimi")?.accounts.find(candidate => candidate.id === accountId); + expect(row?.needsReauth).toBe(true); + // The rotated refresh token is still persisted; only the mark is kept. + expect(row?.credential.refresh).toBe("rotated-refresh"); + }); + test("failed refresh clears single-flight so a later call can retry", async () => { const accountId = await seedExpiredKimi(); let refreshCalls = 0;