From ad7cd9a410e5e857e3b088899184e140cb8e419d Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:09:05 +0900 Subject: [PATCH 1/3] fix(codex): expose bounded main quota refresh outcomes Carry #3658 onto the current credential-generation and Reserve publication boundary. Keep diagnostics ephemeral and omit stale identity results. Source-commit: 035dfc2c77e3825e507442dff3f98a6631ee0e99 Co-authored-by: Ingwannu --- .../docs/reference/cli/providers-accounts.md | 22 +++ src/cli/account-api.ts | 8 +- src/codex/auth-api.ts | 36 +++- src/codex/quota-refresh-outcome.ts | 27 +++ structure/05_gui-and-management-api.md | 14 ++ tests/cli/cli-account.test.ts | 58 ++++++ .../codex-integration/codex-auth-api.test.ts | 170 +++++++++++++++++- 7 files changed, 327 insertions(+), 8 deletions(-) create mode 100644 src/codex/quota-refresh-outcome.ts diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index be4028cb57..cc2471a527 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -58,6 +58,28 @@ Use `--api-key` or an OAuth login for anything secret. ## Authentication +### Diagnosing missing main-account quota + +`ocx account list openai --quota --refresh --json` includes a `quotaRefresh` object on +the main-account row when that operation attempts a WHAM usage read. The existing +`GET /api/codex-auth/accounts?refresh=1` response exposes the same diagnostic. + +Its `status` is `ok`, `not_reported` (no parseable quota in a successful response), +`http_error`, `timeout`, `network_error`, `invalid_response`, or `internal_error`. +Only `http_error` includes a numeric `httpStatus`. No raw response, error message, +credential, or account identifier is included in this object. Cache-only reads, +credential deferrals, and invalidated account snapshots omit it; older servers +also omit it. Absence is not proof of success. A non-success HTTP status remains +`http_error` even if its error body cannot be read; `timeout` and `network_error` +describe failures before headers or while reading a successful response. + +A valid login does not guarantee that this separate usage request succeeds. +These categories do not change authentication, account selection, or quota +freshness rules, and do not turn unknown quota into zero usage. This diagnostic +currently covers the native main account, not pool-account refreshes. When +reporting missing quota, share the category and HTTP status rather than credential +files or a raw network capture. + ### `ocx login ` Start the provider's registered login flow. OAuth providers open a browser and store auto-refreshed diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index b21f72b85d..76c3456c9c 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -8,6 +8,7 @@ import { runningProxyUpdateHeaders } from "../oauth/login-cli"; import { isPublicOAuthProvider } from "../oauth/index"; import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers/registry"; import type { OcxConfig } from "../types"; +import { projectCodexQuotaRefreshOutcome, type CodexQuotaRefreshOutcome } from "../codex/quota-refresh-outcome"; export type AccountType = "codex" | "oauth" | "api-key"; @@ -24,6 +25,7 @@ export interface AccountRow { /** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */ priority?: number; quota?: CodexQuotaDto | null; + quotaRefresh?: CodexQuotaRefreshOutcome; /** * Whether the pool is holding this account out of rotation. * @@ -237,6 +239,7 @@ interface CodexAccountDto { needsReauth?: boolean; priority?: number; quota?: CodexQuotaDto | null; + quotaRefresh?: unknown; paused?: boolean; } @@ -299,7 +302,10 @@ export async function fetchCodexRows( needsReauth: a.needsReauth, priority: typeof a.priority === "number" ? a.priority : 0, paused: a.paused === true, - ...(includeQuota ? { quota: projectQuota(a.quota) } : {}), + ...(includeQuota ? { + quota: projectQuota(a.quota), + quotaRefresh: projectCodexQuotaRefreshOutcome(a.quotaRefresh), + } : {}), })); return { rows, activeId, autoSwitchThreshold, status: 200 }; } diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 865711ba86..b778cb91f8 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -105,6 +105,7 @@ import { type MainAccountInfo, } from "./main-account-cache"; export { clearMainAccountInfoCache } from "./main-account-cache"; +import type { CodexQuotaRefreshOutcome } from "./quota-refresh-outcome"; import { getMainAccountHardLockStatus, type MainAccountHardLockStatus } from "./main-account-hard-lock"; import { observeMainReserveRevocation } from "./reserve-availability"; import { maskEmail } from "../lib/privacy"; @@ -777,6 +778,8 @@ async function readMainAuthErrorCode(resp: Response): Promise { interface MainAccountInfoFetchResult { info: MainAccountInfo; + /** Ephemeral result of this attempt, omitted when no WHAM request was made. */ + quotaRefresh?: CodexQuotaRefreshOutcome; /** Whether this attempt safely inspected the physical native-main credential. */ credentialChecked: boolean; /** Meaningful only when credentialChecked is true. */ @@ -792,12 +795,14 @@ interface MainAccountInfoFetchResult { export interface MainAccountInfoSnapshot { info: MainAccountInfo; mainIdentityGeneration: number; + quotaRefresh?: CodexQuotaRefreshOutcome; } export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promise { const result = await fetchMainAccountInfoAttempt(forceRefresh, 1); return { info: result.info, + ...(result.quotaRefresh ? { quotaRefresh: result.quotaRefresh } : {}), mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(), }; } @@ -900,11 +905,15 @@ async function fetchMainAccountInfoWhileOwned( ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) : undefined; const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration(); + // Keep diagnostics separate from authentication and freshness policy. Never serialize errors. + const quotaSignal = AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS); + let quotaPhase: "request" | "body" | "decode" | "publish" = "request"; try { const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id }, - signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS), + signal: quotaSignal, }); + quotaPhase = "publish"; if (!resp.ok) { const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive()); const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); @@ -913,9 +922,14 @@ async function fetchMainAccountInfoWhileOwned( clearMainAccountInfoCache(); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration); } - return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; + return { + info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, + quotaRefresh: { status: "http_error", httpStatus: resp.status }, + }; } + quotaPhase = "body"; const data = (await resp.json()) as WhamUsageResponse; + quotaPhase = "publish"; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); if (retried) return retried; // A delayed response from a replaced bearer cannot revoke a newer Reserve grant, @@ -924,10 +938,12 @@ async function fetchMainAccountInfoWhileOwned( && matchesMainQuotaCredential(tokens.access_token, tokens.account_id)) { observeMainReserveRevocation(data, mainQuotaWriter); } + quotaPhase = "decode"; const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan()); const usage = { ...data, ...(plan ? { plan_type: plan } : {}) }; const quota = parseUsageQuota(usage); const policyQuota = parseMainPolicyUsageQuota(usage); + quotaPhase = "publish"; const freshResetCredits = quota?.resetCredits; // 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. @@ -957,14 +973,24 @@ async function fetchMainAccountInfoWhileOwned( } return { info: result, + quotaRefresh: { status: quota ? "ok" : "not_reported" }, credentialChecked: true, hasCredential: true, ...(quota ? { freshQuota: quota } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), }; - } catch { + } catch (error) { const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); - return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; + if (retried) return retried; + let status: CodexQuotaRefreshOutcome["status"] = "internal_error"; + if ((quotaPhase === "request" || quotaPhase === "body") && quotaSignal.aborted) status = "timeout"; + else if (quotaPhase === "request") status = "network_error"; + else if (quotaPhase === "body") status = error instanceof SyntaxError ? "invalid_response" : "network_error"; + else if (quotaPhase === "decode") status = "invalid_response"; + return { + info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, + quotaRefresh: { status }, + }; } } @@ -1061,6 +1087,7 @@ export interface CodexAuthAccountDto { healthSummary: string; healthAction?: string; quotaProbeSkipped?: true; + quotaRefresh?: CodexQuotaRefreshOutcome; mainAccountHardLock?: MainAccountHardLockStatus; } @@ -1769,6 +1796,7 @@ export async function listCodexAuthAccountsSnapshot( id: MAIN_CODEX_ACCOUNT_ID, email: maskEmail(mainInfo.email) ?? "Codex App login", plan: mainInfo.plan, + ...(mainSnapshotLive && mainResult.quotaRefresh ? { quotaRefresh: mainResult.quotaRefresh } : {}), logLabel: "main", isMain: true, paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), diff --git a/src/codex/quota-refresh-outcome.ts b/src/codex/quota-refresh-outcome.ts new file mode 100644 index 0000000000..fb059ef69d --- /dev/null +++ b/src/codex/quota-refresh-outcome.ts @@ -0,0 +1,27 @@ +/** Diagnostic only: never use this outcome as quota, entitlement, or admission evidence. */ +export type CodexQuotaRefreshOutcome = + | { status: "http_error"; httpStatus: number } + | { status: "ok" | "not_reported" | "timeout" | "network_error" | "invalid_response" | "internal_error" }; + +/** 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; + const row = value as Record; + 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 } + : undefined; + } + switch (row.status) { + case "ok": + case "not_reported": + case "timeout": + case "network_error": + case "invalid_response": + case "internal_error": + return { status: row.status }; + default: + return undefined; + } +} diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 7a38460fc4..c8fb3bc101 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -362,6 +362,20 @@ include credits-only and measured-zero readings, unsupported, unobserved, explic unavailable-with-last-good. Forced account/key enrichment settles before its control reports a completed check, and provider-report waiters are bound to the exact refresh epoch. +Main-account WHAM refresh diagnostics are an ephemeral `quotaRefresh` outcome carried +from `fetchMainAccountInfoWhileOwned` to the generation-checked account DTO and the +opt-in CLI quota JSON. They are not persisted or consumed by admission/rotation. +The CLI reconstructs the object using a fixed vocabulary and bounded numeric HTTP +status, so an unexpected management response cannot add raw upstream material. + +[Decision Log] +- 목적과 의도: Explain missing main-account quota without confusing a working login with a successful WHAM read. +- 기존 구현 및 제약 조건: HTTP failures and body/transport exceptions returned identical null metadata; existing authentication and freshness policy must remain unchanged. +- 검토한 주요 대안: Copy raw errors, infer plan/quota, reuse stale evidence, or add a bounded diagnostic outcome. +- 선택한 방식: Carry a non-persisted fixed category and optional numeric HTTP status through the existing management and CLI read paths. +- 다른 대안 대신 이 방식을 선택한 이유: It gives reporters actionable evidence without disclosing payloads, changing permissions, or introducing another cache. +- 장점, 단점 및 영향: Main-account failures become distinguishable; root-cause repair and pool diagnostics remain separate work, and clients must tolerate an absent field. + `src/usage/log.ts` writes append-only JSONL to `~/.opencodex/usage.jsonl` with file mode `0o600`. An opt-in shadow-call rewrite persists the bounded, redacted original helper model as `shadowCallRewrittenFrom`, so helper traffic remains identifiable after restart without storing diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 58146c6383..9f8acf3ab1 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { cmdAccount, classifyAccount, formatAccountTable, type AccountDeps } from "../../src/cli/account"; import type { AccountStdin } from "../../src/cli/account-api"; +import { projectCodexQuotaRefreshOutcome } from "../../src/codex/quota-refresh-outcome"; import { printSubcommandUsage } from "../../src/cli/help"; import { DEFAULT_ACCOUNT_PRIORITY, @@ -584,6 +585,63 @@ afterEach(() => { }); describe("ocx account CLI (issue #180 matrix)", () => { + test("main quota diagnostics survive opt-in JSON without copying upstream data", async () => { + codexAccounts = [{ id: "__main__", isMain: true, quota: null, + quotaRefresh: { status: "http_error", httpStatus: 503, message: RAW_SENTINEL } }]; + const result = await run(["list", "openai", "--quota", "--refresh", "--json"]); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout).accounts[0].quotaRefresh).toEqual({ status: "http_error", httpStatus: 503 }); + expect(result.output).not.toContain(RAW_SENTINEL); + const ordinary = await run(["list", "openai", "--json"]); + expect(JSON.parse(ordinary.stdout).accounts[0]).not.toHaveProperty("quotaRefresh"); + }); + + test.each([ + { status: "private-status-canary" }, + { status: "http_error", httpStatus: "503" }, + { status: "http_error", httpStatus: 999 }, + { status: "http_error", httpStatus: 503.5 }, + null, + ])("invalid quota diagnostic is omitted: %j", async quotaRefresh => { + codexAccounts = [{ id: "__main__", isMain: true, quota: null, quotaRefresh }]; + const result = await run(["list", "openai", "--quota", "--json"]); + expect(JSON.parse(result.stdout).accounts[0]).not.toHaveProperty("quotaRefresh"); + expect(result.output).not.toContain("canary"); + }); + + test.each(["ok", "not_reported", "timeout", "network_error", "invalid_response", "internal_error"])( + "quota JSON reconstructs %s without extra fields", async status => { + codexAccounts = [{ id: "__main__", isMain: true, quota: null, + quotaRefresh: { status, httpStatus: 503, accountId: RAW_SENTINEL, nested: { token: RAW_SENTINEL } } }]; + const result = await run(["list", "openai", "--quota", "--json"]); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout).accounts[0].quotaRefresh).toEqual({ status }); + expect(result.output).not.toContain(RAW_SENTINEL); + }, + ); + + test.each([ + undefined, null, [], "private-diagnostic-canary", 0, true, {}, + { status: "private-status-canary" }, + { status: "http_error" }, + { status: "http_error", httpStatus: NaN }, + { status: "http_error", httpStatus: Infinity }, + { status: "http_error", httpStatus: 99 }, + { status: "http_error", httpStatus: 600 }, + { status: "http_error", httpStatus: 403.5 }, + { status: "http_error", httpStatus: "403" }, + ])("diagnostic projector rejects invalid values: %j", value => { + expect(projectCodexQuotaRefreshOutcome(value)).toBeUndefined(); + }); + + test.each([100, 599])("diagnostic projector bounds HTTP status %s and strips extra fields", httpStatus => { + const source = { status: "http_error", httpStatus, token: RAW_SENTINEL }; + const projected = projectCodexQuotaRefreshOutcome(source); + expect(projected).toEqual({ status: "http_error", httpStatus }); + expect(projected).not.toBe(source); + expect(JSON.stringify(projected)).not.toContain(RAW_SENTINEL); + }); + test("1: list renders all three account families, main alias, and padded columns", async () => { const result = await run(["list"]); diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 1cba651ee5..3191a8e6d2 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -14,7 +14,7 @@ import { handleCodexAuthAPI, updateAccountQuota, getAccountQuota, checkAccountIdCollision, getMainChatgptAccountId, markAccountNeedsReauth, isAccountNeedsReauth, clearAccountNeedsReauth, clearAccountQuota, - clearMainAccountInfoCache, maskEmail, fetchMainAccountInfo, + clearMainAccountInfoCache, maskEmail, fetchMainAccountInfo, fetchMainAccountInfoSnapshot, clearCodexQuotaPrimeState, primeCodexPoolQuotas, seedCodexAuthAdmissionForTests, type CodexAuthAccountDto, listCodexAuthAccounts, @@ -27,6 +27,7 @@ import { saveCodexAccountCredential, } from "../../src/codex/account-store"; import * as accountStoreModule from "../../src/codex/account-store"; +import * as reserveAvailabilityModule from "../../src/codex/reserve-availability"; import { clearCodexUpstreamHealth, clearThreadAccountMap, @@ -255,6 +256,164 @@ function seedPoolAccount( }); } +describe("main quota refresh diagnostics", () => { + function writeMain(): void { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), account_id: "fixture-account" }, + })); + } + + test.each([401, 403, 429, 503])("HTTP %s is diagnostic, not proof of sign-out", async status => { + writeMain(); + globalThis.fetch = (async () => new Response("private-upstream-canary", { status })) as typeof fetch; + const main = (await listCodexAuthAccounts(makeConfig(), true)).find(row => row.isMain); + expect(main).toMatchObject({ quotaRefresh: { status: "http_error", httpStatus: status }, + plan: null, quota: null, hasCredential: true, needsReauth: false }); + expect(JSON.stringify(main)).not.toContain("private-upstream-canary"); + }); + + test.each(["network_error", "invalid_response", "not_reported", "body_reset"] as const)("classifies %s without serializing errors", async kind => { + writeMain(); + globalThis.fetch = (async () => { + if (kind === "network_error") throw new TypeError("private-network-canary"); + if (kind === "body_reset") return new Response(new ReadableStream({ + start(controller) { controller.error(new TypeError("private-stream-canary")); }, + })); + return kind === "invalid_response" ? new Response("private-json-canary") : Response.json({}); + }) as typeof fetch; + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toEqual({ status: kind === "body_reset" ? "network_error" : kind }); + expect(result.info.quota).toBeNull(); + expect(JSON.stringify(result)).not.toContain("canary"); + }); + + test("timeout reports only the fixed category", async () => { + writeMain(); + const signal = AbortSignal.abort(new DOMException("private-timeout-canary", "TimeoutError")); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(signal); + globalThis.fetch = (async () => { throw signal.reason; }) as typeof fetch; + try { + expect((await fetchMainAccountInfoSnapshot(true)).quotaRefresh).toEqual({ status: "timeout" }); + } finally { timeout.mockRestore(); } + }); + + test.each([401, 403])("HTTP %s takes precedence over an unreadable error body", async status => { + writeMain(); + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(controller) { controller.error(new TypeError("private-error-body-canary")); }, + }), { status })) as typeof fetch; + const main = (await listCodexAuthAccounts(makeConfig(), true)).find(row => row.isMain); + expect(main).toMatchObject({ quotaRefresh: { status: "http_error", httpStatus: status }, + quota: null, hasCredential: true, needsReauth: false }); + expect(JSON.stringify(main)).not.toContain("canary"); + }); + + test("HTTP status survives an aborted error body", async () => { + writeMain(); + const controller = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + globalThis.fetch = (async () => { + controller.abort(new DOMException("private-http-timeout-canary", "TimeoutError")); + return new Response(new ReadableStream({ + start(stream) { stream.error(controller.signal.reason); }, + }), { status: 403 }); + }) as typeof fetch; + try { + expect((await fetchMainAccountInfoSnapshot(true)).quotaRefresh) + .toEqual({ status: "http_error", httpStatus: 403 }); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + } finally { timeout.mockRestore(); } + }); + + test("timeout while reading a successful body reports timeout", async () => { + writeMain(); + const controller = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(stream) { + controller.abort(new DOMException("private-body-timeout-canary", "TimeoutError")); + stream.error(controller.signal.reason); + }, + }))) as typeof fetch; + try { + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toEqual({ status: "timeout" }); + expect(JSON.stringify(result)).not.toContain("canary"); + } finally { timeout.mockRestore(); } + }); + + test("Reserve observer failure is internal even if the request timer has expired", async () => { + writeMain(); + const controller = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + const observer = spyOn(reserveAvailabilityModule, "observeMainReserveRevocation") + .mockImplementation(() => { + controller.abort(); + throw new Error("private-reserve-publication-canary"); + }); + globalThis.fetch = (async () => Response.json({ plan_type: "plus", + rate_limit: { primary_window: { used_percent: 37 } } })) as typeof fetch; + try { + const result = await fetchMainAccountInfoSnapshot(true); + expect(observer).toHaveBeenCalledTimes(1); + expect(result.quotaRefresh).toEqual({ status: "internal_error" }); + expect(result.info.quota).toBeNull(); + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)).toBeNull(); + expect(JSON.stringify(result)).not.toContain("canary"); + } finally { observer.mockRestore(); timeout.mockRestore(); } + }); + + test("decoded invalid usage is distinct from a response body failure", async () => { + writeMain(); + const observer = spyOn(reserveAvailabilityModule, "observeMainReserveRevocation") + .mockImplementation(() => {}); + globalThis.fetch = (async () => Response.json(null)) as typeof fetch; + try { + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toEqual({ status: "invalid_response" }); + expect(result.info.quota).toBeNull(); + } finally { observer.mockRestore(); } + }); + + test("missing credentials omit diagnostics without issuing a request", async () => { + let reads = 0; + globalThis.fetch = (async () => { reads++; return Response.json({}); }) as typeof fetch; + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toBeUndefined(); + expect(reads).toBe(0); + }); + + test("exhausted identity retry does not publish either account's diagnostic", async () => { + writeMain(); + let reads = 0; + globalThis.fetch = (async () => { + reads++; + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), + account_id: `replacement-${reads}` }, + })); + return Response.json({ rate_limit: { primary_window: { used_percent: 37 } } }); + }) as typeof fetch; + const result = await fetchMainAccountInfoSnapshot(true); + expect(reads).toBe(2); + expect(result.quotaRefresh).toBeUndefined(); + expect(result.info.quota).toBeNull(); + }); + + test("fresh success reports ok while cache reuse never claims another probe", async () => { + writeMain(); + let reads = 0; + globalThis.fetch = (async () => { reads++; return Response.json({ plan_type: "plus", + rate_limit: { primary_window: { used_percent: 37 } } }); }) as typeof fetch; + const fresh = await fetchMainAccountInfoSnapshot(true); + expect(fresh.quotaRefresh).toEqual({ status: "ok" }); + const cached = await fetchMainAccountInfoSnapshot(false); + expect(cached.quotaRefresh).toBeUndefined(); + expect(cached.info.quota).toEqual(fresh.info.quota); + expect(reads).toBe(1); + }); +}); + beforeEach(() => { resetLifecycleDrainStateForTests(); previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -432,6 +591,7 @@ describe("codex-auth API", () => { releasePool(); const response = await pending; const body = await response?.json() as { accounts: CodexAuthAccountDto[] }; + expect(body.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).not.toHaveProperty("quotaRefresh"); expect(body.accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).toMatchObject({ email: "Codex App login", plan: null, @@ -462,7 +622,9 @@ describe("codex-auth API", () => { clearMainAccountInfoCache(); let drain = acquireNativeMainProfileDrain("credential-snapshot-cold-cache"); try { - expect((await listMain()).hasCredential).toBe(true); + const main = await listMain(); + expect(main.hasCredential).toBe(true); + expect(main).not.toHaveProperty("quotaRefresh"); } finally { drain?.release(); } @@ -472,7 +634,9 @@ describe("codex-auth API", () => { expect((await listMain()).hasCredential).toBe(false); drain = acquireNativeMainProfileDrain("credential-snapshot-warm-cache"); try { - expect((await listMain()).hasCredential).toBe(false); + const main = await listMain(); + expect(main.hasCredential).toBe(false); + expect(main).not.toHaveProperty("quotaRefresh"); } finally { drain?.release(); } From a22cd5d766f407a94d975bb93b058e129cf0039c Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:15:38 +0900 Subject: [PATCH 2/3] fix(codex): fence quota diagnostics at request dispatch --- src/codex/auth-api.ts | 22 +++- structure/05_gui-and-management-api.md | 3 + tests/cli/cli-account.test.ts | 26 ++-- .../codex-integration/codex-auth-api.test.ts | 121 ++++++++++++++++-- 4 files changed, 151 insertions(+), 21 deletions(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index b778cb91f8..51e3fed303 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -780,6 +780,8 @@ interface MainAccountInfoFetchResult { info: MainAccountInfo; /** Ephemeral result of this attempt, omitted when no WHAM request was made. */ quotaRefresh?: CodexQuotaRefreshOutcome; + /** Internal dispatch fence for diagnostics only; never copied into a public DTO or cache. */ + quotaRefreshGeneration?: number; /** Whether this attempt safely inspected the physical native-main credential. */ credentialChecked: boolean; /** Meaningful only when credentialChecked is true. */ @@ -802,7 +804,9 @@ export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promis const result = await fetchMainAccountInfoAttempt(forceRefresh, 1); return { info: result.info, - ...(result.quotaRefresh ? { quotaRefresh: result.quotaRefresh } : {}), + ...(result.quotaRefresh && result.quotaRefreshGeneration !== undefined + && isMainAccountIdentityGenerationLive(result.quotaRefreshGeneration) + ? { quotaRefresh: result.quotaRefresh } : {}), mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(), }; } @@ -908,6 +912,7 @@ async function fetchMainAccountInfoWhileOwned( // Keep diagnostics separate from authentication and freshness policy. Never serialize errors. const quotaSignal = AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS); let quotaPhase: "request" | "body" | "decode" | "publish" = "request"; + let quotaRefreshGeneration = captureMainAccountIdentityGeneration(); try { const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id }, @@ -919,12 +924,16 @@ async function fetchMainAccountInfoWhileOwned( const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); if (retried) return retried; if (terminalAuthFailure) { + // Account for this attempt's own synchronous invalidation, never prior external drift. + const diagnosticStillLive = isMainAccountIdentityGenerationLive(quotaRefreshGeneration); clearMainAccountInfoCache(); + if (diagnosticStillLive) quotaRefreshGeneration = captureMainAccountIdentityGeneration(); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration); } return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, quotaRefresh: { status: "http_error", httpStatus: resp.status }, + quotaRefreshGeneration, }; } quotaPhase = "body"; @@ -932,6 +941,11 @@ async function fetchMainAccountInfoWhileOwned( quotaPhase = "publish"; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); if (retried) return retried; + quotaPhase = "decode"; + if (data === null || typeof data !== "object" || Array.isArray(data)) { + throw new Error("Invalid WHAM usage object"); + } + quotaPhase = "publish"; // A delayed response from a replaced bearer cannot revoke a newer Reserve grant, // even in the same workspace or after an A→B→A credential transition. if (mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() @@ -974,6 +988,7 @@ async function fetchMainAccountInfoWhileOwned( return { info: result, quotaRefresh: { status: quota ? "ok" : "not_reported" }, + quotaRefreshGeneration, credentialChecked: true, hasCredential: true, ...(quota ? { freshQuota: quota } : {}), @@ -990,6 +1005,7 @@ async function fetchMainAccountInfoWhileOwned( return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, quotaRefresh: { status }, + quotaRefreshGeneration, }; } } @@ -1796,7 +1812,9 @@ export async function listCodexAuthAccountsSnapshot( id: MAIN_CODEX_ACCOUNT_ID, email: maskEmail(mainInfo.email) ?? "Codex App login", plan: mainInfo.plan, - ...(mainSnapshotLive && mainResult.quotaRefresh ? { quotaRefresh: mainResult.quotaRefresh } : {}), + ...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined + && isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration) + ? { quotaRefresh: mainResult.quotaRefresh } : {}), logLabel: "main", isMain: true, paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index c8fb3bc101..f4e32adb35 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -365,6 +365,9 @@ completed check, and provider-report waiters are bound to the exact refresh epoc Main-account WHAM refresh diagnostics are an ephemeral `quotaRefresh` outcome carried from `fetchMainAccountInfoWhileOwned` to the generation-checked account DTO and the opt-in CLI quota JSON. They are not persisted or consumed by admission/rotation. +A private per-dispatch identity generation fences the diagnostic independently of ordinary +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. diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 9f8acf3ab1..f6792933f1 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -621,16 +621,22 @@ describe("ocx account CLI (issue #180 matrix)", () => { ); test.each([ - undefined, null, [], "private-diagnostic-canary", 0, true, {}, - { status: "private-status-canary" }, - { status: "http_error" }, - { status: "http_error", httpStatus: NaN }, - { status: "http_error", httpStatus: Infinity }, - { status: "http_error", httpStatus: 99 }, - { status: "http_error", httpStatus: 600 }, - { status: "http_error", httpStatus: 403.5 }, - { status: "http_error", httpStatus: "403" }, - ])("diagnostic projector rejects invalid values: %j", value => { + { value: undefined }, + { value: null }, + { value: [] }, + { value: "private-diagnostic-canary" }, + { value: 0 }, + { value: true }, + { value: {} }, + { value: { status: "private-status-canary" } }, + { value: { status: "http_error" } }, + { value: { status: "http_error", httpStatus: NaN } }, + { value: { status: "http_error", httpStatus: Infinity } }, + { value: { status: "http_error", httpStatus: 99 } }, + { value: { status: "http_error", httpStatus: 600 } }, + { value: { status: "http_error", httpStatus: 403.5 } }, + { value: { status: "http_error", httpStatus: "403" } }, + ])("diagnostic projector rejects invalid values: %j", ({ value }) => { expect(projectCodexQuotaRefreshOutcome(value)).toBeUndefined(); }); diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 3191a8e6d2..04e19d49fa 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -28,6 +28,7 @@ import { } from "../../src/codex/account-store"; import * as accountStoreModule from "../../src/codex/account-store"; import * as reserveAvailabilityModule from "../../src/codex/reserve-availability"; +import { getMainAccountInfoCache, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; import { clearCodexUpstreamHealth, clearThreadAccountMap, @@ -257,10 +258,12 @@ function seedPoolAccount( } describe("main quota refresh diagnostics", () => { - function writeMain(): void { + function writeMain(accountId = "fixture-account"): string { + const accessToken = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ - tokens: { access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), account_id: "fixture-account" }, + tokens: { access_token: accessToken, account_id: accountId }, })); + return accessToken; } test.each([401, 403, 429, 503])("HTTP %s is diagnostic, not proof of sign-out", async status => { @@ -363,16 +366,116 @@ describe("main quota refresh diagnostics", () => { } finally { observer.mockRestore(); timeout.mockRestore(); } }); - test("decoded invalid usage is distinct from a response body failure", async () => { - writeMain(); - const observer = spyOn(reserveAvailabilityModule, "observeMainReserveRevocation") - .mockImplementation(() => {}); - globalThis.fetch = (async () => Response.json(null)) as typeof fetch; - try { + test.each([false, true])("decoded null is invalid with a matching Reserve slot=%s", async matchingSlot => { + const accessToken = writeMain(); + reconcileMainCodexAccountRuntimeState(); + const token = { accessToken, chatgptAccountId: "fixture-account" }; + const writer = observeMainQuotaCredential(accessToken, token.chatgptAccountId); + let capabilityReads = 0; + let passiveReads = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + if (new Headers(init?.headers).get("x-openai-codex-luna-reserve") === "1") { + capabilityReads++; + return Response.json({ + rate_limit: { allowed: false }, + rate_limit_upsell: { banner_type: "luna_reserve" }, + additional_rate_limits: [{ limit_name: "gpt-reserve", rate_limit: { allowed: true } }], + }); + } + passiveReads++; + return Response.json(null); + }) as typeof fetch; + const authorization = matchingSlot + ? await reserveAvailabilityModule.getMainReserveAuthorization({ token, writer, observeOrdinaryQuota: () => {} }) + : undefined; + if (matchingSlot) { + expect(authorization).toBeDefined(); + expect(reserveAvailabilityModule.isMainReserveAuthorizationLive(authorization, token)).toBe(true); + } + const result = await fetchMainAccountInfoSnapshot(true); + expect(result.quotaRefresh).toEqual({ status: "invalid_response" }); + expect(result.info.quota).toBeNull(); + expect(capabilityReads).toBe(matchingSlot ? 1 : 0); + expect(passiveReads).toBe(1); + if (matchingSlot) { + expect(reserveAvailabilityModule.isMainReserveAuthorizationLive(authorization, token)).toBe(true); + } + }); + + test.each([{ value: [] }, { value: "invalid-usage" }, { value: 7 }, { value: false }])( + "decoded non-object usage is invalid: %j", async ({ value }) => { + writeMain(); + globalThis.fetch = (async () => Response.json(value)) as typeof fetch; const result = await fetchMainAccountInfoSnapshot(true); expect(result.quotaRefresh).toEqual({ status: "invalid_response" }); expect(result.info.quota).toBeNull(); - } finally { observer.mockRestore(); } + }, + ); + + test.each( + (["snapshot", "accounts"] as const).flatMap(surface => + (["none", "same_id", "round_trip"] as const).flatMap(invalidation => + (["http", "terminal_http", "body", "ok"] as const).map(outcome => ({ surface, invalidation, outcome })), + ), + ), + )("diagnostic dispatch fence: %j", async ({ surface, invalidation, outcome }) => { + writeMain(); + let started!: () => void; + const dispatched = new Promise(resolve => { started = resolve; }); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let reads = 0; + globalThis.fetch = (async () => { + reads++; + started(); + await gate; + if (outcome === "http") return new Response("private-http-canary", { status: 503 }); + if (outcome === "terminal_http") { + return Response.json({ detail: { code: "invalid_workspace_selected" } }, { status: 403 }); + } + if (outcome === "body") return new Response(new ReadableStream({ + start(controller) { controller.error(new TypeError("private-body-canary")); }, + })); + return Response.json({ rate_limit: { primary_window: { used_percent: 37 } } }); + }) as typeof fetch; + const pending = surface === "snapshot" + ? fetchMainAccountInfoSnapshot(true) + : listCodexAuthAccounts(makeConfig(), true).then(rows => rows.find(row => row.isMain)!); + try { + await Promise.race([dispatched, pending.then(() => { throw new Error("Main WHAM never dispatched"); })]); + if (invalidation === "same_id") { + clearMainAccountInfoCache(); + } else if (invalidation === "round_trip") { + writeMain("other-account"); + reconcileMainCodexAccountRuntimeState(); + writeMain(); + reconcileMainCodexAccountRuntimeState(); + } + release(); + const result = await pending; + expect(reads).toBe(1); + if (invalidation === "none") { + const expected = outcome === "http" ? { status: "http_error", httpStatus: 503 } + : outcome === "terminal_http" ? { status: "http_error", httpStatus: 403 } + : { status: outcome === "body" ? "network_error" : "ok" }; + expect(result.quotaRefresh).toEqual(expected); + } else { + expect(result).not.toHaveProperty("quotaRefresh"); + } + // The existing terminal-auth decision still applies, independently of diagnostic freshness. + if (outcome === "terminal_http") expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + expect(result).not.toHaveProperty("quotaRefreshGeneration"); + expect(JSON.stringify(result)).not.toContain("quotaRefreshGeneration"); + expect(JSON.stringify(result)).not.toContain("canary"); + const cached = getMainAccountInfoCache(); + if (cached) { + expect(cached).not.toHaveProperty("quotaRefreshGeneration"); + expect(cached).not.toHaveProperty("quotaRefresh"); + } + } finally { + release(); + await pending; + } }); test("missing credentials omit diagnostics without issuing a request", async () => { From b2a0a22aa9582b554a296ddfcd2ee1ae0f2516c5 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:44:26 +0900 Subject: [PATCH 3/3] test(clients): bound client-state probe subprocesses --- tests/clients/client-connect.test.ts | 95 ++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 18 deletions(-) diff --git a/tests/clients/client-connect.test.ts b/tests/clients/client-connect.test.ts index 05dff4a708..b1f8fc4d19 100644 --- a/tests/clients/client-connect.test.ts +++ b/tests/clients/client-connect.test.ts @@ -3,8 +3,7 @@ import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { join } from "node:path"; import { downloadClientCatalog, exchangeConnectPairingGrant, @@ -14,8 +13,40 @@ import { } from "../../src/client/hub-client"; import { handleConnectCommand } from "../../src/cli/connect"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoRoot as findRepoRoot } from "../helpers/repo-root"; +import { INTERNAL_DEADLINE_MS } from "../helpers/test-budget"; -const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); +const repoRoot = findRepoRoot(); + +class ClientStateProbeError extends Error { + constructor( + readonly pid: number, + readonly status: number | null, + readonly signal: NodeJS.Signals | null, + readonly timedOut: boolean, + ) { + // Do not include the child script, environment, stdout or stderr in failure output. + super(`Client state probe ${timedOut ? "timed out" : "failed"} (status=${status}, signal=${signal})`); + this.name = "ClientStateProbeError"; + } +} + +function readStateProbe(script: string, home: string, timeoutMs = INTERNAL_DEADLINE_MS) { + const child = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, OPENCODEX_HOME: home }, + encoding: "utf8", + timeout: timeoutMs, + killSignal: "SIGKILL", + }); + if (child.error || child.status !== 0 || child.signal !== null) { + throw new ClientStateProbeError( + child.pid, child.status, child.signal, + (child.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT", + ); + } + return JSON.parse(child.stdout.trim().split("\n").at(-1) ?? "{}"); +} function readyBody(protocol = 1, minimumClientProtocol = 1) { return { @@ -41,21 +72,49 @@ describe("remote hub client boundary", () => { console.log(JSON.stringify(readClientConnectionState())); `; const home = mkdtempSync(join(tmpdir(), "ocx-hub-role-")); - const readState = () => { - const child = spawnSync(process.execPath, ["--eval", readScript], { - cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: home }, - encoding: "utf8", - }); - return JSON.parse(child.stdout.trim().split("\n").at(-1) ?? "{}"); - }; - writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10190, runtimeRole: "hub" })); - expect(readState().kind).toBe("disconnected"); - // Hub role WITH a client block stays mismatched (the honest conflict). - writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10190, runtimeRole: "hub", client: { serverUrl: "https://hub.example.test" } })); - expect(readState().kind).toBe("mismatched"); - removeTreeWithRetry(home); - }); + try { + writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10190, runtimeRole: "hub" })); + expect(readStateProbe(readScript, home).kind).toBe("disconnected"); + // Hub role WITH a client block stays mismatched (the honest conflict). + writeFileSync(join(home, "config.json"), JSON.stringify({ port: 10190, runtimeRole: "hub", client: { serverUrl: "https://hub.example.test" } })); + expect(readStateProbe(readScript, home).kind).toBe("mismatched"); + } finally { + removeTreeWithRetry(home); + } + }, 35_000); // Two 15s child deadlines plus setup and cleanup, below the CI 60s cap. + + test("state probe kills a stalled child before parsing its output", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-state-probe-stall-")); + const startedPath = join(home, "probe-started"); + const script = ` + const fs = require("node:fs"); + fs.writeFileSync(require("node:path").join(process.env.OPENCODEX_HOME, "probe-started"), String(process.pid)); + fs.writeSync(1, "not-json"); + setInterval(() => {}, 1000); + `; + try { + const startedAt = performance.now(); + let failure: unknown; + try { readStateProbe(script, home, 2_000); } + catch (error) { failure = error; } + expect(performance.now() - startedAt).toBeLessThan(10_000); + expect(failure).toBeInstanceOf(ClientStateProbeError); + if (!(failure instanceof ClientStateProbeError)) throw new Error("Expected bounded child failure"); + expect(failure.timedOut).toBe(true); + expect(failure.status).toBeNull(); + expect(failure.signal).toBe("SIGKILL"); + expect(failure.message).not.toContain("not-json"); + expect(Number(readFileSync(startedPath, "utf8"))).toBe(failure.pid); + // spawnSync must reap this exact child, not merely return while it remains alive. + let exitCode: string | undefined; + try { process.kill(failure.pid, 0); } + catch (error) { exitCode = (error as NodeJS.ErrnoException).code; } + expect(exitCode).toBe("ESRCH"); + } finally { + removeTreeWithRetry(home); + } + }, 10_000); + test("canonicalizes origin and terminal /v1 only", () => { expect(normalizeHubOrigin("https://hub.example.test/v1")).toBe("https://hub.example.test"); expect(normalizeHubOrigin("https://hub.example.test/v1/")).toBe("https://hub.example.test");