diff --git a/src/codex/catalog/antigravity-discovery.ts b/src/codex/catalog/antigravity-discovery.ts index 97c78fe9e02..3792e807dc6 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,8 +30,10 @@ 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; - const snapshot = await getValidAccessSnapshotForAccount(name, sibling.id, { requireUsableAccount: true }); + if (getConfigDir() !== root || !authorityCurrent()) break; + 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/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 877e6b275b9..9cdeee5b011 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -796,7 +796,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/src/oauth/index.ts b/src/oauth/index.ts index fab4ef78aa8..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 }>): { @@ -188,7 +190,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 +633,8 @@ async function resolveAccessSnapshotForAccount( rejectedGeneration?: string, requireUsableAccount = false, oauthProvider = provider, + authorityCurrent?: () => boolean, + retryCancelledDiscovery = true, ): Promise { const def = OAUTH_PROVIDERS[oauthProvider]; if (!def) throw new UnsupportedOAuthProviderError(oauthProvider); @@ -692,7 +696,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(); @@ -714,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( @@ -772,9 +779,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 +1046,7 @@ export async function refreshGenericAccountWithLock( } const generation = credentialGeneration(stored); try { + 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, { @@ -1137,10 +1145,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 70b2219487e..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`; 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. 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-and-adapters.md b/structure/providers-and-adapters.md index 240a397f29b..41e2ed816d9 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/structure/providers/generic-account-pool.md b/structure/providers/generic-account-pool.md index 52905b19b79..abe7bb9f37a 100644 --- a/structure/providers/generic-account-pool.md +++ b/structure/providers/generic-account-pool.md @@ -26,6 +26,14 @@ 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. +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. `src/server/responses/core-combo.ts` asks `src/oauth/generic-account-failover.ts` whether a refused 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/adapters/google/antigravity-routing-carry.test.ts b/tests/adapters/google/antigravity-routing-carry.test.ts index 860d8a909a1..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, @@ -78,6 +79,78 @@ 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(); + 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-revocation")); + }) as typeof fetch; + const pending = gatherRoutedModels(cfg); + 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 () => { const { cfg } = setup(); let releaseSibling!: () => void; @@ -147,12 +220,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 }); @@ -177,6 +253,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"]); diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index 3ab33edae87..1595da1f9b4 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -25,6 +25,8 @@ import { firstPartyDesired } from "../../src/claude/first-party-settings"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; +import { setLocalCaWindowsAclRunnerForTests } from "../../src/claude/intercept/local-ca-windows"; +import { resolveCurrentWindowsPrincipal } from "../../src/lib/windows-user-principal"; let root = ""; let lifecycleLock: ReturnType; @@ -72,10 +74,23 @@ beforeEach(() => { process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; process.env.CLAUDE_CONFIG_DIR = claudeDir; writeFileSync(join(root, "config.json"), JSON.stringify(config())); + // The local-CA ACL inspection spawns PowerShell under the shared 30 s principal-lookup budget, + // which a loaded hosted Windows runner can exhaust (local_ca_path_unsafe). This file tests + // first-party apply; claude-intercept-local-ca.test.ts runs the native inspection. Report the + // private ACL for the current SID, which production resolves (and caches) before this runner. + if (process.platform === "win32") { + setLocalCaWindowsAclRunnerForTests((_path, timeoutMs) => { + const sid = resolveCurrentWindowsPrincipal(timeoutMs).replace(/^\*/, ""); + return { success: true, exitCode: 0, timedOut: false, stdout: JSON.stringify({ + owner: sid, protected: true, rules: [{ sid, type: 0, rights: 2032127 }], + }) }; + }); + } }); afterEach(async () => { - await flushConfigDirHardeningAndReaps(root); + try { await flushConfigDirHardeningAndReaps(root); } + finally { setLocalCaWindowsAclRunnerForTests(null); } lifecycleLock.restore(); for (const key of ENV_KEYS) { if (previous[key] === undefined) delete process.env[key]; 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);