From e4851ff0ec4d6156b582cb82e742eba6a467d310 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 7 Sep 2026 14:29:58 +0900 Subject: [PATCH 1/2] fix(codex): settle reset-credit aliases canonically (cherry picked from commit 6f20c3d08cd202e43b8679de33674133bdc7f34c) --- docs-site/src/content/docs/reference/management-api.md | 7 +++++++ src/codex/auth-api.ts | 3 ++- tests/codex-integration/codex-auth-api.test.ts | 7 +++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 238d23b336..dafb51dc7a 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -427,6 +427,13 @@ manager. Its routes are: | `POST /api/codex-auth/login/cancel` | Cancel a Codex login flow | — | | `GET /api/codex-auth/login-status` | Poll a flow or account login state. A completed new-account flow includes `catalogRefreshPending: true` only when recovery is needed. | Unknown flows report `expired`; no active flow reports `idle` | +For reset-credit consumption, a different `operationId` supplied while the same physical +account has an unfinished operation joins that operation as an alias. Its retry uses the +original upstream request ID and records the outcome under that same identity, so later +requests with the original ID or a known alias replay the stored result without another +consume request. A previously unseen ID supplied after settlement starts a new explicit +redemption; clients retrying an existing action should keep its ID. + If a new account config row is saved but credential setup cannot finish, OAuth `login-status` reports `status: "error"` with `code: "codex_credential_persistence_failed"`, `accountId`, `needsReauth: true`, and optional diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 6768c4fa09..7ced31b3df 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -2341,7 +2341,7 @@ export async function handleCodexAuthAPI( const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { // The ledger keys manual operations by the *physical* ChatGPT account, which is // only known after the auth wrapper resolves credentials. Open here, not earlier. - const identity = requestedOperationId === undefined + let identity = requestedOperationId === undefined ? undefined : { accountId, @@ -2377,6 +2377,7 @@ export async function handleCodexAuthAPI( return response; } // Canonical id, which an alias join may map to an earlier caller id. + identity = { ...identity, operationId: opened.operationId }; idempotencyKey = opened.operationId; } else { idempotencyKey = crypto.randomUUID(); diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 04e19d49fa..0fea9ee659 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -3280,6 +3280,13 @@ describe("codex-auth API", () => { config, ); expect(retried!.status).toBe(200); + const replayed = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-alias", operationId: OTHER_OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(replayed!.status).toBe(200); + expect(await replayed!.json()).toEqual({ code: "reset", replayed: true }); expect(upstream.redeemRequestIds).toEqual([OP_ID, OP_ID]); } finally { globalThis.fetch = previousFetch; From 62412d38606851f7cace76360f3c5737db9cae20 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 08:11:35 +0900 Subject: [PATCH 2/2] test(codex): prove alias failures update pending canonical operations Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../codex-integration/codex-auth-api.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 0fea9ee659..610ce7d9e4 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -29,6 +29,7 @@ import { 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 { openManualResetCreditOperation } from "../../src/codex/reset-credit-operation-ledger"; import { clearCodexUpstreamHealth, clearThreadAccountMap, @@ -3293,6 +3294,48 @@ describe("codex-auth API", () => { } }); + for (const failure of ["throw", "non-2xx", "unknown-code"] as const) { + test(`an alias marks a pending canonical operation ambiguous after ${failure}`, async () => { + const config = makeConfig(); + const accountId = "pool-pending-alias"; + const chatgptAccountId = "physical-pending-alias"; + seedPoolAccount(config, { id: accountId, email: "pending@example.test", chatgptAccountId }); + expect(openManualResetCreditOperation({ accountId, chatgptAccountId, operationId: OP_ID })) + .toMatchObject({ kind: "execute", operationId: OP_ID }); + const readOperation = () => { + const database = new Database(join(TEST_DIR, "config-mutation.sqlite"), { readonly: true }); + try { + return database.query<{ account_key: string; operation_id: string; state: string; code: string | null }, []>( + "SELECT account_key, operation_id, state, code FROM reset_credit_operations WHERE operation_kind = 'manual'", + ).get(); + } finally { + database.close(); + } + }; + const pending = readOperation(); + expect(pending).toMatchObject({ operation_id: OP_ID, state: "pending", code: null }); + const upstream = stubUpstream(() => { + if (failure === "throw") throw new Error("fixture consume failure"); + return failure === "non-2xx" + ? new Response("fixture unavailable", { status: 503 }) + : Response.json({ code: "weird" }); + }); + try { + const response = await handleCodexAuthAPI( + consumeRequest({ accountId, operationId: OTHER_OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(response!.status).toBe(failure === "throw" ? 500 : failure === "non-2xx" ? 503 : 200); + expect(readOperation()).toEqual({ ...pending!, state: "ambiguous" }); + expect(upstream.redeemRequestIds).toEqual([OP_ID]); + expect(getCodexAccountCredential(accountId)?.chatgptAccountId).toBe(chatgptAccountId); + } finally { + globalThis.fetch = previousFetch; + } + }); + } + test("an unknown upstream code stays ambiguous instead of settling the ledger", async () => { const config = makeConfig(); seedPoolAccount(config, { id: "pool-weird", email: "weird@example.test" });