From 2008e55c74b306797dea2e2d7ad8d5c40430dae8 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:30:21 +0900 Subject: [PATCH 1/7] feat(codex): persist reset-credit operation identity --- src/codex/auth-api.ts | 44 +- src/codex/reset-credit-consume.ts | 128 +++++ src/codex/reset-credit-operation-ledger.ts | 464 ++++++++++++++++++ src/codex/reset-credit-recovery.ts | 19 +- src/config.ts | 10 + tests/codex-auth-api.test.ts | 36 +- tests/codex-reset-credit-consume.test.ts | 108 ++++ ...odex-reset-credit-operation-ledger.test.ts | 313 ++++++++++++ 8 files changed, 1093 insertions(+), 29 deletions(-) create mode 100644 src/codex/reset-credit-consume.ts create mode 100644 src/codex/reset-credit-operation-ledger.ts create mode 100644 tests/codex-reset-credit-consume.test.ts create mode 100644 tests/codex-reset-credit-operation-ledger.test.ts diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index c233630aa2..18d25cfb32 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -106,6 +106,10 @@ import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../p import { providerCodexAccountMode } from "../providers/registry"; import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBody } from "../lib/bounded-body"; import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort"; +import { + CodexResetCreditConsumeError, + consumeCodexResetCredit, +} from "./reset-credit-consume"; import { oauthAccountHealthFields, projectCodexAccountHealth, @@ -332,11 +336,6 @@ function safeResetCreditsDto(input: unknown): { credits: { granted_at: string; e }; } -function safeResetCreditConsumeDto(input: unknown): { code: string } { - const obj = typeof input === "object" && input !== null ? input as Record : {}; - return { code: typeof obj.code === "string" ? obj.code : "unknown" }; -} - type ResetCreditJsonRead = | { ok: true; value: unknown } | { ok: false }; @@ -1701,25 +1700,12 @@ export async function handleCodexAuthAPI( try { const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { - const idempotencyKey = crypto.randomUUID(); - const resp = await fetch( - "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", - { - method: "POST", - headers: { - Authorization: `Bearer ${auth.accessToken}`, - "ChatGPT-Account-Id": auth.chatgptAccountId, - "Content-Type": "application/json", - }, - body: JSON.stringify({ redeem_request_id: idempotencyKey }), - signal: AbortSignal.timeout(10_000), - }, - ); - if (!resp.ok) { - await resp.body?.cancel().catch(() => {}); - return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); - } - const result = safeResetCreditConsumeDto(await resp.json()); + const result = await consumeCodexResetCredit({ + accessToken: auth.accessToken, + chatgptAccountId: auth.chatgptAccountId, + operationId: crypto.randomUUID(), + signal: req.signal, + }); // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage // and return remaining only when that refresh freshly parsed available_count. // Do not fall back to a preserved cached resetCredits (failed/omitted refresh). @@ -1743,7 +1729,7 @@ export async function handleCodexAuthAPI( : {}), }); } - return jsonResponse(result); + return jsonResponse({ code: result.code }); }); return operation.ok ? operation.value : operation.response; } catch (e) { @@ -1752,6 +1738,14 @@ export async function handleCodexAuthAPI( response.headers.set("Retry-After", "1"); return response; } + if (req.signal.aborted) { + return jsonResponse({ error: "Reset credit consume cancelled by client" }, 499); + } + if (e instanceof CodexResetCreditConsumeError) { + return e.reason === "upstream" && e.upstreamStatus !== undefined + ? jsonResponse({ error: `Upstream error ${e.upstreamStatus}` }, e.upstreamStatus) + : jsonResponse({ error: "Invalid upstream reset-credit response" }, 502); + } return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit consume failed" }, 500); } } diff --git a/src/codex/reset-credit-consume.ts b/src/codex/reset-credit-consume.ts new file mode 100644 index 0000000000..355d133968 --- /dev/null +++ b/src/codex/reset-credit-consume.ts @@ -0,0 +1,128 @@ +import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBody } from "../lib/bounded-body"; +import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort"; +import { + isCodexResetCreditOperationId, + type CodexResetCreditConsumeCode, +} from "./reset-credit-recovery"; + +const RESET_CREDIT_CONSUME_URL = + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"; +const RESET_CREDIT_CONSUME_TIMEOUT_MS = 10_000; +const CONSUME_CODES: ReadonlySet = new Set([ + "reset", + "already_redeemed", + "nothing_to_reset", + "no_credit", +]); + +export type CodexResetCreditConsumeResult = Readonly<{ + code: CodexResetCreditConsumeCode; + operationId: string; +}>; + +export class CodexResetCreditConsumeError extends Error { + constructor( + readonly reason: "invalid-input" | "upstream" | "invalid-response" | "transport", + readonly upstreamStatus?: number, + options?: ErrorOptions, + ) { + super( + upstreamStatus === undefined + ? `Reset-credit consume failed: ${reason}` + : `Reset-credit consume upstream returned ${upstreamStatus}`, + options, + ); + this.name = "CodexResetCreditConsumeError"; + } +} + +export interface CodexResetCreditConsumeInput { + accessToken: string; + chatgptAccountId: string; + operationId: string; + signal: AbortSignal; +} + +export interface CodexResetCreditConsumeDeps { + fetchImpl?: typeof fetch; + timeoutMs?: number; +} + +function ownConsumeCode(value: unknown): CodexResetCreditConsumeCode | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + if (!Object.prototype.hasOwnProperty.call(value, "code")) return undefined; + const code = (value as { code?: unknown }).code; + return typeof code === "string" && CONSUME_CODES.has(code) + ? code as CodexResetCreditConsumeCode + : undefined; +} + +function validateInput(input: CodexResetCreditConsumeInput): void { + if (!isCodexResetCreditOperationId(input.operationId) + || typeof input.accessToken !== "string" + || input.accessToken.length === 0 + || typeof input.chatgptAccountId !== "string" + || input.chatgptAccountId.length === 0 + || !(input.signal instanceof AbortSignal)) { + throw new CodexResetCreditConsumeError("invalid-input"); + } +} + +export async function consumeCodexResetCredit( + input: CodexResetCreditConsumeInput, + deps: CodexResetCreditConsumeDeps = {}, +): Promise { + validateInput(input); + if (input.signal.aborted) throw input.signal.reason; + const linked = signalWithTimeout(deps.timeoutMs ?? RESET_CREDIT_CONSUME_TIMEOUT_MS, input.signal); + let detachBodyAbort = () => {}; + try { + let response: Response; + try { + response = await (deps.fetchImpl ?? fetch)(RESET_CREDIT_CONSUME_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${input.accessToken}`, + "ChatGPT-Account-Id": input.chatgptAccountId, + "Content-Type": "application/json", + }, + body: JSON.stringify({ redeem_request_id: input.operationId }), + signal: linked.signal, + }); + } catch (cause) { + throw new CodexResetCreditConsumeError("transport", undefined, { cause }); + } + detachBodyAbort = cancelBodyOnAbort(response.body, linked.signal); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw new CodexResetCreditConsumeError("upstream", response.status); + } + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isSafeInteger(declaredLength) + && declaredLength >= 0 + && declaredLength > BOUNDED_BODY_MAX_BYTES) { + await response.body?.cancel().catch(() => {}); + throw new CodexResetCreditConsumeError("invalid-response"); + } + let value: unknown; + try { + const body = await readBoundedResponseBody(response, { + signal: linked.signal, + maxBytes: BOUNDED_BODY_MAX_BYTES, + fatalUtf8: true, + }); + if (!body.displaySafe || body.truncated || !body.text.trim()) { + throw new Error("invalid body"); + } + value = JSON.parse(body.text) as unknown; + } catch (cause) { + throw new CodexResetCreditConsumeError("invalid-response", undefined, { cause }); + } + const code = ownConsumeCode(value); + if (!code) throw new CodexResetCreditConsumeError("invalid-response"); + return Object.freeze({ code, operationId: input.operationId }); + } finally { + detachBodyAbort(); + linked.cleanup(); + } +} diff --git a/src/codex/reset-credit-operation-ledger.ts b/src/codex/reset-credit-operation-ledger.ts new file mode 100644 index 0000000000..79e559c220 --- /dev/null +++ b/src/codex/reset-credit-operation-ledger.ts @@ -0,0 +1,464 @@ +import { createHash, randomUUID } from "node:crypto"; +import { chmodSync } from "node:fs"; +import { Database } from "bun:sqlite"; +import { prepareConfigMutationDatabasePathForWrite } from "../config"; +import { initializeConfigGeneration } from "./generation"; +import { + isCodexResetCreditOperationId, + type CodexResetCreditConsumeCode, + type CodexResetCreditRecoveryGeneration, +} from "./reset-credit-recovery"; +import { isValidCodexAccountId } from "./account-id"; + +export const MAX_RESET_CREDIT_OPERATION_ACCOUNTS = 128; +const ACCOUNT_KEY_PATTERN = /^[0-9a-f]{64}$/; +const TERMINAL_CODES: ReadonlySet = new Set([ + "reset", + "already_redeemed", + "nothing_to_reset", + "no_credit", +]); +const STATES: ReadonlySet = new Set(["pending", "ambiguous", "confirmed", "stopped"]); + +type ResetCreditOperationState = "pending" | "ambiguous" | "confirmed" | "stopped"; + +type ResetCreditOperationRecord = Readonly<{ + accountKey: string; + credentialGeneration: number; + exhaustionGeneration: number; + operationId: string; + state: ResetCreditOperationState; + code?: CodexResetCreditConsumeCode; + createdAt: number; + updatedAt: number; +}>; + +type ResetCreditOperationRow = { + account_key: unknown; + credential_generation: unknown; + exhaustion_generation: unknown; + operation_id: unknown; + state: unknown; + code: unknown; + created_at: unknown; + updated_at: unknown; +}; + +export type OpenResetCreditOperationResult = + | Readonly<{ kind: "execute"; operationId: string; resumed: boolean }> + | Readonly<{ kind: "terminal"; operationId: string; code: CodexResetCreditConsumeCode }> + | Readonly<{ kind: "stale-generation" | "unresolved-prior-generation" | "capacity" | "unavailable" }>; + +export type UpdateResetCreditOperationResult = + | Readonly<{ kind: "updated" }> + | Readonly<{ kind: "mismatch" | "unavailable" }>; + +const TABLE_NAME = "reset_credit_operations"; +const CREATE_TABLE = `CREATE TABLE main.reset_credit_operations ( + account_key TEXT PRIMARY KEY, + credential_generation INTEGER NOT NULL CHECK (credential_generation >= 0), + exhaustion_generation INTEGER NOT NULL CHECK (exhaustion_generation >= 0), + operation_id TEXT NOT NULL, + state TEXT NOT NULL, + code TEXT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= created_at) + ) STRICT, WITHOUT ROWID`; +const EXPECTED_SCHEMA_SQL = CREATE_TABLE.replace("main.", ""); +const SELECT_ALL = ` + SELECT account_key, credential_generation, exhaustion_generation, operation_id, + state, code, created_at, updated_at + FROM main.reset_credit_operations + ORDER BY account_key + LIMIT ${MAX_RESET_CREDIT_OPERATION_ACCOUNTS + 1}`; +const SELECT_BY_KEY = ` + SELECT account_key, credential_generation, exhaustion_generation, operation_id, + state, code, created_at, updated_at + FROM main.reset_credit_operations + WHERE account_key = ? + LIMIT 2`; +const INSERT_RECORD = ` + INSERT INTO main.reset_credit_operations ( + account_key, credential_generation, exhaustion_generation, operation_id, + state, code, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`; +const REPLACE_RECORD = ` + UPDATE main.reset_credit_operations + SET credential_generation = ?, exhaustion_generation = ?, operation_id = ?, + state = ?, code = ?, created_at = ?, updated_at = ? + WHERE account_key = ?`; +const UPDATE_RECORD = ` + UPDATE main.reset_credit_operations + SET state = ?, code = ?, updated_at = ? + WHERE account_key = ? AND credential_generation = ? + AND exhaustion_generation = ? AND operation_id = ?`; + +type SchemaObjectRow = { + type: unknown; + name: unknown; + tbl_name: unknown; + sql: unknown; +}; + +type TableListRow = { + schema: unknown; + name: unknown; + type: unknown; + ncol: unknown; + wr: unknown; + strict: unknown; +}; + +type TableColumnRow = { + cid: unknown; + name: unknown; + type: unknown; + notnull: unknown; + dflt_value: unknown; + pk: unknown; + hidden: unknown; +}; + +const EXPECTED_COLUMNS = Object.freeze([ + Object.freeze({ name: "account_key", type: "TEXT", notnull: 1, pk: 1 }), + Object.freeze({ name: "credential_generation", type: "INTEGER", notnull: 1, pk: 0 }), + Object.freeze({ name: "exhaustion_generation", type: "INTEGER", notnull: 1, pk: 0 }), + Object.freeze({ name: "operation_id", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "state", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "code", type: "TEXT", notnull: 0, pk: 0 }), + Object.freeze({ name: "created_at", type: "INTEGER", notnull: 1, pk: 0 }), + Object.freeze({ name: "updated_at", type: "INTEGER", notnull: 1, pk: 0 }), +]); + +function accountKey(accountId: string): string { + return createHash("sha256").update(`codex-reset-credit-operation\0${accountId}`).digest("hex"); +} + +function isGenerationNumber(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function validateGeneration(generation: CodexResetCreditRecoveryGeneration): void { + if (!isValidCodexAccountId(generation.accountId) + || !isGenerationNumber(generation.credentialGeneration) + || !isGenerationNumber(generation.exhaustionGeneration)) { + throw new TypeError("invalid reset-credit recovery generation"); + } +} + +function parseRecord(row: ResetCreditOperationRow | null): ResetCreditOperationRecord | undefined { + if (!row) return undefined; + const state = row.state; + const code = row.code; + if (typeof row.account_key !== "string" || !ACCOUNT_KEY_PATTERN.test(row.account_key) + || !isGenerationNumber(row.credential_generation) + || !isGenerationNumber(row.exhaustion_generation) + || !isCodexResetCreditOperationId(row.operation_id) + || typeof state !== "string" || !STATES.has(state) + || !isGenerationNumber(row.created_at) + || !isGenerationNumber(row.updated_at) + || row.updated_at < row.created_at) { + return undefined; + } + const terminal = state === "confirmed" || state === "stopped"; + if (terminal !== (typeof code === "string" && TERMINAL_CODES.has(code))) return undefined; + if (state === "confirmed" && code !== "reset" && code !== "already_redeemed") return undefined; + if (state === "stopped" && code !== "nothing_to_reset" && code !== "no_credit") return undefined; + return Object.freeze({ + accountKey: row.account_key, + credentialGeneration: row.credential_generation, + exhaustionGeneration: row.exhaustion_generation, + operationId: row.operation_id, + state: state as ResetCreditOperationState, + ...(terminal ? { code: code as CodexResetCreditConsumeCode } : {}), + createdAt: row.created_at, + updatedAt: row.updated_at, + }); +} + +function assertCanonicalTable(database: Database): void { + const schemaRows = database.query(` + SELECT type, name, tbl_name, sql + FROM main.sqlite_schema + WHERE name = ? COLLATE NOCASE OR tbl_name = ? COLLATE NOCASE + ORDER BY type, name + LIMIT 4 + `).all(TABLE_NAME, TABLE_NAME); + if (schemaRows.length === 0) { + database.exec(CREATE_TABLE); + } else if (schemaRows.length !== 1 + || schemaRows[0]?.type !== "table" + || schemaRows[0]?.name !== TABLE_NAME + || schemaRows[0]?.tbl_name !== TABLE_NAME + || schemaRows[0]?.sql !== EXPECTED_SCHEMA_SQL) { + throw new Error("invalid reset-credit operation ledger schema"); + } + + const tableRows = database.query("PRAGMA main.table_list").all() + .filter(row => row.name === TABLE_NAME); + if (tableRows.length !== 1) throw new Error("invalid reset-credit operation ledger table"); + const table = tableRows[0]!; + if (table.schema !== "main" || table.type !== "table" || table.ncol !== EXPECTED_COLUMNS.length + || table.wr !== 1 || table.strict !== 1) { + throw new Error("invalid reset-credit operation ledger table"); + } + + const columns = database.query( + "PRAGMA main.table_xinfo(reset_credit_operations)", + ).all(); + if (columns.length !== EXPECTED_COLUMNS.length) { + throw new Error("invalid reset-credit operation ledger columns"); + } + for (let index = 0; index < EXPECTED_COLUMNS.length; index += 1) { + const actual = columns[index]!; + const expected = EXPECTED_COLUMNS[index]!; + if (actual.cid !== index || actual.name !== expected.name || actual.type !== expected.type + || actual.notnull !== expected.notnull || actual.dflt_value !== null + || actual.pk !== expected.pk || actual.hidden !== 0) { + throw new Error("invalid reset-credit operation ledger columns"); + } + } + + const mainTrigger = database.query<{ name: unknown }, [string]>(` + SELECT name FROM main.sqlite_schema + WHERE type = 'trigger' AND tbl_name = ? COLLATE NOCASE LIMIT 1 + `).get(TABLE_NAME); + const tempTrigger = database.query<{ name: unknown }, [string]>(` + SELECT name FROM temp.sqlite_schema + WHERE type = 'trigger' AND tbl_name = ? COLLATE NOCASE LIMIT 1 + `).get(TABLE_NAME); + if (mainTrigger || tempTrigger) throw new Error("reset-credit operation ledger triggers are forbidden"); +} + +function initializeTable(database: Database): number { + assertCanonicalTable(database); + const rows = database.query(SELECT_ALL).all(); + if (rows.length > MAX_RESET_CREDIT_OPERATION_ACCOUNTS) { + throw new Error("invalid reset-credit operation ledger capacity"); + } + const accountKeys = new Set(); + const operationIds = new Set(); + for (const row of rows) { + const record = parseRecord(row); + if (!record || accountKeys.has(record.accountKey) || operationIds.has(record.operationId)) { + throw new Error("invalid reset-credit operation ledger state"); + } + accountKeys.add(record.accountKey); + operationIds.add(record.operationId); + } + return rows.length; +} + +function readRecord(database: Database, key: string): ResetCreditOperationRecord | undefined { + const rows = database.query(SELECT_BY_KEY).all(key); + if (rows.length > 1) throw new Error("duplicate reset-credit operation records"); + const row = rows[0]; + const record = parseRecord(row ?? null); + if (row && !record) throw new Error("invalid reset-credit operation record"); + return record; +} + +function sameRecord(left: ResetCreditOperationRecord, right: ResetCreditOperationRecord): boolean { + return left.accountKey === right.accountKey + && left.credentialGeneration === right.credentialGeneration + && left.exhaustionGeneration === right.exhaustionGeneration + && left.operationId === right.operationId + && left.state === right.state + && left.code === right.code + && left.createdAt === right.createdAt + && left.updatedAt === right.updatedAt; +} + +function assertStoredRecord( + database: Database, + expected: ResetCreditOperationRecord, +): void { + const stored = readRecord(database, expected.accountKey); + if (!stored || !sameRecord(stored, expected)) { + throw new Error("reset-credit operation write did not persist the expected record"); + } +} + +function compareGeneration( + record: ResetCreditOperationRecord, + generation: CodexResetCreditRecoveryGeneration, +): -1 | 0 | 1 { + if (record.credentialGeneration !== generation.credentialGeneration) { + return record.credentialGeneration < generation.credentialGeneration ? -1 : 1; + } + if (record.exhaustionGeneration !== generation.exhaustionGeneration) { + return record.exhaustionGeneration < generation.exhaustionGeneration ? -1 : 1; + } + return 0; +} + +function isTerminal(record: ResetCreditOperationRecord): boolean { + return record.state === "confirmed" || record.state === "stopped"; +} + +function isThenable(value: unknown): boolean { + return (typeof value === "object" && value !== null) || typeof value === "function" + ? typeof (value as { then?: unknown }).then === "function" + : false; +} + +function withLedger(operation: (database: Database, recordCount: number) => T): T { + const path = prepareConfigMutationDatabasePathForWrite(); + let database: Database | undefined; + let transactionOpen = false; + try { + database = new Database(path, { create: true }); + try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ } + database.exec("PRAGMA trusted_schema = OFF; PRAGMA busy_timeout = 0; PRAGMA synchronous = FULL; BEGIN IMMEDIATE"); + transactionOpen = true; + initializeConfigGeneration(database); + const recordCount = initializeTable(database); + const value = operation(database, recordCount); + if (isThenable(value) || !database.inTransaction) { + throw new Error("reset-credit operation ledger work escaped its synchronous transaction"); + } + database.exec("COMMIT"); + transactionOpen = false; + return value; + } catch (error) { + if (transactionOpen) { + try { database?.exec("ROLLBACK"); } catch { /* close still releases the write lock */ } + transactionOpen = false; + } + throw error; + } finally { + try { database?.close(); } catch { /* operation already completed */ } + } +} + +export function openResetCreditOperation( + generation: CodexResetCreditRecoveryGeneration, + now = Date.now(), +): OpenResetCreditOperationResult { + validateGeneration(generation); + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); + try { + return withLedger((database, recordCount) => { + const key = accountKey(generation.accountId); + const current = readRecord(database, key); + if (current) { + const comparison = compareGeneration(current, generation); + if (comparison > 0) return Object.freeze({ kind: "stale-generation" as const }); + if (comparison === 0) { + if (isTerminal(current)) { + return Object.freeze({ + kind: "terminal" as const, + operationId: current.operationId, + code: current.code!, + }); + } + return Object.freeze({ kind: "execute" as const, operationId: current.operationId, resumed: true }); + } + if (!isTerminal(current)) return Object.freeze({ kind: "unresolved-prior-generation" as const }); + } else if (recordCount >= MAX_RESET_CREDIT_OPERATION_ACCOUNTS) { + return Object.freeze({ kind: "capacity" as const }); + } + + const operationId = randomUUID(); + if (!isCodexResetCreditOperationId(operationId)) throw new Error("runtime generated invalid UUID"); + const values = [ + generation.credentialGeneration, + generation.exhaustionGeneration, + operationId, + "pending", + null, + now, + now, + key, + ] as const; + const result = current + ? database.query(REPLACE_RECORD).run(...values) + : database.query(INSERT_RECORD).run(key, ...values.slice(0, 7)); + if (result.changes !== 1) throw new Error("reset-credit operation reservation lost ownership"); + assertStoredRecord(database, Object.freeze({ + accountKey: key, + credentialGeneration: generation.credentialGeneration, + exhaustionGeneration: generation.exhaustionGeneration, + operationId, + state: "pending", + createdAt: now, + updatedAt: now, + })); + return Object.freeze({ kind: "execute" as const, operationId, resumed: false }); + }); + } catch { + return Object.freeze({ kind: "unavailable" }); + } +} + +function updateOperation( + generation: CodexResetCreditRecoveryGeneration, + operationId: string, + update: (record: ResetCreditOperationRecord) => ResetCreditOperationRecord | undefined, +): UpdateResetCreditOperationResult { + validateGeneration(generation); + if (!isCodexResetCreditOperationId(operationId)) return Object.freeze({ kind: "mismatch" }); + try { + return withLedger(database => { + const key = accountKey(generation.accountId); + const current = readRecord(database, key); + if (!current + || compareGeneration(current, generation) !== 0 + || current.operationId !== operationId) { + return Object.freeze({ kind: "mismatch" as const }); + } + const updated = update(current); + if (!updated) return Object.freeze({ kind: "mismatch" as const }); + const result = database.query(UPDATE_RECORD).run( + updated.state, + updated.code ?? null, + updated.updatedAt, + key, + generation.credentialGeneration, + generation.exhaustionGeneration, + operationId, + ); + if (result.changes !== 1) throw new Error("reset-credit operation update lost ownership"); + assertStoredRecord(database, updated); + return Object.freeze({ kind: "updated" as const }); + }); + } catch { + return Object.freeze({ kind: "unavailable" }); + } +} + +export function markResetCreditOperationAmbiguous( + generation: CodexResetCreditRecoveryGeneration, + operationId: string, + now = Date.now(), +): UpdateResetCreditOperationResult { + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); + return updateOperation(generation, operationId, record => { + if (isTerminal(record)) return undefined; + return Object.freeze({ + ...record, + state: "ambiguous", + code: undefined, + updatedAt: Math.max(record.updatedAt, now), + }); + }); +} + +export function settleResetCreditOperation( + generation: CodexResetCreditRecoveryGeneration, + operationId: string, + code: CodexResetCreditConsumeCode, + now = Date.now(), +): UpdateResetCreditOperationResult { + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); + if (!TERMINAL_CODES.has(code)) return Object.freeze({ kind: "mismatch" }); + return updateOperation(generation, operationId, record => { + if (isTerminal(record)) return record.code === code ? record : undefined; + return Object.freeze({ + ...record, + state: code === "reset" || code === "already_redeemed" ? "confirmed" : "stopped", + code, + updatedAt: Math.max(record.updatedAt, now), + }); + }); +} diff --git a/src/codex/reset-credit-recovery.ts b/src/codex/reset-credit-recovery.ts index 69771eddd7..4d35a2a3bb 100644 --- a/src/codex/reset-credit-recovery.ts +++ b/src/codex/reset-credit-recovery.ts @@ -27,6 +27,13 @@ export type CodexResetCreditConsumeCode = | "nothing_to_reset" | "no_credit"; +export const CODEX_RESET_CREDIT_OPERATION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function isCodexResetCreditOperationId(value: unknown): value is string { + return typeof value === "string" && CODEX_RESET_CREDIT_OPERATION_ID_PATTERN.test(value); +} + export type CodexResetCreditRecoveryAuthorization = Readonly<{ enabled: boolean; /** @@ -164,7 +171,6 @@ export const MAX_TRACKED_RECOVERY_ACCOUNTS = 128; export const MAX_TRACKED_RECOVERY_FLIGHTS = 128; export const MAX_TRACKED_RECOVERY_WAITERS_PER_FLIGHT = 128; const RESET_PROCESS_STATE_FOR_TESTS = Symbol("reset-credit-recovery-process-state-for-tests"); -const OPERATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const ADD_EVENT_LISTENER = EventTarget.prototype.addEventListener; const REMOVE_EVENT_LISTENER = EventTarget.prototype.removeEventListener; @@ -475,7 +481,16 @@ export class CodexResetCreditRecoveryCoordinator { createLogicalTurn(): CodexResetCreditLogicalTurn { const operationId = crypto.randomUUID(); - if (!OPERATION_ID_PATTERN.test(operationId)) { + return this.createLogicalTurnForOperation(operationId); + } + + /** + * Restores a logical turn whose operation identity was durably reserved before + * this coordinator instance existed. Only a validated ledger/adapter should use + * this seam; ordinary requests must keep using createLogicalTurn(). + */ + createLogicalTurnForOperation(operationId: string): CodexResetCreditLogicalTurn { + if (!isCodexResetCreditOperationId(operationId)) { throw new TypeError("operationId must be an RFC 4122 version 4 UUID"); } const turn = Object.freeze({ operationId }); diff --git a/src/config.ts b/src/config.ts index ef3580e1a2..9f65fceb37 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2450,6 +2450,16 @@ function configMutationDatabasePath(): string { return path; } +/** + * Prepare the shared config-mutation database path for an independent top-level + * SQLite transaction. Callers must not invoke this while holding + * {@link withConfigMutationLockSync}; a second `BEGIN IMMEDIATE` deliberately + * fails busy instead of joining an uncommitted transaction. + */ +export function prepareConfigMutationDatabasePathForWrite(): string { + return configMutationDatabasePath(); +} + let configMutationLockDepth = 0; let configMutationDatabase: Database | null = null; diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 1496f3bc9a..34727a9038 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -540,7 +540,7 @@ describe("codex-auth API", () => { consumeCalls += 1; markStarted(); await consumeGate; - return Response.json({ code: "noop" }); + return Response.json({ code: "nothing_to_reset" }); } return previousFetch(input, init); }) as typeof fetch; @@ -568,7 +568,7 @@ describe("codex-auth API", () => { releaseConsume(); const completed = await pending; expect(completed?.status).toBe(200); - expect(await completed?.json()).toEqual({ code: "noop" }); + expect(await completed?.json()).toEqual({ code: "nothing_to_reset" }); expect(getNativeMainProfileRequestCount()).toBe(0); } finally { releaseConsume(); @@ -2127,6 +2127,34 @@ describe("codex-auth API", () => { expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); }); + test("reset-credit consume sanitizes a pre-dispatch client abort", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-aborted", email: "aborted@example.test" }); + const controller = new AbortController(); + controller.abort(new Error("private client cancellation detail")); + let fetchCalls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (...args: Parameters) => { + fetchCalls += 1; + return originalFetch(...args); + }) as typeof fetch; + try { + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: "pool-aborted" }), + signal: controller.signal, + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp?.status).toBe(499); + const body = await resp?.text(); + expect(body).not.toContain("private client cancellation detail"); + expect(fetchCalls).toBe(0); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("reset-credit consume returns remaining from refreshed quota, not the consume payload", async () => { const config = makeConfig(); seedPoolAccount(config, { id: "pool-reset", email: "reset@example.test" }); @@ -2139,6 +2167,10 @@ describe("codex-auth API", () => { const url = String(input); if (url.includes("/backend-api/wham/rate-limit-reset-credits/consume")) { expect(init?.method).toBe("POST"); + const consumeBody = JSON.parse(String(init?.body)) as { redeem_request_id?: unknown }; + expect(consumeBody.redeem_request_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); // Upstream may advertise a wrong/stale remaining — management must ignore it. return Response.json({ code: "reset", remaining: 99, available_count: 99 }); } diff --git a/tests/codex-reset-credit-consume.test.ts b/tests/codex-reset-credit-consume.test.ts new file mode 100644 index 0000000000..1ea37f93eb --- /dev/null +++ b/tests/codex-reset-credit-consume.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { + CodexResetCreditConsumeError, + consumeCodexResetCredit, +} from "../src/codex/reset-credit-consume"; + +const OPERATION_ID = "00000000-0000-4000-8000-000000000657"; + +function input(signal = new AbortController().signal) { + return { + accessToken: "test-access-token", + chatgptAccountId: "test-chatgpt-account", + operationId: OPERATION_ID, + signal, + }; +} + +describe("Codex reset-credit consume transport", () => { + for (const code of ["reset", "already_redeemed", "nothing_to_reset", "no_credit"] as const) { + test(`sends and echoes one stable operation id for ${code}`, async () => { + let seenUrl = ""; + let seenBody: unknown; + const result = await consumeCodexResetCredit(input(), { + fetchImpl: async (url, init) => { + seenUrl = String(url); + seenBody = JSON.parse(String(init?.body)); + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe("Bearer test-access-token"); + expect(headers.get("chatgpt-account-id")).toBe("test-chatgpt-account"); + return Response.json({ code, operationId: "attacker-controlled" }); + }, + }); + expect(seenUrl).toBe("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"); + expect(seenBody).toEqual({ redeem_request_id: OPERATION_ID }); + expect(result).toEqual({ code, operationId: OPERATION_ID }); + expect(Object.isFrozen(result)).toBe(true); + }); + } + + test("rejects invalid operation ids before dispatch", async () => { + let calls = 0; + await expect(consumeCodexResetCredit({ ...input(), operationId: "not-a-uuid" }, { + fetchImpl: async () => { calls += 1; return Response.json({ code: "reset" }); }, + })).rejects.toMatchObject({ name: "CodexResetCreditConsumeError", reason: "invalid-input" }); + expect(calls).toBe(0); + }); + + test.each([ + ["unknown code", { code: "unknown" }], + ["inherited code", Object.create({ code: "reset" })], + ["array", [{ code: "reset" }]], + ["malformed JSON", "{"], + ])("fails closed for %s", async (_label, body) => { + await expect(consumeCodexResetCredit(input(), { + fetchImpl: async () => typeof body === "string" ? new Response(body) : Response.json(body), + })).rejects.toMatchObject({ name: "CodexResetCreditConsumeError", reason: "invalid-response" }); + }); + + test("rejects a declared oversized body and cancels it", async () => { + let cancelled = false; + await expect(consumeCodexResetCredit(input(), { + fetchImpl: async () => new Response(new ReadableStream({ + cancel() { cancelled = true; }, + }), { headers: { "content-length": "65537" } }), + })).rejects.toMatchObject({ reason: "invalid-response" }); + expect(cancelled).toBe(true); + }); + + test("propagates an already-aborted caller without dispatch", async () => { + const controller = new AbortController(); + controller.abort(new DOMException("cancelled", "AbortError")); + let calls = 0; + await expect(consumeCodexResetCredit(input(controller.signal), { + fetchImpl: async () => { calls += 1; return Response.json({ code: "reset" }); }, + })).rejects.toMatchObject({ name: "AbortError" }); + expect(calls).toBe(0); + }); + + test("classifies a post-dispatch abort as an ambiguous transport failure", async () => { + const controller = new AbortController(); + let started!: () => void; + const dispatched = new Promise(resolve => { started = resolve; }); + const pending = consumeCodexResetCredit(input(controller.signal), { + fetchImpl: async (_url, init) => { + started(); + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); + }, + }); + await dispatched; + controller.abort(new DOMException("client disconnected", "AbortError")); + await expect(pending).rejects.toMatchObject({ + name: "CodexResetCreditConsumeError", + reason: "transport", + }); + }); + + test("preserves non-2xx status without reflecting the body", async () => { + await expect(consumeCodexResetCredit(input(), { + fetchImpl: async () => new Response("private upstream text", { status: 429 }), + })).rejects.toEqual(expect.objectContaining({ + name: "CodexResetCreditConsumeError", + reason: "upstream", + upstreamStatus: 429, + })); + }); +}); diff --git a/tests/codex-reset-credit-operation-ledger.test.ts b/tests/codex-reset-credit-operation-ledger.test.ts new file mode 100644 index 0000000000..be410ad547 --- /dev/null +++ b/tests/codex-reset-credit-operation-ledger.test.ts @@ -0,0 +1,313 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { Database } from "bun:sqlite"; +import { join } from "node:path"; +import { withConfigMutationLockSync } from "../src/config"; +import { + MAX_RESET_CREDIT_OPERATION_ACCOUNTS, + markResetCreditOperationAmbiguous, + openResetCreditOperation, + settleResetCreditOperation, +} from "../src/codex/reset-credit-operation-ledger"; +import { + CodexResetCreditRecoveryCoordinator, + resetCodexResetCreditRecoveryProcessStateForTests, + type CodexResetCreditRecoveryGeneration, +} from "../src/codex/reset-credit-recovery"; + +const GENERATION: CodexResetCreditRecoveryGeneration = { + accountId: "pool-a", + credentialGeneration: 4, + exhaustionGeneration: 9, +}; + +function databasePath(): string { + return join(process.env.OPENCODEX_HOME!, "config-mutation.sqlite"); +} + +function corruptFirstRecord(): void { + const database = new Database(databasePath()); + try { + database.run("UPDATE reset_credit_operations SET operation_id = 'not-a-uuid' LIMIT 1"); + } finally { + database.close(); + } +} + +function createLaxDuplicateLedger(): void { + const database = new Database(databasePath(), { create: true }); + try { + database.exec(` + CREATE TABLE reset_credit_operations ( + account_key TEXT, + credential_generation INTEGER, + exhaustion_generation INTEGER, + operation_id TEXT, + state TEXT, + code TEXT, + created_at INTEGER, + updated_at INTEGER + )`); + const key = createHash("sha256") + .update(`codex-reset-credit-operation\0${GENERATION.accountId}`) + .digest("hex"); + const insert = database.prepare(` + INSERT INTO reset_credit_operations VALUES (?, ?, ?, ?, 'pending', NULL, 1, 1)`); + insert.run(key, GENERATION.credentialGeneration, GENERATION.exhaustionGeneration, + "00000000-0000-4000-8000-000000000001"); + insert.run(key, GENERATION.credentialGeneration, GENERATION.exhaustionGeneration, + "00000000-0000-4000-8000-000000000002"); + } finally { + database.close(); + } +} + +beforeEach(async () => { + await resetCodexResetCreditRecoveryProcessStateForTests(); + const database = new Database(databasePath(), { create: true }); + try { database.exec("DROP TABLE IF EXISTS reset_credit_operations"); } + finally { database.close(); } +}); + +afterEach(async () => { + await resetCodexResetCreditRecoveryProcessStateForTests(); +}); + +describe("Codex reset-credit operation ledger", () => { + test("durably reserves before dispatch and restores the same logical turn identity", async () => { + const first = openResetCreditOperation(GENERATION, 100); + expect(first).toMatchObject({ kind: "execute", resumed: false }); + if (first.kind !== "execute") throw new Error("reservation failed"); + + const restarted = openResetCreditOperation(GENERATION, 200); + expect(restarted).toEqual({ kind: "execute", operationId: first.operationId, resumed: true }); + const consumedOperationIds: string[] = []; + const coordinator = new CodexResetCreditRecoveryCoordinator({ + coordinationScope: {}, + revalidate: async generation => ({ kind: "eligible", ...generation, availableCredits: 1 }), + consume: async ({ operationId }) => { + consumedOperationIds.push(operationId); + return { code: "reset", operationId }; + }, + }); + const turn = coordinator.createLogicalTurnForOperation(first.operationId); + const authorization = { + enabled: true, + isOutputExposed: () => false, + rejection: { + kind: "reset-eligible-exhaustion", + status: 429, + alternateRetryEligible: true, + resetCreditEligible: true, + semanticCode: "usage_limit_exceeded", + }, + } as const; + const firstAttempt = coordinator.recover(turn, GENERATION, authorization); + const secondAttempt = coordinator.recover(turn, GENERATION, authorization); + expect(secondAttempt).toBe(firstAttempt); + expect(await firstAttempt).toEqual({ kind: "refresh-required", code: "reset" }); + expect(consumedOperationIds).toEqual([first.operationId]); + expect(() => coordinator.createLogicalTurnForOperation("not-a-uuid")) + .toThrow("operationId must be an RFC 4122 version 4 UUID"); + }); + + test("retains ambiguous operations and never allocates a replacement id", () => { + const opened = openResetCreditOperation(GENERATION, 100); + if (opened.kind !== "execute") throw new Error("reservation failed"); + expect(markResetCreditOperationAmbiguous(GENERATION, opened.operationId, 150)).toEqual({ kind: "updated" }); + expect(openResetCreditOperation(GENERATION, 200)).toEqual({ + kind: "execute", + operationId: opened.operationId, + resumed: true, + }); + expect(openResetCreditOperation({ ...GENERATION, exhaustionGeneration: 10 })).toEqual({ + kind: "unresolved-prior-generation", + }); + }); + + test("keeps timestamps monotonic when the wall clock rolls back", () => { + const opened = openResetCreditOperation(GENERATION, 200); + if (opened.kind !== "execute") throw new Error("reservation failed"); + expect(markResetCreditOperationAmbiguous(GENERATION, opened.operationId, 100)) + .toEqual({ kind: "updated" }); + expect(openResetCreditOperation(GENERATION, 50)).toEqual({ + kind: "execute", + operationId: opened.operationId, + resumed: true, + }); + expect(settleResetCreditOperation(GENERATION, opened.operationId, "reset", 50)) + .toEqual({ kind: "updated" }); + expect(openResetCreditOperation(GENERATION, 25)).toEqual({ + kind: "terminal", + operationId: opened.operationId, + code: "reset", + }); + }); + + test("returns terminal outcomes without another execution and permits a newer generation", () => { + const opened = openResetCreditOperation(GENERATION, 100); + if (opened.kind !== "execute") throw new Error("reservation failed"); + expect(settleResetCreditOperation(GENERATION, opened.operationId, "already_redeemed", 200)) + .toEqual({ kind: "updated" }); + expect(openResetCreditOperation(GENERATION)).toEqual({ + kind: "terminal", + operationId: opened.operationId, + code: "already_redeemed", + }); + expect(openResetCreditOperation({ ...GENERATION, exhaustionGeneration: 10 })) + .toMatchObject({ kind: "execute", resumed: false }); + }); + + test("rejects stale generations and mismatched settlement", () => { + const current = openResetCreditOperation(GENERATION); + if (current.kind !== "execute") throw new Error("reservation failed"); + expect(openResetCreditOperation({ ...GENERATION, exhaustionGeneration: 8 })) + .toEqual({ kind: "stale-generation" }); + expect(settleResetCreditOperation(GENERATION, "00000000-0000-4000-8000-000000000999", "reset")) + .toEqual({ kind: "mismatch" }); + }); + + test("fails closed for malformed durable rows without overwriting them", () => { + const opened = openResetCreditOperation(GENERATION); + if (opened.kind !== "execute") throw new Error("reservation failed"); + corruptFirstRecord(); + expect(openResetCreditOperation(GENERATION)).toEqual({ kind: "unavailable" }); + expect(markResetCreditOperationAmbiguous(GENERATION, opened.operationId)).toEqual({ kind: "unavailable" }); + expect(settleResetCreditOperation(GENERATION, opened.operationId, "reset")) + .toEqual({ kind: "unavailable" }); + const database = new Database(databasePath(), { readonly: true }); + try { + expect(database.query<{ operation_id: string }, []>( + "SELECT operation_id FROM reset_credit_operations", + ).get()?.operation_id).toBe("not-a-uuid"); + } finally { + database.close(); + } + }); + + test("refuses a lax duplicate schema without choosing or replacing an operation", () => { + createLaxDuplicateLedger(); + expect(openResetCreditOperation(GENERATION)).toEqual({ kind: "unavailable" }); + expect(markResetCreditOperationAmbiguous( + GENERATION, + "00000000-0000-4000-8000-000000000001", + )).toEqual({ kind: "unavailable" }); + const database = new Database(databasePath(), { readonly: true }); + try { + expect(database.query<{ count: number }, []>( + "SELECT COUNT(*) AS count FROM reset_credit_operations", + ).get()?.count).toBe(2); + } finally { + database.close(); + } + }); + + test("refuses a trigger without replacing the terminal reservation", () => { + const first = openResetCreditOperation(GENERATION, 100); + if (first.kind !== "execute") throw new Error("reservation failed"); + expect(settleResetCreditOperation(GENERATION, first.operationId, "reset", 200)) + .toEqual({ kind: "updated" }); + const database = new Database(databasePath()); + try { + database.exec(` + CREATE TRIGGER reset_credit_tamper AFTER UPDATE ON reset_credit_operations + BEGIN + DELETE FROM reset_credit_operations WHERE account_key = NEW.account_key; + END`); + } finally { + database.close(); + } + expect(openResetCreditOperation({ ...GENERATION, exhaustionGeneration: 10 }, 300)) + .toEqual({ kind: "unavailable" }); + const verifier = new Database(databasePath(), { readonly: true }); + try { + expect(verifier.query<{ operation_id: string }, []>( + "SELECT operation_id FROM reset_credit_operations", + ).get()?.operation_id).toBe(first.operationId); + } finally { + verifier.close(); + } + }); + + test("fails fast under cross-process mutation contention without minting an id", () => { + expect(openResetCreditOperation(GENERATION)).toMatchObject({ kind: "execute", resumed: false }); + const holder = new Database(databasePath()); + holder.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + try { + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-b" })) + .toEqual({ kind: "unavailable" }); + } finally { + holder.exec("ROLLBACK"); + holder.close(); + } + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-b" })) + .toMatchObject({ kind: "execute", resumed: false }); + }); + + test("never authorizes execution from inside an uncommitted config transaction", () => { + let nested: unknown; + expect(() => withConfigMutationLockSync(() => { + nested = openResetCreditOperation(GENERATION); + expect(nested).toEqual({ kind: "unavailable" }); + throw new Error("roll back outer config transaction"); + })).toThrow("roll back outer config transaction"); + expect(openResetCreditOperation(GENERATION)) + .toMatchObject({ kind: "execute", resumed: false }); + }); + + test("admits existing accounts but refuses a new account at capacity", () => { + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-0" })) + .toMatchObject({ kind: "execute", resumed: false }); + const database = new Database(databasePath()); + try { + const insert = database.prepare(` + INSERT INTO reset_credit_operations ( + account_key, credential_generation, exhaustion_generation, operation_id, + state, code, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'pending', NULL, 1, 1)`); + database.exec("BEGIN IMMEDIATE"); + for (let index = 1; index < MAX_RESET_CREDIT_OPERATION_ACCOUNTS; index += 1) { + const accountId = `pool-${index}`; + const key = createHash("sha256") + .update(`codex-reset-credit-operation\0${accountId}`) + .digest("hex"); + const operationId = `00000000-0000-4000-8000-${index.toString(16).padStart(12, "0")}`; + insert.run(key, GENERATION.credentialGeneration, GENERATION.exhaustionGeneration, operationId); + } + database.exec("COMMIT"); + } catch (error) { + try { database.exec("ROLLBACK"); } catch { /* surface the original fixture error */ } + throw error; + } finally { + database.close(); + } + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-over-cap" })) + .toEqual({ kind: "capacity" }); + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-0" })) + .toMatchObject({ kind: "execute", resumed: true }); + + const overflow = new Database(databasePath()); + try { + const key = createHash("sha256") + .update("codex-reset-credit-operation\0pool-corrupt-over-cap") + .digest("hex"); + overflow.prepare(` + INSERT INTO reset_credit_operations ( + account_key, credential_generation, exhaustion_generation, operation_id, + state, code, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'pending', NULL, 1, 1)`) + .run( + key, + GENERATION.credentialGeneration, + GENERATION.exhaustionGeneration, + "00000000-0000-4000-8000-000000000129", + ); + } finally { + overflow.close(); + } + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-0" })) + .toEqual({ kind: "unavailable" }); + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-new" })) + .toEqual({ kind: "unavailable" }); + }); +}); From 7bd5f0c93750736a8a2174dff82ea22073f11c00 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:39:26 +0900 Subject: [PATCH 2/7] fix(codex): harden reset-credit ledger contract --- src/codex/reset-credit-operation-ledger.ts | 106 +++++++++++++----- src/codex/reset-credit-recovery.ts | 21 +++- src/config.ts | 5 + ...odex-reset-credit-operation-ledger.test.ts | 84 ++++++++++++-- 4 files changed, 177 insertions(+), 39 deletions(-) diff --git a/src/codex/reset-credit-operation-ledger.ts b/src/codex/reset-credit-operation-ledger.ts index 79e559c220..b11b35aa7c 100644 --- a/src/codex/reset-credit-operation-ledger.ts +++ b/src/codex/reset-credit-operation-ledger.ts @@ -4,20 +4,25 @@ import { Database } from "bun:sqlite"; import { prepareConfigMutationDatabasePathForWrite } from "../config"; import { initializeConfigGeneration } from "./generation"; import { + compareCodexResetCreditRecoveryGenerationOrder, isCodexResetCreditOperationId, type CodexResetCreditConsumeCode, type CodexResetCreditRecoveryGeneration, + type CodexReservedOperationId, } from "./reset-credit-recovery"; import { isValidCodexAccountId } from "./account-id"; export const MAX_RESET_CREDIT_OPERATION_ACCOUNTS = 128; const ACCOUNT_KEY_PATTERN = /^[0-9a-f]{64}$/; -const TERMINAL_CODES: ReadonlySet = new Set([ - "reset", - "already_redeemed", - "nothing_to_reset", - "no_credit", -]); +const TERMINAL_STATE_BY_CODE: Readonly> = Object.freeze({ + reset: "confirmed", + already_redeemed: "confirmed", + nothing_to_reset: "stopped", + no_credit: "stopped", +}); const STATES: ReadonlySet = new Set(["pending", "ambiguous", "confirmed", "stopped"]); type ResetCreditOperationState = "pending" | "ambiguous" | "confirmed" | "stopped"; @@ -45,8 +50,8 @@ type ResetCreditOperationRow = { }; export type OpenResetCreditOperationResult = - | Readonly<{ kind: "execute"; operationId: string; resumed: boolean }> - | Readonly<{ kind: "terminal"; operationId: string; code: CodexResetCreditConsumeCode }> + | Readonly<{ kind: "execute"; operationId: CodexReservedOperationId; resumed: boolean }> + | Readonly<{ kind: "terminal"; operationId: CodexReservedOperationId; code: CodexResetCreditConsumeCode }> | Readonly<{ kind: "stale-generation" | "unresolved-prior-generation" | "capacity" | "unavailable" }>; export type UpdateResetCreditOperationResult = @@ -65,6 +70,7 @@ const CREATE_TABLE = `CREATE TABLE main.reset_credit_operations ( updated_at INTEGER NOT NULL CHECK (updated_at >= created_at) ) STRICT, WITHOUT ROWID`; const EXPECTED_SCHEMA_SQL = CREATE_TABLE.replace("main.", ""); +export const RESET_CREDIT_OPERATION_SCHEMA_SQL_FOR_TESTS = EXPECTED_SCHEMA_SQL; const SELECT_ALL = ` SELECT account_key, credential_generation, exhaustion_generation, operation_id, state, code, created_at, updated_at @@ -161,9 +167,12 @@ function parseRecord(row: ResetCreditOperationRow | null): ResetCreditOperationR return undefined; } const terminal = state === "confirmed" || state === "stopped"; - if (terminal !== (typeof code === "string" && TERMINAL_CODES.has(code))) return undefined; - if (state === "confirmed" && code !== "reset" && code !== "already_redeemed") return undefined; - if (state === "stopped" && code !== "nothing_to_reset" && code !== "no_credit") return undefined; + const terminalState = typeof code === "string" + && Object.prototype.hasOwnProperty.call(TERMINAL_STATE_BY_CODE, code) + ? TERMINAL_STATE_BY_CODE[code as CodexResetCreditConsumeCode] + : undefined; + if (terminal !== (terminalState !== undefined)) return undefined; + if (terminal && state !== terminalState) return undefined; return Object.freeze({ accountKey: row.account_key, credentialGeneration: row.credential_generation, @@ -204,7 +213,7 @@ function assertCanonicalTable(database: Database): void { } const columns = database.query( - "PRAGMA main.table_xinfo(reset_credit_operations)", + `PRAGMA main.table_xinfo(${TABLE_NAME})`, ).all(); if (columns.length !== EXPECTED_COLUMNS.length) { throw new Error("invalid reset-credit operation ledger columns"); @@ -283,13 +292,11 @@ function compareGeneration( record: ResetCreditOperationRecord, generation: CodexResetCreditRecoveryGeneration, ): -1 | 0 | 1 { - if (record.credentialGeneration !== generation.credentialGeneration) { - return record.credentialGeneration < generation.credentialGeneration ? -1 : 1; - } - if (record.exhaustionGeneration !== generation.exhaustionGeneration) { - return record.exhaustionGeneration < generation.exhaustionGeneration ? -1 : 1; - } - return 0; + return compareCodexResetCreditRecoveryGenerationOrder({ + accountId: generation.accountId, + credentialGeneration: record.credentialGeneration, + exhaustionGeneration: record.exhaustionGeneration, + }, generation); } function isTerminal(record: ResetCreditOperationRecord): boolean { @@ -302,7 +309,9 @@ function isThenable(value: unknown): boolean { : false; } -function withLedger(operation: (database: Database, recordCount: number) => T): T { +type Synchronous = T extends PromiseLike ? never : T; + +function withLedger(operation: (database: Database, recordCount: number) => Synchronous): T { const path = prepareConfigMutationDatabasePathForWrite(); let database: Database | undefined; let transactionOpen = false; @@ -331,6 +340,28 @@ function withLedger(operation: (database: Database, recordCount: number) => T } } +function isLedgerBusyError(error: unknown): boolean { + const code = error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + const message = error instanceof Error ? error.message : ""; + return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" + || /database (?:is|table is) locked/i.test(message); +} + +function warnLedgerUnavailable(error: unknown): void { + if (isLedgerBusyError(error)) return; + const nested = error instanceof Error + && error.message === "prepareConfigMutationDatabasePathForWrite must not run inside withConfigMutationLockSync"; + console.warn(nested + ? "[opencodex] Reset-credit operation ledger refused a nested config mutation." + : "[opencodex] Reset-credit operation ledger is unavailable."); +} + +/** + * Throws `TypeError` for a malformed generation or timestamp. Runtime storage + * and contention failures are represented by a result kind. + */ export function openResetCreditOperation( generation: CodexResetCreditRecoveryGeneration, now = Date.now(), @@ -348,11 +379,15 @@ export function openResetCreditOperation( if (isTerminal(current)) { return Object.freeze({ kind: "terminal" as const, - operationId: current.operationId, + operationId: current.operationId as CodexReservedOperationId, code: current.code!, }); } - return Object.freeze({ kind: "execute" as const, operationId: current.operationId, resumed: true }); + return Object.freeze({ + kind: "execute" as const, + operationId: current.operationId as CodexReservedOperationId, + resumed: true, + }); } if (!isTerminal(current)) return Object.freeze({ kind: "unresolved-prior-generation" as const }); } else if (recordCount >= MAX_RESET_CREDIT_OPERATION_ACCOUNTS) { @@ -384,9 +419,14 @@ export function openResetCreditOperation( createdAt: now, updatedAt: now, })); - return Object.freeze({ kind: "execute" as const, operationId, resumed: false }); + return Object.freeze({ + kind: "execute" as const, + operationId: operationId as CodexReservedOperationId, + resumed: false, + }); }); - } catch { + } catch (error) { + warnLedgerUnavailable(error); return Object.freeze({ kind: "unavailable" }); } } @@ -422,11 +462,16 @@ function updateOperation( assertStoredRecord(database, updated); return Object.freeze({ kind: "updated" as const }); }); - } catch { + } catch (error) { + warnLedgerUnavailable(error); return Object.freeze({ kind: "unavailable" }); } } +/** + * Throws `TypeError` for a malformed generation or timestamp. An invalid + * operation id returns `mismatch`; runtime storage failures return `unavailable`. + */ export function markResetCreditOperationAmbiguous( generation: CodexResetCreditRecoveryGeneration, operationId: string, @@ -444,6 +489,11 @@ export function markResetCreditOperationAmbiguous( }); } +/** + * Throws `TypeError` for a malformed generation or timestamp. An invalid + * operation id or non-terminal code returns `mismatch`; runtime storage + * failures return `unavailable`. + */ export function settleResetCreditOperation( generation: CodexResetCreditRecoveryGeneration, operationId: string, @@ -451,12 +501,14 @@ export function settleResetCreditOperation( now = Date.now(), ): UpdateResetCreditOperationResult { if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); - if (!TERMINAL_CODES.has(code)) return Object.freeze({ kind: "mismatch" }); + if (!Object.prototype.hasOwnProperty.call(TERMINAL_STATE_BY_CODE, code)) { + return Object.freeze({ kind: "mismatch" }); + } return updateOperation(generation, operationId, record => { if (isTerminal(record)) return record.code === code ? record : undefined; return Object.freeze({ ...record, - state: code === "reset" || code === "already_redeemed" ? "confirmed" : "stopped", + state: TERMINAL_STATE_BY_CODE[code], code, updatedAt: Math.max(record.updatedAt, now), }); diff --git a/src/codex/reset-credit-recovery.ts b/src/codex/reset-credit-recovery.ts index 4d35a2a3bb..246c9149ba 100644 --- a/src/codex/reset-credit-recovery.ts +++ b/src/codex/reset-credit-recovery.ts @@ -27,6 +27,13 @@ export type CodexResetCreditConsumeCode = | "nothing_to_reset" | "no_credit"; +declare const CODEX_RESERVED_OPERATION_ID_BRAND: unique symbol; + +/** An operation id whose durable reservation was validated by the operation ledger. */ +export type CodexReservedOperationId = string & { + readonly [CODEX_RESERVED_OPERATION_ID_BRAND]: true; +}; + export const CODEX_RESET_CREDIT_OPERATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -244,7 +251,7 @@ function generationKey(generation: CodexResetCreditRecoveryGeneration): string { ]); } -function compareGenerationOrder( +export function compareCodexResetCreditRecoveryGenerationOrder( left: CodexResetCreditRecoveryGeneration, right: CodexResetCreditRecoveryGeneration, ): -1 | 0 | 1 { @@ -481,7 +488,7 @@ export class CodexResetCreditRecoveryCoordinator { createLogicalTurn(): CodexResetCreditLogicalTurn { const operationId = crypto.randomUUID(); - return this.createLogicalTurnForOperation(operationId); + return this.registerLogicalTurn(operationId); } /** @@ -489,10 +496,14 @@ export class CodexResetCreditRecoveryCoordinator { * this coordinator instance existed. Only a validated ledger/adapter should use * this seam; ordinary requests must keep using createLogicalTurn(). */ - createLogicalTurnForOperation(operationId: string): CodexResetCreditLogicalTurn { + createLogicalTurnForOperation(operationId: CodexReservedOperationId): CodexResetCreditLogicalTurn { if (!isCodexResetCreditOperationId(operationId)) { throw new TypeError("operationId must be an RFC 4122 version 4 UUID"); } + return this.registerLogicalTurn(operationId); + } + + private registerLogicalTurn(operationId: string): CodexResetCreditLogicalTurn { const turn = Object.freeze({ operationId }); this.logicalTurns.set(turn, {}); return turn; @@ -569,7 +580,7 @@ export class CodexResetCreditRecoveryCoordinator { if (!recoveryContractsMatch(terminal.contract, this.contract)) { return Promise.resolve(notDispatched("coordination-mismatch")); } - const order = compareGenerationOrder(generationSnapshot, terminal.generation); + const order = compareCodexResetCreditRecoveryGenerationOrder(generationSnapshot, terminal.generation); if (order === 0) { return this.resolveTerminalOutcome( terminal.outcome, @@ -794,7 +805,7 @@ export class CodexResetCreditRecoveryCoordinator { const current = CodexResetCreditRecoveryCoordinator.terminalByAccount.get( flight.generation.accountId, ); - if (!current || compareGenerationOrder(flight.generation, current.generation) > 0) { + if (!current || compareCodexResetCreditRecoveryGenerationOrder(flight.generation, current.generation) > 0) { CodexResetCreditRecoveryCoordinator.terminalByAccount.set( flight.generation.accountId, { generation: flight.generation, outcome: result, contract: flight.contract }, diff --git a/src/config.ts b/src/config.ts index 9f65fceb37..a2d9732e88 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2457,6 +2457,11 @@ function configMutationDatabasePath(): string { * fails busy instead of joining an uncommitted transaction. */ export function prepareConfigMutationDatabasePathForWrite(): string { + if (configMutationLockDepth > 0) { + throw new Error( + "prepareConfigMutationDatabasePathForWrite must not run inside withConfigMutationLockSync", + ); + } return configMutationDatabasePath(); } diff --git a/tests/codex-reset-credit-operation-ledger.test.ts b/tests/codex-reset-credit-operation-ledger.test.ts index be410ad547..d9beb3e609 100644 --- a/tests/codex-reset-credit-operation-ledger.test.ts +++ b/tests/codex-reset-credit-operation-ledger.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { withConfigMutationLockSync } from "../src/config"; import { MAX_RESET_CREDIT_OPERATION_ACCOUNTS, + RESET_CREDIT_OPERATION_SCHEMA_SQL_FOR_TESTS, markResetCreditOperationAmbiguous, openResetCreditOperation, settleResetCreditOperation, @@ -12,6 +13,7 @@ import { import { CodexResetCreditRecoveryCoordinator, resetCodexResetCreditRecoveryProcessStateForTests, + type CodexReservedOperationId, type CodexResetCreditRecoveryGeneration, } from "../src/codex/reset-credit-recovery"; @@ -28,12 +30,16 @@ function databasePath(): string { function corruptFirstRecord(): void { const database = new Database(databasePath()); try { - database.run("UPDATE reset_credit_operations SET operation_id = 'not-a-uuid' LIMIT 1"); + database.run("UPDATE reset_credit_operations SET operation_id = 'not-a-uuid'"); } finally { database.close(); } } +function fixtureOperationId(index: number): string { + return `00000000-0000-4000-8000-${index.toString(16).padStart(12, "0")}`; +} + function createLaxDuplicateLedger(): void { const database = new Database(databasePath(), { create: true }); try { @@ -74,6 +80,20 @@ afterEach(async () => { }); describe("Codex reset-credit operation ledger", () => { + test("creates the exact canonical SQLite schema", () => { + expect(openResetCreditOperation(GENERATION, 100)) + .toMatchObject({ kind: "execute", resumed: false }); + const database = new Database(databasePath(), { readonly: true }); + try { + expect(database.query<{ sql: string }, []>(` + SELECT sql FROM main.sqlite_schema + WHERE type = 'table' AND name = 'reset_credit_operations' + `).get()?.sql).toBe(RESET_CREDIT_OPERATION_SCHEMA_SQL_FOR_TESTS); + } finally { + database.close(); + } + }); + test("durably reserves before dispatch and restores the same logical turn identity", async () => { const first = openResetCreditOperation(GENERATION, 100); expect(first).toMatchObject({ kind: "execute", resumed: false }); @@ -107,7 +127,23 @@ describe("Codex reset-credit operation ledger", () => { expect(secondAttempt).toBe(firstAttempt); expect(await firstAttempt).toEqual({ kind: "refresh-required", code: "reset" }); expect(consumedOperationIds).toEqual([first.operationId]); - expect(() => coordinator.createLogicalTurnForOperation("not-a-uuid")) + expect(coordinator.terminalGenerationCountForTests()).toBe(1); + // Automatic runtime wiring is intentionally out of scope: the coordinator + // has fenced this generation, while the durable reservation remains pending + // until its future adapter explicitly settles it. + expect(openResetCreditOperation(GENERATION, 300)).toEqual({ + kind: "execute", + operationId: first.operationId, + resumed: true, + }); + expect(settleResetCreditOperation(GENERATION, first.operationId, "reset", 400)) + .toEqual({ kind: "updated" }); + expect(openResetCreditOperation(GENERATION, 500)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "reset", + }); + expect(() => coordinator.createLogicalTurnForOperation("not-a-uuid" as CodexReservedOperationId)) .toThrow("operationId must be an RFC 4122 version 4 UUID"); }); @@ -202,6 +238,33 @@ describe("Codex reset-credit operation ledger", () => { } }); + test("refuses a canonical ledger that reuses an operation id across accounts", () => { + const first = openResetCreditOperation(GENERATION, 100); + if (first.kind !== "execute") throw new Error("reservation failed"); + const database = new Database(databasePath()); + try { + const secondKey = createHash("sha256") + .update("codex-reset-credit-operation\0pool-b") + .digest("hex"); + database.prepare(` + INSERT INTO reset_credit_operations ( + account_key, credential_generation, exhaustion_generation, operation_id, + state, code, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'pending', NULL, 1, 1)`) + .run( + secondKey, + GENERATION.credentialGeneration, + GENERATION.exhaustionGeneration, + first.operationId, + ); + } finally { + database.close(); + } + expect(openResetCreditOperation(GENERATION)).toEqual({ kind: "unavailable" }); + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-c" })) + .toEqual({ kind: "unavailable" }); + }); + test("refuses a trigger without replacing the terminal reservation", () => { const first = openResetCreditOperation(GENERATION, 100); if (first.kind !== "execute") throw new Error("reservation failed"); @@ -232,13 +295,18 @@ describe("Codex reset-credit operation ledger", () => { test("fails fast under cross-process mutation contention without minting an id", () => { expect(openResetCreditOperation(GENERATION)).toMatchObject({ kind: "execute", resumed: false }); const holder = new Database(databasePath()); - holder.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + let transactionOpen = false; try { + holder.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + transactionOpen = true; expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-b" })) .toEqual({ kind: "unavailable" }); } finally { - holder.exec("ROLLBACK"); - holder.close(); + try { + if (transactionOpen) holder.exec("ROLLBACK"); + } finally { + holder.close(); + } } expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-b" })) .toMatchObject({ kind: "execute", resumed: false }); @@ -271,7 +339,7 @@ describe("Codex reset-credit operation ledger", () => { const key = createHash("sha256") .update(`codex-reset-credit-operation\0${accountId}`) .digest("hex"); - const operationId = `00000000-0000-4000-8000-${index.toString(16).padStart(12, "0")}`; + const operationId = fixtureOperationId(index); insert.run(key, GENERATION.credentialGeneration, GENERATION.exhaustionGeneration, operationId); } database.exec("COMMIT"); @@ -300,7 +368,9 @@ describe("Codex reset-credit operation ledger", () => { key, GENERATION.credentialGeneration, GENERATION.exhaustionGeneration, - "00000000-0000-4000-8000-000000000129", + // SELECT_ALL intentionally reads MAX + 1 rows so the corrupt + // over-capacity state cannot be mistaken for an ordinary full ledger. + fixtureOperationId(MAX_RESET_CREDIT_OPERATION_ACCOUNTS + 1), ); } finally { overflow.close(); From ce931da64b40e0d42abe00ad9d2a0f64c5058f7c Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:11:38 +0900 Subject: [PATCH 3/7] fix(codex): bind manual reset-credit retries --- gui/src/components/CodexAccountPool.tsx | 10 +- .../components/codex-account-pool-handlers.ts | 3 +- gui/src/lib/uuid.ts | 22 + gui/src/pages/claude-code-types.ts | 19 +- gui/tests/browser-uuid.test.ts | 21 + gui/tests/codex-account-pool-handlers.test.ts | 22 +- .../codex-account-pool-toast-tone.test.tsx | 58 +++ src/cli/account-auth.ts | 6 +- src/codex/auth-api.ts | 141 ++++-- src/codex/reset-credit-operation-ledger.ts | 425 +++++++++++++++--- tests/cli-account.test.ts | 20 + tests/codex-auth-api.test.ts | 170 ++++++- ...odex-reset-credit-operation-ledger.test.ts | 130 +++++- 13 files changed, 913 insertions(+), 134 deletions(-) create mode 100644 gui/src/lib/uuid.ts create mode 100644 gui/tests/browser-uuid.test.ts diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 0440f79e55..8828632fbc 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -20,6 +20,7 @@ import { accountNeedsReauth } from "../oauth-health-display"; import { useCopyFeedback } from "./use-copy-feedback"; import { DEFAULT_ACCOUNT_POOL_STRATEGY } from "../account-pool-strategy"; import type { CodexAccountMutationCompletion } from "../codex-account-mutation"; +import { newBrowserUuid } from "../lib/uuid"; // Single definition lives with the controller that owns this data (WP3). export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; @@ -69,6 +70,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const feedbackTimerRef = useRef | null>(null); const [refreshingQuota, setRefreshingQuota] = useState(false); const [resetPopup, setResetPopup] = useState(null); + const [resetOperationId, setResetOperationId] = useState(null); const [resetConfirm, setResetConfirm] = useState(false); const [redeeming, setRedeeming] = useState(false); const [creditDetails, setCreditDetails] = useState<{ granted_at: string; expires_at: string }[] | null>(null); @@ -236,6 +238,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const openResetPopup = async (account: CodexAccountEntry) => { setResetPopup(account); + setResetOperationId(newBrowserUuid()); setResetConfirm(false); setCreditDetails(null); setCreditDetailsLoading(true); @@ -255,9 +258,12 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const handleRedeem = async (accountId: string) => { setRedeeming(true); try { - const result = await redeemResetCredit(apiBase, accountId, t, load); + const operationId = resetOperationId ?? newBrowserUuid(); + if (!resetOperationId) setResetOperationId(operationId); + const result = await redeemResetCredit(apiBase, accountId, operationId, t, load); if (result.close) { setResetPopup(null); + setResetOperationId(null); setResetConfirm(false); } if (result.toast) { @@ -410,7 +416,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban creditDetails={creditDetails} creditDetailsLoading={creditDetailsLoading} redeeming={redeeming} - onClose={() => { setResetPopup(null); setResetConfirm(false); setCreditDetails(null); }} + onClose={() => { setResetPopup(null); setResetOperationId(null); setResetConfirm(false); setCreditDetails(null); }} onShowConfirm={() => setResetConfirm(true)} onCancelConfirm={() => setResetConfirm(false)} onRedeem={() => { void handleRedeem(resetPopup.id); }} diff --git a/gui/src/components/codex-account-pool-handlers.ts b/gui/src/components/codex-account-pool-handlers.ts index 47bf57ad43..b45f749e85 100644 --- a/gui/src/components/codex-account-pool-handlers.ts +++ b/gui/src/components/codex-account-pool-handlers.ts @@ -12,6 +12,7 @@ function remainingCreditsToast( export async function redeemResetCredit( apiBase: string, accountId: string, + operationId: string, t: TFn, load: (refresh?: boolean) => Promise, ): Promise<{ ok: boolean; toast?: string; close?: boolean }> { @@ -19,7 +20,7 @@ export async function redeemResetCredit( const resp = await fetch(`${apiBase}/api/codex-auth/reset-credits/consume`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ accountId }), + body: JSON.stringify({ accountId, operationId }), }); const result = await readJsonIfOk<{ code: string; remaining?: number }>(resp); if (!result) return { ok: false, toast: t("codexAuth.resetError") }; diff --git a/gui/src/lib/uuid.ts b/gui/src/lib/uuid.ts new file mode 100644 index 0000000000..e5f98e72e0 --- /dev/null +++ b/gui/src/lib/uuid.ts @@ -0,0 +1,22 @@ +/** UUIDv4 for browser state; remains available on LAN HTTP/non-secure contexts. */ +export function newBrowserUuid(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + try { + return crypto.randomUUID(); + } catch { + // Some browsers expose randomUUID but reject it outside a secure context. + } + } + const bytes = new Uint8Array(16); + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + crypto.getRandomValues(bytes); + } else { + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256); + } + } + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/gui/src/pages/claude-code-types.ts b/gui/src/pages/claude-code-types.ts index 0ca9150892..cb9c6f8543 100644 --- a/gui/src/pages/claude-code-types.ts +++ b/gui/src/pages/claude-code-types.ts @@ -1,4 +1,5 @@ import type { SidecarOverride } from "./claude-manual-env"; +import { newBrowserUuid } from "../lib/uuid"; export interface MapRow { id: string; @@ -8,23 +9,7 @@ export interface MapRow { /** Stable client key for list rows; works outside secure contexts (LAN HTTP). */ export function newClientId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - try { - return crypto.randomUUID(); - } catch { - // crypto.randomUUID throws outside a secure context in some browsers. - } - } - const bytes = new Uint8Array(16); - if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { - crypto.getRandomValues(bytes); - } else { - for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256); - } - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + return newBrowserUuid(); } export interface ClaudeCodeState { diff --git a/gui/tests/browser-uuid.test.ts b/gui/tests/browser-uuid.test.ts new file mode 100644 index 0000000000..88e1cae402 --- /dev/null +++ b/gui/tests/browser-uuid.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test"; +import { newBrowserUuid } from "../src/lib/uuid"; + +test("browser UUID falls back to RFC 4122 v4 when randomUUID rejects", () => { + const cryptoObject = globalThis.crypto; + const originalRandomUuid = cryptoObject.randomUUID; + Object.defineProperty(cryptoObject, "randomUUID", { + configurable: true, + value: () => { throw new Error("randomUUID requires a secure context"); }, + }); + try { + expect(newBrowserUuid()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + } finally { + Object.defineProperty(cryptoObject, "randomUUID", { + configurable: true, + value: originalRandomUuid, + }); + } +}); diff --git a/gui/tests/codex-account-pool-handlers.test.ts b/gui/tests/codex-account-pool-handlers.test.ts index 7312f6f16f..d9c19b15bd 100644 --- a/gui/tests/codex-account-pool-handlers.test.ts +++ b/gui/tests/codex-account-pool-handlers.test.ts @@ -10,14 +10,19 @@ const t: TFn = ((key: string, vars?: Record) => { let originalFetch: typeof globalThis.fetch; let consumeBody: { code: string; remaining?: number } | null = null; let loadCalls = 0; +let requestBody: unknown; beforeEach(() => { originalFetch = globalThis.fetch; consumeBody = null; loadCalls = 0; + requestBody = null; Object.defineProperty(globalThis, "fetch", { configurable: true, - value: async () => Response.json(consumeBody ?? { code: "error" }), + value: async (_input: RequestInfo | URL, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)); + return Response.json(consumeBody ?? { code: "error" }); + }, }); }); @@ -28,7 +33,8 @@ afterEach(() => { test("balance changed after modal opened: toast uses authoritative remaining, not a stale snapshot", async () => { // Modal opened when balance was 3; concurrent activity left 1 — server reports 1. consumeBody = { code: "reset", remaining: 1 }; - const result = await redeemResetCredit("", "acct-1", t, async () => { + const operationId = crypto.randomUUID(); + const result = await redeemResetCredit("", "acct-1", operationId, t, async () => { loadCalls += 1; return true; }); @@ -39,11 +45,15 @@ test("balance changed after modal opened: toast uses authoritative remaining, no expect(result.toast).toBe("codexAuth.resetSuccess:remaining=1"); expect(result.toast).not.toContain("remaining=2"); expect(result.toast).not.toContain("remaining=3"); + expect(requestBody).toMatchObject({ + accountId: "acct-1", + operationId, + }); }); test("already_redeemed does not decrement and uses the returned remaining count", async () => { consumeBody = { code: "already_redeemed", remaining: 3 }; - const result = await redeemResetCredit("", "acct-1", t, async () => { + const result = await redeemResetCredit("", "acct-1", crypto.randomUUID(), t, async () => { loadCalls += 1; return true; }); @@ -57,7 +67,7 @@ test("already_redeemed does not decrement and uses the returned remaining count" test("missing refreshed count uses the generic success toast", async () => { consumeBody = { code: "reset" }; - const result = await redeemResetCredit("", "acct-1", t, async () => true); + const result = await redeemResetCredit("", "acct-1", crypto.randomUUID(), t, async () => true); expect(result.ok).toBe(true); expect(result.toast).toBe("codexAuth.resetSuccessGeneric"); @@ -65,7 +75,7 @@ test("missing refreshed count uses the generic success toast", async () => { test("already_redeemed without remaining also uses the generic success toast", async () => { consumeBody = { code: "already_redeemed" }; - const result = await redeemResetCredit("", "acct-1", t, async () => true); + const result = await redeemResetCredit("", "acct-1", crypto.randomUUID(), t, async () => true); expect(result.ok).toBe(true); expect(result.toast).toBe("codexAuth.resetSuccessGeneric"); @@ -74,7 +84,7 @@ test("already_redeemed without remaining also uses the generic success toast", a test("failure paths return ok:false so callers can set toastError from result.ok", async () => { consumeBody = { code: "no_credit" }; - const result = await redeemResetCredit("", "acct-1", t, async () => true); + const result = await redeemResetCredit("", "acct-1", crypto.randomUUID(), t, async () => true); expect(result.ok).toBe(false); expect(result.toast).toBe("codexAuth.resetNoCredit"); diff --git a/gui/tests/codex-account-pool-toast-tone.test.tsx b/gui/tests/codex-account-pool-toast-tone.test.tsx index a8e070bfc1..c32f3b2278 100644 --- a/gui/tests/codex-account-pool-toast-tone.test.tsx +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -18,6 +18,8 @@ let host: HTMLElement; let root: Root | null = null; let originalFetch: typeof globalThis.fetch; let originalConfirm: typeof window.confirm; +let consumeAttempts = 0; +let consumedOperationIds: string[] = []; const account: CodexAccountEntry = { id: "pool-1", @@ -74,6 +76,8 @@ beforeEach(() => { originalFetch = globalThis.fetch; originalConfirm = window.confirm; window.confirm = () => true; + consumeAttempts = 0; + consumedOperationIds = []; Object.defineProperty(globalThis, "fetch", { configurable: true, @@ -83,6 +87,10 @@ beforeEach(() => { return Response.json({ credits: [] }); } if (url.pathname === "/api/codex-auth/reset-credits/consume" && (init?.method ?? "GET") === "POST") { + consumeAttempts += 1; + consumedOperationIds.push( + (JSON.parse(String(init?.body)) as { operationId: string }).operationId, + ); return Response.json({ code: "already_redeemed", remaining: 2 }); } if (url.pathname.startsWith("/api/codex-auth/")) { @@ -261,3 +269,53 @@ test("successful redeem clears a stale error toast tone", async () => { expect(host.querySelector(".codex-auth-page-head__feedback.is-err")).toBeNull(); expect(host.querySelector(".codex-auth-page-head__feedback.is-ok")).toBeTruthy(); }); + +test("LAN fallback UUID remains stable across a failed redeem retry", async () => { + const cryptoObject = globalThis.crypto; + const originalRandomUUID = cryptoObject.randomUUID; + Object.defineProperty(cryptoObject, "randomUUID", { + configurable: true, + value: () => { throw new Error("randomUUID requires a secure context"); }, + }); + const baseFetch = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/codex-auth/reset-credits/consume") { + consumeAttempts += 1; + consumedOperationIds.push( + (JSON.parse(String(init?.body)) as { operationId: string }).operationId, + ); + if (consumeAttempts === 1) return Response.json({ error: "lost" }, { status: 502 }); + return Response.json({ code: "already_redeemed", remaining: 2 }); + } + return baseFetch(input, init); + }, + }); + try { + await mountPool(makeController()); + const resetBtn = host.querySelector('button[aria-label="2 reset credit(s)"]') as HTMLButtonElement; + await act(async () => { resetBtn.click(); await new Promise(resolve => setTimeout(resolve, 40)); }); + const useCredit = [...host.querySelectorAll("button")].find(button => + (button.textContent ?? "").includes("Use 1 Credit"), + )!; + await act(async () => { useCredit.click(); }); + const redeem = () => [...host.querySelectorAll("button")].find(button => { + const text = (button.textContent ?? "").trim(); + return text === "Use Credit" || text.startsWith("Resetting"); + })!; + await act(async () => { redeem().click(); await new Promise(resolve => setTimeout(resolve, 40)); }); + await act(async () => { redeem().click(); await new Promise(resolve => setTimeout(resolve, 40)); }); + expect(consumeAttempts).toBe(2); + expect(new Set(consumedOperationIds).size).toBe(1); + expect(consumedOperationIds[0]).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + } finally { + Object.defineProperty(cryptoObject, "randomUUID", { + configurable: true, + value: originalRandomUUID, + }); + } +}); diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index 5b8d57dda8..1fca423fc0 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -1,4 +1,5 @@ import { writeSync } from "node:fs"; +import { randomUUID } from "node:crypto"; import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh"; import { CliUsageError, @@ -232,7 +233,10 @@ async function resetCredits(argv: string[], deps: RuntimeApiDeps): Promise rejectArgs(args, USAGE); const accountId = rawId === "main" ? "__main__" : rawId; const result = consume - ? await runtimeRequest("/api/codex-auth/reset-credits/consume", { method: "POST", body: JSON.stringify({ accountId }) }, deps) + ? await runtimeRequest("/api/codex-auth/reset-credits/consume", { + method: "POST", + body: JSON.stringify({ accountId, operationId: randomUUID() }), + }, deps) : await runtimeRequest(`/api/codex-auth/reset-credits?accountId=${encodeURIComponent(accountId)}`, {}, deps); printData(result, wantsJson); } diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 18d25cfb32..d2a955134c 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -110,6 +110,13 @@ import { CodexResetCreditConsumeError, consumeCodexResetCredit, } from "./reset-credit-consume"; +import { + markManualResetCreditOperationAmbiguous, + openManualResetCreditOperation, + settleManualResetCreditOperation, + type ManualResetCreditOperationIdentity, +} from "./reset-credit-operation-ledger"; +import { isCodexResetCreditOperationId, type CodexResetCreditConsumeCode } from "./reset-credit-recovery"; import { oauthAccountHealthFields, projectCodexAccountHealth, @@ -262,6 +269,33 @@ interface ResetCreditAuth { nativeMainSharedClaimHeld?: true; } +const manualResetCreditFlights = new Map>(); + +function manualResetCreditFlightKey(chatgptAccountId: string): string { + return chatgptAccountId.trim(); +} + +function manualResetCreditBusyResponse(): Response { + const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503); + response.headers.set("Retry-After", "1"); + return response; +} + +async function runManualResetCreditFlight( + chatgptAccountId: string, + start: () => Promise, +): Promise { + const key = manualResetCreditFlightKey(chatgptAccountId); + if (manualResetCreditFlights.has(key)) return manualResetCreditBusyResponse(); + const flight = start(); + manualResetCreditFlights.set(key, flight); + try { + return await flight; + } finally { + if (manualResetCreditFlights.get(key) === flight) manualResetCreditFlights.delete(key); + } +} + async function withResetCreditAuth( runtimeConfig: OcxConfig, accountId: string, @@ -1694,43 +1728,88 @@ export async function handleCodexAuthAPI( } if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") { - const body = (await req.json().catch(() => ({}))) as { accountId?: string }; + const body = (await req.json().catch(() => ({}))) as { accountId?: string; operationId?: string }; if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400); + if (body.accountId !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(body.accountId)) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } + if (!body.operationId || !isCodexResetCreditOperationId(body.operationId)) { + return jsonResponse({ error: "operationId must be an RFC 4122 version 4 UUID" }, 400); + } + const requestedOperationId = body.operationId; const accountId = body.accountId; try { - const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { - const result = await consumeCodexResetCredit({ - accessToken: auth.accessToken, - chatgptAccountId: auth.chatgptAccountId, - operationId: crypto.randomUUID(), - signal: req.signal, - }); - // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage - // and return remaining only when that refresh freshly parsed available_count. - // Do not fall back to a preserved cached resetCredits (failed/omitted refresh). - if (result.code === "reset" || result.code === "already_redeemed") { - let freshResetCredits: number | undefined; - if (auth.isMain) { - ({ freshResetCredits } = await fetchMainAccountInfoAttempt( - true, - 1, - auth.nativeMainLease, - auth.nativeMainSharedClaimHeld === true, - )); + const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, auth => + runManualResetCreditFlight(auth.chatgptAccountId, async () => { + const identity: ManualResetCreditOperationIdentity = { + accountId, + chatgptAccountId: auth.chatgptAccountId, + operationId: requestedOperationId, + }; + const opened = openManualResetCreditOperation(identity); + if (opened.kind === "capacity" || opened.kind === "unavailable") { + const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503); + response.headers.set("Retry-After", "1"); + return response; + } + let code: CodexResetCreditConsumeCode; + if (opened.kind === "terminal") { + code = opened.code; } else { - const account = configuredPoolAccount(getRuntimeConfig(config), accountId); - ({ freshResetCredits } = await fetchPoolAccountQuota(accountId, true, account?.plan)); + if (opened.kind !== "execute") { + const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503); + response.headers.set("Retry-After", "1"); + return response; + } + const effectiveIdentity: ManualResetCreditOperationIdentity = { + ...identity, + operationId: opened.operationId, + }; + try { + const result = await consumeCodexResetCredit({ + accessToken: auth.accessToken, + chatgptAccountId: auth.chatgptAccountId, + operationId: opened.operationId, + signal: req.signal, + }); + code = result.code; + } catch (error) { + markManualResetCreditOperationAmbiguous(effectiveIdentity); + throw error; + } + const settled = settleManualResetCreditOperation(effectiveIdentity, code); + if (settled.kind !== "updated") { + const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503); + response.headers.set("Retry-After", "1"); + return response; + } } - return jsonResponse({ - code: result.code, - ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits) - ? { remaining: freshResetCredits } - : {}), - }); - } - return jsonResponse({ code: result.code }); - }); + // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage + // and return remaining only when that refresh freshly parsed available_count. + // Do not fall back to a preserved cached resetCredits (failed/omitted refresh). + if (code === "reset" || code === "already_redeemed") { + let freshResetCredits: number | undefined; + if (auth.isMain) { + ({ freshResetCredits } = await fetchMainAccountInfoAttempt( + true, + 1, + auth.nativeMainLease, + auth.nativeMainSharedClaimHeld === true, + )); + } else { + const account = configuredPoolAccount(getRuntimeConfig(config), accountId); + ({ freshResetCredits } = await fetchPoolAccountQuota(accountId, true, account?.plan)); + } + return jsonResponse({ + code, + ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits) + ? { remaining: freshResetCredits } + : {}), + }); + } + return jsonResponse({ code }); + })); return operation.ok ? operation.value : operation.response; } catch (e) { if (e instanceof PoolQuotaProbeBusyError) { diff --git a/src/codex/reset-credit-operation-ledger.ts b/src/codex/reset-credit-operation-ledger.ts index b11b35aa7c..cf28fcc6da 100644 --- a/src/codex/reset-credit-operation-ledger.ts +++ b/src/codex/reset-credit-operation-ledger.ts @@ -10,7 +10,7 @@ import { type CodexResetCreditRecoveryGeneration, type CodexReservedOperationId, } from "./reset-credit-recovery"; -import { isValidCodexAccountId } from "./account-id"; +import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id"; export const MAX_RESET_CREDIT_OPERATION_ACCOUNTS = 128; const ACCOUNT_KEY_PATTERN = /^[0-9a-f]{64}$/; @@ -26,11 +26,13 @@ const TERMINAL_STATE_BY_CODE: Readonly = new Set(["pending", "ambiguous", "confirmed", "stopped"]); type ResetCreditOperationState = "pending" | "ambiguous" | "confirmed" | "stopped"; +type ResetCreditOperationKind = "recovery" | "manual"; type ResetCreditOperationRecord = Readonly<{ accountKey: string; - credentialGeneration: number; - exhaustionGeneration: number; + operationKind: ResetCreditOperationKind; + credentialGeneration?: number; + exhaustionGeneration?: number; operationId: string; state: ResetCreditOperationState; code?: CodexResetCreditConsumeCode; @@ -40,6 +42,7 @@ type ResetCreditOperationRecord = Readonly<{ type ResetCreditOperationRow = { account_key: unknown; + operation_kind: unknown; credential_generation: unknown; exhaustion_generation: unknown; operation_id: unknown; @@ -58,8 +61,41 @@ export type UpdateResetCreditOperationResult = | Readonly<{ kind: "updated" }> | Readonly<{ kind: "mismatch" | "unavailable" }>; +export type ManualResetCreditOperationIdentity = Readonly<{ + accountId: string; + chatgptAccountId: string; + operationId: string; +}>; + +export type OpenManualResetCreditOperationResult = + | Readonly<{ kind: "execute"; operationId: CodexReservedOperationId; resumed: boolean }> + | Readonly<{ kind: "terminal"; operationId: CodexReservedOperationId; code: CodexResetCreditConsumeCode }> + | Readonly<{ kind: "capacity" | "unavailable" }>; + const TABLE_NAME = "reset_credit_operations"; const CREATE_TABLE = `CREATE TABLE main.reset_credit_operations ( + account_key TEXT PRIMARY KEY, + operation_kind TEXT NOT NULL CHECK (operation_kind IN ('recovery', 'manual')), + credential_generation INTEGER, + exhaustion_generation INTEGER, + operation_id TEXT NOT NULL, + state TEXT NOT NULL, + code TEXT, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= created_at), + CHECK ( + (operation_kind = 'recovery' + AND credential_generation IS NOT NULL AND credential_generation >= 0 + AND exhaustion_generation IS NOT NULL AND exhaustion_generation >= 0) + OR + (operation_kind = 'manual' + AND credential_generation IS NULL AND exhaustion_generation IS NULL) + ) + ) STRICT, WITHOUT ROWID`; +const EXPECTED_SCHEMA_SQL = CREATE_TABLE.replace("main.", ""); +export const RESET_CREDIT_OPERATION_SCHEMA_SQL_FOR_TESTS = EXPECTED_SCHEMA_SQL; +const LEGACY_TABLE_NAME = "reset_credit_operations_legacy_v1"; +const LEGACY_CREATE_TABLE = `CREATE TABLE reset_credit_operations ( account_key TEXT PRIMARY KEY, credential_generation INTEGER NOT NULL CHECK (credential_generation >= 0), exhaustion_generation INTEGER NOT NULL CHECK (exhaustion_generation >= 0), @@ -69,35 +105,42 @@ const CREATE_TABLE = `CREATE TABLE main.reset_credit_operations ( created_at INTEGER NOT NULL CHECK (created_at >= 0), updated_at INTEGER NOT NULL CHECK (updated_at >= created_at) ) STRICT, WITHOUT ROWID`; -const EXPECTED_SCHEMA_SQL = CREATE_TABLE.replace("main.", ""); -export const RESET_CREDIT_OPERATION_SCHEMA_SQL_FOR_TESTS = EXPECTED_SCHEMA_SQL; +export const RESET_CREDIT_OPERATION_LEGACY_SCHEMA_SQL_FOR_TESTS = LEGACY_CREATE_TABLE; const SELECT_ALL = ` - SELECT account_key, credential_generation, exhaustion_generation, operation_id, + SELECT account_key, operation_kind, credential_generation, + exhaustion_generation, operation_id, state, code, created_at, updated_at FROM main.reset_credit_operations ORDER BY account_key LIMIT ${MAX_RESET_CREDIT_OPERATION_ACCOUNTS + 1}`; const SELECT_BY_KEY = ` - SELECT account_key, credential_generation, exhaustion_generation, operation_id, + SELECT account_key, operation_kind, credential_generation, + exhaustion_generation, operation_id, state, code, created_at, updated_at FROM main.reset_credit_operations WHERE account_key = ? LIMIT 2`; +const SELECT_KEY_BY_OPERATION_ID = ` + SELECT account_key + FROM main.reset_credit_operations + WHERE operation_id = ? + LIMIT 2`; const INSERT_RECORD = ` INSERT INTO main.reset_credit_operations ( - account_key, credential_generation, exhaustion_generation, operation_id, - state, code, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`; + account_key, operation_kind, credential_generation, exhaustion_generation, + operation_id, state, code, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`; const REPLACE_RECORD = ` UPDATE main.reset_credit_operations - SET credential_generation = ?, exhaustion_generation = ?, operation_id = ?, - state = ?, code = ?, created_at = ?, updated_at = ? + SET operation_kind = ?, credential_generation = ?, exhaustion_generation = ?, + operation_id = ?, state = ?, code = ?, + created_at = ?, updated_at = ? WHERE account_key = ?`; const UPDATE_RECORD = ` UPDATE main.reset_credit_operations SET state = ?, code = ?, updated_at = ? - WHERE account_key = ? AND credential_generation = ? - AND exhaustion_generation = ? AND operation_id = ?`; + WHERE account_key = ? AND operation_kind = ? AND operation_id = ? + AND credential_generation IS ? AND exhaustion_generation IS ?`; type SchemaObjectRow = { type: unknown; @@ -126,6 +169,18 @@ type TableColumnRow = { }; const EXPECTED_COLUMNS = Object.freeze([ + Object.freeze({ name: "account_key", type: "TEXT", notnull: 1, pk: 1 }), + Object.freeze({ name: "operation_kind", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "credential_generation", type: "INTEGER", notnull: 0, pk: 0 }), + Object.freeze({ name: "exhaustion_generation", type: "INTEGER", notnull: 0, pk: 0 }), + Object.freeze({ name: "operation_id", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "state", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "code", type: "TEXT", notnull: 0, pk: 0 }), + Object.freeze({ name: "created_at", type: "INTEGER", notnull: 1, pk: 0 }), + Object.freeze({ name: "updated_at", type: "INTEGER", notnull: 1, pk: 0 }), +]); + +const LEGACY_COLUMNS = Object.freeze([ Object.freeze({ name: "account_key", type: "TEXT", notnull: 1, pk: 1 }), Object.freeze({ name: "credential_generation", type: "INTEGER", notnull: 1, pk: 0 }), Object.freeze({ name: "exhaustion_generation", type: "INTEGER", notnull: 1, pk: 0 }), @@ -140,6 +195,20 @@ function accountKey(accountId: string): string { return createHash("sha256").update(`codex-reset-credit-operation\0${accountId}`).digest("hex"); } +function validateManualAccountId(accountId: string): void { + if (accountId !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(accountId)) { + throw new TypeError("invalid manual reset-credit account"); + } +} + +function manualPhysicalAccountKey(chatgptAccountId: string): string { + const normalized = chatgptAccountId.trim(); + if (!normalized) throw new TypeError("invalid manual reset-credit credential identity"); + return createHash("sha256") + .update(`codex-reset-credit-manual-physical\0${normalized}`) + .digest("hex"); +} + function isGenerationNumber(value: unknown): value is number { return Number.isSafeInteger(value) && Number(value) >= 0; } @@ -157,8 +226,7 @@ function parseRecord(row: ResetCreditOperationRow | null): ResetCreditOperationR const state = row.state; const code = row.code; if (typeof row.account_key !== "string" || !ACCOUNT_KEY_PATTERN.test(row.account_key) - || !isGenerationNumber(row.credential_generation) - || !isGenerationNumber(row.exhaustion_generation) + || (row.operation_kind !== "recovery" && row.operation_kind !== "manual") || !isCodexResetCreditOperationId(row.operation_id) || typeof state !== "string" || !STATES.has(state) || !isGenerationNumber(row.created_at) @@ -166,6 +234,14 @@ function parseRecord(row: ResetCreditOperationRow | null): ResetCreditOperationR || row.updated_at < row.created_at) { return undefined; } + const recovery = row.operation_kind === "recovery"; + const manual = row.operation_kind === "manual"; + if (recovery !== (isGenerationNumber(row.credential_generation) + && isGenerationNumber(row.exhaustion_generation)) + || manual !== (row.credential_generation === null + && row.exhaustion_generation === null)) { + return undefined; + } const terminal = state === "confirmed" || state === "stopped"; const terminalState = typeof code === "string" && Object.prototype.hasOwnProperty.call(TERMINAL_STATE_BY_CODE, code) @@ -175,8 +251,13 @@ function parseRecord(row: ResetCreditOperationRow | null): ResetCreditOperationR if (terminal && state !== terminalState) return undefined; return Object.freeze({ accountKey: row.account_key, - credentialGeneration: row.credential_generation, - exhaustionGeneration: row.exhaustion_generation, + operationKind: row.operation_kind, + ...(recovery + ? { + credentialGeneration: row.credential_generation as number, + exhaustionGeneration: row.exhaustion_generation as number, + } + : {}), operationId: row.operation_id, state: state as ResetCreditOperationState, ...(terminal ? { code: code as CodexResetCreditConsumeCode } : {}), @@ -185,60 +266,129 @@ function parseRecord(row: ResetCreditOperationRow | null): ResetCreditOperationR }); } -function assertCanonicalTable(database: Database): void { - const schemaRows = database.query(` - SELECT type, name, tbl_name, sql - FROM main.sqlite_schema - WHERE name = ? COLLATE NOCASE OR tbl_name = ? COLLATE NOCASE - ORDER BY type, name - LIMIT 4 - `).all(TABLE_NAME, TABLE_NAME); - if (schemaRows.length === 0) { - database.exec(CREATE_TABLE); - } else if (schemaRows.length !== 1 - || schemaRows[0]?.type !== "table" - || schemaRows[0]?.name !== TABLE_NAME - || schemaRows[0]?.tbl_name !== TABLE_NAME - || schemaRows[0]?.sql !== EXPECTED_SCHEMA_SQL) { - throw new Error("invalid reset-credit operation ledger schema"); - } - +function assertColumnLayout( + database: Database, + tableName: string, + expectedColumns: readonly Readonly<{ name: string; type: string; notnull: number; pk: number }>[], +): void { const tableRows = database.query("PRAGMA main.table_list").all() - .filter(row => row.name === TABLE_NAME); + .filter(row => row.name === tableName); if (tableRows.length !== 1) throw new Error("invalid reset-credit operation ledger table"); const table = tableRows[0]!; - if (table.schema !== "main" || table.type !== "table" || table.ncol !== EXPECTED_COLUMNS.length + if (table.schema !== "main" || table.type !== "table" || table.ncol !== expectedColumns.length || table.wr !== 1 || table.strict !== 1) { throw new Error("invalid reset-credit operation ledger table"); } - const columns = database.query( - `PRAGMA main.table_xinfo(${TABLE_NAME})`, + `PRAGMA main.table_xinfo(${tableName})`, ).all(); - if (columns.length !== EXPECTED_COLUMNS.length) { + if (columns.length !== expectedColumns.length) { throw new Error("invalid reset-credit operation ledger columns"); } - for (let index = 0; index < EXPECTED_COLUMNS.length; index += 1) { + for (let index = 0; index < expectedColumns.length; index += 1) { const actual = columns[index]!; - const expected = EXPECTED_COLUMNS[index]!; + const expected = expectedColumns[index]!; if (actual.cid !== index || actual.name !== expected.name || actual.type !== expected.type || actual.notnull !== expected.notnull || actual.dflt_value !== null || actual.pk !== expected.pk || actual.hidden !== 0) { throw new Error("invalid reset-credit operation ledger columns"); } } +} +function assertNoLedgerTriggers(database: Database, tableName: string): void { const mainTrigger = database.query<{ name: unknown }, [string]>(` SELECT name FROM main.sqlite_schema WHERE type = 'trigger' AND tbl_name = ? COLLATE NOCASE LIMIT 1 - `).get(TABLE_NAME); + `).get(tableName); const tempTrigger = database.query<{ name: unknown }, [string]>(` SELECT name FROM temp.sqlite_schema WHERE type = 'trigger' AND tbl_name = ? COLLATE NOCASE LIMIT 1 - `).get(TABLE_NAME); + `).get(tableName); if (mainTrigger || tempTrigger) throw new Error("reset-credit operation ledger triggers are forbidden"); } +function migrateLegacyTable(database: Database): void { + assertColumnLayout(database, TABLE_NAME, LEGACY_COLUMNS); + assertNoLedgerTriggers(database, TABLE_NAME); + const legacyRows = database.query<{ + account_key: unknown; + credential_generation: unknown; + exhaustion_generation: unknown; + operation_id: unknown; + state: unknown; + code: unknown; + created_at: unknown; + updated_at: unknown; + }, []>(` + SELECT account_key, credential_generation, exhaustion_generation, operation_id, + state, code, created_at, updated_at + FROM main.reset_credit_operations + ORDER BY account_key + LIMIT ${MAX_RESET_CREDIT_OPERATION_ACCOUNTS + 1} + `).all(); + if (legacyRows.length > MAX_RESET_CREDIT_OPERATION_ACCOUNTS) { + throw new Error("invalid reset-credit operation ledger capacity"); + } + const keys = new Set(); + const operations = new Set(); + for (const row of legacyRows) { + const record = parseRecord({ ...row, operation_kind: "recovery" }); + if (!record || keys.has(record.accountKey) || operations.has(record.operationId)) { + throw new Error("invalid reset-credit operation ledger state"); + } + keys.add(record.accountKey); + operations.add(record.operationId); + } + database.exec(`ALTER TABLE main.${TABLE_NAME} RENAME TO ${LEGACY_TABLE_NAME}`); + database.exec(CREATE_TABLE); + database.exec(` + INSERT INTO main.${TABLE_NAME} ( + account_key, operation_kind, credential_generation, exhaustion_generation, + operation_id, state, code, created_at, updated_at + ) + SELECT account_key, 'recovery', credential_generation, exhaustion_generation, + operation_id, state, code, created_at, updated_at + FROM main.${LEGACY_TABLE_NAME} + `); + database.exec(`DROP TABLE main.${LEGACY_TABLE_NAME}`); +} + +function isExactLegacySchema(database: Database, schema: SchemaObjectRow): boolean { + if (schema.type !== "table" || schema.name !== TABLE_NAME || schema.tbl_name !== TABLE_NAME + || schema.sql !== LEGACY_CREATE_TABLE) return false; + try { + assertColumnLayout(database, TABLE_NAME, LEGACY_COLUMNS); + assertNoLedgerTriggers(database, TABLE_NAME); + return true; + } catch { + return false; + } +} + +function assertCanonicalTable(database: Database): void { + const schemaRows = database.query(` + SELECT type, name, tbl_name, sql + FROM main.sqlite_schema + WHERE name = ? COLLATE NOCASE OR tbl_name = ? COLLATE NOCASE + ORDER BY type, name + LIMIT 4 + `).all(TABLE_NAME, TABLE_NAME); + if (schemaRows.length === 0) { + database.exec(CREATE_TABLE); + } else if (schemaRows.length === 1 && isExactLegacySchema(database, schemaRows[0]!)) { + migrateLegacyTable(database); + } else if (schemaRows.length !== 1 + || schemaRows[0]?.type !== "table" + || schemaRows[0]?.name !== TABLE_NAME + || schemaRows[0]?.tbl_name !== TABLE_NAME + || schemaRows[0]?.sql !== EXPECTED_SCHEMA_SQL) { + throw new Error("invalid reset-credit operation ledger schema"); + } + assertColumnLayout(database, TABLE_NAME, EXPECTED_COLUMNS); + assertNoLedgerTriggers(database, TABLE_NAME); +} + function initializeTable(database: Database): number { assertCanonicalTable(database); const rows = database.query(SELECT_ALL).all(); @@ -267,8 +417,19 @@ function readRecord(database: Database, key: string): ResetCreditOperationRecord return record; } +function operationOwner(database: Database, operationId: string): string | undefined { + const rows = database.query<{ account_key: unknown }, [string]>(SELECT_KEY_BY_OPERATION_ID).all(operationId); + if (rows.length > 1) throw new Error("duplicate reset-credit operation ids"); + const owner = rows[0]?.account_key; + if (owner !== undefined && (typeof owner !== "string" || !ACCOUNT_KEY_PATTERN.test(owner))) { + throw new Error("invalid reset-credit operation owner"); + } + return owner; +} + function sameRecord(left: ResetCreditOperationRecord, right: ResetCreditOperationRecord): boolean { return left.accountKey === right.accountKey + && left.operationKind === right.operationKind && left.credentialGeneration === right.credentialGeneration && left.exhaustionGeneration === right.exhaustionGeneration && left.operationId === right.operationId @@ -294,8 +455,8 @@ function compareGeneration( ): -1 | 0 | 1 { return compareCodexResetCreditRecoveryGenerationOrder({ accountId: generation.accountId, - credentialGeneration: record.credentialGeneration, - exhaustionGeneration: record.exhaustionGeneration, + credentialGeneration: record.credentialGeneration!, + exhaustionGeneration: record.exhaustionGeneration!, }, generation); } @@ -373,6 +534,9 @@ export function openResetCreditOperation( const key = accountKey(generation.accountId); const current = readRecord(database, key); if (current) { + if (current.operationKind !== "recovery") { + return Object.freeze({ kind: "unresolved-prior-generation" as const }); + } const comparison = compareGeneration(current, generation); if (comparison > 0) return Object.freeze({ kind: "stale-generation" as const }); if (comparison === 0) { @@ -397,6 +561,7 @@ export function openResetCreditOperation( const operationId = randomUUID(); if (!isCodexResetCreditOperationId(operationId)) throw new Error("runtime generated invalid UUID"); const values = [ + "recovery", generation.credentialGeneration, generation.exhaustionGeneration, operationId, @@ -404,14 +569,14 @@ export function openResetCreditOperation( null, now, now, - key, ] as const; const result = current - ? database.query(REPLACE_RECORD).run(...values) - : database.query(INSERT_RECORD).run(key, ...values.slice(0, 7)); + ? database.query(REPLACE_RECORD).run(...values, key) + : database.query(INSERT_RECORD).run(key, ...values); if (result.changes !== 1) throw new Error("reset-credit operation reservation lost ownership"); assertStoredRecord(database, Object.freeze({ accountKey: key, + operationKind: "recovery", credentialGeneration: generation.credentialGeneration, exhaustionGeneration: generation.exhaustionGeneration, operationId, @@ -432,18 +597,23 @@ export function openResetCreditOperation( } function updateOperation( - generation: CodexResetCreditRecoveryGeneration, + owner: Readonly<{ + accountKey: string; + operationKind: ResetCreditOperationKind; + credentialGeneration?: number; + exhaustionGeneration?: number; + }>, operationId: string, update: (record: ResetCreditOperationRecord) => ResetCreditOperationRecord | undefined, ): UpdateResetCreditOperationResult { - validateGeneration(generation); if (!isCodexResetCreditOperationId(operationId)) return Object.freeze({ kind: "mismatch" }); try { return withLedger(database => { - const key = accountKey(generation.accountId); - const current = readRecord(database, key); + const current = readRecord(database, owner.accountKey); if (!current - || compareGeneration(current, generation) !== 0 + || current.operationKind !== owner.operationKind + || current.credentialGeneration !== owner.credentialGeneration + || current.exhaustionGeneration !== owner.exhaustionGeneration || current.operationId !== operationId) { return Object.freeze({ kind: "mismatch" as const }); } @@ -453,10 +623,11 @@ function updateOperation( updated.state, updated.code ?? null, updated.updatedAt, - key, - generation.credentialGeneration, - generation.exhaustionGeneration, + owner.accountKey, + owner.operationKind, operationId, + owner.credentialGeneration ?? null, + owner.exhaustionGeneration ?? null, ); if (result.changes !== 1) throw new Error("reset-credit operation update lost ownership"); assertStoredRecord(database, updated); @@ -478,7 +649,13 @@ export function markResetCreditOperationAmbiguous( now = Date.now(), ): UpdateResetCreditOperationResult { if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); - return updateOperation(generation, operationId, record => { + validateGeneration(generation); + return updateOperation({ + accountKey: accountKey(generation.accountId), + operationKind: "recovery", + credentialGeneration: generation.credentialGeneration, + exhaustionGeneration: generation.exhaustionGeneration, + }, operationId, record => { if (isTerminal(record)) return undefined; return Object.freeze({ ...record, @@ -504,7 +681,135 @@ export function settleResetCreditOperation( if (!Object.prototype.hasOwnProperty.call(TERMINAL_STATE_BY_CODE, code)) { return Object.freeze({ kind: "mismatch" }); } - return updateOperation(generation, operationId, record => { + validateGeneration(generation); + return updateOperation({ + accountKey: accountKey(generation.accountId), + operationKind: "recovery", + credentialGeneration: generation.credentialGeneration, + exhaustionGeneration: generation.exhaustionGeneration, + }, operationId, record => { + if (isTerminal(record)) return record.code === code ? record : undefined; + return Object.freeze({ + ...record, + state: TERMINAL_STATE_BY_CODE[code], + code, + updatedAt: Math.max(record.updatedAt, now), + }); + }); +} + +function validateManualIdentity(identity: ManualResetCreditOperationIdentity): { + accountKey: string; +} { + if (!isCodexResetCreditOperationId(identity.operationId)) { + throw new TypeError("invalid manual reset-credit operation id"); + } + validateManualAccountId(identity.accountId); + return { accountKey: manualPhysicalAccountKey(identity.chatgptAccountId) }; +} + +/** Reserve or restore one explicit manual redemption intent. */ +export function openManualResetCreditOperation( + identity: ManualResetCreditOperationIdentity, + now = Date.now(), +): OpenManualResetCreditOperationResult { + const owner = validateManualIdentity(identity); + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); + try { + return withLedger((database, recordCount) => { + const current = readRecord(database, owner.accountKey); + if (current) { + if (current.operationKind !== "manual") { + return Object.freeze({ kind: "unavailable" as const }); + } + if (current.operationId !== identity.operationId) { + if (!isTerminal(current)) { + return Object.freeze({ + kind: "execute" as const, + operationId: current.operationId as CodexReservedOperationId, + resumed: true, + }); + } + } else if (isTerminal(current)) { + return Object.freeze({ + kind: "terminal" as const, + operationId: current.operationId as CodexReservedOperationId, + code: current.code!, + }); + } else { + return Object.freeze({ + kind: "execute" as const, + operationId: current.operationId as CodexReservedOperationId, + resumed: true, + }); + } + } else if (recordCount >= MAX_RESET_CREDIT_OPERATION_ACCOUNTS) { + return Object.freeze({ kind: "capacity" as const }); + } + + const existingOwner = operationOwner(database, identity.operationId); + if (existingOwner !== undefined && existingOwner !== owner.accountKey) { + return Object.freeze({ kind: "unavailable" as const }); + } + + const record: ResetCreditOperationRecord = Object.freeze({ + accountKey: owner.accountKey, + operationKind: "manual", + operationId: identity.operationId, + state: "pending", + createdAt: now, + updatedAt: now, + }); + const values = [ + "manual", + null, + null, + identity.operationId, + "pending", + null, + now, + now, + ] as const; + const result = current + ? database.query(REPLACE_RECORD).run(...values, owner.accountKey) + : database.query(INSERT_RECORD).run(owner.accountKey, ...values); + if (result.changes !== 1) throw new Error("manual reset-credit reservation lost ownership"); + assertStoredRecord(database, record); + return Object.freeze({ + kind: "execute" as const, + operationId: identity.operationId as CodexReservedOperationId, + resumed: false, + }); + }); + } catch (error) { + warnLedgerUnavailable(error); + return Object.freeze({ kind: "unavailable" }); + } +} + +export function markManualResetCreditOperationAmbiguous( + identity: ManualResetCreditOperationIdentity, + now = Date.now(), +): UpdateResetCreditOperationResult { + const owner = validateManualIdentity(identity); + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); + return updateOperation({ ...owner, operationKind: "manual" }, identity.operationId, record => { + if (isTerminal(record)) return undefined; + return Object.freeze({ ...record, state: "ambiguous", code: undefined, updatedAt: Math.max(record.updatedAt, now) }); + }); +} + +export function settleManualResetCreditOperation( + identity: ManualResetCreditOperationIdentity, + code: CodexResetCreditConsumeCode, + now = Date.now(), +): UpdateResetCreditOperationResult { + const owner = validateManualIdentity(identity); + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); + if (!Object.prototype.hasOwnProperty.call(TERMINAL_STATE_BY_CODE, code)) { + return Object.freeze({ kind: "mismatch" }); + } + return updateOperation({ ...owner, operationKind: "manual" }, identity.operationId, record => { if (isTerminal(record)) return record.code === code ? record : undefined; return Object.freeze({ ...record, diff --git a/tests/cli-account.test.ts b/tests/cli-account.test.ts index 8a0110d679..c64c6316e6 100644 --- a/tests/cli-account.test.ts +++ b/tests/cli-account.test.ts @@ -141,6 +141,10 @@ async function mockManagementApi(req: Request): Promise { return json({ accounts: codexAccounts }); } + if (req.method === "POST" && url.pathname === "/api/codex-auth/reset-credits/consume") { + return json({ code: "reset" }); + } + if (req.method === "DELETE" && url.pathname === "/api/codex-auth/accounts") { if (deleteFailure) return json({ error: deleteFailure.error }, deleteFailure.status); const id = url.searchParams.get("id"); @@ -1403,6 +1407,22 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(requests).toHaveLength(before); }); + test("reset-credit consume sends one UUIDv4 operation identity", async () => { + const result = await run(["reset-credits", "main", "--consume", "--yes", "--json"]); + + expect(result.code).toBe(0); + expect(requests.at(-1)).toEqual(expect.objectContaining({ + method: "POST", + path: "/api/codex-auth/reset-credits/consume", + body: expect.objectContaining({ + accountId: "__main__", + operationId: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ), + }), + })); + }); + test("a silent pipe times out and cleans up its listeners", async () => { const silent = new PassThrough() as AccountStdin; silent.isTTY = false; diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 34727a9038..04a924a8f1 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -59,6 +59,7 @@ import { import * as configModule from "../src/config"; import type { CatalogDisposition } from "../src/codex/convergence-types"; import { captureConfigGeneration, registerStateStore } from "../src/lib/state-store-sweeper"; +import { randomUUID } from "node:crypto"; import { reconcileLiveStateStores, setLiveStateStoreConfig, @@ -73,6 +74,9 @@ import { BOUNDED_BODY_MAX_BYTES } from "../src/lib/bounded-body"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-auth-api-test"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT"; +function resetCreditConsumeBody(accountId: string): string { + return JSON.stringify({ accountId, operationId: randomUUID() }); +} let previousOpencodexHome: string | undefined; let previousCodexHome: string | undefined; let previousManualImportEnv: string | undefined; @@ -548,7 +552,7 @@ describe("codex-auth API", () => { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ accountId: MAIN_CODEX_ACCOUNT_ID }), + body: resetCreditConsumeBody(MAIN_CODEX_ACCOUNT_ID), }); return handleCodexAuthAPI(req, new URL(req.url), makeConfig()); }; @@ -674,7 +678,7 @@ describe("codex-auth API", () => { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ accountId: "quota-reset-busy" }), + body: resetCreditConsumeBody("quota-reset-busy"), }); const response = await handleCodexAuthAPI(req, new URL(req.url), config); expect(response?.status).toBe(503); @@ -2127,6 +2131,19 @@ describe("codex-auth API", () => { expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); }); + test("reset-credit consume requires a caller-stable operation id", async () => { + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: "pool-without-operation" }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toEqual({ + error: "operationId must be an RFC 4122 version 4 UUID", + }); + }); + test("reset-credit consume sanitizes a pre-dispatch client abort", async () => { const config = makeConfig(); seedPoolAccount(config, { id: "pool-aborted", email: "aborted@example.test" }); @@ -2142,7 +2159,7 @@ describe("codex-auth API", () => { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ accountId: "pool-aborted" }), + body: resetCreditConsumeBody("pool-aborted"), signal: controller.signal, }); const resp = await handleCodexAuthAPI(req, new URL(req.url), config); @@ -2155,6 +2172,139 @@ describe("codex-auth API", () => { } }); + test("reset-credit consume resumes an ambiguous operation id and terminally short-circuits it", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-ambiguous", email: "ambiguous@example.test" }); + const operationId = randomUUID(); + const seenOperationIds: string[] = []; + let consumeCalls = 0; + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/backend-api/wham/rate-limit-reset-credits/consume")) { + consumeCalls += 1; + const body = JSON.parse(String(init?.body)) as { redeem_request_id?: string }; + seenOperationIds.push(body.redeem_request_id ?? ""); + if (consumeCalls === 1) throw new Error("response lost after dispatch"); + return Response.json({ code: "already_redeemed" }); + } + if (url.includes("/backend-api/wham/usage")) { + return Response.json({ rate_limit_reset_credits: { available_count: 1 } }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const request = () => { + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: "pool-ambiguous", operationId }), + }); + return handleCodexAuthAPI(req, new URL(req.url), config); + }; + + const first = await request(); + expect(first?.status).toBe(502); + expect(await first?.json()).toEqual({ error: "Invalid upstream reset-credit response" }); + + const second = await request(); + expect(second?.status).toBe(200); + expect(await second?.json()).toEqual({ code: "already_redeemed", remaining: 1 }); + + const third = await request(); + expect(third?.status).toBe(200); + expect(await third?.json()).toEqual({ code: "already_redeemed", remaining: 1 }); + expect(consumeCalls).toBe(2); + expect(seenOperationIds).toEqual([operationId, operationId]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("reset-credit consume resumes the prior id when the client starts a new retry intent", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-conflict", email: "conflict@example.test" }); + let consumeCalls = 0; + const seenOperationIds: string[] = []; + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).includes("/backend-api/wham/rate-limit-reset-credits/consume")) { + consumeCalls += 1; + seenOperationIds.push( + (JSON.parse(String(init?.body)) as { redeem_request_id: string }).redeem_request_id, + ); + throw new Error("response lost after dispatch"); + } + return originalFetch(input); + }) as typeof fetch; + const call = async (operationId: string) => { + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: "pool-conflict", operationId }), + }); + return await handleCodexAuthAPI(req, new URL(req.url), config); + }; + expect((await call(randomUUID()))?.status).toBe(502); + expect((await call(randomUUID()))?.status).toBe(502); + expect(consumeCalls).toBe(2); + expect(new Set(seenOperationIds).size).toBe(1); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("concurrent reset-credit retries share one process-local consume flight", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-flight", email: "flight@example.test" }); + const operationId = randomUUID(); + let consumeCalls = 0; + let releaseConsume!: () => void; + const consumeReleased = new Promise(resolve => { releaseConsume = resolve; }); + let consumeStarted!: () => void; + const started = new Promise(resolve => { consumeStarted = resolve; }); + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/backend-api/wham/rate-limit-reset-credits/consume")) { + consumeCalls += 1; + expect((JSON.parse(String(init?.body)) as { redeem_request_id: string }).redeem_request_id) + .toBe(operationId); + consumeStarted(); + await consumeReleased; + return Response.json({ code: "nothing_to_reset" }); + } + return originalFetch(input, init); + }) as typeof fetch; + const request = () => { + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: "pool-flight", operationId }), + }); + return handleCodexAuthAPI(req, new URL(req.url), config); + }; + const first = request(); + await started; + const second = request(); + await Promise.resolve(); + expect(consumeCalls).toBe(1); + releaseConsume(); + const [firstResponse, secondResponse] = await Promise.all([first, second]); + expect(firstResponse?.status).toBe(200); + expect(secondResponse?.status).toBe(503); + expect(await firstResponse?.json()).toEqual({ code: "nothing_to_reset" }); + expect(await secondResponse?.json()).toEqual({ error: "server_busy", code: "server_busy" }); + expect(consumeCalls).toBe(1); + } finally { + releaseConsume?.(); + globalThis.fetch = originalFetch; + } + }); + test("reset-credit consume returns remaining from refreshed quota, not the consume payload", async () => { const config = makeConfig(); seedPoolAccount(config, { id: "pool-reset", email: "reset@example.test" }); @@ -2187,7 +2337,7 @@ describe("codex-auth API", () => { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ accountId: "pool-reset" }), + body: resetCreditConsumeBody("pool-reset"), }); const resp = await handleCodexAuthAPI(req, new URL(req.url), config); expect(resp!.status).toBe(200); @@ -2222,7 +2372,7 @@ describe("codex-auth API", () => { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ accountId: "pool-idempotent" }), + body: resetCreditConsumeBody("pool-idempotent"), }); const resp = await handleCodexAuthAPI(req, new URL(req.url), config); expect(resp!.status).toBe(200); @@ -2256,7 +2406,7 @@ describe("codex-auth API", () => { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ accountId: "pool-nocount" }), + body: resetCreditConsumeBody("pool-nocount"), }); const resp = await handleCodexAuthAPI(req, new URL(req.url), config); expect(resp!.status).toBe(200); @@ -2288,7 +2438,7 @@ describe("codex-auth API", () => { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ accountId: "pool-wham-fail" }), + body: resetCreditConsumeBody("pool-wham-fail"), }); const resp = await handleCodexAuthAPI(req, new URL(req.url), config); expect(resp!.status).toBe(200); @@ -2322,7 +2472,7 @@ describe("codex-auth API", () => { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ accountId: MAIN_CODEX_ACCOUNT_ID }), + body: resetCreditConsumeBody(MAIN_CODEX_ACCOUNT_ID), }); const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); expect(resp!.status).toBe(200); @@ -2359,7 +2509,7 @@ describe("codex-auth API", () => { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ accountId: MAIN_CODEX_ACCOUNT_ID }), + body: resetCreditConsumeBody(MAIN_CODEX_ACCOUNT_ID), }); const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); expect(resp!.status).toBe(200); @@ -2397,7 +2547,7 @@ describe("codex-auth API", () => { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ accountId: MAIN_CODEX_ACCOUNT_ID }), + body: resetCreditConsumeBody(MAIN_CODEX_ACCOUNT_ID), }); const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); expect(resp!.status).toBe(200); diff --git a/tests/codex-reset-credit-operation-ledger.test.ts b/tests/codex-reset-credit-operation-ledger.test.ts index d9beb3e609..c55ccdec58 100644 --- a/tests/codex-reset-credit-operation-ledger.test.ts +++ b/tests/codex-reset-credit-operation-ledger.test.ts @@ -5,9 +5,13 @@ import { join } from "node:path"; import { withConfigMutationLockSync } from "../src/config"; import { MAX_RESET_CREDIT_OPERATION_ACCOUNTS, + RESET_CREDIT_OPERATION_LEGACY_SCHEMA_SQL_FOR_TESTS, RESET_CREDIT_OPERATION_SCHEMA_SQL_FOR_TESTS, + markManualResetCreditOperationAmbiguous, markResetCreditOperationAmbiguous, + openManualResetCreditOperation, openResetCreditOperation, + settleManualResetCreditOperation, settleResetCreditOperation, } from "../src/codex/reset-credit-operation-ledger"; import { @@ -80,6 +84,120 @@ afterEach(async () => { }); describe("Codex reset-credit operation ledger", () => { + test("migrates the exact prior recovery schema without changing durable state", () => { + const database = new Database(databasePath(), { create: true }); + const key = createHash("sha256") + .update(`codex-reset-credit-operation\0${GENERATION.accountId}`) + .digest("hex"); + const operationId = fixtureOperationId(699); + try { + database.exec(RESET_CREDIT_OPERATION_LEGACY_SCHEMA_SQL_FOR_TESTS); + database.prepare(` + INSERT INTO reset_credit_operations VALUES (?, ?, ?, ?, 'ambiguous', NULL, 100, 200) + `).run(key, GENERATION.credentialGeneration, GENERATION.exhaustionGeneration, operationId); + } finally { + database.close(); + } + + expect(openResetCreditOperation(GENERATION, 300)).toEqual({ + kind: "execute", + operationId, + resumed: true, + }); + const migrated = new Database(databasePath(), { readonly: true }); + try { + expect(migrated.query, []>( + "SELECT * FROM reset_credit_operations", + ).get()).toMatchObject({ + account_key: key, + operation_kind: "recovery", + credential_generation: GENERATION.credentialGeneration, + exhaustion_generation: GENERATION.exhaustionGeneration, + operation_id: operationId, + state: "ambiguous", + created_at: 100, + updated_at: 200, + }); + } finally { + migrated.close(); + } + }); + + test("manual operations resume one intent and short-circuit its terminal result", () => { + const identity = { + accountId: "pool-manual", + chatgptAccountId: "chatgpt-manual", + operationId: fixtureOperationId(700), + }; + expect(openManualResetCreditOperation(identity, 100)).toEqual({ + kind: "execute", + operationId: identity.operationId, + resumed: false, + }); + expect(markManualResetCreditOperationAmbiguous(identity, 200)).toEqual({ kind: "updated" }); + expect(openManualResetCreditOperation(identity, 300)).toEqual({ + kind: "execute", + operationId: identity.operationId, + resumed: true, + }); + expect(settleManualResetCreditOperation( + identity, + "not-a-reset-code" as never, + 350, + )).toEqual({ kind: "mismatch" }); + expect(settleManualResetCreditOperation(identity, "already_redeemed", 400)) + .toEqual({ kind: "updated" }); + expect(openManualResetCreditOperation(identity, 500)).toEqual({ + kind: "terminal", + operationId: identity.operationId, + code: "already_redeemed", + }); + }); + + test("manual operations share one physical-account intent across local aliases", () => { + const first = { + accountId: "pool-manual-fence", + chatgptAccountId: "chatgpt-a", + operationId: fixtureOperationId(701), + }; + expect(openManualResetCreditOperation(first, 100)).toMatchObject({ kind: "execute" }); + expect(openManualResetCreditOperation({ ...first, operationId: fixtureOperationId(702) }, 200)) + .toEqual({ kind: "execute", operationId: first.operationId, resumed: true }); + expect(openManualResetCreditOperation({ + ...first, + accountId: "pool-manual-alias", + operationId: fixtureOperationId(703), + }, 300)).toEqual({ kind: "execute", operationId: first.operationId, resumed: true }); + const otherPhysical = { + ...first, + chatgptAccountId: "chatgpt-b", + operationId: fixtureOperationId(704), + }; + expect(openManualResetCreditOperation(otherPhysical, 400)) + .toEqual({ kind: "execute", operationId: otherPhysical.operationId, resumed: false }); + }); + + test("manual operations reject a caller UUID already owned by another physical account", () => { + const operationId = fixtureOperationId(705); + const first = { + accountId: "pool-manual-first", + chatgptAccountId: "chatgpt-first", + operationId, + }; + const second = { + accountId: "pool-manual-second", + chatgptAccountId: "chatgpt-second", + operationId, + }; + expect(openManualResetCreditOperation(first, 100)).toMatchObject({ kind: "execute" }); + expect(openManualResetCreditOperation(second, 200)).toEqual({ kind: "unavailable" }); + expect(openManualResetCreditOperation(first, 300)).toEqual({ + kind: "execute", + operationId, + resumed: true, + }); + }); + test("creates the exact canonical SQLite schema", () => { expect(openResetCreditOperation(GENERATION, 100)) .toMatchObject({ kind: "execute", resumed: false }); @@ -248,9 +366,9 @@ describe("Codex reset-credit operation ledger", () => { .digest("hex"); database.prepare(` INSERT INTO reset_credit_operations ( - account_key, credential_generation, exhaustion_generation, operation_id, + account_key, operation_kind, credential_generation, exhaustion_generation, operation_id, state, code, created_at, updated_at - ) VALUES (?, ?, ?, ?, 'pending', NULL, 1, 1)`) + ) VALUES (?, 'recovery', ?, ?, ?, 'pending', NULL, 1, 1)`) .run( secondKey, GENERATION.credentialGeneration, @@ -330,9 +448,9 @@ describe("Codex reset-credit operation ledger", () => { try { const insert = database.prepare(` INSERT INTO reset_credit_operations ( - account_key, credential_generation, exhaustion_generation, operation_id, + account_key, operation_kind, credential_generation, exhaustion_generation, operation_id, state, code, created_at, updated_at - ) VALUES (?, ?, ?, ?, 'pending', NULL, 1, 1)`); + ) VALUES (?, 'recovery', ?, ?, ?, 'pending', NULL, 1, 1)`); database.exec("BEGIN IMMEDIATE"); for (let index = 1; index < MAX_RESET_CREDIT_OPERATION_ACCOUNTS; index += 1) { const accountId = `pool-${index}`; @@ -361,9 +479,9 @@ describe("Codex reset-credit operation ledger", () => { .digest("hex"); overflow.prepare(` INSERT INTO reset_credit_operations ( - account_key, credential_generation, exhaustion_generation, operation_id, + account_key, operation_kind, credential_generation, exhaustion_generation, operation_id, state, code, created_at, updated_at - ) VALUES (?, ?, ?, ?, 'pending', NULL, 1, 1)`) + ) VALUES (?, 'recovery', ?, ?, ?, 'pending', NULL, 1, 1)`) .run( key, GENERATION.credentialGeneration, From 338feb85b4730f3f7ad16ec29686e66e59174346 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:22:30 +0900 Subject: [PATCH 4/7] docs(codex): clarify manual ledger result contract --- src/codex/reset-credit-operation-ledger.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/codex/reset-credit-operation-ledger.ts b/src/codex/reset-credit-operation-ledger.ts index cf28fcc6da..2c58c06db7 100644 --- a/src/codex/reset-credit-operation-ledger.ts +++ b/src/codex/reset-credit-operation-ledger.ts @@ -708,7 +708,12 @@ function validateManualIdentity(identity: ManualResetCreditOperationIdentity): { return { accountKey: manualPhysicalAccountKey(identity.chatgptAccountId) }; } -/** Reserve or restore one explicit manual redemption intent. */ +/** + * Reserve or restore one explicit manual redemption intent. + * + * Throws `TypeError` for malformed identity fields or `now`; these are caller + * contract violations. Durable-state and runtime failures return a result kind. + */ export function openManualResetCreditOperation( identity: ManualResetCreditOperationIdentity, now = Date.now(), @@ -787,6 +792,12 @@ export function openManualResetCreditOperation( } } +/** + * Mark a reserved manual redemption as ambiguous. + * + * Throws `TypeError` for malformed identity fields or `now`. A missing or + * incompatible durable record returns the existing result kind. + */ export function markManualResetCreditOperationAmbiguous( identity: ManualResetCreditOperationIdentity, now = Date.now(), @@ -799,6 +810,12 @@ export function markManualResetCreditOperationAmbiguous( }); } +/** + * Settle a reserved manual redemption with one terminal consume code. + * + * Throws `TypeError` for malformed identity fields or `now`; an unsupported + * code or incompatible durable record returns `mismatch`. + */ export function settleManualResetCreditOperation( identity: ManualResetCreditOperationIdentity, code: CodexResetCreditConsumeCode, From f38ea9c98be49ea542f724c59e765b8a2d9c7f97 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:24:12 +0900 Subject: [PATCH 5/7] fix(codex): enforce reset-credit consent boundary --- AGENTS_INSTALL.md | 26 ++- .../docs/getting-started/for-agents.md | 7 + .../ja/reference/cli/providers-accounts.md | 2 +- .../docs/ja/reference/management-api.md | 2 +- .../ko/reference/cli/providers-accounts.md | 2 +- .../docs/ko/reference/management-api.md | 2 +- .../docs/reference/cli/providers-accounts.md | 3 +- .../content/docs/reference/management-api.md | 2 +- .../ru/reference/cli/providers-accounts.md | 5 +- .../docs/ru/reference/management-api.md | 2 +- .../zh-cn/reference/cli/providers-accounts.md | 4 +- .../docs/zh-cn/reference/management-api.md | 2 +- .../zh-tw/reference/cli/providers-accounts.md | 2 +- .../docs/zh-tw/reference/management-api.md | 2 +- src/cli/account-api.ts | 4 + src/cli/account-auth.ts | 54 ++++- src/cli/reset-credit-consent-client.ts | 162 ++++++++++++++ src/codex/auth-api.ts | 35 +-- src/codex/reset-credit-operation-ledger.ts | 31 ++- src/codex/reset-credit-recovery.ts | 8 +- src/config.ts | 11 +- .../codex-reset-credit-consent-contract.ts | 138 ++++++++++++ src/server/index.ts | 2 + src/server/management-api.ts | 2 +- src/server/management-auth.ts | 68 +++++- src/server/proxy-liveness.ts | 1 + tests/cli-account.test.ts | 68 +++++- tests/codex-auth-api.test.ts | 115 ++++++++-- ...odex-reset-credit-operation-ledger.test.ts | 74 ++++++ tests/reset-credit-consent-client.test.ts | 144 ++++++++++++ tests/server-auth.test.ts | 3 + tests/server-management-auth.test.ts | 211 ++++++++++++++++++ 32 files changed, 1101 insertions(+), 93 deletions(-) create mode 100644 src/cli/reset-credit-consent-client.ts create mode 100644 src/lib/codex-reset-credit-consent-contract.ts create mode 100644 tests/reset-credit-consent-client.test.ts diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index 445da2e4b9..87955cbc53 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -5,8 +5,8 @@ user's machine. It is not about contributing to this repository — that is [`AGENTS.md`](./AGENTS.md), and nothing here concerns you if you are only reading or changing this codebase. -There is one rule, and it exists because `ocx start` can print a question that -an agent is capable of answering and must not. +There is one consent rule, and it covers user-owned identity and scarce credits +that an agent is capable of spending and must not. For everything else about driving opencodex from a scripted terminal — installation, `ocx init`, headless flags, exit codes — see the @@ -22,7 +22,8 @@ install or operations task authorizes, and an agent must never perform or auto-answer them — even when the surrounding task is approved, even when the credential is sitting right there. -The current one is **starring the repository on GitHub**. +The current consent-bound actions are **starring the repository on GitHub** and +**consuming a Codex reset credit**. ### Do not @@ -35,6 +36,13 @@ The current one is **starring the repository on GitHub**. `ocx service install`. When an agent is detected the prompt is deliberately suppressed and its one-time marker left unwritten, so the user still gets the real question on their own next run. +- Do **not** run `ocx account reset-credits --consume --yes` or call + `POST /api/codex-auth/reset-credits/consume` on the user's behalf. Inspecting + reset credits is read-only and allowed; consuming one is not. Agent-driven + CLI runs are refused, and the API returns `403 agent_consent_required` unless + the request carries a dashboard GUI session or the CLI's short-lived, + one-shot local consent capability. A reusable admin token or a client + `confirmed` field is not consent; do not route around either refusal. ### Do @@ -68,10 +76,18 @@ agent-driven callers regardless: the one-time marker. - [`src/server/management/sidebar-routes.ts`](./src/server/management/sidebar-routes.ts) — the `403 agent_consent_required` refusal. +- [`src/cli/account-auth.ts`](./src/cli/account-auth.ts) and + [`src/cli/reset-credit-consent-client.ts`](./src/cli/reset-credit-consent-client.ts) + — hand-typed reset-credit consent and one-shot capability transport. +- [`src/codex/auth-api.ts`](./src/codex/auth-api.ts) and + [`src/server/management-auth.ts`](./src/server/management-auth.ts) — consent + principal enforcement before any reset-credit dispatch. Regression coverage: `tests/startup-prompt.test.ts`, -`tests/agent-driven.test.ts`, `tests/sidebar-routes.test.ts`. +`tests/agent-driven.test.ts`, `tests/sidebar-routes.test.ts`, +`tests/cli-account.test.ts`, `tests/reset-credit-consent-client.test.ts`, +`tests/server-management-auth.test.ts`, and `tests/codex-auth-api.test.ts`. -If a future action spends the user's identity, credits, or reputation, gate it +If another action spends the user's identity, credits, or reputation, gate it the same way rather than relying on a prompt an agent can answer, and document it here. diff --git a/docs-site/src/content/docs/getting-started/for-agents.md b/docs-site/src/content/docs/getting-started/for-agents.md index 62241df747..2b2488f28b 100644 --- a/docs-site/src/content/docs/getting-started/for-agents.md +++ b/docs-site/src/content/docs/getting-started/for-agents.md @@ -54,6 +54,13 @@ most once per opencodex version, and a later version asks again on its own. Star only after an explicit yes; an explicit no settles it permanently and is never raised again. ::: +:::caution[Codex reset-credit consent] +Inspecting reset credits is read-only. Never run `ocx account reset-credits --consume --yes` +or call the consume endpoint on the user's behalf. Agent-driven attempts are refused with +`agent_consent_required`; do not bypass the refusal with an admin token or a client `confirmed` +field. Only a hand-typed user-confirmed CLI run or the dashboard's GUI session may consume a credit. +::: + ## Check a headless installation Use these read-only checks in scripts and agent runs: diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index cab525623a..07b68a9413 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -207,7 +207,7 @@ security find-generic-password -w openrouter | ocx account add-key openrouter -- ### `ocx account reset-credits [--consume --yes]` -アカウントの Codex リセット クレジットを検査します。クレジットの消費は破壊的であり、`--consume` と `--yes` の両方が必要です。 +アカウントの Codex リセット クレジットを検査します。消費は破壊的なため、ユーザーが手入力で確認した実行で `--consume` と `--yes` の両方が必要です。エージェント駆動の実行は one-shot のローカル同意 capability を発行する前に拒否され、再利用可能な管理トークンでは代替できません。 ### `ocx account main ` diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 7982a6341c..52b6b9a50a 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -217,7 +217,7 @@ Authorization: Bearer | `PUT /api/codex-auth/failover` |アカウントのフェイルオーバーしきい値を設定する | 400 無効なしきい値 | | `GET /api/codex-auth/quota` |キャッシュされたクォータ状態をアカウントごとに読み取る | — | | `GET /api/codex-auth/reset-credits` |アカウントのリセット クレジット資格を検査する | 400 アカウント ID がありません。アップストリームステータスパススルー。 500 検索失敗 | -| `POST /api/codex-auth/reset-credits/consume` |対象となるリセット クレジットを消費する | 400 アカウント ID がありません。アップストリームステータスパススルー。 503 `server_busy`; 500 消費失敗 | +| `POST /api/codex-auth/reset-credits/consume` | 対象のリセット クレジットを消費する。GUI session または CLI の one-shot ローカル同意 capability が必要で、再利用可能な管理認証や `confirmed` field では代替不可 | 400 無効な identity; 403 `agent_consent_required`; upstream status passthrough; 503 `server_busy`; 500 消費失敗 | | `POST /api/codex-auth/login` | Codex のログインまたは再認証を開始する | 400 無効なリクエスト。競合/ビジー ログイン状態 | | `POST /api/codex-auth/login/code` | Codex ログイン フローの手動コードを送信する | 400 無効なフロー/コード | | `POST /api/codex-auth/login/cancel` | Codex ログイン フローをキャンセルする | — | diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 5b7bd66ca9..3bcae1a0e9 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -206,7 +206,7 @@ security find-generic-password -w openrouter | ocx account add-key openrouter -- ### `ocx account reset-credits [--consume --yes]` -계정의 Codex reset credits를 확인합니다. credit을 소비하는 동작은 파괴적이므로 `--consume`와 `--yes`를 둘 다 요구합니다. +계정의 Codex reset credits를 확인합니다. credit 소비는 파괴적이므로 사용자가 직접 입력해 확인한 실행에서 `--consume`와 `--yes`를 둘 다 요구합니다. 에이전트가 실행한 호출은 one-shot 로컬 동의 capability를 만들기 전에 거부되며, 재사용 가능한 관리 토큰으로 대체할 수 없습니다. ### `ocx account main ` diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index b0ed3dac31..2f2d993da6 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -217,7 +217,7 @@ Authorization: Bearer | `PUT /api/codex-auth/failover` | account failover threshold를 설정합니다 | 400 잘못된 threshold | | `GET /api/codex-auth/quota` | 계정별 캐시된 quota 상태를 읽습니다 | — | | `GET /api/codex-auth/reset-credits` | 계정의 reset-credit 자격을 확인합니다 | 400 누락된 account id; upstream 상태 전달; 500 조회 실패 | -| `POST /api/codex-auth/reset-credits/consume` | 사용할 수 있는 reset credit을 소비합니다 | 400 누락된 account id; upstream 상태 전달; 503 `server_busy`; 500 소비 실패 | +| `POST /api/codex-auth/reset-credits/consume` | 사용할 수 있는 reset credit을 소비합니다. GUI 세션 또는 CLI의 one-shot 로컬 동의 capability가 필요하며 재사용 가능한 관리 인증이나 `confirmed` 필드로 대체할 수 없습니다 | 400 잘못된 식별자; 403 `agent_consent_required`; upstream 상태 전달; 503 `server_busy`; 500 소비 실패 | | `POST /api/codex-auth/login` | Codex 로그인 또는 재인증을 시작합니다 | 400 잘못된 요청; 충돌/바쁨 로그인 상태 | | `POST /api/codex-auth/login/code` | Codex 로그인 흐름용 수동 코드를 제출합니다 | 400 잘못된 흐름/code | | `POST /api/codex-auth/login/cancel` | Codex 로그인 흐름을 취소합니다 | — | 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 457b83c571..cdd876545f 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -279,7 +279,8 @@ security find-generic-password -w openrouter | ocx account add-key openrouter -- ### `ocx account reset-credits [--consume --yes]` Inspect Codex reset credits for an account. Consuming a credit is destructive and requires both -`--consume` and `--yes`. +`--consume` and `--yes` in a hand-typed user-confirmed run. Agent-driven runs are refused before +the one-shot local consent capability is minted; a reusable management token cannot substitute. ### `ocx account main ` diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 40a01b5767..d276a1ab0a 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -250,7 +250,7 @@ manager. Its routes are: | `PUT /api/codex-auth/failover` | Set the account failover threshold | 400 invalid threshold | | `GET /api/codex-auth/quota` | Read cached quota state by account | — | | `GET /api/codex-auth/reset-credits` | Inspect reset-credit eligibility for an account | 400 missing account id; upstream status passthrough; 500 lookup failure | -| `POST /api/codex-auth/reset-credits/consume` | Consume an eligible reset credit | 400 missing account id; upstream status passthrough; 503 `server_busy`; 500 consume failure | +| `POST /api/codex-auth/reset-credits/consume` | Consume an eligible reset credit; requires a GUI session or the CLI's one-shot local consent capability, not reusable admin auth or a `confirmed` field | 400 invalid identity; 403 `agent_consent_required`; upstream status passthrough; 503 `server_busy`; 500 consume failure | | `POST /api/codex-auth/login` | Start Codex login or reauthentication | 400 invalid request; conflict/busy login states | | `POST /api/codex-auth/login/code` | Submit a manual code for a Codex login flow | 400 invalid flow/code | | `POST /api/codex-auth/login/cancel` | Cancel a Codex login flow | — | diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index c1a27cb057..b7935a60fb 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -266,8 +266,9 @@ security find-generic-password -w openrouter | ocx account add-key openrouter -- ### `ocx account reset-credits [--consume --yes]` -Проверить reset-credit'ы Codex для аккаунта. Расходование кредита разрушительно и требует сразу -оба флага: и `--consume`, и `--yes`. +Проверить reset-credit'ы Codex для аккаунта. Расходование кредита необратимо и требует `--consume` +и `--yes` в запуске, который пользователь ввёл и подтвердил сам. Запуск агентом отклоняется до +создания одноразового локального consent capability; многоразовый admin token его не заменяет. ### `ocx account main ` diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index 860fb742d6..9864b7db1d 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -241,7 +241,7 @@ picker изменилась. `catalogRefreshPending: true` в успешном | `PUT /api/codex-auth/failover` | Задать порог failover аккаунтов | 400 invalid threshold | | `GET /api/codex-auth/quota` | Прочитать кэшированное состояние квоты по аккаунтам | — | | `GET /api/codex-auth/reset-credits` | Проверить право аккаунта на reset credit | 400 missing account id; upstream status passthrough; 500 lookup failure | -| `POST /api/codex-auth/reset-credits/consume` | Израсходовать доступный reset credit | 400 missing account id; upstream status passthrough; 503 `server_busy`; 500 consume failure | +| `POST /api/codex-auth/reset-credits/consume` | Израсходовать reset credit; требуется GUI session или одноразовый локальный consent capability CLI, а не многоразовая admin auth или поле `confirmed` | 400 invalid identity; 403 `agent_consent_required`; upstream status passthrough; 503 `server_busy`; 500 consume failure | | `POST /api/codex-auth/login` | Запустить login или reauthentication для Codex | 400 invalid request; conflict/busy login states | | `POST /api/codex-auth/login/code` | Отправить manual code для login-flow Codex | 400 invalid flow/code | | `POST /api/codex-auth/login/cancel` | Отменить login-flow Codex | — | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index 765c21aad6..1892412d57 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -238,8 +238,8 @@ security find-generic-password -w openrouter | ocx account add-key openrouter -- ### `ocx account reset-credits [--consume --yes]` -查看某个账号的 Codex 重置额度。消耗额度会造成破坏性影响,因此同时需要 `--consume` -和 `--yes`。 +查看某个账号的 Codex 重置额度。消耗额度是破坏性操作,只有用户亲自输入并确认的运行才可同时使用 +`--consume` 和 `--yes`。代理驱动的运行会在签发一次性本地同意 capability 之前被拒绝;可重复使用的管理令牌不能替代该同意。 ### `ocx account main ` diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index ab06540139..297b5529dd 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -219,7 +219,7 @@ Authorization: Bearer | `PUT /api/codex-auth/failover` | 设置账户故障转移阈值 | 400 阈值无效 | | `GET /api/codex-auth/quota` | 按账户读取缓存的配额状态 | — | | `GET /api/codex-auth/reset-credits` | 检查某个账户是否具备 reset-credit 资格 | 400 缺少账户 id;上游状态透传;500 查询失败 | -| `POST /api/codex-auth/reset-credits/consume` | 消耗一个符合条件的 reset credit | 400 缺少账户 id;上游状态透传;503 `server_busy`;500 消耗失败 | +| `POST /api/codex-auth/reset-credits/consume` | 消耗 reset credit;需要 GUI session 或 CLI 的一次性本地同意 capability,不能用可重复使用的管理认证或 `confirmed` 字段代替 | 400 身份无效;403 `agent_consent_required`;上游状态透传;503 `server_busy`;500 消耗失败 | | `POST /api/codex-auth/login` | 启动 Codex 登录或重新认证 | 400 请求无效;登录状态冲突/忙碌 | | `POST /api/codex-auth/login/code` | 为 Codex 登录流程提交手动代码 | 400 流程/代码无效 | | `POST /api/codex-auth/login/cancel` | 取消一个 Codex 登录流程 | — | diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index ec2063b31b..73f499cc5c 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -161,7 +161,7 @@ security find-generic-password -w openrouter | ocx account add-key openrouter -- ### `ocx account reset-credits [--consume --yes]` -檢查帳號的 Codex reset credits。消耗 credit 是破壞性的,需要同時提供 `--consume` 與 `--yes`。 +檢查帳號的 Codex reset credits。消耗 credit 是破壞性操作,僅限使用者親自輸入並確認的執行同時提供 `--consume` 與 `--yes`。代理驅動的執行會在簽發一次性本機同意 capability 前遭拒;可重複使用的管理權杖不能取代該同意。 ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index b93b197245..9db1bf6bcb 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -212,7 +212,7 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | `PUT /api/codex-auth/failover` | 設定帳號容錯移轉閾值 | 400 無效閾值 | | `GET /api/codex-auth/quota` | 依帳號讀取快取配額狀態 | — | | `GET /api/codex-auth/reset-credits` | 檢查帳號的 reset-credit 資格 | 400 缺失帳號 id;上游狀態 passthrough;500 查詢失敗 | -| `POST /api/codex-auth/reset-credits/consume` | 消耗一個合格的 reset credit | 400 缺失帳號 id;上游狀態 passthrough;503 `server_busy`;500 消耗失敗 | +| `POST /api/codex-auth/reset-credits/consume` | 消耗 reset credit;需要 GUI session 或 CLI 的一次性本機同意 capability,不能以可重複使用的管理認證或 `confirmed` 欄位取代 | 400 身分無效;403 `agent_consent_required`;上游狀態 passthrough;503 `server_busy`;500 消耗失敗 | | `POST /api/codex-auth/login` | 啟動 Codex 登入或重新認證 | 400 無效請求;衝突/忙碌登入狀態 | | `POST /api/codex-auth/login/code` | 為 Codex 登入流程提交手動碼 | 400 無效流程/碼 | | `POST /api/codex-auth/login/cancel` | 取消 Codex 登入流程 | — | diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index be0e2e39ec..084b39fa11 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -58,6 +58,10 @@ export interface AccountDeps { stageHeartbeatIntervalMinMs?: number; /** Test-only clock for deterministic native-profile lease deadline coverage. */ stageLeaseClock?: StageLeaseClock; + /** Test seam for the consent-bound reset-credit client. */ + requestResetCreditConsentImpl?: typeof import("./reset-credit-consent-client").requestBoundCodexResetCreditConsent; + /** Test seam for the process-level user-consent guard. */ + isAgentDrivenImpl?: () => boolean; } export function classifyAccount(config: OcxConfig, name: string): ClassifyResult { diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index 1fca423fc0..a9c44b9c25 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -1,5 +1,8 @@ import { writeSync } from "node:fs"; import { randomUUID } from "node:crypto"; +import { isAgentDriven } from "./agent-driven"; +import { requestBoundCodexResetCreditConsent } from "./reset-credit-consent-client"; +import type { AccountDeps } from "./account-api"; import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh"; import { CliUsageError, @@ -222,7 +225,7 @@ async function cancel(argv: string[], deps: RuntimeApiDeps): Promise { printData(result, wantsJson, [`Cancelled ${provider} login.`]); } -async function resetCredits(argv: string[], deps: RuntimeApiDeps): Promise { +async function resetCredits(argv: string[], deps: AccountDeps): Promise { const args = [...argv]; const rawId = args.shift()?.trim(); const wantsJson = takeFlag(args, "--json"); @@ -232,16 +235,51 @@ async function resetCredits(argv: string[], deps: RuntimeApiDeps): Promise if (consume && !yes) throw new CliUsageError("consuming a reset credit requires --yes", USAGE); rejectArgs(args, USAGE); const accountId = rawId === "main" ? "__main__" : rawId; - const result = consume - ? await runtimeRequest("/api/codex-auth/reset-credits/consume", { - method: "POST", - body: JSON.stringify({ accountId, operationId: randomUUID() }), - }, deps) - : await runtimeRequest(`/api/codex-auth/reset-credits?accountId=${encodeURIComponent(accountId)}`, {}, deps); + let result: unknown; + if (consume) { + if ((deps.isAgentDrivenImpl ?? isAgentDriven)()) { + throw new CliUsageError( + "reset-credit consumption requires a hand-typed user-confirmed run", + USAGE, + ); + } + const operationId = randomUUID(); + const consent = await (deps.requestResetCreditConsentImpl ?? requestBoundCodexResetCreditConsent)( + accountId, + operationId, + ); + if (consent.kind !== "response") { + throw new CliUsageError( + consent.reason === "invalid-identity" + ? "Invalid account id format" + : "reset-credit consent capability is unavailable", + USAGE, + ); + } + const text = await consent.response.text(); + let body: unknown = null; + if (text) { + try { body = JSON.parse(text); } catch { body = text; } + } + if (!consent.response.ok) { + const detail = body && typeof body === "object" + && typeof (body as { error?: unknown }).error === "string" + ? (body as { error: string }).error + : `Reset-credit request failed (${consent.response.status})`; + throw new CliUsageError(detail, USAGE); + } + result = body; + } else { + result = await runtimeRequest( + `/api/codex-auth/reset-credits?accountId=${encodeURIComponent(accountId)}`, + {}, + deps, + ); + } printData(result, wantsJson); } -export async function handleAccountAuthCommand(sub: string, argv: string[], deps: RuntimeApiDeps = {}): Promise { +export async function handleAccountAuthCommand(sub: string, argv: string[], deps: AccountDeps = {}): Promise { let action: (() => Promise) | undefined; if (sub === "login" || sub === "reauth") action = () => login(sub === "reauth" ? [...argv, "--reauth"] : argv, deps); else if (sub === "code") action = () => code(argv, deps); diff --git a/src/cli/reset-credit-consent-client.ts b/src/cli/reset-credit-consent-client.ts new file mode 100644 index 0000000000..434a2c3d75 --- /dev/null +++ b/src/cli/reset-credit-consent-client.ts @@ -0,0 +1,162 @@ +import { readRuntimePort, type RuntimePortState } from "../config"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationChallenge, + verifyLocalAttestationProof, +} from "../lib/local-management-attestation"; +import { + CODEX_RESET_CREDIT_CONSENT_ACCOUNT_ID_HEADER, + CODEX_RESET_CREDIT_CONSENT_CAPABILITY_HEADER, + CODEX_RESET_CREDIT_CONSENT_CAPABILITY_TTL_MS, + CODEX_RESET_CREDIT_CONSENT_CAPABILITY_VERSION, + CODEX_RESET_CREDIT_CONSENT_EXPECTED_PID_HEADER, + CODEX_RESET_CREDIT_CONSENT_EXPIRES_AT_HEADER, + CODEX_RESET_CREDIT_CONSENT_METHOD, + CODEX_RESET_CREDIT_CONSENT_NONCE_HEADER, + CODEX_RESET_CREDIT_CONSENT_OPERATION_ID_HEADER, + CODEX_RESET_CREDIT_CONSENT_PATH, + createCodexResetCreditConsentCapability, + isCodexResetCreditConsentAccountId, +} from "../lib/codex-reset-credit-consent-contract"; +import { directLocalHttpFetch } from "../server/direct-local-http"; +import { + findLiveProxy, + isOpencodexHealthz, + probeHostname, + type HealthzIdentity, + type LiveProxy, +} from "../server/proxy-liveness"; +import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery"; + +export type ResetCreditConsentResult = + | { kind: "response"; response: Response } + | { + kind: "unavailable"; + reason: + | "invalid-identity" + | "unattested-target" + | "runtime-mismatch" + | "attestation" + | "capability" + | "transport"; + }; + +export interface ResetCreditConsentDeps { + findLive?: typeof findLiveProxy; + fetchImpl?: typeof fetch; + readRuntime?: (pid: number) => RuntimePortState | null; + createNonce?: () => string; + now?: () => number; + timeoutMs?: number; +} + +const RESET_CREDIT_CONSENT_TIMEOUT_MS = 10_000; + +function sameRuntime(left: RuntimePortState, right: RuntimePortState | null): boolean { + return !!right + && right.pid === left.pid + && right.port === left.port + && right.hostname === left.hostname + && right.attestationSecret === left.attestationSecret; +} + +/** + * Send one user-confirmed redemption to the exact attested local proxy. + * + * The request carries no reusable management credential. Its body is empty; the + * account and idempotency identities are bound into a short-lived, one-shot HMAC. + */ +export async function requestBoundCodexResetCreditConsent( + accountId: string, + operationId: string, + deps: ResetCreditConsentDeps = {}, +): Promise { + if ( + !isCodexResetCreditConsentAccountId(accountId) + || !isCodexResetCreditOperationId(operationId) + ) return { kind: "unavailable", reason: "invalid-identity" }; + + let target: LiveProxy | null; + try { + target = await (deps.findLive ?? findLiveProxy)(); + } catch { + return { kind: "unavailable", reason: "transport" }; + } + if (target?.source !== "runtime" || target.pid === null || target.pid <= 0) { + return { kind: "unavailable", reason: "unattested-target" }; + } + const readRuntime = deps.readRuntime ?? readRuntimePort; + const runtime = readRuntime(target.pid); + if ( + !runtime?.attestationSecret + || runtime.pid !== target.pid + || runtime.port !== target.port + || runtime.hostname !== target.hostname + ) return { kind: "unavailable", reason: "runtime-mismatch" }; + + const fetchImpl = deps.fetchImpl ?? directLocalHttpFetch; + const timeoutMs = deps.timeoutMs ?? RESET_CREDIT_CONSENT_TIMEOUT_MS; + const nonce = (deps.createNonce ?? createLocalAttestationChallenge)(); + const baseUrl = `http://${probeHostname(target.hostname)}:${target.port}`; + let proofResponse: Response; + try { + proofResponse = await fetchImpl(`${baseUrl}/healthz`, { + headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: nonce }, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch { + return { kind: "unavailable", reason: "transport" }; + } + const health = await proofResponse.json().catch(() => null) as HealthzIdentity | null; + if ( + !proofResponse.ok + || !isOpencodexHealthz(health) + || health?.pid !== target.pid + || health?.port !== target.port + || health?.resetCreditConsentCapability !== CODEX_RESET_CREDIT_CONSENT_CAPABILITY_VERSION + || !verifyLocalAttestationProof( + runtime.attestationSecret, + nonce, + target.pid, + target.port, + proofResponse.headers.get(LOCAL_ATTESTATION_PROOF_HEADER), + ) + ) return { kind: "unavailable", reason: "attestation" }; + + if (!sameRuntime(runtime, readRuntime(target.pid))) { + return { kind: "unavailable", reason: "runtime-mismatch" }; + } + + const expiresAt = (deps.now ?? Date.now)() + CODEX_RESET_CREDIT_CONSENT_CAPABILITY_TTL_MS; + const capability = createCodexResetCreditConsentCapability( + runtime.attestationSecret, + nonce, + CODEX_RESET_CREDIT_CONSENT_METHOD, + CODEX_RESET_CREDIT_CONSENT_PATH, + accountId, + operationId, + target.pid, + target.port, + expiresAt, + ); + if (!capability) return { kind: "unavailable", reason: "capability" }; + + try { + const response = await fetchImpl(`${baseUrl}${CODEX_RESET_CREDIT_CONSENT_PATH}`, { + method: CODEX_RESET_CREDIT_CONSENT_METHOD, + headers: { + [CODEX_RESET_CREDIT_CONSENT_EXPECTED_PID_HEADER]: String(target.pid), + [CODEX_RESET_CREDIT_CONSENT_NONCE_HEADER]: nonce, + [CODEX_RESET_CREDIT_CONSENT_EXPIRES_AT_HEADER]: String(expiresAt), + [CODEX_RESET_CREDIT_CONSENT_ACCOUNT_ID_HEADER]: accountId, + [CODEX_RESET_CREDIT_CONSENT_OPERATION_ID_HEADER]: operationId, + [CODEX_RESET_CREDIT_CONSENT_CAPABILITY_HEADER]: capability, + }, + signal: AbortSignal.timeout(timeoutMs), + }); + return { kind: "response", response }; + } catch { + return { kind: "unavailable", reason: "transport" }; + } +} diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index d2a955134c..2434dd4464 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -102,6 +102,11 @@ import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./ export { maskEmail } from "../lib/privacy"; import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../types"; import type { CatalogDisposition } from "./convergence-types"; +import type { ManagementPrincipal } from "../server/management-auth"; +import { + CODEX_RESET_CREDIT_CONSENT_ACCOUNT_ID_HEADER, + CODEX_RESET_CREDIT_CONSENT_OPERATION_ID_HEADER, +} from "../lib/codex-reset-credit-consent-contract"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { providerCodexAccountMode } from "../providers/registry"; import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBody } from "../lib/bounded-body"; @@ -1388,6 +1393,7 @@ export async function handleCodexAuthAPI( url: URL, config: OcxConfig, convergeCodexCatalog?: CodexAuthCatalogConvergence, + principal?: ManagementPrincipal, ): Promise { if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") { @@ -1728,7 +1734,18 @@ export async function handleCodexAuthAPI( } if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") { - const body = (await req.json().catch(() => ({}))) as { accountId?: string; operationId?: string }; + if (principal !== "gui-session" && principal !== "local-reset-credit-capability") { + return jsonResponse({ + error: "User consent is required to consume a reset credit", + code: "agent_consent_required", + }, 403); + } + const body = principal === "local-reset-credit-capability" + ? { + accountId: req.headers.get(CODEX_RESET_CREDIT_CONSENT_ACCOUNT_ID_HEADER) ?? undefined, + operationId: req.headers.get(CODEX_RESET_CREDIT_CONSENT_OPERATION_ID_HEADER) ?? undefined, + } + : (await req.json().catch(() => ({}))) as { accountId?: string; operationId?: string }; if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400); if (body.accountId !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(body.accountId)) { return jsonResponse({ error: "Invalid account id format" }, 400); @@ -1749,18 +1766,14 @@ export async function handleCodexAuthAPI( }; const opened = openManualResetCreditOperation(identity); if (opened.kind === "capacity" || opened.kind === "unavailable") { - const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503); - response.headers.set("Retry-After", "1"); - return response; + return manualResetCreditBusyResponse(); } let code: CodexResetCreditConsumeCode; if (opened.kind === "terminal") { code = opened.code; } else { if (opened.kind !== "execute") { - const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503); - response.headers.set("Retry-After", "1"); - return response; + return manualResetCreditBusyResponse(); } const effectiveIdentity: ManualResetCreditOperationIdentity = { ...identity, @@ -1780,9 +1793,7 @@ export async function handleCodexAuthAPI( } const settled = settleManualResetCreditOperation(effectiveIdentity, code); if (settled.kind !== "updated") { - const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503); - response.headers.set("Retry-After", "1"); - return response; + return manualResetCreditBusyResponse(); } } // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage @@ -1813,9 +1824,7 @@ export async function handleCodexAuthAPI( return operation.ok ? operation.value : operation.response; } catch (e) { if (e instanceof PoolQuotaProbeBusyError) { - const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503); - response.headers.set("Retry-After", "1"); - return response; + return manualResetCreditBusyResponse(); } if (req.signal.aborted) { return jsonResponse({ error: "Reset credit consume cancelled by client" }, 499); diff --git a/src/codex/reset-credit-operation-ledger.ts b/src/codex/reset-credit-operation-ledger.ts index 2c58c06db7..d8151ae7fa 100644 --- a/src/codex/reset-credit-operation-ledger.ts +++ b/src/codex/reset-credit-operation-ledger.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import { chmodSync } from "node:fs"; import { Database } from "bun:sqlite"; -import { prepareConfigMutationDatabasePathForWrite } from "../config"; +import { NestedConfigMutationError, prepareConfigMutationDatabasePathForWrite } from "../config"; import { initializeConfigGeneration } from "./generation"; import { compareCodexResetCreditRecoveryGenerationOrder, @@ -243,6 +243,7 @@ function parseRecord(row: ResetCreditOperationRow | null): ResetCreditOperationR return undefined; } const terminal = state === "confirmed" || state === "stopped"; + if (!terminal && code !== null) return undefined; const terminalState = typeof code === "string" && Object.prototype.hasOwnProperty.call(TERMINAL_STATE_BY_CODE, code) ? TERMINAL_STATE_BY_CODE[code as CodexResetCreditConsumeCode] @@ -512,8 +513,7 @@ function isLedgerBusyError(error: unknown): boolean { function warnLedgerUnavailable(error: unknown): void { if (isLedgerBusyError(error)) return; - const nested = error instanceof Error - && error.message === "prepareConfigMutationDatabasePathForWrite must not run inside withConfigMutationLockSync"; + const nested = error instanceof NestedConfigMutationError; console.warn(nested ? "[opencodex] Reset-credit operation ledger refused a nested config mutation." : "[opencodex] Reset-credit operation ledger is unavailable."); @@ -727,27 +727,24 @@ export function openManualResetCreditOperation( if (current.operationKind !== "manual") { return Object.freeze({ kind: "unavailable" as const }); } - if (current.operationId !== identity.operationId) { - if (!isTerminal(current)) { - return Object.freeze({ - kind: "execute" as const, - operationId: current.operationId as CodexReservedOperationId, - resumed: true, - }); - } - } else if (isTerminal(current)) { + if (!isTerminal(current)) { + // One physical account owns at most one unsettled manual intent. A + // different caller id joins that intent instead of opening a second one. return Object.freeze({ - kind: "terminal" as const, + kind: "execute" as const, operationId: current.operationId as CodexReservedOperationId, - code: current.code!, + resumed: true, }); - } else { + } + if (current.operationId === identity.operationId) { return Object.freeze({ - kind: "execute" as const, + kind: "terminal" as const, operationId: current.operationId as CodexReservedOperationId, - resumed: true, + code: current.code!, }); } + // Deliberate: a distinct caller id after a settled intent represents a + // new explicit redemption and replaces the terminal record below. } else if (recordCount >= MAX_RESET_CREDIT_OPERATION_ACCOUNTS) { return Object.freeze({ kind: "capacity" as const }); } diff --git a/src/codex/reset-credit-recovery.ts b/src/codex/reset-credit-recovery.ts index 246c9149ba..d1221aa6ec 100644 --- a/src/codex/reset-credit-recovery.ts +++ b/src/codex/reset-credit-recovery.ts @@ -35,7 +35,7 @@ export type CodexReservedOperationId = string & { }; export const CODEX_RESET_CREDIT_OPERATION_ID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; export function isCodexResetCreditOperationId(value: unknown): value is string { return typeof value === "string" && CODEX_RESET_CREDIT_OPERATION_ID_PATTERN.test(value); @@ -497,13 +497,13 @@ export class CodexResetCreditRecoveryCoordinator { * this seam; ordinary requests must keep using createLogicalTurn(). */ createLogicalTurnForOperation(operationId: CodexReservedOperationId): CodexResetCreditLogicalTurn { - if (!isCodexResetCreditOperationId(operationId)) { - throw new TypeError("operationId must be an RFC 4122 version 4 UUID"); - } return this.registerLogicalTurn(operationId); } private registerLogicalTurn(operationId: string): CodexResetCreditLogicalTurn { + if (!isCodexResetCreditOperationId(operationId)) { + throw new TypeError("operationId must be an RFC 4122 version 4 UUID"); + } const turn = Object.freeze({ operationId }); this.logicalTurns.set(turn, {}); return turn; diff --git a/src/config.ts b/src/config.ts index a2d9732e88..129fd4de83 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2456,11 +2456,16 @@ function configMutationDatabasePath(): string { * {@link withConfigMutationLockSync}; a second `BEGIN IMMEDIATE` deliberately * fails busy instead of joining an uncommitted transaction. */ +export class NestedConfigMutationError extends Error { + constructor() { + super("prepareConfigMutationDatabasePathForWrite must not run inside withConfigMutationLockSync"); + this.name = "NestedConfigMutationError"; + } +} + export function prepareConfigMutationDatabasePathForWrite(): string { if (configMutationLockDepth > 0) { - throw new Error( - "prepareConfigMutationDatabasePathForWrite must not run inside withConfigMutationLockSync", - ); + throw new NestedConfigMutationError(); } return configMutationDatabasePath(); } diff --git a/src/lib/codex-reset-credit-consent-contract.ts b/src/lib/codex-reset-credit-consent-contract.ts new file mode 100644 index 0000000000..7a9680bc93 --- /dev/null +++ b/src/lib/codex-reset-credit-consent-contract.ts @@ -0,0 +1,138 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "../codex/account-id"; +import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery"; +import { isLocalAttestationSecret } from "./local-management-attestation"; + +export const CODEX_RESET_CREDIT_CONSENT_METHOD = "POST"; +export const CODEX_RESET_CREDIT_CONSENT_PATH = "/api/codex-auth/reset-credits/consume"; +export const CODEX_RESET_CREDIT_CONSENT_CAPABILITY_VERSION = "v1"; +export const CODEX_RESET_CREDIT_CONSENT_EXPECTED_PID_HEADER = + "x-opencodex-reset-credit-expected-pid"; +export const CODEX_RESET_CREDIT_CONSENT_NONCE_HEADER = + "x-opencodex-reset-credit-nonce"; +export const CODEX_RESET_CREDIT_CONSENT_EXPIRES_AT_HEADER = + "x-opencodex-reset-credit-expires-at"; +export const CODEX_RESET_CREDIT_CONSENT_ACCOUNT_ID_HEADER = + "x-opencodex-reset-credit-account-id"; +export const CODEX_RESET_CREDIT_CONSENT_OPERATION_ID_HEADER = + "x-opencodex-reset-credit-operation-id"; +export const CODEX_RESET_CREDIT_CONSENT_CAPABILITY_HEADER = + "x-opencodex-reset-credit-capability"; +export const CODEX_RESET_CREDIT_CONSENT_CAPABILITY_TTL_MS = 10_000; + +const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/; + +export type ExpectedCodexResetCreditConsentPid = + | { kind: "absent" } + | { kind: "invalid" } + | { kind: "present"; pid: number }; + +export function parseExpectedCodexResetCreditConsentPid( + value: string | null, +): ExpectedCodexResetCreditConsentPid { + if (value === null) return { kind: "absent" }; + if (!/^[1-9]\d*$/.test(value)) return { kind: "invalid" }; + const pid = Number(value); + return Number.isSafeInteger(pid) ? { kind: "present", pid } : { kind: "invalid" }; +} + +export function isCodexResetCreditConsentAccountId(value: unknown): value is string { + return value === MAIN_CODEX_ACCOUNT_ID || isValidCodexAccountId(value); +} + +function capabilityPayload( + nonce: string, + method: string, + path: string, + accountId: string, + operationId: string, + pid: number, + port: number, + expiresAt: number, +): string | null { + if (!BASE64URL_256.test(nonce)) return null; + if (method !== CODEX_RESET_CREDIT_CONSENT_METHOD || path !== CODEX_RESET_CREDIT_CONSENT_PATH) { + return null; + } + if (!isCodexResetCreditConsentAccountId(accountId)) return null; + if (!isCodexResetCreditOperationId(operationId)) return null; + if (!Number.isSafeInteger(pid) || pid <= 0) return null; + if (!Number.isInteger(port) || port <= 0 || port > 65535) return null; + if (!Number.isSafeInteger(expiresAt) || expiresAt <= 0) return null; + return [ + "opencodex-codex-reset-credit-consent-v1", + nonce, + method, + path, + accountId, + operationId, + String(pid), + String(port), + String(expiresAt), + ].join("\n"); +} + +/** One-shot authorization for a user-confirmed reset-credit redemption. */ +export function createCodexResetCreditConsentCapability( + secret: string, + nonce: string, + method: string, + path: string, + accountId: string, + operationId: string, + pid: number, + port: number, + expiresAt: number, +): string | null { + if (!isLocalAttestationSecret(secret)) return null; + const payload = capabilityPayload( + nonce, + method, + path, + accountId, + operationId, + pid, + port, + expiresAt, + ); + if (!payload) return null; + return createHmac("sha256", secret).update(payload).digest("base64url"); +} + +export function verifyCodexResetCreditConsentCapability( + secret: string, + nonce: string | null, + method: string, + path: string, + accountId: string | null, + operationId: string | null, + pid: number, + port: number, + expiresAt: number, + capability: string | null, + now = Date.now(), +): boolean { + if (!nonce || !accountId || !operationId || !capability || !BASE64URL_256.test(capability)) { + return false; + } + if ( + !Number.isSafeInteger(now) + || expiresAt <= now + || expiresAt > now + CODEX_RESET_CREDIT_CONSENT_CAPABILITY_TTL_MS + ) return false; + const expected = createCodexResetCreditConsentCapability( + secret, + nonce, + method, + path, + accountId, + operationId, + pid, + port, + expiresAt, + ); + if (!expected) return false; + const expectedBytes = Buffer.from(expected); + const actualBytes = Buffer.from(capability); + return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes); +} diff --git a/src/server/index.ts b/src/server/index.ts index 0a5a8a36f3..de81eae73b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -196,6 +196,7 @@ import { } from "../lib/local-management-attestation"; import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract"; import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../lib/local-provider-reload-contract"; +import { CODEX_RESET_CREDIT_CONSENT_CAPABILITY_VERSION } from "../lib/codex-reset-credit-consent-contract"; import { createReadinessGate, type ReadinessGate } from "./readiness"; export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024; @@ -823,6 +824,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server(); const LOCAL_PROVIDER_RELOAD_REPLAY_LIMIT = 256; const consumedLocalProviderReloadCapabilities = new Map(); const admittedLocalProviderReloadRequests = new WeakSet(); +const RESET_CREDIT_CONSENT_REPLAY_LIMIT = 256; +const consumedResetCreditConsentCapabilities = new Map(); +const admittedResetCreditConsentRequests = new WeakSet(); interface GuiSessionRecord { csrfToken: string; @@ -279,13 +293,15 @@ export function issueGuiSession( * rather than off request headers, which the token holder can forge freely. * The capability principals are process-scoped HMACs bound to the current process * PID and listening port. Local reads are accepted only for two exact GET paths; - * restart and provider reload remain separate wire contracts for their exact POSTs. + * restart, provider reload, and reset-credit consent remain separate wire contracts + * for their exact POSTs. */ export type ManagementPrincipal = | "admin-token" | "gui-session" | "local-read-capability" | "local-provider-reload-capability" + | "local-reset-credit-capability" | "system-restart-capability"; export interface LocalManagementAuthContext { @@ -416,6 +432,54 @@ function hasLocalProviderReloadCapability( return true; } +function hasResetCreditConsentCapability( + req: Request, + local: LocalManagementAuthContext | undefined, +): boolean { + if (admittedResetCreditConsentRequests.has(req)) return true; + if (!local || req.method !== "POST") return false; + let url: URL; + try { + url = new URL(req.url); + } catch { + return false; + } + if (url.pathname !== CODEX_RESET_CREDIT_CONSENT_PATH || url.search !== "") return false; + const contentLength = req.headers.get("content-length"); + if (contentLength !== "0" || req.headers.has("transfer-encoding")) return false; + const expectedPid = parseExpectedCodexResetCreditConsentPid( + req.headers.get(CODEX_RESET_CREDIT_CONSENT_EXPECTED_PID_HEADER), + ); + if (expectedPid.kind !== "present" || expectedPid.pid !== local.pid) return false; + const expiresAtRaw = req.headers.get(CODEX_RESET_CREDIT_CONSENT_EXPIRES_AT_HEADER); + if (!expiresAtRaw || !/^[1-9]\d*$/.test(expiresAtRaw)) return false; + const expiresAt = Number(expiresAtRaw); + if (!Number.isSafeInteger(expiresAt)) return false; + const capability = req.headers.get(CODEX_RESET_CREDIT_CONSENT_CAPABILITY_HEADER); + const now = Date.now(); + if (!verifyCodexResetCreditConsentCapability( + local.attestationSecret, + req.headers.get(CODEX_RESET_CREDIT_CONSENT_NONCE_HEADER), + req.method, + url.pathname, + req.headers.get(CODEX_RESET_CREDIT_CONSENT_ACCOUNT_ID_HEADER), + req.headers.get(CODEX_RESET_CREDIT_CONSENT_OPERATION_ID_HEADER), + local.pid, + local.port, + expiresAt, + capability, + now, + )) return false; + for (const [consumed, retainedUntil] of consumedResetCreditConsentCapabilities) { + if (retainedUntil <= now) consumedResetCreditConsentCapabilities.delete(consumed); + } + if (!capability || consumedResetCreditConsentCapabilities.has(capability)) return false; + if (consumedResetCreditConsentCapabilities.size >= RESET_CREDIT_CONSENT_REPLAY_LIMIT) return false; + consumedResetCreditConsentCapabilities.set(capability, expiresAt); + admittedResetCreditConsentRequests.add(req); + return true; +} + /** * The principal for a request that already passed `requireManagementAuth`. Kept as a * separate resolution (rather than a changed return type) so every existing caller @@ -430,6 +494,7 @@ export function managementPrincipal( local?: LocalManagementAuthContext, ): ManagementPrincipal | null { if (hasSystemRestartCapability(req, local)) return "system-restart-capability"; + if (hasResetCreditConsentCapability(req, local)) return "local-reset-credit-capability"; if (hasLocalProviderReloadCapability(req, local)) return "local-provider-reload-capability"; if (hasLocalReadCapability(req, local)) return "local-read-capability"; if (!state.available) return null; @@ -449,6 +514,7 @@ export function requireManagementAuth( local?: LocalManagementAuthContext, ): Response | null { if (hasSystemRestartCapability(req, local)) return null; + if (hasResetCreditConsentCapability(req, local)) return null; if (hasLocalProviderReloadCapability(req, local)) return null; if (hasLocalReadCapability(req, local)) return null; if (!state.available) { diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index c430018286..e670b99ee0 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -21,6 +21,7 @@ export interface HealthzIdentity { port?: unknown; restartCapability?: unknown; providerReloadCapability?: unknown; + resetCreditConsentCapability?: unknown; } export interface LivenessIo { diff --git a/tests/cli-account.test.ts b/tests/cli-account.test.ts index c64c6316e6..5f7e8eadab 100644 --- a/tests/cli-account.test.ts +++ b/tests/cli-account.test.ts @@ -1407,20 +1407,64 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(requests).toHaveLength(before); }); - test("reset-credit consume sends one UUIDv4 operation identity", async () => { - const result = await run(["reset-credits", "main", "--consume", "--yes", "--json"]); + test("reset-credit consume sends one UUIDv4 identity through the consent-bound client", async () => { + let requested: { accountId: string; operationId: string } | undefined; + const result = await run( + ["reset-credits", "main", "--consume", "--yes", "--json"], + { + ...defaultDeps(), + isAgentDrivenImpl: () => false, + requestResetCreditConsentImpl: async (accountId, operationId) => { + requested = { accountId, operationId }; + return { kind: "response", response: json({ code: "reset" }) }; + }, + }, + ); expect(result.code).toBe(0); - expect(requests.at(-1)).toEqual(expect.objectContaining({ - method: "POST", - path: "/api/codex-auth/reset-credits/consume", - body: expect.objectContaining({ - accountId: "__main__", - operationId: expect.stringMatching( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, - ), - }), - })); + expect(requested).toEqual({ + accountId: "__main__", + operationId: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ), + }); + expect(JSON.parse(result.stdout)).toEqual({ code: "reset" }); + }); + + test("agent-driven reset-credit consumption stops before minting consent", async () => { + let consentCalls = 0; + const result = await run( + ["reset-credits", "main", "--consume", "--yes"], + { + ...defaultDeps(), + isAgentDrivenImpl: () => true, + requestResetCreditConsentImpl: async () => { + consentCalls += 1; + return { kind: "response", response: json({ code: "reset" }) }; + }, + }, + ); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("hand-typed user-confirmed run"); + expect(consentCalls).toBe(0); + }); + + test("reset-credit consume preserves the invalid-account diagnostic", async () => { + const result = await run( + ["reset-credits", "../bad", "--consume", "--yes"], + { + ...defaultDeps(), + isAgentDrivenImpl: () => false, + requestResetCreditConsentImpl: async () => ({ + kind: "unavailable", + reason: "invalid-identity", + }), + }, + ); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("Invalid account id format"); }); test("a silent pipe times out and cleans up its listeners", async () => { diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 04a924a8f1..5c2d19945a 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -70,6 +70,11 @@ import { resolveFirstUsableOpenAiSidecar, } from "../src/providers/openai-sidecar"; import { BOUNDED_BODY_MAX_BYTES } from "../src/lib/bounded-body"; +import type { ManagementPrincipal } from "../src/server/management-auth"; +import { + CODEX_RESET_CREDIT_CONSENT_ACCOUNT_ID_HEADER, + CODEX_RESET_CREDIT_CONSENT_OPERATION_ID_HEADER, +} from "../src/lib/codex-reset-credit-consent-contract"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-auth-api-test"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); @@ -77,6 +82,13 @@ const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT"; function resetCreditConsumeBody(accountId: string): string { return JSON.stringify({ accountId, operationId: randomUUID() }); } +function handleResetCreditConsume( + req: Request, + config: OcxConfig, + principal: ManagementPrincipal | undefined = "gui-session", +): Promise { + return handleCodexAuthAPI(req, new URL(req.url), config, undefined, principal); +} let previousOpencodexHome: string | undefined; let previousCodexHome: string | undefined; let previousManualImportEnv: string | undefined; @@ -554,7 +566,7 @@ describe("codex-auth API", () => { headers: { "content-type": "application/json" }, body: resetCreditConsumeBody(MAIN_CODEX_ACCOUNT_ID), }); - return handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + return handleResetCreditConsume(req, makeConfig()); }; const pending = request(); @@ -680,7 +692,7 @@ describe("codex-auth API", () => { headers: { "content-type": "application/json" }, body: resetCreditConsumeBody("quota-reset-busy"), }); - const response = await handleCodexAuthAPI(req, new URL(req.url), config); + const response = await handleResetCreditConsume(req, config); expect(response?.status).toBe(503); expect(response?.headers.get("Retry-After")).toBe("1"); expect(await response?.json()).toMatchObject({ code: "server_busy" }); @@ -2126,18 +2138,90 @@ describe("codex-auth API", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ accountId: "../bad" }), }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + const resp = await handleResetCreditConsume(req, makeConfig()); expect(resp!.status).toBe(400); expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); }); + for (const principal of [undefined, "admin-token"] as const) { + test(`reset-credit consume refuses ${principal ?? "missing"} consent authority before upstream work`, async () => { + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + return Response.json({ code: "reset" }); + }) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + accountId: "pool-consent-boundary", + operationId: randomUUID(), + confirmed: true, + }), + }); + + const resp = principal === undefined + ? await handleCodexAuthAPI(req, new URL(req.url), makeConfig()) + : await handleResetCreditConsume(req, makeConfig(), principal); + + expect(resp?.status).toBe(403); + expect(await resp?.json()).toEqual({ + error: "User consent is required to consume a reset credit", + code: "agent_consent_required", + }); + expect(fetchCalls).toBe(0); + }); + } + + test("reset-credit consume reads the capability-bound account and operation from a bodyless request", async () => { + const config = makeConfig(); + const accountId = "pool-local-consent"; + const operationId = randomUUID(); + seedPoolAccount(config, { id: accountId, email: "local-consent@example.test" }); + globalThis.fetch = (async (input: RequestInfo | URL) => { + expect(String(input)).toContain("/rate-limit-reset-credits/consume"); + return Response.json({ code: "nothing_to_reset" }); + }) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { + "content-length": "0", + [CODEX_RESET_CREDIT_CONSENT_ACCOUNT_ID_HEADER]: accountId, + [CODEX_RESET_CREDIT_CONSENT_OPERATION_ID_HEADER]: operationId, + }, + }); + + const resp = await handleResetCreditConsume(req, config, "local-reset-credit-capability"); + + expect(resp?.status).toBe(200); + expect(await resp?.json()).toEqual({ code: "nothing_to_reset" }); + }); + + test("reset-credit consume rejects non-canonical uppercase operation ids", async () => { + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + accountId: "pool-uppercase-operation", + operationId: randomUUID().toUpperCase(), + }), + }); + + const resp = await handleResetCreditConsume(req, makeConfig()); + + expect(resp?.status).toBe(400); + expect(await resp?.json()).toEqual({ + error: "operationId must be an RFC 4122 version 4 UUID", + }); + }); + test("reset-credit consume requires a caller-stable operation id", async () => { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ accountId: "pool-without-operation" }), }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + const resp = await handleResetCreditConsume(req, makeConfig()); expect(resp!.status).toBe(400); expect(await resp!.json()).toEqual({ error: "operationId must be an RFC 4122 version 4 UUID", @@ -2162,7 +2246,7 @@ describe("codex-auth API", () => { body: resetCreditConsumeBody("pool-aborted"), signal: controller.signal, }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + const resp = await handleResetCreditConsume(req, config); expect(resp?.status).toBe(499); const body = await resp?.text(); expect(body).not.toContain("private client cancellation detail"); @@ -2201,7 +2285,7 @@ describe("codex-auth API", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ accountId: "pool-ambiguous", operationId }), }); - return handleCodexAuthAPI(req, new URL(req.url), config); + return handleResetCreditConsume(req, config); }; const first = await request(); @@ -2245,7 +2329,7 @@ describe("codex-auth API", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ accountId: "pool-conflict", operationId }), }); - return await handleCodexAuthAPI(req, new URL(req.url), config); + return await handleResetCreditConsume(req, config); }; expect((await call(randomUUID()))?.status).toBe(502); expect((await call(randomUUID()))?.status).toBe(502); @@ -2285,7 +2369,7 @@ describe("codex-auth API", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ accountId: "pool-flight", operationId }), }); - return handleCodexAuthAPI(req, new URL(req.url), config); + return handleResetCreditConsume(req, config); }; const first = request(); await started; @@ -2296,6 +2380,7 @@ describe("codex-auth API", () => { const [firstResponse, secondResponse] = await Promise.all([first, second]); expect(firstResponse?.status).toBe(200); expect(secondResponse?.status).toBe(503); + expect(secondResponse?.headers.get("Retry-After")).toBe("1"); expect(await firstResponse?.json()).toEqual({ code: "nothing_to_reset" }); expect(await secondResponse?.json()).toEqual({ error: "server_busy", code: "server_busy" }); expect(consumeCalls).toBe(1); @@ -2339,7 +2424,7 @@ describe("codex-auth API", () => { headers: { "content-type": "application/json" }, body: resetCreditConsumeBody("pool-reset"), }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + const resp = await handleResetCreditConsume(req, config); expect(resp!.status).toBe(200); expect(await resp!.json()).toEqual({ code: "reset", remaining: 2 }); expect(usageCalls).toBe(1); @@ -2374,7 +2459,7 @@ describe("codex-auth API", () => { headers: { "content-type": "application/json" }, body: resetCreditConsumeBody("pool-idempotent"), }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + const resp = await handleResetCreditConsume(req, config); expect(resp!.status).toBe(200); expect(await resp!.json()).toEqual({ code: "already_redeemed", remaining: 3 }); expect(getAccountQuota("pool-idempotent")?.resetCredits).toBe(3); @@ -2408,7 +2493,7 @@ describe("codex-auth API", () => { headers: { "content-type": "application/json" }, body: resetCreditConsumeBody("pool-nocount"), }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + const resp = await handleResetCreditConsume(req, config); expect(resp!.status).toBe(200); expect(await resp!.json()).toEqual({ code: "reset" }); // Cache may still preserve the prior credit count for other callers. @@ -2440,7 +2525,7 @@ describe("codex-auth API", () => { headers: { "content-type": "application/json" }, body: resetCreditConsumeBody("pool-wham-fail"), }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + const resp = await handleResetCreditConsume(req, config); expect(resp!.status).toBe(200); expect(await resp!.json()).toEqual({ code: "reset" }); expect(getAccountQuota("pool-wham-fail")?.resetCredits).toBe(5); @@ -2474,7 +2559,7 @@ describe("codex-auth API", () => { headers: { "content-type": "application/json" }, body: resetCreditConsumeBody(MAIN_CODEX_ACCOUNT_ID), }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + const resp = await handleResetCreditConsume(req, makeConfig()); expect(resp!.status).toBe(200); expect(await resp!.json()).toEqual({ code: "already_redeemed" }); expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.resetCredits).toBe(4); @@ -2511,7 +2596,7 @@ describe("codex-auth API", () => { headers: { "content-type": "application/json" }, body: resetCreditConsumeBody(MAIN_CODEX_ACCOUNT_ID), }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + const resp = await handleResetCreditConsume(req, makeConfig()); expect(resp!.status).toBe(200); expect(await resp!.json()).toEqual({ code: "reset" }); expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.resetCredits).toBe(6); @@ -2549,7 +2634,7 @@ describe("codex-auth API", () => { headers: { "content-type": "application/json" }, body: resetCreditConsumeBody(MAIN_CODEX_ACCOUNT_ID), }); - const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + const resp = await handleResetCreditConsume(req, makeConfig()); expect(resp!.status).toBe(200); expect(await resp!.json()).toEqual({ code: "reset", remaining: 1 }); expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.resetCredits).toBe(1); diff --git a/tests/codex-reset-credit-operation-ledger.test.ts b/tests/codex-reset-credit-operation-ledger.test.ts index c55ccdec58..34b667fd3c 100644 --- a/tests/codex-reset-credit-operation-ledger.test.ts +++ b/tests/codex-reset-credit-operation-ledger.test.ts @@ -106,6 +106,14 @@ describe("Codex reset-credit operation ledger", () => { }); const migrated = new Database(databasePath(), { readonly: true }); try { + expect(migrated.query<{ sql: string }, []>(` + SELECT sql FROM main.sqlite_schema + WHERE type = 'table' AND name = 'reset_credit_operations' + `).get()?.sql).toBe(RESET_CREDIT_OPERATION_SCHEMA_SQL_FOR_TESTS); + expect(migrated.query<{ name: string }, []>(` + SELECT name FROM main.sqlite_schema + WHERE name = 'reset_credit_operations_legacy_v1' + `).get()).toBeNull(); expect(migrated.query, []>( "SELECT * FROM reset_credit_operations", ).get()).toMatchObject({ @@ -154,6 +162,27 @@ describe("Codex reset-credit operation ledger", () => { }); }); + test("a distinct manual id after settlement opens one explicit new intent", () => { + const first = { + accountId: "pool-manual-new-intent", + chatgptAccountId: "chatgpt-new-intent", + operationId: fixtureOperationId(706), + }; + expect(openManualResetCreditOperation(first, 100)).toMatchObject({ kind: "execute" }); + expect(settleManualResetCreditOperation(first, "reset", 200)).toEqual({ kind: "updated" }); + const second = { ...first, operationId: fixtureOperationId(707) }; + expect(openManualResetCreditOperation(second, 300)).toEqual({ + kind: "execute", + operationId: second.operationId, + resumed: false, + }); + expect(openManualResetCreditOperation(second, 400)).toEqual({ + kind: "execute", + operationId: second.operationId, + resumed: true, + }); + }); + test("manual operations share one physical-account intent across local aliases", () => { const first = { accountId: "pool-manual-fence", @@ -339,6 +368,51 @@ describe("Codex reset-credit operation ledger", () => { } }); + test("rejects a nonterminal row carrying any code without overwriting it", () => { + const opened = openResetCreditOperation(GENERATION, 100); + if (opened.kind !== "execute") throw new Error("reservation failed"); + const database = new Database(databasePath()); + try { + database.run("UPDATE reset_credit_operations SET code = 'garbage'"); + } finally { + database.close(); + } + expect(openResetCreditOperation(GENERATION, 200)).toEqual({ kind: "unavailable" }); + expect(markResetCreditOperationAmbiguous(GENERATION, opened.operationId, 300)) + .toEqual({ kind: "unavailable" }); + const stored = new Database(databasePath(), { readonly: true }); + try { + expect(stored.query<{ code: string }, []>( + "SELECT code FROM reset_credit_operations", + ).get()?.code).toBe("garbage"); + } finally { + stored.close(); + } + }); + + test("fails closed for a noncanonical uppercase operation id", () => { + const opened = openResetCreditOperation(GENERATION, 100); + if (opened.kind !== "execute") throw new Error("reservation failed"); + const uppercase = opened.operationId.toUpperCase(); + const database = new Database(databasePath()); + try { + database.prepare("UPDATE reset_credit_operations SET operation_id = ?").run(uppercase); + } finally { + database.close(); + } + expect(openResetCreditOperation(GENERATION, 200)).toEqual({ kind: "unavailable" }); + expect(settleResetCreditOperation(GENERATION, opened.operationId, "reset", 300)) + .toEqual({ kind: "unavailable" }); + const stored = new Database(databasePath(), { readonly: true }); + try { + expect(stored.query<{ operation_id: string }, []>( + "SELECT operation_id FROM reset_credit_operations", + ).get()?.operation_id).toBe(uppercase); + } finally { + stored.close(); + } + }); + test("refuses a lax duplicate schema without choosing or replacing an operation", () => { createLaxDuplicateLedger(); expect(openResetCreditOperation(GENERATION)).toEqual({ kind: "unavailable" }); diff --git a/tests/reset-credit-consent-client.test.ts b/tests/reset-credit-consent-client.test.ts new file mode 100644 index 0000000000..fb22f83a86 --- /dev/null +++ b/tests/reset-credit-consent-client.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationProof, +} from "../src/lib/local-management-attestation"; +import { + CODEX_RESET_CREDIT_CONSENT_ACCOUNT_ID_HEADER, + CODEX_RESET_CREDIT_CONSENT_CAPABILITY_HEADER, + CODEX_RESET_CREDIT_CONSENT_CAPABILITY_VERSION, + CODEX_RESET_CREDIT_CONSENT_EXPIRES_AT_HEADER, + CODEX_RESET_CREDIT_CONSENT_OPERATION_ID_HEADER, + CODEX_RESET_CREDIT_CONSENT_PATH, + verifyCodexResetCreditConsentCapability, +} from "../src/lib/codex-reset-credit-consent-contract"; +import { requestBoundCodexResetCreditConsent } from "../src/cli/reset-credit-consent-client"; +import type { LiveProxy } from "../src/server/proxy-liveness"; + +const secret = "A".repeat(43); +const nonce = "B".repeat(43); +const accountId = "pool-consent-test"; +const operationId = "00112233-4455-4677-8899-aabbccddeeff"; +const target: LiveProxy = { + pid: 4242, + port: 10100, + hostname: "127.0.0.1", + source: "runtime", +}; + +function proofResponse(init?: RequestInit): Response { + const challenge = new Headers(init?.headers).get(LOCAL_ATTESTATION_CHALLENGE_HEADER)!; + return Response.json({ + service: "opencodex", + status: "ok", + version: "test", + uptime: 1, + pid: target.pid, + port: target.port, + resetCreditConsentCapability: CODEX_RESET_CREDIT_CONSENT_CAPABILITY_VERSION, + }, { + headers: { + [LOCAL_ATTESTATION_PROOF_HEADER]: createLocalAttestationProof( + secret, + challenge, + target.pid!, + target.port, + )!, + }, + }); +} + +describe("reset-credit consent client", () => { + test("never sends a request when the target lacks process-bound runtime identity", async () => { + let calls = 0; + const result = await requestBoundCodexResetCreditConsent(accountId, operationId, { + findLive: async () => ({ ...target, source: "config" }), + fetchImpl: async () => { calls += 1; return new Response(); }, + }); + expect(result).toEqual({ kind: "unavailable", reason: "unattested-target" }); + expect(calls).toBe(0); + }); + + test("requires listener proof before the consent POST", async () => { + const requests: string[] = []; + const result = await requestBoundCodexResetCreditConsent(accountId, operationId, { + findLive: async () => target, + readRuntime: () => ({ ...target, attestationSecret: secret }), + createNonce: () => nonce, + fetchImpl: async input => { + requests.push(String(input)); + return Response.json({ + service: "opencodex", + pid: target.pid, + port: target.port, + resetCreditConsentCapability: CODEX_RESET_CREDIT_CONSENT_CAPABILITY_VERSION, + }); + }, + }); + expect(result).toEqual({ kind: "unavailable", reason: "attestation" }); + expect(requests).toEqual(["http://127.0.0.1:10100/healthz"]); + }); + + test("sends only an operation-bound bodyless capability after proof", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const now = 1_800_000_000_000; + const result = await requestBoundCodexResetCreditConsent(accountId, operationId, { + findLive: async () => target, + readRuntime: () => ({ ...target, attestationSecret: secret }), + createNonce: () => nonce, + now: () => now, + fetchImpl: async (input, init) => { + requests.push({ url: String(input), init }); + return requests.length === 1 ? proofResponse(init) : Response.json({ code: "reset" }); + }, + }); + + expect(result.kind).toBe("response"); + if (result.kind !== "response") throw new Error("expected response"); + expect(await result.response.json()).toEqual({ code: "reset" }); + expect(requests).toHaveLength(2); + expect(requests[1]!.url).toBe(`http://127.0.0.1:10100${CODEX_RESET_CREDIT_CONSENT_PATH}`); + expect(requests[1]!.init?.method).toBe("POST"); + expect(requests[1]!.init?.body).toBeUndefined(); + const headers = new Headers(requests[1]!.init?.headers); + expect(headers.get(CODEX_RESET_CREDIT_CONSENT_ACCOUNT_ID_HEADER)).toBe(accountId); + expect(headers.get(CODEX_RESET_CREDIT_CONSENT_OPERATION_ID_HEADER)).toBe(operationId); + expect(headers.has("authorization")).toBe(false); + expect(headers.has("x-opencodex-api-key")).toBe(false); + expect(verifyCodexResetCreditConsentCapability( + secret, + nonce, + "POST", + CODEX_RESET_CREDIT_CONSENT_PATH, + accountId, + operationId, + target.pid!, + target.port, + Number(headers.get(CODEX_RESET_CREDIT_CONSENT_EXPIRES_AT_HEADER)), + headers.get(CODEX_RESET_CREDIT_CONSENT_CAPABILITY_HEADER), + now, + )).toBe(true); + }); + + test("stops when the protected runtime record changes after proof", async () => { + let reads = 0; + let calls = 0; + const result = await requestBoundCodexResetCreditConsent(accountId, operationId, { + findLive: async () => target, + readRuntime: () => { + reads += 1; + return reads === 1 + ? { ...target, attestationSecret: secret } + : { ...target, port: target.port + 1, attestationSecret: secret }; + }, + createNonce: () => nonce, + fetchImpl: async (_input, init) => { + calls += 1; + return proofResponse(init); + }, + }); + expect(result).toEqual({ kind: "unavailable", reason: "runtime-mismatch" }); + expect(calls).toBe(1); + }); +}); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 671baa67ec..a1922dc004 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -43,6 +43,7 @@ import { ownedServiceHomeInspection } from "./helpers/owned-service-home-inspect import { configuredAdminToken } from "../src/lib/admin-secrets"; import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../src/lib/system-restart-contract"; import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../src/lib/local-provider-reload-contract"; +import { CODEX_RESET_CREDIT_CONSENT_CAPABILITY_VERSION } from "../src/lib/codex-reset-credit-consent-contract"; const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; const previousOpencodexHome = process.env.OPENCODEX_HOME; @@ -691,6 +692,7 @@ describe("server local API auth", () => { "pid", "port", "providerReloadCapability", + "resetCreditConsentCapability", "restartCapability", "service", "status", @@ -699,6 +701,7 @@ describe("server local API auth", () => { ]); expect(healthBody.restartCapability).toBe(SYSTEM_RESTART_CAPABILITY_VERSION); expect(healthBody.providerReloadCapability).toBe(LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION); + expect(healthBody.resetCreditConsentCapability).toBe(CODEX_RESET_CREDIT_CONSENT_CAPABILITY_VERSION); expect("rss" in healthBody).toBe(false); } finally { await server.stop(true); diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index f4d256edeb..d3a9ed2a0e 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -58,6 +58,19 @@ import { createLocalProviderReloadCapability, verifyLocalProviderReloadCapability, } from "../src/lib/local-provider-reload-contract"; +import { + CODEX_RESET_CREDIT_CONSENT_ACCOUNT_ID_HEADER, + CODEX_RESET_CREDIT_CONSENT_CAPABILITY_HEADER, + CODEX_RESET_CREDIT_CONSENT_CAPABILITY_TTL_MS, + CODEX_RESET_CREDIT_CONSENT_EXPECTED_PID_HEADER, + CODEX_RESET_CREDIT_CONSENT_EXPIRES_AT_HEADER, + CODEX_RESET_CREDIT_CONSENT_METHOD, + CODEX_RESET_CREDIT_CONSENT_NONCE_HEADER, + CODEX_RESET_CREDIT_CONSENT_OPERATION_ID_HEADER, + CODEX_RESET_CREDIT_CONSENT_PATH, + createCodexResetCreditConsentCapability, + verifyCodexResetCreditConsentCapability, +} from "../src/lib/codex-reset-credit-consent-contract"; import { setSystemRestartIoForTests } from "../src/server/management/system-restart"; const previousHome = process.env.OPENCODEX_HOME; @@ -467,6 +480,148 @@ describe("management and data-plane credential separation", () => { )).toBe(false); }); + test("a reset-credit consent capability is one-shot and exact to its operation", () => { + const secret = "A".repeat(43); + const nonce = "L".repeat(43); + const accountId = "pool-consent-auth"; + const operationId = "00112233-4455-4677-8899-aabbccddeeff"; + const expiresAt = Date.now() + CODEX_RESET_CREDIT_CONSENT_CAPABILITY_TTL_MS; + const unavailable = { available: false, reason: "injected unavailable state" } as const; + const local = { attestationSecret: secret, pid: process.pid, port: 10100 }; + const headers = { + [CODEX_RESET_CREDIT_CONSENT_EXPECTED_PID_HEADER]: String(process.pid), + [CODEX_RESET_CREDIT_CONSENT_NONCE_HEADER]: nonce, + [CODEX_RESET_CREDIT_CONSENT_EXPIRES_AT_HEADER]: String(expiresAt), + [CODEX_RESET_CREDIT_CONSENT_ACCOUNT_ID_HEADER]: accountId, + [CODEX_RESET_CREDIT_CONSENT_OPERATION_ID_HEADER]: operationId, + "content-length": "0", + [CODEX_RESET_CREDIT_CONSENT_CAPABILITY_HEADER]: createCodexResetCreditConsentCapability( + secret, + nonce, + CODEX_RESET_CREDIT_CONSENT_METHOD, + CODEX_RESET_CREDIT_CONSENT_PATH, + accountId, + operationId, + process.pid, + local.port, + expiresAt, + )!, + }; + + const request = new Request(`http://127.0.0.1:${local.port}${CODEX_RESET_CREDIT_CONSENT_PATH}`, { + method: CODEX_RESET_CREDIT_CONSENT_METHOD, + headers, + }); + expect(requireManagementAuth(request, unavailable, remoteConfig(), local)).toBeNull(); + expect(managementPrincipal(request, unavailable, remoteConfig(), local)) + .toBe("local-reset-credit-capability"); + + const replay = new Request(request.url, { method: CODEX_RESET_CREDIT_CONSENT_METHOD, headers }); + expect(requireManagementAuth(replay, unavailable, remoteConfig(), local)?.status).toBe(503); + const wrongAccount = new Request(request.url, { + method: CODEX_RESET_CREDIT_CONSENT_METHOD, + headers: { ...headers, [CODEX_RESET_CREDIT_CONSENT_ACCOUNT_ID_HEADER]: "other-account" }, + }); + expect(requireManagementAuth(wrongAccount, unavailable, remoteConfig(), local)?.status).toBe(503); + const wrongOperation = new Request(request.url, { + method: CODEX_RESET_CREDIT_CONSENT_METHOD, + headers: { ...headers, [CODEX_RESET_CREDIT_CONSENT_OPERATION_ID_HEADER]: "11112222-3333-4444-8999-aabbccddeeff" }, + }); + expect(requireManagementAuth(wrongOperation, unavailable, remoteConfig(), local)?.status).toBe(503); + const query = new Request(`${request.url}?confirm=1`, { + method: CODEX_RESET_CREDIT_CONSENT_METHOD, + headers, + }); + expect(requireManagementAuth(query, unavailable, remoteConfig(), local)?.status).toBe(503); + const body = new Request(request.url, { + method: CODEX_RESET_CREDIT_CONSENT_METHOD, + headers: { ...headers, "content-length": "2" }, + body: "{}", + }); + expect(requireManagementAuth(body, unavailable, remoteConfig(), local)?.status).toBe(503); + const chunked = new Request(request.url, { + method: CODEX_RESET_CREDIT_CONSENT_METHOD, + headers: { ...headers, "transfer-encoding": "chunked" }, + }); + expect(requireManagementAuth(chunked, unavailable, remoteConfig(), local)?.status).toBe(503); + }); + + test("reset-credit consent capability binds method path identity process endpoint and TTL", () => { + const secret = "A".repeat(43); + const nonce = "M".repeat(43); + const now = 1_800_000_000_000; + const accountId = "pool-consent-contract"; + const operationId = "00112233-4455-4677-8899-aabbccddeeff"; + const pid = 4242; + const port = 10100; + const validExpiry = now + CODEX_RESET_CREDIT_CONSENT_CAPABILITY_TTL_MS; + const capability = createCodexResetCreditConsentCapability( + secret, + nonce, + CODEX_RESET_CREDIT_CONSENT_METHOD, + CODEX_RESET_CREDIT_CONSENT_PATH, + accountId, + operationId, + pid, + port, + validExpiry, + )!; + const verify = ( + method = CODEX_RESET_CREDIT_CONSENT_METHOD, + path = CODEX_RESET_CREDIT_CONSENT_PATH, + selectedAccountId = accountId, + selectedOperationId = operationId, + selectedPid = pid, + selectedPort = port, + expiresAt = validExpiry, + candidate = capability, + ) => verifyCodexResetCreditConsentCapability( + secret, + nonce, + method, + path, + selectedAccountId, + selectedOperationId, + selectedPid, + selectedPort, + expiresAt, + candidate, + now, + ); + + expect(verify()).toBe(true); + expect(verify("GET")).toBe(false); + expect(verify(CODEX_RESET_CREDIT_CONSENT_METHOD, "/api/codex-auth/reset-credits")).toBe(false); + expect(verify(CODEX_RESET_CREDIT_CONSENT_METHOD, CODEX_RESET_CREDIT_CONSENT_PATH, "../bad")).toBe(false); + expect(verify(CODEX_RESET_CREDIT_CONSENT_METHOD, CODEX_RESET_CREDIT_CONSENT_PATH, accountId, operationId.toUpperCase())).toBe(false); + expect(verify(CODEX_RESET_CREDIT_CONSENT_METHOD, CODEX_RESET_CREDIT_CONSENT_PATH, accountId, operationId, pid + 1)).toBe(false); + expect(verify(CODEX_RESET_CREDIT_CONSENT_METHOD, CODEX_RESET_CREDIT_CONSENT_PATH, accountId, operationId, pid, port + 1)).toBe(false); + expect(verify(CODEX_RESET_CREDIT_CONSENT_METHOD, CODEX_RESET_CREDIT_CONSENT_PATH, accountId, operationId, pid, port, now)).toBe(false); + + const tooLate = now + CODEX_RESET_CREDIT_CONSENT_CAPABILITY_TTL_MS + 1; + const tooLateCapability = createCodexResetCreditConsentCapability( + secret, + nonce, + CODEX_RESET_CREDIT_CONSENT_METHOD, + CODEX_RESET_CREDIT_CONSENT_PATH, + accountId, + operationId, + pid, + port, + tooLate, + )!; + expect(verify( + CODEX_RESET_CREDIT_CONSENT_METHOD, + CODEX_RESET_CREDIT_CONSENT_PATH, + accountId, + operationId, + pid, + port, + tooLate, + tooLateCapability, + )).toBe(false); + }); + test("management-token temp cleanup forgets successful ACL memos and retains failed removals", () => { const temporary = join(testHome, ".admin-token.tmp"); const previousUsername = process.env.USERNAME; @@ -840,6 +995,62 @@ describe("management and data-plane credential separation", () => { } }); + test("live server refuses admin-token reset-credit consume and admits GUI-session validation", async () => { + const config = remoteConfig(); + config.hostname = "127.0.0.1"; + saveConfig(config); + const state = initializeManagementAuthState(config); + const server = startServer(0, { managementAuthState: state }); + try { + const operationId = "123e4567-e89b-42d3-a456-426614174000"; + const adminResponse = await fetch( + new URL("/api/codex-auth/reset-credits/consume", server.url), + { + method: "POST", + headers: { + "content-type": "application/json", + "x-opencodex-api-key": "admin-secret", + }, + body: JSON.stringify({ + accountId: "pool-account", + operationId, + confirmed: true, + }), + }, + ); + expect(adminResponse.status).toBe(403); + expect(await adminResponse.json()).toEqual({ + error: "User consent is required to consume a reset credit", + code: "agent_consent_required", + }); + + const pageRequest = new Request(server.url, { + headers: { Host: server.url.host }, + }); + const session = issueGuiSession(pageRequest, config, state); + expect(session).not.toBeNull(); + + const guiResponse = await fetch( + new URL("/api/codex-auth/reset-credits/consume", server.url), + { + method: "POST", + headers: { + "content-type": "application/json", + Origin: server.url.origin, + "x-opencodex-api-key": session?.token ?? "", + "x-opencodex-gui-origin": session?.origin ?? "", + "x-opencodex-csrf-token": session?.csrfToken ?? "", + }, + body: "{}", + }, + ); + expect(guiResponse.status).toBe(400); + expect(await guiResponse.json()).toEqual({ error: "accountId required" }); + } finally { + await server.stop(true); + } + }, SERVER_BUDGET_MS); + test("a non-loopback binding never issues a GUI session from a forged loopback Host", () => { const config = remoteConfig(); const state = initializeManagementAuthState(config); From cefac33295ed92f6ebfb449e0788fecfd78b5dff Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:58:05 +0900 Subject: [PATCH 6/7] fix(codex): preserve reset-credit retry identity --- AGENTS_INSTALL.md | 8 +- .../docs/getting-started/for-agents.md | 2 + .../docs/reference/cli/providers-accounts.md | 2 + .../content/docs/reference/management-api.md | 2 +- gui/src/components/CodexAccountPool.tsx | 73 +++++- .../components/codex-account-pool-handlers.ts | 25 +- .../components/codex-account-reset-modal.tsx | 8 +- gui/tests/codex-account-pool-handlers.test.ts | 18 +- .../codex-account-pool-toast-tone.test.tsx | 55 ++++- src/cli/account-api.ts | 3 + src/cli/account-auth.ts | 31 ++- src/cli/reset-credit-consent-client.ts | 4 +- src/cli/reset-credit-pending.ts | 218 ++++++++++++++++++ src/codex/auth-api.ts | 25 +- .../codex-reset-credit-consent-contract.ts | 2 +- tests/cli-account.test.ts | 38 ++- tests/codex-auth-api.test.ts | 30 +-- tests/reset-credit-pending.test.ts | 96 ++++++++ 18 files changed, 567 insertions(+), 73 deletions(-) create mode 100644 src/cli/reset-credit-pending.ts create mode 100644 tests/reset-credit-pending.test.ts diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index 87955cbc53..de101325b7 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -68,8 +68,12 @@ reads, so the CLI prints one dim line and this file carries the contract. ## Where the enforcement lives -Reading this file is not what makes the boundary hold — the code refuses -agent-driven callers regardless: +Reading this file is not what makes the boundary hold — the code refuses known +agent-driven callers on the normal path. Like the dashboard session, local +capability checks are not proof of human presence: a determined process running +as the same user can reach the same local secrets and browser surface. The rule +above is the actual boundary and remains binding even when those mechanisms are +technically reachable: - [`src/cli/agent-driven.ts`](./src/cli/agent-driven.ts) — agent detection. - [`src/cli/star-prompt.ts`](./src/cli/star-prompt.ts) — prompt suppression and diff --git a/docs-site/src/content/docs/getting-started/for-agents.md b/docs-site/src/content/docs/getting-started/for-agents.md index 2b2488f28b..3692be8126 100644 --- a/docs-site/src/content/docs/getting-started/for-agents.md +++ b/docs-site/src/content/docs/getting-started/for-agents.md @@ -59,6 +59,8 @@ Inspecting reset credits is read-only. Never run `ocx account reset-credits ` diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index d276a1ab0a..8001c59ff1 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -250,7 +250,7 @@ manager. Its routes are: | `PUT /api/codex-auth/failover` | Set the account failover threshold | 400 invalid threshold | | `GET /api/codex-auth/quota` | Read cached quota state by account | — | | `GET /api/codex-auth/reset-credits` | Inspect reset-credit eligibility for an account | 400 missing account id; upstream status passthrough; 500 lookup failure | -| `POST /api/codex-auth/reset-credits/consume` | Consume an eligible reset credit; requires a GUI session or the CLI's one-shot local consent capability, not reusable admin auth or a `confirmed` field | 400 invalid identity; 403 `agent_consent_required`; upstream status passthrough; 503 `server_busy`; 500 consume failure | +| `POST /api/codex-auth/reset-credits/consume` | Consume an eligible reset credit; requires a GUI session or the CLI's one-shot local consent capability, not reusable admin auth or a `confirmed` field. The caller must durably reuse its operation ID until a terminal code is observed; quota refresh is a separate follow-up read. | 400 invalid identity; 403 `agent_consent_required`; upstream status passthrough; 503 `server_busy` before settlement; 500 consume failure | | `POST /api/codex-auth/login` | Start Codex login or reauthentication | 400 invalid request; conflict/busy login states | | `POST /api/codex-auth/login/code` | Submit a manual code for a Codex login flow | 400 invalid flow/code | | `POST /api/codex-auth/login/cancel` | Cancel a Codex login flow | — | diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 8828632fbc..373eddbd69 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -26,6 +26,33 @@ import { newBrowserUuid } from "../lib/uuid"; export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; const DOCTOR_CMD = "ocx doctor"; +const RESET_OPERATION_STORAGE_KEY = "ocx.codexResetCreditOperation.v1"; + +interface PendingResetOperation { + accountId: string; + operationId: string; +} + +type PendingResetOperations = Record; + +function readPendingResetOperations(): PendingResetOperations { + try { + const value = JSON.parse(sessionStorage.getItem(RESET_OPERATION_STORAGE_KEY) ?? "{}") as Record; + return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => + typeof entry[1] === "string")); + } catch { + return {}; + } +} + +function writePendingResetOperations(operations: PendingResetOperations): void { + try { + if (Object.keys(operations).length > 0) { + sessionStorage.setItem(RESET_OPERATION_STORAGE_KEY, JSON.stringify(operations)); + } + else sessionStorage.removeItem(RESET_OPERATION_STORAGE_KEY); + } catch { /* storage may be unavailable; component state still preserves the retry */ } +} /** * Global ChatGPT / Codex account pool (main + extras), extracted from the Codex @@ -70,11 +97,13 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const feedbackTimerRef = useRef | null>(null); const [refreshingQuota, setRefreshingQuota] = useState(false); const [resetPopup, setResetPopup] = useState(null); - const [resetOperationId, setResetOperationId] = useState(null); + const [pendingResetOperations, setPendingResetOperations] = useState(readPendingResetOperations); const [resetConfirm, setResetConfirm] = useState(false); const [redeeming, setRedeeming] = useState(false); const [creditDetails, setCreditDetails] = useState<{ granted_at: string; expires_at: string }[] | null>(null); const [creditDetailsLoading, setCreditDetailsLoading] = useState(false); + const resetDetailEpochRef = useRef(0); + const redeemingRef = useRef(false); const doctorCopy = useCopyFeedback(); const showActionFeedback = useCallback((text: string, tone: NoticeTone = "ok") => { @@ -237,39 +266,57 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban }; const openResetPopup = async (account: CodexAccountEntry) => { + const epoch = ++resetDetailEpochRef.current; setResetPopup(account); - setResetOperationId(newBrowserUuid()); setResetConfirm(false); setCreditDetails(null); setCreditDetailsLoading(true); try { const resp = await fetch(`${apiBase}/api/codex-auth/reset-credits?accountId=${encodeURIComponent(account.id)}`); const data = await readJsonIfOk<{ credits?: { granted_at: string; expires_at: string }[] }>(resp); - if (data) { + if (data && resetDetailEpochRef.current === epoch) { const sorted = (data.credits ?? []).sort((a, b) => new Date(a.granted_at).getTime() - new Date(b.granted_at).getTime() ); setCreditDetails(sorted); } } catch { /* detail fetch is non-blocking */ } - finally { setCreditDetailsLoading(false); } + finally { + if (resetDetailEpochRef.current === epoch) setCreditDetailsLoading(false); + } }; const handleRedeem = async (accountId: string) => { + if (redeemingRef.current) return; + redeemingRef.current = true; + const operation: PendingResetOperation = { + accountId, + operationId: pendingResetOperations[accountId] ?? newBrowserUuid(), + }; + setPendingResetOperations(current => { + const next = { ...current, [operation.accountId]: operation.operationId }; + writePendingResetOperations(next); + return next; + }); setRedeeming(true); try { - const operationId = resetOperationId ?? newBrowserUuid(); - if (!resetOperationId) setResetOperationId(operationId); - const result = await redeemResetCredit(apiBase, accountId, operationId, t, load); - if (result.close) { + const result = await redeemResetCredit(apiBase, accountId, operation.operationId, t, load); + if (result.outcome === "terminal") { + setPendingResetOperations(current => { + if (current[operation.accountId] !== operation.operationId) return current; + const next = { ...current }; + delete next[operation.accountId]; + writePendingResetOperations(next); + return next; + }); setResetPopup(null); - setResetOperationId(null); setResetConfirm(false); } if (result.toast) { showActionFeedback(result.toast, result.ok ? "ok" : "err"); } } finally { + redeemingRef.current = false; setRedeeming(false); } }; @@ -416,7 +463,13 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban creditDetails={creditDetails} creditDetailsLoading={creditDetailsLoading} redeeming={redeeming} - onClose={() => { setResetPopup(null); setResetOperationId(null); setResetConfirm(false); setCreditDetails(null); }} + onClose={() => { + if (redeeming) return; + resetDetailEpochRef.current += 1; + setResetPopup(null); + setResetConfirm(false); + setCreditDetails(null); + }} onShowConfirm={() => setResetConfirm(true)} onCancelConfirm={() => setResetConfirm(false)} onRedeem={() => { void handleRedeem(resetPopup.id); }} diff --git a/gui/src/components/codex-account-pool-handlers.ts b/gui/src/components/codex-account-pool-handlers.ts index b45f749e85..36399bda9c 100644 --- a/gui/src/components/codex-account-pool-handlers.ts +++ b/gui/src/components/codex-account-pool-handlers.ts @@ -15,7 +15,11 @@ export async function redeemResetCredit( operationId: string, t: TFn, load: (refresh?: boolean) => Promise, -): Promise<{ ok: boolean; toast?: string; close?: boolean }> { +): Promise<{ + ok: boolean; + outcome: "terminal" | "ambiguous"; + toast?: string; +}> { try { const resp = await fetch(`${apiBase}/api/codex-auth/reset-credits/consume`, { method: "POST", @@ -23,9 +27,9 @@ export async function redeemResetCredit( body: JSON.stringify({ accountId, operationId }), }); const result = await readJsonIfOk<{ code: string; remaining?: number }>(resp); - if (!result) return { ok: false, toast: t("codexAuth.resetError") }; + if (!result) return { ok: false, outcome: "ambiguous", toast: t("codexAuth.resetError") }; if (result.code === "reset" || result.code === "already_redeemed") { - await load(true); + try { await load(true); } catch { /* the consume outcome is already terminal */ } // Authoritative remaining comes from the management endpoint (refreshed quota). // Never invent a decrement from a stale modal snapshot. const remaining = @@ -34,15 +38,18 @@ export async function redeemResetCredit( : undefined; return { ok: true, - close: true, + outcome: "terminal", toast: remainingCreditsToast(t, remaining), }; } - const key = result.code === "nothing_to_reset" ? "codexAuth.resetNothingToReset" - : result.code === "no_credit" ? "codexAuth.resetNoCredit" - : "codexAuth.resetError"; - return { ok: false, close: true, toast: t(key) }; + if (result.code === "nothing_to_reset" || result.code === "no_credit") { + const key = result.code === "nothing_to_reset" + ? "codexAuth.resetNothingToReset" + : "codexAuth.resetNoCredit"; + return { ok: false, outcome: "terminal", toast: t(key) }; + } + return { ok: false, outcome: "ambiguous", toast: t("codexAuth.resetError") }; } catch { - return { ok: false, toast: t("codexAuth.resetError") }; + return { ok: false, outcome: "ambiguous", toast: t("codexAuth.resetError") }; } } diff --git a/gui/src/components/codex-account-reset-modal.tsx b/gui/src/components/codex-account-reset-modal.tsx index cc518029bc..5d0bf81df0 100644 --- a/gui/src/components/codex-account-reset-modal.tsx +++ b/gui/src/components/codex-account-reset-modal.tsx @@ -36,8 +36,9 @@ export function CodexAccountResetModal({ const handleCancel = useCallback((e: React.SyntheticEvent) => { e.preventDefault(); + if (redeeming) return; onClose(); - }, [onClose]); + }, [onClose, redeeming]); return ( - + diff --git a/gui/tests/codex-account-pool-handlers.test.ts b/gui/tests/codex-account-pool-handlers.test.ts index d9c19b15bd..2c76ac0208 100644 --- a/gui/tests/codex-account-pool-handlers.test.ts +++ b/gui/tests/codex-account-pool-handlers.test.ts @@ -41,7 +41,7 @@ test("balance changed after modal opened: toast uses authoritative remaining, no expect(loadCalls).toBe(1); expect(result.ok).toBe(true); - expect(result.close).toBe(true); + expect(result.outcome).toBe("terminal"); expect(result.toast).toBe("codexAuth.resetSuccess:remaining=1"); expect(result.toast).not.toContain("remaining=2"); expect(result.toast).not.toContain("remaining=3"); @@ -60,7 +60,7 @@ test("already_redeemed does not decrement and uses the returned remaining count" expect(loadCalls).toBe(1); expect(result.ok).toBe(true); - expect(result.close).toBe(true); + expect(result.outcome).toBe("terminal"); expect(result.toast).toBe("codexAuth.resetSuccess:remaining=3"); expect(result.toast).not.toContain("remaining=2"); }); @@ -87,5 +87,19 @@ test("failure paths return ok:false so callers can set toastError from result.ok const result = await redeemResetCredit("", "acct-1", crypto.randomUUID(), t, async () => true); expect(result.ok).toBe(false); + expect(result.outcome).toBe("terminal"); expect(result.toast).toBe("codexAuth.resetNoCredit"); }); + +test("transport and malformed outcomes remain ambiguous for same-id retry", async () => { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async () => { throw new Error("response lost"); }, + }); + const result = await redeemResetCredit("", "acct-1", crypto.randomUUID(), t, async () => true); + expect(result).toEqual({ + ok: false, + outcome: "ambiguous", + toast: "codexAuth.resetError", + }); +}); diff --git a/gui/tests/codex-account-pool-toast-tone.test.tsx b/gui/tests/codex-account-pool-toast-tone.test.tsx index c32f3b2278..e65e74a068 100644 --- a/gui/tests/codex-account-pool-toast-tone.test.tsx +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -11,7 +11,7 @@ import { LanguageProvider } from "../src/i18n/provider"; * Stale toastError must not paint a successful redeem as notice-err (PR #475). */ -const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; let previous: Record<(typeof globals)[number], unknown>; let win: Window; let host: HTMLElement; @@ -70,6 +70,7 @@ beforeEach(() => { window: { configurable: true, value: win }, navigator: { configurable: true, value: win.navigator }, localStorage: { configurable: true, value: win.localStorage }, + sessionStorage: { configurable: true, value: win.sessionStorage }, }); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -319,3 +320,55 @@ test("LAN fallback UUID remains stable across a failed redeem retry", async () = }); } }); + +test("an ambiguous redeem survives modal close and remount with the same operation identity", async () => { + const baseFetch = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/codex-auth/reset-credits/consume") { + consumeAttempts += 1; + consumedOperationIds.push( + (JSON.parse(String(init?.body)) as { operationId: string }).operationId, + ); + if (consumeAttempts === 1) return Response.json({ error: "lost" }, { status: 502 }); + return Response.json({ code: "already_redeemed", remaining: 1 }); + } + return baseFetch(input, init); + }, + }); + + const redeemOnce = async () => { + const reset = host.querySelector('button[aria-label="2 reset credit(s)"]') as HTMLButtonElement; + await act(async () => { reset.click(); await new Promise(resolve => setTimeout(resolve, 40)); }); + const useCredit = [...host.querySelectorAll("button")].find(button => + (button.textContent ?? "").includes("Use 1 Credit"), + )!; + await act(async () => { useCredit.click(); }); + const redeem = [...host.querySelectorAll("button")].find(button => + (button.textContent ?? "").trim() === "Use Credit", + )!; + await act(async () => { redeem.click(); await new Promise(resolve => setTimeout(resolve, 40)); }); + }; + + await mountPool(makeController()); + await redeemOnce(); + expect(consumeAttempts).toBe(1); + const backdrop = host.querySelector(".modal-backdrop-dismiss") as HTMLButtonElement; + await act(async () => { backdrop.click(); }); + expect(host.querySelector("dialog")).toBeNull(); + + const current = root!; + await act(async () => { current.unmount(); }); + root = null; + host.remove(); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); + await mountPool(makeController()); + await redeemOnce(); + + expect(consumeAttempts).toBe(2); + expect(new Set(consumedOperationIds).size).toBe(1); + expect(sessionStorage.getItem("ocx.codexResetCreditOperation.v1")).toBeNull(); +}); diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index 084b39fa11..9adfec3e3f 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -60,6 +60,9 @@ export interface AccountDeps { stageLeaseClock?: StageLeaseClock; /** Test seam for the consent-bound reset-credit client. */ requestResetCreditConsentImpl?: typeof import("./reset-credit-consent-client").requestBoundCodexResetCreditConsent; + /** Test seams for the durable cross-process reset-credit retry identity. */ + reserveResetCreditOperationImpl?: typeof import("./reset-credit-pending").reservePendingResetCreditOperation; + clearResetCreditOperationImpl?: typeof import("./reset-credit-pending").clearPendingResetCreditOperation; /** Test seam for the process-level user-consent guard. */ isAgentDrivenImpl?: () => boolean; } diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index a9c44b9c25..f086dcb0b4 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -1,7 +1,11 @@ import { writeSync } from "node:fs"; -import { randomUUID } from "node:crypto"; import { isAgentDriven } from "./agent-driven"; import { requestBoundCodexResetCreditConsent } from "./reset-credit-consent-client"; +import { + clearPendingResetCreditOperation, + reservePendingResetCreditOperation, +} from "./reset-credit-pending"; +import { isCodexResetCreditConsentAccountId } from "../lib/codex-reset-credit-consent-contract"; import type { AccountDeps } from "./account-api"; import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh"; import { @@ -235,6 +239,9 @@ async function resetCredits(argv: string[], deps: AccountDeps): Promise { if (consume && !yes) throw new CliUsageError("consuming a reset credit requires --yes", USAGE); rejectArgs(args, USAGE); const accountId = rawId === "main" ? "__main__" : rawId; + if (!isCodexResetCreditConsentAccountId(accountId)) { + throw new CliUsageError("Invalid account id format", USAGE); + } let result: unknown; if (consume) { if ((deps.isAgentDrivenImpl ?? isAgentDriven)()) { @@ -243,7 +250,12 @@ async function resetCredits(argv: string[], deps: AccountDeps): Promise { USAGE, ); } - const operationId = randomUUID(); + let operationId: string; + try { + operationId = (deps.reserveResetCreditOperationImpl ?? reservePendingResetCreditOperation)(accountId); + } catch { + throw new CliUsageError("reset-credit retry state is unavailable", USAGE); + } const consent = await (deps.requestResetCreditConsentImpl ?? requestBoundCodexResetCreditConsent)( accountId, operationId, @@ -268,6 +280,21 @@ async function resetCredits(argv: string[], deps: AccountDeps): Promise { : `Reset-credit request failed (${consent.response.status})`; throw new CliUsageError(detail, USAGE); } + const terminalCode = body && typeof body === "object" + ? (body as { code?: unknown }).code + : undefined; + if ( + terminalCode === "reset" + || terminalCode === "already_redeemed" + || terminalCode === "nothing_to_reset" + || terminalCode === "no_credit" + ) { + try { + (deps.clearResetCreditOperationImpl ?? clearPendingResetCreditOperation)(accountId, operationId); + } catch { + throw new CliUsageError("reset-credit retry state could not be cleared", USAGE); + } + } result = body; } else { result = await runtimeRequest( diff --git a/src/cli/reset-credit-consent-client.ts b/src/cli/reset-credit-consent-client.ts index 434a2c3d75..b9ab1a4e08 100644 --- a/src/cli/reset-credit-consent-client.ts +++ b/src/cli/reset-credit-consent-client.ts @@ -62,10 +62,12 @@ function sameRuntime(left: RuntimePortState, right: RuntimePortState | null): bo } /** - * Send one user-confirmed redemption to the exact attested local proxy. + * Send one CLI-confirmed redemption to the exact attested local proxy. * * The request carries no reusable management credential. Its body is empty; the * account and idempotency identities are bound into a short-lived, one-shot HMAC. + * This proves exact request/process authority, not human presence; the normative + * agent rule is the boundary against a determined same-user local process. */ export async function requestBoundCodexResetCreditConsent( accountId: string, diff --git a/src/cli/reset-credit-pending.ts b/src/cli/reset-credit-pending.ts new file mode 100644 index 0000000000..63203942e4 --- /dev/null +++ b/src/cli/reset-credit-pending.ts @@ -0,0 +1,218 @@ +import { createHash, randomUUID } from "node:crypto"; +import { chmodSync } from "node:fs"; +import { Database } from "bun:sqlite"; +import { prepareConfigMutationDatabasePathForWrite } from "../config"; +import { initializeConfigGeneration } from "../codex/generation"; +import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery"; +import { isCodexResetCreditConsentAccountId } from "../lib/codex-reset-credit-consent-contract"; + +const TABLE_NAME = "reset_credit_cli_pending"; +const MAX_PENDING_OPERATIONS = 128; +const ACCOUNT_KEY_PATTERN = /^[0-9a-f]{64}$/; +const CREATE_TABLE = `CREATE TABLE main.reset_credit_cli_pending ( + account_key TEXT PRIMARY KEY + CHECK (length(account_key) = 64 AND account_key NOT GLOB '*[^0-9a-f]*'), + operation_id TEXT NOT NULL UNIQUE + ) STRICT, WITHOUT ROWID`; +const EXPECTED_SCHEMA_SQL = CREATE_TABLE.replace("main.", ""); +export const RESET_CREDIT_PENDING_SCHEMA_SQL_FOR_TESTS = EXPECTED_SCHEMA_SQL; + +type PendingRow = { + account_key: unknown; + operation_id: unknown; +}; + +type SchemaRow = { + type: unknown; + name: unknown; + tbl_name: unknown; + sql: unknown; +}; + +function accountKey(accountId: string): string { + if (!isCodexResetCreditConsentAccountId(accountId)) { + throw new TypeError("Invalid reset-credit account id"); + } + return createHash("sha256").update(accountId).digest("hex"); +} + +function assertCanonicalTable(database: Database): void { + const rows = database.query(` + SELECT type, name, tbl_name, sql + FROM main.sqlite_schema + WHERE name = ? COLLATE NOCASE + ORDER BY type, name + LIMIT 4 + `).all(TABLE_NAME); + if (rows.length === 0) { + database.exec(CREATE_TABLE); + } else if ( + rows.length !== 1 + || rows[0]?.type !== "table" + || rows[0]?.name !== TABLE_NAME + || rows[0]?.tbl_name !== TABLE_NAME + || rows[0]?.sql !== EXPECTED_SCHEMA_SQL + ) { + throw new Error("Reset-credit retry state schema is invalid"); + } + + const tableRows = database.query<{ + schema: unknown; + name: unknown; + type: unknown; + ncol: unknown; + wr: unknown; + strict: unknown; + }, []>("PRAGMA main.table_list").all().filter(row => row.name === TABLE_NAME); + if ( + tableRows.length !== 1 + || tableRows[0]?.schema !== "main" + || tableRows[0]?.type !== "table" + || tableRows[0]?.ncol !== 2 + || tableRows[0]?.wr !== 1 + || tableRows[0]?.strict !== 1 + ) { + throw new Error("Reset-credit retry state table is invalid"); + } + + const trigger = database.query<{ name: unknown }, [string]>(` + SELECT name FROM main.sqlite_schema + WHERE type = 'trigger' AND tbl_name = ? COLLATE NOCASE + LIMIT 1 + `).get(TABLE_NAME); + const tempTrigger = database.query<{ name: unknown }, [string]>(` + SELECT name FROM temp.sqlite_schema + WHERE type = 'trigger' AND tbl_name = ? COLLATE NOCASE + LIMIT 1 + `).get(TABLE_NAME); + if (trigger || tempTrigger) { + throw new Error("Reset-credit retry state triggers are forbidden"); + } +} + +function readAllPending(database: Database): ReadonlyMap { + const rows = database.query(` + SELECT account_key, operation_id + FROM main.reset_credit_cli_pending + ORDER BY account_key + LIMIT ${MAX_PENDING_OPERATIONS + 1} + `).all(); + if (rows.length > MAX_PENDING_OPERATIONS) { + throw new Error("Reset-credit retry state capacity is exhausted"); + } + const pending = new Map(); + const operationIds = new Set(); + for (const row of rows) { + if ( + typeof row.account_key !== "string" + || !ACCOUNT_KEY_PATTERN.test(row.account_key) + || typeof row.operation_id !== "string" + || !isCodexResetCreditOperationId(row.operation_id) + || pending.has(row.account_key) + || operationIds.has(row.operation_id) + ) { + throw new Error("Reset-credit retry state is invalid"); + } + pending.set(row.account_key, row.operation_id); + operationIds.add(row.operation_id); + } + return pending; +} + +function isThenable(value: unknown): boolean { + return ((typeof value === "object" && value !== null) || typeof value === "function") + && typeof (value as { then?: unknown }).then === "function"; +} + +type Synchronous = T extends PromiseLike ? never : T; + +/** + * Commit pending intent changes with SQLite FULL synchronous durability. Keeping + * this state in the shared config-mutation database avoids the Windows rename + * window where a directory entry can disappear after a power loss. + */ +function withPendingDatabase(operation: (database: Database) => Synchronous): T { + const path = prepareConfigMutationDatabasePathForWrite(); + let database: Database | undefined; + let transactionOpen = false; + try { + database = new Database(path, { create: true }); + try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ } + database.exec( + "PRAGMA trusted_schema = OFF; PRAGMA busy_timeout = 0; PRAGMA synchronous = FULL; BEGIN IMMEDIATE", + ); + transactionOpen = true; + initializeConfigGeneration(database); + assertCanonicalTable(database); + const value = operation(database); + if (isThenable(value) || !database.inTransaction) { + throw new Error("Reset-credit retry state work escaped its synchronous transaction"); + } + database.exec("COMMIT"); + transactionOpen = false; + return value; + } catch (error) { + if (transactionOpen) { + try { database?.exec("ROLLBACK"); } catch { /* close still releases the lock */ } + transactionOpen = false; + } + throw error; + } finally { + try { database?.close(); } catch { /* operation already completed */ } + } +} + +export function reservePendingResetCreditOperation(accountId: string): string { + const key = accountKey(accountId); + return withPendingDatabase(database => { + const pending = readAllPending(database); + const existing = pending.get(key); + if (existing) return existing; + if (pending.size >= MAX_PENDING_OPERATIONS) { + throw new Error("Reset-credit retry state capacity is exhausted"); + } + const operationId = randomUUID(); + database.query(` + INSERT INTO main.reset_credit_cli_pending (account_key, operation_id) + VALUES (?, ?) + `).run(key, operationId); + const persisted = database.query(` + SELECT account_key, operation_id + FROM main.reset_credit_cli_pending + WHERE account_key = ? + LIMIT 2 + `).all(key); + if ( + persisted.length !== 1 + || persisted[0]?.account_key !== key + || persisted[0]?.operation_id !== operationId + ) { + throw new Error("Reset-credit retry state could not be verified"); + } + return operationId; + }); +} + +export function clearPendingResetCreditOperation(accountId: string, operationId: string): boolean { + const key = accountKey(accountId); + return withPendingDatabase(database => { + const pending = readAllPending(database); + if (pending.get(key) !== operationId) return false; + const result = database.query(` + DELETE FROM main.reset_credit_cli_pending + WHERE account_key = ? AND operation_id = ? + `).run(key, operationId); + if (result.changes !== 1) { + throw new Error("Reset-credit retry state could not be cleared"); + } + const persisted = database.query<{ count: unknown }, [string]>(` + SELECT count(*) AS count + FROM main.reset_credit_cli_pending + WHERE account_key = ? + `).get(key); + if (persisted?.count !== 0) { + throw new Error("Reset-credit retry state clear could not be verified"); + } + return true; + }); +} diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 2434dd4464..2e8176bcd8 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -1796,29 +1796,8 @@ export async function handleCodexAuthAPI( return manualResetCreditBusyResponse(); } } - // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage - // and return remaining only when that refresh freshly parsed available_count. - // Do not fall back to a preserved cached resetCredits (failed/omitted refresh). - if (code === "reset" || code === "already_redeemed") { - let freshResetCredits: number | undefined; - if (auth.isMain) { - ({ freshResetCredits } = await fetchMainAccountInfoAttempt( - true, - 1, - auth.nativeMainLease, - auth.nativeMainSharedClaimHeld === true, - )); - } else { - const account = configuredPoolAccount(getRuntimeConfig(config), accountId); - ({ freshResetCredits } = await fetchPoolAccountQuota(accountId, true, account?.plan)); - } - return jsonResponse({ - code, - ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits) - ? { remaining: freshResetCredits } - : {}), - }); - } + // Settlement is the authoritative result. Return it before any follow-up + // quota read so an already-terminal same-id retry cannot time out again. return jsonResponse({ code }); })); return operation.ok ? operation.value : operation.response; diff --git a/src/lib/codex-reset-credit-consent-contract.ts b/src/lib/codex-reset-credit-consent-contract.ts index 7a9680bc93..b4cd7b7c3d 100644 --- a/src/lib/codex-reset-credit-consent-contract.ts +++ b/src/lib/codex-reset-credit-consent-contract.ts @@ -72,7 +72,7 @@ function capabilityPayload( ].join("\n"); } -/** One-shot authorization for a user-confirmed reset-credit redemption. */ +/** One-shot exact-request authorization; this is not proof of human presence. */ export function createCodexResetCreditConsentCapability( secret: string, nonce: string, diff --git a/tests/cli-account.test.ts b/tests/cli-account.test.ts index 5f7e8eadab..33f929b0a3 100644 --- a/tests/cli-account.test.ts +++ b/tests/cli-account.test.ts @@ -1409,11 +1409,17 @@ describe("ocx account CLI (issue #180 matrix)", () => { test("reset-credit consume sends one UUIDv4 identity through the consent-bound client", async () => { let requested: { accountId: string; operationId: string } | undefined; + let cleared: { accountId: string; operationId: string } | undefined; const result = await run( ["reset-credits", "main", "--consume", "--yes", "--json"], { ...defaultDeps(), isAgentDrivenImpl: () => false, + reserveResetCreditOperationImpl: () => "123e4567-e89b-42d3-a456-426614174000", + clearResetCreditOperationImpl: (accountId, operationId) => { + cleared = { accountId, operationId }; + return true; + }, requestResetCreditConsentImpl: async (accountId, operationId) => { requested = { accountId, operationId }; return { kind: "response", response: json({ code: "reset" }) }; @@ -1424,13 +1430,39 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(result.code).toBe(0); expect(requested).toEqual({ accountId: "__main__", - operationId: expect.stringMatching( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ), + operationId: "123e4567-e89b-42d3-a456-426614174000", }); + expect(cleared).toEqual(requested); expect(JSON.parse(result.stdout)).toEqual({ code: "reset" }); }); + test("reset-credit consume reuses durable identity after transport loss and clears only terminal success", async () => { + const operationId = "123e4567-e89b-42d3-a456-426614174000"; + const requested: string[] = []; + let clearCalls = 0; + const deps: AccountDeps = { + ...defaultDeps(), + isAgentDrivenImpl: () => false, + reserveResetCreditOperationImpl: () => operationId, + clearResetCreditOperationImpl: () => { + clearCalls += 1; + return true; + }, + requestResetCreditConsentImpl: async (_accountId, requestedOperationId) => { + requested.push(requestedOperationId); + return requested.length === 1 + ? { kind: "unavailable", reason: "transport" } + : { kind: "response", response: json({ code: "already_redeemed" }) }; + }, + }; + + expect((await run(["reset-credits", "main", "--consume", "--yes"], deps)).code).toBe(2); + expect(clearCalls).toBe(0); + expect((await run(["reset-credits", "main", "--consume", "--yes"], deps)).code).toBe(0); + expect(requested).toEqual([operationId, operationId]); + expect(clearCalls).toBe(1); + }); + test("agent-driven reset-credit consumption stops before minting consent", async () => { let consentCalls = 0; const result = await run( diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 5c2d19945a..a03b346d83 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -679,7 +679,7 @@ describe("codex-auth API", () => { } }); - test("busy pool-quota probe maps reset-credit refresh to 503 server_busy with Retry-After 1", async () => { + test("busy post-settlement quota refresh preserves the terminal reset result", async () => { const config = makeConfig(); seedPoolAccount(config, { id: "quota-reset-busy", email: "busy@example.test" }); const cleanup = seedCodexAuthAdmissionForTests({ quotaFlights: 16 }); @@ -693,9 +693,9 @@ describe("codex-auth API", () => { body: resetCreditConsumeBody("quota-reset-busy"), }); const response = await handleResetCreditConsume(req, config); - expect(response?.status).toBe(503); - expect(response?.headers.get("Retry-After")).toBe("1"); - expect(await response?.json()).toMatchObject({ code: "server_busy" }); + expect(response?.status).toBe(200); + expect(response?.headers.get("Retry-After")).toBeNull(); + expect(await response?.json()).toEqual({ code: "reset" }); } finally { cleanup(); } @@ -2294,11 +2294,11 @@ describe("codex-auth API", () => { const second = await request(); expect(second?.status).toBe(200); - expect(await second?.json()).toEqual({ code: "already_redeemed", remaining: 1 }); + expect(await second?.json()).toEqual({ code: "already_redeemed" }); const third = await request(); expect(third?.status).toBe(200); - expect(await third?.json()).toEqual({ code: "already_redeemed", remaining: 1 }); + expect(await third?.json()).toEqual({ code: "already_redeemed" }); expect(consumeCalls).toBe(2); expect(seenOperationIds).toEqual([operationId, operationId]); } finally { @@ -2390,7 +2390,7 @@ describe("codex-auth API", () => { } }); - test("reset-credit consume returns remaining from refreshed quota, not the consume payload", async () => { + test("reset-credit consume returns its terminal code without waiting for quota refresh", async () => { const config = makeConfig(); seedPoolAccount(config, { id: "pool-reset", email: "reset@example.test" }); // Stale local count before redeem — must not be what the response reports. @@ -2426,15 +2426,15 @@ describe("codex-auth API", () => { }); const resp = await handleResetCreditConsume(req, config); expect(resp!.status).toBe(200); - expect(await resp!.json()).toEqual({ code: "reset", remaining: 2 }); - expect(usageCalls).toBe(1); - expect(getAccountQuota("pool-reset")?.resetCredits).toBe(2); + expect(await resp!.json()).toEqual({ code: "reset" }); + expect(usageCalls).toBe(0); + expect(getAccountQuota("pool-reset")?.resetCredits).toBe(9); } finally { globalThis.fetch = originalFetch; } }); - test("reset-credit already_redeemed refreshes quota and never invents a local decrement", async () => { + test("reset-credit already_redeemed returns immediately and never invents a local decrement", async () => { const config = makeConfig(); seedPoolAccount(config, { id: "pool-idempotent", email: "idem@example.test" }); updateAccountQuota("pool-idempotent", undefined, undefined, undefined, undefined, 3); @@ -2461,7 +2461,7 @@ describe("codex-auth API", () => { }); const resp = await handleResetCreditConsume(req, config); expect(resp!.status).toBe(200); - expect(await resp!.json()).toEqual({ code: "already_redeemed", remaining: 3 }); + expect(await resp!.json()).toEqual({ code: "already_redeemed" }); expect(getAccountQuota("pool-idempotent")?.resetCredits).toBe(3); } finally { globalThis.fetch = originalFetch; @@ -2605,7 +2605,7 @@ describe("codex-auth API", () => { } }); - test("reset-credit consume returns remaining from fresh main WHAM credits", async () => { + test("reset-credit consume leaves main quota refresh to the caller after settlement", async () => { writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: "main-reset-ok", account_id: "acct-main-reset-ok" }, })); @@ -2636,8 +2636,8 @@ describe("codex-auth API", () => { }); const resp = await handleResetCreditConsume(req, makeConfig()); expect(resp!.status).toBe(200); - expect(await resp!.json()).toEqual({ code: "reset", remaining: 1 }); - expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.resetCredits).toBe(1); + expect(await resp!.json()).toEqual({ code: "reset" }); + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.resetCredits).toBe(9); } finally { globalThis.fetch = originalFetch; } diff --git a/tests/reset-credit-pending.test.ts b/tests/reset-credit-pending.test.ts new file mode 100644 index 0000000000..f072dde206 --- /dev/null +++ b/tests/reset-credit-pending.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Database } from "bun:sqlite"; +import { + clearPendingResetCreditOperation, + reservePendingResetCreditOperation, + RESET_CREDIT_PENDING_SCHEMA_SQL_FOR_TESTS, +} from "../src/cli/reset-credit-pending"; + +const previousHome = process.env.OPENCODEX_HOME; +let home = ""; + +function databasePath(): string { + return join(home, "config-mutation.sqlite"); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-reset-credit-pending-")); + process.env.OPENCODEX_HOME = home; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); +}); + +test("reserve reuses one FULL-synchronous SQLite operation until an exact terminal clear", () => { + const first = reservePendingResetCreditOperation("__main__"); + const database = new Database(databasePath()); + try { + const schema = database.query<{ sql: string }, []>(` + SELECT sql FROM main.sqlite_schema + WHERE type = 'table' AND name = 'reset_credit_cli_pending' + `).get(); + expect(schema?.sql).toBe(RESET_CREDIT_PENDING_SCHEMA_SQL_FOR_TESTS); + expect(database.query<{ account_key: string; operation_id: string }, []>(` + SELECT account_key, operation_id FROM main.reset_credit_cli_pending + `).get()).toEqual({ + account_key: createHash("sha256").update("__main__").digest("hex"), + operation_id: first, + }); + } finally { + database.close(); + } + + expect(reservePendingResetCreditOperation("__main__")).toBe(first); + expect(clearPendingResetCreditOperation("__main__", "123e4567-e89b-42d3-a456-426614174000")).toBe(false); + expect(reservePendingResetCreditOperation("__main__")).toBe(first); + expect(clearPendingResetCreditOperation("__main__", first)).toBe(true); + expect(reservePendingResetCreditOperation("__main__")).not.toBe(first); +}); + +test("separate accounts never share a pending operation", () => { + expect(reservePendingResetCreditOperation("pool-a")).not.toBe( + reservePendingResetCreditOperation("pool-b"), + ); +}); + +test("contention fails closed without replacing the durable operation", () => { + const first = reservePendingResetCreditOperation("pool-a"); + const holder = new Database(databasePath()); + try { + holder.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + expect(() => reservePendingResetCreditOperation("pool-a")).toThrow(); + } finally { + if (holder.inTransaction) holder.exec("ROLLBACK"); + holder.close(); + } + expect(reservePendingResetCreditOperation("pool-a")).toBe(first); +}); + +test("a non-canonical retry table is rejected without replacement", () => { + const database = new Database(databasePath(), { create: true }); + try { + database.exec("CREATE TABLE reset_credit_cli_pending (account_key TEXT PRIMARY KEY, operation_id TEXT)"); + } finally { + database.close(); + } + expect(() => reservePendingResetCreditOperation("pool-a")).toThrow( + "Reset-credit retry state schema is invalid", + ); + const reopened = new Database(databasePath()); + try { + expect(reopened.query<{ sql: string }, []>(` + SELECT sql FROM main.sqlite_schema WHERE name = 'reset_credit_cli_pending' + `).get()?.sql).toBe( + "CREATE TABLE reset_credit_cli_pending (account_key TEXT PRIMARY KEY, operation_id TEXT)", + ); + } finally { + reopened.close(); + } +}); From ba17ff255da241ed83de2f7136248558caf9e5cf Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:21:44 +0900 Subject: [PATCH 7/7] test(codex): close reset-credit review gaps --- src/codex/reset-credit-operation-ledger.ts | 77 ++++++++++--------- ...odex-reset-credit-operation-ledger.test.ts | 27 +++++++ 2 files changed, 68 insertions(+), 36 deletions(-) diff --git a/src/codex/reset-credit-operation-ledger.ts b/src/codex/reset-credit-operation-ledger.ts index d8151ae7fa..a20dfbdcf4 100644 --- a/src/codex/reset-credit-operation-ledger.ts +++ b/src/codex/reset-credit-operation-ledger.ts @@ -722,6 +722,42 @@ export function openManualResetCreditOperation( if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); try { return withLedger((database, recordCount) => { + const reserve = (replaceCurrent: boolean): OpenManualResetCreditOperationResult => { + const existingOwner = operationOwner(database, identity.operationId); + if (existingOwner !== undefined && existingOwner !== owner.accountKey) { + return Object.freeze({ kind: "unavailable" as const }); + } + + const record: ResetCreditOperationRecord = Object.freeze({ + accountKey: owner.accountKey, + operationKind: "manual", + operationId: identity.operationId, + state: "pending", + createdAt: now, + updatedAt: now, + }); + const values = [ + "manual", + null, + null, + identity.operationId, + "pending", + null, + now, + now, + ] as const; + const result = replaceCurrent + ? database.query(REPLACE_RECORD).run(...values, owner.accountKey) + : database.query(INSERT_RECORD).run(owner.accountKey, ...values); + if (result.changes !== 1) throw new Error("manual reset-credit reservation lost ownership"); + assertStoredRecord(database, record); + return Object.freeze({ + kind: "execute" as const, + operationId: identity.operationId as CodexReservedOperationId, + resumed: false, + }); + }; + const current = readRecord(database, owner.accountKey); if (current) { if (current.operationKind !== "manual") { @@ -744,44 +780,13 @@ export function openManualResetCreditOperation( }); } // Deliberate: a distinct caller id after a settled intent represents a - // new explicit redemption and replaces the terminal record below. - } else if (recordCount >= MAX_RESET_CREDIT_OPERATION_ACCOUNTS) { - return Object.freeze({ kind: "capacity" as const }); + // new explicit redemption and replaces exactly that terminal record. + return reserve(true); } - - const existingOwner = operationOwner(database, identity.operationId); - if (existingOwner !== undefined && existingOwner !== owner.accountKey) { - return Object.freeze({ kind: "unavailable" as const }); + if (recordCount >= MAX_RESET_CREDIT_OPERATION_ACCOUNTS) { + return Object.freeze({ kind: "capacity" as const }); } - - const record: ResetCreditOperationRecord = Object.freeze({ - accountKey: owner.accountKey, - operationKind: "manual", - operationId: identity.operationId, - state: "pending", - createdAt: now, - updatedAt: now, - }); - const values = [ - "manual", - null, - null, - identity.operationId, - "pending", - null, - now, - now, - ] as const; - const result = current - ? database.query(REPLACE_RECORD).run(...values, owner.accountKey) - : database.query(INSERT_RECORD).run(owner.accountKey, ...values); - if (result.changes !== 1) throw new Error("manual reset-credit reservation lost ownership"); - assertStoredRecord(database, record); - return Object.freeze({ - kind: "execute" as const, - operationId: identity.operationId as CodexReservedOperationId, - resumed: false, - }); + return reserve(false); }); } catch (error) { warnLedgerUnavailable(error); diff --git a/tests/codex-reset-credit-operation-ledger.test.ts b/tests/codex-reset-credit-operation-ledger.test.ts index 34b667fd3c..bd384a15eb 100644 --- a/tests/codex-reset-credit-operation-ledger.test.ts +++ b/tests/codex-reset-credit-operation-ledger.test.ts @@ -183,6 +183,33 @@ describe("Codex reset-credit operation ledger", () => { }); }); + test("an uppercase terminal id cannot reopen as a lowercase retry", () => { + const identity = { + accountId: "pool-manual-uppercase-terminal", + chatgptAccountId: "chatgpt-uppercase-terminal", + operationId: fixtureOperationId(708), + }; + expect(openManualResetCreditOperation(identity, 100)).toMatchObject({ kind: "execute" }); + expect(settleManualResetCreditOperation(identity, "reset", 200)).toEqual({ kind: "updated" }); + const uppercase = identity.operationId.toUpperCase(); + const database = new Database(databasePath()); + try { + database.prepare("UPDATE reset_credit_operations SET operation_id = ?").run(uppercase); + } finally { + database.close(); + } + + expect(openManualResetCreditOperation(identity, 300)).toEqual({ kind: "unavailable" }); + const stored = new Database(databasePath(), { readonly: true }); + try { + expect(stored.query<{ operation_id: string; state: string; code: string }, []>(` + SELECT operation_id, state, code FROM reset_credit_operations + `).get()).toEqual({ operation_id: uppercase, state: "confirmed", code: "reset" }); + } finally { + stored.close(); + } + }); + test("manual operations share one physical-account intent across local aliases", () => { const first = { accountId: "pool-manual-fence",