From 12817250690541d14e6fa2813e927b2ef5022050 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 02:30:37 +0000 Subject: [PATCH 01/15] fix(codex): retain keyring-backed main token --- src/codex/main-account.ts | 41 +++++++++++++++++-- structure/providers/openai-accounts.md | 4 +- .../native-main-refresh-mode.test.ts | 4 +- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 29595b8bffd..0849fc315d5 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -50,6 +50,7 @@ let mainAccountPlan: string | null = null; let jwtPlanAttempted = false; const MAIN_TOKEN_REFRESH_SKEW_MS = 60_000; let beforeMainAuthJsonRenameForTests: (() => void) | null = null; +let codexOwnedMainToken: { home: string; accessToken: string; chatgptAccountId: string } | null = null; type MainAuthJsonCredential = { path: string; @@ -246,7 +247,9 @@ export function getMainAccountCredentialStatus(now = Date.now()): MainAccountCre const current = readMainAuthJsonCredential(); const refreshGrantRejected = credentialRefreshGrantRejected(current); const hasRefreshGrant = !!current?.refreshToken && !refreshGrantRejected; - const usable = !refreshGrantRejected && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken, now, 0)); + const owned = current ? null : currentCodexOwnedMainToken(); + const usable = !refreshGrantRejected + && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken ?? owned?.accessToken, now, 0)); observeMainDecisionCredentialUsable(usable); return { refreshGrantRejected, @@ -454,10 +457,29 @@ async function resolveMainAccountToken( ): Promise<{ accessToken: string; chatgptAccountId: string } | null> { if (dependencies.signal?.aborted) throw dependencies.signal.reason; const initial = readMainAuthJsonCredential(); + const owned = initial ? null : currentCodexOwnedMainToken(); // Every skip below ends a forced refresh WITHOUT asking the token endpoint anything, which from // the outside is indistinguishable from a refusal: same failed request, no write, no verdict. // A forced refresh is the only caller that passes `rejectedAccessToken`, so only it reports. if (!initial) { + if (owned?.accessToken !== rejectedAccessToken + && mainAccessTokenFresh(owned?.accessToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) { + return owned; + } + const refreshMode = await resolveNativeMainRefreshMode(loadConfig()); + if (refreshMode.mode === "codex") { + const refreshTimeout = AbortSignal.timeout(30_000); + const signal = dependencies.signal + ? AbortSignal.any([dependencies.signal, refreshTimeout]) + : refreshTimeout; + return withNativeMainExclusiveClaim(resolveNativeProfileContext(), () => refreshThroughCodex( + null, + rejectedAccessToken, + dependencies, + refreshTimeout, + dependencies.codexAuthStatus ?? (options => requestCodexAuthStatus(refreshMode.command!, options)), + ), { waitMs: 30_000, signal }); + } if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-credential"); return null; } @@ -608,7 +630,7 @@ function inFlightMainAccountToken( * the grant before Codex saves its successor. */ async function refreshThroughCodex( - locked: MainAuthJsonCredential, + locked: MainAuthJsonCredential | null, rejectedAccessToken: string | undefined, dependencies: NativeMainRefreshDependencies, refreshTimeout: AbortSignal, @@ -625,7 +647,8 @@ async function refreshThroughCodex( // The only permanent-failure signal the response carries: Codex withholds the token once // its refresh was refused for good. The specific OAuth code is not on the wire. noteNativeMainRefresh("codex-reauth"); - markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!); + const grantKey = locked && mainRefreshGrantKey(locked); + if (grantKey) markMainRefreshGrantRejected(grantKey); throw new MainAccountTokenRefreshError("reauth"); } const token = status.authToken; @@ -641,7 +664,17 @@ async function refreshThroughCodex( noteNativeMainRefresh("codex-ok"); if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); if (dependencies.signal?.aborted) throw dependencies.signal.reason; - return { accessToken: token, chatgptAccountId: extractAccountId(undefined, token) ?? locked.chatgptAccountId }; + const result = { + accessToken: token, + chatgptAccountId: extractAccountId(undefined, token) ?? locked?.chatgptAccountId ?? "", + }; + codexOwnedMainToken = { home: resolveCodexHomeDir(), ...result }; + return result; +} + +function currentCodexOwnedMainToken(): { accessToken: string; chatgptAccountId: string } | null { + if (codexOwnedMainToken?.home !== resolveCodexHomeDir()) return null; + return { accessToken: codexOwnedMainToken.accessToken, chatgptAccountId: codexOwnedMainToken.chatgptAccountId }; } /** Refresh the CLI-owned native credential before upstream I/O and publish it atomically. */ diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 961cee4f8ac..a35b32af8ac 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -310,8 +310,8 @@ runtime resolved by `src/codex/runtime.ts` is 0.130.0 or newer, starts a short-lived `codex -c model_provider="openai" app-server` under the same CODEX_HOME and calls `getAuthStatus` with `includeToken` and `refreshToken`. OpenCodex uses the returned `authToken` for the request and writes nothing; it -does not read auth.json again afterwards, because Codex in keyring mode deletes -the file. A `chatgpt` method with a null token is the only permanent-failure +does not read auth.json again afterwards, because Codex keyring mode deletes the +file. It retains that bearer and asks Codex again when it needs refresh. A `chatgpt` method with a null token is the only permanent-failure signal on the wire and marks the grant rejected (reauth). An unreachable binary, a non-ChatGPT method, or the same or an expired bearer is transient. The exchange runs on the refresh timeout alone, under the same claim and grant lock diff --git a/tests/codex-integration/native-main-refresh-mode.test.ts b/tests/codex-integration/native-main-refresh-mode.test.ts index 3c0ad23b015..8770ea83cf3 100644 --- a/tests/codex-integration/native-main-refresh-mode.test.ts +++ b/tests/codex-integration/native-main-refresh-mode.test.ts @@ -238,12 +238,14 @@ describe("main account in codex refresh mode (#2999)", () => { setNativeMainRefreshResolutionForTests(CODEX_MODE); seedAuth(expired()); const fresh = fakeChatGptJwt("acct-other"); - const { codexAuthStatus } = seam(() => { + const { calls, codexAuthStatus } = seam(() => { rmSync(join(home, "auth.json")); return { authMethod: "chatgpt", authToken: fresh, requiresOpenaiAuth: true }; }); expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: fresh, chatgptAccountId: "acct-other" }); + expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: fresh, chatgptAccountId: "acct-other" }); + expect(calls).toEqual([{ refresh: true }]); }); test("a chatgpt login with a withheld token means reauthentication", async () => { From 8abf963a0594a347d53e1d49f9581f436d82dc0c Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 12:40:24 +0000 Subject: [PATCH 02/15] fix(auth): revalidate keyring credentials for each dispatch --- .../docs/reference/configuration/server.md | 2 +- src/codex/auth-context.ts | 18 ++ src/codex/main-account.ts | 70 ++++--- src/server/audio-upstream.ts | 12 +- structure/data-planes/inbound-compat.md | 4 +- structure/providers/openai-accounts.md | 28 +-- structure/runtime.md | 2 +- .../native-main-refresh-mode.test.ts | 192 +++++++++++++++++- tests/server/audio-transcriptions.test.ts | 47 +++++ 9 files changed, 327 insertions(+), 48 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index ae044e3faa9..ceb35cc5a80 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -43,7 +43,7 @@ runs helper features around provider requests. | `codexMainAccountHardLock?` | `boolean` | `true` | Block new identity-matched main-account requests at their per-window threshold. Only explicit `false` opts out. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). | | `codexMainAccountHardLockThresholds?` | `{ short?: number; long?: number }` | `{ short: 90, long: 98 }` | Independent 5h and weekly/monthly thresholds. Integers 80–100, with `short <= long`. Invalid persisted fields fall back to defaults; the settings API rejects invalid writes before mutation. | | `codexMainAccountHardLockFallback?` | `{ enabled?: boolean; model?: string; reasoningEffort?: string }` | off | Extended hard-lock settings: while the lock blocks and no Codex account can serve the request, qualifying native-Codex-model requests reroute to `model` with `reasoningEffort` instead of failing. `enabled: false` pauses the reroute and keeps the model. An absent section or model keeps the refusal behavior; a usable Pool account still serves first. `reasoningEffort` must be a declared effort; invalid persisted sections degrade to off and the settings API rejects invalid writes before mutation. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). | -| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Who refreshes the native main ChatGPT login in `CODEX_HOME`. `"codex"` asks the Codex binary (app-server `getAuthStatus`, Codex 0.130.0 or newer, found through the same runtime resolution as `ocx sync`, including the ChatGPT app bundle) and never writes `auth.json`; this also works when Codex stores its login in the OS keyring. `"opencodex"` keeps OpenCodex's guarded `auth.json` writer. Unset picks `"codex"` when a supported binary is found; without one, both unset and `"codex"` fall back to the guarded writer. `ocx doctor` shows the active mode under Native main refresh. | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Who refreshes the native main ChatGPT login in `CODEX_HOME`. `"codex"` asks the Codex binary (app-server `getAuthStatus`, Codex 0.130.0 or newer, found through the same runtime resolution as `ocx sync`, including the ChatGPT app bundle) and never writes `auth.json`; this also works when Codex stores its login in the OS keyring. When the file is absent, native Pool and stored Direct requests consult the current Codex login, including after restart; logout and account changes take effect at the next credential lookup. `"opencodex"` keeps OpenCodex's guarded `auth.json` writer. Unset picks `"codex"` when a supported binary is found; without one, both unset and `"codex"` fall back to the guarded writer. `ocx doctor` shows the active mode under Native main refresh. | | `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another's request record. Logs carry a hashed account key only. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. | | `spendPoolAliases?` | `Record` | unset | Explicit historical salted pool-alias to canonical provider mappings. Keep this key at the top level, outside `spend`. See [Historical pool continuity](#historical-pool-continuity). | diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 5afdd2230fd..b7746c3192a 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -26,6 +26,7 @@ import { MainAccountTokenRefreshError, MainAuthJsonChangedDuringRefreshError, getMainAccountToken, + getMainAccountCredentialStatus, getValidMainAccountToken, isMainAccountTokenLive, type NativeMainRefreshDependencies, @@ -1329,6 +1330,7 @@ async function resolveCodexAuthContextUnannotated( const nativeMainSelectionOnly = !nativeMainTrafficBlocked && selectionAdmission?.mainProfileDraining === true; let accountId: string; + let mainDiscoveryFailure: unknown; // Answering this with the manual-pin predicate made `codexAccountUnusableReason` report // `main_credential_unavailable` for every UNPINNED request, so `getEligiblePoolAccounts` never // listed main and the strategy compared only the stored accounts. With one stored sibling the @@ -1345,6 +1347,21 @@ async function resolveCodexAuthContextUnannotated( && callerOwnsAnyCooledPoolSubscription(headers, config, quotaScope), }); try { + // A keyring-only login has no auth.json to make synchronous Pool candidacy live. + // Discover it while this selector still fences profile switching. The selected + // account is independently revalidated before dispatch, just like a file-backed one. + if (selectionAdmission && !nativeMainReadsForbidden && !options.isMainAccountTokenLive + && (fixedAccountId === undefined || fixedAccountId === MAIN_CODEX_ACCOUNT_ID) + && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID + && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID) + && !isMainAccountHardLocked(policy) && !getMainAccountCredentialStatus().usable) { + try { + await (options.getValidMainAccountToken ?? getValidMainAccountToken)({ + signal: options.signal, ...(options.nativeMainRefreshDependencies ?? {}), + }); + } catch (cause) { mainDiscoveryFailure = cause; } + options.signal?.throwIfAborted(); + } const excludeAccountIds = nativeMainReadsForbidden ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; @@ -1457,6 +1474,7 @@ async function resolveCodexAuthContextUnannotated( return context; } if (!selected) { + if (mainDiscoveryFailure !== undefined) throw new CodexAuthContextError(MAIN_CODEX_ACCOUNT_ID, mainDiscoveryFailure); // A retry that excluded a failed Pool account may still use the validated caller-owned // main credential. Treating every exclusion as if main itself had failed strands a healthy // native bearer after the first Pool attempt. Preserve the exactly-once boundary by refusing diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 0849fc315d5..35f69dfe560 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -50,7 +50,11 @@ let mainAccountPlan: string | null = null; let jwtPlanAttempted = false; const MAIN_TOKEN_REFRESH_SKEW_MS = 60_000; let beforeMainAuthJsonRenameForTests: (() => void) | null = null; -let codexOwnedMainToken: { home: string; accessToken: string; chatgptAccountId: string } | null = null; +let codexOwnedMainCredential: { home: string; expiresAt: number; refreshRefused?: true } | null = null; + +function forgetCodexOwnedMainUsability(home: string): void { + if (codexOwnedMainCredential?.home === home && !codexOwnedMainCredential.refreshRefused) codexOwnedMainCredential = null; +} type MainAuthJsonCredential = { path: string; @@ -245,11 +249,12 @@ export interface MainAccountCredentialStatus { /** One owned physical observation; no credential material escapes this view. */ export function getMainAccountCredentialStatus(now = Date.now()): MainAccountCredentialStatus { const current = readMainAuthJsonCredential(); - const refreshGrantRejected = credentialRefreshGrantRejected(current); + const owned = !current && codexOwnedMainCredential?.home === resolveCodexHomeDir() ? codexOwnedMainCredential : null; + const refreshGrantRejected = credentialRefreshGrantRejected(current) || owned?.refreshRefused === true; const hasRefreshGrant = !!current?.refreshToken && !refreshGrantRejected; - const owned = current ? null : currentCodexOwnedMainToken(); + const ownedUsable = !!owned && owned.expiresAt > now; const usable = !refreshGrantRejected - && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken ?? owned?.accessToken, now, 0)); + && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken, now, 0) || ownedUsable); observeMainDecisionCredentialUsable(usable); return { refreshGrantRejected, @@ -456,30 +461,33 @@ async function resolveMainAccountToken( rejectedAccessToken?: string, ): Promise<{ accessToken: string; chatgptAccountId: string } | null> { if (dependencies.signal?.aborted) throw dependencies.signal.reason; + const codexHome = resolveCodexHomeDir(); const initial = readMainAuthJsonCredential(); - const owned = initial ? null : currentCodexOwnedMainToken(); // Every skip below ends a forced refresh WITHOUT asking the token endpoint anything, which from // the outside is indistinguishable from a refusal: same failed request, no write, no verdict. // A forced refresh is the only caller that passes `rejectedAccessToken`, so only it reports. if (!initial) { - if (owned?.accessToken !== rejectedAccessToken - && mainAccessTokenFresh(owned?.accessToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) { - return owned; - } const refreshMode = await resolveNativeMainRefreshMode(loadConfig()); if (refreshMode.mode === "codex") { const refreshTimeout = AbortSignal.timeout(30_000); const signal = dependencies.signal ? AbortSignal.any([dependencies.signal, refreshTimeout]) : refreshTimeout; - return withNativeMainExclusiveClaim(resolveNativeProfileContext(), () => refreshThroughCodex( - null, - rejectedAccessToken, - dependencies, - refreshTimeout, - dependencies.codexAuthStatus ?? (options => requestCodexAuthStatus(refreshMode.command!, options)), - ), { waitMs: 30_000, signal }); + try { + return await withNativeMainExclusiveClaim(resolveNativeProfileContext({ codexHome }), () => refreshThroughCodex( + null, rejectedAccessToken, dependencies, refreshTimeout, + dependencies.codexAuthStatus ?? (options => requestCodexAuthStatus(refreshMode.command!, { ...options, codexHome })), + codexHome, rejectedAccessToken !== undefined, + ), { waitMs: 30_000, signal }); + } catch (cause) { + forgetCodexOwnedMainUsability(codexHome); + if (refreshTimeout.aborted && !dependencies.signal?.aborted) { + throw new MainAccountTokenRefreshError("transient", { cause }); + } + throw cause; + } } + forgetCodexOwnedMainUsability(codexHome); if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-credential"); return null; } @@ -526,8 +534,9 @@ async function resolveMainAccountToken( // locks and then reaching for the same claim would deadlock. try { return await withNativeMainExclusiveClaim( - resolveNativeProfileContext(), + resolveNativeProfileContext({ codexHome }), () => withCodexRefreshFileLock(lockKey, signal, async () => { + if (resolveCodexHomeDir() !== codexHome) throw new MainAuthJsonChangedDuringRefreshError(); const locked = readMainAuthJsonCredential(); if (!locked) throw new MainAuthJsonChangedDuringRefreshError(); if (credentialRefreshGrantRejected(locked)) throw new MainAccountTokenRefreshError("reauth"); @@ -546,7 +555,7 @@ async function resolveMainAccountToken( if (refreshMode?.mode === "codex") { const command = refreshMode.command!; return refreshThroughCodex(locked, rejectedAccessToken, dependencies, refreshTimeout, - dependencies.codexAuthStatus ?? (options => requestCodexAuthStatus(command, options))); + dependencies.codexAuthStatus ?? (options => requestCodexAuthStatus(command, { ...options, codexHome })), codexHome); } const refresh = dependencies.refreshToken ?? refreshNativeMainGrant; let refreshed: OAuthCredentials; @@ -635,20 +644,35 @@ async function refreshThroughCodex( dependencies: NativeMainRefreshDependencies, refreshTimeout: AbortSignal, authStatus: NonNullable, + codexHome: string, + refresh = true, ): Promise<{ accessToken: string; chatgptAccountId: string }> { + if (resolveCodexHomeDir() !== codexHome) throw new MainAuthJsonChangedDuringRefreshError(); + // This observation is only selection evidence. A later request must ask Codex again: + // JWT expiry cannot detect logout or an external keyring account switch. + forgetCodexOwnedMainUsability(codexHome); let status: CodexAuthStatus; try { - status = await authStatus({ refresh: true, signal: refreshTimeout }); + const readSignal = dependencies.signal ? AbortSignal.any([dependencies.signal, refreshTimeout]) : refreshTimeout; + status = await authStatus({ refresh, signal: refresh ? refreshTimeout : readSignal }); + if (!refresh && status.authMethod === "chatgpt" && status.authToken + && !mainAccessTokenFresh(status.authToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) { + dependencies.signal?.throwIfAborted(); + status = await authStatus({ refresh: true, signal: refreshTimeout }); + } } catch (cause) { + if (!refresh && dependencies.signal?.aborted) throw dependencies.signal.reason; noteNativeMainRefresh("codex-unavailable"); throw new MainAccountTokenRefreshError("transient", { cause }); } + if (resolveCodexHomeDir() !== codexHome) throw new MainAuthJsonChangedDuringRefreshError(); if (status.authMethod === "chatgpt" && !status.authToken) { // The only permanent-failure signal the response carries: Codex withholds the token once // its refresh was refused for good. The specific OAuth code is not on the wire. noteNativeMainRefresh("codex-reauth"); const grantKey = locked && mainRefreshGrantKey(locked); if (grantKey) markMainRefreshGrantRejected(grantKey); + else codexOwnedMainCredential = { home: codexHome, expiresAt: 0, refreshRefused: true }; throw new MainAccountTokenRefreshError("reauth"); } const token = status.authToken; @@ -668,15 +692,11 @@ async function refreshThroughCodex( accessToken: token, chatgptAccountId: extractAccountId(undefined, token) ?? locked?.chatgptAccountId ?? "", }; - codexOwnedMainToken = { home: resolveCodexHomeDir(), ...result }; + const exp = decodeJwtPayload(token)?.exp; + codexOwnedMainCredential = { home: codexHome, expiresAt: typeof exp === "number" ? exp * 1000 : Infinity }; return result; } -function currentCodexOwnedMainToken(): { accessToken: string; chatgptAccountId: string } | null { - if (codexOwnedMainToken?.home !== resolveCodexHomeDir()) return null; - return { accessToken: codexOwnedMainToken.accessToken, chatgptAccountId: codexOwnedMainToken.chatgptAccountId }; -} - /** Refresh the CLI-owned native credential before upstream I/O and publish it atomically. */ export function getValidMainAccountToken( dependencies: NativeMainRefreshDependencies = {}, diff --git a/src/server/audio-upstream.ts b/src/server/audio-upstream.ts index 5eee3620aed..076dbee2649 100644 --- a/src/server/audio-upstream.ts +++ b/src/server/audio-upstream.ts @@ -1,15 +1,18 @@ import { formatErrorResponse } from "../bridge"; import { CodexAccountCooldownError, + CodexAuthContextError, CodexMainProfileDrainingError, codexMainProfileDrainingResponse, cooldownErrorResponse, isCodexAuthContextUsable, - materializeCodexUpstreamAuth, + materializeCodexUpstreamAuthAsync, releaseCodexAuthContextProbeLease, resolveCodexAuthContext, type CodexAuthContext, } from "../codex/auth-context"; +import { MAIN_CODEX_ACCOUNT_ID, MainAccountTokenRefreshError, MainAuthJsonChangedDuringRefreshError } from "../codex/main-account"; +import { NativeProfileError } from "../codex/native-profile-types"; import { formatCodexProviderForLog } from "../codex/routing"; import type { AdmissionLease } from "../lib/admission"; import { captureExplicitOpenAiCallerAuth, resolveFirstUsableOpenAiSidecar, selectOpenAiImagesProvider } from "../providers/openai-sidecar"; @@ -25,6 +28,7 @@ import { import { codexAccountSelectionForTurn } from "./lifecycle"; import type { RequestLogContext } from "./request-log"; import { authorizationCredential } from "../lib/authorization-scheme"; +import { nativeMainRefreshFailureResponse } from "./responses/codex-auth-error"; export const TRANSCRIPTION_MODEL = "gpt-4o-transcribe"; export const LIVE_AUDIO_MODEL = "gpt-live-1-codex"; @@ -103,10 +107,11 @@ export async function resolveAudioUpstream( signal: options.signal, }); options.signal?.throwIfAborted(); - selected = materializeCodexUpstreamAuth(headers, context, { + selected = await materializeCodexUpstreamAuthAsync(headers, context, { config, admission: options.admission, substituteMainCredential: true, + signal: options.signal, }); } else { const resolved = await resolveFirstUsableOpenAiSidecar(candidates.forwardCandidates, headers, config, { @@ -172,6 +177,9 @@ export async function resolveAudioUpstream( releaseCodexAuthContextProbeLease(context); if (error instanceof CodexAccountCooldownError) return cooldownErrorResponse(error); if (error instanceof CodexMainProfileDrainingError) return codexMainProfileDrainingResponse(); + const mainCause = error instanceof CodexAuthContextError && error.accountId === MAIN_CODEX_ACCOUNT_ID ? error.cause : error; + if (mainCause instanceof MainAccountTokenRefreshError || mainCause instanceof MainAuthJsonChangedDuringRefreshError + || (mainCause instanceof NativeProfileError && mainCause.retryable)) return nativeMainRefreshFailureResponse(mainCause); return formatErrorResponse(401, "authentication_error", "Audio account authentication unavailable; reconnect the selected OpenAI account"); } } diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 1a8472338da..36128633d0e 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -22,9 +22,9 @@ is scoped to canonical ChatGPT Responses forwarding; other source-area behavior `src/server/audio-transcriptions.ts` owns `POST /v1/audio/transcriptions`, independently of Responses and Chat conversion. `src/server/audio-upstream.ts` resolves explicit data-plane keys on both listeners and substitutes stored OpenAI credentials. Direct stored-main access claims -the enclosing admission lease and derives its account header only from that stored credential; +the enclosing admission lease, asynchronously resolves the current file or Codex-owned keyring credential under the request signal, and derives its account header only from that credential; caller-supplied account selection is never retained. Pool uses the existing sidecar account resolver. A selected -ChatGPT authentication failure never falls through to the paid OpenAI provider. +ChatGPT authentication failure never falls through to the paid OpenAI provider; transient native-main lookup failures retain the shared retryable 503 response. The bounded multipart input accepts one nonempty file up to 25,000,000 bytes within a 32 MiB body, required model, and optional prompt, language and JSON/text response format. Unknown or diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index a35b32af8ac..bb04fd64c51 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -304,20 +304,20 @@ grant rather than rotating the existing grant and keeps its pre-publication fenc ## Native-main refresh ownership -`src/codex/native-main-refresh-mode.ts` decides who refreshes the native main -login (#2999). `nativeMainRefresh: "codex"`, the default whenever the Codex -runtime resolved by `src/codex/runtime.ts` is 0.130.0 or newer, starts a -short-lived `codex -c model_provider="openai" app-server` under the same -CODEX_HOME and calls `getAuthStatus` with `includeToken` and `refreshToken`. -OpenCodex uses the returned `authToken` for the request and writes nothing; it -does not read auth.json again afterwards, because Codex keyring mode deletes the -file. It retains that bearer and asks Codex again when it needs refresh. A `chatgpt` method with a null token is the only permanent-failure -signal on the wire and marks the grant rejected (reauth). An unreachable binary, -a non-ChatGPT method, or the same or an expired bearer is transient. The -exchange runs on the refresh timeout alone, under the same claim and grant lock -as the writer. `"opencodex"`, a missing binary, or an older binary keeps the -guarded writer above. While the test home guard is armed, auto resolution -never spawns Codex. `ocx doctor` prints the active mode and its reason. +`src/codex/native-main-refresh-mode.ts` selects the refresh owner. Auto uses Codex 0.130.0 or newer, resolved by `src/codex/runtime.ts`. +Codex mode starts a short-lived `codex -c model_provider="openai" app-server` and calls `getAuthStatus` with `includeToken` and `refreshToken`. +OpenCodex uses the returned bearer for that request, writes nothing and does not reread auth.json after refresh: keyring mode deletes it. + +With no file credential, every lookup reads the current login with `refreshToken: false`; an expiring or explicitly rejected bearer requests refresh. +Only home-scoped expiry/refusal metadata survives the lookup. Logout, account switches and unavailable reads cannot reuse an earlier bearer. +A `chatgpt` method with a null token marks the file grant or keyring observation rejected (reauth); a verified new keyring login replaces that observation. +An unreachable binary, non-ChatGPT method, unchanged rejected bearer or expired bearer is transient. Read-only lookups honor caller cancellation. +Actual refresh runs on its independent 30-second timeout so Codex can save its grant. Both paths pin CODEX_HOME and hold its exclusive claim. +Only file-backed refresh has a local grant fingerprint lock; keyring claim deadlines are transient. `"opencodex"`, missing or old Codex keeps the guarded writer. + +Pool discovers keyring credentials before synchronous candidacy when none is usable, then revalidates the selected token before dispatch. +Startup, drain, caller-owned credential, pause and hard-lock fences govern discovery. Async stored-Direct materialization, including audio, uses the same resolver; synchronous readers remain file-only. +`tests/codex-integration/native-main-refresh-mode.test.ts` covers these boundaries. Armed test-home guards prevent auto-spawn; `ocx doctor` prints the active mode and reason. ## Preserve signed-in quota diff --git a/structure/runtime.md b/structure/runtime.md index 65a345ef8dd..7adcae5ff3e 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -152,7 +152,7 @@ there. Feature code is grouped by responsibility: while `index/serve-options.ts` owns route ordering; `responses.ts` and `responses/core.ts` compose Responses handling from the owners inventoried in [Responses transport](transports/responses.md), and `responses/compact.ts` and `responses/compaction-retention.ts` own v1/v2 compaction under the [routed reasoning-retention contract](transports/responses-failover.md#compaction-routing-overrides) implemented by `src/responses/reasoning-retention.ts`; `images.ts` owns the standalone Images relay; -`responses/codex-auth-error.ts` owns the shared Responses/compact Codex auth-context HTTP mapping. +`responses/codex-auth-error.ts` owns the shared Responses/compact Codex auth-context HTTP mapping and audio's native-main refresh refusals. Model entitlement denial is a 400 request error and temporary exhaustion of every model-capable account is a retryable 429; neither is reported as an invalid API key. Images, Live, and Search reuse that model-availability mapping while retaining their existing credential handling. Account diff --git a/tests/codex-integration/native-main-refresh-mode.test.ts b/tests/codex-integration/native-main-refresh-mode.test.ts index 8770ea83cf3..867aba04a41 100644 --- a/tests/codex-integration/native-main-refresh-mode.test.ts +++ b/tests/codex-integration/native-main-refresh-mode.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -14,11 +14,19 @@ import { } from "../../src/codex/native-main-refresh-mode"; import { forceRefreshMainAccountToken, + getMainAccountCredentialStatus, getValidMainAccountToken, isMainAccountRefreshGrantRejected, + MainAuthJsonChangedDuringRefreshError, MainAccountTokenRefreshError, } from "../../src/codex/main-account"; import { codexCredentialMutationEpoch } from "../../src/codex/credential-mutation-epoch"; +import { resolveCodexAuthContext, materializeCodexUpstreamAuthAsync, CodexAuthContextError } from "../../src/codex/auth-context"; +import { clearThreadAccountMap } from "../../src/codex/routing"; +import { clearAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { withNativeMainSharedClaim } from "../../src/codex/native-main-claim"; +import { resolveNativeProfileContext } from "../../src/codex/native-profile-store"; +import type { OcxConfig } from "../../src/types"; import type { ResolveCodexRuntimeResult } from "../../src/codex/runtime"; import { fakeChatGptJwt } from "../helpers/agent-task-recovery"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -239,13 +247,191 @@ describe("main account in codex refresh mode (#2999)", () => { seedAuth(expired()); const fresh = fakeChatGptJwt("acct-other"); const { calls, codexAuthStatus } = seam(() => { - rmSync(join(home, "auth.json")); + rmSync(join(home, "auth.json"), { force: true }); return { authMethod: "chatgpt", authToken: fresh, requiresOpenaiAuth: true }; }); expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: fresh, chatgptAccountId: "acct-other" }); expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: fresh, chatgptAccountId: "acct-other" }); - expect(calls).toEqual([{ refresh: true }]); + expect(calls).toEqual([{ refresh: true }, { refresh: false }]); + }); + + test("a keyring-only login is discovered without an earlier file-backed refresh", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const fresh = fakeChatGptJwt("acct-main"); + const { calls, codexAuthStatus } = seam(() => ({ authMethod: "chatgpt", authToken: fresh, requiresOpenaiAuth: true })); + + expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: fresh, chatgptAccountId: "acct-main" }); + expect(calls).toEqual([{ refresh: false }]); + }); + + test("Pool discovers a keyring-only main login before synchronous candidacy", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const fresh = fakeChatGptJwt("acct-keyring-pool"); + const { calls, codexAuthStatus } = seam(() => ({ authMethod: "chatgpt", authToken: fresh, requiresOpenaiAuth: true })); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + let claimed = false; + const context = await resolveCodexAuthContext(new Headers(), config, "pool", { + modelId: "gpt-5.5", nativeMainRefreshDependencies: { codexAuthStatus }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, + claimMainProfile: () => { claimed = true; return true; }, release() {} }), + }); + expect(context).toMatchObject({ kind: "main-pool", accessToken: fresh, chatgptAccountId: "acct-keyring-pool" }); + expect(claimed).toBe(true); + expect(calls).toEqual([{ refresh: false }, { refresh: false }]); + }); + + test("stored Direct materialization uses the currently verified keyring account", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const fresh = fakeChatGptJwt("acct-keyring-direct"); + const { codexAuthStatus } = seam(() => ({ authMethod: "chatgpt", authToken: fresh, requiresOpenaiAuth: true })); + const inbound = new Headers({ authorization: "Bearer ocx_data_fixture", "chatgpt-account-id": "caller-account" }); + const selected = await materializeCodexUpstreamAuthAsync(inbound, { kind: "main", accountId: null }, { + substituteMainCredential: true, nativeMainRefreshDependencies: { codexAuthStatus }, + }); + expect(selected.get("authorization")).toBe(`Bearer ${fresh}`); + expect(selected.get("chatgpt-account-id")).toBe("acct-keyring-direct"); + expect(inbound.get("chatgpt-account-id")).toBe("caller-account"); + }); + + test("a cold keyring discovery transport failure retains its retryable auth-context cause", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const failure = await resolveCodexAuthContext(new Headers(), config, "pool", { + nativeMainRefreshDependencies: { codexAuthStatus: async () => { throw new CodexAuthStatusError("fixture unavailable"); } }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }).catch(error => error); + expect(failure).toBeInstanceOf(CodexAuthContextError); + expect(failure.cause).toMatchObject({ reason: "transient" }); + }); + + test("a near-expiry keyring read refreshes once through Codex", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const fresh = fakeChatGptJwt("acct-main"); + const calls: boolean[] = []; + const token = await getValidMainAccountToken({ codexAuthStatus: async ({ refresh }) => { + calls.push(refresh); + return { authMethod: "chatgpt", authToken: refresh ? fresh : expired(), requiresOpenaiAuth: true }; + } }); + expect(token?.accessToken).toBe(fresh); + expect(calls).toEqual([false, true]); + }); + + test("a keyring result is never attributed to a different home after an awaited read", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const otherHome = join(root, "other-codex-home"); + mkdirSync(otherHome); + const { codexAuthStatus } = seam(() => { + process.env.CODEX_HOME = otherHome; + return { authMethod: "chatgpt", authToken: fakeChatGptJwt("acct-old-home"), requiresOpenaiAuth: true }; + }); + await expect(getValidMainAccountToken({ codexAuthStatus })).rejects.toBeInstanceOf(MainAuthJsonChangedDuringRefreshError); + expect(getMainAccountCredentialStatus().usable).toBe(false); + process.env.CODEX_HOME = home; + expect(getMainAccountCredentialStatus().usable).toBe(false); + }); + + test("cancellation interrupts a read-only keyring lookup without starting a refresh", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const controller = new AbortController(); + const reason = new Error("fixture caller disconnected"); + let started!: () => void; + const entered = new Promise(resolve => { started = resolve; }); + const calls: boolean[] = []; + const pending = getValidMainAccountToken({ signal: controller.signal, + codexAuthStatus: ({ refresh, signal }) => new Promise((_resolve, reject) => { + calls.push(refresh); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + started(); + }), + }); + await entered; + controller.abort(reason); + await expect(pending).rejects.toBe(reason); + expect(calls).toEqual([false]); + expect(getMainAccountCredentialStatus().usable).toBe(false); + }); + + test("a keyring claim deadline is transient and never starts Codex while a reader owns the home", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + let started!: () => void; + let release!: () => void; + const entered = new Promise(resolve => { started = resolve; }); + const held = new Promise(resolve => { release = resolve; }); + const reader = withNativeMainSharedClaim(resolveNativeProfileContext(), async () => { started(); await held; }); + await entered; + const originalTimeout = AbortSignal.timeout.bind(AbortSignal); + const timeout = spyOn(AbortSignal, "timeout").mockImplementation(ms => originalTimeout(ms === 30_000 ? 10 : ms)); + const { calls, codexAuthStatus } = seam(() => ({ authMethod: "chatgpt", authToken: fakeChatGptJwt("acct-main"), requiresOpenaiAuth: true })); + try { + await expect(getValidMainAccountToken({ codexAuthStatus })).rejects.toMatchObject({ reason: "transient" }); + expect(calls).toEqual([]); + } finally { + timeout.mockRestore(); + release(); + await reader; + } + }); + + test("a subsequent keyring read follows an external account switch", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + seedAuth(expired()); + let current = fakeChatGptJwt("acct-main"); + const { calls, codexAuthStatus } = seam(() => { + rmSync(join(home, "auth.json"), { force: true }); + return { authMethod: "chatgpt", authToken: current, requiresOpenaiAuth: true }; + }); + expect((await getValidMainAccountToken({ codexAuthStatus }))?.chatgptAccountId).toBe("acct-main"); + current = fakeChatGptJwt("acct-switched"); + + expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: current, chatgptAccountId: "acct-switched" }); + expect(calls).toEqual([{ refresh: true }, { refresh: false }]); + }); + + test("external logout invalidates the previously observed keyring bearer", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + seedAuth(expired()); + let status: CodexAuthStatus = { authMethod: "chatgpt", authToken: fakeChatGptJwt("acct-main"), requiresOpenaiAuth: true }; + const { codexAuthStatus } = seam(() => { + rmSync(join(home, "auth.json"), { force: true }); + return status; + }); + await getValidMainAccountToken({ codexAuthStatus }); + status = { authMethod: null, authToken: null, requiresOpenaiAuth: true }; + + await expect(getValidMainAccountToken({ codexAuthStatus })).rejects.toBeInstanceOf(MainAccountTokenRefreshError); + expect(getMainAccountCredentialStatus().usable).toBe(false); + }); + + test("a keyring refresh refusal cannot resurrect the rejected bearer", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + seedAuth(expired()); + const rejected = fakeChatGptJwt("acct-main"); + let status: CodexAuthStatus = { authMethod: "chatgpt", authToken: rejected, requiresOpenaiAuth: true }; + const { codexAuthStatus } = seam(() => { + rmSync(join(home, "auth.json"), { force: true }); + return status; + }); + await getValidMainAccountToken({ codexAuthStatus }); + status = { authMethod: "chatgpt", authToken: null, requiresOpenaiAuth: true }; + + await expect(forceRefreshMainAccountToken(rejected, { codexAuthStatus })).rejects.toMatchObject({ reason: "reauth" }); + expect(getMainAccountCredentialStatus().usable).toBe(false); + expect(isMainAccountRefreshGrantRejected()).toBe(true); + await expect(getValidMainAccountToken({ codexAuthStatus })).rejects.toMatchObject({ reason: "reauth" }); + status = { authMethod: "chatgpt", authToken: fakeChatGptJwt("acct-new-login"), requiresOpenaiAuth: true }; + expect((await getValidMainAccountToken({ codexAuthStatus }))?.chatgptAccountId).toBe("acct-new-login"); + expect(isMainAccountRefreshGrantRejected()).toBe(false); }); test("a chatgpt login with a withheld token means reauthentication", async () => { diff --git a/tests/server/audio-transcriptions.test.ts b/tests/server/audio-transcriptions.test.ts index 7141cc1fe0c..1144dddaaa5 100644 --- a/tests/server/audio-transcriptions.test.ts +++ b/tests/server/audio-transcriptions.test.ts @@ -11,6 +11,7 @@ import { waitForNativeMainStartupGate } from "../../src/codex/native-profile-sta import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; import * as routing from "../../src/codex/routing"; import * as authContext from "../../src/codex/auth-context"; +import * as nativeMainRefresh from "../../src/codex/native-main-refresh-mode"; import { startServer } from "../../src/server"; import { handleAudioTranscriptions, AUDIO_BODY_MAX_BYTES, AUDIO_FILE_MAX_BYTES } from "../../src/server/audio-transcriptions"; import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; @@ -284,6 +285,52 @@ describe("standalone transcription API", () => { expect((await captured[0]!.formData()).get("model")).toBeNull(); }); + test("stored Direct transcription discovers a keyring-only login", async () => { + const token = fakeChatGptJwt({ chatgpt_account_id: "fixture-keyring-account" }); + nativeMainRefresh.setNativeMainRefreshResolutionForTests({ + mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fake/codex", version: "0.162.1", + }); + const authStatus = spyOn(nativeMainRefresh, "requestCodexAuthStatus").mockResolvedValue({ + authMethod: "chatgpt", authToken: token, requiresOpenaiAuth: true, + }); + try { + const cfg = config(); + cfg.defaultProvider = "openai"; + cfg.providers = { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" } }; + saveConfig(cfg); + expect((await request()).status).toBe(200); + expect(captured).toHaveLength(1); + expect(captured[0]!.headers.get("authorization")).toBe(`Bearer ${token}`); + expect(captured[0]!.headers.get("chatgpt-account-id")).toBe("fixture-keyring-account"); + } finally { + nativeMainRefresh.setNativeMainRefreshResolutionForTests(null); + authStatus.mockRestore(); + } + }); + + test("a temporary keyring lookup failure is retryable without sending audio", async () => { + nativeMainRefresh.setNativeMainRefreshResolutionForTests({ + mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fake/codex", version: "0.162.1", + }); + const authStatus = spyOn(nativeMainRefresh, "requestCodexAuthStatus").mockRejectedValue( + new nativeMainRefresh.CodexAuthStatusError("fixture private lookup detail"), + ); + try { + const cfg = config(); + cfg.defaultProvider = "openai"; + cfg.providers = { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" } }; + saveConfig(cfg); + const response = await request(); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBe("1"); + expect(await response.text()).not.toContain("fixture private lookup detail"); + expect(captured).toHaveLength(0); + } finally { + nativeMainRefresh.setNativeMainRefreshResolutionForTests(null); + authStatus.mockRestore(); + } + }); + test("stored Direct credentials never inherit a caller account ID", async () => { writeFileSync(join(codex.path, "auth.json"), JSON.stringify({ tokens: { access_token: "fixture-main-access" } })); clearMainAccountInfoCache(); From 050c1cc290920b74b4fb33dbcaf52aa53e58c890 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 13:06:27 +0000 Subject: [PATCH 03/15] fix(codex): reconcile owned keyring identity before account policy --- .../docs/reference/configuration/server.md | 2 +- src/codex/account-lifecycle.ts | 36 ++- src/codex/auth-context.ts | 21 +- src/codex/main-account.ts | 24 +- structure/providers/openai-accounts.md | 8 +- structure/providers/openai-tiers.md | 2 +- .../native-main-refresh-mode.test.ts | 212 +++++++++++++++++- 7 files changed, 276 insertions(+), 29 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index ceb35cc5a80..77b8e33a548 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -43,7 +43,7 @@ runs helper features around provider requests. | `codexMainAccountHardLock?` | `boolean` | `true` | Block new identity-matched main-account requests at their per-window threshold. Only explicit `false` opts out. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). | | `codexMainAccountHardLockThresholds?` | `{ short?: number; long?: number }` | `{ short: 90, long: 98 }` | Independent 5h and weekly/monthly thresholds. Integers 80–100, with `short <= long`. Invalid persisted fields fall back to defaults; the settings API rejects invalid writes before mutation. | | `codexMainAccountHardLockFallback?` | `{ enabled?: boolean; model?: string; reasoningEffort?: string }` | off | Extended hard-lock settings: while the lock blocks and no Codex account can serve the request, qualifying native-Codex-model requests reroute to `model` with `reasoningEffort` instead of failing. `enabled: false` pauses the reroute and keeps the model. An absent section or model keeps the refusal behavior; a usable Pool account still serves first. `reasoningEffort` must be a declared effort; invalid persisted sections degrade to off and the settings API rejects invalid writes before mutation. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). | -| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Who refreshes the native main ChatGPT login in `CODEX_HOME`. `"codex"` asks the Codex binary (app-server `getAuthStatus`, Codex 0.130.0 or newer, found through the same runtime resolution as `ocx sync`, including the ChatGPT app bundle) and never writes `auth.json`; this also works when Codex stores its login in the OS keyring. When the file is absent, native Pool and stored Direct requests consult the current Codex login, including after restart; logout and account changes take effect at the next credential lookup. `"opencodex"` keeps OpenCodex's guarded `auth.json` writer. Unset picks `"codex"` when a supported binary is found; without one, both unset and `"codex"` fall back to the guarded writer. `ocx doctor` shows the active mode under Native main refresh. | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Who refreshes the native main ChatGPT login in `CODEX_HOME`. `"codex"` asks the Codex binary (app-server `getAuthStatus`, Codex 0.130.0 or newer, found through the same runtime resolution as `ocx sync`, including the ChatGPT app bundle) and never writes `auth.json`; this also works when Codex stores its login in the OS keyring. When the file is absent, native Pool and stored Direct requests consult the current Codex login, including after restart; logout and account changes take effect at the next credential lookup. A verified account change clears the previous account's quota restrictions before selection; the same account keeps its restrictions, and manual pause settings remain unchanged. `"opencodex"` keeps OpenCodex's guarded `auth.json` writer. Unset picks `"codex"` when a supported binary is found; without one, both unset and `"codex"` fall back to the guarded writer. `ocx doctor` shows the active mode under Native main refresh. | | `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another's request record. Logs carry a hashed account key only. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. | | `spendPoolAliases?` | `Record` | unset | Explicit historical salted pool-alias to canonical provider mappings. Keep this key at the top level, outside `spend`. See [Historical pool continuity](#historical-pool-continuity). | diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index b150355cc08..53781b28223 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -20,7 +20,7 @@ import { clearThreadAccountMapForAccount } from "./routing/thread-affinity"; import { clearCodexPoolRefreshFailure } from "./pool-refresh-backoff"; import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; -import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "./main-account-cache"; +import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity, type MainQuotaWriter } from "./main-account-cache"; import { extractAccountId, extractAccountIdClaims } from "../oauth/chatgpt"; import { forgetCodexAccountPause } from "./account-pause"; import { forgetCodexAccountCreditUse } from "./account-credit-use"; @@ -73,6 +73,27 @@ function purgeMainCodexAccountRuntimeState(): void { invalidateCodexWebSocketsForAccount(MAIN_CODEX_ACCOUNT_ID); } +function observeMainCodexAccountIdentity(accountId: string): boolean { + const previousAccountId = observedMainChatgptAccountId; + observedMainChatgptAccountId = accountId; + const changed = previousAccountId !== undefined && previousAccountId !== accountId; + if (changed) purgeMainCodexAccountRuntimeState(); + observeMainQuotaIdentity(accountId); + return changed; +} + +/** Only the pinned-home, exclusively claimed Codex RPC may supply this credential. */ +export function observeCodexOwnedMainAccountCredential(accessToken: string): MainQuotaWriter | undefined { + const identity = extractAccountIdClaims(accessToken); + // An opaque or conflicting answer cannot retire a previously verified identity or its policy. + if (!identity.accountId || identity.conflict) return undefined; + observeMainCodexAccountIdentity(identity.accountId); + const tokens = { access_token: accessToken, account_id: identity.accountId }; + observeSignedInCodexIdentity(tokens); + observeMainCodexCollisionIdentity(tokens); + return observeMainQuotaCredential(accessToken, identity.accountId); +} + /** * The main Codex login is stored under the stable `__main__` alias, while * `~/.codex/auth.json` can be replaced with credentials for another physical @@ -89,18 +110,9 @@ export function reconcileMainCodexAccountRuntimeState(): boolean { // A missing/malformed auth.json is an unknown identity, not a confirmed account switch. Keep the // prior observation and its safety state until a real account id can be read again. if (currentAccountId === null) return false; - const previousAccountId = observedMainChatgptAccountId; - observedMainChatgptAccountId = currentAccountId; - if (previousAccountId === undefined || previousAccountId === currentAccountId) { - observeMainQuotaIdentity(currentAccountId); - if (read.status === "ok") observeMainCodexCollisionIdentity(read.tokens); - return false; - } - - purgeMainCodexAccountRuntimeState(); - observeMainQuotaIdentity(currentAccountId); + const changed = observeMainCodexAccountIdentity(currentAccountId); if (read.status === "ok") observeMainCodexCollisionIdentity(read.tokens); - return true; + return changed; } /** diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index b7746c3192a..d8e01b1f09f 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -28,6 +28,7 @@ import { getMainAccountToken, getMainAccountCredentialStatus, getValidMainAccountToken, + mainAccountTokenQuotaWriter, isMainAccountTokenLive, type NativeMainRefreshDependencies, } from "./main-account"; @@ -901,6 +902,10 @@ function observeSelectedMainCredential( token: { accessToken: string; chatgptAccountId: string }, writer: MainQuotaWriter | undefined, ): MainQuotaWriter | undefined { + // A claimed RPC can observe a replacement identity while the caller awaits it. Only that + // exact result carries the replacement writer; stale results keep their stale fence. + const ownedWriter = mainAccountTokenQuotaWriter(token); + if (ownedWriter) return ownedWriter; if (!writer) return undefined; // Carry an explicitly stale writer through to quota's rejection fence; turning it into an // untagged write would instead invalidate the replacement account's trusted observation. @@ -1246,6 +1251,14 @@ async function resolveCodexAuthContextUnannotated( throw new CodexMainProfileDrainingError(); } if (isMainAccountHardLockEnabled(policy)) reconcileMainCodexAccountRuntimeState(); + if (!getMainAccountToken()) { + try { + await (options.getValidMainAccountToken ?? getValidMainAccountToken)({ + signal: options.signal, ...(options.nativeMainRefreshDependencies ?? {}), + }); + } catch (cause) { throw new CodexAuthContextError(MAIN_CODEX_ACCOUNT_ID, cause); } + options.signal?.throwIfAborted(); + } assertMainAccountPolicy(policy); if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) { const entitled = entitledCodexAccountIdsForModel( @@ -1347,14 +1360,14 @@ async function resolveCodexAuthContextUnannotated( && callerOwnsAnyCooledPoolSubscription(headers, config, quotaScope), }); try { - // A keyring-only login has no auth.json to make synchronous Pool candidacy live. - // Discover it while this selector still fences profile switching. The selected - // account is independently revalidated before dispatch, just like a file-backed one. + // A fileless login can change identity even while its previous expiry observation is usable. + // Discover it under the selector before applying identity-bound quota or hard-lock policy; + // the selected credential is independently revalidated before dispatch. if (selectionAdmission && !nativeMainReadsForbidden && !options.isMainAccountTokenLive && (fixedAccountId === undefined || fixedAccountId === MAIN_CODEX_ACCOUNT_ID) && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID) - && !isMainAccountHardLocked(policy) && !getMainAccountCredentialStatus().usable) { + && (!getMainAccountToken() || !getMainAccountCredentialStatus().usable)) { try { await (options.getValidMainAccountToken ?? getValidMainAccountToken)({ signal: options.signal, ...(options.nativeMainRefreshDependencies ?? {}), diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 35f69dfe560..63b66bcde87 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -1,4 +1,4 @@ -import { observeMainDecisionCredentialUsable } from "./main-account-cache"; +import { captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive, observeMainDecisionCredentialUsable, type MainQuotaWriter } from "./main-account-cache"; import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chatgpt-refresh-failure"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { createHash } from "node:crypto"; @@ -51,6 +51,13 @@ let jwtPlanAttempted = false; const MAIN_TOKEN_REFRESH_SKEW_MS = 60_000; let beforeMainAuthJsonRenameForTests: (() => void) | null = null; let codexOwnedMainCredential: { home: string; expiresAt: number; refreshRefused?: true } | null = null; +type MainAccountToken = Readonly<{ accessToken: string; chatgptAccountId: string }>; +const codexOwnedQuotaWriters = new WeakMap(); + +/** Exact RPC result provenance; copied/request-created tokens cannot recapture native quota ownership. */ +export function mainAccountTokenQuotaWriter(token: MainAccountToken): MainQuotaWriter | undefined { + return codexOwnedQuotaWriters.get(token); +} function forgetCodexOwnedMainUsability(home: string): void { if (codexOwnedMainCredential?.home === home && !codexOwnedMainCredential.refreshRefused) codexOwnedMainCredential = null; @@ -647,7 +654,10 @@ async function refreshThroughCodex( codexHome: string, refresh = true, ): Promise<{ accessToken: string; chatgptAccountId: string }> { + // Deferred to keep lifecycle's cache/plan cleanup off this module's load-time import cycle. + const { observeCodexOwnedMainAccountCredential } = await import("./account-lifecycle"); if (resolveCodexHomeDir() !== codexHome) throw new MainAuthJsonChangedDuringRefreshError(); + const identityGeneration = captureMainAccountIdentityGeneration(); // This observation is only selection evidence. A later request must ask Codex again: // JWT expiry cannot detect logout or an external keyring account switch. forgetCodexOwnedMainUsability(codexHome); @@ -665,7 +675,9 @@ async function refreshThroughCodex( noteNativeMainRefresh("codex-unavailable"); throw new MainAccountTokenRefreshError("transient", { cause }); } - if (resolveCodexHomeDir() !== codexHome) throw new MainAuthJsonChangedDuringRefreshError(); + if (resolveCodexHomeDir() !== codexHome || !isMainAccountIdentityGenerationLive(identityGeneration)) { + throw new MainAuthJsonChangedDuringRefreshError(); + } if (status.authMethod === "chatgpt" && !status.authToken) { // The only permanent-failure signal the response carries: Codex withholds the token once // its refresh was refused for good. The specific OAuth code is not on the wire. @@ -686,12 +698,14 @@ async function refreshThroughCodex( throw new MainAccountTokenRefreshError("transient"); } noteNativeMainRefresh("codex-ok"); - if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); if (dependencies.signal?.aborted) throw dependencies.signal.reason; - const result = { + const result = Object.freeze({ accessToken: token, chatgptAccountId: extractAccountId(undefined, token) ?? locked?.chatgptAccountId ?? "", - }; + }); + const writer = observeCodexOwnedMainAccountCredential(token); + if (writer) codexOwnedQuotaWriters.set(result, writer); + if (writer && dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); const exp = decodeJwtPayload(token)?.exp; codexOwnedMainCredential = { home: codexHome, expiresAt: typeof exp === "number" ? exp * 1000 : Infinity }; return result; diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index bb04fd64c51..9e392566e3d 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -309,14 +309,14 @@ Codex mode starts a short-lived `codex -c model_provider="openai" app-server` an OpenCodex uses the returned bearer for that request, writes nothing and does not reread auth.json after refresh: keyring mode deletes it. With no file credential, every lookup reads the current login with `refreshToken: false`; an expiring or explicitly rejected bearer requests refresh. -Only home-scoped expiry/refusal metadata survives the lookup. Logout, account switches and unavailable reads cannot reuse an earlier bearer. +Only home-scoped expiry/refusal metadata survives the lookup. Logout, account switches and unavailable reads cannot reuse an earlier bearer; a weak association gives the exact immutable RPC result its owned quota writer without retaining the result or token. A `chatgpt` method with a null token marks the file grant or keyring observation rejected (reauth); a verified new keyring login replaces that observation. An unreachable binary, non-ChatGPT method, unchanged rejected bearer or expired bearer is transient. Read-only lookups honor caller cancellation. -Actual refresh runs on its independent 30-second timeout so Codex can save its grant. Both paths pin CODEX_HOME and hold its exclusive claim. +Actual refresh runs on its independent 30-second timeout so Codex can save its grant. Both paths pin CODEX_HOME and hold its exclusive claim; home and identity-generation changes reject a delayed observation. Only file-backed refresh has a local grant fingerprint lock; keyring claim deadlines are transient. `"opencodex"`, missing or old Codex keeps the guarded writer. -Pool discovers keyring credentials before synchronous candidacy when none is usable, then revalidates the selected token before dispatch. -Startup, drain, caller-owned credential, pause and hard-lock fences govern discovery. Async stored-Direct materialization, including audio, uses the same resolver; synchronous readers remain file-only. +Fileless Pool and stored-Direct requests discover the current identity before selection or quota policy, even when prior expiry metadata is usable or the previous account is hard-locked; the selected token is revalidated before dispatch. +`src/codex/account-lifecycle.ts` applies the same identity transition to a validated owned RPC as to an owned file read: a changed account purges prior runtime/quota state and retires its writers, while unchanged or ambiguous identity preserves its quota policy and ambiguous identity cannot clear reauth quarantine. Startup, drain, caller-owned credential and Pool manual-pause fences remain; async stored-Direct materialization, including audio, uses the same resolver, while synchronous readers remain file-only. `tests/codex-integration/native-main-refresh-mode.test.ts` covers these boundaries. Armed test-home guards prevent auto-spawn; `ocx doctor` prints the active mode and reason. ## Preserve signed-in quota diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 983b001f860..d596774507d 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -441,7 +441,7 @@ consent is covered by `tests/codex-integration/main-account-credit-renewal.test. The policy reads a separately retained identity-tagged quota snapshot, so the legacy rotation cache's six-hour expiry does not silently release a known block. A confirmed account transition -invalidates old evidence. Request-owned bearers are matched only against a credential and effective +invalidates old evidence, including an account change verified through [the owned Codex RPC](openai-accounts.md#native-main-refresh-ownership). Request-owned bearers are matched only against a credential and effective workspace already observed under native ownership; an unrelated or unmatched keyring credential is not attributed to stored main and introduces no physical-main read. Credential equality tags remain process-local and never enter disk, logs, or management DTOs. diff --git a/tests/codex-integration/native-main-refresh-mode.test.ts b/tests/codex-integration/native-main-refresh-mode.test.ts index 867aba04a41..902c8b283d6 100644 --- a/tests/codex-integration/native-main-refresh-mode.test.ts +++ b/tests/codex-integration/native-main-refresh-mode.test.ts @@ -21,9 +21,13 @@ import { MainAccountTokenRefreshError, } from "../../src/codex/main-account"; import { codexCredentialMutationEpoch } from "../../src/codex/credential-mutation-epoch"; -import { resolveCodexAuthContext, materializeCodexUpstreamAuthAsync, CodexAuthContextError } from "../../src/codex/auth-context"; +import { resolveCodexAuthContext, materializeCodexUpstreamAuthAsync, CodexAuthContextError, CodexMainAccountHardLockError } from "../../src/codex/auth-context"; +import { applyConfirmedMainCodexAccountTransition, reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { captureMainQuotaWriter, isMainQuotaDispatchLive, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; +import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { clearThreadAccountMap } from "../../src/codex/routing"; -import { clearAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; import { withNativeMainSharedClaim } from "../../src/codex/native-main-claim"; import { resolveNativeProfileContext } from "../../src/codex/native-profile-store"; import type { OcxConfig } from "../../src/types"; @@ -494,3 +498,207 @@ describe("main account in codex refresh mode (#2999)", () => { expect(JSON.parse(readFileSync(join(home, "auth.json"), "utf8")).tokens.refresh_token).toBe("rotated"); }); }); + +describe("owned keyring account identity and quota", () => { + let previousHome: string | undefined; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(root, "opencodex-home"); + mkdirSync(process.env.OPENCODEX_HOME); + resetMainCodexAccountIdentityTrackingForTests(); + clearAccountQuota(); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + setNativeMainRefreshResolutionForTests(CODEX_MODE); + }); + + afterEach(() => { + clearAccountQuota(); + resetMainCodexAccountIdentityTrackingForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + }); + + function config(): OcxConfig { + return { port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: true, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } } }; + } + + function oldAccount(percent = 99) { + const accessToken = fakeChatGptJwt("acct-main"); + seedAuth(accessToken); + reconcileMainCodexAccountRuntimeState(); + const writer = observeMainQuotaCredential(accessToken, "acct-main")!; + expect(writer).toBeDefined(); + setAccountQuotaFromParsed("__main__", { weeklyPercent: percent, weeklyResetAt: Date.now() / 1000 + 3600 }, undefined, writer); + rmSync(join(home, "auth.json")); + return { accessToken, writer }; + } + + function answer(accessToken: string) { + const calls: boolean[] = []; + return { calls, codexAuthStatus: async ({ refresh }: { refresh: boolean; signal: AbortSignal }) => { + calls.push(refresh); + return { authMethod: "chatgpt", authToken: accessToken, requiresOpenaiAuth: true } as CodexAuthStatus; + } }; + } + + const selection = () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }); + + test("a verified keyring switch retires the old quota writer and binds the new credential", async () => { + const old = oldAccount(); + const fresh = fakeChatGptJwt("acct-keyring-new"); + const { codexAuthStatus } = answer(fresh); + expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: fresh, chatgptAccountId: "acct-keyring-new" }); + expect(isMainQuotaWriterLive(old.writer)).toBe(false); + expect(matchesMainQuotaCredential(old.accessToken, "acct-main")).toBe(false); + expect(matchesMainQuotaCredential(fresh, "acct-keyring-new")).toBe(true); + const nextWriter = captureMainQuotaWriter("acct-keyring-new"); + expect(nextWriter).toBeDefined(); + expect(getMainPolicyQuota()).toBeNull(); + setAccountQuotaFromParsed("__main__", { weeklyPercent: 5 }, undefined, nextWriter); + setAccountQuotaFromParsed("__main__", { weeklyPercent: 100 }, undefined, old.writer); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(5); + }); + + test("stored Direct materialization binds its new keyring dispatch after an awaited identity switch", async () => { + oldAccount(); + const fresh = fakeChatGptJwt("acct-keyring-new"); + const { codexAuthStatus } = answer(fresh); + const context: { kind: "main"; accountId: null; mainQuotaDispatch?: import("../../src/codex/main-account-cache").MainQuotaDispatch } = { kind: "main", accountId: null }; + const headers = await materializeCodexUpstreamAuthAsync(new Headers({ authorization: "Bearer fixture-admission" }), context, { + config: config(), substituteMainCredential: true, nativeMainRefreshDependencies: { codexAuthStatus }, + }); + expect(headers.get("authorization")).toBe(`Bearer ${fresh}`); + expect(headers.get("chatgpt-account-id")).toBe("acct-keyring-new"); + expect(context.mainQuotaDispatch).toBeDefined(); + expect(isMainQuotaDispatchLive(context.mainQuotaDispatch!)).toBe(true); + }); + + test.each([true, false])("stored Direct discovers a replacement before old policy checks (hard lock=%s)", async hardLock => { + oldAccount(100); + const cfg = { ...config(), codexMainAccountHardLock: hardLock }; + const { calls, codexAuthStatus } = answer(fakeChatGptJwt("acct-keyring-new")); + await expect(resolveCodexAuthContext(new Headers({ authorization: "Bearer fixture-admission" }), cfg, "direct", { + substituteMainCredentialForDirect: true, nativeMainRefreshDependencies: { codexAuthStatus }, + beginCodexAccountSelection: selection, + })).resolves.toMatchObject({ kind: "main", accountId: null }); + expect(calls).toEqual([false]); + expect(getMainPolicyQuota()).toBeNull(); + }); + + test.each([false, true])("Pool observes a replacement before old quota selection (prior keyring lookup=%s)", async warm => { + const old = oldAccount(); + if (warm) { + await getValidMainAccountToken({ codexAuthStatus: answer(old.accessToken).codexAuthStatus }); + expect(getMainAccountCredentialStatus().usable).toBe(true); + } + const cfg = config(); + const before = structuredClone(cfg); + const fresh = fakeChatGptJwt("acct-keyring-new"); + const { calls, codexAuthStatus } = answer(fresh); + const context = await resolveCodexAuthContext(new Headers(), cfg, "pool", { + modelId: "gpt-5.5", nativeMainRefreshDependencies: { codexAuthStatus }, + beginCodexAccountSelection: selection, primeCodexPoolQuotas: async () => {}, + }); + expect(context).toMatchObject({ kind: "main-pool", accessToken: fresh, chatgptAccountId: "acct-keyring-new" }); + expect(context.kind === "main-pool" && context.mainQuotaWriter).toBeDefined(); + if (context.kind === "main-pool") expect(isMainQuotaWriterLive(context.mainQuotaWriter!)).toBe(true); + expect(calls).toEqual([false, false]); + // Ordinary Pool selection records its active slot; operator policy stays unchanged. + expect(cfg).toEqual({ ...before, activeCodexAccountId: "__main__" }); + expect(isMainQuotaWriterLive(old.writer)).toBe(false); + }); + + test("the unchanged keyring account retains its hard lock", async () => { + const old = oldAccount(); + const { codexAuthStatus } = answer(old.accessToken); + await getValidMainAccountToken({ codexAuthStatus }); + expect(getMainAccountHardLockStatus(config()).state).toBe("blocked"); + expect(isMainQuotaWriterLive(old.writer)).toBe(true); + await expect(materializeCodexUpstreamAuthAsync(new Headers(), { kind: "main", accountId: null }, { + config: config(), substituteMainCredential: true, nativeMainRefreshDependencies: { codexAuthStatus }, + })).rejects.toBeInstanceOf(CodexMainAccountHardLockError); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + }); + + test("Pool acquisition carries the specifically owned replacement writer after discovery", async () => { + const old = oldAccount(5); + const fresh = fakeChatGptJwt("acct-keyring-new"); + let reads = 0; + const context = await resolveCodexAuthContext(new Headers(), config(), "pool", { + nativeMainRefreshDependencies: { codexAuthStatus: async () => ({ + authMethod: "chatgpt", authToken: ++reads === 1 ? old.accessToken : fresh, requiresOpenaiAuth: true, + }) }, + beginCodexAccountSelection: selection, primeCodexPoolQuotas: async () => {}, + }); + expect(reads).toBe(2); + expect(context).toMatchObject({ kind: "main-pool", chatgptAccountId: "acct-keyring-new" }); + expect(isMainQuotaWriterLive(old.writer)).toBe(false); + expect(context.kind === "main-pool" && context.mainQuotaWriter).toEqual(captureMainQuotaWriter("acct-keyring-new")); + if (context.kind === "main-pool") { + setAccountQuotaFromParsed("__main__", { weeklyPercent: 7 }, undefined, context.mainQuotaWriter); + } + expect(getMainPolicyQuota()?.weeklyPercent).toBe(7); + }); + + test("an incoming Direct bearer cannot replace the owned quota identity", async () => { + const old = oldAccount(); + const incoming = fakeChatGptJwt("acct-caller-only"); + const { calls, codexAuthStatus } = answer(incoming); + await expect(resolveCodexAuthContext(new Headers({ authorization: `Bearer ${incoming}` }), config(), "direct", { + nativeMainRefreshDependencies: { codexAuthStatus }, beginCodexAccountSelection: selection, + })).resolves.toMatchObject({ kind: "main", accountId: null }); + expect(calls).toEqual([]); + expect(isMainQuotaWriterLive(old.writer)).toBe(true); + expect(matchesMainQuotaCredential(incoming, "acct-caller-only")).toBe(false); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + }); + + test("a late RPC cannot overwrite a newer native identity generation", async () => { + const old = oldAccount(); + await expect(getValidMainAccountToken({ codexAuthStatus: async () => { + applyConfirmedMainCodexAccountTransition("acct-main", "acct-concurrent"); + return { authMethod: "chatgpt", authToken: fakeChatGptJwt("acct-keyring-new"), requiresOpenaiAuth: true }; + } })).rejects.toBeInstanceOf(MainAuthJsonChangedDuringRefreshError); + expect(isMainQuotaWriterLive(old.writer)).toBe(false); + expect(captureMainQuotaWriter("acct-concurrent")).toBeDefined(); + expect(captureMainQuotaWriter("acct-keyring-new")).toBeUndefined(); + }); + + test.each(["unavailable", "missing-id", "conflicting-id", "cancelled", "home-changed"])("uncertain keyring evidence preserves old quota policy (%s)", async scenario => { + const old = oldAccount(); + markAccountNeedsReauth("__main__"); + const controller = new AbortController(); + const otherHome = join(root, "other-home"); + mkdirSync(otherHome); + const token = scenario === "missing-id" ? "opaque-fixture-token" : scenario === "conflicting-id" + ? fakeChatGptJwt("acct-keyring-new", { chatgpt_account_id: "acct-conflict" }) + : fakeChatGptJwt("acct-keyring-new"); + await getValidMainAccountToken({ signal: controller.signal, codexAuthStatus: async () => { + if (scenario === "unavailable") throw new CodexAuthStatusError("fixture unavailable"); + if (scenario === "cancelled") controller.abort(new Error("fixture cancelled")); + if (scenario === "home-changed") process.env.CODEX_HOME = otherHome; + return { authMethod: "chatgpt", authToken: token, requiresOpenaiAuth: true }; + } }).catch(() => null); + process.env.CODEX_HOME = home; + expect(isMainQuotaWriterLive(old.writer)).toBe(true); + expect(getMainAccountHardLockStatus(config()).state).toBe("blocked"); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + expect(isAccountNeedsReauth("__main__")).toBe(true); + }); + + test("a manual main pause prevents discovery and remains configured", async () => { + oldAccount(); + const cfg = { ...config(), pausedCodexAccountIds: ["__main__"] }; + const { calls, codexAuthStatus } = answer(fakeChatGptJwt("acct-keyring-new")); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { + nativeMainRefreshDependencies: { codexAuthStatus }, beginCodexAccountSelection: selection, + primeCodexPoolQuotas: async () => {}, + })).rejects.toBeDefined(); + expect(calls).toEqual([]); + expect(cfg.pausedCodexAccountIds).toEqual(["__main__"]); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + }); +}); From 76a134c11851c34f6b71b4afc78a9fd9b62a1e37 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 13:23:42 +0000 Subject: [PATCH 04/15] fix(codex): retire proven stale main quota at cold startup --- .../docs/reference/configuration/server.md | 2 +- src/codex/account-lifecycle.ts | 4 +- src/codex/quota.ts | 9 +++ structure/providers/openai-accounts.md | 2 +- structure/providers/openai-tiers.md | 2 +- .../main-quota-provenance.test.ts | 81 +++++++++++++++++++ 6 files changed, 96 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 77b8e33a548..047b58dfa50 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -43,7 +43,7 @@ runs helper features around provider requests. | `codexMainAccountHardLock?` | `boolean` | `true` | Block new identity-matched main-account requests at their per-window threshold. Only explicit `false` opts out. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). | | `codexMainAccountHardLockThresholds?` | `{ short?: number; long?: number }` | `{ short: 90, long: 98 }` | Independent 5h and weekly/monthly thresholds. Integers 80–100, with `short <= long`. Invalid persisted fields fall back to defaults; the settings API rejects invalid writes before mutation. | | `codexMainAccountHardLockFallback?` | `{ enabled?: boolean; model?: string; reasoningEffort?: string }` | off | Extended hard-lock settings: while the lock blocks and no Codex account can serve the request, qualifying native-Codex-model requests reroute to `model` with `reasoningEffort` instead of failing. `enabled: false` pauses the reroute and keeps the model. An absent section or model keeps the refusal behavior; a usable Pool account still serves first. `reasoningEffort` must be a declared effort; invalid persisted sections degrade to off and the settings API rejects invalid writes before mutation. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). | -| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Who refreshes the native main ChatGPT login in `CODEX_HOME`. `"codex"` asks the Codex binary (app-server `getAuthStatus`, Codex 0.130.0 or newer, found through the same runtime resolution as `ocx sync`, including the ChatGPT app bundle) and never writes `auth.json`; this also works when Codex stores its login in the OS keyring. When the file is absent, native Pool and stored Direct requests consult the current Codex login, including after restart; logout and account changes take effect at the next credential lookup. A verified account change clears the previous account's quota restrictions before selection; the same account keeps its restrictions, and manual pause settings remain unchanged. `"opencodex"` keeps OpenCodex's guarded `auth.json` writer. Unset picks `"codex"` when a supported binary is found; without one, both unset and `"codex"` fall back to the guarded writer. `ocx doctor` shows the active mode under Native main refresh. | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Who refreshes the native main ChatGPT login in `CODEX_HOME`. `"codex"` asks the Codex binary (app-server `getAuthStatus`, Codex 0.130.0 or newer, found through the same runtime resolution as `ocx sync`, including the ChatGPT app bundle) and never writes `auth.json`; this also works when Codex stores its login in the OS keyring. When the file is absent, native Pool and stored Direct requests consult the current Codex login, including after restart; logout and account changes take effect at the next credential lookup. A verified account change clears the previous account's quota restrictions before selection. On the first lookup after restart, a valid saved quota record must identify a different account before old restrictions are removed. The same account keeps its restrictions, and manual pause settings remain unchanged. `"opencodex"` keeps OpenCodex's guarded `auth.json` writer. Unset picks `"codex"` when a supported binary is found; without one, both unset and `"codex"` fall back to the guarded writer. `ocx doctor` shows the active mode under Native main refresh. | | `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another's request record. Logs carry a hashed account key only. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. | | `spendPoolAliases?` | `Record` | unset | Explicit historical salted pool-alias to canonical provider mappings. Keep this key at the top level, outside `spend`. See [Historical pool continuity](#historical-pool-continuity). | diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 53781b28223..44000f2bc03 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -12,7 +12,7 @@ import { removeCodexAccountCredential } from "./account-store"; import { clearAccountNeedsReauth } from "./account-runtime-state"; import { readMainAuth } from "./auth-collision"; import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; -import { clearAccountQuota } from "./quota"; +import { clearAccountQuota, discardMismatchedMainQuota } from "./quota"; import { forgetManualPreference } from "./routing/active-account"; import { deleteAllHealthForAccount } from "./routing/health-store"; import { forgetCodexFailureWindow } from "./routing/failure-window"; @@ -79,6 +79,7 @@ function observeMainCodexAccountIdentity(accountId: string): boolean { const changed = previousAccountId !== undefined && previousAccountId !== accountId; if (changed) purgeMainCodexAccountRuntimeState(); observeMainQuotaIdentity(accountId); + discardMismatchedMainQuota(); return changed; } @@ -146,6 +147,7 @@ export function initializeMainAccountPolicyBinding(authPath: string): boolean { if (previousAccountId !== undefined && previousAccountId !== accountId) purgeMainCodexAccountRuntimeState(); observeSignedInCodexIdentity(tokens); observeMainQuotaIdentity(accountId); + discardMismatchedMainQuota(); observeMainCodexCollisionIdentity(tokens); return observeMainQuotaCredential(tokens.access_token, accountId) !== undefined; } diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 0088c4968d6..638074b5c42 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -811,6 +811,15 @@ export function getMainPolicyQuota(): StoredAccountQuota | null { return structuredClone(mainPolicyQuota.quota); } +/** An owned identity observation may retire legacy main quota only when its retained policy tag proves replacement. */ +export function discardMismatchedMainQuota(): void { + hydrateAccountQuotasFromDisk(); + const observedIdentity = getObservedMainQuotaIdentityKey(); + // Untagged/malformed snapshots and a matching identity retain their existing restrictions. + if (observedIdentity !== undefined && mainPolicyQuota + && mainPolicyQuota.identityKey !== observedIdentity) clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); +} + export function listAccountQuotas(): IterableIterator<[string, StoredAccountQuota]> { hydrateAccountQuotasFromDisk(); return accountQuota.entries(); diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 9e392566e3d..2f48f4d9e04 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -316,7 +316,7 @@ Actual refresh runs on its independent 30-second timeout so Codex can save its g Only file-backed refresh has a local grant fingerprint lock; keyring claim deadlines are transient. `"opencodex"`, missing or old Codex keeps the guarded writer. Fileless Pool and stored-Direct requests discover the current identity before selection or quota policy, even when prior expiry metadata is usable or the previous account is hard-locked; the selected token is revalidated before dispatch. -`src/codex/account-lifecycle.ts` applies the same identity transition to a validated owned RPC as to an owned file read: a changed account purges prior runtime/quota state and retires its writers, while unchanged or ambiguous identity preserves its quota policy and ambiguous identity cannot clear reauth quarantine. Startup, drain, caller-owned credential and Pool manual-pause fences remain; async stored-Direct materialization, including audio, uses the same resolver, while synchronous readers remain file-only. +`src/codex/account-lifecycle.ts` applies the same identity transition to a validated owned RPC as to an owned file read: a changed account purges prior runtime/quota state and retires its writers, while unchanged or ambiguous identity preserves its quota policy and ambiguous identity cannot clear reauth quarantine. On the first owned observation after restart, `src/codex/quota.ts` discards legacy main quota only when a valid retained policy tag proves a different account; matching, missing or invalid tags retain their restrictions, and other accounts remain untouched. Startup, drain, caller-owned credential and Pool manual-pause fences remain; async stored-Direct materialization, including audio, uses the same resolver, while synchronous readers remain file-only. `tests/codex-integration/native-main-refresh-mode.test.ts` covers these boundaries. Armed test-home guards prevent auto-spawn; `ocx doctor` prints the active mode and reason. ## Preserve signed-in quota diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index d596774507d..a95c03ad3ba 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -441,7 +441,7 @@ consent is covered by `tests/codex-integration/main-account-credit-renewal.test. The policy reads a separately retained identity-tagged quota snapshot, so the legacy rotation cache's six-hour expiry does not silently release a known block. A confirmed account transition -invalidates old evidence, including an account change verified through [the owned Codex RPC](openai-accounts.md#native-main-refresh-ownership). Request-owned bearers are matched only against a credential and effective +invalidates old evidence, including an account change verified through [the owned Codex RPC](openai-accounts.md#native-main-refresh-ownership). A valid persisted policy tag for another account also retires the legacy main selection quota at the first owned observation after restart; same-account and untagged quota remain. Request-owned bearers are matched only against a credential and effective workspace already observed under native ownership; an unrelated or unmatched keyring credential is not attributed to stored main and introduces no physical-main read. Credential equality tags remain process-local and never enter disk, logs, or management DTOs. diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index d8cb4b4fd65..e5a7232619a 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -44,6 +44,11 @@ const QUOTA_PROVENANCE_IMPORT_PROLOGUE = ` import { getAccountQuota, getMainPolicyQuota } from ${JSON.stringify(repoPath("src/codex/quota.ts"))}; import { observeMainQuotaIdentity, matchesMainQuotaCredential } from ${JSON.stringify(repoPath("src/codex/main-account-cache.ts"))}; `; +const KEYRING_QUOTA_RESTART_IMPORTS = `${QUOTA_PROVENANCE_IMPORT_PROLOGUE} + import { resolveCodexAuthContext } from ${JSON.stringify(repoPath("src/codex/auth-context.ts"))}; + import { initializeMainAccountPolicyBinding } from ${JSON.stringify(repoPath("src/codex/account-lifecycle.ts"))}; + import { setNativeMainRefreshResolutionForTests } from ${JSON.stringify(repoPath("src/codex/native-main-refresh-mode.ts"))}; +`; let testDir: string; let previousHome: string | undefined; @@ -339,8 +344,84 @@ describe("main policy quota durability and lifecycle", () => { source: QUOTA_PROVENANCE_IMPORT_PROLOGUE, cwd: repoRoot(), }); + await warmModuleGraph({ graph: "codex/keyring-quota-restart-eval", source: KEYRING_QUOTA_RESTART_IMPORTS, cwd: repoRoot() }); }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + for (const scenario of [ + { name: "verified replacement", account: "fixture-main-b", tag: "known", selected: true }, + { name: "same account", account: "fixture-main-a", tag: "known", selected: false }, + { name: "untagged quota", account: "fixture-main-b", tag: "missing", selected: false }, + { name: "invalid quota tag", account: "fixture-main-b", tag: "invalid", selected: false }, + { name: "unknown RPC identity", account: "", tag: "known", selected: false }, + { name: "manually paused", account: "fixture-main-b", tag: "known", selected: false, paused: true }, + { name: "startup file replacement", account: "fixture-main-b", tag: "known", selected: true, source: "file" }, + { name: "startup same-account file", account: "fixture-main-a", tag: "known", selected: false, source: "file" }, + ]) { + test(`cold keyring Pool preserves quota unless replacement is proven: ${scenario.name}`, () => { + const writer = writerFor("fixture-main-a"); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 100, weeklyResetAt: Date.now() / 1000 + 3600 }, undefined, writer); + setAccountQuotaFromParsed("fixture-stored", { weeklyPercent: 33 }); + const snapshot = JSON.parse(flushPersistence()); + if (scenario.tag === "missing") delete snapshot.mainPolicyQuota; + if (scenario.tag === "invalid") snapshot.mainPolicyQuota.identityKey = "invalid-fixture-identity"; + writeSnapshot(snapshot); + const script = ` + ${KEYRING_QUOTA_RESTART_IMPORTS} + globalThis.fetch = async () => { throw new Error("unexpected fixture network request"); }; + setNativeMainRefreshResolutionForTests({ mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fixture/codex", version: "0.162.1" }); + const before = getAccountQuota("__main__"); + const account = ${JSON.stringify(scenario.account)}; + const token = account ? "header." + Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, + "https://api.openai.com/auth": { chatgpt_account_id: account } })).toString("base64url") + ".fixture" : "opaque-fixture-token"; + const fileOwned = ${JSON.stringify(scenario.source === "file")}; + let initialized; + if (fileOwned) { + const authPath = process.env.CODEX_HOME + "/auth.json"; + await Bun.write(authPath, JSON.stringify({ tokens: { access_token: token, account_id: account } })); + initialized = initializeMainAccountPolicyBinding(authPath); + } + let reads = 0; + const config = { port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: true, + pausedCodexAccountIds: ${JSON.stringify(scenario.paused ? [MAIN] : [])}, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } } }; + let selected = false; + let failure; + try { + const result = await resolveCodexAuthContext(new Headers(), config, "pool", { + nativeMainRefreshDependencies: { codexAuthStatus: async () => { reads++; return { authMethod: "chatgpt", authToken: token, requiresOpenaiAuth: true }; } }, + primeCodexPoolQuotas: async () => {}, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }); + selected = result.kind === "main-pool" && result.chatgptAccountId === account; + } catch (error) { failure = error.name; } + console.log(JSON.stringify({ fixture: "cold-keyring-quota", before, selected, failure, reads, initialized, + legacy: getAccountQuota("__main__"), policy: getMainPolicyQuota(), stored: getAccountQuota("fixture-stored"), + paused: config.pausedCodexAccountIds })); + `; + const child = Bun.spawnSync({ cmd: [process.execPath, "--eval", script], cwd: repoRoot(), env: process.env, + timeout: process.platform === "win32" ? SPAWN_BUDGET_MS - INTERNAL_DEADLINE_MS : 10_000 }); + expect(child.exitCode).toBe(0); + const line = child.stdout.toString().split("\n").find(row => row.startsWith('{"fixture":"cold-keyring-quota"')); + expect(line).toBeDefined(); + const result = JSON.parse(line!); + expect(result.before.weeklyPercent).toBe(100); + expect(result.selected).toBe(scenario.selected); + expect(result.stored.weeklyPercent).toBe(33); + expect(result.paused).toEqual(scenario.paused ? [MAIN] : []); + if (scenario.source === "file") expect(result.initialized).toBe(true); + if (scenario.selected) { + expect(result.reads).toBe(scenario.source === "file" ? 0 : 2); + expect(result.legacy).toBeNull(); + expect(result.policy).toBeNull(); + } else { + expect(result.failure).toBeDefined(); + expect(result.reads).toBe(scenario.paused || scenario.source === "file" ? 0 : 1); + expect(result.legacy.weeklyPercent).toBe(100); + if (scenario.account === "fixture-main-a") expect(result.policy.weeklyPercent).toBe(100); + } + }, SPAWN_BUDGET_MS); + } + for (const resetAt of [undefined, 4_000_000_000]) { test(`restart beyond six hours retains ${resetAt ? "future-reset" : "missing-reset"} policy evidence only for observed A`, () => { const writer = writerFor(); From 7d572b6400e38c4826bc25ccf9109d4eb600e3ab Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Mon, 26 Oct 2026 14:28:57 +0000 Subject: [PATCH 05/15] fix(codex): retain owned keyring identity in quota metadata probes --- .../docs/reference/configuration/server.md | 2 +- src/codex/auth-api/main-account-probe.ts | 61 ++++++-- src/codex/auth-api/pool-mode-gate.ts | 24 ++- src/codex/main-account.ts | 29 +++- structure/providers/openai-accounts.md | 4 +- structure/providers/openai-tiers.md | 1 + .../main-account-credit-renewal.test.ts | 146 +++++++++++++++++- 7 files changed, 234 insertions(+), 33 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 047b58dfa50..d7a06816372 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -43,7 +43,7 @@ runs helper features around provider requests. | `codexMainAccountHardLock?` | `boolean` | `true` | Block new identity-matched main-account requests at their per-window threshold. Only explicit `false` opts out. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). | | `codexMainAccountHardLockThresholds?` | `{ short?: number; long?: number }` | `{ short: 90, long: 98 }` | Independent 5h and weekly/monthly thresholds. Integers 80–100, with `short <= long`. Invalid persisted fields fall back to defaults; the settings API rejects invalid writes before mutation. | | `codexMainAccountHardLockFallback?` | `{ enabled?: boolean; model?: string; reasoningEffort?: string }` | off | Extended hard-lock settings: while the lock blocks and no Codex account can serve the request, qualifying native-Codex-model requests reroute to `model` with `reasoningEffort` instead of failing. `enabled: false` pauses the reroute and keeps the model. An absent section or model keeps the refusal behavior; a usable Pool account still serves first. `reasoningEffort` must be a declared effort; invalid persisted sections degrade to off and the settings API rejects invalid writes before mutation. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). | -| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Who refreshes the native main ChatGPT login in `CODEX_HOME`. `"codex"` asks the Codex binary (app-server `getAuthStatus`, Codex 0.130.0 or newer, found through the same runtime resolution as `ocx sync`, including the ChatGPT app bundle) and never writes `auth.json`; this also works when Codex stores its login in the OS keyring. When the file is absent, native Pool and stored Direct requests consult the current Codex login, including after restart; logout and account changes take effect at the next credential lookup. A verified account change clears the previous account's quota restrictions before selection. On the first lookup after restart, a valid saved quota record must identify a different account before old restrictions are removed. The same account keeps its restrictions, and manual pause settings remain unchanged. `"opencodex"` keeps OpenCodex's guarded `auth.json` writer. Unset picks `"codex"` when a supported binary is found; without one, both unset and `"codex"` fall back to the guarded writer. `ocx doctor` shows the active mode under Native main refresh. | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Who refreshes the native main ChatGPT login in `CODEX_HOME`. `"codex"` asks the Codex binary (app-server `getAuthStatus`, Codex 0.130.0 or newer, found through the same runtime resolution as `ocx sync`, including the ChatGPT app bundle) and never writes `auth.json`; this also works when Codex stores its login in the OS keyring. When the file is absent, native Pool and stored Direct requests consult the current Codex login, including after restart; logout and account changes take effect at the next credential lookup. A verified account change clears the previous account's quota restrictions before selection. On the first lookup after restart, a valid saved quota record must identify a different account before old restrictions are removed. The same account keeps its restrictions, and manual pause settings remain unchanged. Account-card quota reads, startup Pool priming and idle main-quota recovery also use the owned keyring login; passive recovery preserves the traffic reauthentication quarantine. `"opencodex"` keeps OpenCodex's guarded `auth.json` writer. Unset picks `"codex"` when a supported binary is found; without one, both unset and `"codex"` fall back to the guarded writer. `ocx doctor` shows the active mode under Native main refresh. | | `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another's request record. Logs carry a hashed account key only. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. | | `spendPoolAliases?` | `Record` | unset | Explicit historical salted pool-alias to canonical provider mappings. Keep this key at the top level, outside `spend`. See [Historical pool continuity](#historical-pool-continuity). | diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index d3aebe4da53..1ed67345060 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; import { parseCodexCredits, rememberCodexCredits } from "../credits"; import { fetchCodexUsage } from "../quota-query-backoff"; import type { CodexUsageOwner } from "../quota-query-backoff"; @@ -11,8 +13,9 @@ import type { StoredAccountQuota, WhamUsageResponse } from "../quota"; import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; import { getMainChatgptAccountId, readCodexTokensResult } from "../auth-collision"; import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state"; -import { extractAccountId } from "../../oauth/chatgpt"; -import { getMainAccountPlan, isMainAccountTokenVerifiablyLive, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../main-account"; +import { decodeJwtPayload, extractAccountId } from "../../oauth/chatgpt"; +import { getMainAccountPlan, getValidMainAccountToken, isMainAccountTokenVerifiablyLive, mainAccountTokenIsCurrent, mainAccountTokenQuotaWriter, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan, type MainAccountToken } from "../main-account"; +import { resolveCodexHomeDir } from "../home"; import { captureConfigGeneration } from "../../lib/state-store-sweeper"; import { captureMainAccountIdentityGeneration, clearMainAccountInfoCache, getMainAccountInfoCache, getMainQuotaCredentialGeneration, isMainAccountIdentityGenerationLive, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential, setMainAccountCredentialPresence, setMainAccountInfoCache } from "../main-account-cache"; import type { MainQuotaWriter, MainAccountInfo } from "../main-account-cache"; @@ -149,10 +152,19 @@ export async function fetchMainAccountInfo(forceRefresh = false, config?: OcxCon export interface MainAccountInfoProbeOptions { /** Metadata-only renewal never sets or clears the traffic reauth quarantine. */ passive?: boolean; + /** Exact owned result obtained before entering WHAM's shared credential claim. Never serialized. */ + preparedToken?: MainAccountToken | null; } export const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null }; +/** Caller holds the runtime lease, but no shared credential claim: Codex may need an exclusive claim. */ +export async function prepareMainAccountInfoToken(): Promise { + const home = resolveCodexHomeDir(); + if (!existsSync(home) || existsSync(join(home, "auth.json"))) return undefined; + return getValidMainAccountToken({ preserveReauth: true }); +} + export async function retryMainAccountInfoIfIdentityChanged( requestAccountId: string | null, retriesRemaining: number, @@ -189,9 +201,23 @@ export async function fetchMainAccountInfoAttempt( }; } try { + let preparedToken = options.preparedToken; + if (!nativeMainSharedClaimHeld && preparedToken === undefined) { + const home = resolveCodexHomeDir(); + const generation = captureMainAccountIdentityGeneration(); + try { preparedToken = await prepareMainAccountInfoToken(); } + catch (error) { + const absent = error instanceof MainAccountTokenRefreshError && (error.reason === "reauth" || error.credentialObservedAbsent) + && home === resolveCodexHomeDir() && isMainAccountIdentityGenerationLive(generation); + if (absent) setMainAccountCredentialPresence(false); + return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: absent, hasCredential: false, identityGeneration: captureMainAccountIdentityGeneration() }; + } + } + const probeOptions = preparedToken === undefined ? options : { ...options, preparedToken }; const paced = sharedMainQuotaPacingEnabled(config ?? loadConfig()); const operation = async () => ({ - ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh, postReset, paced, options), + ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh, postReset, paced, probeOptions), identityGeneration: captureMainAccountIdentityGeneration(), }); if (nativeMainSharedClaimHeld) return await operation(); @@ -235,20 +261,28 @@ export async function fetchMainAccountInfoWhileOwned( options: MainAccountInfoProbeOptions = {}, ): Promise { const writerGeneration = captureConfigGeneration(); - reconcileMainCodexAccountRuntimeState(); const tokenRead = readCodexTokensResult(); - setMainAccountCredentialPresence(tokenRead.status === "ok"); - if (tokenRead.status !== "ok") { + if (tokenRead.status === "missing" && options.preparedToken && !mainAccountTokenIsCurrent(options.preparedToken)) { + // A stale/copy handoff proves nothing about the current login, including its presence. + return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: false, hasCredential: false }; + } + const owned = tokenRead.status === "missing" && options.preparedToken + && mainAccountTokenIsCurrent(options.preparedToken) ? options.preparedToken : undefined; + // The owned RPC already reconciled identity; a missing file must not erase that observation. + if (!owned) reconcileMainCodexAccountRuntimeState(); + const tokens = tokenRead.status === "ok" ? tokenRead.tokens : owned + ? { access_token: owned.accessToken, account_id: owned.chatgptAccountId, id_token: undefined } : null; + setMainAccountCredentialPresence(tokens !== null); + if (!tokens) { // A local read failure is NOT proof of sign-out: a missing file can be a non-atomic rewrite // gap, and malformed JSON can be a half-written file. Clearing the cache and marking the // account for reauth here destroyed healthy email/plan/quota state and pinned a working // account as unusable. Preserve what we already know and let the caller retry; request - // routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the - // account DTO still reports hasCredential=false while the file is unreadable. + // routing stays fail-closed through its owned credential resolver, and the account DTO + // reports hasCredential=false without either a readable file or a verified keyring handoff. const preserved = getMainAccountInfoCache(); return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false }; } - const tokens = tokenRead.tokens; const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null); const cached = getMainAccountInfoCache(); if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) { @@ -256,13 +290,15 @@ export async function fetchMainAccountInfoWhileOwned( } // Bind quota to the owned credential and the account actually selected by WHAM's header. // A conflicting legacy token/account tuple is not evidence for the new policy. - const mainQuotaWriter = requestAccountId === tokens.account_id + const mainQuotaWriter = owned ? mainAccountTokenQuotaWriter(owned) : requestAccountId === tokens.account_id ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) : undefined; const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration(); /** A disk replacement may have no second probe to advance the credential generation. */ const credentialIsCurrent = (): boolean => { const current = readCodexTokensResult(undefined, { bounded: true }); + if (owned) return current.status === "missing" && mainAccountTokenIsCurrent(owned) + && mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration(); if (current.status !== "ok") return false; const effectiveAccountId = extractAccountId(current.tokens.id_token, current.tokens.access_token) ?? (current.tokens.account_id || null); @@ -313,7 +349,10 @@ export async function fetchMainAccountInfoWhileOwned( const resp = admission.response; quotaPhase = "publish"; if (!resp.ok) { - const authFailure = await classifyMainAuthResponse(resp, isMainAccountTokenVerifiablyLive()); + const ownedExpiry = owned ? decodeJwtPayload(owned.accessToken)?.exp : undefined; + const authFailure = await classifyMainAuthResponse(resp, owned + ? typeof ownedExpiry === "number" && ownedExpiry * 1000 > Date.now() + : isMainAccountTokenVerifiablyLive()); const terminalAuthFailure = authFailure.terminal; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced, options); if (retried) return retried; diff --git a/src/codex/auth-api/pool-mode-gate.ts b/src/codex/auth-api/pool-mode-gate.ts index e024473cdb9..f17621091bc 100644 --- a/src/codex/auth-api/pool-mode-gate.ts +++ b/src/codex/auth-api/pool-mode-gate.ts @@ -10,7 +10,7 @@ import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; import { claimDueCodexQuotaRecoveryProbes, settleCodexQuotaRecoveryProbe } from "../routing"; import { readCodexTokens } from "../auth-collision"; import { isAccountNeedsReauth } from "../account-runtime-state"; -import { getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account"; +import { getValidMainAccountToken, mainAccountTokenIsCurrent, mainAccountTokenQuotaWriter, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account"; import { captureConfigGeneration, registerStateSweepAfterTick } from "../../lib/state-store-sweeper"; import { observeMainQuotaCredential, getMainQuotaCredentialGeneration, captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "../main-account-cache"; import { getMainAccountHardLockStatus, latestMainBlockingResetAt } from "../main-account-hard-lock"; @@ -22,7 +22,7 @@ import type { AdmissionLease } from "../../lib/admission"; import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; import type { PoolQuotaResult } from "./pool-quota-probe"; -import { fetchMainAccountInfoAttempt, fetchMainAccountInfo, MAIN_CACHE_TTL } from "./main-account-probe"; +import { fetchMainAccountInfoAttempt, fetchMainAccountInfo, prepareMainAccountInfoToken, MAIN_CACHE_TTL } from "./main-account-probe"; import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_CACHE_TTL, POOL_QUOTA_REFRESH_CONCURRENCY } from "./pool-quota-probe"; import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config"; import { trackBackgroundWork } from "../../lib/background-work"; @@ -176,12 +176,15 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; const identityGeneration = captureMainAccountIdentityGeneration(); const passive = getMainAccountHardLockStatus(config).state !== "blocked"; + let prepared: Awaited>; try { // Refresh can require an exclusive credential claim: never hold WHAM's shared // claim while obtaining a valid token. The runtime lease spans both operations. - const prepared = await getValidMainAccountToken({ preserveReauth: true }); + prepared = await getValidMainAccountToken({ preserveReauth: true }); if (!prepared) return; - observeMainQuotaCredential(prepared.accessToken, prepared.chatgptAccountId); + if (mainAccountTokenQuotaWriter(prepared)) { + if (!mainAccountTokenIsCurrent(prepared)) return; + } else observeMainQuotaCredential(prepared.accessToken, prepared.chatgptAccountId); } catch { // Native refresh retains its own grant-scoped refusal; global quarantine would outlive it. return; @@ -207,7 +210,7 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise after: currentQueryAfter }; return; } - const result = await fetchMainAccountInfoAttempt(true, 1, lease, false, false, false, config, { passive }); + const result = await fetchMainAccountInfoAttempt(true, 1, lease, false, false, false, config, { passive, preparedToken: prepared }); // Never charge a replacement credential for a late result from its predecessor. if (isMainAccountIdentityGenerationLive(identityGeneration) && credential === getMainQuotaCredentialGeneration()) { @@ -350,21 +353,26 @@ export async function primeCodexPoolQuotas( if (!mainLease) return; try { try { + // The Codex RPC owns an exclusive claim; finish it before WHAM's shared claim. + const preparedToken = await prepareMainAccountInfoToken(); await withNativeMainCredentialClaim(async () => { // Keep one local owner and one cross-process reader from physical // identity reconciliation through WHAM and all quota publication. - (options.reconcileMainAccount ?? reconcileMainCodexAccountRuntimeState)(); + if (preparedToken) { + if (!mainAccountTokenIsCurrent(preparedToken)) return; + } else (options.reconcileMainAccount ?? reconcileMainCodexAccountRuntimeState)(); const quota = getAccountQuota(MAIN_CODEX_ACCOUNT_ID); const observationStale = reason === "priority-failback" && quota && !codexQuotaHasFreshUsage(quota, getMainAccountPlan(), Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS); if (quota && (reason !== "priority-failback" || (!observationStale && Date.now() - quota.updatedAt < MAIN_CACHE_TTL))) return; - if (!(options.readMainTokens ?? readCodexTokens)()) return; + if (!(options.readMainTokens ?? readCodexTokens)() + && !(preparedToken && mainAccountTokenIsCurrent(preparedToken))) return; const bypassCachedQuota = !!observationStale; if (options.fetchMainInfo) await options.fetchMainInfo(bypassCachedQuota); // Cache bypass is passive observation, never an explicit reauthentication recovery. else await fetchMainAccountInfoAttempt(bypassCachedQuota, 1, mainLease, true, - false, false, config); + false, false, config, { preparedToken }); }); } catch (error) { if (!isNativeMainClaimUnavailable(error)) throw error; diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 63b66bcde87..ef62fc3882a 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -1,4 +1,4 @@ -import { captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive, observeMainDecisionCredentialUsable, type MainQuotaWriter } from "./main-account-cache"; +import { captureMainAccountIdentityGeneration, getMainQuotaCredentialGeneration, isMainAccountIdentityGenerationLive, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainDecisionCredentialUsable, type MainQuotaWriter } from "./main-account-cache"; import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chatgpt-refresh-failure"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { createHash } from "node:crypto"; @@ -51,12 +51,23 @@ let jwtPlanAttempted = false; const MAIN_TOKEN_REFRESH_SKEW_MS = 60_000; let beforeMainAuthJsonRenameForTests: (() => void) | null = null; let codexOwnedMainCredential: { home: string; expiresAt: number; refreshRefused?: true } | null = null; -type MainAccountToken = Readonly<{ accessToken: string; chatgptAccountId: string }>; -const codexOwnedQuotaWriters = new WeakMap(); +export type MainAccountToken = Readonly<{ accessToken: string; chatgptAccountId: string }>; +const codexOwnedQuotaWriters = new WeakMap(); /** Exact RPC result provenance; copied/request-created tokens cannot recapture native quota ownership. */ export function mainAccountTokenQuotaWriter(token: MainAccountToken): MainQuotaWriter | undefined { - return codexOwnedQuotaWriters.get(token); + return codexOwnedQuotaWriters.get(token)?.writer; +} + +/** A metadata probe may use only the exact owned observation while its home and credential remain current. */ +export function mainAccountTokenIsCurrent(token: MainAccountToken): boolean { + const proof = codexOwnedQuotaWriters.get(token); + return proof !== undefined && proof.home === resolveCodexHomeDir() + && codexOwnedMainCredential?.home === proof.home && !codexOwnedMainCredential.refreshRefused + && codexOwnedMainCredential.expiresAt > Date.now() + && proof.credentialGeneration === getMainQuotaCredentialGeneration() + && isMainQuotaWriterLive(proof.writer) + && matchesMainQuotaCredential(token.accessToken, token.chatgptAccountId); } function forgetCodexOwnedMainUsability(home: string): void { @@ -98,11 +109,13 @@ export class MainAuthJsonChangedDuringRefreshError extends Error { } export class MainAccountTokenRefreshError extends Error { - constructor(readonly reason: "reauth" | "transient", options?: ErrorOptions) { + readonly credentialObservedAbsent: boolean; + constructor(readonly reason: "reauth" | "transient", options?: ErrorOptions & { credentialObservedAbsent?: boolean }) { super(reason === "reauth" ? "Codex main account needs reauthentication" : "Codex main token refresh did not complete", options); this.name = "MainAccountTokenRefreshError"; + this.credentialObservedAbsent = options?.credentialObservedAbsent === true; } } @@ -685,12 +698,12 @@ async function refreshThroughCodex( const grantKey = locked && mainRefreshGrantKey(locked); if (grantKey) markMainRefreshGrantRejected(grantKey); else codexOwnedMainCredential = { home: codexHome, expiresAt: 0, refreshRefused: true }; - throw new MainAccountTokenRefreshError("reauth"); + throw new MainAccountTokenRefreshError("reauth", { credentialObservedAbsent: true }); } const token = status.authToken; if (status.authMethod !== "chatgpt" || !token) { noteNativeMainRefresh("codex-no-chatgpt-login"); - throw new MainAccountTokenRefreshError("transient"); + throw new MainAccountTokenRefreshError("transient", { credentialObservedAbsent: true }); } // A transient Codex refresh failure still answers with its cached bearer. if (token === rejectedAccessToken || !mainAccessTokenFresh(token, Date.now(), 0)) { @@ -704,7 +717,7 @@ async function refreshThroughCodex( chatgptAccountId: extractAccountId(undefined, token) ?? locked?.chatgptAccountId ?? "", }); const writer = observeCodexOwnedMainAccountCredential(token); - if (writer) codexOwnedQuotaWriters.set(result, writer); + if (writer) codexOwnedQuotaWriters.set(result, { writer, home: codexHome, credentialGeneration: getMainQuotaCredentialGeneration() }); if (writer && dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); const exp = decodeJwtPayload(token)?.exp; codexOwnedMainCredential = { home: codexHome, expiresAt: typeof exp === "number" ? exp * 1000 : Infinity }; diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 2f48f4d9e04..642ddabf23d 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -309,7 +309,7 @@ Codex mode starts a short-lived `codex -c model_provider="openai" app-server` an OpenCodex uses the returned bearer for that request, writes nothing and does not reread auth.json after refresh: keyring mode deletes it. With no file credential, every lookup reads the current login with `refreshToken: false`; an expiring or explicitly rejected bearer requests refresh. -Only home-scoped expiry/refusal metadata survives the lookup. Logout, account switches and unavailable reads cannot reuse an earlier bearer; a weak association gives the exact immutable RPC result its owned quota writer without retaining the result or token. +Only home-scoped expiry/refusal metadata survives the lookup. Logout, account switches and unavailable reads cannot reuse an earlier bearer; a weak association gives the exact immutable RPC result its owned quota writer, home and credential generation without retaining the result or token. A `chatgpt` method with a null token marks the file grant or keyring observation rejected (reauth); a verified new keyring login replaces that observation. An unreachable binary, non-ChatGPT method, unchanged rejected bearer or expired bearer is transient. Read-only lookups honor caller cancellation. Actual refresh runs on its independent 30-second timeout so Codex can save its grant. Both paths pin CODEX_HOME and hold its exclusive claim; home and identity-generation changes reject a delayed observation. @@ -317,7 +317,7 @@ Only file-backed refresh has a local grant fingerprint lock; keyring claim deadl Fileless Pool and stored-Direct requests discover the current identity before selection or quota policy, even when prior expiry metadata is usable or the previous account is hard-locked; the selected token is revalidated before dispatch. `src/codex/account-lifecycle.ts` applies the same identity transition to a validated owned RPC as to an owned file read: a changed account purges prior runtime/quota state and retires its writers, while unchanged or ambiguous identity preserves its quota policy and ambiguous identity cannot clear reauth quarantine. On the first owned observation after restart, `src/codex/quota.ts` discards legacy main quota only when a valid retained policy tag proves a different account; matching, missing or invalid tags retain their restrictions, and other accounts remain untouched. Startup, drain, caller-owned credential and Pool manual-pause fences remain; async stored-Direct materialization, including audio, uses the same resolver, while synchronous readers remain file-only. -`tests/codex-integration/native-main-refresh-mode.test.ts` covers these boundaries. Armed test-home guards prevent auto-spawn; `ocx doctor` prints the active mode and reason. +Account-card metadata, initial Pool quota priming and idle main-quota recovery also use the owned keyring login. `src/codex/auth-api/main-account-probe.ts` obtains an absent-file credential under the runtime lease before taking WHAM's shared claim; recovery reuses its exact prepared result. The handoff must still match the current home, identity writer, bearer HMAC and credential generation, and auth.json must remain absent. Copies, credential ABA, a changed home, a later file credential and stale responses cannot publish quota or reset-recovery proof; an invalid handoff cannot overwrite current credential presence. Verified RPC identity is retained instead of erased by a missing-file reconciliation. A confirmed current-home logout/refusal reports credential absence, while transport uncertainty preserves the prior observation; request error classification is unchanged. Passive preparation and WHAM reads preserve traffic quarantine and manual pause; explicit refresh retains its existing recovery meaning. `tests/codex-integration/main-account-credit-renewal.test.ts` and `tests/codex-integration/native-main-refresh-mode.test.ts` cover these boundaries. Armed test-home guards prevent auto-spawn; `ocx doctor` prints the active mode and reason. ## Preserve signed-in quota diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index a95c03ad3ba..a390b0047c5 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -334,6 +334,7 @@ identity retries: WHAM success and terminal 401/403 responses never set or clear this mode. Native token preparation also preserves the traffic quarantine. Other callers retain the existing auth behavior. Failed or incomplete observations never renew the credit clock; refreshing usage does not redeem reset credits or validate pending accounts through inference. +For keyring-only main, the exact owned token is handed from exclusive preparation to the shared WHAM read with home, identity, credential-generation and bearer checks; no second RPC runs under the shared claim. Stale or copied handoffs and late responses cannot replace quota or revoke a newer login's quarantine. Coverage: `tests/codex-integration/main-account-credit-renewal.test.ts`. Pool accounts use the separate [pool credit renewal](pool-credit-renewal.md) contract; their fresh spendable-evidence requirement is identical. Credit parsing, expiry, partial updates and reset-ticket separation are covered in diff --git a/tests/codex-integration/main-account-credit-renewal.test.ts b/tests/codex-integration/main-account-credit-renewal.test.ts index 10a26d01edc..3eb70aff3ea 100644 --- a/tests/codex-integration/main-account-credit-renewal.test.ts +++ b/tests/codex-integration/main-account-credit-renewal.test.ts @@ -1,20 +1,22 @@ import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { CodexMainAccountCreditsOffError, resolveCodexAuthContext } from "../../src/codex/auth-context"; import { fetchMainAccountInfoAttempt } from "../../src/codex/auth-api/main-account-probe"; -import { registerCodexCooldownRecoveryProbeWorker, runMainAccountHardLockRecovery } from "../../src/codex/auth-api/pool-mode-gate"; +import { clearCodexQuotaPrimeState, primeCodexPoolQuotas, registerCodexCooldownRecoveryProbeWorker, runMainAccountHardLockRecovery } from "../../src/codex/auth-api/pool-mode-gate"; import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; -import { captureMainQuotaWriter, clearMainAccountInfoCache, getMainAccountInfoCache, isMainAccountIdentityGenerationLive, observeMainQuotaCredential, setMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, getMainAccountCredentialPresence, getMainAccountInfoCache, isMainAccountIdentityGenerationLive, isMainQuotaWriterLive, observeMainQuotaCredential, setMainAccountCredentialPresence, setMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { getObservedMainCodexCollisionIdentity } from "../../src/codex/collision-identity"; import { clearAccountQuota, getAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; import { hasSpendableCodexCredits, type CodexSpendableCredits } from "../../src/codex/quota-types"; import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; import { clearCodexUpstreamHealth } from "../../src/codex/routing"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import * as mainAccount from "../../src/codex/main-account"; +import * as nativeRefresh from "../../src/codex/native-main-refresh-mode"; import * as sweeper from "../../src/lib/state-store-sweeper"; import * as quotaQueries from "../../src/codex/quota-query-backoff"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; @@ -130,6 +132,8 @@ beforeEach(() => { afterEach(async () => { mock.restore(); + nativeRefresh.setNativeMainRefreshResolutionForTests(null); + clearCodexQuotaPrimeState(); globalThis.fetch = previousFetch; clearAccountQuota(); clearAccountNeedsReauth(MAIN); @@ -149,6 +153,142 @@ afterEach(async () => { removeTreeWithRetry(home); }); +function keyringLogin(id = accountId) { + rmSync(join(home, "auth.json")); + nativeRefresh.setNativeMainRefreshResolutionForTests({ + mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fixture/codex", version: "0.162.1", + }); + return spyOn(nativeRefresh, "requestCodexAuthStatus").mockImplementation(async () => ({ + authMethod: "chatgpt", authToken: bearer(id), requiresOpenaiAuth: true, + })); +} + +test("keyring-only metadata probing discovers the owned credential and publishes quota", async () => { + clearAccountQuota(MAIN); + const auth = keyringLogin("fixture-keyring"); + fetchWith(async (_url, init) => { + const headers = new Headers(init?.headers); + expect(headers.get("chatgpt-account-id")).toBe("fixture-keyring"); + expect(headers.get("authorization")).toBe(`Bearer ${bearer("fixture-keyring")}`); + return usage(); + }); + const result = await fetchMainAccountInfoAttempt(true, 1, undefined, false, true, false, cfg); + expect(result).toMatchObject({ credentialChecked: true, hasCredential: true, quotaRefresh: { status: "ok" } }); + expect(result.info.plan).toBe("plus"); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(100); + expect(getObservedMainCodexCollisionIdentity()?.accountId).toBe("fixture-keyring"); + expect(auth).toHaveBeenCalledTimes(1); + expect(calls).toEqual([whamUrl]); + expect(existsSync(join(home, "auth.json"))).toBe(false); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + +test("keyring-only credit renewal reuses its prepared credential for WHAM", async () => { + const auth = keyringLogin(); + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([whamUrl]); + expect(auth).toHaveBeenCalledTimes(1); + expect(getMainPolicyQuota()?.credits?.observedAt).toBe(now); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + +test("keyring-only Pool priming observes quota before any traffic", async () => { + clearAccountQuota(MAIN); + const auth = keyringLogin(); + await primeCodexPoolQuotas(cfg, "startup"); + expect(calls).toEqual([whamUrl]); + expect(auth).toHaveBeenCalledTimes(1); + expect(getMainPolicyQuota()?.credits?.observedAt).toBe(now); + expect(getObservedMainCodexCollisionIdentity()?.accountId).toBe(accountId); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + +test("keyring-only passive metadata preserves traffic quarantine and manual pause", async () => { + keyringLogin(); + markAccountNeedsReauth(MAIN); + cfg.pausedCodexAccountIds = [MAIN]; + const result = await fetchMainAccountInfoAttempt(true, 1, undefined, false, true, false, cfg, { passive: true }); + expect(result).toMatchObject({ credentialChecked: true, hasCredential: true, quotaRefresh: { status: "ok" } }); + expect(isAccountNeedsReauth(MAIN)).toBe(true); + expect(cfg.pausedCodexAccountIds).toEqual([MAIN]); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + +test.each(["copy", "credential-aba", "home"])("keyring-only stale handoff cannot overwrite credential presence (%s)", async change => { + const auth = keyringLogin(); + const prepared = (await mainAccount.getValidMainAccountToken({ preserveReauth: true }))!; + expect(mainAccount.mainAccountTokenIsCurrent(prepared)).toBe(true); + setMainAccountCredentialPresence(true); + let candidate = prepared; + if (change === "copy") candidate = { ...prepared }; + if (change === "credential-aba") { + observeMainQuotaCredential(bearer(accountId, true), accountId); + observeMainQuotaCredential(prepared.accessToken, prepared.chatgptAccountId); + } + if (change === "home") { + process.env.CODEX_HOME = join(home, "replacement-home"); + mkdirSync(process.env.CODEX_HOME); + } + expect(mainAccount.mainAccountTokenIsCurrent(candidate)).toBe(false); + const before = getMainPolicyQuota(); + const result = await fetchMainAccountInfoAttempt(true, 0, undefined, false, true, false, cfg, { preparedToken: candidate }); + expect(result).toMatchObject({ credentialChecked: false, hasCredential: false }); + expect(getMainAccountCredentialPresence()).toBe(true); + expect(getMainPolicyQuota()).toEqual(before); + expect(calls).toEqual([]); + expect(auth).toHaveBeenCalledTimes(1); +}); + +test("keyring-only late usage cannot publish or clear a replacement login's quarantine", async () => { + keyringLogin(); + const entered = deferred(), response = deferred(); + fetchWith(async () => { entered.resolve(); return response.promise; }); + const pending = fetchMainAccountInfoAttempt(true, 0, undefined, false, true, false, cfg); + await entered.promise; + const oldWriter = captureMainQuotaWriter(accountId)!; + writeMain("fixture-replacement"); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 7 }, undefined, captureMainQuotaWriter("fixture-replacement")); + markAccountNeedsReauth(MAIN); + const before = getMainPolicyQuota(); + response.resolve(usage()); + const result = await pending; + expect(isMainQuotaWriterLive(oldWriter)).toBe(false); + expect(result.freshQuota).toBeUndefined(); + expect(result.resetRecoveryProof).toBeUndefined(); + expect(getMainPolicyQuota()).toEqual(before); + expect(isAccountNeedsReauth(MAIN)).toBe(true); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + +test("keyring-only live bearer treats an unclassified WHAM 401 as transient", async () => { + keyringLogin(); + fetchWith(async () => Response.json({}, { status: 401 })); + const result = await fetchMainAccountInfoAttempt(true, 0, undefined, false, false, false, cfg); + expect(result.quotaRefresh).toMatchObject({ status: "http_error", httpStatus: 401 }); + expect(result.terminalAuthFailure).toBeUndefined(); + expect(isAccountNeedsReauth(MAIN)).toBe(false); +}); + +test.each(["unavailable", "refused", "logged-out"])("keyring-only credential observation distinguishes absence from failure (%s)", async outcome => { + const auth = keyringLogin(); + await mainAccount.getValidMainAccountToken({ preserveReauth: true }); + setMainAccountCredentialPresence(true); + markAccountNeedsReauth(MAIN); + const before = getMainPolicyQuota(); + auth.mockImplementation(async () => { + if (outcome === "unavailable") throw new Error("fixture RPC unavailable"); + return { authMethod: outcome === "refused" ? "chatgpt" : null, authToken: null, requiresOpenaiAuth: true }; + }); + const result = await fetchMainAccountInfoAttempt(true, 0, undefined, false, false, false, cfg, { passive: true }); + expect(result.credentialChecked).toBe(outcome !== "unavailable"); + expect(result.hasCredential).toBe(false); + expect(getMainAccountCredentialPresence()).toBe(outcome === "unavailable"); + expect(getMainPolicyQuota()).toEqual(before); + expect(isAccountNeedsReauth(MAIN)).toBe(true); + expect(calls).toEqual([]); + expect(getNativeMainProfileRequestCount()).toBe(0); +}); + test("without renewal, five-minute credit expiry produces a local 429", async () => { seed({ hasCredits: true, balance: 5, observedAt: now }); await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")).resolves.toMatchObject({ kind: "main-pool" }); From d40f821813a68d3cd1cb277a2337dc9661279f10 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Tue, 27 Oct 2026 04:53:27 +0900 Subject: [PATCH 06/15] fix(codex): distinguish absent main login from refresh failures Preserve retryable discovery errors for explicit main selection without overriding a recovered credential or current model eligibility. Keep promoted and forced refresh failures distinct from an ordinary no-login observation, and link translated settings to the canonical contract. --- .../docs/ja/reference/configuration/server.md | 1 + .../docs/ko/reference/configuration/server.md | 1 + .../docs/ru/reference/configuration/server.md | 1 + .../zh-cn/reference/configuration/server.md | 1 + src/codex/auth-context.ts | 15 +- src/codex/main-account.ts | 12 +- structure/providers/openai-accounts.md | 4 +- .../native-main-refresh-mode.test.ts | 224 +++++++++++++++++- 8 files changed, 253 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index f7f1665247e..26f2e1637c2 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -26,6 +26,7 @@ description: リスナー、リモート アクセス、アドミッション | `serviceHealthRecovery.enabled?` | `boolean` | `false` | バックグラウンドサービス向けのオプトイン復旧。小さな監視プロセスが `/healthz` を確認し、プロキシが生きたまま連続プローブに約 1 分間まったく応答しない場合に終了させ、サービスマネージャー(タスク スケジューラ、launchd、systemd、WinSW)に再起動させます。管理対象サービスの子プロセスでのみ起動し、停止したプロセスが保持していたリクエストは再送しません。1 時間あたり最大 3 回。再起動が必要です。自動復旧には数値のバインドアドレス(または既定の localhost/ワイルドカード マッピング)が必要です。DNS バインド名は監視されません。記録されたアドレスまたはポートが変更されると監視が停止します。 | | `codexAutoStart?` | `boolean` | `true` | Codex を起動する前に、Codex シムで `ocx ensure` を実行させます。 False を指定すると、操作が行われないことが保証されます。 | | `codexShimAutoRestore?` | `boolean` | `true` |完了した外部 Codex アップデートによってインストールされたシムが置き換えられた後、インストールされているシムを復元します。環境オプトアウト: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | `CODEX_HOME` 内のネイティブ メイン ChatGPT ログインを更新する主体を選択します。自動選択を含む動作の詳細は[英語版 Server fields](/reference/configuration/server/#server-fields)を参照してください。 | | `syncResumeHistory?` | `boolean` | `true` | Codex App 履歴の互換性を元に戻すことができます。元のメタデータは `ocx stop` / `ocx restore` によってバックアップおよび復元されます。 | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` |オフ |認識された Codex ヘルパー/シャドウ呼び出しを、リクエストに設定された推論エフォートを維持したまま選択したモデルにリダイレクトします。デフォルトのソースプレフィックスは `gpt-6-luna`, `gpt-5.6-luna` です。0.144.x 以前のクライアントでは `gpt-5.4-mini` が使われており、`sourceModels` で復元できます。 | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` |使用可能な場合はオン | Web 検索サイドカー オプション。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 145679df1e4..002ab739fb0 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -26,6 +26,7 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, | `serviceHealthRecovery.enabled?` | `boolean` | `false` | 백그라운드 서비스용 선택적 복구입니다. 작은 감시 프로세스가 `/healthz`를 확인하고, 프록시가 살아 있지만 연속 프로브에 약 1분 동안 전혀 응답하지 않으면 종료해 서비스 관리자(작업 스케줄러, launchd, systemd, WinSW)가 다시 시작하게 합니다. 관리되는 서비스 자식 프로세스에서만 시작되며, 멈춘 프로세스가 처리 중이던 요청은 재전송하지 않습니다. 시간당 최대 3회. 재시작이 필요합니다. 자동 복구에는 숫자 바인드 주소(또는 기본 localhost/와일드카드 매핑)가 필요합니다. DNS 바인드 이름은 모니터링하지 않습니다. 기록된 주소 또는 포트가 변경되면 모니터링이 중지됩니다. | | `codexAutoStart?` | `boolean` | `true` | Codex shim이 Codex를 실행하기 전에 `ocx ensure`를 돌리도록 허용합니다. `false`이면 ensure는 아무 작업도 하지 않습니다. | | `codexShimAutoRestore?` | `boolean` | `true` | 완료된 외부 Codex 업데이트가 설치된 shim을 교체한 뒤 복원합니다. 환경 변수로 끌 수 있습니다: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | `CODEX_HOME`의 네이티브 메인 ChatGPT 로그인을 갱신할 주체를 고릅니다. 자동 선택을 포함한 전체 동작은 [Server fields(영문)](/reference/configuration/server/#server-fields)를 참조하세요. | | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App history 호환성입니다. 원래 메타데이터는 `ocx stop` / `ocx restore`가 백업하고 복원합니다. | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | 인식된 Codex 보조/섀도 호출을 요청에 설정된 reasoning effort를 유지한 채 선택한 모델로 다시 보냅니다. 기본 source prefix는 `gpt-6-luna`, `gpt-5.6-luna`입니다. 0.144.x 이하의 이전 클라이언트는 `gpt-5.4-mini`를 사용했으며 `sourceModels`로 복원할 수 있습니다. | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | 웹 검색 사이드카 옵션입니다. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index d66c298b873..580ccb7d9c2 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -27,6 +27,7 @@ description: Listener, удалённый доступ, admission key, тайм | `serviceHealthRecovery.enabled?` | `boolean` | `false` | Необязательное восстановление фоновой службы: небольшой процесс-наблюдатель опрашивает `/healthz` и, если прокси жив, но около минуты не отвечает на идущие подряд пробы, завершает его, чтобы менеджер службы (Планировщик заданий, launchd, systemd, WinSW) перезапустил его. Запускается только в дочернем процессе управляемой службы; запросы зависшего процесса не повторяются. Не более трёх восстановлений в час. Требуется перезапуск. Для автоматического восстановления требуется числовой адрес привязки (или сопоставление localhost/подстановочного адреса по умолчанию); DNS-имена привязки не отслеживаются. Изменение записанного адреса или порта останавливает наблюдение. | | `codexAutoStart?` | `boolean` | `true` | Разрешает shim'у Codex запускать `ocx ensure` перед стартом Codex. При false `ensure` становится no-op. | | `codexShimAutoRestore?` | `boolean` | `true` | Восстанавливает установленный shim после завершённого внешнего обновления Codex, которое заменило его. Для отключения через окружение: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Определяет, кто обновляет основной нативный вход ChatGPT в `CODEX_HOME`. Полное поведение, включая автоматический выбор, см. в [английской таблице Server fields](/reference/configuration/server/#server-fields). | | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. Исходные metadata резервируются и восстанавливаются через `ocx stop` / `ocx restore`. | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Перенаправляет распознанные helper/shadow-call'ы Codex на выбранную модель с сохранением настроенного для запроса reasoning effort. Source-prefix по умолчанию: `gpt-6-luna`, `gpt-5.6-luna`; клиенты до 0.144.x включительно использовали `gpt-5.4-mini`, который можно восстановить через `sourceModels`. | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | Настройки sidecar'а web-search. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 821de23773b..ea69f6a695c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -27,6 +27,7 @@ description: 监听、远程访问、准入密钥、超时、存储、侧车、 | `serviceHealthRecovery.enabled?` | `boolean` | `false` | 后台服务的可选恢复:一个小型守护进程探测 `/healthz`,当代理进程仍存活但在连续探测中约一分钟毫无响应时将其终止,由服务管理器(任务计划程序、launchd、systemd、WinSW)重新启动。仅受管服务子进程会启动它;卡住进程持有的请求不会重放。每小时最多 3 次。需要重启。自动恢复需要数字绑定地址(或默认的 localhost/通配符映射);不监控 DNS 绑定名称。记录的地址或端口发生更改时会停止监控。 | | `codexAutoStart?` | `boolean` | `true` | 允许 Codex shim 在启动 Codex 之前运行 `ocx ensure`。设为 false 会让 ensure 变成无操作。 | | `codexShimAutoRestore?` | `boolean` | `true` | 在完成外部 Codex 更新并覆盖安装的 shim 之后恢复该 shim。环境退出开关:`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | 选择由谁刷新 `CODEX_HOME` 中的原生主 ChatGPT 登录。包括自动选择在内的完整行为见[英文 Server fields](/reference/configuration/server/#server-fields)。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容性。原始元数据会被备份,并由 `ocx stop` / `ocx restore` 恢复。 | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | 将识别出的 Codex 辅助/影子调用重定向到选定模型,并保留为请求配置的推理强度。默认源前缀为 `gpt-6-luna`, `gpt-5.6-luna`;0.144.x 及更早客户端使用 `gpt-5.4-mini`,可通过 `sourceModels` 恢复。 | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | 在可用时启用 | Web 搜索侧车选项。 | diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index d8e01b1f09f..06bac5e5d82 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1487,7 +1487,11 @@ async function resolveCodexAuthContextUnannotated( return context; } if (!selected) { - if (mainDiscoveryFailure !== undefined) throw new CodexAuthContextError(MAIN_CODEX_ACCOUNT_ID, mainDiscoveryFailure); + // After an entitlement-await recovery, current selection evidence outranks the saved + // probe failure; retain it only while main is still unusable (as for fixed selection). + if (mainDiscoveryFailure !== undefined && !getMainAccountCredentialStatus().usable) { + throw new CodexAuthContextError(MAIN_CODEX_ACCOUNT_ID, mainDiscoveryFailure); + } // A retry that excluded a failed Pool account may still use the validated caller-owned // main credential. Treating every exclusion as if main itself had failed strands a healthy // native bearer after the first Pool attempt. Preserve the exactly-once boundary by refusing @@ -1571,6 +1575,15 @@ async function resolveCodexAuthContextUnannotated( ); } if (fixedAccountId !== undefined) { + // Discovery ran for an exact `__main__` selector too, so its saved failure must not be + // downgraded into a blanket unavailability refusal here: propagate it the same way the + // no-selection path above does. After an entitlement-await recovery, stale failure wins + // while the credential is still unusable; a healthy account falls through to the normal + // checks and gets served. + if (accountId === MAIN_CODEX_ACCOUNT_ID && mainDiscoveryFailure !== undefined + && !getMainAccountCredentialStatus().usable) { + throw new CodexAuthContextError(MAIN_CODEX_ACCOUNT_ID, mainDiscoveryFailure); + } if (policy.pausedCodexAccountIds?.includes(accountId)) { throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); } diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index ef62fc3882a..6ac8bf4e55a 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -657,6 +657,9 @@ function inFlightMainAccountToken( * keyring save succeeds, so the RPC answer is the only place the new token exists for us. * The call runs on the refresh timeout alone: killing app-server mid-exchange could retire * the grant before Codex saves its successor. + * + * Returns null when an ordinary read (no locked file credential, nothing upstream rejected) + * observes that Codex has no ChatGPT login at all -- an observed absence, not a refresh failure. */ async function refreshThroughCodex( locked: MainAuthJsonCredential | null, @@ -666,7 +669,7 @@ async function refreshThroughCodex( authStatus: NonNullable, codexHome: string, refresh = true, -): Promise<{ accessToken: string; chatgptAccountId: string }> { +): Promise<{ accessToken: string; chatgptAccountId: string } | null> { // Deferred to keep lifecycle's cache/plan cleanup off this module's load-time import cycle. const { observeCodexOwnedMainAccountCredential } = await import("./account-lifecycle"); if (resolveCodexHomeDir() !== codexHome) throw new MainAuthJsonChangedDuringRefreshError(); @@ -675,12 +678,14 @@ async function refreshThroughCodex( // JWT expiry cannot detect logout or an external keyring account switch. forgetCodexOwnedMainUsability(codexHome); let status: CodexAuthStatus; + let didRefresh = refresh; try { const readSignal = dependencies.signal ? AbortSignal.any([dependencies.signal, refreshTimeout]) : refreshTimeout; status = await authStatus({ refresh, signal: refresh ? refreshTimeout : readSignal }); if (!refresh && status.authMethod === "chatgpt" && status.authToken && !mainAccessTokenFresh(status.authToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) { dependencies.signal?.throwIfAborted(); + didRefresh = true; status = await authStatus({ refresh: true, signal: refreshTimeout }); } } catch (cause) { @@ -703,6 +708,11 @@ async function refreshThroughCodex( const token = status.authToken; if (status.authMethod !== "chatgpt" || !token) { noteNativeMainRefresh("codex-no-chatgpt-login"); + // An ordinary read with no locked credential and nothing rejected observed a real + // absence, not a refresh failure: report no credential instead of a retryable error. + // Once the expiring-bearer answer above promoted this lookup into a real refresh, the + // same no-login outcome is a refresh failure again. + if (locked === null && rejectedAccessToken === undefined && !didRefresh) return null; throw new MainAccountTokenRefreshError("transient", { credentialObservedAbsent: true }); } // A transient Codex refresh failure still answers with its cached bearer. diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 587f33482a1..6e66f31b187 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -311,12 +311,12 @@ OpenCodex uses the returned bearer for that request, writes nothing and does not With no file credential, every lookup reads the current login with `refreshToken: false`; an expiring or explicitly rejected bearer requests refresh. Only home-scoped expiry/refusal metadata survives the lookup. Logout, account switches and unavailable reads cannot reuse an earlier bearer; a weak association gives the exact immutable RPC result its owned quota writer, home and credential generation without retaining the result or token. A `chatgpt` method with a null token marks the file grant or keyring observation rejected (reauth); a verified new keyring login replaces that observation. -An unreachable binary, non-ChatGPT method, unchanged rejected bearer or expired bearer is transient. Read-only lookups honor caller cancellation. +An unreachable binary, unchanged rejected bearer or expired bearer is transient. A non-ChatGPT or missing login answers an ordinary lookup as an observed absence, while a rejected-token or file-locked refresh still reports it transiently. Read-only lookups honor caller cancellation. Actual refresh runs on its independent 30-second timeout so Codex can save its grant. Both paths pin CODEX_HOME and hold its exclusive claim; home and identity-generation changes reject a delayed observation. Only file-backed refresh has a local grant fingerprint lock; keyring claim deadlines are transient. `"opencodex"`, missing or old Codex keeps the guarded writer. Fileless Pool and stored-Direct requests discover the current identity before selection or quota policy, even when prior expiry metadata is usable or the previous account is hard-locked; the selected token is revalidated before dispatch. -`src/codex/account-lifecycle.ts` applies the same identity transition to a validated owned RPC as to an owned file read: a changed account purges prior runtime/quota state and retires its writers, while unchanged or ambiguous identity preserves its quota policy and ambiguous identity cannot clear reauth quarantine. On the first owned observation after restart, `src/codex/quota.ts` discards legacy main quota only when a valid retained policy tag proves a different account; matching, missing or invalid tags retain their restrictions, and other accounts remain untouched. Startup, drain, caller-owned credential and Pool manual-pause fences remain; async stored-Direct materialization, including audio, uses the same resolver, while synchronous readers remain file-only. +A discovery failure is retained as the request's own classified cause for an empty candidacy and for the exact `__main__` selector alike, rather than degrading to a blanket unavailability refusal. It stays evidence only while the credential remains unverifiable: a credential recovered while model-entitlement resolution is in flight is re-checked live, so an already-healthy login answers with the current selection outcome instead of the stale probe error. `src/codex/account-lifecycle.ts` applies the same identity transition to a validated owned RPC as to an owned file read: a changed account purges prior runtime/quota state and retires its writers, while unchanged or ambiguous identity preserves its quota policy and ambiguous identity cannot clear reauth quarantine. On the first owned observation after restart, `src/codex/quota.ts` discards legacy main quota only when a valid retained policy tag proves a different account; matching, missing or invalid tags retain their restrictions, and other accounts remain untouched. Startup, drain, caller-owned credential and Pool manual-pause fences remain; async stored-Direct materialization, including audio, uses the same resolver, while synchronous readers remain file-only. Account-card metadata, initial Pool quota priming and idle main-quota recovery also use the owned keyring login. `src/codex/auth-api/main-account-probe.ts` obtains an absent-file credential under the runtime lease before taking WHAM's shared claim; recovery reuses its exact prepared result. The handoff must still match the current home, identity writer, bearer HMAC and credential generation, and auth.json must remain absent. Copies, credential ABA, a changed home, a later file credential and stale responses cannot publish quota or reset-recovery proof; an invalid handoff cannot overwrite current credential presence. Verified RPC identity is retained instead of erased by a missing-file reconciliation. A confirmed current-home logout/refusal reports credential absence, while transport uncertainty preserves the prior observation; request error classification is unchanged. Passive preparation and WHAM reads preserve traffic quarantine and manual pause; explicit refresh retains its existing recovery meaning. `tests/codex-integration/main-account-credit-renewal.test.ts` and `tests/codex-integration/native-main-refresh-mode.test.ts` cover these boundaries. Armed test-home guards prevent auto-spawn; `ocx doctor` prints the active mode and reason. ## Preserve signed-in quota diff --git a/tests/codex-integration/native-main-refresh-mode.test.ts b/tests/codex-integration/native-main-refresh-mode.test.ts index 902c8b283d6..01cf0981d10 100644 --- a/tests/codex-integration/native-main-refresh-mode.test.ts +++ b/tests/codex-integration/native-main-refresh-mode.test.ts @@ -17,11 +17,14 @@ import { getMainAccountCredentialStatus, getValidMainAccountToken, isMainAccountRefreshGrantRejected, + MAIN_CODEX_ACCOUNT_ID, MainAuthJsonChangedDuringRefreshError, MainAccountTokenRefreshError, } from "../../src/codex/main-account"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { NATIVE_GPT6_ASTRA_MINOR_MODEL } from "../../src/codex/catalog/native-models"; import { codexCredentialMutationEpoch } from "../../src/codex/credential-mutation-epoch"; -import { resolveCodexAuthContext, materializeCodexUpstreamAuthAsync, CodexAuthContextError, CodexMainAccountHardLockError } from "../../src/codex/auth-context"; +import { resolveCodexAuthContext, materializeCodexUpstreamAuthAsync, CodexAuthContextError, CodexMainAccountHardLockError, CodexMainProfileDrainingError, CodexModelAvailabilityError, CodexPoolAuthenticationError } from "../../src/codex/auth-context"; import { applyConfirmedMainCodexAccountTransition, reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; import { captureMainQuotaWriter, isMainQuotaDispatchLive, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; @@ -319,6 +322,223 @@ describe("main account in codex refresh mode (#2999)", () => { expect(failure.cause).toMatchObject({ reason: "transient" }); }); + test("a cold keyring discovery failure reaches the exact __main__ selector as its retryable cause", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const failure = await resolveCodexAuthContext(new Headers(), config, "pool", { + accountId: MAIN_CODEX_ACCOUNT_ID, + nativeMainRefreshDependencies: { codexAuthStatus: async () => { throw new CodexAuthStatusError("fixture unavailable"); } }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }).catch(error => error); + expect(failure).toBeInstanceOf(CodexAuthContextError); + expect(failure).not.toBeInstanceOf(CodexPoolAuthenticationError); + expect(failure.cause).toMatchObject({ reason: "transient" }); + }); + + test("an ordinary keyring read with no Codex login reports absence for the exact __main__ selector", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const { codexAuthStatus } = seam(() => ({ authMethod: null, authToken: null, requiresOpenaiAuth: true })); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const failure = await resolveCodexAuthContext(new Headers(), config, "pool", { + accountId: MAIN_CODEX_ACCOUNT_ID, + nativeMainRefreshDependencies: { codexAuthStatus }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }).catch(error => error); + expect(failure).toBeInstanceOf(CodexPoolAuthenticationError); + expect(failure).not.toBeInstanceOf(CodexAuthContextError); + expect(getMainAccountCredentialStatus().usable).toBe(false); + }); + + test("an ordinary keyring read with no Codex login reports absence in Pool mode", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const { codexAuthStatus } = seam(() => ({ authMethod: null, authToken: null, requiresOpenaiAuth: true })); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const failure = await resolveCodexAuthContext(new Headers(), config, "pool", { + nativeMainRefreshDependencies: { codexAuthStatus }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }).catch(error => error); + expect(failure).toBeInstanceOf(CodexPoolAuthenticationError); + expect(failure).not.toBeInstanceOf(CodexAuthContextError); + }); + + test("an ordinary fileless read with no Codex login returns null instead of a refresh failure", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const { calls, codexAuthStatus } = seam(() => ({ authMethod: null, authToken: null, requiresOpenaiAuth: true })); + expect(await getValidMainAccountToken({ codexAuthStatus })).toBeNull(); + expect(calls).toEqual([{ refresh: false }]); + expect(getMainAccountCredentialStatus().usable).toBe(false); + }); + + test("an expiring-bearer promotion turns the same no-login back into a refresh failure", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const answers: CodexAuthStatus[] = [ + { authMethod: "chatgpt", authToken: expired(), requiresOpenaiAuth: true }, + { authMethod: null, authToken: null, requiresOpenaiAuth: true }, + ]; + let index = 0; + const { calls, codexAuthStatus } = seam(() => answers[index++]); + const failure = await getValidMainAccountToken({ codexAuthStatus }).catch(error => error); + expect(failure).toBeInstanceOf(MainAccountTokenRefreshError); + expect((failure as MainAccountTokenRefreshError).reason).toBe("transient"); + expect(calls).toEqual([{ refresh: false }, { refresh: true }]); + }); + + test("an expiring-bearer promotion keeps a withheld grant at reauth", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const answers: CodexAuthStatus[] = [ + { authMethod: "chatgpt", authToken: expired(), requiresOpenaiAuth: true }, + { authMethod: "chatgpt", authToken: null, requiresOpenaiAuth: true }, + ]; + let index = 0; + const { calls, codexAuthStatus } = seam(() => answers[index++]); + const failure = await getValidMainAccountToken({ codexAuthStatus }).catch(error => error); + expect(failure).toBeInstanceOf(MainAccountTokenRefreshError); + expect((failure as MainAccountTokenRefreshError).reason).toBe("reauth"); + expect(calls).toEqual([{ refresh: false }, { refresh: true }]); + }); + + test("a forced fileless refresh still reports no-login as transient", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const { calls, codexAuthStatus } = seam(() => ({ authMethod: null, authToken: null, requiresOpenaiAuth: true })); + const failure = await forceRefreshMainAccountToken(expired(), { codexAuthStatus }).catch(error => error); + expect(failure).toBeInstanceOf(MainAccountTokenRefreshError); + expect((failure as MainAccountTokenRefreshError).reason).toBe("transient"); + expect(calls).toEqual([{ refresh: true }]); + }); + + test("a refused main-profile claim outranks a saved discovery failure", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const failure = await resolveCodexAuthContext(new Headers(), config, "pool", { + accountId: MAIN_CODEX_ACCOUNT_ID, + nativeMainRefreshDependencies: { codexAuthStatus: async () => { throw new CodexAuthStatusError("fixture unavailable"); } }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => false, release() {} }), + }).catch(error => error); + expect(failure).toBeInstanceOf(CodexMainProfileDrainingError); + }); + + test("a stale discovery failure does not refuse an exact __main__ selector once the credential recovers", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const fresh = fakeChatGptJwt("acct-main"); + let recovered = false; + const { codexAuthStatus } = seam(() => { + if (!recovered) throw new CodexAuthStatusError("fixture unavailable"); + return { authMethod: "chatgpt", authToken: fresh, requiresOpenaiAuth: true }; + }); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const context = await resolveCodexAuthContext(new Headers(), config, "pool", { + accountId: MAIN_CODEX_ACCOUNT_ID, + modelId: NATIVE_GPT6_ASTRA_MINOR_MODEL, + nativeMainRefreshDependencies: { codexAuthStatus }, + resolveCodexModelEntitlements: async () => { + // A real recovery landing during the entitlement await: the read below goes through + // the production credential path, so the saved probe error above is stale evidence. + recovered = true; + await getValidMainAccountToken({ codexAuthStatus }); + return { + modelsByAccount: new Map([[MAIN_CODEX_ACCOUNT_ID, new Set([NATIVE_GPT6_ASTRA_MINOR_MODEL])]]), + clientVersionByAccount: new Map(), + confirmedAccountIds: new Set([MAIN_CODEX_ACCOUNT_ID]), + credentialIdentities: new Map(), + }; + }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }); + expect(context).toMatchObject({ kind: "main-pool", accountId: MAIN_CODEX_ACCOUNT_ID, accessToken: fresh }); + }); + + test("a stale discovery failure does not outrank the current selection answer when nothing can serve", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + const fresh = fakeChatGptJwt("acct-main"); + let recovered = false; + const { codexAuthStatus } = seam(() => { + if (!recovered) throw new CodexAuthStatusError("fixture unavailable"); + return { authMethod: "chatgpt", authToken: fresh, requiresOpenaiAuth: true }; + }); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexAccounts: [], codexMainAccountHardLock: false, + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const failure = await resolveCodexAuthContext(new Headers(), config, "pool", { + modelId: NATIVE_GPT6_ASTRA_MINOR_MODEL, + nativeMainRefreshDependencies: { codexAuthStatus }, + resolveCodexModelEntitlements: async () => { + // Same mid-await recovery as above: the credential verifies healthy, so the saved + // probe error is stale. The roster admits nobody for this model, so main stays + // out of candidacy on CURRENT eligibility evidence and the honest refusal is the + // selection's own answer, not the stale transient. + recovered = true; + await getValidMainAccountToken({ codexAuthStatus }); + return { + modelsByAccount: new Map(), + clientVersionByAccount: new Map(), + confirmedAccountIds: new Set(), + credentialIdentities: new Map(), + }; + }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }).catch(error => error); + expect(failure).toBeInstanceOf(CodexModelAvailabilityError); + expect(failure).not.toBeInstanceOf(CodexAuthContextError); + }); + + test("a failed main discovery still lets a healthy pool account serve the request", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + clearThreadAccountMap(); + const previousHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(root, "opencodex-home"); + mkdirSync(process.env.OPENCODEX_HOME); + try { + saveCodexAccountCredential("pool-a", { + accessToken: "pool-a-bearer", + refreshToken: "pool-a-refresh", + expiresAt: Date.now() + 3_600_000, + chatgptAccountId: "pool_acc", + }); + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", codexMainAccountHardLock: false, + activeCodexAccountId: "pool-a", + codexAccounts: [{ id: "pool-a", email: "pool@example.test", isMain: false, chatgptAccountId: "pool_acc" }], + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" } }, + }; + const context = await resolveCodexAuthContext(new Headers(), config, "pool", { + nativeMainRefreshDependencies: { codexAuthStatus: async () => { throw new CodexAuthStatusError("fixture unavailable"); } }, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + }); + expect(context).toMatchObject({ kind: "pool", accountId: "pool-a" }); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + } + }); + test("a near-expiry keyring read refreshes once through Codex", async () => { setNativeMainRefreshResolutionForTests(CODEX_MODE); const fresh = fakeChatGptJwt("acct-main"); @@ -413,7 +633,7 @@ describe("main account in codex refresh mode (#2999)", () => { await getValidMainAccountToken({ codexAuthStatus }); status = { authMethod: null, authToken: null, requiresOpenaiAuth: true }; - await expect(getValidMainAccountToken({ codexAuthStatus })).rejects.toBeInstanceOf(MainAccountTokenRefreshError); + expect(await getValidMainAccountToken({ codexAuthStatus })).toBeNull(); expect(getMainAccountCredentialStatus().usable).toBe(false); }); From 99ef74cb2ebafc518179943f515fa860fdea33a2 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Tue, 27 Oct 2026 06:52:02 +0900 Subject: [PATCH 07/15] fix(codex): treat proven missing main home as absent --- src/codex/main-account.ts | 49 ++++++++++- .../native-main-refresh-mode.test.ts | 82 ++++++++++++++++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 6ac8bf4e55a..7af99f276d7 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -2,8 +2,8 @@ import { captureMainAccountIdentityGeneration, getMainQuotaCredentialGeneration, import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chatgpt-refresh-failure"; import { readBoundedResponseBody } from "../lib/bounded-body"; import { createHash } from "node:crypto"; -import { readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { lstatSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; import { readCodexTokens } from "./auth-collision"; import { CHATGPT_CLIENT_ID, @@ -31,6 +31,7 @@ import { import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch"; import { withNativeMainExclusiveClaim } from "./native-main-claim"; import { resolveNativeProfileContext } from "./native-profile-store"; +import { NativeProfileError } from "./native-profile-types"; import { isNativeMainTrafficBlocked } from "./native-profile-startup"; import { loadConfig } from "../config"; import { @@ -475,6 +476,37 @@ export function beginNativeMainReauth(): { }; } +/** + * True only when CODEX_HOME provably does not exist: the resolved path itself + * has no directory entry and the nearest existing ancestor resolves to a real + * directory. resolve() normalizes the string without dereferencing links, so + * lstat sees each component itself -- an existing entry still counts as + * existing, and a link in the chain must stat to a directory before the absence + * below it counts as genuine. EACCES, a file where a directory belongs, and a + * dangling link all stay unproven, which keeps the caller fail closed. + */ +function codexHomeProvenMissing(codexHome: string): boolean { + const target = resolve(codexHome); + let cursor = target; + for (;;) { + try { + lstatSync(cursor); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; + const parent = dirname(cursor); + if (parent === cursor) return false; + cursor = parent; + } + } + if (cursor === target) return false; + try { + return statSync(cursor).isDirectory(); + } catch { + return false; + } +} + /** Resolve the main account access token from the owned auth.json read, refreshing through the token endpoint when needed, or return null when no usable credential exists. */ async function resolveMainAccountToken( dependencies: NativeMainRefreshDependencies = {}, @@ -501,6 +533,19 @@ async function resolveMainAccountToken( ), { waitMs: 30_000, signal }); } catch (cause) { forgetCodexOwnedMainUsability(codexHome); + // A proven-absent home is the same observed absence as a missing + // auth.json: no credential exists to claim or to refresh. Every other + // CODEX_HOME_UNAVAILABLE cause -- an entry that exists but cannot be + // verified, a dangling link, an untraversable parent -- stays fail + // closed; a blanket existsSync check would misclassify them as absence. + if (cause instanceof NativeProfileError && cause.code === "CODEX_HOME_UNAVAILABLE" + && codexHomeProvenMissing(codexHome)) { + // Mode discovery awaited: absence must still belong to this caller and home. + dependencies.signal?.throwIfAborted(); + if (resolveCodexHomeDir() !== codexHome) throw new MainAuthJsonChangedDuringRefreshError(); + if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-credential"); + return null; + } if (refreshTimeout.aborted && !dependencies.signal?.aborted) { throw new MainAccountTokenRefreshError("transient", { cause }); } diff --git a/tests/codex-integration/native-main-refresh-mode.test.ts b/tests/codex-integration/native-main-refresh-mode.test.ts index 01cf0981d10..4d98b373f4a 100644 --- a/tests/codex-integration/native-main-refresh-mode.test.ts +++ b/tests/codex-integration/native-main-refresh-mode.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import * as nativeMainRefreshMode from "../../src/codex/native-main-refresh-mode"; import { CODEX_AUTH_STATUS_MIN_VERSION, CodexAuthStatusError, @@ -384,6 +385,85 @@ describe("main account in codex refresh mode (#2999)", () => { expect(getMainAccountCredentialStatus().usable).toBe(false); }); + test("a missing CODEX_HOME is the same observed absence: no claim, no RPC, null", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + process.env.CODEX_HOME = join(root, "never-created", "codex-home"); + const { calls, codexAuthStatus } = seam(() => { throw new Error("must not be called"); }); + expect(await getValidMainAccountToken({ codexAuthStatus })).toBeNull(); + expect(calls).toEqual([]); + }); + + test("a forced fileless refresh over a missing CODEX_HOME still reports the absence", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + process.env.CODEX_HOME = join(root, "never-created"); + const { calls, codexAuthStatus } = seam(() => { throw new Error("must not be called"); }); + expect(await forceRefreshMainAccountToken(expired(), { codexAuthStatus })).toBeNull(); + expect(calls).toEqual([]); + }); + + test.each(["cancel", "home-change"] as const)("missing-home absence does not mask %s during mode discovery", async change => { + process.env.CODEX_HOME = join(root, "never-created"); + const controller = new AbortController(); + const cancellation = new Error("cancelled during mode discovery"); + let started!: () => void; + let release!: () => void; + const entered = new Promise(resolve => { started = resolve; }); + const held = new Promise(resolve => { release = () => resolve(CODEX_MODE); }); + const mode = spyOn(nativeMainRefreshMode, "resolveNativeMainRefreshMode").mockImplementation(() => { + started(); + return held; + }); + const { calls, codexAuthStatus } = seam(() => { throw new Error("must not be called"); }); + const observed = getValidMainAccountToken({ codexAuthStatus, signal: controller.signal }).then( + value => ({ kind: "resolved" as const, value }), + (error: unknown) => ({ kind: "rejected" as const, error }), + ); + try { + await entered; + if (change === "cancel") controller.abort(cancellation); + else process.env.CODEX_HOME = home; + release(); + const outcome = await observed; + expect(outcome.kind).toBe("rejected"); + if (outcome.kind !== "rejected") throw new Error("expected the pending lookup to reject"); + if (change === "cancel") expect(outcome.error).toBe(cancellation); + else expect(outcome.error).toBeInstanceOf(MainAuthJsonChangedDuringRefreshError); + expect(calls).toEqual([]); + } finally { + release(); + await observed; + mode.mockRestore(); + } + }); + + test("a CODEX_HOME that exists but is not a directory stays fail closed", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const file = join(root, "codex-home-is-a-file"); + writeFileSync(file, "not a directory"); + process.env.CODEX_HOME = file; + const { calls, codexAuthStatus } = seam(() => { throw new Error("must not be called"); }); + await expect(getValidMainAccountToken({ codexAuthStatus })) + .rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + expect(calls).toEqual([]); + }); + + test("a dangling CODEX_HOME link stays fail closed, including through a dangling parent", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const dangling = join(root, "dangling-home"); + symlinkSync(join(root, "gone"), dangling, process.platform === "win32" ? "junction" : "dir"); + process.env.CODEX_HOME = dangling; + const { calls, codexAuthStatus } = seam(() => { throw new Error("must not be called"); }); + await expect(getValidMainAccountToken({ codexAuthStatus })) + .rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + + // A missing child under a dangling parent must not read as an absent home: + // the entry that broke the chain exists, so the verdict stays unsafe. + process.env.CODEX_HOME = join(dangling, "child"); + await expect(getValidMainAccountToken({ codexAuthStatus })) + .rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + expect(calls).toEqual([]); + }); + test("an expiring-bearer promotion turns the same no-login back into a refresh failure", async () => { setNativeMainRefreshResolutionForTests(CODEX_MODE); const answers: CodexAuthStatus[] = [ From 4e482170edafabed030a1e06de5424d5a8c50f1d Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Tue, 27 Oct 2026 07:50:27 +0900 Subject: [PATCH 08/15] test(codex): accept native ENOTDIR for invalid main homes --- .../native-main-refresh-mode.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/codex-integration/native-main-refresh-mode.test.ts b/tests/codex-integration/native-main-refresh-mode.test.ts index 4d98b373f4a..6277f0bd8bd 100644 --- a/tests/codex-integration/native-main-refresh-mode.test.ts +++ b/tests/codex-integration/native-main-refresh-mode.test.ts @@ -442,9 +442,16 @@ describe("main account in codex refresh mode (#2999)", () => { writeFileSync(file, "not a directory"); process.env.CODEX_HOME = file; const { calls, codexAuthStatus } = seam(() => { throw new Error("must not be called"); }); - await expect(getValidMainAccountToken({ codexAuthStatus })) - .rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + const before = readFileSync(file); + const failure = await getValidMainAccountToken({ codexAuthStatus }).then( + () => { throw new Error("expected a non-directory CODEX_HOME to be rejected"); }, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(Error); + // Linux can reject file/auth.json at lstat before the profile-context validator runs. + expect(["CODEX_HOME_UNAVAILABLE", "ENOTDIR"]).toContain((failure as NodeJS.ErrnoException).code); expect(calls).toEqual([]); + expect(readFileSync(file)).toEqual(before); }); test("a dangling CODEX_HOME link stays fail closed, including through a dangling parent", async () => { From 22f6b33761c83d312328a8b9c67249b3d9eed84e Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:55:36 +0900 Subject: [PATCH 09/15] fix(codex): keep a refused native-main bearer refused Retain home-scoped, domain-separated SHA-256 fingerprints when Codex withholds a native-main bearer after refresh. Preserve refusal across ordinary reads, ambiguous identity answers, and home switches; clear it only for a verified successor or a committed verified login. Keep bearer material out of long-lived refusal state and preserve the existing fences. Cover rejected-bearer reappearance, opaque and conflicting identity answers, and a home round trip, followed by same-account successor recovery. Update the native-main ownership contract. Verification: new regressions fail on PR #7774 plus the dev merge with the source fix stashed (0 pass, 4 fail), then pass with the fix. The five focused files pass 217 tests with 5 Windows-only skips. Typecheck, structure:check, privacy:scan, and diff whitespace checks pass. Full/test:changed suites were not run under the critical-load restriction. Co-authored-by: Epinephrine --- src/codex/main-account.ts | 25 ++++++++++++----- structure/providers/openai-accounts.md | 6 ++--- .../native-main-refresh-mode.test.ts | 27 ++++++++++++++++--- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 7af99f276d7..76a3e999370 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -51,7 +51,9 @@ let mainAccountPlan: string | null = null; let jwtPlanAttempted = false; const MAIN_TOKEN_REFRESH_SKEW_MS = 60_000; let beforeMainAuthJsonRenameForTests: (() => void) | null = null; -let codexOwnedMainCredential: { home: string; expiresAt: number; refreshRefused?: true } | null = null; +let codexOwnedMainCredential: { home: string; expiresAt: number } | null = null; +// Refusal survives uncertain reads and home switches; never retain the rejected bearer. +const codexOwnedMainRefusals = new Map>(); export type MainAccountToken = Readonly<{ accessToken: string; chatgptAccountId: string }>; const codexOwnedQuotaWriters = new WeakMap(); @@ -64,7 +66,7 @@ export function mainAccountTokenQuotaWriter(token: MainAccountToken): MainQuotaW export function mainAccountTokenIsCurrent(token: MainAccountToken): boolean { const proof = codexOwnedQuotaWriters.get(token); return proof !== undefined && proof.home === resolveCodexHomeDir() - && codexOwnedMainCredential?.home === proof.home && !codexOwnedMainCredential.refreshRefused + && codexOwnedMainCredential?.home === proof.home && !codexOwnedMainRefusals.has(proof.home) && codexOwnedMainCredential.expiresAt > Date.now() && proof.credentialGeneration === getMainQuotaCredentialGeneration() && isMainQuotaWriterLive(proof.writer) @@ -72,7 +74,7 @@ export function mainAccountTokenIsCurrent(token: MainAccountToken): boolean { } function forgetCodexOwnedMainUsability(home: string): void { - if (codexOwnedMainCredential?.home === home && !codexOwnedMainCredential.refreshRefused) codexOwnedMainCredential = null; + if (codexOwnedMainCredential?.home === home) codexOwnedMainCredential = null; } type MainAuthJsonCredential = { @@ -271,7 +273,8 @@ export interface MainAccountCredentialStatus { export function getMainAccountCredentialStatus(now = Date.now()): MainAccountCredentialStatus { const current = readMainAuthJsonCredential(); const owned = !current && codexOwnedMainCredential?.home === resolveCodexHomeDir() ? codexOwnedMainCredential : null; - const refreshGrantRejected = credentialRefreshGrantRejected(current) || owned?.refreshRefused === true; + const refreshGrantRejected = credentialRefreshGrantRejected(current) + || (!current && codexOwnedMainRefusals.has(resolveCodexHomeDir())); const hasRefreshGrant = !!current?.refreshToken && !refreshGrantRejected; const ownedUsable = !!owned && owned.expiresAt > now; const usable = !refreshGrantRejected @@ -469,6 +472,7 @@ export function beginNativeMainReauth(): { if (options.signal?.aborted) throw options.signal.reason; assertMainAuthJsonSnapshotUnchanged(expected); persistNativeMainReauthTokens(expected, tokens); + codexOwnedMainRefusals.delete(resolveCodexHomeDir()); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); return { chatgptAccountId: tokens.chatgptAccountId }; }, { waitMs: 30_000, signal: options.signal }); @@ -724,6 +728,7 @@ async function refreshThroughCodex( forgetCodexOwnedMainUsability(codexHome); let status: CodexAuthStatus; let didRefresh = refresh; + let refreshBearer = rejectedAccessToken ?? locked?.accessToken; try { const readSignal = dependencies.signal ? AbortSignal.any([dependencies.signal, refreshTimeout]) : refreshTimeout; status = await authStatus({ refresh, signal: refresh ? refreshTimeout : readSignal }); @@ -731,6 +736,7 @@ async function refreshThroughCodex( && !mainAccessTokenFresh(status.authToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) { dependencies.signal?.throwIfAborted(); didRefresh = true; + refreshBearer = status.authToken; status = await authStatus({ refresh: true, signal: refreshTimeout }); } } catch (cause) { @@ -747,7 +753,9 @@ async function refreshThroughCodex( noteNativeMainRefresh("codex-reauth"); const grantKey = locked && mainRefreshGrantKey(locked); if (grantKey) markMainRefreshGrantRejected(grantKey); - else codexOwnedMainCredential = { home: codexHome, expiresAt: 0, refreshRefused: true }; + const refusedBearers = codexOwnedMainRefusals.get(codexHome) ?? new Set(); + if (refreshBearer) refusedBearers.add(sha256(`opencodex:native-main:refused-bearer:v1\0${refreshBearer}`)); + codexOwnedMainRefusals.set(codexHome, refusedBearers); throw new MainAccountTokenRefreshError("reauth", { credentialObservedAbsent: true }); } const token = status.authToken; @@ -760,18 +768,23 @@ async function refreshThroughCodex( if (locked === null && rejectedAccessToken === undefined && !didRefresh) return null; throw new MainAccountTokenRefreshError("transient", { credentialObservedAbsent: true }); } + if (codexOwnedMainRefusals.get(codexHome)?.has(sha256(`opencodex:native-main:refused-bearer:v1\0${token}`))) { + throw new MainAccountTokenRefreshError("reauth"); + } // A transient Codex refresh failure still answers with its cached bearer. if (token === rejectedAccessToken || !mainAccessTokenFresh(token, Date.now(), 0)) { noteNativeMainRefresh("codex-transient"); throw new MainAccountTokenRefreshError("transient"); } - noteNativeMainRefresh("codex-ok"); if (dependencies.signal?.aborted) throw dependencies.signal.reason; const result = Object.freeze({ accessToken: token, chatgptAccountId: extractAccountId(undefined, token) ?? locked?.chatgptAccountId ?? "", }); const writer = observeCodexOwnedMainAccountCredential(token); + if (codexOwnedMainRefusals.has(codexHome) && !writer) throw new MainAccountTokenRefreshError("reauth"); + if (writer) codexOwnedMainRefusals.delete(codexHome); + noteNativeMainRefresh("codex-ok"); if (writer) codexOwnedQuotaWriters.set(result, { writer, home: codexHome, credentialGeneration: getMainQuotaCredentialGeneration() }); if (writer && dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); const exp = decodeJwtPayload(token)?.exp; diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index a290d495742..fed0124ce83 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -309,9 +309,9 @@ Codex mode starts a short-lived `codex -c model_provider="openai" app-server` an OpenCodex uses the returned bearer for that request, writes nothing and does not reread auth.json after refresh: keyring mode deletes it. With no file credential, every lookup reads the current login with `refreshToken: false`; an expiring or explicitly rejected bearer requests refresh. -Only home-scoped expiry/refusal metadata survives the lookup. Logout, account switches and unavailable reads cannot reuse an earlier bearer; a weak association gives the exact immutable RPC result its owned quota writer, home and credential generation without retaining the result or token. -A `chatgpt` method with a null token marks the file grant or keyring observation rejected (reauth); a verified new keyring login replaces that observation. -An unreachable binary, unchanged rejected bearer or expired bearer is transient. A non-ChatGPT or missing login answers an ordinary lookup as an observed absence, while a rejected-token or file-locked refresh still reports it transiently. Read-only lookups honor caller cancellation. +Only home-scoped expiry/refusal metadata survives the lookup. Keyring refusal retains a domain-separated SHA-256 fingerprint of the rejected bearer, never the bearer itself; uncertain reads and home switches preserve it. Logout, account switches and unavailable reads cannot reuse an earlier bearer; a weak association gives the exact immutable RPC result its owned quota writer, home and credential generation without retaining the result or token. +A `chatgpt` method with a null token marks the file grant or keyring observation rejected (reauth). Reappearance of that bearer, or an opaque or conflicting identity answer, preserves refusal and unusability. Only a different bearer with verified identity or a committed verified login clears keyring refusal. +Without prior permanent refusal, an unreachable binary, unchanged rejected bearer or expired bearer is transient. A non-ChatGPT or missing login answers an ordinary lookup as an observed absence, while a rejected-token or file-locked refresh still reports it transiently. Read-only lookups honor caller cancellation. Actual refresh runs on its independent 30-second timeout so Codex can save its grant. Both paths pin CODEX_HOME and hold its exclusive claim; home and identity-generation changes reject a delayed observation. Only file-backed refresh has a local grant fingerprint lock; keyring claim deadlines are transient. `"opencodex"`, missing or old Codex keeps the guarded writer. diff --git a/tests/codex-integration/native-main-refresh-mode.test.ts b/tests/codex-integration/native-main-refresh-mode.test.ts index 6277f0bd8bd..4f00cd2d115 100644 --- a/tests/codex-integration/native-main-refresh-mode.test.ts +++ b/tests/codex-integration/native-main-refresh-mode.test.ts @@ -724,7 +724,7 @@ describe("main account in codex refresh mode (#2999)", () => { expect(getMainAccountCredentialStatus().usable).toBe(false); }); - test("a keyring refresh refusal cannot resurrect the rejected bearer", async () => { + test.each(["rejected-bearer", "opaque", "conflicting-identity", "home-roundtrip"])("a keyring refresh refusal stays quarantined until a verified successor (%s)", async scenario => { setNativeMainRefreshResolutionForTests(CODEX_MODE); seedAuth(expired()); const rejected = fakeChatGptJwt("acct-main"); @@ -740,9 +740,30 @@ describe("main account in codex refresh mode (#2999)", () => { expect(getMainAccountCredentialStatus().usable).toBe(false); expect(isMainAccountRefreshGrantRejected()).toBe(true); await expect(getValidMainAccountToken({ codexAuthStatus })).rejects.toMatchObject({ reason: "reauth" }); - status = { authMethod: "chatgpt", authToken: fakeChatGptJwt("acct-new-login"), requiresOpenaiAuth: true }; - expect((await getValidMainAccountToken({ codexAuthStatus }))?.chatgptAccountId).toBe("acct-new-login"); + if (scenario === "home-roundtrip") { + const otherHome = join(root, "other-home"); + mkdirSync(otherHome); + process.env.CODEX_HOME = otherHome; + try { + expect(isMainAccountRefreshGrantRejected()).toBe(false); + expect(await getValidMainAccountToken({ codexAuthStatus: async () => ({ + authMethod: "chatgpt", authToken: rejected, requiresOpenaiAuth: true, + }) })).toEqual({ accessToken: rejected, chatgptAccountId: "acct-main" }); + } finally { + process.env.CODEX_HOME = home; + } + } + const uncertain = scenario === "rejected-bearer" || scenario === "home-roundtrip" ? rejected : scenario === "opaque" ? "opaque-fixture-token" + : fakeChatGptJwt("acct-main", { chatgpt_account_id: "acct-conflicting" }); + status = { authMethod: "chatgpt", authToken: uncertain, requiresOpenaiAuth: true }; + await expect(getValidMainAccountToken({ codexAuthStatus })).rejects.toMatchObject({ reason: "reauth" }); + expect(getMainAccountCredentialStatus()).toMatchObject({ refreshGrantRejected: true, usable: false }); + // A different bearer for the same verified identity is sufficient successor evidence. + const successor = fakeChatGptJwt("acct-main", { jti: "verified-successor" }); + status = { authMethod: "chatgpt", authToken: successor, requiresOpenaiAuth: true }; + expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: successor, chatgptAccountId: "acct-main" }); expect(isMainAccountRefreshGrantRejected()).toBe(false); + expect(getMainAccountCredentialStatus().usable).toBe(true); }); test("a chatgpt login with a withheld token means reauthentication", async () => { From 7a30a4425e8b8b745078730503f3296a1d4ff7a4 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:05:17 +0900 Subject: [PATCH 10/15] fix(codex): bind native-main refusal to the observed bearer and keep retry quota ownership --- src/codex/main-account.ts | 14 ++-- src/codex/native-main-refusals.ts | 75 ++++++++++++++++++ src/server/responses/compact.ts | 2 + src/server/responses/core-auth.ts | 3 +- structure/providers/openai-accounts.md | 4 +- .../native-main-refresh-mode.test.ts | 78 +++++++++++++++++++ .../responses-native-main-refresh.test.ts | 63 +++++++++++++++ 7 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 src/codex/native-main-refusals.ts diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 76a3e999370..bba4409139a 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -34,6 +34,7 @@ import { resolveNativeProfileContext } from "./native-profile-store"; import { NativeProfileError } from "./native-profile-types"; import { isNativeMainTrafficBlocked } from "./native-profile-startup"; import { loadConfig } from "../config"; +import { nativeMainRefusals as codexOwnedMainRefusals } from "./native-main-refusals"; import { requestCodexAuthStatus, resolveNativeMainRefreshMode, @@ -52,8 +53,6 @@ let jwtPlanAttempted = false; const MAIN_TOKEN_REFRESH_SKEW_MS = 60_000; let beforeMainAuthJsonRenameForTests: (() => void) | null = null; let codexOwnedMainCredential: { home: string; expiresAt: number } | null = null; -// Refusal survives uncertain reads and home switches; never retain the rejected bearer. -const codexOwnedMainRefusals = new Map>(); export type MainAccountToken = Readonly<{ accessToken: string; chatgptAccountId: string }>; const codexOwnedQuotaWriters = new WeakMap(); @@ -472,7 +471,7 @@ export function beginNativeMainReauth(): { if (options.signal?.aborted) throw options.signal.reason; assertMainAuthJsonSnapshotUnchanged(expected); persistNativeMainReauthTokens(expected, tokens); - codexOwnedMainRefusals.delete(resolveCodexHomeDir()); + codexOwnedMainRefusals.verifiedLogin(resolveCodexHomeDir()); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); return { chatgptAccountId: tokens.chatgptAccountId }; }, { waitMs: 30_000, signal: options.signal }); @@ -753,9 +752,7 @@ async function refreshThroughCodex( noteNativeMainRefresh("codex-reauth"); const grantKey = locked && mainRefreshGrantKey(locked); if (grantKey) markMainRefreshGrantRejected(grantKey); - const refusedBearers = codexOwnedMainRefusals.get(codexHome) ?? new Set(); - if (refreshBearer) refusedBearers.add(sha256(`opencodex:native-main:refused-bearer:v1\0${refreshBearer}`)); - codexOwnedMainRefusals.set(codexHome, refusedBearers); + codexOwnedMainRefusals.refuse(codexHome, refreshBearer); throw new MainAccountTokenRefreshError("reauth", { credentialObservedAbsent: true }); } const token = status.authToken; @@ -768,7 +765,7 @@ async function refreshThroughCodex( if (locked === null && rejectedAccessToken === undefined && !didRefresh) return null; throw new MainAccountTokenRefreshError("transient", { credentialObservedAbsent: true }); } - if (codexOwnedMainRefusals.get(codexHome)?.has(sha256(`opencodex:native-main:refused-bearer:v1\0${token}`))) { + if (codexOwnedMainRefusals.refuses(codexHome, token)) { throw new MainAccountTokenRefreshError("reauth"); } // A transient Codex refresh failure still answers with its cached bearer. @@ -783,7 +780,8 @@ async function refreshThroughCodex( }); const writer = observeCodexOwnedMainAccountCredential(token); if (codexOwnedMainRefusals.has(codexHome) && !writer) throw new MainAccountTokenRefreshError("reauth"); - if (writer) codexOwnedMainRefusals.delete(codexHome); + // Keep only a fingerprint of the last owned bearer, even across uncertain reads. + codexOwnedMainRefusals.observe(codexHome, token); noteNativeMainRefresh("codex-ok"); if (writer) codexOwnedQuotaWriters.set(result, { writer, home: codexHome, credentialGeneration: getMainQuotaCredentialGeneration() }); if (writer && dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); diff --git a/src/codex/native-main-refusals.ts b/src/codex/native-main-refusals.ts new file mode 100644 index 00000000000..2999964d87b --- /dev/null +++ b/src/codex/native-main-refusals.ts @@ -0,0 +1,75 @@ +import { createHash } from "node:crypto"; + +const MAX_HOMES = 8; +const MAX_FINGERPRINTS = 4; +type HomeState = { observed?: string; refused: boolean; unknown: boolean; fingerprints: Set }; + +/** Bounded process-local metadata only. Losing refusal history always strengthens admission. */ +export class NativeMainRefusals { + private readonly homes = new Map(); + private evictedRefusal = false; + + private state(home: string): HomeState { + const existing = this.homes.get(home); + if (existing) return existing; + if (this.homes.size === MAX_HOMES) { + // Prefer forgetting harmless observations; never forget a refusal without closing + // admission for every untracked home (a bounded map cannot remember evicted names). + const oldest = [...this.homes].find(([, value]) => !value.refused) ?? this.homes.entries().next().value!; + if (oldest[1].refused) this.evictedRefusal = true; + this.homes.delete(oldest[0]); + } + const state: HomeState = { refused: this.evictedRefusal, unknown: this.evictedRefusal, fingerprints: new Set() }; + this.homes.set(home, state); + return state; + } + + has(home: string): boolean { + return this.homes.get(home)?.refused ?? this.evictedRefusal; + } + + refuses(home: string, bearer: string): boolean { + const state = this.homes.get(home); + return state ? state.refused && (state.unknown || state.fingerprints.has(fingerprint(bearer))) : this.evictedRefusal; + } + + refuse(home: string, bearer?: string): void { + const state = this.state(home); + state.refused = true; + const candidates = [state.observed, bearer ? fingerprint(bearer) : undefined]; + for (const candidate of candidates) { + if (!candidate || state.unknown || state.fingerprints.has(candidate)) continue; + if (state.fingerprints.size === MAX_FINGERPRINTS) { + state.unknown = true; + state.fingerprints.clear(); + break; + } + state.fingerprints.add(candidate); + } + if (state.fingerprints.size === 0) state.unknown = true; + } + + /** Remember an accepted owned RPC; the caller verifies identity for refusal recovery. */ + observe(home: string, bearer: string): void { + const state = this.state(home); + state.observed = fingerprint(bearer); + state.refused = false; + state.unknown = false; + state.fingerprints.clear(); + } + + /** Recovery authority: a committed, identity-checked native-main device login. */ + verifiedLogin(home: string): void { + const state = this.state(home); + state.observed = undefined; + state.refused = false; + state.unknown = false; + state.fingerprints.clear(); + } +} + +function fingerprint(bearer: string): string { + return createHash("sha256").update(`opencodex:native-main:refused-bearer:v1\0${bearer}`).digest("hex"); +} + +export const nativeMainRefusals = new NativeMainRefusals(); diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index f9d687e1e09..49c0db6e688 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -80,6 +80,7 @@ import { } from "../../codex/auth-context"; import { forceRefreshMainAccountToken, + mainAccountTokenQuotaWriter, type NativeMainRefreshDependencies, } from "../../codex/main-account"; import { @@ -371,6 +372,7 @@ async function refreshNativeMainCompactContext(args: { ...authCtx, accessToken: refreshed.accessToken, chatgptAccountId: refreshed.chatgptAccountId, + mainQuotaWriter: mainAccountTokenQuotaWriter(refreshed) ?? authCtx.mainQuotaWriter, }; const refreshedProvider = applyCodexAuthContextToProvider( stripCodexRuntimeProviderFields(provider), diff --git a/src/server/responses/core-auth.ts b/src/server/responses/core-auth.ts index 2b60fe00814..6f3990d4cc7 100644 --- a/src/server/responses/core-auth.ts +++ b/src/server/responses/core-auth.ts @@ -42,7 +42,7 @@ import type { RequestLogContext } from "../request-log"; import { markLocalRequestLogRefusal } from "../request-log"; import { CODEX_POOL_REFRESH_INCOMPLETE_LOG_REASON } from "../../codex/pool-refresh-backoff"; import { codexAuthContextLogLabel } from "../../codex/account-label"; -import { forceRefreshMainAccountToken } from "../../codex/main-account"; +import { forceRefreshMainAccountToken, mainAccountTokenQuotaWriter } from "../../codex/main-account"; import { rebindPoolCreditPolicy } from "../../codex/pool-credit-policy"; import { safeCallerSessionId } from "../caller-session-identity"; import { copyResponsesClientMetadata } from "../../adapters/openai-responses/transport"; @@ -578,6 +578,7 @@ export async function refreshNativeMainForwardAuth(args: { ...authCtx, accessToken: refreshed.accessToken, chatgptAccountId: refreshed.chatgptAccountId, + mainQuotaWriter: mainAccountTokenQuotaWriter(refreshed) ?? authCtx.mainQuotaWriter, }; const provider = applyCodexAuthContextToProvider( stripCodexRuntimeProviderFields(route.provider), diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index fed0124ce83..b463ab8a87d 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -309,8 +309,8 @@ Codex mode starts a short-lived `codex -c model_provider="openai" app-server` an OpenCodex uses the returned bearer for that request, writes nothing and does not reread auth.json after refresh: keyring mode deletes it. With no file credential, every lookup reads the current login with `refreshToken: false`; an expiring or explicitly rejected bearer requests refresh. -Only home-scoped expiry/refusal metadata survives the lookup. Keyring refusal retains a domain-separated SHA-256 fingerprint of the rejected bearer, never the bearer itself; uncertain reads and home switches preserve it. Logout, account switches and unavailable reads cannot reuse an earlier bearer; a weak association gives the exact immutable RPC result its owned quota writer, home and credential generation without retaining the result or token. -A `chatgpt` method with a null token marks the file grant or keyring observation rejected (reauth). Reappearance of that bearer, or an opaque or conflicting identity answer, preserves refusal and unusability. Only a different bearer with verified identity or a committed verified login clears keyring refusal. +Only home-scoped expiry/refusal metadata survives lookup. `src/codex/native-main-refusals.ts` retains domain-separated SHA-256 fingerprints, never bearers: at most eight homes, each with its last observed RPC fingerprint and four unresolved refused fingerprints. Uncertain reads and home switches preserve evidence. Fingerprint overflow becomes refused/unknown. Home eviction prefers non-refused records; evicting a refusal sets a process-local saturation latch, making every untracked home refused/unknown. Tracked homes retain their evidence; untracked homes require a committed verified login or process restart. This trades availability after saturation for bounded memory without admitting evicted refused bearers. +A `chatgpt` method with a null token rejects the file grant or keyring observation, including the last observed RPC fingerprint when no rejected or locked bearer exists. Reappearance of a refused bearer, or an opaque/conflicting identity, preserves refusal. Known refusal clears only for a demonstrably different bearer with verified identity. Unknown refusal requires the existing same-identity device-login `beginNativeMainReauth().commit` to publish its complete token set under the native claim and snapshot guards; ordinary verified RPC evidence cannot clear it. A weak association gives only the exact immutable RPC result its quota writer, home and credential generation. Both Responses and compact 401 retries transfer `mainAccountTokenQuotaWriter(refreshed)` before copying token fields, preserving the successor writer after account replacement; a result without owned RPC provenance retains the prior fence. Without prior permanent refusal, an unreachable binary, unchanged rejected bearer or expired bearer is transient. A non-ChatGPT or missing login answers an ordinary lookup as an observed absence, while a rejected-token or file-locked refresh still reports it transiently. Read-only lookups honor caller cancellation. Actual refresh runs on its independent 30-second timeout so Codex can save its grant. Both paths pin CODEX_HOME and hold its exclusive claim; home and identity-generation changes reject a delayed observation. Only file-backed refresh has a local grant fingerprint lock; keyring claim deadlines are transient. `"opencodex"`, missing or old Codex keeps the guarded writer. diff --git a/tests/codex-integration/native-main-refresh-mode.test.ts b/tests/codex-integration/native-main-refresh-mode.test.ts index 4f00cd2d115..2f2037be689 100644 --- a/tests/codex-integration/native-main-refresh-mode.test.ts +++ b/tests/codex-integration/native-main-refresh-mode.test.ts @@ -15,6 +15,7 @@ import { } from "../../src/codex/native-main-refresh-mode"; import { forceRefreshMainAccountToken, + beginNativeMainReauth, getMainAccountCredentialStatus, getValidMainAccountToken, isMainAccountRefreshGrantRejected, @@ -38,6 +39,7 @@ import type { OcxConfig } from "../../src/types"; import type { ResolveCodexRuntimeResult } from "../../src/codex/runtime"; import { fakeChatGptJwt } from "../helpers/agent-task-recovery"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { NativeMainRefusals } from "../../src/codex/native-main-refusals"; const CODEX_MODE: NativeMainRefreshResolution = { mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fake/codex", version: "0.162.1", @@ -766,6 +768,82 @@ describe("main account in codex refresh mode (#2999)", () => { expect(getMainAccountCredentialStatus().usable).toBe(true); }); + test("ordinary lookup refusal remembers the last observed bearer across uncertain reads", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const bearer = fakeChatGptJwt("acct-ordinary-refusal"); + const answer = (authToken: string | null) => async (): Promise => ({ + authMethod: "chatgpt", authToken, requiresOpenaiAuth: true, + }); + await getValidMainAccountToken({ codexAuthStatus: answer(bearer) }); + await expect(getValidMainAccountToken({ codexAuthStatus: async () => { throw new Error("offline"); } })) + .rejects.toMatchObject({ reason: "transient" }); + await expect(getValidMainAccountToken({ codexAuthStatus: answer(null) })).rejects.toMatchObject({ reason: "reauth" }); + await expect(getValidMainAccountToken({ codexAuthStatus: answer(bearer) })).rejects.toMatchObject({ reason: "reauth" }); + expect(getMainAccountCredentialStatus().usable).toBe(false); + const successor = fakeChatGptJwt("acct-ordinary-refusal", { jti: "new-login" }); + expect(await getValidMainAccountToken({ codexAuthStatus: answer(successor) })) + .toMatchObject({ accessToken: successor }); + }); + + test("unknown ordinary lookup refusal cannot be cleared by a newly observed verified bearer", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + await expect(getValidMainAccountToken({ codexAuthStatus: async () => ({ + authMethod: "chatgpt", authToken: null, requiresOpenaiAuth: true, + }) })).rejects.toMatchObject({ reason: "reauth" }); + await expect(getValidMainAccountToken({ codexAuthStatus: async () => ({ + authMethod: "chatgpt", authToken: fakeChatGptJwt("acct-unknown-refusal"), requiresOpenaiAuth: true, + }) })).rejects.toMatchObject({ reason: "reauth" }); + expect(getMainAccountCredentialStatus().usable).toBe(false); + // Only the committed same-identity device login is an explicit recovery event. + const recovered = fakeChatGptJwt("acct-main", { jti: "committed-login" }); + seedAuth(recovered); + await beginNativeMainReauth().commit({ accessToken: recovered, refreshToken: "new-grant", + idToken: recovered, chatgptAccountId: "acct-main" }); + rmSync(join(home, "auth.json")); + expect(await getValidMainAccountToken({ codexAuthStatus: async () => ({ + authMethod: "chatgpt", authToken: recovered, requiresOpenaiAuth: true, + }) })).toMatchObject({ accessToken: recovered }); + }); + + test("home saturation bounds metadata and keeps evicted refusals unknown", () => { + const refusals = new NativeMainRefusals(); + for (let i = 0; i < 12; i++) refusals.refuse(`fixture-home-${i}`, `fixture-bearer-${i}`); + expect(Reflect.get(refusals, "homes").size).toBe(8); + expect(refusals.has("fixture-home-0")).toBe(true); + expect(refusals.refuses("fixture-home-0", "fixture-bearer-0")).toBe(true); + expect(refusals.refuses("fixture-home-0", "different-verified-bearer")).toBe(true); + expect(refusals.refuses("untracked-home", "different-verified-bearer")).toBe(true); + refusals.verifiedLogin("fixture-home-0"); + expect(refusals.has("fixture-home-0")).toBe(false); + expect(refusals.refuses("fixture-home-0", "committed-login-bearer")).toBe(false); + expect(refusals.has("fixture-home-1")).toBe(true); + expect(Reflect.get(refusals, "homes").size).toBe(8); + }); + + test("fingerprint metadata has a small cap and stores only domain-separated hashes", () => { + const refusals = new NativeMainRefusals(); + for (let i = 0; i < 4; i++) refusals.refuse("fixture-home", `fixture-bearer-${i}`); + const state = Reflect.get(refusals, "homes").get("fixture-home"); + expect(state.fingerprints.size).toBe(4); + for (const hash of state.fingerprints) expect(hash).toMatch(/^[a-f0-9]{64}$/); + refusals.refuse("fixture-home", "fixture-bearer-overflow"); + expect(state.fingerprints.size).toBe(0); + expect(state.unknown).toBe(true); + expect(refusals.refuses("fixture-home", "different-verified-bearer")).toBe(true); + }); + + test("fingerprint saturation requires verified login instead of forgetting refused bearers", async () => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const refused = async (): Promise => ({ authMethod: "chatgpt", authToken: null, requiresOpenaiAuth: true }); + for (let i = 0; i < 12; i++) { + await expect(forceRefreshMainAccountToken(fakeChatGptJwt("acct-saturated", { jti: i }), { codexAuthStatus: refused })) + .rejects.toMatchObject({ reason: "reauth" }); + } + await expect(getValidMainAccountToken({ codexAuthStatus: async () => ({ + authMethod: "chatgpt", authToken: fakeChatGptJwt("acct-saturated", { jti: "unproven-successor" }), requiresOpenaiAuth: true, + }) })).rejects.toMatchObject({ reason: "reauth" }); + }); + test("a chatgpt login with a withheld token means reauthentication", async () => { setNativeMainRefreshResolutionForTests(CODEX_MODE); const original = seedAuth(expired()); diff --git a/tests/responses/responses-native-main-refresh.test.ts b/tests/responses/responses-native-main-refresh.test.ts index 1167e609896..acc2d58c51c 100644 --- a/tests/responses/responses-native-main-refresh.test.ts +++ b/tests/responses/responses-native-main-refresh.test.ts @@ -22,6 +22,9 @@ import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; import { captureCallerDirectAuth } from "../../src/providers/caller-authorization"; import { fakeChatGptJwt } from "../helpers/agent-task-recovery"; +import { setNativeMainRefreshResolutionForTests } from "../../src/codex/native-main-refresh-mode"; +import { clearAccountQuota, getAccountQuota, getMainPolicyQuota } from "../../src/codex/quota"; +import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; @@ -105,6 +108,9 @@ beforeEach(() => { }); afterEach(() => { + setNativeMainRefreshResolutionForTests(null); + clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); + resetMainCodexAccountIdentityTrackingForTests(); // Release before restoring or removing the home to prevent Windows removal failures and POSIX unlinked databases. releaseSpendHome?.(); releaseSpendHome = undefined; @@ -154,6 +160,63 @@ function install401ThenRefreshHarness(): { sends: string[]; refreshes: string[] } describe("native main 401 refresh and replay", () => { + test.each(["/v1/responses", "/v1/responses/compact"] as const)( + "%s publishes retry quota for a keyring account replacement after 401", async path => { + clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); + resetMainCodexAccountIdentityTrackingForTests(); + fs.rmSync(join(home, "auth.json")); + setNativeMainRefreshResolutionForTests({ + mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fixture/codex", version: "0.162.1", + }); + const first = fakeChatGptJwt("retry-account-a"); + const replacement = fakeChatGptJwt("retry-account-b"); + if (path.endsWith("compact")) saveCodexAccountCredential(OTHER_ACCOUNT_ID, { + accessToken: "other-access", refreshToken: "other-refresh", expiresAt: Date.now() + 3_600_000, + chatgptAccountId: "account-other", + }); + let token = first; + const refreshes: boolean[] = []; + const nativeMainRefreshDependencies = { codexAuthStatus: async ({ refresh }: { refresh: boolean }) => { + refreshes.push(refresh); + if (refresh) token = replacement; + return { authMethod: "chatgpt", authToken: token, requiresOpenaiAuth: true }; + } }; + const sends: Headers[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (!url.pathname.endsWith("/responses") && !url.pathname.endsWith("/responses/compact")) { + return Response.json({ rate_limit: { primary_window: { used_percent: 10 } } }); + } + sends.push(new Headers(init?.headers)); + if (sends.length === 1) return Response.json({ error: { message: "expired bearer" } }, { status: 401 }); + if (sends.length === 3) return Response.json({ id: "resp_other", object: "response", status: "completed", output: [] }); + return Response.json({ id: "resp_replacement", object: "response", status: "completed", output: [] }, { + status: path.endsWith("compact") ? 429 : 200, + headers: { "x-codex-primary-used-percent": "37", "x-codex-primary-window-minutes": "300", + "x-codex-primary-reset-at": String(Math.floor(Date.now() / 1000) + 600) }, + }); + }) as typeof fetch; + const req = request(path); + const cfg = config({ secondAccount: path.endsWith("compact") }); + await getValidMainAccountToken(nativeMainRefreshDependencies); + const log = { model: "", provider: "" } as RequestLogContext; + const response = path === "/v1/responses" + ? await handleResponses(req, cfg, log, { nativeMainRefreshDependencies }) + : await handleResponsesCompact(req, cfg, log, undefined, undefined, { nativeMainRefreshDependencies }); + const responseBody = await response.text(); + expect({ status: response.status, error: response.status === 200 ? undefined : responseBody }).toEqual({ status: 200, error: undefined }); + expect(sends.map(headers => headers.get("authorization"))).toEqual([ + `Bearer ${first}`, `Bearer ${replacement}`, ...(path.endsWith("compact") ? ["Bearer other-access"] : []), + ]); + expect(sends.map(headers => headers.get("chatgpt-account-id"))).toEqual([ + "retry-account-a", "retry-account-b", ...(path.endsWith("compact") ? ["account-other"] : []), + ]); + expect(refreshes.filter(Boolean)).toHaveLength(1); + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.shortPercent).toBe(37); + expect(getMainPolicyQuota()?.shortPercent).toBe(37); + }, + ); + test.each(["/v1/responses", "/v1/responses/compact"] as const)( "%s strips caller account identity when a bearer key selects stored Direct", async path => { From 0d93972292a7c39a42d0e580571199f3ffe0b86d Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:24:13 +0900 Subject: [PATCH 11/15] fix(codex): enforce native-main refusal on file-backed admission --- src/codex/main-account.ts | 47 ++++++++--- structure/providers/openai-accounts.md | 2 +- .../native-main-refresh-mode.test.ts | 84 ++++++++++++++++++- 3 files changed, 118 insertions(+), 15 deletions(-) diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index bba4409139a..211d6c39234 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -11,6 +11,7 @@ import { credsFromToken, decodeJwtPayload, extractAccountId, + extractAccountIdClaims, } from "../oauth/chatgpt"; import type { OAuthCredentials } from "../oauth/types"; import { extractChatgptPlanType } from "./plan"; @@ -262,6 +263,27 @@ function credentialRefreshGrantRejected(current: MainAuthJsonCredential | null): return !!key && isMainRefreshGrantRejected(key); } +/** Restored file bytes are subject to the same home refusal as an owned RPC. */ +function credentialRefused(current: MainAuthJsonCredential | null, now = Date.now()): boolean { + if (credentialRefreshGrantRejected(current)) return true; + const home = resolveCodexHomeDir(); + if (!codexOwnedMainRefusals.has(home)) return false; + const bearer = current?.accessToken; + if (!current || !bearer || codexOwnedMainRefusals.refuses(home, bearer) + || !mainAccessTokenFresh(bearer, now, 0)) return true; + const accessIdentity = extractAccountIdClaims(bearer); + const idIdentity = extractAccountIdClaims(nonEmptyString(current.tokens.id_token)); + if (!accessIdentity.accountId || accessIdentity.conflict || idIdentity.conflict + || accessIdentity.accountId !== current.chatgptAccountId + || (idIdentity.accountId !== undefined && idIdentity.accountId !== accessIdentity.accountId) + || (nonEmptyString(current.tokens.account_id) !== undefined + && current.tokens.account_id !== accessIdentity.accountId)) return true; + // Only a different, identity-verified bearer can recover a known refusal. + // Unknown/saturated history was rejected above and needs committed login. + codexOwnedMainRefusals.observe(home, bearer); + return false; +} + export interface MainAccountCredentialStatus { refreshGrantRejected: boolean; hasRefreshGrant: boolean; @@ -272,8 +294,7 @@ export interface MainAccountCredentialStatus { export function getMainAccountCredentialStatus(now = Date.now()): MainAccountCredentialStatus { const current = readMainAuthJsonCredential(); const owned = !current && codexOwnedMainCredential?.home === resolveCodexHomeDir() ? codexOwnedMainCredential : null; - const refreshGrantRejected = credentialRefreshGrantRejected(current) - || (!current && codexOwnedMainRefusals.has(resolveCodexHomeDir())); + const refreshGrantRejected = credentialRefused(current, now); const hasRefreshGrant = !!current?.refreshToken && !refreshGrantRejected; const ownedUsable = !!owned && owned.expiresAt > now; const usable = !refreshGrantRejected @@ -559,7 +580,7 @@ async function resolveMainAccountToken( if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-credential"); return null; } - if (credentialRefreshGrantRejected(initial)) throw new MainAccountTokenRefreshError("reauth"); + if (credentialRefused(initial)) throw new MainAccountTokenRefreshError("reauth"); const now = Date.now(); if (initial.accessToken !== rejectedAccessToken && mainAccessTokenFresh(initial.accessToken, now, MAIN_TOKEN_REFRESH_SKEW_MS)) { @@ -607,7 +628,7 @@ async function resolveMainAccountToken( if (resolveCodexHomeDir() !== codexHome) throw new MainAuthJsonChangedDuringRefreshError(); const locked = readMainAuthJsonCredential(); if (!locked) throw new MainAuthJsonChangedDuringRefreshError(); - if (credentialRefreshGrantRejected(locked)) throw new MainAccountTokenRefreshError("reauth"); + if (credentialRefused(locked)) throw new MainAccountTokenRefreshError("reauth"); if (!locked.refreshToken || refreshGrantFingerprintForToken(locked.refreshToken) !== lockKey) { if (locked.accessToken !== rejectedAccessToken @@ -824,9 +845,9 @@ export function getMainAccountPlan(): string | undefined { /** Read-only main account token from ~/.codex/auth.json, or null when not logged in. */ export function getMainAccountToken(): { accessToken: string; chatgptAccountId: string } | null { - const tokens = readCodexTokens(); - if (!tokens?.access_token) return null; - return { accessToken: tokens.access_token, chatgptAccountId: tokens.account_id }; + const current = readMainAuthJsonCredential(); + if (!current?.accessToken || credentialRefused(current)) return null; + return { accessToken: current.accessToken, chatgptAccountId: nonEmptyString(current.tokens.account_id) ?? "" }; } /** @@ -835,9 +856,9 @@ export function getMainAccountToken(): { accessToken: string; chatgptAccountId: * actually-invalid token then surfaces via the upstream 401 → cooldown path. */ export function isMainAccountTokenLive(now = Date.now()): boolean { - const tokens = readCodexTokens(); - if (!tokens?.access_token) return false; - const payload = decodeJwtPayload(tokens.access_token); + const current = readMainAuthJsonCredential(); + if (!current?.accessToken || credentialRefused(current, now)) return false; + const payload = decodeJwtPayload(current.accessToken); const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : undefined; return exp === undefined || exp > now; } @@ -852,9 +873,9 @@ export function isMainAccountTokenLive(now = Date.now()): boolean { * 401 "transient" and needsReauth could never flip. */ export function isMainAccountTokenVerifiablyLive(now = Date.now()): boolean { - const tokens = readCodexTokens(); - if (!tokens?.access_token) return false; - const payload = decodeJwtPayload(tokens.access_token); + const current = readMainAuthJsonCredential(); + if (!current?.accessToken || credentialRefused(current, now)) return false; + const payload = decodeJwtPayload(current.accessToken); const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : undefined; return exp !== undefined && exp > now; } diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index b463ab8a87d..7ec58db50d7 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -310,7 +310,7 @@ OpenCodex uses the returned bearer for that request, writes nothing and does not With no file credential, every lookup reads the current login with `refreshToken: false`; an expiring or explicitly rejected bearer requests refresh. Only home-scoped expiry/refusal metadata survives lookup. `src/codex/native-main-refusals.ts` retains domain-separated SHA-256 fingerprints, never bearers: at most eight homes, each with its last observed RPC fingerprint and four unresolved refused fingerprints. Uncertain reads and home switches preserve evidence. Fingerprint overflow becomes refused/unknown. Home eviction prefers non-refused records; evicting a refusal sets a process-local saturation latch, making every untracked home refused/unknown. Tracked homes retain their evidence; untracked homes require a committed verified login or process restart. This trades availability after saturation for bounded memory without admitting evicted refused bearers. -A `chatgpt` method with a null token rejects the file grant or keyring observation, including the last observed RPC fingerprint when no rejected or locked bearer exists. Reappearance of a refused bearer, or an opaque/conflicting identity, preserves refusal. Known refusal clears only for a demonstrably different bearer with verified identity. Unknown refusal requires the existing same-identity device-login `beginNativeMainReauth().commit` to publish its complete token set under the native claim and snapshot guards; ordinary verified RPC evidence cannot clear it. A weak association gives only the exact immutable RPC result its quota writer, home and credential generation. Both Responses and compact 401 retries transfer `mainAccountTokenQuotaWriter(refreshed)` before copying token fields, preserving the successor writer after account replacement; a result without owned RPC provenance retains the prior fence. +A `chatgpt` method with a null token rejects the file grant or keyring observation, including the last observed RPC fingerprint when no rejected or locked bearer exists. Reappearance of a refused bearer, or an opaque/conflicting identity, preserves refusal, including restored auth.json through status, usability, raw-token, liveness and refresh shortcuts. Known home refusal clears only for a demonstrably different live bearer with verified identity from owned RPC or auth.json; file access-token, id-token and stored account identities must agree. Unknown or saturated refusal requires the existing same-identity device-login `beginNativeMainReauth().commit` to publish its complete token set under the native claim and snapshot guards; ordinary verified RPC or file evidence cannot clear it. A weak association gives only the exact immutable RPC result its quota writer, home and credential generation. Both Responses and compact 401 retries transfer `mainAccountTokenQuotaWriter(refreshed)` before copying token fields, preserving the successor writer after account replacement; a result without owned RPC provenance retains the prior fence. Without prior permanent refusal, an unreachable binary, unchanged rejected bearer or expired bearer is transient. A non-ChatGPT or missing login answers an ordinary lookup as an observed absence, while a rejected-token or file-locked refresh still reports it transiently. Read-only lookups honor caller cancellation. Actual refresh runs on its independent 30-second timeout so Codex can save its grant. Both paths pin CODEX_HOME and hold its exclusive claim; home and identity-generation changes reject a delayed observation. Only file-backed refresh has a local grant fingerprint lock; keyring claim deadlines are transient. `"opencodex"`, missing or old Codex keeps the guarded writer. diff --git a/tests/codex-integration/native-main-refresh-mode.test.ts b/tests/codex-integration/native-main-refresh-mode.test.ts index 2f2037be689..dfa3b89fb8f 100644 --- a/tests/codex-integration/native-main-refresh-mode.test.ts +++ b/tests/codex-integration/native-main-refresh-mode.test.ts @@ -17,7 +17,10 @@ import { forceRefreshMainAccountToken, beginNativeMainReauth, getMainAccountCredentialStatus, + getMainAccountToken, getValidMainAccountToken, + isMainAccountTokenLive, + isMainAccountTokenVerifiablyLive, isMainAccountRefreshGrantRejected, MAIN_CODEX_ACCOUNT_ID, MainAuthJsonChangedDuringRefreshError, @@ -39,7 +42,7 @@ import type { OcxConfig } from "../../src/types"; import type { ResolveCodexRuntimeResult } from "../../src/codex/runtime"; import { fakeChatGptJwt } from "../helpers/agent-task-recovery"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { NativeMainRefusals } from "../../src/codex/native-main-refusals"; +import { NativeMainRefusals, nativeMainRefusals } from "../../src/codex/native-main-refusals"; const CODEX_MODE: NativeMainRefreshResolution = { mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fake/codex", version: "0.162.1", @@ -785,6 +788,85 @@ describe("main account in codex refresh mode (#2999)", () => { .toMatchObject({ accessToken: successor }); }); + for (const refusal of ["known", "unknown", "fingerprint-saturated", "home-evicted"] as const) { + test.each(["status", "valid", "forced", "raw", "live", "verifiably-live"] as const)( + `restored auth.json cannot bypass ${refusal} keyring refusal (%s)`, async admission => { + // Saturation is process-wide; restore metadata so this matrix cannot poison later homes. + const homes = new Map(Reflect.get(nativeMainRefusals, "homes")); + const evicted = Reflect.get(nativeMainRefusals, "evictedRefusal"); + try { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const bearer = fakeChatGptJwt("acct-main", { jti: "restored-refused" }); + const answer = (authToken: string | null) => async (): Promise => ({ + authMethod: "chatgpt", authToken, requiresOpenaiAuth: true, + }); + if (refusal !== "unknown") await getValidMainAccountToken({ codexAuthStatus: answer(bearer) }); + await expect(getValidMainAccountToken({ codexAuthStatus: answer(null) })) + .rejects.toMatchObject({ reason: "reauth" }); + if (refusal === "fingerprint-saturated") { + for (let i = 0; i < 5; i++) { + await expect(forceRefreshMainAccountToken(fakeChatGptJwt("acct-main", { jti: i }), { codexAuthStatus: answer(null) })) + .rejects.toMatchObject({ reason: "reauth" }); + } + } else if (refusal === "home-evicted") { + for (let i = 0; i < 12; i++) nativeMainRefusals.refuse(join(root, `evicted-${i}`), `fixture-${i}`); + } + seedAuth(bearer); + if (admission === "status") { + expect(getMainAccountCredentialStatus()).toEqual({ refreshGrantRejected: true, hasRefreshGrant: false, usable: false }); + } else if (admission === "valid" || admission === "forced") { + const dependencies = { codexAuthStatus: answer(bearer) }; + await expect(admission === "valid" ? getValidMainAccountToken(dependencies) + : forceRefreshMainAccountToken("different-upstream-bearer", dependencies)).rejects.toMatchObject({ reason: "reauth" }); + } else if (admission === "raw") { + expect(getMainAccountToken()).toBeNull(); + } else { + expect(admission === "live" ? isMainAccountTokenLive() : isMainAccountTokenVerifiablyLive()).toBe(false); + } + if (refusal !== "known") { + // A fresh verified successor alone cannot recover lost refusal history. + const successor = fakeChatGptJwt("acct-main", { jti: "unproven-file-successor" }); + seedAuth(successor); + await expect(getValidMainAccountToken()).rejects.toMatchObject({ reason: "reauth" }); + expect(getMainAccountCredentialStatus().usable).toBe(false); + await beginNativeMainReauth().commit({ accessToken: successor, refreshToken: "committed-new-grant", + idToken: successor, chatgptAccountId: "acct-main" }); + expect(await getValidMainAccountToken()).toEqual({ accessToken: successor, chatgptAccountId: "acct-main" }); + } + } finally { + Reflect.set(nativeMainRefusals, "homes", homes); + Reflect.set(nativeMainRefusals, "evictedRefusal", evicted); + } + }, + ); + } + + test.each(["status", "valid", "raw"] as const)("restored auth.json requires a different verified bearer for recovery (%s)", async admission => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const bearer = fakeChatGptJwt("acct-main", { jti: "file-recovery-old" }); + const answer = (authToken: string | null) => async (): Promise => ({ + authMethod: "chatgpt", authToken, requiresOpenaiAuth: true, + }); + await getValidMainAccountToken({ codexAuthStatus: answer(bearer) }); + await expect(getValidMainAccountToken({ codexAuthStatus: answer(null) })).rejects.toMatchObject({ reason: "reauth" }); + seedAuth(bearer); + await expect(getValidMainAccountToken()).rejects.toMatchObject({ reason: "reauth" }); + for (const uncertain of ["opaque-fixture-token", fakeChatGptJwt("acct-main", { chatgpt_account_id: "acct-conflict" }), + fakeChatGptJwt("acct-other"), expired()]) { + seedAuth(uncertain); + await expect(getValidMainAccountToken()).rejects.toMatchObject({ reason: "reauth" }); + } + const successor = fakeChatGptJwt("acct-main", { jti: "file-recovery-new" }); + seedAuth(successor); + if (admission === "status") { + expect(getMainAccountCredentialStatus()).toEqual({ refreshGrantRejected: false, hasRefreshGrant: true, usable: true }); + } else { + const result = admission === "valid" ? await getValidMainAccountToken() : getMainAccountToken(); + expect(result).toEqual({ accessToken: successor, chatgptAccountId: "acct-main" }); + } + expect(isMainAccountRefreshGrantRejected()).toBe(false); + }); + test("unknown ordinary lookup refusal cannot be cleared by a newly observed verified bearer", async () => { setNativeMainRefreshResolutionForTests(CODEX_MODE); await expect(getValidMainAccountToken({ codexAuthStatus: async () => ({ From 41d24a8c7a858b0f5a64c3141f3ab899c7b3b7fe Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:50:38 +0900 Subject: [PATCH 12/15] fix(codex): gate every native-main bearer reader and clear refusal only on authenticated evidence --- src/codex/auth-api/main-account-probe.ts | 6 + src/codex/auth-api/reset-credit-service.ts | 13 +- src/codex/auth-collision.ts | 4 +- src/codex/auth-context.ts | 10 +- src/codex/main-account-cache.ts | 29 ++++- src/codex/main-account.ts | 19 +-- src/codex/native-main-refusals.ts | 44 ++++++- src/codex/quota-auto-refresh.ts | 3 +- src/server/audio-upstream.ts | 7 +- src/server/responses/compact.ts | 3 + src/server/responses/passthrough-delivery.ts | 5 +- structure/providers/openai-accounts.md | 2 +- .../codex-credits-probes.test.ts | 113 ++++++++++++++++++ .../native-main-refresh-mode.test.ts | 33 ++++- .../responses-native-main-refresh.test.ts | 35 ++++++ tests/server/audio-transcriptions.test.ts | 30 +++++ 16 files changed, 328 insertions(+), 28 deletions(-) diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index 1ed67345060..871b87b2eeb 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -16,6 +16,7 @@ import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../account-runt import { decodeJwtPayload, extractAccountId } from "../../oauth/chatgpt"; import { getMainAccountPlan, getValidMainAccountToken, isMainAccountTokenVerifiablyLive, mainAccountTokenIsCurrent, mainAccountTokenQuotaWriter, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan, type MainAccountToken } from "../main-account"; import { resolveCodexHomeDir } from "../home"; +import { confirmNativeMainAuthentication, nativeMainAuthenticationIsCurrent, nativeMainRefusals } from "../native-main-refusals"; import { captureConfigGeneration } from "../../lib/state-store-sweeper"; import { captureMainAccountIdentityGeneration, clearMainAccountInfoCache, getMainAccountInfoCache, getMainQuotaCredentialGeneration, isMainAccountIdentityGenerationLive, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential, setMainAccountCredentialPresence, setMainAccountInfoCache } from "../main-account-cache"; import type { MainQuotaWriter, MainAccountInfo } from "../main-account-cache"; @@ -283,6 +284,8 @@ export async function fetchMainAccountInfoWhileOwned( const preserved = getMainAccountInfoCache(); return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false }; } + const authentication = nativeMainRefusals.capture(resolveCodexHomeDir(), tokens.access_token); + if (!authentication) return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false }; const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null); const cached = getMainAccountInfoCache(); if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) { @@ -296,6 +299,7 @@ export async function fetchMainAccountInfoWhileOwned( const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration(); /** A disk replacement may have no second probe to advance the credential generation. */ const credentialIsCurrent = (): boolean => { + if (!nativeMainAuthenticationIsCurrent(authentication)) return false; const current = readCodexTokensResult(undefined, { bounded: true }); if (owned) return current.status === "missing" && mainAccountTokenIsCurrent(owned) && mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration(); @@ -318,6 +322,7 @@ export async function fetchMainAccountInfoWhileOwned( const read = async (): Promise => { try { let dispatchSequence = 0; + if (!nativeMainAuthenticationIsCurrent(authentication)) return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false }; const baseKey = `main:${writerGeneration}:${mainQuotaCredentialGeneration}`; const resetEpoch = postReset ? `:post-reset:${currentQuotaDispatchSequence()}` : ""; const admission = await fetchCodexUsage( @@ -419,6 +424,7 @@ export async function fetchMainAccountInfoWhileOwned( credentialChecked: true, hasCredential: true }; } const freshResetCredits = quota?.resetCredits; + confirmNativeMainAuthentication(authentication); // Tag the count with the identity it was read from, so a later response that omits the // summary can restore the badge without ever crossing an account boundary. rememberMainResetCredits(requestAccountId, freshResetCredits); diff --git a/src/codex/auth-api/reset-credit-service.ts b/src/codex/auth-api/reset-credit-service.ts index cf49da06063..054d2482091 100644 --- a/src/codex/auth-api/reset-credit-service.ts +++ b/src/codex/auth-api/reset-credit-service.ts @@ -5,6 +5,8 @@ import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; import { claimManualResetCooldowns, settleManualResetCooldown } from "../routing"; import type { ManualResetCooldownClaim } from "../routing"; import { readCodexTokens } from "../auth-collision"; +import { confirmNativeMainAuthentication, nativeMainAuthenticationIsCurrent, nativeMainBearerAllowed, nativeMainRefusals, type NativeMainAuthentication } from "../native-main-refusals"; +import { resolveCodexHomeDir } from "../home"; import { extractAccountId } from "../../oauth/chatgpt"; import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; import { getMainQuotaCredentialGeneration, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential } from "../main-account-cache"; @@ -29,6 +31,7 @@ interface ResetCreditAuth { nativeMainSharedClaimHeld?: true; poolGeneration?: number; mainProof?: MainResetQuotaProof; + authentication?: NativeMainAuthentication; } async function withResetCreditAuth( @@ -57,6 +60,7 @@ async function withResetCreditAuth( ok: true, value: await operation({ isMain: true, + authentication: nativeMainRefusals.capture(resolveCodexHomeDir(), tokens.access_token), ...(writer ? { mainProof: { writer, credentialGeneration: getMainQuotaCredentialGeneration() } } : {}), accessToken: tokens.access_token, chatgptAccountId: tokens.account_id, @@ -206,7 +210,8 @@ function manualResetAuthStillLive(accountId: string, auth: ResetCreditAuth): boo && record?.credential?.chatgptAccountId === auth.chatgptAccountId; } const tokens = readCodexTokens(); - return !!auth.mainProof && !!tokens + return !!auth.authentication && nativeMainAuthenticationIsCurrent(auth.authentication) + && nativeMainBearerAllowed(auth.accessToken) && !!auth.mainProof && !!tokens && tokens.access_token === auth.accessToken && tokens.account_id === auth.chatgptAccountId && isMainQuotaWriterLive(auth.mainProof.writer) && auth.mainProof.credentialGeneration === getMainQuotaCredentialGeneration() @@ -270,6 +275,7 @@ async function refreshAfterManualReset( export async function inspectResetCredits(config: OcxConfig, accountId: string, signal: AbortSignal): Promise { const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { + if (auth.isMain && !manualResetAuthStillLive(accountId, auth)) return jsonResponse({ error: "Main Codex account not logged in" }, 401); const linkedSignal = signalWithTimeout(8000, signal); let detachBodyAbort = () => {}; try { @@ -303,6 +309,8 @@ export async function inspectResetCredits(config: OcxConfig, accountId: string, if (!parsed.ok) { return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502); } + if (auth.isMain && !manualResetAuthStillLive(accountId, auth)) return jsonResponse({ error: "Main Codex credential changed" }, 409); + if (auth.authentication) confirmNativeMainAuthentication(auth.authentication); return jsonResponse(safeResetCreditsDto(parsed.value)); } finally { detachBodyAbort(); @@ -314,6 +322,7 @@ export async function inspectResetCredits(config: OcxConfig, accountId: string, export async function consumeResetCredits(config: OcxConfig, accountId: string, requestedOperationId: string | undefined): Promise { const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { + if (auth.isMain && !manualResetAuthStillLive(accountId, auth)) return jsonResponse({ error: "Main Codex account not logged in" }, 401); // The ledger keys manual operations by the *physical* ChatGPT account, which is // only known after the auth wrapper resolves credentials. Open here, not earlier. let identity = requestedOperationId === undefined @@ -362,6 +371,7 @@ export async function consumeResetCredits(config: OcxConfig, accountId: string, try { let resp: Response; try { + if (auth.isMain && !manualResetAuthStillLive(accountId, auth)) return jsonResponse({ error: "Main Codex credential changed" }, 409); resp = await fetch( "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", { @@ -387,6 +397,7 @@ export async function consumeResetCredits(config: OcxConfig, accountId: string, return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); } const consumed = await readResetCreditJson(resp, AbortSignal.timeout(10_000)); + if (auth.authentication && manualResetAuthStillLive(accountId, auth)) confirmNativeMainAuthentication(auth.authentication); if (!consumed.ok) { // The spend may already have landed upstream and its outcome code is unreadable, // so this id must never come back as a new operation. diff --git a/src/codex/auth-collision.ts b/src/codex/auth-collision.ts index 20452a1e64a..cf62104820b 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { nativeMainBearerAllowed } from "./native-main-refusals"; import { getCodexAccountCredential } from "./account-store"; import { loadConfig } from "../config"; import { resolveCodexHomeDir } from "./home"; @@ -56,6 +57,7 @@ export function readCodexTokensResult( tokens?: { access_token?: string; account_id?: string; id_token?: string }; }; if (!j?.tokens?.access_token) return { status: "invalid" }; + if (!nativeMainBearerAllowed(j.tokens.access_token, dirname(authPath))) return { status: "invalid" }; return { status: "ok", tokens: { diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index a7397b0f342..4a0803c2830 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,5 +1,6 @@ import { bindPoolCreditPolicy, poolCreditHoldResetAt, poolContextCreditHoldResetAt, poolCreditRecoveryNeeded, poolContextCreditRecoveryNeeded, poolCreditRecoveryRetryAt } from "./pool-credit-policy"; import { hasSpendableCodexCredits } from "./quota-types"; +import { nativeMainBearerAllowed } from "./native-main-refusals"; import { nextQuotaQueryDelay } from "./quota-query-backoff"; import { noteMainAccountActivity } from "./main-account-external-usage"; import { codexAccountPriorityFailbackEnabled } from "./account-priority"; @@ -903,16 +904,15 @@ function observeSelectedMainCredential( token: { accessToken: string; chatgptAccountId: string }, writer: MainQuotaWriter | undefined, ): MainQuotaWriter | undefined { - // A claimed RPC can observe a replacement identity while the caller awaits it. Only that - // exact result carries the replacement writer; stale results keep their stale fence. + if (!nativeMainBearerAllowed(token.accessToken)) throw new MainAccountTokenRefreshError("reauth"); + // Only the exact RPC result carries its replacement identity writer. const ownedWriter = mainAccountTokenQuotaWriter(token); if (ownedWriter) return ownedWriter; if (!writer) return undefined; - // Carry an explicitly stale writer through to quota's rejection fence; turning it into an - // untagged write would instead invalidate the replacement account's trusted observation. + // Keep stale writers tagged so they cannot invalidate the replacement's trusted observation. if (!isMainQuotaWriterLive(writer)) return writer; const observed = observeMainQuotaCredential(token.accessToken, token.chatgptAccountId); - return observed?.identityKey === writer.identityKey ? writer : undefined; + return observed?.identityKey === writer.identityKey ? observed : undefined; } /** diff --git a/src/codex/main-account-cache.ts b/src/codex/main-account-cache.ts index ad1d02c0630..c022a1a54ca 100644 --- a/src/codex/main-account-cache.ts +++ b/src/codex/main-account-cache.ts @@ -4,6 +4,8 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypt import { codexCredentialMutationEpoch } from "./credential-mutation-epoch"; import type { StoredAccountQuota } from "./quota-types"; import { truncateRetainedUtf8 } from "../lib/admission"; +import { resolveCodexHomeDir } from "./home"; +import { confirmNativeMainAuthentication, nativeMainAuthenticationIsCurrent, nativeMainBearerAllowed, nativeMainRefusals, type NativeMainAuthentication } from "./native-main-refusals"; const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024; @@ -22,7 +24,7 @@ let cachedMainCredentialPresence: boolean | null = null; let mainAccountIdentityGeneration = 0; let observedMainQuotaIdentityKey: string | undefined; const mainQuotaCredentialKey = randomBytes(32); -let mainQuotaCredential: { bearerHmac: Buffer; writer: MainQuotaWriter } | undefined; +let mainQuotaCredential: { bearerHmac: Buffer; writer: MainQuotaWriter; authentication: NativeMainAuthentication } | undefined; let mainQuotaCredentialGeneration = 0; let mainDecisionCredentialUsable = true; // Advisory-only generation for the decision row and its writers. It follows every core credential @@ -46,6 +48,15 @@ export function getMainQuotaCredentialGeneration(): number { return mainQuotaCre export type MainQuotaWriter = Readonly<{ identityKey: string; identityGeneration: number }>; const decisionWriterGenerations = new WeakMap(); +const writerAuthentications = new WeakMap(); +const writerCredentialGenerations = new WeakMap(); + +/** Called only for an upstream HTTP 2xx, never a local read or projected WS response. */ +export function confirmMainQuotaAuthentication(writer: MainQuotaWriter): boolean { + const proof = writerAuthentications.get(writer); + return !!proof && writerCredentialGenerations.get(writer) === mainQuotaCredentialGeneration + && isMainQuotaWriterLive(writer) && confirmNativeMainAuthentication(proof); +} /** Return the credential generation a main quota writer was captured under, or undefined once that writer is stale or its credential generation has moved. */ export function mainDecisionQuotaWriterGeneration(writer: MainQuotaWriter): number | undefined { const generation = decisionWriterGenerations.get(writer); @@ -76,21 +87,29 @@ export function captureMainQuotaWriter(accountId: string): MainQuotaWriter | und const identityKey = mainQuotaIdentityKey(accountId); if (identityKey !== observedMainQuotaIdentityKey) return undefined; const writer = { identityKey, identityGeneration: mainAccountIdentityGeneration }; + if (mainQuotaCredential) writerAuthentications.set(writer, mainQuotaCredential.authentication); + if (mainQuotaCredential) writerCredentialGenerations.set(writer, mainQuotaCredentialGeneration); if (mainQuotaCredential) decisionWriterGenerations.set(writer, mainDecisionGeneration); return writer; } /** Credential material must come from an already-owned read, never an incoming request. */ export function observeMainQuotaCredential(accessToken: string, accountId: string): MainQuotaWriter | undefined { + if (!nativeMainBearerAllowed(accessToken)) return undefined; + const authentication = nativeMainRefusals.capture(resolveCodexHomeDir(), accessToken); const writer = captureMainQuotaWriter(accountId); - if (!accessToken || !writer) return undefined; + if (!accessToken || !writer || !authentication) return undefined; + writerAuthentications.set(writer, authentication); const bearerHmac = createHmac("sha256", mainQuotaCredentialKey).update(accessToken).digest(); if (!mainQuotaCredential || !isMainQuotaWriterLive(mainQuotaCredential.writer) || !timingSafeEqual(bearerHmac, mainQuotaCredential.bearerHmac)) { mainQuotaCredentialGeneration += 1; mainDecisionGeneration += 1; } - mainQuotaCredential = { bearerHmac, writer }; + mainQuotaCredential = { bearerHmac, writer, authentication }; + writerCredentialGenerations.set(writer, mainQuotaCredentialGeneration); mainDecisionCredentialFresh = true; publishMainDecisionRoster(true); const result = { ...writer }; + writerAuthentications.set(result, authentication); + writerCredentialGenerations.set(result, mainQuotaCredentialGeneration); decisionWriterGenerations.set(result, mainDecisionGeneration); return result; } @@ -104,8 +123,10 @@ export function matchesMainQuotaCredential(accessToken: string, effectiveAccount } export function isMainQuotaWriterLive(writer: MainQuotaWriter): boolean { + const proof = writerAuthentications.get(writer); return writer.identityKey === observedMainQuotaIdentityKey - && writer.identityGeneration === mainAccountIdentityGeneration; + && writer.identityGeneration === mainAccountIdentityGeneration + && (!proof || nativeMainAuthenticationIsCurrent(proof)); } /** Proof that a dispatch used the observed main credential; process-local, never persisted. */ diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 211d6c39234..273698ce0c8 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -35,7 +35,7 @@ import { resolveNativeProfileContext } from "./native-profile-store"; import { NativeProfileError } from "./native-profile-types"; import { isNativeMainTrafficBlocked } from "./native-profile-startup"; import { loadConfig } from "../config"; -import { nativeMainRefusals as codexOwnedMainRefusals } from "./native-main-refusals"; +import { nativeMainAuthenticationIsCurrent, nativeMainBearerAllowed, nativeMainRefusals as codexOwnedMainRefusals, type NativeMainAuthentication } from "./native-main-refusals"; import { requestCodexAuthStatus, resolveNativeMainRefreshMode, @@ -53,7 +53,7 @@ let mainAccountPlan: string | null = null; let jwtPlanAttempted = false; const MAIN_TOKEN_REFRESH_SKEW_MS = 60_000; let beforeMainAuthJsonRenameForTests: (() => void) | null = null; -let codexOwnedMainCredential: { home: string; expiresAt: number } | null = null; +let codexOwnedMainCredential: { home: string; expiresAt: number; authentication: NativeMainAuthentication } | null = null; export type MainAccountToken = Readonly<{ accessToken: string; chatgptAccountId: string }>; const codexOwnedQuotaWriters = new WeakMap(); @@ -66,7 +66,7 @@ export function mainAccountTokenQuotaWriter(token: MainAccountToken): MainQuotaW export function mainAccountTokenIsCurrent(token: MainAccountToken): boolean { const proof = codexOwnedQuotaWriters.get(token); return proof !== undefined && proof.home === resolveCodexHomeDir() - && codexOwnedMainCredential?.home === proof.home && !codexOwnedMainRefusals.has(proof.home) + && codexOwnedMainCredential?.home === proof.home && nativeMainBearerAllowed(token.accessToken, proof.home) && codexOwnedMainCredential.expiresAt > Date.now() && proof.credentialGeneration === getMainQuotaCredentialGeneration() && isMainQuotaWriterLive(proof.writer) @@ -268,8 +268,10 @@ function credentialRefused(current: MainAuthJsonCredential | null, now = Date.no if (credentialRefreshGrantRejected(current)) return true; const home = resolveCodexHomeDir(); if (!codexOwnedMainRefusals.has(home)) return false; + if (!current && codexOwnedMainCredential?.home === home + && nativeMainAuthenticationIsCurrent(codexOwnedMainCredential.authentication)) return false; const bearer = current?.accessToken; - if (!current || !bearer || codexOwnedMainRefusals.refuses(home, bearer) + if (!current || !bearer || !nativeMainBearerAllowed(bearer, home) || !mainAccessTokenFresh(bearer, now, 0)) return true; const accessIdentity = extractAccountIdClaims(bearer); const idIdentity = extractAccountIdClaims(nonEmptyString(current.tokens.id_token)); @@ -278,9 +280,7 @@ function credentialRefused(current: MainAuthJsonCredential | null, now = Date.no || (idIdentity.accountId !== undefined && idIdentity.accountId !== accessIdentity.accountId) || (nonEmptyString(current.tokens.account_id) !== undefined && current.tokens.account_id !== accessIdentity.accountId)) return true; - // Only a different, identity-verified bearer can recover a known refusal. - // Unknown/saturated history was rejected above and needs committed login. - codexOwnedMainRefusals.observe(home, bearer); + // Decoded claims admit a candidate only; upstream authentication or committed login recovers. return false; } @@ -786,7 +786,7 @@ async function refreshThroughCodex( if (locked === null && rejectedAccessToken === undefined && !didRefresh) return null; throw new MainAccountTokenRefreshError("transient", { credentialObservedAbsent: true }); } - if (codexOwnedMainRefusals.refuses(codexHome, token)) { + if (!nativeMainBearerAllowed(token, codexHome)) { throw new MainAccountTokenRefreshError("reauth"); } // A transient Codex refresh failure still answers with its cached bearer. @@ -807,7 +807,8 @@ async function refreshThroughCodex( if (writer) codexOwnedQuotaWriters.set(result, { writer, home: codexHome, credentialGeneration: getMainQuotaCredentialGeneration() }); if (writer && dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); const exp = decodeJwtPayload(token)?.exp; - codexOwnedMainCredential = { home: codexHome, expiresAt: typeof exp === "number" ? exp * 1000 : Infinity }; + const authentication = codexOwnedMainRefusals.capture(codexHome, token)!; + codexOwnedMainCredential = { home: codexHome, expiresAt: typeof exp === "number" ? exp * 1000 : Infinity, authentication }; return result; } diff --git a/src/codex/native-main-refusals.ts b/src/codex/native-main-refusals.ts index 2999964d87b..00e5e744ce7 100644 --- a/src/codex/native-main-refusals.ts +++ b/src/codex/native-main-refusals.ts @@ -1,13 +1,16 @@ import { createHash } from "node:crypto"; +import { resolveCodexHomeDir } from "./home"; const MAX_HOMES = 8; const MAX_FINGERPRINTS = 4; -type HomeState = { observed?: string; refused: boolean; unknown: boolean; fingerprints: Set }; +type HomeState = { observed?: string; refused: boolean; unknown: boolean; fingerprints: Set; generation: number }; +export type NativeMainAuthentication = Readonly<{ home: string; fingerprint: string; generation: number }>; /** Bounded process-local metadata only. Losing refusal history always strengthens admission. */ export class NativeMainRefusals { private readonly homes = new Map(); private evictedRefusal = false; + private generation = 0; private state(home: string): HomeState { const existing = this.homes.get(home); @@ -19,7 +22,7 @@ export class NativeMainRefusals { if (oldest[1].refused) this.evictedRefusal = true; this.homes.delete(oldest[0]); } - const state: HomeState = { refused: this.evictedRefusal, unknown: this.evictedRefusal, fingerprints: new Set() }; + const state: HomeState = { refused: this.evictedRefusal, unknown: this.evictedRefusal, fingerprints: new Set(), generation: ++this.generation }; this.homes.set(home, state); return state; } @@ -35,6 +38,7 @@ export class NativeMainRefusals { refuse(home: string, bearer?: string): void { const state = this.state(home); + state.generation = ++this.generation; state.refused = true; const candidates = [state.observed, bearer ? fingerprint(bearer) : undefined]; for (const candidate of candidates) { @@ -49,18 +53,39 @@ export class NativeMainRefusals { if (state.fingerprints.size === 0) state.unknown = true; } - /** Remember an accepted owned RPC; the caller verifies identity for refusal recovery. */ + /** Read observations remember candidates; they never recover a refused home. */ observe(home: string, bearer: string): void { const state = this.state(home); state.observed = fingerprint(bearer); + } + + capture(home: string, bearer: string): NativeMainAuthentication | undefined { + const state = this.state(home); + if (this.refuses(home, bearer)) return undefined; + return { home, fingerprint: fingerprint(bearer), generation: state.generation }; + } + + current(proof: NativeMainAuthentication): boolean { + const state = this.homes.get(proof.home); + return !!state && state.generation === proof.generation && !state.unknown + && !state.fingerprints.has(proof.fingerprint); + } + + /** Only a successful upstream exchange with this exact pre-dispatch candidate may recover. */ + authenticated(proof: NativeMainAuthentication): boolean { + if (!this.current(proof)) return false; + const state = this.homes.get(proof.home)!; + state.observed = proof.fingerprint; state.refused = false; state.unknown = false; state.fingerprints.clear(); + return true; } /** Recovery authority: a committed, identity-checked native-main device login. */ verifiedLogin(home: string): void { const state = this.state(home); + state.generation = ++this.generation; state.observed = undefined; state.refused = false; state.unknown = false; @@ -73,3 +98,16 @@ function fingerprint(bearer: string): string { } export const nativeMainRefusals = new NativeMainRefusals(); + +/** Common admission boundary for every owned main file/keyring bearer reader. */ +export function nativeMainBearerAllowed(bearer: string, home = resolveCodexHomeDir()): boolean { + return !nativeMainRefusals.refuses(home, bearer); +} + +export function nativeMainAuthenticationIsCurrent(proof: NativeMainAuthentication): boolean { + return proof.home === resolveCodexHomeDir() && nativeMainRefusals.current(proof); +} + +export function confirmNativeMainAuthentication(proof: NativeMainAuthentication): boolean { + return nativeMainAuthenticationIsCurrent(proof) && nativeMainRefusals.authenticated(proof); +} diff --git a/src/codex/quota-auto-refresh.ts b/src/codex/quota-auto-refresh.ts index 42fa040ee53..4952c632312 100644 --- a/src/codex/quota-auto-refresh.ts +++ b/src/codex/quota-auto-refresh.ts @@ -15,7 +15,7 @@ import { isMainAccountHardLocked } from "./main-account-hard-lock"; import { tryAcquireNativeMainProfileClaim } from "./native-main-admission"; import { withNativeMainSharedClaim } from "./native-main-claim"; import { resolveNativeProfileContext } from "./native-profile-store"; -import { getMainQuotaCredentialGeneration, observeMainQuotaCredential } from "./main-account-cache"; +import { confirmMainQuotaAuthentication, getMainQuotaCredentialGeneration, observeMainQuotaCredential } from "./main-account-cache"; import { applyAccountQuotaFromUpstreamHeaders, getAccountQuota, type StoredAccountQuota } from "./quota"; import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./warmup"; import { @@ -263,6 +263,7 @@ async function warmAccount(config: OcxConfig, accountId: string): Promise { if (writer && credentialStillLive()) { + confirmMainQuotaAuthentication(writer); applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration, writer); } } }); diff --git a/src/server/audio-upstream.ts b/src/server/audio-upstream.ts index 076dbee2649..a781ecda553 100644 --- a/src/server/audio-upstream.ts +++ b/src/server/audio-upstream.ts @@ -29,6 +29,7 @@ import { codexAccountSelectionForTurn } from "./lifecycle"; import type { RequestLogContext } from "./request-log"; import { authorizationCredential } from "../lib/authorization-scheme"; import { nativeMainRefreshFailureResponse } from "./responses/codex-auth-error"; +import { confirmMainQuotaAuthentication } from "../codex/main-account-cache"; export const TRANSCRIPTION_MODEL = "gpt-4o-transcribe"; export const LIVE_AUDIO_MODEL = "gpt-live-1-codex"; @@ -151,7 +152,11 @@ export async function resolveAudioUpstream( headers: Object.fromEntries(selected), keyed: false, authContext: context, - recordOutcome, + recordOutcome: status => { + const writer = context?.kind === "main-pool" ? context.mainQuotaWriter : context?.kind === "main" ? context.mainQuotaDispatch?.writer : undefined; + if (typeof status === "number" && status >= 200 && status < 300 && writer) confirmMainQuotaAuthentication(writer); + recordOutcome?.(status); + }, release: () => releaseCodexAuthContextProbeLease(context), }; } diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 49c0db6e688..24c4d9516c3 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -4,6 +4,7 @@ import { createPhysicalSendReporter, rebindPhysicalSend } from "../../lib/reques import { createInferenceSendBudget } from "../inference/context"; import { unboundPoolSpendRefusalResponse } from "../workflow-refusal"; import { capturePoolQuotaWriter } from "../../codex/account-store"; +import { confirmMainQuotaAuthentication } from "../../codex/main-account-cache"; import { join } from "node:path"; import { rmSync } from "node:fs"; import { previewXaiOauthWireModel } from "./core-normalize"; @@ -1601,6 +1602,8 @@ export async function handleResponsesCompact( } } // Capture the final serving account as well as an earlier rejected account, once per response. + const mainWriter = outcomeCtx.kind === "main-pool" ? outcomeCtx.mainQuotaWriter : outcomeCtx.kind === "main" ? outcomeCtx.mainQuotaDispatch?.writer : undefined; + if (upstream.ok && mainWriter) confirmMainQuotaAuthentication(mainWriter); if (outcomeCtx.kind === "pool") { const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/quota"); applyAccountQuotaFromUpstreamHeaders(outcomeCtx.accountId, upstream.headers, outcomeCtx.writerGeneration, diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 60d07e53ff2..f604d05573c 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -33,7 +33,7 @@ import { isFixedCodexAccount, shouldDeferCodexResetDerivedCooldown, } from "./core-codex-account"; -import { isMainQuotaDispatchLive, isMainQuotaDispatchWsClaimed } from "../../codex/main-account-cache"; +import { confirmMainQuotaAuthentication, isMainQuotaDispatchLive, isMainQuotaDispatchWsClaimed } from "../../codex/main-account-cache"; import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/account-id"; import type { ResponsesTerminalStatus } from "../../bridge"; import { isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse, isCodexWsPreludeProjection } from "./ws-upstream"; @@ -510,6 +510,9 @@ export async function deliverPassthroughResponse( } : undefined; const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream; + const mainWriter = admissionState.authCtx.kind === "main-pool" ? admissionState.authCtx.mainQuotaWriter : arrivalMainDispatch?.writer; + if (upstreamResponse.ok && mainWriter && !isCodexWsUpstreamResponse(upstreamResponse) + && !isCodexWsPreludeProjection(upstreamResponse)) confirmMainQuotaAuthentication(mainWriter); // Capture quota from upstream response for multi-account tracking if (usesCodexForwardPoolAuth(admissionState.authCtx, route.provider)) { // primary was the 5h window; it now carries weekly data for GPT plans. diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 7ec58db50d7..1e788b10df9 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -310,7 +310,7 @@ OpenCodex uses the returned bearer for that request, writes nothing and does not With no file credential, every lookup reads the current login with `refreshToken: false`; an expiring or explicitly rejected bearer requests refresh. Only home-scoped expiry/refusal metadata survives lookup. `src/codex/native-main-refusals.ts` retains domain-separated SHA-256 fingerprints, never bearers: at most eight homes, each with its last observed RPC fingerprint and four unresolved refused fingerprints. Uncertain reads and home switches preserve evidence. Fingerprint overflow becomes refused/unknown. Home eviction prefers non-refused records; evicting a refusal sets a process-local saturation latch, making every untracked home refused/unknown. Tracked homes retain their evidence; untracked homes require a committed verified login or process restart. This trades availability after saturation for bounded memory without admitting evicted refused bearers. -A `chatgpt` method with a null token rejects the file grant or keyring observation, including the last observed RPC fingerprint when no rejected or locked bearer exists. Reappearance of a refused bearer, or an opaque/conflicting identity, preserves refusal, including restored auth.json through status, usability, raw-token, liveness and refresh shortcuts. Known home refusal clears only for a demonstrably different live bearer with verified identity from owned RPC or auth.json; file access-token, id-token and stored account identities must agree. Unknown or saturated refusal requires the existing same-identity device-login `beginNativeMainReauth().commit` to publish its complete token set under the native claim and snapshot guards; ordinary verified RPC or file evidence cannot clear it. A weak association gives only the exact immutable RPC result its quota writer, home and credential generation. Both Responses and compact 401 retries transfer `mainAccountTokenQuotaWriter(refreshed)` before copying token fields, preserving the successor writer after account replacement; a result without owned RPC provenance retains the prior fence. +A `chatgpt` method with a null token rejects the file grant or keyring observation, including the last observed RPC fingerprint when no rejected or locked bearer exists. The shared `nativeMainBearerAllowed` guard fences resolution, status/raw/liveness reads, direct file readers in `src/codex/auth-collision.ts`, account probes, quota priming, reset-credit inspection/consumption, audio and idle recovery. Reads never clear or shrink refusals: a different live bearer is only a candidate, and decoded file identities must agree. Known refusal clears only after upstream 2xx evidence for that exact candidate and home generation, or committed verified login; quota/account-info publication and reset consumption recheck admission. Refusal, home/identity changes and credential ABA invalidate delayed evidence. Unknown or saturated refusal requires the existing same-identity device-login `beginNativeMainReauth().commit` under native claim and snapshot guards. A weak association gives only the exact immutable RPC result its quota writer, home and credential generation. Responses and compact 401 retries transfer `mainAccountTokenQuotaWriter(refreshed)` before copying fields; results without owned RPC provenance retain the prior fence. Without prior permanent refusal, an unreachable binary, unchanged rejected bearer or expired bearer is transient. A non-ChatGPT or missing login answers an ordinary lookup as an observed absence, while a rejected-token or file-locked refresh still reports it transiently. Read-only lookups honor caller cancellation. Actual refresh runs on its independent 30-second timeout so Codex can save its grant. Both paths pin CODEX_HOME and hold its exclusive claim; home and identity-generation changes reject a delayed observation. Only file-backed refresh has a local grant fingerprint lock; keyring claim deadlines are transient. `"opencodex"`, missing or old Codex keeps the guarded writer. diff --git a/tests/codex-integration/codex-credits-probes.test.ts b/tests/codex-integration/codex-credits-probes.test.ts index 71e7602d963..7690c352299 100644 --- a/tests/codex-integration/codex-credits-probes.test.ts +++ b/tests/codex-integration/codex-credits-probes.test.ts @@ -15,6 +15,14 @@ import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; import type { CodexAccount, OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { fakeChatGptJwt } from "../helpers/agent-task-recovery"; +import { getMainAccountCredentialStatus, getMainAccountToken, getValidMainAccountToken } from "../../src/codex/main-account"; +import { nativeMainRefusals } from "../../src/codex/native-main-refusals"; +import { setNativeMainRefreshResolutionForTests, type CodexAuthStatus } from "../../src/codex/native-main-refresh-mode"; +import { inspectResetCredits, consumeResetCredits } from "../../src/codex/auth-api/reset-credit-service"; +import { clearCodexQuotaPrimeState, primeCodexPoolQuotas } from "../../src/codex/auth-api/pool-mode-gate"; +import { getAccountQuota } from "../../src/codex/quota"; +import { getMainAccountInfoCache } from "../../src/codex/main-account-cache"; let testDir: string; let previousHome: string | undefined; @@ -60,6 +68,9 @@ beforeEach(() => { resetQuotaQueryBackoffForTests(); }); afterEach(() => { + nativeMainRefusals.verifiedLogin(process.env.CODEX_HOME!); + setNativeMainRefreshResolutionForTests(null); + clearCodexQuotaPrimeState(); clearMainAccountInfoCache(); clearAccountQuota(); resetMainCodexAccountIdentityTrackingForTests(); @@ -74,6 +85,108 @@ afterEach(() => { removeTreeWithRetry(testDir); }); +async function refuseFileless(bearer: string, kind: "known" | "unknown" | "saturated" = "known") { + setNativeMainRefreshResolutionForTests({ mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fake/codex", version: "0.162.1" }); + const answer = (authToken: string | null) => async (): Promise => ({ authMethod: "chatgpt", authToken, requiresOpenaiAuth: true }); + if (kind !== "unknown") await getValidMainAccountToken({ codexAuthStatus: answer(bearer) }); + await expect(getValidMainAccountToken({ codexAuthStatus: answer(null) })).rejects.toMatchObject({ reason: "reauth" }); + if (kind === "saturated") for (let i = 0; i < 5; i++) nativeMainRefusals.refuse(process.env.CODEX_HOME!, `fixture-refused-${i}`); +} + +describe("native-main refusal at every metadata dispatch", () => { + for (const history of ["known", "unknown", "saturated", "plain"] as const) { + test.each(["probe", "list", "inspect", "consume", "prime"] as const)(`${history} history gates %s`, async path => { + const bearer = fakeChatGptJwt("fixture-main-identity"); + if (history !== "plain") await refuseFileless(bearer, history); + writeMain(bearer); + let requests = 0; + globalThis.fetch = (async () => { + requests++; + return Response.json({ plan_type: "plus", email: "fixture@example.test", code: "no_credit", available_count: 2, + rate_limit: { primary_window: { used_percent: 12, limit_window_seconds: 18000, reset_at: Math.floor(Date.now() / 1000) + 3600 } } }); + }) as typeof fetch; + const cfg: OcxConfig = { ...config(), providers: { openai: { name: "OpenAI", adapter: "openai-responses", + authMode: "forward", codexAccountMode: "pool", baseUrl: "https://chatgpt.com/backend-api/codex" } } }; + let response: Response | undefined; + if (path === "probe") await fetchMainAccountInfoSnapshot(true, cfg); + else if (path === "list") await listCodexAuthAccounts(cfg, true); + else if (path === "inspect") response = await inspectResetCredits(cfg, "__main__", new AbortController().signal); + else if (path === "consume") response = await consumeResetCredits(cfg, "__main__", undefined); + else await primeCodexPoolQuotas(cfg, "startup"); + expect(requests).toBe(history === "plain" ? 1 : 0); + if (response) expect(response.status).toBe(history === "plain" ? 200 : 401); + if (history !== "plain") { + expect(getMainAccountInfoCache()).toBeNull(); + expect(getAccountQuota("__main__")).toBeNull(); + expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); + } + }); + } + + test.each([200, 401])("successor candidate clears refusal only after upstream %s", async status => { + const bearer = fakeChatGptJwt("fixture-main-identity", { jti: "refused" }); + await refuseFileless(bearer); + const successor = fakeChatGptJwt("fixture-main-identity", { jti: "successor" }); + writeMain(successor); + expect(getMainAccountCredentialStatus().usable).toBe(true); + expect(getMainAccountToken()?.accessToken).toBe(successor); + expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); + let requests = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + requests++; + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${successor}`); + return Response.json({ plan_type: "plus" }, { status }); + }) as typeof fetch; + await fetchMainAccountInfoSnapshot(true, config()); + expect(requests).toBe(1); + expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(status !== 200); + if (status !== 200) { + writeMain(bearer); + expect(getMainAccountToken()).toBeNull(); + } + }); + + test("a refusal during WHAM invalidates publication and authenticated recovery", async () => { + const bearer = fakeChatGptJwt("fixture-main-identity"); + writeMain(bearer); + globalThis.fetch = (async () => { + nativeMainRefusals.refuse(process.env.CODEX_HOME!, bearer); + return Response.json({ plan_type: "plus", credits: { balance: "999" }, rate_limit: { + primary_window: { used_percent: 12, limit_window_seconds: 18000, reset_at: Math.floor(Date.now() / 1000) + 3600 } } }); + }) as typeof fetch; + await fetchMainAccountInfoSnapshot(true, config()); + expect(getMainAccountInfoCache()).toBeNull(); + expect(getAccountQuota("__main__")).toBeNull(); + expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); + }); + + test.each(["inspect", "consume"] as const)("reset-credit %s authenticates a successor only after upstream success", async path => { + await refuseFileless(fakeChatGptJwt("fixture-main-identity", { jti: "refused-reset" })); + const successor = fakeChatGptJwt("fixture-main-identity", { jti: "successor-reset" }); + writeMain(successor); + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${successor}`); + expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); + return Response.json({ available_count: 2, code: "no_credit" }); + }) as typeof fetch; + const response = path === "inspect" ? await inspectResetCredits(config(), "__main__", new AbortController().signal) + : await consumeResetCredits(config(), "__main__", undefined); + expect(response.status).toBe(200); + expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(false); + }); + + test("reset inspection cannot publish after a concurrent refusal", async () => { + const bearer = fakeChatGptJwt("fixture-main-identity"); + writeMain(bearer); + globalThis.fetch = (async () => { + nativeMainRefusals.refuse(process.env.CODEX_HOME!, bearer); + return Response.json({ available_count: 2 }); + }) as typeof fetch; + expect((await inspectResetCredits(config(), "__main__", new AbortController().signal)).status).toBe(409); + expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); + }); +}); + describe("main credits publication", () => { test("current credential publishes credits-only usage, omission keeps, null clears", async () => { writeMain(); diff --git a/tests/codex-integration/native-main-refresh-mode.test.ts b/tests/codex-integration/native-main-refresh-mode.test.ts index dfa3b89fb8f..3451aba8309 100644 --- a/tests/codex-integration/native-main-refresh-mode.test.ts +++ b/tests/codex-integration/native-main-refresh-mode.test.ts @@ -136,6 +136,8 @@ process.stdin.on("data", chunk => { let root: string; let home: string; let previousCodexHome: string | undefined; +let refusalHomes: Map; +let refusalEvicted: boolean; const touchedEnv = ["OCX_FAKE_CODEX_SCENARIO", "OCX_FAKE_CODEX_LOG", "OCX_FAKE_CODEX_TOKEN"] as const; function fakeCodex(): { command: string; logPath: string } { @@ -159,6 +161,8 @@ function seedAuth(accessToken: string, refreshToken = "codex-owned-grant"): stri const expired = () => fakeChatGptJwt("acct-main", { exp: Math.floor(Date.now() / 1000) - 60 }); beforeEach(() => { + refusalHomes = new Map(Reflect.get(nativeMainRefusals, "homes")); + refusalEvicted = Reflect.get(nativeMainRefusals, "evictedRefusal"); root = mkdtempSync(join(tmpdir(), "ocx-native-refresh-mode-")); home = join(root, "codex-home"); mkdirSync(home); @@ -167,6 +171,8 @@ beforeEach(() => { }); afterEach(() => { + Reflect.set(nativeMainRefusals, "homes", refusalHomes); + Reflect.set(nativeMainRefusals, "evictedRefusal", refusalEvicted); setNativeMainRefreshResolutionForTests(null); for (const name of touchedEnv) delete process.env[name]; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; @@ -763,10 +769,12 @@ describe("main account in codex refresh mode (#2999)", () => { status = { authMethod: "chatgpt", authToken: uncertain, requiresOpenaiAuth: true }; await expect(getValidMainAccountToken({ codexAuthStatus })).rejects.toMatchObject({ reason: "reauth" }); expect(getMainAccountCredentialStatus()).toMatchObject({ refreshGrantRejected: true, usable: false }); - // A different bearer for the same verified identity is sufficient successor evidence. + // A different bearer is a candidate; reading it must retain the refused fingerprints. const successor = fakeChatGptJwt("acct-main", { jti: "verified-successor" }); status = { authMethod: "chatgpt", authToken: successor, requiresOpenaiAuth: true }; expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: successor, chatgptAccountId: "acct-main" }); + expect(nativeMainRefusals.has(home)).toBe(true); + expect(nativeMainRefusals.refuses(home, rejected)).toBe(true); expect(isMainAccountRefreshGrantRejected()).toBe(false); expect(getMainAccountCredentialStatus().usable).toBe(true); }); @@ -867,6 +875,29 @@ describe("main account in codex refresh mode (#2999)", () => { expect(isMainAccountRefreshGrantRejected()).toBe(false); }); + test.each(["status", "valid", "raw", "live", "verifiably-live"] as const)( + "signature-mutated successor cannot clear a refusal through %s reads", async admission => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const bearer = fakeChatGptJwt("acct-main", { jti: "signature-only-mutation" }); + const answer = (authToken: string | null) => async (): Promise => ({ + authMethod: "chatgpt", authToken, requiresOpenaiAuth: true, + }); + await getValidMainAccountToken({ codexAuthStatus: answer(bearer) }); + await expect(getValidMainAccountToken({ codexAuthStatus: answer(null) })).rejects.toMatchObject({ reason: "reauth" }); + const forged = bearer.slice(0, bearer.lastIndexOf(".") + 1) + "forged-signature"; + seedAuth(forged); + if (admission === "status") expect(getMainAccountCredentialStatus().usable).toBe(true); + else if (admission === "valid") expect((await getValidMainAccountToken())?.accessToken).toBe(forged); + else if (admission === "raw") expect(getMainAccountToken()?.accessToken).toBe(forged); + else expect(admission === "live" ? isMainAccountTokenLive() : isMainAccountTokenVerifiablyLive()).toBe(true); + expect(nativeMainRefusals.has(home)).toBe(true); + seedAuth(bearer); + expect(getMainAccountToken()).toBeNull(); + expect(getMainAccountCredentialStatus().usable).toBe(false); + await expect(getValidMainAccountToken()).rejects.toMatchObject({ reason: "reauth" }); + }, + ); + test("unknown ordinary lookup refusal cannot be cleared by a newly observed verified bearer", async () => { setNativeMainRefreshResolutionForTests(CODEX_MODE); await expect(getValidMainAccountToken({ codexAuthStatus: async () => ({ diff --git a/tests/responses/responses-native-main-refresh.test.ts b/tests/responses/responses-native-main-refresh.test.ts index acc2d58c51c..a77827b085f 100644 --- a/tests/responses/responses-native-main-refresh.test.ts +++ b/tests/responses/responses-native-main-refresh.test.ts @@ -29,6 +29,7 @@ import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; +import { nativeMainRefusals } from "../../src/codex/native-main-refusals"; /** Smallest complete Anthropic stream: the combo's second target has to actually answer. */ const ANTHROPIC_SSE = [ @@ -108,6 +109,7 @@ beforeEach(() => { }); afterEach(() => { + nativeMainRefusals.verifiedLogin(home); setNativeMainRefreshResolutionForTests(null); clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); resetMainCodexAccountIdentityTrackingForTests(); @@ -160,6 +162,39 @@ function install401ThenRefreshHarness(): { sends: string[]; refreshes: string[] } describe("native main 401 refresh and replay", () => { + test.each(["/v1/responses", "/v1/responses/compact"] as const)( + "%s authenticates a successor only on its upstream 2xx", async path => { + clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); + resetMainCodexAccountIdentityTrackingForTests(); + fs.rmSync(join(home, "auth.json")); + setNativeMainRefreshResolutionForTests({ mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fixture/codex", version: "0.162.1" }); + const rejected = fakeChatGptJwt("account-main", { jti: "refused" }); + await getValidMainAccountToken({ codexAuthStatus: async () => ({ authMethod: "chatgpt", authToken: rejected, requiresOpenaiAuth: true }) }); + await expect(getValidMainAccountToken({ codexAuthStatus: async () => ({ authMethod: "chatgpt", authToken: null, requiresOpenaiAuth: true }) })).rejects.toMatchObject({ reason: "reauth" }); + const successor = fakeChatGptJwt("account-main", { jti: "successor" }); + writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: successor, account_id: "account-main" } })); + expect((await getValidMainAccountToken())?.accessToken).toBe(successor); + expect(nativeMainRefusals.has(home)).toBe(true); + let sends = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (!url.pathname.endsWith("/responses") && !url.pathname.endsWith("/responses/compact")) return Response.json({}, { status: 503 }); + sends++; + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${successor}`); + expect(nativeMainRefusals.has(home)).toBe(true); + return Response.json({ id: "resp_authenticated", object: "response", status: "completed", output: [] }); + }) as typeof fetch; + const cfg = config(); + const req = request(path); + const log = { model: "", provider: "" } as RequestLogContext; + const response = path === "/v1/responses" ? await handleResponses(req, cfg, log) + : await handleResponsesCompact(req, cfg, log); + const body = await response.text(); + expect({ status: response.status, error: response.status === 200 ? undefined : body }).toEqual({ status: 200, error: undefined }); + expect(sends).toBe(1); + expect(nativeMainRefusals.has(home)).toBe(false); + }, + ); test.each(["/v1/responses", "/v1/responses/compact"] as const)( "%s publishes retry quota for a keyring account replacement after 401", async path => { clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); diff --git a/tests/server/audio-transcriptions.test.ts b/tests/server/audio-transcriptions.test.ts index 1144dddaaa5..7acf7b7dbcd 100644 --- a/tests/server/audio-transcriptions.test.ts +++ b/tests/server/audio-transcriptions.test.ts @@ -21,6 +21,8 @@ import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; +import { getValidMainAccountToken } from "../../src/codex/main-account"; +import { nativeMainRefusals } from "../../src/codex/native-main-refusals"; const KEY = "ocx_data_audio_test_client"; const originalFetch = globalThis.fetch; @@ -102,6 +104,7 @@ afterEach(async () => { clearMainAccountInfoCache(); resetMainCodexAccountIdentityTrackingForTests(); resetLifecycleDrainStateForTests(); + nativeMainRefusals.verifiedLogin(codex.path); codex.restore(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; @@ -331,6 +334,33 @@ describe("standalone transcription API", () => { } }); + test.each([false, true])("stored Direct audio honors refusal until authenticated successor (successor=%s)", async successor => { + nativeMainRefresh.setNativeMainRefreshResolutionForTests({ mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fake/codex", version: "0.162.1" }); + const bearer = fakeChatGptJwt({ chatgpt_account_id: "fixture-refused-audio" }); + try { + await getValidMainAccountToken({ codexAuthStatus: async () => ({ authMethod: "chatgpt", authToken: bearer, requiresOpenaiAuth: true }) }); + await expect(getValidMainAccountToken({ codexAuthStatus: async () => ({ authMethod: "chatgpt", authToken: null, requiresOpenaiAuth: true }) })).rejects.toMatchObject({ reason: "reauth" }); + const selected = successor ? fakeChatGptJwt({ chatgpt_account_id: "fixture-refused-audio", jti: "successor" }) : bearer; + writeFileSync(join(codex.path, "auth.json"), JSON.stringify({ tokens: { access_token: selected, account_id: "fixture-refused-audio" } })); + const cfg = config(); + cfg.defaultProvider = "openai"; + cfg.providers = { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" } }; + saveConfig(cfg); + respond = req => { + expect(nativeMainRefusals.has(codex.path)).toBe(true); + expect(req.headers.get("authorization")).toBe(`Bearer ${selected}`); + return Response.json({ text: "synthetic successor transcript" }); + }; + const response = await request(); + await response.text(); + expect(response.status).toBe(successor ? 200 : 401); + expect(captured).toHaveLength(successor ? 1 : 0); + expect(nativeMainRefusals.has(codex.path)).toBe(!successor); + } finally { + nativeMainRefresh.setNativeMainRefreshResolutionForTests(null); + } + }); + test("stored Direct credentials never inherit a caller account ID", async () => { writeFileSync(join(codex.path, "auth.json"), JSON.stringify({ tokens: { access_token: "fixture-main-access" } })); clearMainAccountInfoCache(); From 3bf1c094f6d1224777341437921dcf6e060820a4 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:27:01 +0900 Subject: [PATCH 13/15] fix(codex): recheck native-main refusal at dispatch and drop gateway-based recovery --- src/codex/auth-api/main-account-probe.ts | 3 +- src/codex/auth-api/reset-credit-service.ts | 4 +- src/codex/auth-context.ts | 4 +- .../desktop-compatibility/native-identity.ts | 9 ++- src/codex/main-account-cache.ts | 13 +--- src/codex/model-entitlements.ts | 16 +++- src/codex/native-main-refusals.ts | 29 +++---- src/codex/quota-auto-refresh.ts | 7 +- src/codex/warmup.ts | 3 + src/oauth/token-guardian.ts | 5 ++ src/server/audio-upstream.ts | 7 +- src/server/responses/compact.ts | 3 - src/server/responses/passthrough-delivery.ts | 5 +- structure/catalog.md | 2 +- structure/clients/codex-desktop.md | 2 +- structure/providers/openai-accounts.md | 2 +- ...ktop-compatibility-native-identity.test.ts | 28 ++++++- .../codex-credits-probes.test.ts | 45 ++++++++--- ...-quota-auto-refresh-main-admission.test.ts | 14 ++++ .../codex-integration/token-guardian.test.ts | 18 +++++ .../responses-native-main-refresh.test.ts | 77 ++++++++++++++++++- tests/server/audio-transcriptions.test.ts | 4 +- 22 files changed, 230 insertions(+), 70 deletions(-) diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index 871b87b2eeb..13dbde4d02f 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -16,7 +16,7 @@ import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../account-runt import { decodeJwtPayload, extractAccountId } from "../../oauth/chatgpt"; import { getMainAccountPlan, getValidMainAccountToken, isMainAccountTokenVerifiablyLive, mainAccountTokenIsCurrent, mainAccountTokenQuotaWriter, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan, type MainAccountToken } from "../main-account"; import { resolveCodexHomeDir } from "../home"; -import { confirmNativeMainAuthentication, nativeMainAuthenticationIsCurrent, nativeMainRefusals } from "../native-main-refusals"; +import { nativeMainAuthenticationIsCurrent, nativeMainRefusals } from "../native-main-refusals"; import { captureConfigGeneration } from "../../lib/state-store-sweeper"; import { captureMainAccountIdentityGeneration, clearMainAccountInfoCache, getMainAccountInfoCache, getMainQuotaCredentialGeneration, isMainAccountIdentityGenerationLive, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential, setMainAccountCredentialPresence, setMainAccountInfoCache } from "../main-account-cache"; import type { MainQuotaWriter, MainAccountInfo } from "../main-account-cache"; @@ -424,7 +424,6 @@ export async function fetchMainAccountInfoWhileOwned( credentialChecked: true, hasCredential: true }; } const freshResetCredits = quota?.resetCredits; - confirmNativeMainAuthentication(authentication); // Tag the count with the identity it was read from, so a later response that omits the // summary can restore the badge without ever crossing an account boundary. rememberMainResetCredits(requestAccountId, freshResetCredits); diff --git a/src/codex/auth-api/reset-credit-service.ts b/src/codex/auth-api/reset-credit-service.ts index 054d2482091..cca9ec2ecbf 100644 --- a/src/codex/auth-api/reset-credit-service.ts +++ b/src/codex/auth-api/reset-credit-service.ts @@ -5,7 +5,7 @@ import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; import { claimManualResetCooldowns, settleManualResetCooldown } from "../routing"; import type { ManualResetCooldownClaim } from "../routing"; import { readCodexTokens } from "../auth-collision"; -import { confirmNativeMainAuthentication, nativeMainAuthenticationIsCurrent, nativeMainBearerAllowed, nativeMainRefusals, type NativeMainAuthentication } from "../native-main-refusals"; +import { nativeMainAuthenticationIsCurrent, nativeMainBearerAllowed, nativeMainRefusals, type NativeMainAuthentication } from "../native-main-refusals"; import { resolveCodexHomeDir } from "../home"; import { extractAccountId } from "../../oauth/chatgpt"; import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; @@ -310,7 +310,6 @@ export async function inspectResetCredits(config: OcxConfig, accountId: string, return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502); } if (auth.isMain && !manualResetAuthStillLive(accountId, auth)) return jsonResponse({ error: "Main Codex credential changed" }, 409); - if (auth.authentication) confirmNativeMainAuthentication(auth.authentication); return jsonResponse(safeResetCreditsDto(parsed.value)); } finally { detachBodyAbort(); @@ -397,7 +396,6 @@ export async function consumeResetCredits(config: OcxConfig, accountId: string, return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); } const consumed = await readResetCreditJson(resp, AbortSignal.timeout(10_000)); - if (auth.authentication && manualResetAuthStillLive(accountId, auth)) confirmNativeMainAuthentication(auth.authentication); if (!consumed.ok) { // The spend may already have landed upstream and its outcome code is unreadable, // so this id must never come back as a new operation. diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 4a0803c2830..391bcd490f6 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,6 +1,6 @@ import { bindPoolCreditPolicy, poolCreditHoldResetAt, poolContextCreditHoldResetAt, poolCreditRecoveryNeeded, poolContextCreditRecoveryNeeded, poolCreditRecoveryRetryAt } from "./pool-credit-policy"; import { hasSpendableCodexCredits } from "./quota-types"; -import { nativeMainBearerAllowed } from "./native-main-refusals"; +import { guardNativeMainDispatch, nativeMainBearerAllowed } from "./native-main-refusals"; import { nextQuotaQueryDelay } from "./quota-query-backoff"; import { noteMainAccountActivity } from "./main-account-external-usage"; import { codexAccountPriorityFailbackEnabled } from "./account-priority"; @@ -779,7 +779,7 @@ export function createCodexAuthDispatchGuard( admission?: Pick, terminalHelper = false, ): ((headers: Headers) => void) | undefined { - const reserveGuard = createCodexReserveDispatchGuard(ctx, config, modelId, admission, terminalHelper); + const reserveGuard = guardNativeMainDispatch(ctx, createCodexReserveDispatchGuard(ctx, config, modelId, admission, terminalHelper), () => new CodexAuthContextError(MAIN_CODEX_ACCOUNT_ID, new MainAccountTokenRefreshError("reauth"))); if (ctx.kind !== "pool" && ctx.kind !== "main-pool") return reserveGuard; return headers => { if (config.pausedCodexAccountIds?.includes(ctx.accountId)) throw new CodexPoolAuthenticationError("Selected Codex account is paused; resume it in account settings"); diff --git a/src/codex/desktop-compatibility/native-identity.ts b/src/codex/desktop-compatibility/native-identity.ts index e3f97d59e86..19a6f5d3474 100644 --- a/src/codex/desktop-compatibility/native-identity.ts +++ b/src/codex/desktop-compatibility/native-identity.ts @@ -1,5 +1,7 @@ import { readFileSync, statSync, type BigIntStats } from 'node:fs'; import { createHash, randomUUID } from 'node:crypto'; +import { dirname } from 'node:path'; +import { nativeMainBearerAllowed, nativeMainRefusals } from '../native-main-refusals'; import type { UsageIdentity } from './usage-controller'; type Credential = { identity: UsageIdentity; accessToken: string }; @@ -21,7 +23,8 @@ export function createNativeIdentityReader(authPath: string, upstreamFetch: type const auth = JSON.parse(text); if (auth.auth_mode !== 'chatgpt' || typeof auth.tokens?.id_token !== 'string' || typeof auth.tokens?.access_token !== 'string' || !auth.tokens.access_token - || typeof auth.tokens?.account_id !== 'string') throw new Error('Native login required'); + || typeof auth.tokens?.account_id !== 'string' + || !nativeMainBearerAllowed(auth.tokens.access_token, dirname(authPath))) throw new Error('Native login required'); // These local claims are only a binding hint. Activation additionally verifies upstream. const claims = JSON.parse(Buffer.from(auth.tokens.id_token.split('.')[1], 'base64url').toString('utf8'))['https://api.openai.com/auth']; if (!claims || claims.chatgpt_account_id !== auth.tokens.account_id @@ -36,6 +39,8 @@ export function createNativeIdentityReader(authPath: string, upstreamFetch: type const verifyFreshIdentity = async (): Promise => { try { const before = readCredential(); + const proof = nativeMainRefusals.capture(dirname(authPath), before.accessToken); + if (!proof) return null; const response = await upstreamFetch('https://chatgpt.com/backend-api/wham/usage', { headers: { authorization: 'Bearer ' + before.accessToken, 'ChatGPT-Account-ID': before.identity.id }, redirect: 'manual', signal: AbortSignal.timeout(10000), @@ -52,7 +57,7 @@ export function createNativeIdentityReader(authPath: string, upstreamFetch: type } finally { reader.releaseLock(); } const usage = JSON.parse(Buffer.concat(chunks).toString('utf8')); const after = readCredential(); - if (!equal(before.identity, after.identity) || before.accessToken !== after.accessToken + if (!nativeMainRefusals.current(proof) || !equal(before.identity, after.identity) || before.accessToken !== after.accessToken || usage.account_id !== before.identity.id || usage.user_id !== before.identity.userId || usage.plan_type !== before.identity.plan) return null; return before.identity; diff --git a/src/codex/main-account-cache.ts b/src/codex/main-account-cache.ts index c022a1a54ca..8f793b56732 100644 --- a/src/codex/main-account-cache.ts +++ b/src/codex/main-account-cache.ts @@ -5,7 +5,7 @@ import { codexCredentialMutationEpoch } from "./credential-mutation-epoch"; import type { StoredAccountQuota } from "./quota-types"; import { truncateRetainedUtf8 } from "../lib/admission"; import { resolveCodexHomeDir } from "./home"; -import { confirmNativeMainAuthentication, nativeMainAuthenticationIsCurrent, nativeMainBearerAllowed, nativeMainRefusals, type NativeMainAuthentication } from "./native-main-refusals"; +import { nativeMainAuthenticationIsCurrent, nativeMainBearerAllowed, nativeMainRefusals, type NativeMainAuthentication } from "./native-main-refusals"; const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024; @@ -49,14 +49,6 @@ export function getMainQuotaCredentialGeneration(): number { return mainQuotaCre export type MainQuotaWriter = Readonly<{ identityKey: string; identityGeneration: number }>; const decisionWriterGenerations = new WeakMap(); const writerAuthentications = new WeakMap(); -const writerCredentialGenerations = new WeakMap(); - -/** Called only for an upstream HTTP 2xx, never a local read or projected WS response. */ -export function confirmMainQuotaAuthentication(writer: MainQuotaWriter): boolean { - const proof = writerAuthentications.get(writer); - return !!proof && writerCredentialGenerations.get(writer) === mainQuotaCredentialGeneration - && isMainQuotaWriterLive(writer) && confirmNativeMainAuthentication(proof); -} /** Return the credential generation a main quota writer was captured under, or undefined once that writer is stale or its credential generation has moved. */ export function mainDecisionQuotaWriterGeneration(writer: MainQuotaWriter): number | undefined { const generation = decisionWriterGenerations.get(writer); @@ -88,7 +80,6 @@ export function captureMainQuotaWriter(accountId: string): MainQuotaWriter | und if (identityKey !== observedMainQuotaIdentityKey) return undefined; const writer = { identityKey, identityGeneration: mainAccountIdentityGeneration }; if (mainQuotaCredential) writerAuthentications.set(writer, mainQuotaCredential.authentication); - if (mainQuotaCredential) writerCredentialGenerations.set(writer, mainQuotaCredentialGeneration); if (mainQuotaCredential) decisionWriterGenerations.set(writer, mainDecisionGeneration); return writer; } @@ -104,12 +95,10 @@ export function observeMainQuotaCredential(accessToken: string, accountId: strin if (!mainQuotaCredential || !isMainQuotaWriterLive(mainQuotaCredential.writer) || !timingSafeEqual(bearerHmac, mainQuotaCredential.bearerHmac)) { mainQuotaCredentialGeneration += 1; mainDecisionGeneration += 1; } mainQuotaCredential = { bearerHmac, writer, authentication }; - writerCredentialGenerations.set(writer, mainQuotaCredentialGeneration); mainDecisionCredentialFresh = true; publishMainDecisionRoster(true); const result = { ...writer }; writerAuthentications.set(result, authentication); - writerCredentialGenerations.set(result, mainQuotaCredentialGeneration); decisionWriterGenerations.set(result, mainDecisionGeneration); return result; } diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index 4a93a23206c..c6c264f3fdb 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -1,4 +1,6 @@ import { createHash } from "node:crypto"; +import { nativeMainBearerAllowed } from "./native-main-refusals"; +import { resolveCodexHomeDir } from "./home"; import { readBoundedResponseBody } from "../lib/bounded-body"; import type { CodexAccountCredentialRecord, OcxConfig } from "../types"; import { isSelectableCodexPoolAccount } from "./account-id"; @@ -377,6 +379,13 @@ export interface CodexModelEntitlementCredentialSnapshot { readonly chatgptAccountId: string; /** Stable local identity for rejecting a catalog commit after credential replacement. */ readonly credentialIdentity: string; + readonly nativeMainHome?: string; +} + +function nativeMainCredentialAllowed(credential: CodexModelEntitlementCredentialSnapshot): boolean { + const home = credential.nativeMainHome ?? resolveCodexHomeDir(); + return credential.accountId !== MAIN_CODEX_ACCOUNT_ID + || (home === resolveCodexHomeDir() && nativeMainBearerAllowed(credential.accessToken, home)); } export type CodexModelEntitlementProvenance = @@ -608,6 +617,7 @@ async function accountCredentialSnapshot( ): Promise { if (accountId === MAIN_CODEX_ACCOUNT_ID) { try { + const nativeMainHome = resolveCodexHomeDir(); const token = await getValidMainAccountToken({ signal: options.signal, ...(options.nativeMainRefreshDependencies ?? {}), @@ -615,6 +625,7 @@ async function accountCredentialSnapshot( return token ? { accountId, + nativeMainHome, accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, credentialIdentity: `main:${token.chatgptAccountId}`, @@ -725,6 +736,7 @@ async function fetchAccountModels( Accept: "application/json", }); if (credential.chatgptAccountId) headers.set("ChatGPT-Account-Id", credential.chatgptAccountId); + if (!nativeMainCredentialAllowed(credential)) throw new Error("Main Codex account unavailable"); const response = await fetcher(codexModelsUrl(clientVersion), { headers, redirect: "error", @@ -760,6 +772,7 @@ async function fetchAccountModels( if (!usable) { return unconfirmedAccountModels(credential, clientVersion, now, { kind: "parsed-empty" }); } + if (!nativeMainCredentialAllowed(credential)) throw new Error("Main Codex account unavailable"); recordDiscoveredNativeModels(parsed.discoveredRows, clientVersion, now); const hasUnknownGatedAbsence = [...ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS] // Reachable only through tier 1, an inbound client_version below the floor. Every other @@ -878,6 +891,7 @@ async function fetchDiscoveryRoster( const now = options.now ?? Date.now(); const cached = discoveryEtags.get(credential.credentialIdentity); if (cached && now - cached.fetchedAt < DISCOVERY_ETAG_MAX_AGE_MS) headers.set("If-None-Match", cached.etag); + if (!nativeMainCredentialAllowed(credential)) return "unavailable"; const response = await fetcher(codexModelsUrl(CODEX_ROSTER_DISCOVERY_CLIENT_VERSION), { headers, redirect: "error", @@ -893,7 +907,7 @@ async function fetchDiscoveryRoster( if (!body.displaySafe || body.truncated) return "unavailable"; const parsed = parseAccountModels(body.text); if (parsed === null || parsed.models.size === 0) return "unavailable"; - if (controller.signal.aborted || options.isCurrent?.() === false) return "unavailable"; + if (controller.signal.aborted || options.isCurrent?.() === false || !nativeMainCredentialAllowed(credential)) return "unavailable"; recordDiscoveredNativeModels(parsed.discoveredRows, CODEX_ROSTER_DISCOVERY_CLIENT_VERSION, now); const nextEtag = response.headers.get("etag"); if (nextEtag && nextEtag.length <= 256) discoveryEtags.set(credential.credentialIdentity, { etag: nextEtag, fetchedAt: now }); diff --git a/src/codex/native-main-refusals.ts b/src/codex/native-main-refusals.ts index 00e5e744ce7..b1228a69e04 100644 --- a/src/codex/native-main-refusals.ts +++ b/src/codex/native-main-refusals.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { resolveCodexHomeDir } from "./home"; +import type { CodexAuthContext } from "./auth-context"; const MAX_HOMES = 8; const MAX_FINGERPRINTS = 4; @@ -71,17 +72,6 @@ export class NativeMainRefusals { && !state.fingerprints.has(proof.fingerprint); } - /** Only a successful upstream exchange with this exact pre-dispatch candidate may recover. */ - authenticated(proof: NativeMainAuthentication): boolean { - if (!this.current(proof)) return false; - const state = this.homes.get(proof.home)!; - state.observed = proof.fingerprint; - state.refused = false; - state.unknown = false; - state.fingerprints.clear(); - return true; - } - /** Recovery authority: a committed, identity-checked native-main device login. */ verifiedLogin(home: string): void { const state = this.state(home); @@ -108,6 +98,19 @@ export function nativeMainAuthenticationIsCurrent(proof: NativeMainAuthenticatio return proof.home === resolveCodexHomeDir() && nativeMainRefusals.current(proof); } -export function confirmNativeMainAuthentication(proof: NativeMainAuthentication): boolean { - return nativeMainAuthenticationIsCurrent(proof) && nativeMainRefusals.authenticated(proof); +/** Keep the captured home and inspect the actual bearer after pacing, on every attempt. */ +export function guardNativeMainDispatch( + ctx: CodexAuthContext, + guard: ((headers: Headers) => void) | undefined, + refused: () => Error, +): ((headers: Headers) => void) | undefined { + if (ctx.kind === "pool") return guard; + const home = resolveCodexHomeDir(); + return headers => { + if (ctx.kind === "main-pool" || ctx.mainQuotaDispatch) { + const bearer = headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1]; + if (home !== resolveCodexHomeDir() || !bearer || !nativeMainBearerAllowed(bearer, home)) throw refused(); + } + guard?.(headers); + }; } diff --git a/src/codex/quota-auto-refresh.ts b/src/codex/quota-auto-refresh.ts index 4952c632312..ec768fc8ff5 100644 --- a/src/codex/quota-auto-refresh.ts +++ b/src/codex/quota-auto-refresh.ts @@ -15,7 +15,7 @@ import { isMainAccountHardLocked } from "./main-account-hard-lock"; import { tryAcquireNativeMainProfileClaim } from "./native-main-admission"; import { withNativeMainSharedClaim } from "./native-main-claim"; import { resolveNativeProfileContext } from "./native-profile-store"; -import { confirmMainQuotaAuthentication, getMainQuotaCredentialGeneration, observeMainQuotaCredential } from "./main-account-cache"; +import { getMainQuotaCredentialGeneration, observeMainQuotaCredential } from "./main-account-cache"; import { applyAccountQuotaFromUpstreamHeaders, getAccountQuota, type StoredAccountQuota } from "./quota"; import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./warmup"; import { @@ -261,9 +261,10 @@ async function warmAccount(config: OcxConfig, accountId: string): Promise { + await warmCodexAccount({ ...token, beforeDispatch: () => { + if (!credentialStillLive()) throw new Error("main account unavailable"); + }, onCompleted: headers => { if (writer && credentialStillLive()) { - confirmMainQuotaAuthentication(writer); applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration, writer); } } }); diff --git a/src/codex/warmup.ts b/src/codex/warmup.ts index a8ae8c7af5f..05f239c534d 100644 --- a/src/codex/warmup.ts +++ b/src/codex/warmup.ts @@ -22,6 +22,8 @@ export interface CodexWarmupOptions { chatgptAccountId: string; model?: string; timeoutMs?: number; + /** Recheck an owned credential before every physical send, including model fallback. */ + beforeDispatch?: () => void; /** Publish quota headers only after a completed inference, never on a failed stream. */ onCompleted?: (headers: Headers) => void; } @@ -247,6 +249,7 @@ async function tryWarmup(options: CodexWarmupOptions, model: string): Promise { + if (mainHome !== resolveCodexHomeDir() || getMainAccountToken()?.accessToken !== mainToken.accessToken) throw new Error("main account unavailable"); + }, }); backoff.delete(key); result.warmed.push(key); diff --git a/src/server/audio-upstream.ts b/src/server/audio-upstream.ts index a781ecda553..076dbee2649 100644 --- a/src/server/audio-upstream.ts +++ b/src/server/audio-upstream.ts @@ -29,7 +29,6 @@ import { codexAccountSelectionForTurn } from "./lifecycle"; import type { RequestLogContext } from "./request-log"; import { authorizationCredential } from "../lib/authorization-scheme"; import { nativeMainRefreshFailureResponse } from "./responses/codex-auth-error"; -import { confirmMainQuotaAuthentication } from "../codex/main-account-cache"; export const TRANSCRIPTION_MODEL = "gpt-4o-transcribe"; export const LIVE_AUDIO_MODEL = "gpt-live-1-codex"; @@ -152,11 +151,7 @@ export async function resolveAudioUpstream( headers: Object.fromEntries(selected), keyed: false, authContext: context, - recordOutcome: status => { - const writer = context?.kind === "main-pool" ? context.mainQuotaWriter : context?.kind === "main" ? context.mainQuotaDispatch?.writer : undefined; - if (typeof status === "number" && status >= 200 && status < 300 && writer) confirmMainQuotaAuthentication(writer); - recordOutcome?.(status); - }, + recordOutcome, release: () => releaseCodexAuthContextProbeLease(context), }; } diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 24c4d9516c3..49c0db6e688 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -4,7 +4,6 @@ import { createPhysicalSendReporter, rebindPhysicalSend } from "../../lib/reques import { createInferenceSendBudget } from "../inference/context"; import { unboundPoolSpendRefusalResponse } from "../workflow-refusal"; import { capturePoolQuotaWriter } from "../../codex/account-store"; -import { confirmMainQuotaAuthentication } from "../../codex/main-account-cache"; import { join } from "node:path"; import { rmSync } from "node:fs"; import { previewXaiOauthWireModel } from "./core-normalize"; @@ -1602,8 +1601,6 @@ export async function handleResponsesCompact( } } // Capture the final serving account as well as an earlier rejected account, once per response. - const mainWriter = outcomeCtx.kind === "main-pool" ? outcomeCtx.mainQuotaWriter : outcomeCtx.kind === "main" ? outcomeCtx.mainQuotaDispatch?.writer : undefined; - if (upstream.ok && mainWriter) confirmMainQuotaAuthentication(mainWriter); if (outcomeCtx.kind === "pool") { const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/quota"); applyAccountQuotaFromUpstreamHeaders(outcomeCtx.accountId, upstream.headers, outcomeCtx.writerGeneration, diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index f604d05573c..60d07e53ff2 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -33,7 +33,7 @@ import { isFixedCodexAccount, shouldDeferCodexResetDerivedCooldown, } from "./core-codex-account"; -import { confirmMainQuotaAuthentication, isMainQuotaDispatchLive, isMainQuotaDispatchWsClaimed } from "../../codex/main-account-cache"; +import { isMainQuotaDispatchLive, isMainQuotaDispatchWsClaimed } from "../../codex/main-account-cache"; import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/account-id"; import type { ResponsesTerminalStatus } from "../../bridge"; import { isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse, isCodexWsPreludeProjection } from "./ws-upstream"; @@ -510,9 +510,6 @@ export async function deliverPassthroughResponse( } : undefined; const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream; - const mainWriter = admissionState.authCtx.kind === "main-pool" ? admissionState.authCtx.mainQuotaWriter : arrivalMainDispatch?.writer; - if (upstreamResponse.ok && mainWriter && !isCodexWsUpstreamResponse(upstreamResponse) - && !isCodexWsPreludeProjection(upstreamResponse)) confirmMainQuotaAuthentication(mainWriter); // Capture quota from upstream response for multi-account tracking if (usesCodexForwardPoolAuth(admissionState.authCtx, route.provider)) { // primary was the 5h window; it now carries weekly data for GPT plans. diff --git a/structure/catalog.md b/structure/catalog.md index e0faab25764..0e0f3c2558d 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -147,7 +147,7 @@ the bare native or API-key model list. This keeps account-scoped upstream ids su Configured natives widen the bare list without a release: eligible bare `gpt-*` ids under `providers.openai.models` on the canonical Codex forward provider join the shared registry in place (`src/codex/catalog/native-models.ts`). Each borrows the pinned `gpt-6.1-sol` row under its generated name, takes `NATIVE_GPT6_CONTEXT` (272,000 / 872,000), and is ungated. Built-in, retired and reserve ids never register. `src/config/derived-registries.ts` filters the config and refreshes registration on load, persist and reconcile; removing a configured id unregisters it unless it is also discovered. Configured natives have no entitlement routing preference or main-drain sentinel and cannot be combo `nativeAlias` targets because schema validation precedes registration. Covered by `tests/codex-integration/configured-native-models.test.ts`. -Authenticated discovery also widens the set: after a successful nonempty roster fetch, `src/codex/model-entitlements.ts` passes full eligible rows to `src/codex/catalog/discovered-natives.ts`. Rows require a visible, API-supported bare `gpt-*` slug, display name and sane reasoning-level array; built-in, retired, reserve, malformed, oversized and non-scalar Unicode rows are rejected. Existing discoveries and configured natives remain eligible for refresh, so an already-supported slug does not freeze its metadata or keep borrowing a template. +Authenticated discovery also widens the set: after a successful nonempty roster fetch, `src/codex/model-entitlements.ts` passes full eligible rows to `src/codex/catalog/discovered-natives.ts`. Native-main entitlement and discovery requests check the central refusal guard before dispatch and before publishing rows; owned credential snapshots retain their Codex home so delayed work cannot move across homes. Rows require a visible, API-supported bare `gpt-*` slug, display name and sane reasoning-level array; built-in, retired, reserve, malformed, oversized and non-scalar Unicode rows are rejected. Existing discoveries and configured natives remain eligible for refresh, so an already-supported slug does not freeze its metadata or keep borrowing a template. The store keeps version-1 `discovered-native-models.json` under the resolved OpenCodex home using the private atomic writer (0600 on POSIX). It merges by slug with first/last observation times and client version, expires entries unseen for 14 days, and bounds rows to 32, each to 256 KiB (a live row carries its instructions twice; GPT-6.1 Sol's was 87 KB), plus a file-size bound. Each record re-reads the file and merges onto it, so another process's discovery survives (the replace is atomic, not locked), and an unchanged row renews its last-seen time on disk at most hourly so request-time roster fetches rarely write. The discovery ETag is sent only within 24 hours of the full fetch that earned it, because a 304 cannot renew retention. Missing/corrupt files read as empty; persistence failures never fail entitlement. Config activation loads the file through derived registries; observations register immediately. A process-local monotonic generation changes for metadata or membership changes, independently of timestamp refreshes. diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index cb11a2005d9..31c2416b841 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -155,7 +155,7 @@ clears its observation and outputs, and excludes that stream from refresh. A fai activation identity check also invalidates the current trial. A verification result from an older binding or generation cannot invalidate a newer trial. Resuming correction requires another matched exhaustion observation and explicit activation; native cache refresh remains unconfirmed. -Native identity verification also binds an opaque reader-local credential generation from a stable file-stat/content snapshot. Replacement, token rotation and A-to-B-to-A restoration invalidate pending identity checks and response correction; they require a fresh observation runtime. Neither credential hashes nor tokens appear in public status. `tests/clients/desktop-compatibility-native-identity.test.ts` exercises delayed verification and build-check races with synthetic auth files. +Native identity verification also binds an opaque reader-local credential generation from a stable file-stat/content snapshot. `src/codex/desktop-compatibility/native-identity.ts` applies the central native-main refusal guard to local identity reads and before WHAM dispatch, then rechecks the captured bearer fingerprint and home generation before returning verified identity. Known, unknown and saturated refusal block dispatch; refusal during the request blocks publication. Replacement, token rotation and A-to-B-to-A restoration invalidate pending identity checks and response correction; they require a fresh observation runtime. Neither credential hashes nor tokens appear in public status. `tests/clients/desktop-compatibility-native-identity.test.ts` exercises refusal, delayed verification and build-check races with synthetic auth files. Fresh identity checks, generation changes, unknown schemas and elapsed deadlines refuse correction. `usage-sse-controller.ts` preserves event metadata and original sequence IDs; records over its default 256 KiB cap pass through unchanged until the record delimiter, diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 1e788b10df9..10133c8efc3 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -310,7 +310,7 @@ OpenCodex uses the returned bearer for that request, writes nothing and does not With no file credential, every lookup reads the current login with `refreshToken: false`; an expiring or explicitly rejected bearer requests refresh. Only home-scoped expiry/refusal metadata survives lookup. `src/codex/native-main-refusals.ts` retains domain-separated SHA-256 fingerprints, never bearers: at most eight homes, each with its last observed RPC fingerprint and four unresolved refused fingerprints. Uncertain reads and home switches preserve evidence. Fingerprint overflow becomes refused/unknown. Home eviction prefers non-refused records; evicting a refusal sets a process-local saturation latch, making every untracked home refused/unknown. Tracked homes retain their evidence; untracked homes require a committed verified login or process restart. This trades availability after saturation for bounded memory without admitting evicted refused bearers. -A `chatgpt` method with a null token rejects the file grant or keyring observation, including the last observed RPC fingerprint when no rejected or locked bearer exists. The shared `nativeMainBearerAllowed` guard fences resolution, status/raw/liveness reads, direct file readers in `src/codex/auth-collision.ts`, account probes, quota priming, reset-credit inspection/consumption, audio and idle recovery. Reads never clear or shrink refusals: a different live bearer is only a candidate, and decoded file identities must agree. Known refusal clears only after upstream 2xx evidence for that exact candidate and home generation, or committed verified login; quota/account-info publication and reset consumption recheck admission. Refusal, home/identity changes and credential ABA invalidate delayed evidence. Unknown or saturated refusal requires the existing same-identity device-login `beginNativeMainReauth().commit` under native claim and snapshot guards. A weak association gives only the exact immutable RPC result its quota writer, home and credential generation. Responses and compact 401 retries transfer `mainAccountTokenQuotaWriter(refreshed)` before copying fields; results without owned RPC provenance retain the prior fence. +A `chatgpt` method with a null token rejects the file grant or keyring observation, including the last observed RPC fingerprint when no rejected or locked bearer exists. The shared `nativeMainBearerAllowed` guard fences resolution, status/raw/liveness reads, direct file readers in `src/codex/auth-collision.ts`, account probes, quota priming, reset-credit inspection/consumption, audio and idle recovery. Reads never clear or shrink refusals: a different live bearer is only a candidate, and decoded file identities must agree. Refusal history clears only on committed verified login, through the existing same-identity device-login `beginNativeMainReauth().commit` under native claim and snapshot guards; inference, gateway, audio, warmup, WHAM and reset-credit success never clear it. Quota/account-info publication and reset consumption recheck admission. `src/codex/native-main-refusals.ts` wraps the physical-send guard for owned main credentials with a captured-home and outgoing-bearer check after pacing and on retries, including stored Direct; native-main background warmup rechecks before every model fallback send. Desktop compatibility checks refusal before WHAM and rechecks its fingerprint/home generation before publishing identity. Refusal, home/identity changes and credential ABA invalidate delayed evidence. Unknown or saturated refusal requires verified login. A weak association gives only the exact immutable RPC result its quota writer, home and credential generation. Responses and compact 401 retries transfer `mainAccountTokenQuotaWriter(refreshed)` before copying fields; results without owned RPC provenance retain the prior fence. Without prior permanent refusal, an unreachable binary, unchanged rejected bearer or expired bearer is transient. A non-ChatGPT or missing login answers an ordinary lookup as an observed absence, while a rejected-token or file-locked refresh still reports it transiently. Read-only lookups honor caller cancellation. Actual refresh runs on its independent 30-second timeout so Codex can save its grant. Both paths pin CODEX_HOME and hold its exclusive claim; home and identity-generation changes reject a delayed observation. Only file-backed refresh has a local grant fingerprint lock; keyring claim deadlines are transient. `"opencodex"`, missing or old Codex keeps the guarded writer. diff --git a/tests/clients/desktop-compatibility-native-identity.test.ts b/tests/clients/desktop-compatibility-native-identity.test.ts index 82769518bfc..ce52c3bd1d6 100644 --- a/tests/clients/desktop-compatibility-native-identity.test.ts +++ b/tests/clients/desktop-compatibility-native-identity.test.ts @@ -1,12 +1,13 @@ import { afterEach, expect, test } from "bun:test"; import { mkdtempSync, rmdirSync, unlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { nativeMainRefusals } from "../../src/codex/native-main-refusals"; import { createNativeIdentityReader } from "../../src/codex/desktop-compatibility/native-identity"; import { UsageRelayController } from "../../src/codex/desktop-compatibility/usage-controller"; const roots: string[] = []; -afterEach(() => { for (const root of roots.splice(0)) { unlinkSync(join(root, "auth.json")); rmdirSync(root); } }); +afterEach(() => { for (const root of roots.splice(0)) { nativeMainRefusals.verifiedLogin(root); unlinkSync(join(root, "auth.json")); rmdirSync(root); } }); function fixture() { const root = mkdtempSync(join(tmpdir(), "desktop-identity-")); roots.push(root); const path = join(root, "auth.json"); let revision = 0; @@ -43,6 +44,29 @@ function upstreamFixture(beforeResponse?: () => Promise) { const exchange = { method: "GET", pathname: "/backend-api/wham/usage", status: 200 }; const consent = { scope: "account-ui-compatibility" as const, accountWideConsent: true }; +for (const refusal of ["known", "unknown", "saturated"] as const) test(`native identity refuses ${refusal} main bearer before dispatch`, async () => { + const io = fixture(), upstream = upstreamFixture(), reader = createNativeIdentityReader(io.path, upstream.fetch); + const home = dirname(io.path); + if (refusal === "known") nativeMainRefusals.refuse(home, "synthetic-access-A"); + else if (refusal === "unknown") nativeMainRefusals.refuse(home); + else for (let i = 0; i < 5; i++) nativeMainRefusals.refuse(home, `synthetic-refused-${i}`); + expect(await reader.readCurrentIdentity()).toBeNull(); + expect(await reader.verifyFreshIdentity()).toBeNull(); + upstream.verify([]); +}); + +test("native identity cannot publish after refusal during its in-flight check", async () => { + const io = fixture(); let entered!: () => void, release!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const blocked = new Promise(resolve => { release = resolve; }); + const upstream = upstreamFixture(async () => { entered(); await blocked; }); + const reader = createNativeIdentityReader(io.path, upstream.fetch), pending = reader.verifyFreshIdentity(); + await started; nativeMainRefusals.refuse(dirname(io.path), "synthetic-access-A"); release(); + expect(await pending).toBeNull(); + expect(await reader.readCurrentIdentity()).toBeNull(); + upstream.verify(["synthetic-access-A"]); +}); + test("unchanged native credentials keep one opaque generation and verify without exposing tokens", async () => { const io = fixture(), upstream = upstreamFixture(), reader = createNativeIdentityReader(io.path, upstream.fetch); const first = await reader.readCurrentIdentity(); diff --git a/tests/codex-integration/codex-credits-probes.test.ts b/tests/codex-integration/codex-credits-probes.test.ts index 7690c352299..8b17124fb7f 100644 --- a/tests/codex-integration/codex-credits-probes.test.ts +++ b/tests/codex-integration/codex-credits-probes.test.ts @@ -23,6 +23,8 @@ import { inspectResetCredits, consumeResetCredits } from "../../src/codex/auth-a import { clearCodexQuotaPrimeState, primeCodexPoolQuotas } from "../../src/codex/auth-api/pool-mode-gate"; import { getAccountQuota } from "../../src/codex/quota"; import { getMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { discoverCodexNativeRoster, resetCodexModelEntitlementCacheForTests, resolveCodexModelEntitlements } from "../../src/codex/model-entitlements"; +import { discoveredNativeModelsGeneration, resetDiscoveredNativeModelsForTests } from "../../src/codex/catalog/discovered-natives"; let testDir: string; let previousHome: string | undefined; @@ -94,6 +96,33 @@ async function refuseFileless(bearer: string, kind: "known" | "unknown" | "satur } describe("native-main refusal at every metadata dispatch", () => { + for (const lane of ["entitlement", "discovery"] as const) test.each(["before-send", "in-flight", "home-changed"] as const)( + `${lane} roster rechecks native-main admission (%s)`, async timing => { + resetCodexModelEntitlementCacheForTests(); + resetDiscoveredNativeModelsForTests(); const generation = discoveredNativeModelsGeneration(); + const home = process.env.CODEX_HOME!, bearer = fakeChatGptJwt("fixture-main-identity"); + writeMain(bearer); + const credentials = [{ accountId: "__main__", accessToken: bearer, chatgptAccountId: "fixture-main-identity", + credentialIdentity: "main:fixture-main-identity", nativeMainHome: timing === "home-changed" ? join(home, "previous-home") : home }]; + if (timing === "before-send") nativeMainRefusals.refuse(home, bearer); + let sends = 0; + const fetcher = (async () => { + sends++; nativeMainRefusals.refuse(home, bearer); + return Response.json({ models: [{ slug: "gpt-9-test", display_name: "Fixture model", supported_in_api: true, visibility: "list", + context_window: 123000, max_context_window: 456000, supported_reasoning_levels: [{ effort: "low", description: "Quick" }], + default_reasoning_level: "low", model_messages: { instructions_template: "Synthetic instructions" } }] }); + }) as typeof fetch; + if (lane === "discovery") expect(await discoverCodexNativeRoster(config(), { credentials, fetcher })).toBe("unavailable"); + else { + const snapshot = await resolveCodexModelEntitlements(config(), { credentials, fetcher, clientVersion: "99.0.0" }); + expect(snapshot.modelsByAccount.get("__main__")?.has("gpt-9-test")).not.toBe(true); + } + expect(sends).toBe(timing === "in-flight" ? 1 : 0); + expect(discoveredNativeModelsGeneration()).toBe(generation); + resetCodexModelEntitlementCacheForTests(); + resetDiscoveredNativeModelsForTests(); + }, + ); for (const history of ["known", "unknown", "saturated", "plain"] as const) { test.each(["probe", "list", "inspect", "consume", "prime"] as const)(`${history} history gates %s`, async path => { const bearer = fakeChatGptJwt("fixture-main-identity"); @@ -123,7 +152,7 @@ describe("native-main refusal at every metadata dispatch", () => { }); } - test.each([200, 401])("successor candidate clears refusal only after upstream %s", async status => { + test.each([200, 401])("successor candidate retains refusal after upstream %s", async status => { const bearer = fakeChatGptJwt("fixture-main-identity", { jti: "refused" }); await refuseFileless(bearer); const successor = fakeChatGptJwt("fixture-main-identity", { jti: "successor" }); @@ -139,14 +168,12 @@ describe("native-main refusal at every metadata dispatch", () => { }) as typeof fetch; await fetchMainAccountInfoSnapshot(true, config()); expect(requests).toBe(1); - expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(status !== 200); - if (status !== 200) { - writeMain(bearer); - expect(getMainAccountToken()).toBeNull(); - } + expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); + writeMain(bearer); + expect(getMainAccountToken()).toBeNull(); }); - test("a refusal during WHAM invalidates publication and authenticated recovery", async () => { + test("a refusal during WHAM invalidates publication", async () => { const bearer = fakeChatGptJwt("fixture-main-identity"); writeMain(bearer); globalThis.fetch = (async () => { @@ -160,7 +187,7 @@ describe("native-main refusal at every metadata dispatch", () => { expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); }); - test.each(["inspect", "consume"] as const)("reset-credit %s authenticates a successor only after upstream success", async path => { + test.each(["inspect", "consume"] as const)("reset-credit %s success retains refusal history", async path => { await refuseFileless(fakeChatGptJwt("fixture-main-identity", { jti: "refused-reset" })); const successor = fakeChatGptJwt("fixture-main-identity", { jti: "successor-reset" }); writeMain(successor); @@ -172,7 +199,7 @@ describe("native-main refusal at every metadata dispatch", () => { const response = path === "inspect" ? await inspectResetCredits(config(), "__main__", new AbortController().signal) : await consumeResetCredits(config(), "__main__", undefined); expect(response.status).toBe(200); - expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(false); + expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); }); test("reset inspection cannot publish after a concurrent refusal", async () => { diff --git a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts index 7af348306f5..ea6e9694467 100644 --- a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts @@ -8,6 +8,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { setMainAccountPlan } from "../../src/codex/main-account"; +import { nativeMainRefusals } from "../../src/codex/native-main-refusals"; import * as mainAccount from "../../src/codex/main-account"; import * as nativeClaim from "../../src/codex/native-main-claim"; import { clearAccountQuota, flushQuotaObservationsForTests, getAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; @@ -117,6 +118,7 @@ beforeEach(() => { }); afterEach(async () => { + nativeMainRefusals.verifiedLogin(home); globalThis.fetch = previousFetch; clearAccountQuota(); await flushQuotaObservationsForTests(); @@ -278,6 +280,18 @@ describe("quota auto-refresh native-main admission", () => { expect(getNativeMainProfileRequestCount()).toBe(0); }); + test("main warmup rechecks refusal before a model fallback send", async () => { + const cfg = config(); cfg.codexMainAccountHardLock = false; + const calls = installFetch(async () => { + nativeMainRefusals.refuse(home, bearer()); + return new Response(null, { status: 400 }); + }); + await runCodexQuotaAutoRefresh(cfg, now, { persistCompleted: recordMarkers }); + expect(calls).toEqual([responsesUrl]); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBeUndefined(); + expect(getNativeMainProfileRequestCount()).toBe(0); + }); + test("policy off preserves main warmup and its existing model fallback", async () => { const cfg = config(); cfg.codexMainAccountHardLock = false; diff --git a/tests/codex-integration/token-guardian.test.ts b/tests/codex-integration/token-guardian.test.ts index 9d97fface44..a5ea7f70a24 100644 --- a/tests/codex-integration/token-guardian.test.ts +++ b/tests/codex-integration/token-guardian.test.ts @@ -7,6 +7,7 @@ import { getConfigPath } from "../../src/config"; import { flushConfigDirHardening } from "../../src/config/paths"; import { markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store"; import { __resetGuardianState, guardianSweep } from "../../src/oauth/token-guardian"; +import { nativeMainRefusals } from "../../src/codex/native-main-refusals"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { acquireNativeMainProfileDrain, @@ -48,6 +49,7 @@ beforeEach(() => { }); afterEach(async () => { + nativeMainRefusals.verifiedLogin(join(tmp, "codex")); // Optional Windows ACL work can outlive credential writes and keep this home open. await flushConfigDirHardening(join(tmp, "ocx")); resetLifecycleDrainStateForTests(); @@ -403,6 +405,22 @@ describe("token guardian", () => { expect(tokenCalls).toBe(3); }); + test("guardian native-main warmup rechecks refusal before model fallback", async () => { + writeFileSync(join(tmp, "codex", "auth.json"), JSON.stringify({ tokens: { access_token: "main-guardian", account_id: "main-chatgpt-id" } })); + writeConfig({ tokenGuardian: { enabled: true, tickSeconds: 60, leadSeconds: 60, codexWarmupEnabled: true }, providers: { openai: { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", refreshPolicy: "proactive", + } } }); + let sends = 0; + globalThis.fetch = (async () => { + sends++; nativeMainRefusals.refuse(join(tmp, "codex"), "main-guardian"); + return new Response(null, { status: 400 }); + }) as typeof fetch; + const result = await guardianSweep(Date.now()); + expect(sends).toBe(1); + expect(result.warmed).toEqual([]); + expect(result.failed).toEqual(["codex:__main__"]); + }); + test("direct mode warms main only and never enumerates the added-account store", async () => { const accountStore = join(tmp, "ocx", "codex-accounts.json"); writeFileSync(accountStore, "invalid-added-store"); diff --git a/tests/responses/responses-native-main-refresh.test.ts b/tests/responses/responses-native-main-refresh.test.ts index a77827b085f..10672aefc95 100644 --- a/tests/responses/responses-native-main-refresh.test.ts +++ b/tests/responses/responses-native-main-refresh.test.ts @@ -30,6 +30,8 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; import { nativeMainRefusals } from "../../src/codex/native-main-refusals"; +import { providerRequestPacingStatus, resetProviderRequestPacingForTest, waitForProviderRequestSlot } from "../../src/providers/request-pacing"; +import * as routedConfig from "../../src/providers/routed-config"; /** Smallest complete Anthropic stream: the combo's second target has to actually answer. */ const ANTHROPIC_SSE = [ @@ -162,8 +164,8 @@ function install401ThenRefreshHarness(): { sends: string[]; refreshes: string[] } describe("native main 401 refresh and replay", () => { - test.each(["/v1/responses", "/v1/responses/compact"] as const)( - "%s authenticates a successor only on its upstream 2xx", async path => { + for (const gateway of [false, true]) test.each(["/v1/responses", "/v1/responses/compact"] as const)( + `%s success retains refusal history (gateway=${gateway})`, async path => { clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); resetMainCodexAccountIdentityTrackingForTests(); fs.rmSync(join(home, "auth.json")); @@ -180,11 +182,18 @@ describe("native main 401 refresh and replay", () => { const url = new URL(input instanceof Request ? input.url : String(input)); if (!url.pathname.endsWith("/responses") && !url.pathname.endsWith("/responses/compact")) return Response.json({}, { status: 503 }); sends++; - expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${successor}`); + expect(url.hostname).toBe(gateway ? "gateway.example.test" : "chatgpt.com"); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${gateway ? "gateway-fixture-key" : successor}`); expect(nativeMainRefusals.has(home)).toBe(true); return Response.json({ id: "resp_authenticated", object: "response", status: "completed", output: [] }); }) as typeof fetch; const cfg = config(); + if (gateway) Object.assign(cfg.providers.openai!, { baseUrl: "https://gateway.example.test/v1", + headers: { Authorization: "Bearer gateway-fixture-key" }, supportsNativeCompactEndpoint: true }); + // Inject an already-resolved gateway row: the preset normally pins openai's URL. + // Selection, transport credentials and both generic success hooks remain real. + const resolved = gateway ? spyOn(routedConfig, "routedProviderConfig").mockImplementation((_name, provider) => provider) : undefined; + try { const req = request(path); const log = { model: "", provider: "" } as RequestLogContext; const response = path === "/v1/responses" ? await handleResponses(req, cfg, log) @@ -192,7 +201,69 @@ describe("native main 401 refresh and replay", () => { const body = await response.text(); expect({ status: response.status, error: response.status === 200 ? undefined : body }).toEqual({ status: 200, error: undefined }); expect(sends).toBe(1); + expect(nativeMainRefusals.has(home)).toBe(true); + expect(nativeMainRefusals.refuses(home, rejected)).toBe(true); + nativeMainRefusals.verifiedLogin(home); expect(nativeMainRefusals.has(home)).toBe(false); + } finally { resolved?.mockRestore(); } + }, + ); + for (const mode of ["pool", "direct"] as const) test.each(["/v1/responses", "/v1/responses/compact"] as const)( + `%s rechecks refusal after pacing (mode=${mode})`, async path => { + const cfg = config(); cfg.providers.openai!.codexAccountMode = mode; + cfg.hostname = "0.0.0.0"; + cfg.apiKeys = [{ id: "queued", name: "queued", key: "ocx_data_queued_fixture", createdAt: "2026-10-27T00:00:00.000Z" }]; + cfg.providers.openai!.requestPacing = { enabled: true, maxConcurrentRequests: 1 }; + const blocker = await waitForProviderRequestSlot("openai", cfg.providers.openai!, "gpt-5.5"); + let sends = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.pathname.endsWith("/responses") || url.pathname.endsWith("/responses/compact")) sends++; + return Response.json({ id: "resp_queued", object: "response", status: "completed", output: [] }); + }) as typeof fetch; + const req = request(path), log = { model: "", provider: "" } as RequestLogContext; + req.headers.set("authorization", "Bearer ocx_data_queued_fixture"); + const admission = resolveResponsesApiAuth(req, cfg)!, turn = tryAdmitTurn()!; + expect(admission.source).toBe("bearer"); expect(turn).not.toBeNull(); + const pending = path.endsWith("compact") ? handleResponsesCompact(req, cfg, log, turn, admission) + : handleResponses(req, cfg, log, { admission, turnAdmissionLease: turn }); + try { + for (let i = 0; i < 1000 && providerRequestPacingStatus("openai", cfg.providers.openai!).queued === 0; i++) await Bun.sleep(1); + expect(providerRequestPacingStatus("openai", cfg.providers.openai!).queued).toBe(1); + nativeMainRefusals.refuse(home, "rejected-access"); + blocker?.release(); + const response = await pending; + expect(sends).toBe(0); + expect(response.status).toBe(401); + expect(await response.text()).toContain(CODEX_MAIN_SIGN_IN_REQUIRED_MESSAGE); + } finally { blocker?.release(); resetProviderRequestPacingForTest(); await pending; turn.release(); } + }, + ); + test.each(["/v1/responses", "/v1/responses/compact"] as const)( + "%s rechecks refusal while a refreshed replay waits for pacing", async path => { + const cfg = config(); cfg.providers.openai!.requestPacing = { enabled: true, maxConcurrentRequests: 1 }; + const harness = install401ThenRefreshHarness(), upstream = globalThis.fetch; + let blocker: Awaited>; + let entered!: () => void; const refreshing = new Promise(resolve => { entered = resolve; }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (new URL(input instanceof Request ? input.url : String(input)).hostname === "auth.openai.com") { + blocker = await waitForProviderRequestSlot("openai", cfg.providers.openai!, "gpt-5.5"); entered(); + } + return upstream(input, init); + }) as typeof fetch; + const req = request(path), log = { model: "", provider: "" } as RequestLogContext; + const pending = path.endsWith("compact") ? handleResponsesCompact(req, cfg, log) : handleResponses(req, cfg, log); + try { + await refreshing; + for (let i = 0; i < 1000 && providerRequestPacingStatus("openai", cfg.providers.openai!).queued === 0; i++) await Bun.sleep(1); + expect(providerRequestPacingStatus("openai", cfg.providers.openai!).queued).toBe(1); + nativeMainRefusals.refuse(home, "refreshed-access"); blocker?.release(); + const response = await pending; + expect(harness.sends).toEqual(["Bearer rejected-access"]); + expect(harness.refreshes).toEqual(["refresh-grant"]); + expect(response.status).toBe(401); + expect(await response.text()).toContain(CODEX_MAIN_SIGN_IN_REQUIRED_MESSAGE); + } finally { blocker?.release(); resetProviderRequestPacingForTest(); await pending; } }, ); test.each(["/v1/responses", "/v1/responses/compact"] as const)( diff --git a/tests/server/audio-transcriptions.test.ts b/tests/server/audio-transcriptions.test.ts index 7acf7b7dbcd..beb316264e9 100644 --- a/tests/server/audio-transcriptions.test.ts +++ b/tests/server/audio-transcriptions.test.ts @@ -334,7 +334,7 @@ describe("standalone transcription API", () => { } }); - test.each([false, true])("stored Direct audio honors refusal until authenticated successor (successor=%s)", async successor => { + test.each([false, true])("stored Direct audio retains refusal history with a successor (successor=%s)", async successor => { nativeMainRefresh.setNativeMainRefreshResolutionForTests({ mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fake/codex", version: "0.162.1" }); const bearer = fakeChatGptJwt({ chatgpt_account_id: "fixture-refused-audio" }); try { @@ -355,7 +355,7 @@ describe("standalone transcription API", () => { await response.text(); expect(response.status).toBe(successor ? 200 : 401); expect(captured).toHaveLength(successor ? 1 : 0); - expect(nativeMainRefusals.has(codex.path)).toBe(!successor); + expect(nativeMainRefusals.has(codex.path)).toBe(true); } finally { nativeMainRefresh.setNativeMainRefreshResolutionForTests(null); } From 5da42d613a191c5a7be827ade52e2d56bcaed5ec Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:52:45 +0900 Subject: [PATCH 14/15] fix(codex): scope keyring availability to each lookup --- .../docs/ja/reference/configuration/server.md | 2 +- .../docs/ko/reference/configuration/server.md | 2 +- .../docs/reference/configuration/server.md | 2 +- .../docs/ru/reference/configuration/server.md | 2 +- .../zh-cn/reference/configuration/server.md | 2 +- src/codex/auth-api/main-account-probe.ts | 5 - src/codex/auth-api/reset-credit-service.ts | 11 +- src/codex/auth-collision.ts | 4 +- src/codex/auth-context.ts | 12 +- .../desktop-compatibility/native-identity.ts | 9 +- src/codex/main-account-cache.ts | 18 +- src/codex/main-account.ts | 70 ++--- src/codex/model-entitlements.ts | 16 +- src/codex/native-main-refusals.ts | 116 --------- src/codex/quota-auto-refresh.ts | 4 +- src/codex/warmup.ts | 3 - src/oauth/token-guardian.ts | 5 - structure/catalog.md | 2 +- structure/clients/codex-desktop.md | 2 +- structure/providers/openai-accounts.md | 6 +- ...ktop-compatibility-native-identity.test.ts | 28 +- .../codex-credits-probes.test.ts | 140 ---------- ...-quota-auto-refresh-main-admission.test.ts | 14 - .../native-main-refresh-mode.test.ts | 245 ++---------------- .../codex-integration/token-guardian.test.ts | 18 -- .../responses-native-main-refresh.test.ts | 106 -------- tests/server/audio-transcriptions.test.ts | 30 --- 27 files changed, 74 insertions(+), 800 deletions(-) delete mode 100644 src/codex/native-main-refusals.ts diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index 26f2e1637c2..cb3c56adb68 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -26,7 +26,7 @@ description: リスナー、リモート アクセス、アドミッション | `serviceHealthRecovery.enabled?` | `boolean` | `false` | バックグラウンドサービス向けのオプトイン復旧。小さな監視プロセスが `/healthz` を確認し、プロキシが生きたまま連続プローブに約 1 分間まったく応答しない場合に終了させ、サービスマネージャー(タスク スケジューラ、launchd、systemd、WinSW)に再起動させます。管理対象サービスの子プロセスでのみ起動し、停止したプロセスが保持していたリクエストは再送しません。1 時間あたり最大 3 回。再起動が必要です。自動復旧には数値のバインドアドレス(または既定の localhost/ワイルドカード マッピング)が必要です。DNS バインド名は監視されません。記録されたアドレスまたはポートが変更されると監視が停止します。 | | `codexAutoStart?` | `boolean` | `true` | Codex を起動する前に、Codex シムで `ocx ensure` を実行させます。 False を指定すると、操作が行われないことが保証されます。 | | `codexShimAutoRestore?` | `boolean` | `true` |完了した外部 Codex アップデートによってインストールされたシムが置き換えられた後、インストールされているシムを復元します。環境オプトアウト: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | -| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | `CODEX_HOME` 内のネイティブ メイン ChatGPT ログインを更新する主体を選択します。自動選択を含む動作の詳細は[英語版 Server fields](/reference/configuration/server/#server-fields)を参照してください。 | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | `CODEX_HOME` 内のネイティブ メイン ChatGPT ログインを更新する主体を選択します。キーリングの応答はその照会だけに適用されます。トークンがなければその認証情報の準備は失敗しますが、次の照会が同じトークンを返しても利用できます。永続的な拒否状態は保持せず、準備済みのリクエストは完了できます。ファイルベースの動作は変わりません。自動選択を含む動作の詳細は[英語版 Server fields](/reference/configuration/server/#server-fields)を参照してください。 | | `syncResumeHistory?` | `boolean` | `true` | Codex App 履歴の互換性を元に戻すことができます。元のメタデータは `ocx stop` / `ocx restore` によってバックアップおよび復元されます。 | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` |オフ |認識された Codex ヘルパー/シャドウ呼び出しを、リクエストに設定された推論エフォートを維持したまま選択したモデルにリダイレクトします。デフォルトのソースプレフィックスは `gpt-6-luna`, `gpt-5.6-luna` です。0.144.x 以前のクライアントでは `gpt-5.4-mini` が使われており、`sourceModels` で復元できます。 | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` |使用可能な場合はオン | Web 検索サイドカー オプション。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 002ab739fb0..4a180b9a63f 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -26,7 +26,7 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, | `serviceHealthRecovery.enabled?` | `boolean` | `false` | 백그라운드 서비스용 선택적 복구입니다. 작은 감시 프로세스가 `/healthz`를 확인하고, 프록시가 살아 있지만 연속 프로브에 약 1분 동안 전혀 응답하지 않으면 종료해 서비스 관리자(작업 스케줄러, launchd, systemd, WinSW)가 다시 시작하게 합니다. 관리되는 서비스 자식 프로세스에서만 시작되며, 멈춘 프로세스가 처리 중이던 요청은 재전송하지 않습니다. 시간당 최대 3회. 재시작이 필요합니다. 자동 복구에는 숫자 바인드 주소(또는 기본 localhost/와일드카드 매핑)가 필요합니다. DNS 바인드 이름은 모니터링하지 않습니다. 기록된 주소 또는 포트가 변경되면 모니터링이 중지됩니다. | | `codexAutoStart?` | `boolean` | `true` | Codex shim이 Codex를 실행하기 전에 `ocx ensure`를 돌리도록 허용합니다. `false`이면 ensure는 아무 작업도 하지 않습니다. | | `codexShimAutoRestore?` | `boolean` | `true` | 완료된 외부 Codex 업데이트가 설치된 shim을 교체한 뒤 복원합니다. 환경 변수로 끌 수 있습니다: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | -| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | `CODEX_HOME`의 네이티브 메인 ChatGPT 로그인을 갱신할 주체를 고릅니다. 자동 선택을 포함한 전체 동작은 [Server fields(영문)](/reference/configuration/server/#server-fields)를 참조하세요. | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | `CODEX_HOME`의 네이티브 메인 ChatGPT 로그인을 갱신할 주체를 고릅니다. 키링 조회 결과는 해당 조회에만 적용됩니다. 토큰이 없으면 해당 자격 증명 준비는 실패하며, 다음 조회가 같은 토큰을 반환해도 사용할 수 있습니다. 영구 거부 상태는 저장하지 않으며 이미 준비된 요청은 완료할 수 있습니다. 파일 기반 동작은 유지됩니다. 자동 선택을 포함한 전체 동작은 [Server fields(영문)](/reference/configuration/server/#server-fields)를 참조하세요. | | `syncResumeHistory?` | `boolean` | `true` | 되돌릴 수 있는 Codex App history 호환성입니다. 원래 메타데이터는 `ocx stop` / `ocx restore`가 백업하고 복원합니다. | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | 인식된 Codex 보조/섀도 호출을 요청에 설정된 reasoning effort를 유지한 채 선택한 모델로 다시 보냅니다. 기본 source prefix는 `gpt-6-luna`, `gpt-5.6-luna`입니다. 0.144.x 이하의 이전 클라이언트는 `gpt-5.4-mini`를 사용했으며 `sourceModels`로 복원할 수 있습니다. | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | 웹 검색 사이드카 옵션입니다. | diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index d7a06816372..2a394d9d697 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -43,7 +43,7 @@ runs helper features around provider requests. | `codexMainAccountHardLock?` | `boolean` | `true` | Block new identity-matched main-account requests at their per-window threshold. Only explicit `false` opts out. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). | | `codexMainAccountHardLockThresholds?` | `{ short?: number; long?: number }` | `{ short: 90, long: 98 }` | Independent 5h and weekly/monthly thresholds. Integers 80–100, with `short <= long`. Invalid persisted fields fall back to defaults; the settings API rejects invalid writes before mutation. | | `codexMainAccountHardLockFallback?` | `{ enabled?: boolean; model?: string; reasoningEffort?: string }` | off | Extended hard-lock settings: while the lock blocks and no Codex account can serve the request, qualifying native-Codex-model requests reroute to `model` with `reasoningEffort` instead of failing. `enabled: false` pauses the reroute and keeps the model. An absent section or model keeps the refusal behavior; a usable Pool account still serves first. `reasoningEffort` must be a declared effort; invalid persisted sections degrade to off and the settings API rejects invalid writes before mutation. See [main-account protection](/reference/cli/providers-accounts/#main-account-quota-protection). | -| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Who refreshes the native main ChatGPT login in `CODEX_HOME`. `"codex"` asks the Codex binary (app-server `getAuthStatus`, Codex 0.130.0 or newer, found through the same runtime resolution as `ocx sync`, including the ChatGPT app bundle) and never writes `auth.json`; this also works when Codex stores its login in the OS keyring. When the file is absent, native Pool and stored Direct requests consult the current Codex login, including after restart; logout and account changes take effect at the next credential lookup. A verified account change clears the previous account's quota restrictions before selection. On the first lookup after restart, a valid saved quota record must identify a different account before old restrictions are removed. The same account keeps its restrictions, and manual pause settings remain unchanged. Account-card quota reads, startup Pool priming and idle main-quota recovery also use the owned keyring login; passive recovery preserves the traffic reauthentication quarantine. `"opencodex"` keeps OpenCodex's guarded `auth.json` writer. Unset picks `"codex"` when a supported binary is found; without one, both unset and `"codex"` fall back to the guarded writer. `ocx doctor` shows the active mode under Native main refresh. | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Who refreshes the native main ChatGPT login in `CODEX_HOME`. `"codex"` asks the Codex binary (app-server `getAuthStatus`, Codex 0.130.0 or newer, found through the same runtime resolution as `ocx sync`, including the ChatGPT app bundle) and never writes `auth.json`; this also works when Codex stores its login in the OS keyring. When the file is absent, native Pool and stored Direct requests consult the current Codex login, including after restart; logout and account changes take effect at the next credential lookup. Each keyring answer is authoritative for that lookup: a missing or withheld token makes that materialization unavailable, and a later answer may use even the same bearer. No permanent keyring refusal is retained; requests already holding a bearer may finish without a send-time recheck. File-backed credential behavior is unchanged. A verified account change clears the previous account's quota restrictions before selection. On the first lookup after restart, a valid saved quota record must identify a different account before old restrictions are removed. The same account keeps its restrictions, and manual pause settings remain unchanged. Account-card quota reads, startup Pool priming and idle main-quota recovery also use the owned keyring login; passive recovery preserves the traffic reauthentication quarantine. `"opencodex"` keeps OpenCodex's guarded `auth.json` writer. Unset picks `"codex"` when a supported binary is found; without one, both unset and `"codex"` fall back to the guarded writer. `ocx doctor` shows the active mode under Native main refresh. | | `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another's request record. Logs carry a hashed account key only. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. | | `spendPoolAliases?` | `Record` | unset | Explicit historical salted pool-alias to canonical provider mappings. Keep this key at the top level, outside `spend`. See [Historical pool continuity](#historical-pool-continuity). | diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index 580ccb7d9c2..27f00766361 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -27,7 +27,7 @@ description: Listener, удалённый доступ, admission key, тайм | `serviceHealthRecovery.enabled?` | `boolean` | `false` | Необязательное восстановление фоновой службы: небольшой процесс-наблюдатель опрашивает `/healthz` и, если прокси жив, но около минуты не отвечает на идущие подряд пробы, завершает его, чтобы менеджер службы (Планировщик заданий, launchd, systemd, WinSW) перезапустил его. Запускается только в дочернем процессе управляемой службы; запросы зависшего процесса не повторяются. Не более трёх восстановлений в час. Требуется перезапуск. Для автоматического восстановления требуется числовой адрес привязки (или сопоставление localhost/подстановочного адреса по умолчанию); DNS-имена привязки не отслеживаются. Изменение записанного адреса или порта останавливает наблюдение. | | `codexAutoStart?` | `boolean` | `true` | Разрешает shim'у Codex запускать `ocx ensure` перед стартом Codex. При false `ensure` становится no-op. | | `codexShimAutoRestore?` | `boolean` | `true` | Восстанавливает установленный shim после завершённого внешнего обновления Codex, которое заменило его. Для отключения через окружение: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. | -| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Определяет, кто обновляет основной нативный вход ChatGPT в `CODEX_HOME`. Полное поведение, включая автоматический выбор, см. в [английской таблице Server fields](/reference/configuration/server/#server-fields). | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | Определяет, кто обновляет основной нативный вход ChatGPT в `CODEX_HOME`. Ответ хранилища ключей действует только для текущего запроса учётных данных: без токена подготовка не выполняется, но следующий ответ может снова разрешить тот же токен. Постоянный отказ не сохраняется; уже подготовленные запросы могут завершиться. Поведение файловых учётных данных не меняется. Полное поведение, включая автоматический выбор, см. в [английской таблице Server fields](/reference/configuration/server/#server-fields). | | `syncResumeHistory?` | `boolean` | `true` | Обратимый режим совместимости истории Codex App. Исходные metadata резервируются и восстанавливаются через `ocx stop` / `ocx restore`. | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Перенаправляет распознанные helper/shadow-call'ы Codex на выбранную модель с сохранением настроенного для запроса reasoning effort. Source-prefix по умолчанию: `gpt-6-luna`, `gpt-5.6-luna`; клиенты до 0.144.x включительно использовали `gpt-5.4-mini`, который можно восстановить через `sourceModels`. | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | Настройки sidecar'а web-search. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index ea69f6a695c..dabdc65c6fd 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -27,7 +27,7 @@ description: 监听、远程访问、准入密钥、超时、存储、侧车、 | `serviceHealthRecovery.enabled?` | `boolean` | `false` | 后台服务的可选恢复:一个小型守护进程探测 `/healthz`,当代理进程仍存活但在连续探测中约一分钟毫无响应时将其终止,由服务管理器(任务计划程序、launchd、systemd、WinSW)重新启动。仅受管服务子进程会启动它;卡住进程持有的请求不会重放。每小时最多 3 次。需要重启。自动恢复需要数字绑定地址(或默认的 localhost/通配符映射);不监控 DNS 绑定名称。记录的地址或端口发生更改时会停止监控。 | | `codexAutoStart?` | `boolean` | `true` | 允许 Codex shim 在启动 Codex 之前运行 `ocx ensure`。设为 false 会让 ensure 变成无操作。 | | `codexShimAutoRestore?` | `boolean` | `true` | 在完成外部 Codex 更新并覆盖安装的 shim 之后恢复该 shim。环境退出开关:`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | -| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | 选择由谁刷新 `CODEX_HOME` 中的原生主 ChatGPT 登录。包括自动选择在内的完整行为见[英文 Server fields](/reference/configuration/server/#server-fields)。 | +| `nativeMainRefresh?` | `"codex" \| "opencodex"` | auto | 选择由谁刷新 `CODEX_HOME` 中的原生主 ChatGPT 登录。密钥环应答只对本次查询有效:缺失或被扣留的令牌使本次凭据准备失败;后续查询即使返回相同令牌也可以使用。不保留永久拒绝状态,已取得令牌的请求可继续完成。文件凭据行为保持不变。包括自动选择在内的完整行为见[英文 Server fields](/reference/configuration/server/#server-fields)。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容性。原始元数据会被备份,并由 `ocx stop` / `ocx restore` 恢复。 | | `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | 将识别出的 Codex 辅助/影子调用重定向到选定模型,并保留为请求配置的推理强度。默认源前缀为 `gpt-6-luna`, `gpt-5.6-luna`;0.144.x 及更早客户端使用 `gpt-5.4-mini`,可通过 `sourceModels` 恢复。 | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | 在可用时启用 | Web 搜索侧车选项。 | diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index 13dbde4d02f..1ed67345060 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -16,7 +16,6 @@ import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../account-runt import { decodeJwtPayload, extractAccountId } from "../../oauth/chatgpt"; import { getMainAccountPlan, getValidMainAccountToken, isMainAccountTokenVerifiablyLive, mainAccountTokenIsCurrent, mainAccountTokenQuotaWriter, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan, type MainAccountToken } from "../main-account"; import { resolveCodexHomeDir } from "../home"; -import { nativeMainAuthenticationIsCurrent, nativeMainRefusals } from "../native-main-refusals"; import { captureConfigGeneration } from "../../lib/state-store-sweeper"; import { captureMainAccountIdentityGeneration, clearMainAccountInfoCache, getMainAccountInfoCache, getMainQuotaCredentialGeneration, isMainAccountIdentityGenerationLive, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential, setMainAccountCredentialPresence, setMainAccountInfoCache } from "../main-account-cache"; import type { MainQuotaWriter, MainAccountInfo } from "../main-account-cache"; @@ -284,8 +283,6 @@ export async function fetchMainAccountInfoWhileOwned( const preserved = getMainAccountInfoCache(); return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false }; } - const authentication = nativeMainRefusals.capture(resolveCodexHomeDir(), tokens.access_token); - if (!authentication) return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false }; const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null); const cached = getMainAccountInfoCache(); if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) { @@ -299,7 +296,6 @@ export async function fetchMainAccountInfoWhileOwned( const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration(); /** A disk replacement may have no second probe to advance the credential generation. */ const credentialIsCurrent = (): boolean => { - if (!nativeMainAuthenticationIsCurrent(authentication)) return false; const current = readCodexTokensResult(undefined, { bounded: true }); if (owned) return current.status === "missing" && mainAccountTokenIsCurrent(owned) && mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration(); @@ -322,7 +318,6 @@ export async function fetchMainAccountInfoWhileOwned( const read = async (): Promise => { try { let dispatchSequence = 0; - if (!nativeMainAuthenticationIsCurrent(authentication)) return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false }; const baseKey = `main:${writerGeneration}:${mainQuotaCredentialGeneration}`; const resetEpoch = postReset ? `:post-reset:${currentQuotaDispatchSequence()}` : ""; const admission = await fetchCodexUsage( diff --git a/src/codex/auth-api/reset-credit-service.ts b/src/codex/auth-api/reset-credit-service.ts index cca9ec2ecbf..cf49da06063 100644 --- a/src/codex/auth-api/reset-credit-service.ts +++ b/src/codex/auth-api/reset-credit-service.ts @@ -5,8 +5,6 @@ import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; import { claimManualResetCooldowns, settleManualResetCooldown } from "../routing"; import type { ManualResetCooldownClaim } from "../routing"; import { readCodexTokens } from "../auth-collision"; -import { nativeMainAuthenticationIsCurrent, nativeMainBearerAllowed, nativeMainRefusals, type NativeMainAuthentication } from "../native-main-refusals"; -import { resolveCodexHomeDir } from "../home"; import { extractAccountId } from "../../oauth/chatgpt"; import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; import { getMainQuotaCredentialGeneration, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential } from "../main-account-cache"; @@ -31,7 +29,6 @@ interface ResetCreditAuth { nativeMainSharedClaimHeld?: true; poolGeneration?: number; mainProof?: MainResetQuotaProof; - authentication?: NativeMainAuthentication; } async function withResetCreditAuth( @@ -60,7 +57,6 @@ async function withResetCreditAuth( ok: true, value: await operation({ isMain: true, - authentication: nativeMainRefusals.capture(resolveCodexHomeDir(), tokens.access_token), ...(writer ? { mainProof: { writer, credentialGeneration: getMainQuotaCredentialGeneration() } } : {}), accessToken: tokens.access_token, chatgptAccountId: tokens.account_id, @@ -210,8 +206,7 @@ function manualResetAuthStillLive(accountId: string, auth: ResetCreditAuth): boo && record?.credential?.chatgptAccountId === auth.chatgptAccountId; } const tokens = readCodexTokens(); - return !!auth.authentication && nativeMainAuthenticationIsCurrent(auth.authentication) - && nativeMainBearerAllowed(auth.accessToken) && !!auth.mainProof && !!tokens + return !!auth.mainProof && !!tokens && tokens.access_token === auth.accessToken && tokens.account_id === auth.chatgptAccountId && isMainQuotaWriterLive(auth.mainProof.writer) && auth.mainProof.credentialGeneration === getMainQuotaCredentialGeneration() @@ -275,7 +270,6 @@ async function refreshAfterManualReset( export async function inspectResetCredits(config: OcxConfig, accountId: string, signal: AbortSignal): Promise { const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { - if (auth.isMain && !manualResetAuthStillLive(accountId, auth)) return jsonResponse({ error: "Main Codex account not logged in" }, 401); const linkedSignal = signalWithTimeout(8000, signal); let detachBodyAbort = () => {}; try { @@ -309,7 +303,6 @@ export async function inspectResetCredits(config: OcxConfig, accountId: string, if (!parsed.ok) { return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502); } - if (auth.isMain && !manualResetAuthStillLive(accountId, auth)) return jsonResponse({ error: "Main Codex credential changed" }, 409); return jsonResponse(safeResetCreditsDto(parsed.value)); } finally { detachBodyAbort(); @@ -321,7 +314,6 @@ export async function inspectResetCredits(config: OcxConfig, accountId: string, export async function consumeResetCredits(config: OcxConfig, accountId: string, requestedOperationId: string | undefined): Promise { const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { - if (auth.isMain && !manualResetAuthStillLive(accountId, auth)) return jsonResponse({ error: "Main Codex account not logged in" }, 401); // The ledger keys manual operations by the *physical* ChatGPT account, which is // only known after the auth wrapper resolves credentials. Open here, not earlier. let identity = requestedOperationId === undefined @@ -370,7 +362,6 @@ export async function consumeResetCredits(config: OcxConfig, accountId: string, try { let resp: Response; try { - if (auth.isMain && !manualResetAuthStillLive(accountId, auth)) return jsonResponse({ error: "Main Codex credential changed" }, 409); resp = await fetch( "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", { diff --git a/src/codex/auth-collision.ts b/src/codex/auth-collision.ts index cf62104820b..20452a1e64a 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -1,6 +1,5 @@ import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { nativeMainBearerAllowed } from "./native-main-refusals"; +import { join } from "node:path"; import { getCodexAccountCredential } from "./account-store"; import { loadConfig } from "../config"; import { resolveCodexHomeDir } from "./home"; @@ -57,7 +56,6 @@ export function readCodexTokensResult( tokens?: { access_token?: string; account_id?: string; id_token?: string }; }; if (!j?.tokens?.access_token) return { status: "invalid" }; - if (!nativeMainBearerAllowed(j.tokens.access_token, dirname(authPath))) return { status: "invalid" }; return { status: "ok", tokens: { diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 391bcd490f6..a7397b0f342 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1,6 +1,5 @@ import { bindPoolCreditPolicy, poolCreditHoldResetAt, poolContextCreditHoldResetAt, poolCreditRecoveryNeeded, poolContextCreditRecoveryNeeded, poolCreditRecoveryRetryAt } from "./pool-credit-policy"; import { hasSpendableCodexCredits } from "./quota-types"; -import { guardNativeMainDispatch, nativeMainBearerAllowed } from "./native-main-refusals"; import { nextQuotaQueryDelay } from "./quota-query-backoff"; import { noteMainAccountActivity } from "./main-account-external-usage"; import { codexAccountPriorityFailbackEnabled } from "./account-priority"; @@ -779,7 +778,7 @@ export function createCodexAuthDispatchGuard( admission?: Pick, terminalHelper = false, ): ((headers: Headers) => void) | undefined { - const reserveGuard = guardNativeMainDispatch(ctx, createCodexReserveDispatchGuard(ctx, config, modelId, admission, terminalHelper), () => new CodexAuthContextError(MAIN_CODEX_ACCOUNT_ID, new MainAccountTokenRefreshError("reauth"))); + const reserveGuard = createCodexReserveDispatchGuard(ctx, config, modelId, admission, terminalHelper); if (ctx.kind !== "pool" && ctx.kind !== "main-pool") return reserveGuard; return headers => { if (config.pausedCodexAccountIds?.includes(ctx.accountId)) throw new CodexPoolAuthenticationError("Selected Codex account is paused; resume it in account settings"); @@ -904,15 +903,16 @@ function observeSelectedMainCredential( token: { accessToken: string; chatgptAccountId: string }, writer: MainQuotaWriter | undefined, ): MainQuotaWriter | undefined { - if (!nativeMainBearerAllowed(token.accessToken)) throw new MainAccountTokenRefreshError("reauth"); - // Only the exact RPC result carries its replacement identity writer. + // A claimed RPC can observe a replacement identity while the caller awaits it. Only that + // exact result carries the replacement writer; stale results keep their stale fence. const ownedWriter = mainAccountTokenQuotaWriter(token); if (ownedWriter) return ownedWriter; if (!writer) return undefined; - // Keep stale writers tagged so they cannot invalidate the replacement's trusted observation. + // Carry an explicitly stale writer through to quota's rejection fence; turning it into an + // untagged write would instead invalidate the replacement account's trusted observation. if (!isMainQuotaWriterLive(writer)) return writer; const observed = observeMainQuotaCredential(token.accessToken, token.chatgptAccountId); - return observed?.identityKey === writer.identityKey ? observed : undefined; + return observed?.identityKey === writer.identityKey ? writer : undefined; } /** diff --git a/src/codex/desktop-compatibility/native-identity.ts b/src/codex/desktop-compatibility/native-identity.ts index 19a6f5d3474..e3f97d59e86 100644 --- a/src/codex/desktop-compatibility/native-identity.ts +++ b/src/codex/desktop-compatibility/native-identity.ts @@ -1,7 +1,5 @@ import { readFileSync, statSync, type BigIntStats } from 'node:fs'; import { createHash, randomUUID } from 'node:crypto'; -import { dirname } from 'node:path'; -import { nativeMainBearerAllowed, nativeMainRefusals } from '../native-main-refusals'; import type { UsageIdentity } from './usage-controller'; type Credential = { identity: UsageIdentity; accessToken: string }; @@ -23,8 +21,7 @@ export function createNativeIdentityReader(authPath: string, upstreamFetch: type const auth = JSON.parse(text); if (auth.auth_mode !== 'chatgpt' || typeof auth.tokens?.id_token !== 'string' || typeof auth.tokens?.access_token !== 'string' || !auth.tokens.access_token - || typeof auth.tokens?.account_id !== 'string' - || !nativeMainBearerAllowed(auth.tokens.access_token, dirname(authPath))) throw new Error('Native login required'); + || typeof auth.tokens?.account_id !== 'string') throw new Error('Native login required'); // These local claims are only a binding hint. Activation additionally verifies upstream. const claims = JSON.parse(Buffer.from(auth.tokens.id_token.split('.')[1], 'base64url').toString('utf8'))['https://api.openai.com/auth']; if (!claims || claims.chatgpt_account_id !== auth.tokens.account_id @@ -39,8 +36,6 @@ export function createNativeIdentityReader(authPath: string, upstreamFetch: type const verifyFreshIdentity = async (): Promise => { try { const before = readCredential(); - const proof = nativeMainRefusals.capture(dirname(authPath), before.accessToken); - if (!proof) return null; const response = await upstreamFetch('https://chatgpt.com/backend-api/wham/usage', { headers: { authorization: 'Bearer ' + before.accessToken, 'ChatGPT-Account-ID': before.identity.id }, redirect: 'manual', signal: AbortSignal.timeout(10000), @@ -57,7 +52,7 @@ export function createNativeIdentityReader(authPath: string, upstreamFetch: type } finally { reader.releaseLock(); } const usage = JSON.parse(Buffer.concat(chunks).toString('utf8')); const after = readCredential(); - if (!nativeMainRefusals.current(proof) || !equal(before.identity, after.identity) || before.accessToken !== after.accessToken + if (!equal(before.identity, after.identity) || before.accessToken !== after.accessToken || usage.account_id !== before.identity.id || usage.user_id !== before.identity.userId || usage.plan_type !== before.identity.plan) return null; return before.identity; diff --git a/src/codex/main-account-cache.ts b/src/codex/main-account-cache.ts index 8f793b56732..ad1d02c0630 100644 --- a/src/codex/main-account-cache.ts +++ b/src/codex/main-account-cache.ts @@ -4,8 +4,6 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypt import { codexCredentialMutationEpoch } from "./credential-mutation-epoch"; import type { StoredAccountQuota } from "./quota-types"; import { truncateRetainedUtf8 } from "../lib/admission"; -import { resolveCodexHomeDir } from "./home"; -import { nativeMainAuthenticationIsCurrent, nativeMainBearerAllowed, nativeMainRefusals, type NativeMainAuthentication } from "./native-main-refusals"; const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024; @@ -24,7 +22,7 @@ let cachedMainCredentialPresence: boolean | null = null; let mainAccountIdentityGeneration = 0; let observedMainQuotaIdentityKey: string | undefined; const mainQuotaCredentialKey = randomBytes(32); -let mainQuotaCredential: { bearerHmac: Buffer; writer: MainQuotaWriter; authentication: NativeMainAuthentication } | undefined; +let mainQuotaCredential: { bearerHmac: Buffer; writer: MainQuotaWriter } | undefined; let mainQuotaCredentialGeneration = 0; let mainDecisionCredentialUsable = true; // Advisory-only generation for the decision row and its writers. It follows every core credential @@ -48,7 +46,6 @@ export function getMainQuotaCredentialGeneration(): number { return mainQuotaCre export type MainQuotaWriter = Readonly<{ identityKey: string; identityGeneration: number }>; const decisionWriterGenerations = new WeakMap(); -const writerAuthentications = new WeakMap(); /** Return the credential generation a main quota writer was captured under, or undefined once that writer is stale or its credential generation has moved. */ export function mainDecisionQuotaWriterGeneration(writer: MainQuotaWriter): number | undefined { const generation = decisionWriterGenerations.get(writer); @@ -79,26 +76,21 @@ export function captureMainQuotaWriter(accountId: string): MainQuotaWriter | und const identityKey = mainQuotaIdentityKey(accountId); if (identityKey !== observedMainQuotaIdentityKey) return undefined; const writer = { identityKey, identityGeneration: mainAccountIdentityGeneration }; - if (mainQuotaCredential) writerAuthentications.set(writer, mainQuotaCredential.authentication); if (mainQuotaCredential) decisionWriterGenerations.set(writer, mainDecisionGeneration); return writer; } /** Credential material must come from an already-owned read, never an incoming request. */ export function observeMainQuotaCredential(accessToken: string, accountId: string): MainQuotaWriter | undefined { - if (!nativeMainBearerAllowed(accessToken)) return undefined; - const authentication = nativeMainRefusals.capture(resolveCodexHomeDir(), accessToken); const writer = captureMainQuotaWriter(accountId); - if (!accessToken || !writer || !authentication) return undefined; - writerAuthentications.set(writer, authentication); + if (!accessToken || !writer) return undefined; const bearerHmac = createHmac("sha256", mainQuotaCredentialKey).update(accessToken).digest(); if (!mainQuotaCredential || !isMainQuotaWriterLive(mainQuotaCredential.writer) || !timingSafeEqual(bearerHmac, mainQuotaCredential.bearerHmac)) { mainQuotaCredentialGeneration += 1; mainDecisionGeneration += 1; } - mainQuotaCredential = { bearerHmac, writer, authentication }; + mainQuotaCredential = { bearerHmac, writer }; mainDecisionCredentialFresh = true; publishMainDecisionRoster(true); const result = { ...writer }; - writerAuthentications.set(result, authentication); decisionWriterGenerations.set(result, mainDecisionGeneration); return result; } @@ -112,10 +104,8 @@ export function matchesMainQuotaCredential(accessToken: string, effectiveAccount } export function isMainQuotaWriterLive(writer: MainQuotaWriter): boolean { - const proof = writerAuthentications.get(writer); return writer.identityKey === observedMainQuotaIdentityKey - && writer.identityGeneration === mainAccountIdentityGeneration - && (!proof || nativeMainAuthenticationIsCurrent(proof)); + && writer.identityGeneration === mainAccountIdentityGeneration; } /** Proof that a dispatch used the observed main credential; process-local, never persisted. */ diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index 273698ce0c8..c30223cec5a 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -11,7 +11,6 @@ import { credsFromToken, decodeJwtPayload, extractAccountId, - extractAccountIdClaims, } from "../oauth/chatgpt"; import type { OAuthCredentials } from "../oauth/types"; import { extractChatgptPlanType } from "./plan"; @@ -35,7 +34,6 @@ import { resolveNativeProfileContext } from "./native-profile-store"; import { NativeProfileError } from "./native-profile-types"; import { isNativeMainTrafficBlocked } from "./native-profile-startup"; import { loadConfig } from "../config"; -import { nativeMainAuthenticationIsCurrent, nativeMainBearerAllowed, nativeMainRefusals as codexOwnedMainRefusals, type NativeMainAuthentication } from "./native-main-refusals"; import { requestCodexAuthStatus, resolveNativeMainRefreshMode, @@ -53,7 +51,7 @@ let mainAccountPlan: string | null = null; let jwtPlanAttempted = false; const MAIN_TOKEN_REFRESH_SKEW_MS = 60_000; let beforeMainAuthJsonRenameForTests: (() => void) | null = null; -let codexOwnedMainCredential: { home: string; expiresAt: number; authentication: NativeMainAuthentication } | null = null; +let codexOwnedMainCredential: { home: string; expiresAt: number } | null = null; export type MainAccountToken = Readonly<{ accessToken: string; chatgptAccountId: string }>; const codexOwnedQuotaWriters = new WeakMap(); @@ -66,7 +64,7 @@ export function mainAccountTokenQuotaWriter(token: MainAccountToken): MainQuotaW export function mainAccountTokenIsCurrent(token: MainAccountToken): boolean { const proof = codexOwnedQuotaWriters.get(token); return proof !== undefined && proof.home === resolveCodexHomeDir() - && codexOwnedMainCredential?.home === proof.home && nativeMainBearerAllowed(token.accessToken, proof.home) + && codexOwnedMainCredential?.home === proof.home && codexOwnedMainCredential.expiresAt > Date.now() && proof.credentialGeneration === getMainQuotaCredentialGeneration() && isMainQuotaWriterLive(proof.writer) @@ -263,27 +261,6 @@ function credentialRefreshGrantRejected(current: MainAuthJsonCredential | null): return !!key && isMainRefreshGrantRejected(key); } -/** Restored file bytes are subject to the same home refusal as an owned RPC. */ -function credentialRefused(current: MainAuthJsonCredential | null, now = Date.now()): boolean { - if (credentialRefreshGrantRejected(current)) return true; - const home = resolveCodexHomeDir(); - if (!codexOwnedMainRefusals.has(home)) return false; - if (!current && codexOwnedMainCredential?.home === home - && nativeMainAuthenticationIsCurrent(codexOwnedMainCredential.authentication)) return false; - const bearer = current?.accessToken; - if (!current || !bearer || !nativeMainBearerAllowed(bearer, home) - || !mainAccessTokenFresh(bearer, now, 0)) return true; - const accessIdentity = extractAccountIdClaims(bearer); - const idIdentity = extractAccountIdClaims(nonEmptyString(current.tokens.id_token)); - if (!accessIdentity.accountId || accessIdentity.conflict || idIdentity.conflict - || accessIdentity.accountId !== current.chatgptAccountId - || (idIdentity.accountId !== undefined && idIdentity.accountId !== accessIdentity.accountId) - || (nonEmptyString(current.tokens.account_id) !== undefined - && current.tokens.account_id !== accessIdentity.accountId)) return true; - // Decoded claims admit a candidate only; upstream authentication or committed login recovers. - return false; -} - export interface MainAccountCredentialStatus { refreshGrantRejected: boolean; hasRefreshGrant: boolean; @@ -294,7 +271,7 @@ export interface MainAccountCredentialStatus { export function getMainAccountCredentialStatus(now = Date.now()): MainAccountCredentialStatus { const current = readMainAuthJsonCredential(); const owned = !current && codexOwnedMainCredential?.home === resolveCodexHomeDir() ? codexOwnedMainCredential : null; - const refreshGrantRejected = credentialRefused(current, now); + const refreshGrantRejected = credentialRefreshGrantRejected(current); const hasRefreshGrant = !!current?.refreshToken && !refreshGrantRejected; const ownedUsable = !!owned && owned.expiresAt > now; const usable = !refreshGrantRejected @@ -492,7 +469,6 @@ export function beginNativeMainReauth(): { if (options.signal?.aborted) throw options.signal.reason; assertMainAuthJsonSnapshotUnchanged(expected); persistNativeMainReauthTokens(expected, tokens); - codexOwnedMainRefusals.verifiedLogin(resolveCodexHomeDir()); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); return { chatgptAccountId: tokens.chatgptAccountId }; }, { waitMs: 30_000, signal: options.signal }); @@ -580,7 +556,7 @@ async function resolveMainAccountToken( if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-credential"); return null; } - if (credentialRefused(initial)) throw new MainAccountTokenRefreshError("reauth"); + if (credentialRefreshGrantRejected(initial)) throw new MainAccountTokenRefreshError("reauth"); const now = Date.now(); if (initial.accessToken !== rejectedAccessToken && mainAccessTokenFresh(initial.accessToken, now, MAIN_TOKEN_REFRESH_SKEW_MS)) { @@ -628,7 +604,7 @@ async function resolveMainAccountToken( if (resolveCodexHomeDir() !== codexHome) throw new MainAuthJsonChangedDuringRefreshError(); const locked = readMainAuthJsonCredential(); if (!locked) throw new MainAuthJsonChangedDuringRefreshError(); - if (credentialRefused(locked)) throw new MainAccountTokenRefreshError("reauth"); + if (credentialRefreshGrantRejected(locked)) throw new MainAccountTokenRefreshError("reauth"); if (!locked.refreshToken || refreshGrantFingerprintForToken(locked.refreshToken) !== lockKey) { if (locked.accessToken !== rejectedAccessToken @@ -748,7 +724,6 @@ async function refreshThroughCodex( forgetCodexOwnedMainUsability(codexHome); let status: CodexAuthStatus; let didRefresh = refresh; - let refreshBearer = rejectedAccessToken ?? locked?.accessToken; try { const readSignal = dependencies.signal ? AbortSignal.any([dependencies.signal, refreshTimeout]) : refreshTimeout; status = await authStatus({ refresh, signal: refresh ? refreshTimeout : readSignal }); @@ -756,7 +731,6 @@ async function refreshThroughCodex( && !mainAccessTokenFresh(status.authToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) { dependencies.signal?.throwIfAborted(); didRefresh = true; - refreshBearer = status.authToken; status = await authStatus({ refresh: true, signal: refreshTimeout }); } } catch (cause) { @@ -768,12 +742,11 @@ async function refreshThroughCodex( throw new MainAuthJsonChangedDuringRefreshError(); } if (status.authMethod === "chatgpt" && !status.authToken) { - // The only permanent-failure signal the response carries: Codex withholds the token once - // its refresh was refused for good. The specific OAuth code is not on the wire. + // A keyring answer is authoritative for this lookup only. Keep the existing file-backed + // grant rejection, but retain no keyring refusal that could override a later RPC answer. noteNativeMainRefresh("codex-reauth"); const grantKey = locked && mainRefreshGrantKey(locked); if (grantKey) markMainRefreshGrantRejected(grantKey); - codexOwnedMainRefusals.refuse(codexHome, refreshBearer); throw new MainAccountTokenRefreshError("reauth", { credentialObservedAbsent: true }); } const token = status.authToken; @@ -786,29 +759,22 @@ async function refreshThroughCodex( if (locked === null && rejectedAccessToken === undefined && !didRefresh) return null; throw new MainAccountTokenRefreshError("transient", { credentialObservedAbsent: true }); } - if (!nativeMainBearerAllowed(token, codexHome)) { - throw new MainAccountTokenRefreshError("reauth"); - } // A transient Codex refresh failure still answers with its cached bearer. if (token === rejectedAccessToken || !mainAccessTokenFresh(token, Date.now(), 0)) { noteNativeMainRefresh("codex-transient"); throw new MainAccountTokenRefreshError("transient"); } + noteNativeMainRefresh("codex-ok"); if (dependencies.signal?.aborted) throw dependencies.signal.reason; const result = Object.freeze({ accessToken: token, chatgptAccountId: extractAccountId(undefined, token) ?? locked?.chatgptAccountId ?? "", }); const writer = observeCodexOwnedMainAccountCredential(token); - if (codexOwnedMainRefusals.has(codexHome) && !writer) throw new MainAccountTokenRefreshError("reauth"); - // Keep only a fingerprint of the last owned bearer, even across uncertain reads. - codexOwnedMainRefusals.observe(codexHome, token); - noteNativeMainRefresh("codex-ok"); if (writer) codexOwnedQuotaWriters.set(result, { writer, home: codexHome, credentialGeneration: getMainQuotaCredentialGeneration() }); if (writer && dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); const exp = decodeJwtPayload(token)?.exp; - const authentication = codexOwnedMainRefusals.capture(codexHome, token)!; - codexOwnedMainCredential = { home: codexHome, expiresAt: typeof exp === "number" ? exp * 1000 : Infinity, authentication }; + codexOwnedMainCredential = { home: codexHome, expiresAt: typeof exp === "number" ? exp * 1000 : Infinity }; return result; } @@ -846,9 +812,9 @@ export function getMainAccountPlan(): string | undefined { /** Read-only main account token from ~/.codex/auth.json, or null when not logged in. */ export function getMainAccountToken(): { accessToken: string; chatgptAccountId: string } | null { - const current = readMainAuthJsonCredential(); - if (!current?.accessToken || credentialRefused(current)) return null; - return { accessToken: current.accessToken, chatgptAccountId: nonEmptyString(current.tokens.account_id) ?? "" }; + const tokens = readCodexTokens(); + if (!tokens?.access_token) return null; + return { accessToken: tokens.access_token, chatgptAccountId: tokens.account_id }; } /** @@ -857,9 +823,9 @@ export function getMainAccountToken(): { accessToken: string; chatgptAccountId: * actually-invalid token then surfaces via the upstream 401 → cooldown path. */ export function isMainAccountTokenLive(now = Date.now()): boolean { - const current = readMainAuthJsonCredential(); - if (!current?.accessToken || credentialRefused(current, now)) return false; - const payload = decodeJwtPayload(current.accessToken); + const tokens = readCodexTokens(); + if (!tokens?.access_token) return false; + const payload = decodeJwtPayload(tokens.access_token); const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : undefined; return exp === undefined || exp > now; } @@ -874,9 +840,9 @@ export function isMainAccountTokenLive(now = Date.now()): boolean { * 401 "transient" and needsReauth could never flip. */ export function isMainAccountTokenVerifiablyLive(now = Date.now()): boolean { - const current = readMainAuthJsonCredential(); - if (!current?.accessToken || credentialRefused(current, now)) return false; - const payload = decodeJwtPayload(current.accessToken); + const tokens = readCodexTokens(); + if (!tokens?.access_token) return false; + const payload = decodeJwtPayload(tokens.access_token); const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : undefined; return exp !== undefined && exp > now; } diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index c6c264f3fdb..4a93a23206c 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -1,6 +1,4 @@ import { createHash } from "node:crypto"; -import { nativeMainBearerAllowed } from "./native-main-refusals"; -import { resolveCodexHomeDir } from "./home"; import { readBoundedResponseBody } from "../lib/bounded-body"; import type { CodexAccountCredentialRecord, OcxConfig } from "../types"; import { isSelectableCodexPoolAccount } from "./account-id"; @@ -379,13 +377,6 @@ export interface CodexModelEntitlementCredentialSnapshot { readonly chatgptAccountId: string; /** Stable local identity for rejecting a catalog commit after credential replacement. */ readonly credentialIdentity: string; - readonly nativeMainHome?: string; -} - -function nativeMainCredentialAllowed(credential: CodexModelEntitlementCredentialSnapshot): boolean { - const home = credential.nativeMainHome ?? resolveCodexHomeDir(); - return credential.accountId !== MAIN_CODEX_ACCOUNT_ID - || (home === resolveCodexHomeDir() && nativeMainBearerAllowed(credential.accessToken, home)); } export type CodexModelEntitlementProvenance = @@ -617,7 +608,6 @@ async function accountCredentialSnapshot( ): Promise { if (accountId === MAIN_CODEX_ACCOUNT_ID) { try { - const nativeMainHome = resolveCodexHomeDir(); const token = await getValidMainAccountToken({ signal: options.signal, ...(options.nativeMainRefreshDependencies ?? {}), @@ -625,7 +615,6 @@ async function accountCredentialSnapshot( return token ? { accountId, - nativeMainHome, accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, credentialIdentity: `main:${token.chatgptAccountId}`, @@ -736,7 +725,6 @@ async function fetchAccountModels( Accept: "application/json", }); if (credential.chatgptAccountId) headers.set("ChatGPT-Account-Id", credential.chatgptAccountId); - if (!nativeMainCredentialAllowed(credential)) throw new Error("Main Codex account unavailable"); const response = await fetcher(codexModelsUrl(clientVersion), { headers, redirect: "error", @@ -772,7 +760,6 @@ async function fetchAccountModels( if (!usable) { return unconfirmedAccountModels(credential, clientVersion, now, { kind: "parsed-empty" }); } - if (!nativeMainCredentialAllowed(credential)) throw new Error("Main Codex account unavailable"); recordDiscoveredNativeModels(parsed.discoveredRows, clientVersion, now); const hasUnknownGatedAbsence = [...ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS] // Reachable only through tier 1, an inbound client_version below the floor. Every other @@ -891,7 +878,6 @@ async function fetchDiscoveryRoster( const now = options.now ?? Date.now(); const cached = discoveryEtags.get(credential.credentialIdentity); if (cached && now - cached.fetchedAt < DISCOVERY_ETAG_MAX_AGE_MS) headers.set("If-None-Match", cached.etag); - if (!nativeMainCredentialAllowed(credential)) return "unavailable"; const response = await fetcher(codexModelsUrl(CODEX_ROSTER_DISCOVERY_CLIENT_VERSION), { headers, redirect: "error", @@ -907,7 +893,7 @@ async function fetchDiscoveryRoster( if (!body.displaySafe || body.truncated) return "unavailable"; const parsed = parseAccountModels(body.text); if (parsed === null || parsed.models.size === 0) return "unavailable"; - if (controller.signal.aborted || options.isCurrent?.() === false || !nativeMainCredentialAllowed(credential)) return "unavailable"; + if (controller.signal.aborted || options.isCurrent?.() === false) return "unavailable"; recordDiscoveredNativeModels(parsed.discoveredRows, CODEX_ROSTER_DISCOVERY_CLIENT_VERSION, now); const nextEtag = response.headers.get("etag"); if (nextEtag && nextEtag.length <= 256) discoveryEtags.set(credential.credentialIdentity, { etag: nextEtag, fetchedAt: now }); diff --git a/src/codex/native-main-refusals.ts b/src/codex/native-main-refusals.ts deleted file mode 100644 index b1228a69e04..00000000000 --- a/src/codex/native-main-refusals.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { createHash } from "node:crypto"; -import { resolveCodexHomeDir } from "./home"; -import type { CodexAuthContext } from "./auth-context"; - -const MAX_HOMES = 8; -const MAX_FINGERPRINTS = 4; -type HomeState = { observed?: string; refused: boolean; unknown: boolean; fingerprints: Set; generation: number }; -export type NativeMainAuthentication = Readonly<{ home: string; fingerprint: string; generation: number }>; - -/** Bounded process-local metadata only. Losing refusal history always strengthens admission. */ -export class NativeMainRefusals { - private readonly homes = new Map(); - private evictedRefusal = false; - private generation = 0; - - private state(home: string): HomeState { - const existing = this.homes.get(home); - if (existing) return existing; - if (this.homes.size === MAX_HOMES) { - // Prefer forgetting harmless observations; never forget a refusal without closing - // admission for every untracked home (a bounded map cannot remember evicted names). - const oldest = [...this.homes].find(([, value]) => !value.refused) ?? this.homes.entries().next().value!; - if (oldest[1].refused) this.evictedRefusal = true; - this.homes.delete(oldest[0]); - } - const state: HomeState = { refused: this.evictedRefusal, unknown: this.evictedRefusal, fingerprints: new Set(), generation: ++this.generation }; - this.homes.set(home, state); - return state; - } - - has(home: string): boolean { - return this.homes.get(home)?.refused ?? this.evictedRefusal; - } - - refuses(home: string, bearer: string): boolean { - const state = this.homes.get(home); - return state ? state.refused && (state.unknown || state.fingerprints.has(fingerprint(bearer))) : this.evictedRefusal; - } - - refuse(home: string, bearer?: string): void { - const state = this.state(home); - state.generation = ++this.generation; - state.refused = true; - const candidates = [state.observed, bearer ? fingerprint(bearer) : undefined]; - for (const candidate of candidates) { - if (!candidate || state.unknown || state.fingerprints.has(candidate)) continue; - if (state.fingerprints.size === MAX_FINGERPRINTS) { - state.unknown = true; - state.fingerprints.clear(); - break; - } - state.fingerprints.add(candidate); - } - if (state.fingerprints.size === 0) state.unknown = true; - } - - /** Read observations remember candidates; they never recover a refused home. */ - observe(home: string, bearer: string): void { - const state = this.state(home); - state.observed = fingerprint(bearer); - } - - capture(home: string, bearer: string): NativeMainAuthentication | undefined { - const state = this.state(home); - if (this.refuses(home, bearer)) return undefined; - return { home, fingerprint: fingerprint(bearer), generation: state.generation }; - } - - current(proof: NativeMainAuthentication): boolean { - const state = this.homes.get(proof.home); - return !!state && state.generation === proof.generation && !state.unknown - && !state.fingerprints.has(proof.fingerprint); - } - - /** Recovery authority: a committed, identity-checked native-main device login. */ - verifiedLogin(home: string): void { - const state = this.state(home); - state.generation = ++this.generation; - state.observed = undefined; - state.refused = false; - state.unknown = false; - state.fingerprints.clear(); - } -} - -function fingerprint(bearer: string): string { - return createHash("sha256").update(`opencodex:native-main:refused-bearer:v1\0${bearer}`).digest("hex"); -} - -export const nativeMainRefusals = new NativeMainRefusals(); - -/** Common admission boundary for every owned main file/keyring bearer reader. */ -export function nativeMainBearerAllowed(bearer: string, home = resolveCodexHomeDir()): boolean { - return !nativeMainRefusals.refuses(home, bearer); -} - -export function nativeMainAuthenticationIsCurrent(proof: NativeMainAuthentication): boolean { - return proof.home === resolveCodexHomeDir() && nativeMainRefusals.current(proof); -} - -/** Keep the captured home and inspect the actual bearer after pacing, on every attempt. */ -export function guardNativeMainDispatch( - ctx: CodexAuthContext, - guard: ((headers: Headers) => void) | undefined, - refused: () => Error, -): ((headers: Headers) => void) | undefined { - if (ctx.kind === "pool") return guard; - const home = resolveCodexHomeDir(); - return headers => { - if (ctx.kind === "main-pool" || ctx.mainQuotaDispatch) { - const bearer = headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1]; - if (home !== resolveCodexHomeDir() || !bearer || !nativeMainBearerAllowed(bearer, home)) throw refused(); - } - guard?.(headers); - }; -} diff --git a/src/codex/quota-auto-refresh.ts b/src/codex/quota-auto-refresh.ts index ec768fc8ff5..42fa040ee53 100644 --- a/src/codex/quota-auto-refresh.ts +++ b/src/codex/quota-auto-refresh.ts @@ -261,9 +261,7 @@ async function warmAccount(config: OcxConfig, accountId: string): Promise { - if (!credentialStillLive()) throw new Error("main account unavailable"); - }, onCompleted: headers => { + await warmCodexAccount({ ...token, onCompleted: headers => { if (writer && credentialStillLive()) { applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration, writer); } diff --git a/src/codex/warmup.ts b/src/codex/warmup.ts index 05f239c534d..a8ae8c7af5f 100644 --- a/src/codex/warmup.ts +++ b/src/codex/warmup.ts @@ -22,8 +22,6 @@ export interface CodexWarmupOptions { chatgptAccountId: string; model?: string; timeoutMs?: number; - /** Recheck an owned credential before every physical send, including model fallback. */ - beforeDispatch?: () => void; /** Publish quota headers only after a completed inference, never on a failed stream. */ onCompleted?: (headers: Headers) => void; } @@ -249,7 +247,6 @@ async function tryWarmup(options: CodexWarmupOptions, model: string): Promise { - if (mainHome !== resolveCodexHomeDir() || getMainAccountToken()?.accessToken !== mainToken.accessToken) throw new Error("main account unavailable"); - }, }); backoff.delete(key); result.warmed.push(key); diff --git a/structure/catalog.md b/structure/catalog.md index 0e0f3c2558d..e0faab25764 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -147,7 +147,7 @@ the bare native or API-key model list. This keeps account-scoped upstream ids su Configured natives widen the bare list without a release: eligible bare `gpt-*` ids under `providers.openai.models` on the canonical Codex forward provider join the shared registry in place (`src/codex/catalog/native-models.ts`). Each borrows the pinned `gpt-6.1-sol` row under its generated name, takes `NATIVE_GPT6_CONTEXT` (272,000 / 872,000), and is ungated. Built-in, retired and reserve ids never register. `src/config/derived-registries.ts` filters the config and refreshes registration on load, persist and reconcile; removing a configured id unregisters it unless it is also discovered. Configured natives have no entitlement routing preference or main-drain sentinel and cannot be combo `nativeAlias` targets because schema validation precedes registration. Covered by `tests/codex-integration/configured-native-models.test.ts`. -Authenticated discovery also widens the set: after a successful nonempty roster fetch, `src/codex/model-entitlements.ts` passes full eligible rows to `src/codex/catalog/discovered-natives.ts`. Native-main entitlement and discovery requests check the central refusal guard before dispatch and before publishing rows; owned credential snapshots retain their Codex home so delayed work cannot move across homes. Rows require a visible, API-supported bare `gpt-*` slug, display name and sane reasoning-level array; built-in, retired, reserve, malformed, oversized and non-scalar Unicode rows are rejected. Existing discoveries and configured natives remain eligible for refresh, so an already-supported slug does not freeze its metadata or keep borrowing a template. +Authenticated discovery also widens the set: after a successful nonempty roster fetch, `src/codex/model-entitlements.ts` passes full eligible rows to `src/codex/catalog/discovered-natives.ts`. Rows require a visible, API-supported bare `gpt-*` slug, display name and sane reasoning-level array; built-in, retired, reserve, malformed, oversized and non-scalar Unicode rows are rejected. Existing discoveries and configured natives remain eligible for refresh, so an already-supported slug does not freeze its metadata or keep borrowing a template. The store keeps version-1 `discovered-native-models.json` under the resolved OpenCodex home using the private atomic writer (0600 on POSIX). It merges by slug with first/last observation times and client version, expires entries unseen for 14 days, and bounds rows to 32, each to 256 KiB (a live row carries its instructions twice; GPT-6.1 Sol's was 87 KB), plus a file-size bound. Each record re-reads the file and merges onto it, so another process's discovery survives (the replace is atomic, not locked), and an unchanged row renews its last-seen time on disk at most hourly so request-time roster fetches rarely write. The discovery ETag is sent only within 24 hours of the full fetch that earned it, because a 304 cannot renew retention. Missing/corrupt files read as empty; persistence failures never fail entitlement. Config activation loads the file through derived registries; observations register immediately. A process-local monotonic generation changes for metadata or membership changes, independently of timestamp refreshes. diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 31c2416b841..cb11a2005d9 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -155,7 +155,7 @@ clears its observation and outputs, and excludes that stream from refresh. A fai activation identity check also invalidates the current trial. A verification result from an older binding or generation cannot invalidate a newer trial. Resuming correction requires another matched exhaustion observation and explicit activation; native cache refresh remains unconfirmed. -Native identity verification also binds an opaque reader-local credential generation from a stable file-stat/content snapshot. `src/codex/desktop-compatibility/native-identity.ts` applies the central native-main refusal guard to local identity reads and before WHAM dispatch, then rechecks the captured bearer fingerprint and home generation before returning verified identity. Known, unknown and saturated refusal block dispatch; refusal during the request blocks publication. Replacement, token rotation and A-to-B-to-A restoration invalidate pending identity checks and response correction; they require a fresh observation runtime. Neither credential hashes nor tokens appear in public status. `tests/clients/desktop-compatibility-native-identity.test.ts` exercises refusal, delayed verification and build-check races with synthetic auth files. +Native identity verification also binds an opaque reader-local credential generation from a stable file-stat/content snapshot. Replacement, token rotation and A-to-B-to-A restoration invalidate pending identity checks and response correction; they require a fresh observation runtime. Neither credential hashes nor tokens appear in public status. `tests/clients/desktop-compatibility-native-identity.test.ts` exercises delayed verification and build-check races with synthetic auth files. Fresh identity checks, generation changes, unknown schemas and elapsed deadlines refuse correction. `usage-sse-controller.ts` preserves event metadata and original sequence IDs; records over its default 256 KiB cap pass through unchanged until the record delimiter, diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 10133c8efc3..0e9a8c37985 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -309,9 +309,9 @@ Codex mode starts a short-lived `codex -c model_provider="openai" app-server` an OpenCodex uses the returned bearer for that request, writes nothing and does not reread auth.json after refresh: keyring mode deletes it. With no file credential, every lookup reads the current login with `refreshToken: false`; an expiring or explicitly rejected bearer requests refresh. -Only home-scoped expiry/refusal metadata survives lookup. `src/codex/native-main-refusals.ts` retains domain-separated SHA-256 fingerprints, never bearers: at most eight homes, each with its last observed RPC fingerprint and four unresolved refused fingerprints. Uncertain reads and home switches preserve evidence. Fingerprint overflow becomes refused/unknown. Home eviction prefers non-refused records; evicting a refusal sets a process-local saturation latch, making every untracked home refused/unknown. Tracked homes retain their evidence; untracked homes require a committed verified login or process restart. This trades availability after saturation for bounded memory without admitting evicted refused bearers. -A `chatgpt` method with a null token rejects the file grant or keyring observation, including the last observed RPC fingerprint when no rejected or locked bearer exists. The shared `nativeMainBearerAllowed` guard fences resolution, status/raw/liveness reads, direct file readers in `src/codex/auth-collision.ts`, account probes, quota priming, reset-credit inspection/consumption, audio and idle recovery. Reads never clear or shrink refusals: a different live bearer is only a candidate, and decoded file identities must agree. Refusal history clears only on committed verified login, through the existing same-identity device-login `beginNativeMainReauth().commit` under native claim and snapshot guards; inference, gateway, audio, warmup, WHAM and reset-credit success never clear it. Quota/account-info publication and reset consumption recheck admission. `src/codex/native-main-refusals.ts` wraps the physical-send guard for owned main credentials with a captured-home and outgoing-bearer check after pacing and on retries, including stored Direct; native-main background warmup rechecks before every model fallback send. Desktop compatibility checks refusal before WHAM and rechecks its fingerprint/home generation before publishing identity. Refusal, home/identity changes and credential ABA invalidate delayed evidence. Unknown or saturated refusal requires verified login. A weak association gives only the exact immutable RPC result its quota writer, home and credential generation. Responses and compact 401 retries transfer `mainAccountTokenQuotaWriter(refreshed)` before copying fields; results without owned RPC provenance retain the prior fence. -Without prior permanent refusal, an unreachable binary, unchanged rejected bearer or expired bearer is transient. A non-ChatGPT or missing login answers an ordinary lookup as an observed absence, while a rejected-token or file-locked refresh still reports it transiently. Read-only lookups honor caller cancellation. +Only home-scoped expiry metadata survives the lookup. Logout, account switches and unavailable reads cannot reuse an earlier bearer; a weak association gives the exact immutable RPC result its owned quota writer, home and credential generation without retaining the result or token. +Each keyring RPC answer is authoritative for its lookup: a withheld token makes that materialization unavailable, while a later answer can use even the same bearer. No keyring refusal registry, bearer fingerprint history or permanent refusal survives; already-materialized requests can finish without a physical-send recheck. File-backed grant rejection stays unchanged. +An unreachable binary, unchanged rejected bearer or expired bearer is transient. A non-ChatGPT or missing login answers an ordinary lookup as an observed absence, while a rejected-token or file-locked refresh still reports it transiently. Read-only lookups honor caller cancellation. Actual refresh runs on its independent 30-second timeout so Codex can save its grant. Both paths pin CODEX_HOME and hold its exclusive claim; home and identity-generation changes reject a delayed observation. Only file-backed refresh has a local grant fingerprint lock; keyring claim deadlines are transient. `"opencodex"`, missing or old Codex keeps the guarded writer. diff --git a/tests/clients/desktop-compatibility-native-identity.test.ts b/tests/clients/desktop-compatibility-native-identity.test.ts index ce52c3bd1d6..82769518bfc 100644 --- a/tests/clients/desktop-compatibility-native-identity.test.ts +++ b/tests/clients/desktop-compatibility-native-identity.test.ts @@ -1,13 +1,12 @@ import { afterEach, expect, test } from "bun:test"; import { mkdtempSync, rmdirSync, unlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { nativeMainRefusals } from "../../src/codex/native-main-refusals"; +import { join } from "node:path"; import { createNativeIdentityReader } from "../../src/codex/desktop-compatibility/native-identity"; import { UsageRelayController } from "../../src/codex/desktop-compatibility/usage-controller"; const roots: string[] = []; -afterEach(() => { for (const root of roots.splice(0)) { nativeMainRefusals.verifiedLogin(root); unlinkSync(join(root, "auth.json")); rmdirSync(root); } }); +afterEach(() => { for (const root of roots.splice(0)) { unlinkSync(join(root, "auth.json")); rmdirSync(root); } }); function fixture() { const root = mkdtempSync(join(tmpdir(), "desktop-identity-")); roots.push(root); const path = join(root, "auth.json"); let revision = 0; @@ -44,29 +43,6 @@ function upstreamFixture(beforeResponse?: () => Promise) { const exchange = { method: "GET", pathname: "/backend-api/wham/usage", status: 200 }; const consent = { scope: "account-ui-compatibility" as const, accountWideConsent: true }; -for (const refusal of ["known", "unknown", "saturated"] as const) test(`native identity refuses ${refusal} main bearer before dispatch`, async () => { - const io = fixture(), upstream = upstreamFixture(), reader = createNativeIdentityReader(io.path, upstream.fetch); - const home = dirname(io.path); - if (refusal === "known") nativeMainRefusals.refuse(home, "synthetic-access-A"); - else if (refusal === "unknown") nativeMainRefusals.refuse(home); - else for (let i = 0; i < 5; i++) nativeMainRefusals.refuse(home, `synthetic-refused-${i}`); - expect(await reader.readCurrentIdentity()).toBeNull(); - expect(await reader.verifyFreshIdentity()).toBeNull(); - upstream.verify([]); -}); - -test("native identity cannot publish after refusal during its in-flight check", async () => { - const io = fixture(); let entered!: () => void, release!: () => void; - const started = new Promise(resolve => { entered = resolve; }); - const blocked = new Promise(resolve => { release = resolve; }); - const upstream = upstreamFixture(async () => { entered(); await blocked; }); - const reader = createNativeIdentityReader(io.path, upstream.fetch), pending = reader.verifyFreshIdentity(); - await started; nativeMainRefusals.refuse(dirname(io.path), "synthetic-access-A"); release(); - expect(await pending).toBeNull(); - expect(await reader.readCurrentIdentity()).toBeNull(); - upstream.verify(["synthetic-access-A"]); -}); - test("unchanged native credentials keep one opaque generation and verify without exposing tokens", async () => { const io = fixture(), upstream = upstreamFixture(), reader = createNativeIdentityReader(io.path, upstream.fetch); const first = await reader.readCurrentIdentity(); diff --git a/tests/codex-integration/codex-credits-probes.test.ts b/tests/codex-integration/codex-credits-probes.test.ts index 8b17124fb7f..71e7602d963 100644 --- a/tests/codex-integration/codex-credits-probes.test.ts +++ b/tests/codex-integration/codex-credits-probes.test.ts @@ -15,16 +15,6 @@ import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; import type { CodexAccount, OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { fakeChatGptJwt } from "../helpers/agent-task-recovery"; -import { getMainAccountCredentialStatus, getMainAccountToken, getValidMainAccountToken } from "../../src/codex/main-account"; -import { nativeMainRefusals } from "../../src/codex/native-main-refusals"; -import { setNativeMainRefreshResolutionForTests, type CodexAuthStatus } from "../../src/codex/native-main-refresh-mode"; -import { inspectResetCredits, consumeResetCredits } from "../../src/codex/auth-api/reset-credit-service"; -import { clearCodexQuotaPrimeState, primeCodexPoolQuotas } from "../../src/codex/auth-api/pool-mode-gate"; -import { getAccountQuota } from "../../src/codex/quota"; -import { getMainAccountInfoCache } from "../../src/codex/main-account-cache"; -import { discoverCodexNativeRoster, resetCodexModelEntitlementCacheForTests, resolveCodexModelEntitlements } from "../../src/codex/model-entitlements"; -import { discoveredNativeModelsGeneration, resetDiscoveredNativeModelsForTests } from "../../src/codex/catalog/discovered-natives"; let testDir: string; let previousHome: string | undefined; @@ -70,9 +60,6 @@ beforeEach(() => { resetQuotaQueryBackoffForTests(); }); afterEach(() => { - nativeMainRefusals.verifiedLogin(process.env.CODEX_HOME!); - setNativeMainRefreshResolutionForTests(null); - clearCodexQuotaPrimeState(); clearMainAccountInfoCache(); clearAccountQuota(); resetMainCodexAccountIdentityTrackingForTests(); @@ -87,133 +74,6 @@ afterEach(() => { removeTreeWithRetry(testDir); }); -async function refuseFileless(bearer: string, kind: "known" | "unknown" | "saturated" = "known") { - setNativeMainRefreshResolutionForTests({ mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fake/codex", version: "0.162.1" }); - const answer = (authToken: string | null) => async (): Promise => ({ authMethod: "chatgpt", authToken, requiresOpenaiAuth: true }); - if (kind !== "unknown") await getValidMainAccountToken({ codexAuthStatus: answer(bearer) }); - await expect(getValidMainAccountToken({ codexAuthStatus: answer(null) })).rejects.toMatchObject({ reason: "reauth" }); - if (kind === "saturated") for (let i = 0; i < 5; i++) nativeMainRefusals.refuse(process.env.CODEX_HOME!, `fixture-refused-${i}`); -} - -describe("native-main refusal at every metadata dispatch", () => { - for (const lane of ["entitlement", "discovery"] as const) test.each(["before-send", "in-flight", "home-changed"] as const)( - `${lane} roster rechecks native-main admission (%s)`, async timing => { - resetCodexModelEntitlementCacheForTests(); - resetDiscoveredNativeModelsForTests(); const generation = discoveredNativeModelsGeneration(); - const home = process.env.CODEX_HOME!, bearer = fakeChatGptJwt("fixture-main-identity"); - writeMain(bearer); - const credentials = [{ accountId: "__main__", accessToken: bearer, chatgptAccountId: "fixture-main-identity", - credentialIdentity: "main:fixture-main-identity", nativeMainHome: timing === "home-changed" ? join(home, "previous-home") : home }]; - if (timing === "before-send") nativeMainRefusals.refuse(home, bearer); - let sends = 0; - const fetcher = (async () => { - sends++; nativeMainRefusals.refuse(home, bearer); - return Response.json({ models: [{ slug: "gpt-9-test", display_name: "Fixture model", supported_in_api: true, visibility: "list", - context_window: 123000, max_context_window: 456000, supported_reasoning_levels: [{ effort: "low", description: "Quick" }], - default_reasoning_level: "low", model_messages: { instructions_template: "Synthetic instructions" } }] }); - }) as typeof fetch; - if (lane === "discovery") expect(await discoverCodexNativeRoster(config(), { credentials, fetcher })).toBe("unavailable"); - else { - const snapshot = await resolveCodexModelEntitlements(config(), { credentials, fetcher, clientVersion: "99.0.0" }); - expect(snapshot.modelsByAccount.get("__main__")?.has("gpt-9-test")).not.toBe(true); - } - expect(sends).toBe(timing === "in-flight" ? 1 : 0); - expect(discoveredNativeModelsGeneration()).toBe(generation); - resetCodexModelEntitlementCacheForTests(); - resetDiscoveredNativeModelsForTests(); - }, - ); - for (const history of ["known", "unknown", "saturated", "plain"] as const) { - test.each(["probe", "list", "inspect", "consume", "prime"] as const)(`${history} history gates %s`, async path => { - const bearer = fakeChatGptJwt("fixture-main-identity"); - if (history !== "plain") await refuseFileless(bearer, history); - writeMain(bearer); - let requests = 0; - globalThis.fetch = (async () => { - requests++; - return Response.json({ plan_type: "plus", email: "fixture@example.test", code: "no_credit", available_count: 2, - rate_limit: { primary_window: { used_percent: 12, limit_window_seconds: 18000, reset_at: Math.floor(Date.now() / 1000) + 3600 } } }); - }) as typeof fetch; - const cfg: OcxConfig = { ...config(), providers: { openai: { name: "OpenAI", adapter: "openai-responses", - authMode: "forward", codexAccountMode: "pool", baseUrl: "https://chatgpt.com/backend-api/codex" } } }; - let response: Response | undefined; - if (path === "probe") await fetchMainAccountInfoSnapshot(true, cfg); - else if (path === "list") await listCodexAuthAccounts(cfg, true); - else if (path === "inspect") response = await inspectResetCredits(cfg, "__main__", new AbortController().signal); - else if (path === "consume") response = await consumeResetCredits(cfg, "__main__", undefined); - else await primeCodexPoolQuotas(cfg, "startup"); - expect(requests).toBe(history === "plain" ? 1 : 0); - if (response) expect(response.status).toBe(history === "plain" ? 200 : 401); - if (history !== "plain") { - expect(getMainAccountInfoCache()).toBeNull(); - expect(getAccountQuota("__main__")).toBeNull(); - expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); - } - }); - } - - test.each([200, 401])("successor candidate retains refusal after upstream %s", async status => { - const bearer = fakeChatGptJwt("fixture-main-identity", { jti: "refused" }); - await refuseFileless(bearer); - const successor = fakeChatGptJwt("fixture-main-identity", { jti: "successor" }); - writeMain(successor); - expect(getMainAccountCredentialStatus().usable).toBe(true); - expect(getMainAccountToken()?.accessToken).toBe(successor); - expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); - let requests = 0; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - requests++; - expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${successor}`); - return Response.json({ plan_type: "plus" }, { status }); - }) as typeof fetch; - await fetchMainAccountInfoSnapshot(true, config()); - expect(requests).toBe(1); - expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); - writeMain(bearer); - expect(getMainAccountToken()).toBeNull(); - }); - - test("a refusal during WHAM invalidates publication", async () => { - const bearer = fakeChatGptJwt("fixture-main-identity"); - writeMain(bearer); - globalThis.fetch = (async () => { - nativeMainRefusals.refuse(process.env.CODEX_HOME!, bearer); - return Response.json({ plan_type: "plus", credits: { balance: "999" }, rate_limit: { - primary_window: { used_percent: 12, limit_window_seconds: 18000, reset_at: Math.floor(Date.now() / 1000) + 3600 } } }); - }) as typeof fetch; - await fetchMainAccountInfoSnapshot(true, config()); - expect(getMainAccountInfoCache()).toBeNull(); - expect(getAccountQuota("__main__")).toBeNull(); - expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); - }); - - test.each(["inspect", "consume"] as const)("reset-credit %s success retains refusal history", async path => { - await refuseFileless(fakeChatGptJwt("fixture-main-identity", { jti: "refused-reset" })); - const successor = fakeChatGptJwt("fixture-main-identity", { jti: "successor-reset" }); - writeMain(successor); - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${successor}`); - expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); - return Response.json({ available_count: 2, code: "no_credit" }); - }) as typeof fetch; - const response = path === "inspect" ? await inspectResetCredits(config(), "__main__", new AbortController().signal) - : await consumeResetCredits(config(), "__main__", undefined); - expect(response.status).toBe(200); - expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); - }); - - test("reset inspection cannot publish after a concurrent refusal", async () => { - const bearer = fakeChatGptJwt("fixture-main-identity"); - writeMain(bearer); - globalThis.fetch = (async () => { - nativeMainRefusals.refuse(process.env.CODEX_HOME!, bearer); - return Response.json({ available_count: 2 }); - }) as typeof fetch; - expect((await inspectResetCredits(config(), "__main__", new AbortController().signal)).status).toBe(409); - expect(nativeMainRefusals.has(process.env.CODEX_HOME!)).toBe(true); - }); -}); - describe("main credits publication", () => { test("current credential publishes credits-only usage, omission keeps, null clears", async () => { writeMain(); diff --git a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts index ea6e9694467..7af348306f5 100644 --- a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts @@ -8,7 +8,6 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { setMainAccountPlan } from "../../src/codex/main-account"; -import { nativeMainRefusals } from "../../src/codex/native-main-refusals"; import * as mainAccount from "../../src/codex/main-account"; import * as nativeClaim from "../../src/codex/native-main-claim"; import { clearAccountQuota, flushQuotaObservationsForTests, getAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; @@ -118,7 +117,6 @@ beforeEach(() => { }); afterEach(async () => { - nativeMainRefusals.verifiedLogin(home); globalThis.fetch = previousFetch; clearAccountQuota(); await flushQuotaObservationsForTests(); @@ -280,18 +278,6 @@ describe("quota auto-refresh native-main admission", () => { expect(getNativeMainProfileRequestCount()).toBe(0); }); - test("main warmup rechecks refusal before a model fallback send", async () => { - const cfg = config(); cfg.codexMainAccountHardLock = false; - const calls = installFetch(async () => { - nativeMainRefusals.refuse(home, bearer()); - return new Response(null, { status: 400 }); - }); - await runCodexQuotaAutoRefresh(cfg, now, { persistCompleted: recordMarkers }); - expect(calls).toEqual([responsesUrl]); - expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBeUndefined(); - expect(getNativeMainProfileRequestCount()).toBe(0); - }); - test("policy off preserves main warmup and its existing model fallback", async () => { const cfg = config(); cfg.codexMainAccountHardLock = false; diff --git a/tests/codex-integration/native-main-refresh-mode.test.ts b/tests/codex-integration/native-main-refresh-mode.test.ts index 3451aba8309..b64d441cf74 100644 --- a/tests/codex-integration/native-main-refresh-mode.test.ts +++ b/tests/codex-integration/native-main-refresh-mode.test.ts @@ -15,12 +15,8 @@ import { } from "../../src/codex/native-main-refresh-mode"; import { forceRefreshMainAccountToken, - beginNativeMainReauth, getMainAccountCredentialStatus, - getMainAccountToken, getValidMainAccountToken, - isMainAccountTokenLive, - isMainAccountTokenVerifiablyLive, isMainAccountRefreshGrantRejected, MAIN_CODEX_ACCOUNT_ID, MainAuthJsonChangedDuringRefreshError, @@ -42,7 +38,6 @@ import type { OcxConfig } from "../../src/types"; import type { ResolveCodexRuntimeResult } from "../../src/codex/runtime"; import { fakeChatGptJwt } from "../helpers/agent-task-recovery"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { NativeMainRefusals, nativeMainRefusals } from "../../src/codex/native-main-refusals"; const CODEX_MODE: NativeMainRefreshResolution = { mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fake/codex", version: "0.162.1", @@ -136,8 +131,6 @@ process.stdin.on("data", chunk => { let root: string; let home: string; let previousCodexHome: string | undefined; -let refusalHomes: Map; -let refusalEvicted: boolean; const touchedEnv = ["OCX_FAKE_CODEX_SCENARIO", "OCX_FAKE_CODEX_LOG", "OCX_FAKE_CODEX_TOKEN"] as const; function fakeCodex(): { command: string; logPath: string } { @@ -161,8 +154,6 @@ function seedAuth(accessToken: string, refreshToken = "codex-owned-grant"): stri const expired = () => fakeChatGptJwt("acct-main", { exp: Math.floor(Date.now() / 1000) - 60 }); beforeEach(() => { - refusalHomes = new Map(Reflect.get(nativeMainRefusals, "homes")); - refusalEvicted = Reflect.get(nativeMainRefusals, "evictedRefusal"); root = mkdtempSync(join(tmpdir(), "ocx-native-refresh-mode-")); home = join(root, "codex-home"); mkdirSync(home); @@ -171,8 +162,6 @@ beforeEach(() => { }); afterEach(() => { - Reflect.set(nativeMainRefusals, "homes", refusalHomes); - Reflect.set(nativeMainRefusals, "evictedRefusal", refusalEvicted); setNativeMainRefreshResolutionForTests(null); for (const name of touchedEnv) delete process.env[name]; if (previousCodexHome === undefined) delete process.env.CODEX_HOME; @@ -284,6 +273,29 @@ describe("main account in codex refresh mode (#2999)", () => { expect(calls).toEqual([{ refresh: false }]); }); + test.each([false, true])("a withheld lookup only refuses its own materialization (forced=%s)", async forced => { + setNativeMainRefreshResolutionForTests(CODEX_MODE); + const bearer = fakeChatGptJwt("acct-per-lookup"); + let withheld = true; + const { calls, codexAuthStatus } = seam(() => ({ + authMethod: "chatgpt", authToken: withheld ? null : bearer, requiresOpenaiAuth: true, + })); + const inbound = new Headers({ authorization: "Bearer ocx_data_fixture" }); + const context = { kind: "main" as const, accountId: null }; + const options = { substituteMainCredential: true, nativeMainRefreshDependencies: { codexAuthStatus } }; + if (forced) { + await expect(forceRefreshMainAccountToken(bearer, { codexAuthStatus })).rejects.toMatchObject({ reason: "reauth" }); + } else { + await expect(materializeCodexUpstreamAuthAsync(inbound, context, options)).rejects.toMatchObject({ reason: "reauth" }); + } + expect(getMainAccountCredentialStatus()).toMatchObject({ refreshGrantRejected: false, usable: false }); + withheld = false; + const selected = await materializeCodexUpstreamAuthAsync(inbound, context, options); + expect(selected.get("authorization")).toBe(`Bearer ${bearer}`); + expect(selected.get("chatgpt-account-id")).toBe("acct-per-lookup"); + expect(calls).toEqual([{ refresh: forced }, { refresh: false }]); + }); + test("Pool discovers a keyring-only main login before synchronous candidacy", async () => { setNativeMainRefreshResolutionForTests(CODEX_MODE); clearThreadAccountMap(); @@ -735,7 +747,7 @@ describe("main account in codex refresh mode (#2999)", () => { expect(getMainAccountCredentialStatus().usable).toBe(false); }); - test.each(["rejected-bearer", "opaque", "conflicting-identity", "home-roundtrip"])("a keyring refresh refusal stays quarantined until a verified successor (%s)", async scenario => { + test("a later authoritative keyring lookup can return the same bearer after withholding it", async () => { setNativeMainRefreshResolutionForTests(CODEX_MODE); seedAuth(expired()); const rejected = fakeChatGptJwt("acct-main"); @@ -749,215 +761,14 @@ describe("main account in codex refresh mode (#2999)", () => { await expect(forceRefreshMainAccountToken(rejected, { codexAuthStatus })).rejects.toMatchObject({ reason: "reauth" }); expect(getMainAccountCredentialStatus().usable).toBe(false); - expect(isMainAccountRefreshGrantRejected()).toBe(true); - await expect(getValidMainAccountToken({ codexAuthStatus })).rejects.toMatchObject({ reason: "reauth" }); - if (scenario === "home-roundtrip") { - const otherHome = join(root, "other-home"); - mkdirSync(otherHome); - process.env.CODEX_HOME = otherHome; - try { - expect(isMainAccountRefreshGrantRejected()).toBe(false); - expect(await getValidMainAccountToken({ codexAuthStatus: async () => ({ - authMethod: "chatgpt", authToken: rejected, requiresOpenaiAuth: true, - }) })).toEqual({ accessToken: rejected, chatgptAccountId: "acct-main" }); - } finally { - process.env.CODEX_HOME = home; - } - } - const uncertain = scenario === "rejected-bearer" || scenario === "home-roundtrip" ? rejected : scenario === "opaque" ? "opaque-fixture-token" - : fakeChatGptJwt("acct-main", { chatgpt_account_id: "acct-conflicting" }); - status = { authMethod: "chatgpt", authToken: uncertain, requiresOpenaiAuth: true }; - await expect(getValidMainAccountToken({ codexAuthStatus })).rejects.toMatchObject({ reason: "reauth" }); - expect(getMainAccountCredentialStatus()).toMatchObject({ refreshGrantRejected: true, usable: false }); - // A different bearer is a candidate; reading it must retain the refused fingerprints. - const successor = fakeChatGptJwt("acct-main", { jti: "verified-successor" }); - status = { authMethod: "chatgpt", authToken: successor, requiresOpenaiAuth: true }; - expect(await getValidMainAccountToken({ codexAuthStatus })).toEqual({ accessToken: successor, chatgptAccountId: "acct-main" }); - expect(nativeMainRefusals.has(home)).toBe(true); - expect(nativeMainRefusals.refuses(home, rejected)).toBe(true); expect(isMainAccountRefreshGrantRejected()).toBe(false); - expect(getMainAccountCredentialStatus().usable).toBe(true); - }); - - test("ordinary lookup refusal remembers the last observed bearer across uncertain reads", async () => { - setNativeMainRefreshResolutionForTests(CODEX_MODE); - const bearer = fakeChatGptJwt("acct-ordinary-refusal"); - const answer = (authToken: string | null) => async (): Promise => ({ - authMethod: "chatgpt", authToken, requiresOpenaiAuth: true, - }); - await getValidMainAccountToken({ codexAuthStatus: answer(bearer) }); - await expect(getValidMainAccountToken({ codexAuthStatus: async () => { throw new Error("offline"); } })) - .rejects.toMatchObject({ reason: "transient" }); - await expect(getValidMainAccountToken({ codexAuthStatus: answer(null) })).rejects.toMatchObject({ reason: "reauth" }); - await expect(getValidMainAccountToken({ codexAuthStatus: answer(bearer) })).rejects.toMatchObject({ reason: "reauth" }); - expect(getMainAccountCredentialStatus().usable).toBe(false); - const successor = fakeChatGptJwt("acct-ordinary-refusal", { jti: "new-login" }); - expect(await getValidMainAccountToken({ codexAuthStatus: answer(successor) })) - .toMatchObject({ accessToken: successor }); - }); - - for (const refusal of ["known", "unknown", "fingerprint-saturated", "home-evicted"] as const) { - test.each(["status", "valid", "forced", "raw", "live", "verifiably-live"] as const)( - `restored auth.json cannot bypass ${refusal} keyring refusal (%s)`, async admission => { - // Saturation is process-wide; restore metadata so this matrix cannot poison later homes. - const homes = new Map(Reflect.get(nativeMainRefusals, "homes")); - const evicted = Reflect.get(nativeMainRefusals, "evictedRefusal"); - try { - setNativeMainRefreshResolutionForTests(CODEX_MODE); - const bearer = fakeChatGptJwt("acct-main", { jti: "restored-refused" }); - const answer = (authToken: string | null) => async (): Promise => ({ - authMethod: "chatgpt", authToken, requiresOpenaiAuth: true, - }); - if (refusal !== "unknown") await getValidMainAccountToken({ codexAuthStatus: answer(bearer) }); - await expect(getValidMainAccountToken({ codexAuthStatus: answer(null) })) - .rejects.toMatchObject({ reason: "reauth" }); - if (refusal === "fingerprint-saturated") { - for (let i = 0; i < 5; i++) { - await expect(forceRefreshMainAccountToken(fakeChatGptJwt("acct-main", { jti: i }), { codexAuthStatus: answer(null) })) - .rejects.toMatchObject({ reason: "reauth" }); - } - } else if (refusal === "home-evicted") { - for (let i = 0; i < 12; i++) nativeMainRefusals.refuse(join(root, `evicted-${i}`), `fixture-${i}`); - } - seedAuth(bearer); - if (admission === "status") { - expect(getMainAccountCredentialStatus()).toEqual({ refreshGrantRejected: true, hasRefreshGrant: false, usable: false }); - } else if (admission === "valid" || admission === "forced") { - const dependencies = { codexAuthStatus: answer(bearer) }; - await expect(admission === "valid" ? getValidMainAccountToken(dependencies) - : forceRefreshMainAccountToken("different-upstream-bearer", dependencies)).rejects.toMatchObject({ reason: "reauth" }); - } else if (admission === "raw") { - expect(getMainAccountToken()).toBeNull(); - } else { - expect(admission === "live" ? isMainAccountTokenLive() : isMainAccountTokenVerifiablyLive()).toBe(false); - } - if (refusal !== "known") { - // A fresh verified successor alone cannot recover lost refusal history. - const successor = fakeChatGptJwt("acct-main", { jti: "unproven-file-successor" }); - seedAuth(successor); - await expect(getValidMainAccountToken()).rejects.toMatchObject({ reason: "reauth" }); - expect(getMainAccountCredentialStatus().usable).toBe(false); - await beginNativeMainReauth().commit({ accessToken: successor, refreshToken: "committed-new-grant", - idToken: successor, chatgptAccountId: "acct-main" }); - expect(await getValidMainAccountToken()).toEqual({ accessToken: successor, chatgptAccountId: "acct-main" }); - } - } finally { - Reflect.set(nativeMainRefusals, "homes", homes); - Reflect.set(nativeMainRefusals, "evictedRefusal", evicted); - } - }, - ); - } - - test.each(["status", "valid", "raw"] as const)("restored auth.json requires a different verified bearer for recovery (%s)", async admission => { - setNativeMainRefreshResolutionForTests(CODEX_MODE); - const bearer = fakeChatGptJwt("acct-main", { jti: "file-recovery-old" }); - const answer = (authToken: string | null) => async (): Promise => ({ - authMethod: "chatgpt", authToken, requiresOpenaiAuth: true, - }); - await getValidMainAccountToken({ codexAuthStatus: answer(bearer) }); - await expect(getValidMainAccountToken({ codexAuthStatus: answer(null) })).rejects.toMatchObject({ reason: "reauth" }); - seedAuth(bearer); - await expect(getValidMainAccountToken()).rejects.toMatchObject({ reason: "reauth" }); - for (const uncertain of ["opaque-fixture-token", fakeChatGptJwt("acct-main", { chatgpt_account_id: "acct-conflict" }), - fakeChatGptJwt("acct-other"), expired()]) { - seedAuth(uncertain); - await expect(getValidMainAccountToken()).rejects.toMatchObject({ reason: "reauth" }); - } - const successor = fakeChatGptJwt("acct-main", { jti: "file-recovery-new" }); - seedAuth(successor); - if (admission === "status") { - expect(getMainAccountCredentialStatus()).toEqual({ refreshGrantRejected: false, hasRefreshGrant: true, usable: true }); - } else { - const result = admission === "valid" ? await getValidMainAccountToken() : getMainAccountToken(); - expect(result).toEqual({ accessToken: successor, chatgptAccountId: "acct-main" }); - } + await expect(getValidMainAccountToken({ codexAuthStatus })).rejects.toMatchObject({ reason: "reauth" }); + status = { authMethod: "chatgpt", authToken: rejected, requiresOpenaiAuth: true }; + expect((await getValidMainAccountToken({ codexAuthStatus }))?.accessToken).toBe(rejected); expect(isMainAccountRefreshGrantRejected()).toBe(false); }); - test.each(["status", "valid", "raw", "live", "verifiably-live"] as const)( - "signature-mutated successor cannot clear a refusal through %s reads", async admission => { - setNativeMainRefreshResolutionForTests(CODEX_MODE); - const bearer = fakeChatGptJwt("acct-main", { jti: "signature-only-mutation" }); - const answer = (authToken: string | null) => async (): Promise => ({ - authMethod: "chatgpt", authToken, requiresOpenaiAuth: true, - }); - await getValidMainAccountToken({ codexAuthStatus: answer(bearer) }); - await expect(getValidMainAccountToken({ codexAuthStatus: answer(null) })).rejects.toMatchObject({ reason: "reauth" }); - const forged = bearer.slice(0, bearer.lastIndexOf(".") + 1) + "forged-signature"; - seedAuth(forged); - if (admission === "status") expect(getMainAccountCredentialStatus().usable).toBe(true); - else if (admission === "valid") expect((await getValidMainAccountToken())?.accessToken).toBe(forged); - else if (admission === "raw") expect(getMainAccountToken()?.accessToken).toBe(forged); - else expect(admission === "live" ? isMainAccountTokenLive() : isMainAccountTokenVerifiablyLive()).toBe(true); - expect(nativeMainRefusals.has(home)).toBe(true); - seedAuth(bearer); - expect(getMainAccountToken()).toBeNull(); - expect(getMainAccountCredentialStatus().usable).toBe(false); - await expect(getValidMainAccountToken()).rejects.toMatchObject({ reason: "reauth" }); - }, - ); - - test("unknown ordinary lookup refusal cannot be cleared by a newly observed verified bearer", async () => { - setNativeMainRefreshResolutionForTests(CODEX_MODE); - await expect(getValidMainAccountToken({ codexAuthStatus: async () => ({ - authMethod: "chatgpt", authToken: null, requiresOpenaiAuth: true, - }) })).rejects.toMatchObject({ reason: "reauth" }); - await expect(getValidMainAccountToken({ codexAuthStatus: async () => ({ - authMethod: "chatgpt", authToken: fakeChatGptJwt("acct-unknown-refusal"), requiresOpenaiAuth: true, - }) })).rejects.toMatchObject({ reason: "reauth" }); - expect(getMainAccountCredentialStatus().usable).toBe(false); - // Only the committed same-identity device login is an explicit recovery event. - const recovered = fakeChatGptJwt("acct-main", { jti: "committed-login" }); - seedAuth(recovered); - await beginNativeMainReauth().commit({ accessToken: recovered, refreshToken: "new-grant", - idToken: recovered, chatgptAccountId: "acct-main" }); - rmSync(join(home, "auth.json")); - expect(await getValidMainAccountToken({ codexAuthStatus: async () => ({ - authMethod: "chatgpt", authToken: recovered, requiresOpenaiAuth: true, - }) })).toMatchObject({ accessToken: recovered }); - }); - - test("home saturation bounds metadata and keeps evicted refusals unknown", () => { - const refusals = new NativeMainRefusals(); - for (let i = 0; i < 12; i++) refusals.refuse(`fixture-home-${i}`, `fixture-bearer-${i}`); - expect(Reflect.get(refusals, "homes").size).toBe(8); - expect(refusals.has("fixture-home-0")).toBe(true); - expect(refusals.refuses("fixture-home-0", "fixture-bearer-0")).toBe(true); - expect(refusals.refuses("fixture-home-0", "different-verified-bearer")).toBe(true); - expect(refusals.refuses("untracked-home", "different-verified-bearer")).toBe(true); - refusals.verifiedLogin("fixture-home-0"); - expect(refusals.has("fixture-home-0")).toBe(false); - expect(refusals.refuses("fixture-home-0", "committed-login-bearer")).toBe(false); - expect(refusals.has("fixture-home-1")).toBe(true); - expect(Reflect.get(refusals, "homes").size).toBe(8); - }); - - test("fingerprint metadata has a small cap and stores only domain-separated hashes", () => { - const refusals = new NativeMainRefusals(); - for (let i = 0; i < 4; i++) refusals.refuse("fixture-home", `fixture-bearer-${i}`); - const state = Reflect.get(refusals, "homes").get("fixture-home"); - expect(state.fingerprints.size).toBe(4); - for (const hash of state.fingerprints) expect(hash).toMatch(/^[a-f0-9]{64}$/); - refusals.refuse("fixture-home", "fixture-bearer-overflow"); - expect(state.fingerprints.size).toBe(0); - expect(state.unknown).toBe(true); - expect(refusals.refuses("fixture-home", "different-verified-bearer")).toBe(true); - }); - - test("fingerprint saturation requires verified login instead of forgetting refused bearers", async () => { - setNativeMainRefreshResolutionForTests(CODEX_MODE); - const refused = async (): Promise => ({ authMethod: "chatgpt", authToken: null, requiresOpenaiAuth: true }); - for (let i = 0; i < 12; i++) { - await expect(forceRefreshMainAccountToken(fakeChatGptJwt("acct-saturated", { jti: i }), { codexAuthStatus: refused })) - .rejects.toMatchObject({ reason: "reauth" }); - } - await expect(getValidMainAccountToken({ codexAuthStatus: async () => ({ - authMethod: "chatgpt", authToken: fakeChatGptJwt("acct-saturated", { jti: "unproven-successor" }), requiresOpenaiAuth: true, - }) })).rejects.toMatchObject({ reason: "reauth" }); - }); - - test("a chatgpt login with a withheld token means reauthentication", async () => { + test("a chatgpt login with a withheld token keeps the file-backed grant rejection", async () => { setNativeMainRefreshResolutionForTests(CODEX_MODE); const original = seedAuth(expired()); const { codexAuthStatus } = seam(() => ({ authMethod: "chatgpt", authToken: null, requiresOpenaiAuth: true })); diff --git a/tests/codex-integration/token-guardian.test.ts b/tests/codex-integration/token-guardian.test.ts index a5ea7f70a24..9d97fface44 100644 --- a/tests/codex-integration/token-guardian.test.ts +++ b/tests/codex-integration/token-guardian.test.ts @@ -7,7 +7,6 @@ import { getConfigPath } from "../../src/config"; import { flushConfigDirHardening } from "../../src/config/paths"; import { markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store"; import { __resetGuardianState, guardianSweep } from "../../src/oauth/token-guardian"; -import { nativeMainRefusals } from "../../src/codex/native-main-refusals"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { acquireNativeMainProfileDrain, @@ -49,7 +48,6 @@ beforeEach(() => { }); afterEach(async () => { - nativeMainRefusals.verifiedLogin(join(tmp, "codex")); // Optional Windows ACL work can outlive credential writes and keep this home open. await flushConfigDirHardening(join(tmp, "ocx")); resetLifecycleDrainStateForTests(); @@ -405,22 +403,6 @@ describe("token guardian", () => { expect(tokenCalls).toBe(3); }); - test("guardian native-main warmup rechecks refusal before model fallback", async () => { - writeFileSync(join(tmp, "codex", "auth.json"), JSON.stringify({ tokens: { access_token: "main-guardian", account_id: "main-chatgpt-id" } })); - writeConfig({ tokenGuardian: { enabled: true, tickSeconds: 60, leadSeconds: 60, codexWarmupEnabled: true }, providers: { openai: { - adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct", refreshPolicy: "proactive", - } } }); - let sends = 0; - globalThis.fetch = (async () => { - sends++; nativeMainRefusals.refuse(join(tmp, "codex"), "main-guardian"); - return new Response(null, { status: 400 }); - }) as typeof fetch; - const result = await guardianSweep(Date.now()); - expect(sends).toBe(1); - expect(result.warmed).toEqual([]); - expect(result.failed).toEqual(["codex:__main__"]); - }); - test("direct mode warms main only and never enumerates the added-account store", async () => { const accountStore = join(tmp, "ocx", "codex-accounts.json"); writeFileSync(accountStore, "invalid-added-store"); diff --git a/tests/responses/responses-native-main-refresh.test.ts b/tests/responses/responses-native-main-refresh.test.ts index 10672aefc95..acc2d58c51c 100644 --- a/tests/responses/responses-native-main-refresh.test.ts +++ b/tests/responses/responses-native-main-refresh.test.ts @@ -29,9 +29,6 @@ import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; -import { nativeMainRefusals } from "../../src/codex/native-main-refusals"; -import { providerRequestPacingStatus, resetProviderRequestPacingForTest, waitForProviderRequestSlot } from "../../src/providers/request-pacing"; -import * as routedConfig from "../../src/providers/routed-config"; /** Smallest complete Anthropic stream: the combo's second target has to actually answer. */ const ANTHROPIC_SSE = [ @@ -111,7 +108,6 @@ beforeEach(() => { }); afterEach(() => { - nativeMainRefusals.verifiedLogin(home); setNativeMainRefreshResolutionForTests(null); clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); resetMainCodexAccountIdentityTrackingForTests(); @@ -164,108 +160,6 @@ function install401ThenRefreshHarness(): { sends: string[]; refreshes: string[] } describe("native main 401 refresh and replay", () => { - for (const gateway of [false, true]) test.each(["/v1/responses", "/v1/responses/compact"] as const)( - `%s success retains refusal history (gateway=${gateway})`, async path => { - clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); - resetMainCodexAccountIdentityTrackingForTests(); - fs.rmSync(join(home, "auth.json")); - setNativeMainRefreshResolutionForTests({ mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fixture/codex", version: "0.162.1" }); - const rejected = fakeChatGptJwt("account-main", { jti: "refused" }); - await getValidMainAccountToken({ codexAuthStatus: async () => ({ authMethod: "chatgpt", authToken: rejected, requiresOpenaiAuth: true }) }); - await expect(getValidMainAccountToken({ codexAuthStatus: async () => ({ authMethod: "chatgpt", authToken: null, requiresOpenaiAuth: true }) })).rejects.toMatchObject({ reason: "reauth" }); - const successor = fakeChatGptJwt("account-main", { jti: "successor" }); - writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: successor, account_id: "account-main" } })); - expect((await getValidMainAccountToken())?.accessToken).toBe(successor); - expect(nativeMainRefusals.has(home)).toBe(true); - let sends = 0; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = new URL(input instanceof Request ? input.url : String(input)); - if (!url.pathname.endsWith("/responses") && !url.pathname.endsWith("/responses/compact")) return Response.json({}, { status: 503 }); - sends++; - expect(url.hostname).toBe(gateway ? "gateway.example.test" : "chatgpt.com"); - expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${gateway ? "gateway-fixture-key" : successor}`); - expect(nativeMainRefusals.has(home)).toBe(true); - return Response.json({ id: "resp_authenticated", object: "response", status: "completed", output: [] }); - }) as typeof fetch; - const cfg = config(); - if (gateway) Object.assign(cfg.providers.openai!, { baseUrl: "https://gateway.example.test/v1", - headers: { Authorization: "Bearer gateway-fixture-key" }, supportsNativeCompactEndpoint: true }); - // Inject an already-resolved gateway row: the preset normally pins openai's URL. - // Selection, transport credentials and both generic success hooks remain real. - const resolved = gateway ? spyOn(routedConfig, "routedProviderConfig").mockImplementation((_name, provider) => provider) : undefined; - try { - const req = request(path); - const log = { model: "", provider: "" } as RequestLogContext; - const response = path === "/v1/responses" ? await handleResponses(req, cfg, log) - : await handleResponsesCompact(req, cfg, log); - const body = await response.text(); - expect({ status: response.status, error: response.status === 200 ? undefined : body }).toEqual({ status: 200, error: undefined }); - expect(sends).toBe(1); - expect(nativeMainRefusals.has(home)).toBe(true); - expect(nativeMainRefusals.refuses(home, rejected)).toBe(true); - nativeMainRefusals.verifiedLogin(home); - expect(nativeMainRefusals.has(home)).toBe(false); - } finally { resolved?.mockRestore(); } - }, - ); - for (const mode of ["pool", "direct"] as const) test.each(["/v1/responses", "/v1/responses/compact"] as const)( - `%s rechecks refusal after pacing (mode=${mode})`, async path => { - const cfg = config(); cfg.providers.openai!.codexAccountMode = mode; - cfg.hostname = "0.0.0.0"; - cfg.apiKeys = [{ id: "queued", name: "queued", key: "ocx_data_queued_fixture", createdAt: "2026-10-27T00:00:00.000Z" }]; - cfg.providers.openai!.requestPacing = { enabled: true, maxConcurrentRequests: 1 }; - const blocker = await waitForProviderRequestSlot("openai", cfg.providers.openai!, "gpt-5.5"); - let sends = 0; - globalThis.fetch = (async (input: RequestInfo | URL) => { - const url = new URL(input instanceof Request ? input.url : String(input)); - if (url.pathname.endsWith("/responses") || url.pathname.endsWith("/responses/compact")) sends++; - return Response.json({ id: "resp_queued", object: "response", status: "completed", output: [] }); - }) as typeof fetch; - const req = request(path), log = { model: "", provider: "" } as RequestLogContext; - req.headers.set("authorization", "Bearer ocx_data_queued_fixture"); - const admission = resolveResponsesApiAuth(req, cfg)!, turn = tryAdmitTurn()!; - expect(admission.source).toBe("bearer"); expect(turn).not.toBeNull(); - const pending = path.endsWith("compact") ? handleResponsesCompact(req, cfg, log, turn, admission) - : handleResponses(req, cfg, log, { admission, turnAdmissionLease: turn }); - try { - for (let i = 0; i < 1000 && providerRequestPacingStatus("openai", cfg.providers.openai!).queued === 0; i++) await Bun.sleep(1); - expect(providerRequestPacingStatus("openai", cfg.providers.openai!).queued).toBe(1); - nativeMainRefusals.refuse(home, "rejected-access"); - blocker?.release(); - const response = await pending; - expect(sends).toBe(0); - expect(response.status).toBe(401); - expect(await response.text()).toContain(CODEX_MAIN_SIGN_IN_REQUIRED_MESSAGE); - } finally { blocker?.release(); resetProviderRequestPacingForTest(); await pending; turn.release(); } - }, - ); - test.each(["/v1/responses", "/v1/responses/compact"] as const)( - "%s rechecks refusal while a refreshed replay waits for pacing", async path => { - const cfg = config(); cfg.providers.openai!.requestPacing = { enabled: true, maxConcurrentRequests: 1 }; - const harness = install401ThenRefreshHarness(), upstream = globalThis.fetch; - let blocker: Awaited>; - let entered!: () => void; const refreshing = new Promise(resolve => { entered = resolve; }); - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - if (new URL(input instanceof Request ? input.url : String(input)).hostname === "auth.openai.com") { - blocker = await waitForProviderRequestSlot("openai", cfg.providers.openai!, "gpt-5.5"); entered(); - } - return upstream(input, init); - }) as typeof fetch; - const req = request(path), log = { model: "", provider: "" } as RequestLogContext; - const pending = path.endsWith("compact") ? handleResponsesCompact(req, cfg, log) : handleResponses(req, cfg, log); - try { - await refreshing; - for (let i = 0; i < 1000 && providerRequestPacingStatus("openai", cfg.providers.openai!).queued === 0; i++) await Bun.sleep(1); - expect(providerRequestPacingStatus("openai", cfg.providers.openai!).queued).toBe(1); - nativeMainRefusals.refuse(home, "refreshed-access"); blocker?.release(); - const response = await pending; - expect(harness.sends).toEqual(["Bearer rejected-access"]); - expect(harness.refreshes).toEqual(["refresh-grant"]); - expect(response.status).toBe(401); - expect(await response.text()).toContain(CODEX_MAIN_SIGN_IN_REQUIRED_MESSAGE); - } finally { blocker?.release(); resetProviderRequestPacingForTest(); await pending; } - }, - ); test.each(["/v1/responses", "/v1/responses/compact"] as const)( "%s publishes retry quota for a keyring account replacement after 401", async path => { clearAccountQuota(MAIN_CODEX_ACCOUNT_ID); diff --git a/tests/server/audio-transcriptions.test.ts b/tests/server/audio-transcriptions.test.ts index beb316264e9..1144dddaaa5 100644 --- a/tests/server/audio-transcriptions.test.ts +++ b/tests/server/audio-transcriptions.test.ts @@ -21,8 +21,6 @@ import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; -import { getValidMainAccountToken } from "../../src/codex/main-account"; -import { nativeMainRefusals } from "../../src/codex/native-main-refusals"; const KEY = "ocx_data_audio_test_client"; const originalFetch = globalThis.fetch; @@ -104,7 +102,6 @@ afterEach(async () => { clearMainAccountInfoCache(); resetMainCodexAccountIdentityTrackingForTests(); resetLifecycleDrainStateForTests(); - nativeMainRefusals.verifiedLogin(codex.path); codex.restore(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; @@ -334,33 +331,6 @@ describe("standalone transcription API", () => { } }); - test.each([false, true])("stored Direct audio retains refusal history with a successor (successor=%s)", async successor => { - nativeMainRefresh.setNativeMainRefreshResolutionForTests({ mode: "codex", configured: "auto", reason: "codex-compatible", command: "/fake/codex", version: "0.162.1" }); - const bearer = fakeChatGptJwt({ chatgpt_account_id: "fixture-refused-audio" }); - try { - await getValidMainAccountToken({ codexAuthStatus: async () => ({ authMethod: "chatgpt", authToken: bearer, requiresOpenaiAuth: true }) }); - await expect(getValidMainAccountToken({ codexAuthStatus: async () => ({ authMethod: "chatgpt", authToken: null, requiresOpenaiAuth: true }) })).rejects.toMatchObject({ reason: "reauth" }); - const selected = successor ? fakeChatGptJwt({ chatgpt_account_id: "fixture-refused-audio", jti: "successor" }) : bearer; - writeFileSync(join(codex.path, "auth.json"), JSON.stringify({ tokens: { access_token: selected, account_id: "fixture-refused-audio" } })); - const cfg = config(); - cfg.defaultProvider = "openai"; - cfg.providers = { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" } }; - saveConfig(cfg); - respond = req => { - expect(nativeMainRefusals.has(codex.path)).toBe(true); - expect(req.headers.get("authorization")).toBe(`Bearer ${selected}`); - return Response.json({ text: "synthetic successor transcript" }); - }; - const response = await request(); - await response.text(); - expect(response.status).toBe(successor ? 200 : 401); - expect(captured).toHaveLength(successor ? 1 : 0); - expect(nativeMainRefusals.has(codex.path)).toBe(true); - } finally { - nativeMainRefresh.setNativeMainRefreshResolutionForTests(null); - } - }); - test("stored Direct credentials never inherit a caller account ID", async () => { writeFileSync(join(codex.path, "auth.json"), JSON.stringify({ tokens: { access_token: "fixture-main-access" } })); clearMainAccountInfoCache(); From 93af71b75ca85644c363fc446af44d5ffe8a79c4 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:56:07 +0900 Subject: [PATCH 15/15] docs(codex): keep auth context within its line budget --- src/codex/auth-context.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index dabeb11b8e8..660e4f61026 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -1576,11 +1576,7 @@ async function resolveCodexAuthContextUnannotated( ); } if (fixedAccountId !== undefined) { - // Discovery ran for an exact `__main__` selector too, so its saved failure must not be - // downgraded into a blanket unavailability refusal here: propagate it the same way the - // no-selection path above does. After an entitlement-await recovery, stale failure wins - // while the credential is still unusable; a healthy account falls through to the normal - // checks and gets served. + // Preserve exact-main discovery errors only while the current credential remains unusable, as above. if (accountId === MAIN_CODEX_ACCOUNT_ID && mainDiscoveryFailure !== undefined && !getMainAccountCredentialStatus().usable) { throw new CodexAuthContextError(MAIN_CODEX_ACCOUNT_ID, mainDiscoveryFailure);