diff --git a/src/codex/catalog/antigravity-discovery.ts b/src/codex/catalog/antigravity-discovery.ts index 97c78fe9e02..0fefcc1052d 100644 --- a/src/codex/catalog/antigravity-discovery.ts +++ b/src/codex/catalog/antigravity-discovery.ts @@ -8,19 +8,28 @@ 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, 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 +39,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..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) + ? 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/src/oauth/index.ts b/src/oauth/index.ts index 4ae3069f558..4e8e6393a71 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"; @@ -145,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"; @@ -157,6 +156,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,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 } +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;} @@ -449,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 { 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 { if (error instanceof AntigravityProjectDiscoveryError) return error.publicMessage; @@ -632,6 +572,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); @@ -661,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 @@ -675,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) { @@ -691,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); + 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}`); @@ -715,7 +663,13 @@ async function resolveAccessSnapshotForAccount( flight.promise = refresh; tokenRefreshes.set(key, flight); return await refresh; + } catch (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); } export async function getValidAccessTokenSnapshot( @@ -773,9 +727,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. */ @@ -839,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,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();}} /** @@ -1030,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) @@ -1040,11 +997,14 @@ 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, { 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); @@ -1138,10 +1098,12 @@ async function refreshAndPersistAccessToken( signal?: AbortSignal, 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 }); + 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 new file mode 100644 index 00000000000..de4dc91aea4 --- /dev/null +++ b/src/oauth/oauth-errors.ts @@ -0,0 +1,67 @@ +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; + } +} + +/** 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() { + 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"; + } +} 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 { + 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(); } +}); + +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; + 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("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; @@ -147,12 +294,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 +327,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/oauth/oauth-refresh-generic-lock.test.ts b/tests/oauth/oauth-refresh-generic-lock.test.ts index c857b4a6cfa..7b863260e75 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,43 @@ 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("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; 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 c0c5fb70df5..d190bf9b6f5 100644 --- a/tests/server/server-antigravity-model-roster-routing.test.ts +++ b/tests/server/server-antigravity-model-roster-routing.test.ts @@ -4,12 +4,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"; @@ -109,6 +111,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);