From 1d06d1a8906646b8dbf55661497d8ded619eed64 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 23:21:33 +0900 Subject: [PATCH 1/5] docs: record public native-account stabilization sources --- .../000_public_scope.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 devlog/_plan/261003_release_native_accounts/000_public_scope.md diff --git a/devlog/_plan/261003_release_native_accounts/000_public_scope.md b/devlog/_plan/261003_release_native_accounts/000_public_scope.md new file mode 100644 index 00000000000..cf16f255ee3 --- /dev/null +++ b/devlog/_plan/261003_release_native_accounts/000_public_scope.md @@ -0,0 +1,24 @@ +# Native-account release stabilization + +This unit tracks three public proposals for the next integration train. It records +source provenance and review order; it does not claim that the proposals have +landed or passed integration checks. + +| Public source | Proposed behavior | Observed source head | +| --- | --- | --- | +| [#6496](https://github.com/lidge-jun/opencodex/pull/6496) | Explain revoked-session quota failures while retaining the last-known plan. | `8b645854fcae802c93c516a10245f9c3b7eabb76` | +| [#6507](https://github.com/lidge-jun/opencodex/pull/6507) | Keep stored native-main credentials in Pool health on translated Claude turns. | `7ffd1a856957c320d139ff262b4adaf4dd3da07b` | +| [#6505](https://github.com/lidge-jun/opencodex/pull/6505) | Try the next combo target when the current ChatGPT plan cannot use a model. | `ce2c1a60fd6934ee84b1c52d4392a6128911d914` | + +All three proposals were open against `dev` when inspected on 2026-10-03. +Their author is @vadymhimself. Carries preserve source authorship and coauthor +trailers. Source heads and reviews must be rechecked before adoption. + +Review quota diagnostics first, then stored-main ownership and refresh, then +combo refusal behavior. Each coherent change receives its own regression checks, +independent security review where applicable, and applicable CI at the exact PR +head. The integration coordinator owns merging and final integrated verification. + +Unpublished security analysis and implementation plans remain in ignored scratch +space under the repository's security-working-notes policy. This public record +contains only scope already disclosed by the linked proposals. From 4f0a2e4ff7e28f0bdfdf8c63f1da89a6e7d1e416 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Sat, 3 Oct 2026 15:25:17 +0800 Subject: [PATCH 2/5] fix(codex): treat a revoked main session as needing sign-in and keep its last plan ChatGPT revokes every session of an account whose plan changes (for example Pro to Free) and answers usage reads with 401 `token_invalidated`, while the access token's `exp` is still in the future. The main-account probe treated that 401 as transient, so the account kept `needsReauth: false`, its plan and quota went to null, and nothing told the operator to sign in again. - `token_invalidated` joins the terminal auth codes (one list, now shared with the CLI projection). - The quota-refresh diagnostic carries the terminal provider code (`quotaRefresh.code`), a fixed vocabulary, so a caller can say why. - A main row quarantined by this call's 401 reports `reauthReason: "unauthorized"` instead of `refresh_failed`. - A failed usage read keeps the last-known plan instead of nulling it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../docs/reference/configuration/server.md | 5 +- scripts/test-layout/layout.json | 1 + src/codex/auth-api/account-list.ts | 14 +- src/codex/auth-api/main-account-probe.ts | 34 ++-- src/codex/auth-api/pool-quota-probe.ts | 6 +- src/codex/quota-refresh-outcome.ts | 19 ++- structure/dashboard-and-usage.md | 2 +- .../codex-integration/codex-auth-api.test.ts | 2 +- .../codex-main-token-invalidated.test.ts | 146 ++++++++++++++++++ .../main-account-hard-lock-recovery.test.ts | 4 +- tests/fixtures/test-layout-expected.json | 1 + 11 files changed, 205 insertions(+), 29 deletions(-) create mode 100644 tests/codex-integration/codex-main-token-invalidated.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 5a434cfa4c1..ddf1090a36f 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -156,7 +156,10 @@ only these fields when comparing network modes, rather than the full account lis | `internal_error` | An internal refresh step failed. | Only `http_error` includes `httpStatus`. Other statuses do not imply HTTP 0 or an -account entitlement problem. +account entitlement problem. An `http_error` may also include `code` when the provider +named a reason that only a new sign-in fixes (`token_invalidated`, `invalid_refresh_token`, +`invalid_workspace_selected`). A `token_invalidated` response indicates a revoked session; +the row then shows `needsReauth: true` with the last-known plan. ### Which proxy path is used? diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f9e9bda9b2f..4cc5b7e5756 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -12,6 +12,7 @@ "compaction-event-ownership.test.ts": "responses", "zed-retained-budget.test.ts": "providers", "jev-decision-scope.test.ts": "server", + "codex-main-token-invalidated.test.ts": "codex-integration", "codex-shim-path-readiness.test.ts": "codex-integration", "codex-shim-fnm.test.ts": "codex-integration", "claude-intercept-cleanup.test.ts": "claude-integration", diff --git a/src/codex/auth-api/account-list.ts b/src/codex/auth-api/account-list.ts index 5532b98cbaf..2848dde0d33 100644 --- a/src/codex/auth-api/account-list.ts +++ b/src/codex/auth-api/account-list.ts @@ -353,26 +353,28 @@ export async function listCodexAuthAccountsSnapshot( ? mainResult.hasCredential : getMainAccountCredentialPresence() ?? false; const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential; + const liveQuotaRefresh = mainSnapshotLive && mainResult.quotaRefreshGeneration !== undefined + && isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration) + ? mainResult.quotaRefresh : undefined; const mainNeedsReauth = mainMissingCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); const mainHealth = projectCodexAccountHealth({ accountId: MAIN_CODEX_ACCOUNT_ID, needsReauth: mainNeedsReauth, }); // The main row carries the same attribution as a pool row. Reaching this point without - // `mainMissingCredential` means the runtime reauth flag is what set `mainNeedsReauth`, so the - // cause is a refresh that did not complete. + // `mainMissingCredential` means the runtime reauth flag is what set `mainNeedsReauth`: a usage + // read this call rejected with 401 names that cause, anything else is a refresh that did not complete. const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential ? "missing_credential" : mainNeedsReauth - ? "refresh_failed" + ? liveQuotaRefresh?.status === "http_error" && liveQuotaRefresh.httpStatus === 401 + ? "unauthorized" : "refresh_failed" : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined; const main: CodexAuthAccountDto = { id: MAIN_CODEX_ACCOUNT_ID, email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login", plan: mainInfo.plan, - ...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined - && isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration) - ? { quotaRefresh: mainResult.quotaRefresh } : {}), + ...(liveQuotaRefresh ? { quotaRefresh: liveQuotaRefresh } : {}), logLabel: "main", isMain: true, paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index a8e6d69dbd6..e379b3df775 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -16,14 +16,15 @@ import { getMainAccountPlan, isMainAccountTokenVerifiablyLive, MAIN_CODEX_ACCOUN 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"; -import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome"; +import { isCodexTerminalAuthCode } from "../quota-refresh-outcome"; +import type { CodexQuotaRefreshOutcome, CodexTerminalAuthCode } from "../quota-refresh-outcome"; import { observeMainReserveRevocation } from "../reserve-availability"; import type { AdmissionLease } from "../../lib/admission"; import { nonEmptyPlan } from "./runtime-config"; import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing"; import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; -import { MAIN_TERMINAL_AUTH_CODES, readMainAuthErrorCode, currentQuotaDispatchSequence, nextQuotaDispatchSequence, isQuotaDispatchCurrent, publishQuotaDispatch } from "./pool-quota-probe"; +import { readMainAuthErrorCode, currentQuotaDispatchSequence, nextQuotaDispatchSequence, isQuotaDispatchCurrent, publishQuotaDispatch } from "./pool-quota-probe"; /** * Last reset-credit count this process parsed for the main account, tagged with the @@ -79,14 +80,18 @@ function sharedMainQuotaPacingEnabled(config: OcxConfig): boolean { * itself would make a real 401 permanently transient. */ export async function isTerminalMainAuthResponse(resp: Response, accessTokenLive: boolean): Promise { - if (resp.status === 401) { - if (!accessTokenLive) return true; - const code = await readMainAuthErrorCode(resp); - return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code); - } - if (resp.status !== 403) return false; + return (await classifyMainAuthResponse(resp, accessTokenLive)).terminal; +} + +/** As `isTerminalMainAuthResponse`, also naming the provider code that made it terminal. */ +async function classifyMainAuthResponse( + resp: Response, + accessTokenLive: boolean, +): Promise<{ terminal: boolean; code?: CodexTerminalAuthCode }> { + if (resp.status !== 401 && resp.status !== 403) return { terminal: false }; const code = await readMainAuthErrorCode(resp); - return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code); + if (isCodexTerminalAuthCode(code)) return { terminal: true, code }; + return { terminal: resp.status === 401 && !accessTokenLive }; } export interface MainResetQuotaProof { @@ -288,7 +293,8 @@ export async function fetchMainAccountInfoWhileOwned( const resp = admission.response; quotaPhase = "publish"; if (!resp.ok) { - const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive()); + const authFailure = await classifyMainAuthResponse(resp, isMainAccountTokenVerifiablyLive()); + const terminalAuthFailure = authFailure.terminal; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced); if (retried) return retried; if (!isQuotaDispatchCurrent(dispatchSequence) || !credentialIsCurrent()) { @@ -302,9 +308,13 @@ export async function fetchMainAccountInfoWhileOwned( if (diagnosticStillLive) quotaRefreshGeneration = captureMainAccountIdentityGeneration(); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration); } + // Keep the last-known plan visible: after a plan change revokes the session, the plan the + // account HAD is what tells the operator why it stopped working. return { - info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, - quotaRefresh: { status: "http_error", httpStatus: resp.status }, + info: { ...EMPTY_MAIN_ACCOUNT_INFO, plan: nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan()) }, + credentialChecked: true, hasCredential: true, + quotaRefresh: { status: "http_error", httpStatus: resp.status, + ...(authFailure.code ? { code: authFailure.code } : {}) }, quotaRefreshGeneration, }; } diff --git a/src/codex/auth-api/pool-quota-probe.ts b/src/codex/auth-api/pool-quota-probe.ts index d267f00540d..0faa8ddc2d0 100644 --- a/src/codex/auth-api/pool-quota-probe.ts +++ b/src/codex/auth-api/pool-quota-probe.ts @@ -15,14 +15,12 @@ import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing"; import { claimQuotaRecovery, fencePropagatedQuotaRecovery, quotaRecoveryTerminalFor, releaseQuotaRecovery, settleQuotaRecovery, settleQuotaRecoveryTerminal } from "../quota-401-recovery"; import { seedLoginRowsForTests } from "./login-state"; import { nonEmptyPlan } from "./runtime-config"; +import { CODEX_TERMINAL_AUTH_CODES } from "../quota-refresh-outcome"; export const POOL_CACHE_TTL = 5 * 60_000; export const POOL_QUOTA_REFRESH_CONCURRENCY = 4; -export const MAIN_TERMINAL_AUTH_CODES = new Set([ - "invalid_workspace_selected", - "invalid_refresh_token", -]); +export const MAIN_TERMINAL_AUTH_CODES: ReadonlySet = new Set(CODEX_TERMINAL_AUTH_CODES); export async function readMainAuthErrorCode(resp: Response): Promise { try { diff --git a/src/codex/quota-refresh-outcome.ts b/src/codex/quota-refresh-outcome.ts index fb059ef69d3..f43fc147542 100644 --- a/src/codex/quota-refresh-outcome.ts +++ b/src/codex/quota-refresh-outcome.ts @@ -1,8 +1,22 @@ /** Diagnostic only: never use this outcome as quota, entitlement, or admission evidence. */ export type CodexQuotaRefreshOutcome = - | { status: "http_error"; httpStatus: number } + /** `code` is set only for a provider code that requires a new sign-in (fixed vocabulary). */ + | { status: "http_error"; httpStatus: number; code?: CodexTerminalAuthCode } | { status: "ok" | "not_reported" | "timeout" | "network_error" | "invalid_response" | "internal_error" }; +/** Provider auth codes that only a new sign-in can recover. */ +export const CODEX_TERMINAL_AUTH_CODES = [ + "invalid_workspace_selected", + "invalid_refresh_token", + // ChatGPT revokes every session of an account whose plan changes (for example Pro to Free), + // while the access token's `exp` still lies in the future. + "token_invalidated", +] as const; +export type CodexTerminalAuthCode = typeof CODEX_TERMINAL_AUTH_CODES[number]; +export function isCodexTerminalAuthCode(code: unknown): code is CodexTerminalAuthCode { + return (CODEX_TERMINAL_AUTH_CODES as readonly unknown[]).includes(code); +} + /** The management response is untrusted at the CLI boundary; copy only the fixed vocabulary. */ export function projectCodexQuotaRefreshOutcome(value: unknown): CodexQuotaRefreshOutcome | undefined { if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; @@ -10,7 +24,8 @@ export function projectCodexQuotaRefreshOutcome(value: unknown): CodexQuotaRefre if (row.status === "http_error") { return typeof row.httpStatus === "number" && Number.isInteger(row.httpStatus) && row.httpStatus >= 100 && row.httpStatus <= 599 - ? { status: "http_error", httpStatus: row.httpStatus } + ? { status: "http_error", httpStatus: row.httpStatus, + ...(isCodexTerminalAuthCode(row.code) ? { code: row.code } : {}) } : undefined; } switch (row.status) { diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 24aed6d89ed..e3c276669dc 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -253,7 +253,7 @@ A private per-dispatch identity generation fences the diagnostic independently o quota metadata. Both snapshot and account DTO publication omit externally invalidated attempts; the generation itself is never serialized or stored in the quota cache. The CLI reconstructs the object using a fixed vocabulary and bounded numeric HTTP -status, so an unexpected management response cannot add raw upstream material. +status, so an unexpected management response cannot add raw upstream material. An `http_error` may carry `code` only from `CODEX_TERMINAL_AUTH_CODES` (`src/codex/quota-refresh-outcome.ts`; e.g. `token_invalidated` after a plan change revokes the session), and a failed read keeps the main row's last-known plan. > Decision record: [ADR-0078](decisions/ADR-0078-usage-accounting.md) diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 8d706fb949c..d6c715a2ce2 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -478,7 +478,7 @@ describe("main quota refresh diagnostics", () => { expect(reads).toBe(1); if (invalidation === "none") { const expected = outcome === "http" ? { status: "http_error", httpStatus: 503 } - : outcome === "terminal_http" ? { status: "http_error", httpStatus: 403 } + : outcome === "terminal_http" ? { status: "http_error", httpStatus: 403, code: "invalid_workspace_selected" } : { status: outcome === "body" ? "network_error" : "ok" }; expect(result.quotaRefresh).toEqual(expected); } else { diff --git a/tests/codex-integration/codex-main-token-invalidated.test.ts b/tests/codex-integration/codex-main-token-invalidated.test.ts new file mode 100644 index 00000000000..2c3541db22e --- /dev/null +++ b/tests/codex-integration/codex-main-token-invalidated.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearAccountNeedsReauth, clearMainAccountInfoCache, handleCodexAuthAPI, isAccountNeedsReauth, markAccountNeedsReauth, + type CodexAuthAccountDto, +} from "../../src/codex/auth-api"; +import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../../src/codex/main-account"; +import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { isTerminalMainAuthResponse } from "../../src/codex/auth-api/main-account-probe"; +import { projectCodexQuotaRefreshOutcome } from "../../src/codex/quota-refresh-outcome"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// ChatGPT revokes every session of an account whose plan changes (Pro to Free), and the usage +// endpoint then answers 401 `token_invalidated` while the access token's `exp` is still ahead. + +const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +const env = { home: process.env.OPENCODEX_HOME, codex: process.env.CODEX_HOME }; +const previousFetch = globalThis.fetch; +let dir = ""; + +function jwtWithExp(exp: number): string { + const enc = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${enc({ alg: "RS256", typ: "JWT" })}.${enc({ exp })}.sig`; +} + +const config = (): OcxConfig => ({ port: 10100, providers: {}, defaultProvider: "openai", codexAccounts: [] }); + +async function mainRow(runtimeConfig = config()): Promise { + const req = new Request("http://localhost/api/codex-auth/accounts?refresh=1"); + const data = await (await handleCodexAuthAPI(req, new URL(req.url), runtimeConfig))!.json() as { accounts: CodexAuthAccountDto[] }; + return data.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID); +} + +beforeEach(() => { + setIcaclsRunnerForTests(() => ICACLS_OK); + setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); + dir = mkdtempSync(join(tmpdir(), "ocx-main-token-invalidated-")); + const codexHome = join(dir, "codex"); + mkdirSync(codexHome, { recursive: true }); + process.env.OPENCODEX_HOME = dir; + process.env.CODEX_HOME = codexHome; + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + resetMainCodexAccountIdentityTrackingForTests(); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ + tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), account_id: "acct-main" }, + })); +}); + +afterEach(async () => { + globalThis.fetch = previousFetch; + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + if (env.home === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = env.home; + if (env.codex === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = env.codex; + await flushConfigDirHardeningForTests(); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + removeTreeWithRetry(dir); +}); + +test("a session revoked by a plan change needs sign-in, names the code and keeps the last plan", async () => { + globalThis.fetch = (async () => Response.json({ + plan_type: "pro", + rate_limit: { primary_window: { used_percent: 46 } }, + })) as typeof fetch; + await mainRow(); + + globalThis.fetch = (async () => Response.json({ + error: { message: "Your authentication token has been invalidated.", code: "token_invalidated" }, + status: 401, + }, { status: 401 })) as typeof fetch; + + expect(await mainRow()).toMatchObject({ + needsReauth: true, + reauthReason: "unauthorized", + plan: "pro", + quota: null, + quotaRefresh: { status: "http_error", httpStatus: 401, code: "token_invalidated" }, + }); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true); +}); + + +test("quota diagnostics expose only allowlisted terminal codes", () => { + expect(projectCodexQuotaRefreshOutcome({ status: "http_error", httpStatus: 401, + code: "token_invalidated", message: "private-upstream-marker" })).toEqual({ + status: "http_error", httpStatus: 401, code: "token_invalidated", + }); + for (const code of ["private-upstream-marker", "server_error", { code: "token_invalidated" }]) { + expect(projectCodexQuotaRefreshOutcome({ status: "http_error", httpStatus: 401, code })) + .toEqual({ status: "http_error", httpStatus: 401 }); + } +}); + +test("live bare and unknown-code 401s and terminal-looking 5xx stay transient", async () => { + for (const [status, code] of [[401, undefined], [401, "server_error"], [403, "permission_denied"], + [503, "token_invalidated"]] as const) { + expect(await isTerminalMainAuthResponse(Response.json({ error: { code } }, { status }), true)).toBe(false); + } + expect(await isTerminalMainAuthResponse(Response.json({ error: { code: "token_invalidated" } }, { status: 401 }), true)).toBe(true); +}); + + +test("a stale main 401 cannot attribute a later credential failure while pool probes settle", async () => { + saveCodexAccountCredential("delayed-pool", { + accessToken: "pool-access", refreshToken: "pool-refresh", + expiresAt: Date.now() + 3_600_000, chatgptAccountId: "pool-account", + }); + const runtimeConfig = { ...config(), codexAccounts: [{ id: "delayed-pool", email: "pool@example.test", isMain: false }] }; + let started!: () => void; + const poolStarted = new Promise(resolve => { started = resolve; }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + globalThis.fetch = (async (_input, init) => { + if (new Headers(init?.headers).get("authorization") === "Bearer pool-access") { + started(); + await gate; + return Response.json({ plan_type: "pro", rate_limit: { primary_window: { used_percent: 10 } } }); + } + return Response.json({ error: { code: "token_invalidated" } }, { status: 401 }); + }) as typeof fetch; + const pending = mainRow(runtimeConfig); + try { + await Promise.race([poolStarted, pending.then(() => { throw new Error("Pool probe never started"); })]); + clearMainAccountInfoCache(); + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + release(); + const row = await pending; + expect(row).toMatchObject({ needsReauth: true, reauthReason: "refresh_failed", plan: null }); + expect(row).not.toHaveProperty("quotaRefresh"); + } finally { + release(); + await pending; + } +}); diff --git a/tests/codex-integration/main-account-hard-lock-recovery.test.ts b/tests/codex-integration/main-account-hard-lock-recovery.test.ts index 14f61f78001..94cdefa694b 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -547,7 +547,7 @@ describe("main hard-lock background recovery", () => { } else { expect(getMainAccountInfoCache()).toBeNull(); expect(isAccountNeedsReauth(MAIN)).toBe(true); - expect(snapshot.quotaRefresh).toEqual({ status: "http_error", httpStatus: status }); + expect(snapshot.quotaRefresh).toEqual({ status: "http_error", httpStatus: status, code: "invalid_workspace_selected" }); } } else { expect(getMainAccountInfoCache()).toEqual(cached); @@ -610,7 +610,7 @@ describe("main hard-lock background recovery", () => { expect(reads).toBe(1); expect(getMainAccountInfoCache()).toBeNull(); expect(isAccountNeedsReauth(MAIN)).toBe(true); - expect(result.quotaRefresh).toEqual({ status: "http_error", httpStatus: status }); + expect(result.quotaRefresh).toEqual({ status: "http_error", httpStatus: status, code: "invalid_workspace_selected" }); } else { expect(getMainAccountInfoCache()).toEqual(info); expect(getMainPolicyQuota()).toEqual(policy); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ee3209290bb..f14b3fb1c91 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -9,6 +9,7 @@ "compaction-event-ownership.test.ts": "responses", "zed-retained-budget.test.ts": "providers", "jev-decision-scope.test.ts": "server", + "codex-main-token-invalidated.test.ts": "codex-integration", "codex-shim-path-readiness.test.ts": "codex-integration", "codex-shim-fnm.test.ts": "codex-integration", "claude-intercept-cleanup.test.ts": "claude-integration", From 29a9e91f400d24ac746a18dfe3af357f5da6dec3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 23:36:44 +0900 Subject: [PATCH 3/5] test: remove identical duplicate layout mappings --- scripts/test-layout/layout.json | 31 ------------------------ tests/fixtures/test-layout-expected.json | 31 ------------------------ 2 files changed, 62 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4cc5b7e5756..8641ce67610 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -58,42 +58,11 @@ "openai-chat-tool-call-id-remint.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", "usage-snapshot-digest-reuse.test.ts": "usage", - "release-desktop-scripts.test.ts": "ci-workflows", - "installed-gate-drivers.test.ts": "ci-workflows", - "gui-desktop-sidecar-script.test.ts": "gui", - "standalone-build-script.test.ts": "gui", - "standalone-service.test.ts": "service", - "standalone.test.ts": "lib", "worker-embed.test.ts": "lib", "worker-embed-coverage.test.ts": "lib", - "server-combo-held-response.test.ts": "server", - "key-attribution.test.ts": "usage", "jev-stats.test.ts": "usage", - "provider-send-path-import.test.ts": "server", - "socks5-fetch.test.ts": "lib", - "socks5-upload-lifecycle.test.ts": "lib", - "provider-egress.test.ts": "lib", - "provider-egress-outbound.test.ts": "providers", - "provider-egress-fetch.test.ts": "responses", - "provider-egress-management-validation.test.ts": "server", - "start-args.test.ts": "cli", - "start-ownership-publication.test.ts": "cli", - "responses-core-modules.test.ts": "responses", "responses-devin-401-replay.test.ts": "responses", "responses-grok-devin-preflight.test.ts": "responses", - "responses-passthrough-transient-policy.test.ts": "responses", - "responses-spend-ledger-wiring.test.ts": "responses", - "responses-send-budget-errors.test.ts": "responses", - "responses-4546-incident-regression.test.ts": "responses", - "chat-responses-control-integration.test.ts": "responses", - "coding-agent-tool-result-images.test.ts": "adapters", - "cold-spawn-warmup.test.ts": "ci-workflows", - "stepfun-provider.test.ts": "providers", - "warmup-registration.test.ts": "ci-workflows", - "hub-usage.test.ts": "server", - "client-hub-usage.test.ts": "clients", - "cli-usage-hub.test.ts": "cli", - "cli-companion.test.ts": "cli", "deepseek-quota-currency.test.ts": "providers", "mimo-token-plan-capacity.test.ts": "providers", "command-code-tool-text-prose-split.test.ts": "providers", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f14b3fb1c91..29d80cd01e5 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -71,42 +71,11 @@ "openai-chat-tool-call-id-remint.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", "usage-snapshot-digest-reuse.test.ts": "usage", - "release-desktop-scripts.test.ts": "ci-workflows", - "installed-gate-drivers.test.ts": "ci-workflows", - "gui-desktop-sidecar-script.test.ts": "gui", - "standalone-build-script.test.ts": "gui", - "standalone-service.test.ts": "service", - "standalone.test.ts": "lib", "worker-embed.test.ts": "lib", "worker-embed-coverage.test.ts": "lib", - "server-combo-held-response.test.ts": "server", - "key-attribution.test.ts": "usage", "jev-stats.test.ts": "usage", - "provider-send-path-import.test.ts": "server", - "socks5-fetch.test.ts": "lib", - "socks5-upload-lifecycle.test.ts": "lib", - "provider-egress.test.ts": "lib", - "provider-egress-outbound.test.ts": "providers", - "provider-egress-fetch.test.ts": "responses", - "provider-egress-management-validation.test.ts": "server", - "start-args.test.ts": "cli", - "start-ownership-publication.test.ts": "cli", - "responses-core-modules.test.ts": "responses", "responses-devin-401-replay.test.ts": "responses", "responses-grok-devin-preflight.test.ts": "responses", - "responses-passthrough-transient-policy.test.ts": "responses", - "responses-spend-ledger-wiring.test.ts": "responses", - "responses-send-budget-errors.test.ts": "responses", - "responses-4546-incident-regression.test.ts": "responses", - "chat-responses-control-integration.test.ts": "responses", - "coding-agent-tool-result-images.test.ts": "adapters", - "cold-spawn-warmup.test.ts": "ci-workflows", - "stepfun-provider.test.ts": "providers", - "warmup-registration.test.ts": "ci-workflows", - "hub-usage.test.ts": "server", - "client-hub-usage.test.ts": "clients", - "cli-usage-hub.test.ts": "cli", - "cli-companion.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", From 9f661c72cbf2f8d4147a6d53cea8340b8b03310f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 23:47:06 +0900 Subject: [PATCH 4/5] test: preserve known plan in stale quota coverage expectations --- tests/providers/provider-quota.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index fd4ee2710f7..d4f1afa8944 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -2365,7 +2365,7 @@ describe("fetchProviderQuotaReports", () => { includedAccounts: 0, staleQuotaAccounts: 1, missingQuotaAccounts: 1, - unknownPlanAccounts: 1, + unknownPlanAccounts: 0, // A failed main read retains its previously observed Plus plan. incomplete: true, currentAccount: { plan: "prolite", quota: null }, }); From 2a665a16af8b205f9512923039ca4126c10554cd Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 00:18:52 +0900 Subject: [PATCH 5/5] fix(codex): attribute reauth only to terminal probe evidence --- src/codex/auth-api/account-list.ts | 6 +++--- src/codex/auth-api/main-account-probe.ts | 3 +++ structure/dashboard-and-usage.md | 2 +- .../codex-main-token-invalidated.test.ts | 19 +++++++++++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/codex/auth-api/account-list.ts b/src/codex/auth-api/account-list.ts index 2848dde0d33..79b472574df 100644 --- a/src/codex/auth-api/account-list.ts +++ b/src/codex/auth-api/account-list.ts @@ -362,12 +362,12 @@ export async function listCodexAuthAccountsSnapshot( needsReauth: mainNeedsReauth, }); // The main row carries the same attribution as a pool row. Reaching this point without - // `mainMissingCredential` means the runtime reauth flag is what set `mainNeedsReauth`: a usage - // read this call rejected with 401 names that cause, anything else is a refresh that did not complete. + // A diagnostic status is not causal evidence: a live bare 401 can be transient while an + // earlier refresh failure still owns the quarantine. Only a current terminal probe attributes it. const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential ? "missing_credential" : mainNeedsReauth - ? liveQuotaRefresh?.status === "http_error" && liveQuotaRefresh.httpStatus === 401 + ? mainResult.terminalAuthFailure === true && liveQuotaRefresh?.status === "http_error" ? "unauthorized" : "refresh_failed" : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined; const main: CodexAuthAccountDto = { diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index e379b3df775..80472a77929 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -108,6 +108,8 @@ export interface MainAccountInfoFetchResult { quotaRefresh?: CodexQuotaRefreshOutcome; /** Internal dispatch fence for diagnostics only; never copied into a public DTO or cache. */ quotaRefreshGeneration?: number; + /** This current-credential usage response supplied terminal auth evidence; never public. */ + terminalAuthFailure?: true; /** Whether this attempt safely inspected the physical native-main credential. */ credentialChecked: boolean; /** Meaningful only when credentialChecked is true. */ @@ -316,6 +318,7 @@ export async function fetchMainAccountInfoWhileOwned( quotaRefresh: { status: "http_error", httpStatus: resp.status, ...(authFailure.code ? { code: authFailure.code } : {}) }, quotaRefreshGeneration, + ...(terminalAuthFailure ? { terminalAuthFailure: true as const } : {}), }; } quotaPhase = "body"; diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index e3c276669dc..d4a6409c712 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -253,7 +253,7 @@ A private per-dispatch identity generation fences the diagnostic independently o quota metadata. Both snapshot and account DTO publication omit externally invalidated attempts; the generation itself is never serialized or stored in the quota cache. The CLI reconstructs the object using a fixed vocabulary and bounded numeric HTTP -status, so an unexpected management response cannot add raw upstream material. An `http_error` may carry `code` only from `CODEX_TERMINAL_AUTH_CODES` (`src/codex/quota-refresh-outcome.ts`; e.g. `token_invalidated` after a plan change revokes the session), and a failed read keeps the main row's last-known plan. +status, so an unexpected management response cannot add raw upstream material. An `http_error` may carry `code` only from `CODEX_TERMINAL_AUTH_CODES` (`src/codex/quota-refresh-outcome.ts`; e.g. `token_invalidated` after a plan change revokes the session), and a failed read keeps the main row's last-known plan. Reauth attribution uses current terminal-auth evidence behind the same generation fence; a transient HTTP401 diagnostic does not replace an existing refresh-failure cause. > Decision record: [ADR-0078](decisions/ADR-0078-usage-accounting.md) diff --git a/tests/codex-integration/codex-main-token-invalidated.test.ts b/tests/codex-integration/codex-main-token-invalidated.test.ts index 2c3541db22e..2554a0500ec 100644 --- a/tests/codex-integration/codex-main-token-invalidated.test.ts +++ b/tests/codex-integration/codex-main-token-invalidated.test.ts @@ -144,3 +144,22 @@ test("a stale main 401 cannot attribute a later credential failure while pool pr await pending; } }); + + +test.each([ + { status: 401, live: true, code: undefined, reason: "refresh_failed" }, + { status: 401, live: true, code: "server_error", reason: "refresh_failed" }, + { status: 401, live: false, code: undefined, reason: "unauthorized" }, + { status: 403, live: true, code: "invalid_workspace_selected", reason: "unauthorized" }, + { status: 403, live: true, code: "permission_denied", reason: "refresh_failed" }, +])("reauth attribution follows terminal probe evidence: %j", async ({ status, live, code, reason }) => { + writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ + tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + (live ? 3600 : -60)), account_id: "acct-main" }, + })); + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + globalThis.fetch = (async () => Response.json({ error: { code } }, { status })) as typeof fetch; + const row = await mainRow(); + expect(row).toMatchObject({ needsReauth: true, reauthReason: reason, + quotaRefresh: { status: "http_error", httpStatus: status } }); + expect(row).not.toHaveProperty("terminalAuthFailure"); +});