diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index 445da2e4b9..3eb5fdfc9b 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,15 @@ 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 both a loopback-dashboard GUI session and a separately + re-entered owner admin token, or the CLI's short-lived one-shot local consent + capability. Remote dashboards cannot mint that GUI session; the user must run + the local CLI consent flow on the OpenCodex host. A reusable admin token by itself or a client + `confirmed` field is not consent; do not route around either refusal. ### Do @@ -60,18 +70,30 @@ 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 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..9813a088ee 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,17 @@ 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 a loopback dashboard action that combines its +GUI session with a separately re-entered owner admin token may consume a credit. A remote +dashboard cannot mint that GUI session; the user must run the local CLI consent flow on the host. +These checks remove the casual automation path but do not prove human presence to a determined +same-user process; the agent consent rule remains the actual boundary. +::: + ## Check a headless installation Use these read-only checks in scripts and agent runs: diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index a32643fbb6..2f83f3f743 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -34,6 +34,13 @@ password manager can offer to save and autofill it. The dashboard itself still k in memory and does not write it to `localStorage` or `sessionStorage`; whether it is saved is entirely the browser or password manager's decision. +Most loopback dashboard actions need no token entry. Consuming a Codex reset credit is the +exception: because it is irreversible, the confirmation flow asks for the owner admin token and +the server requires that proof together with the short-lived GUI session. Either credential alone +is refused. The token is verified and used for that request without being written to web storage. +Remote dashboards cannot mint the required GUI session, so reset-credit consumption is disabled +there; run the local CLI consent flow on the OpenCodex host instead. + ## What you can do | Area | What it does | diff --git a/docs-site/src/content/docs/ja/getting-started/for-agents.md b/docs-site/src/content/docs/ja/getting-started/for-agents.md index 9eb2aa2957..08674100ab 100644 --- a/docs-site/src/content/docs/ja/getting-started/for-agents.md +++ b/docs-site/src/content/docs/ja/getting-started/for-agents.md @@ -36,6 +36,17 @@ ocx init エージェントは GitHub のスタープロンプトに決して回答せず、ユーザーに代わって `gh`、GitHub の Star API、`POST /api/github/star` のいずれも呼び出してはいけません。スターを付ける操作はユーザーのアイデンティティを使用するため、別途明示的な同意が必要です。エージェント主導の実行では、CLI はプロンプトを抑止して `.star-prompted` を書き込まず、管理 API は `403 agent_consent_required` を返します。どちらの保護も回避しないでください。確認は、その提示が表示された起動に続く返答の冒頭で、ユーザーが必ず選ぶ Yes/No の質問として一度だけ行ってください(`lidge-jun/opencodex にスターを付けますか? Yes / No`)。「よかったらスターをどうぞ」のような曖昧な言い方や、長い返答の末尾に紛れ込ませる形は避けてください。無回答は何も決着しません。沈黙は保留であって Yes でも記録された No でもありませんが、以降の返答で同じ質問を繰り返さないでください。CLI はこの確認を opencodex のバージョンごとに最大一度しか再表示しないため、次のバージョンが自動的に再確認します。明示的に同意した場合にのみスターを付け、明示的な拒否はそれで確定なので二度と持ち出さないでください。 ::: +:::caution[Codex リセットクレジットの同意] +リセットクレジットの確認は読み取り専用です。ユーザーに代わって +`ocx account reset-credits --consume --yes` を実行したり、消費 API を呼び出したり +しないでください。エージェント主導の試行は `agent_consent_required` で拒否されます。管理者 +トークンやクライアントの `confirmed` フィールドでこの拒否を回避してはいけません。クレジットを +消費できるのは、ユーザーが手入力して確認した CLI 実行、または短命な GUI セッションと別途再入力 +した所有者管理者トークンを組み合わせるループバックダッシュボード操作だけです。リモート +ダッシュボードはその GUI セッションを発行できないため、ホスト上のローカル CLI 同意フローを +使用する必要があります。 +::: + ## ヘッドレスインストールを確認する スクリプトおよびエージェントの実行では、次の読み取り専用チェックを使用します。 diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index c63c7a77e4..c609cb4646 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -25,6 +25,8 @@ bun run dev:gui `localhost` や `127.0.0.1` などのループバックアドレスで開いたダッシュボードは、短時間有効な GUI セッションを自動的に受け取るため、通常はトークン入力が不要です。ループバック以外のホストで公開する場合は、`OPENCODEX_ADMIN_AUTH_TOKEN`、または自動生成される `~/.opencodex/admin-api-token` ファイルの管理トークンが必要です。 +Codex reset credit の消費は例外です。この不可逆操作にはループバック GUI セッションに加えて、所有者による管理トークンの再入力が必要です。リモートダッシュボードでは GUI セッションを発行できないため、reset credit の消費にはローカル CLI の同意フローを使用してください。 + リモートダッシュボードでは標準のパスワードフォームが表示され、ブラウザのパスワードマネージャーで保存・自動入力できます。ダッシュボード自体はトークンをメモリ内だけに保持し、`localStorage` や `sessionStorage` には書き込みません。保存するかどうかはブラウザまたはパスワードマネージャーだけが決定します。 ## できること 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..2cc2c161ea 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 を発行する前に拒否され、再利用可能な管理トークンでは代替できません。CLI は terminal response を受け取るまで同じ operation ID を永続的に再利用するため、timeout 後は新しい消費を開始せず同じコマンドを再実行してください。 ### `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..e1c937264d 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` | 対象のリセット クレジットを消費する。管理 token の再入力で所有者を確認した GUI session、または CLI の one-shot ローカル同意 capability が必要。管理認証だけや `confirmed` field では代替不可。呼び出し元が安定して保持する正規の小文字 UUIDv4 を `operationId` として指定し、terminal code を含む response を受け取るまで永続的に再利用する。quota refresh は別の後続 read として行う | 400 `accountId` または `operationId` の欠落/無効; 403 `agent_consent_required`; 409 `reset_credit_operation_identity_changed`; upstream status passthrough; 503 `server_busy`; 507 `reset_credit_operation_history_full`(maintainer の対応が必要、自動再試行不可); 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/getting-started/for-agents.md b/docs-site/src/content/docs/ko/getting-started/for-agents.md index 4b6a07ded6..09928736b6 100644 --- a/docs-site/src/content/docs/ko/getting-started/for-agents.md +++ b/docs-site/src/content/docs/ko/getting-started/for-agents.md @@ -36,6 +36,16 @@ ocx init 에이전트라면 GitHub 별표 프롬프트에 응답하거나, 사용자를 대신해 `gh`, GitHub의 별표 API, 또는 `POST /api/github/star`를 호출하지 마세요. 별표 표시는 사용자의 신원을 사용하며 별도의 명시적 동의가 필요합니다. 에이전트가 주도하는 실행에서는 CLI가 프롬프트를 숨기고 `.star-prompted`를 쓰지 않으며, 관리 API는 `403 agent_consent_required`를 반환합니다. 어느 쪽도 우회하지 마세요. 대신 그 안내가 뜬 시작에 이어지는 답변 맨 앞에서, 사용자가 반드시 골라야 하는 Yes/No 질문으로 한 번만 물으세요 — `lidge-jun/opencodex에 star를 남길까요? Yes / No`. "star 한번 눌러주실래요?" 같은 흘리는 말투나 긴 답변 맨 끝에 끼워 넣는 방식은 안 됩니다. 무응답은 아무것도 결정하지 않습니다. 침묵은 보류일 뿐 yes도 기록된 no도 아닙니다. 그렇다고 이후 답변에서 같은 질문을 반복하지는 마세요. CLI가 이 안내를 opencodex 버전당 최대 한 번만 다시 띄우니, 다음 버전이 알아서 다시 묻습니다. 명시적으로 예라고 답한 뒤에만 별표를 누르고, 명시적인 아니오는 그것으로 끝이니 다시 꺼내지 마세요. ::: +:::caution[Codex 리셋 크레딧 동의] +리셋 크레딧 조회는 읽기 전용입니다. 사용자를 대신해 +`ocx account reset-credits --consume --yes`를 실행하거나 소비 API를 호출하지 +마세요. 에이전트 주도 시도는 `agent_consent_required`로 거부됩니다. 관리자 토큰이나 +클라이언트 `confirmed` 필드로 이 거부를 우회하지 마세요. 크레딧은 사용자가 직접 입력하고 +확인한 CLI 실행, 또는 짧은 수명의 GUI 세션과 별도로 다시 입력한 소유자 관리자 토큰을 함께 +사용하는 루프백 대시보드 작업에서만 소비할 수 있습니다. 원격 대시보드는 이 GUI 세션을 발급할 +수 없으므로 호스트에서 로컬 CLI 동의 절차를 사용해야 합니다. +::: + ## 비대화형 설치 확인하기 스크립트와 에이전트 실행에서는 다음 읽기 전용 점검을 사용합니다: diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 9ec0fe4092..35f9038957 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -25,6 +25,8 @@ bun run dev:gui `localhost`나 `127.0.0.1` 같은 loopback 주소에서 연 대시보드는 짧게 유지되는 GUI 세션을 자동으로 받으므로 보통 토큰을 입력할 필요가 없습니다. loopback이 아닌 호스트로 공개한 대시보드에는 `OPENCODEX_ADMIN_AUTH_TOKEN` 또는 자동 생성되는 `~/.opencodex/admin-api-token` 파일의 관리자 토큰이 필요합니다. +Codex reset credit 소비는 예외입니다. 되돌릴 수 없는 이 작업은 loopback GUI 세션에 더해 소유자가 관리자 토큰을 다시 입력해야 합니다. 원격 대시보드는 GUI 세션을 발급받을 수 없으므로 reset credit 소비에는 로컬 CLI 동의 흐름을 사용하세요. + 원격 대시보드는 표준 비밀번호 폼을 표시하므로 브라우저 비밀번호 관리자가 토큰 저장과 자동 완성을 제안할 수 있습니다. 대시보드 자체는 토큰을 메모리에만 보관하며 `localStorage`나 `sessionStorage`에 쓰지 않습니다. 저장 여부는 전적으로 브라우저 또는 비밀번호 관리자가 결정합니다. ## 할 수 있는 일 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..b41b6e3643 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를 만들기 전에 거부되며, 재사용 가능한 관리 토큰으로 대체할 수 없습니다. CLI는 terminal 응답을 받을 때까지 동일한 operation ID를 영구 재사용하므로 timeout 뒤에는 새 소비를 시작하지 말고 같은 명령을 다시 실행하세요. ### `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..0271d90b85 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` 필드만으로는 동의를 대신할 수 없습니다. 호출자가 안정적으로 유지하는 정규 소문자 UUIDv4를 `operationId`로 제공하고 terminal code가 포함된 응답을 받을 때까지 영구 재사용해야 하며, quota refresh는 별도의 후속 읽기입니다 | 400 `accountId` 또는 `operationId` 누락/잘못됨; 403 `agent_consent_required`; 409 `reset_credit_operation_identity_changed`; upstream 상태 전달; 503 `server_busy`; 507 `reset_credit_operation_history_full`(maintainer 조치 필요, 자동 재시도 금지); 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..ba8af3cd19 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,10 @@ 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. +The CLI durably reuses the same operation identity until a terminal response is observed, so rerun +the same command after a timeout instead of trying to start a second redemption. ### `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..57c8fc5ebb 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 plus a separately re-entered owner admin token, or the CLI's one-shot local consent capability. Neither reusable admin auth alone nor a `confirmed` field is consent. The caller must provide a caller-stable canonical lowercase UUIDv4 as `operationId` and durably reuse it until a response containing a terminal code is observed; quota refresh is a separate follow-up read. | 400 missing/invalid `accountId` or `operationId`; 403 `agent_consent_required`; 409 `reset_credit_operation_identity_changed`; upstream status passthrough; 503 `server_busy` before settlement; 507 `reset_credit_operation_history_full` (maintainer action required, no automatic retry); 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/getting-started/for-agents.md b/docs-site/src/content/docs/ru/getting-started/for-agents.md index 37917a311b..c6062cc9ff 100644 --- a/docs-site/src/content/docs/ru/getting-started/for-agents.md +++ b/docs-site/src/content/docs/ru/getting-started/for-agents.md @@ -50,6 +50,16 @@ ocx init «может, поставите звёздочку?» и не в самом конце длинного ответа. Отсутствие ответа ничего не решает: молчание — это отсрочка, а не `yes` и не записанное `no`, но не повторяйте вопрос в следующих ответах — CLI показывает эту подсказку не чаще одного раза на версию opencodex, и следующая версия спросит сама. Ставьте star только после явного `yes`; явный `no` закрывает вопрос окончательно. ::: +:::caution[Согласие на кредит сброса Codex] +Просмотр кредитов сброса доступен только для чтения. Никогда не запускайте от имени пользователя +`ocx account reset-credits --consume --yes` и не вызывайте endpoint списания. +Агентные попытки отклоняются с `agent_consent_required`; не обходите отказ admin-токеном или +полем клиента `confirmed`. Списание разрешено только при вручную введённом и подтверждённом +пользователем CLI-запуске либо в loopback-дашборде, где краткоживущая GUI-сессия сочетается с +отдельно повторно введённым owner admin token. Удалённый дашборд не может выдать такую GUI-сессию, +поэтому пользователь должен запустить локальный CLI-процесс согласия на хосте. +::: + ## Проверьте headless-установку Используйте эти read-only проверки в сценариях и агентных запусках: diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 58f037dfd4..0196ec096f 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -25,6 +25,8 @@ bun run dev:gui При открытии дашборда через loopback-адрес, например `localhost` или `127.0.0.1`, он автоматически получает краткоживущую GUI-сессию, поэтому ввод токена обычно не требуется. Для дашборда на любом другом хосте нужен административный токен из `OPENCODEX_ADMIN_AUTH_TOKEN` или автоматически созданного файла `~/.opencodex/admin-api-token`. +Расходование reset credit Codex — исключение. Для этой необратимой операции кроме loopback GUI-сессии владелец должен повторно ввести административный токен. Удалённый дашборд не может получить GUI-сессию, поэтому для расходования reset credit используйте локальный consent-flow CLI. + Удалённый дашборд показывает стандартную форму пароля, поэтому менеджер паролей браузера может предложить сохранить и автозаполнять токен. Сам дашборд хранит токен только в памяти и не записывает его в `localStorage` или `sessionStorage`; решение о сохранении полностью остаётся за браузером или менеджером паролей. ## Возможности 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..161148d64f 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,11 @@ 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 его не заменяет. +CLI надёжно повторно использует тот же operation ID до получения terminal response, поэтому после +timeout повторите ту же команду, а не начинайте новое расходование. ### `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..e0ba4df456 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 с повторно введённым owner admin token или одноразовый локальный consent capability CLI. Одной admin auth или поля `confirmed` недостаточно. Вызывающая сторона должна передать стабильный канонический UUIDv4 в нижнем регистре как `operationId` и надёжно повторно использовать его до получения ответа с terminal code; quota refresh выполняется отдельным последующим чтением | 400 отсутствующий/недопустимый `accountId` или `operationId`; 403 `agent_consent_required`; 409 `reset_credit_operation_identity_changed`; upstream status passthrough; 503 `server_busy`; 507 `reset_credit_operation_history_full` (требуется действие сопровождающего, без автоматического повтора); 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/tr/getting-started/for-agents.md b/docs-site/src/content/docs/tr/getting-started/for-agents.md index 54e7023ccf..cba174149d 100644 --- a/docs-site/src/content/docs/tr/getting-started/for-agents.md +++ b/docs-site/src/content/docs/tr/getting-started/for-agents.md @@ -62,6 +62,16 @@ verin; açık bir hayır bunu kalıcı olarak çözer ve bir daha asla gündeme getirilmez. ::: +:::caution[Codex sıfırlama kredisi onayı] +Sıfırlama kredilerini incelemek salt okunurdur. Kullanıcı adına +`ocx account reset-credits --consume --yes` çalıştırmayın veya tüketim uç noktasını +çağırmayın. Ajan güdümlü denemeler `agent_consent_required` ile reddedilir; bu reddi yönetici +belirteci ya da istemci `confirmed` alanıyla aşmayın. Bir kredi yalnızca kullanıcının elle yazıp +onayladığı CLI çalıştırmasıyla veya kısa ömürlü GUI oturumunu ayrıca yeniden girilen sahip yönetici +belirteciyle birleştiren loopback kontrol paneli işlemiyle tüketilebilir. Uzak kontrol paneli bu GUI +oturumunu oluşturamaz; kullanıcı OpenCodex ana bilgisayarında yerel CLI onay akışını çalıştırmalıdır. +::: + ## Başsız (Headless) Kurulumu Kontrol Etme Betiklerde ve ajan çalıştırmalarında bu salt okunur kontrolleri kullanın: @@ -131,4 +141,3 @@ opencodex'i yerel makinenin ötesine açmadan önce [Yapılandırma](/tr/reference/configuration/) içindeki uzaktan erişim kurallarını okuyun. - diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index 5e5e16272d..b9e8b69e16 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -32,6 +32,11 @@ yeniler. Yalnızca geri döngü olmayan bir ana bilgisayar adına bağlı bir ko paneli yönetici belirtecini (`OPENCODEX_ADMIN_AUTH_TOKEN` veya otomatik olarak oluşturulan `~/.opencodex/admin-api-token` dosyası) gerektirir. +Codex sıfırlama kredisi tüketmek bir istisnadır. Bu geri döndürülemez işlem, +geri döngü GUI oturumuna ek olarak sahibin yönetici belirtecini yeniden +girmesini gerektirir. Uzak kontrol paneli GUI oturumu alamaz; sıfırlama kredisi +tüketmek için yerel CLI onay akışını kullanın. + Uzak bir kontrol panelinin bu kimlik bilgisine ihtiyacı olduğunda, bir tarayıcı şifre yöneticisinin onu kaydetmeyi ve otomatik doldurmayı teklif edebilmesi için standart bir şifre formu sunar. Kontrol panelinin kendisi belirteci yine de @@ -254,4 +259,3 @@ kopyalar, böylece [vizyon sidecar'ı](/tr/guides/sidecars/) manuel sınıfland olmadan doğru şekilde geçişlenir. ::: - diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index e8018aeeab..92b8ee2f5c 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -324,7 +324,12 @@ anahtarı içermez. ### `ocx account reset-credits [--consume --yes]` Bir hesap için Codex sıfırlama kredilerini inceleyin. Bir krediyi tüketmek -yıkıcıdır ve hem `--consume` hem de `--yes` gerektirir. +yıkıcıdır ve kullanıcının elle yazıp onayladığı bir çalıştırmada hem `--consume` +hem de `--yes` gerektirir. Ajan tarafından yürütülen çalıştırmalar tek kullanımlık +yerel onay capability'si verilmeden önce reddedilir; yeniden kullanılabilir yönetici +belirteci bunun yerini tutmaz. CLI terminal response alınana kadar aynı operation ID'yi +kalıcı olarak yeniden kullanır; timeout sonrasında yeni bir tüketim başlatmak yerine +aynı komutu yeniden çalıştırın. ### `ocx account main ` @@ -450,4 +455,3 @@ kataloğu reddeder, bu nedenle `add`, `edit` ve yönetim API'si katalog yazıcısının daha sonra çıkarması gereken bir şeyi saklamak yerine hatalı değeri reddeder (#759). - diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index d4a0ed290b..e2c867f6ed 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -265,7 +265,7 @@ devreder. Rotaları şunlardır: | `PUT /api/codex-auth/failover` | Hesap yük devretme eşiğini ayarlayın | 400 geçersiz eşik | | `GET /api/codex-auth/quota` | Hesaba göre önbelleğe alınmış kota durumunu okuyun | — | | `GET /api/codex-auth/reset-credits` | Bir hesap için sıfırlama kredisi uygunluğunu inceleyin | 400 eksik hesap kimliği; yukarı akış durum doğrudan geçişi; 500 arama hatası | -| `POST /api/codex-auth/reset-credits/consume` | Uygun bir sıfırlama kredisini tüketin | 400 eksik hesap kimliği; yukarı akış durum doğrudan geçişi; 503 `server_busy`; 500 tüketme hatası | +| `POST /api/codex-auth/reset-credits/consume` | Uygun bir sıfırlama kredisini tüketin. Yeniden girilmiş sahip yönetici belirteciyle doğrulanan bir GUI oturumu veya CLI'nin tek kullanımlık yerel onay capability'si gerekir; yalnızca yönetici kimlik doğrulaması ya da `confirmed` alanı yeterli değildir. Çağıran, `operationId` olarak çağırana bağlı kararlı, kanonik küçük harfli bir UUIDv4 sağlamalı ve terminal code içeren bir response alana kadar bunu kalıcı olarak yeniden kullanmalıdır; quota refresh ayrı bir takip okumasıdır | 400 eksik/geçersiz `accountId` veya `operationId`; 403 `agent_consent_required`; 409 `reset_credit_operation_identity_changed`; yukarı akış durum doğrudan geçişi; 503 `server_busy`; 507 `reset_credit_operation_history_full` (bakım görevlisi işlemi gerekir, otomatik yeniden deneme yok); 500 tüketme hatası | | `POST /api/codex-auth/login` | Codex girişini veya yeniden kimlik doğrulamasını başlatın | 400 geçersiz istek; çakışma/meşgul giriş durumları | | `POST /api/codex-auth/login/code` | Bir Codex giriş akışı için manuel bir kod gönderin | 400 geçersiz akış/kod | | `POST /api/codex-auth/login/cancel` | Bir Codex giriş akışını iptal edin | — | @@ -300,5 +300,3 @@ rehberli iş akışını sağlar. Başsız ana bilgisayarlar ve otomasyon için olduğunda veya işlem başarısız olduğunda sıfır olmayan bir sonuç döndürürler. Doğrudan HTTP, yukarıdaki tam uç nokta sözleşmelerine ihtiyaç duyan entegrasyonlar için en yararlıdır. - - diff --git a/docs-site/src/content/docs/zh-cn/getting-started/for-agents.md b/docs-site/src/content/docs/zh-cn/getting-started/for-agents.md index 80970e9601..281e5c9c81 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/for-agents.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/for-agents.md @@ -36,6 +36,15 @@ ocx init 如果你是 agent,绝不要代用户回答 GitHub star 提示,或者代表用户调用 `gh`、GitHub 的 star API,或 `POST /api/github/star`;给仓库加星会消耗用户的身份,需要单独的明确同意。在 agent 驱动的运行中,CLI 会抑制该提示并且不写入 `.star-prompted`,而管理 API 会返回 `403 agent_consent_required` —— 不要绕过任一保护。请在打印该提示后的回复开头,把它作为用户必须作答的 Yes/No 选择题只问一次——`要为 lidge-jun/opencodex 加星吗? Yes / No`——不要用"顺手点个星呗"这类含糊说法,也不要塞在长回复的末尾。没有回应不等于任何结论:沉默只是暂缓,既不是同意,也不是记录在案的拒绝;但请不要在后续回复中重复提问——CLI 每个 opencodex 版本最多只会重新显示一次该提示,新版本会自行再次询问。只有在明确同意后才加星;明确拒绝即为最终结论,不要再提起。 ::: +:::caution[Codex 重置额度同意] +查看重置额度是只读操作。绝不要代表用户运行 +`ocx account reset-credits --consume --yes` 或调用额度使用端点。Agent +驱动的尝试会以 `agent_consent_required` 被拒绝;不要用管理员 token 或客户端 +`confirmed` 字段绕过该拒绝。只有用户手动输入并确认的 CLI 运行,或同时提供短期 GUI +会话与另行重新输入的所有者管理员 token 的环回仪表板操作,才能使用额度。远程仪表板无法 +签发该 GUI 会话,因此用户必须在 OpenCodex 主机上运行本地 CLI 同意流程。 +::: + ## 检查无头安装 在脚本和 agent 运行中使用这些只读检查: diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index e8f7e42d21..ef855afba1 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -24,6 +24,8 @@ bun run dev:gui 通过 `localhost`、`127.0.0.1` 等 loopback 地址打开仪表盘时,它会自动获得一个短期 GUI session,因此通常无需输入 token。在非 loopback 主机上公开仪表盘时,必须使用 `OPENCODEX_ADMIN_AUTH_TOKEN` 或自动生成的 `~/.opencodex/admin-api-token` 文件中的管理员 token。 +消耗 Codex reset credit 是例外。这项不可逆操作除 loopback GUI session 外,还要求所有者重新输入管理员 token。远程仪表盘无法获得 GUI session,因此请通过本地 CLI 同意流程消耗 reset credit。 + 远程仪表盘会显示标准密码表单,浏览器密码管理器可以提示保存并自动填充 token。仪表盘本身只在内存中保存 token,不会写入 `localStorage` 或 `sessionStorage`;是否持久保存完全由浏览器或密码管理器决定。 ## 可以完成哪些操作 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..878786ae8f 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 之前被拒绝;可重复使用的管理令牌不能替代该同意。CLI 会持久复用同一 operation ID,直到收到 terminal response;timeout 后请重新运行同一命令,不要开始新的消耗。 ### `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..79f7a3c333 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;需要通过重新输入 owner admin token 验证的 GUI session,或 CLI 的一次性本地同意 capability。单独的管理认证或 `confirmed` 字段不能代替。调用方必须提供由调用方稳定保持的规范小写 UUIDv4 作为 `operationId`,并持久复用该值,直到收到包含 terminal code 的响应;quota refresh 是单独的后续读取 | 400 缺少/无效的 `accountId` 或 `operationId`;403 `agent_consent_required`;409 `reset_credit_operation_identity_changed`;上游状态透传;503 `server_busy`;507 `reset_credit_operation_history_full`(需要维护者处理,不得自动重试);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/getting-started/for-agents.md b/docs-site/src/content/docs/zh-tw/getting-started/for-agents.md index 174f4447fc..4572d2dfb1 100644 --- a/docs-site/src/content/docs/zh-tw/getting-started/for-agents.md +++ b/docs-site/src/content/docs/zh-tw/getting-started/for-agents.md @@ -39,6 +39,15 @@ ocx init `.star-prompted`,而管理 API 回傳 `403 agent_consent_required` — 請勿繞過任一防護。詢問使用者一次,僅在明確同意後加星,若他們說否或不回答,則什麼都不做且不再詢問。 ::: +:::caution[Codex 重設額度同意] +查看重設額度是唯讀操作。絕不要代表使用者執行 +`ocx account reset-credits --consume --yes` 或呼叫額度使用端點。Agent +驅動的嘗試會以 `agent_consent_required` 遭拒;請勿以管理員 token 或用戶端 +`confirmed` 欄位繞過拒絕。只有使用者手動輸入並確認的 CLI 執行,或同時提供短期 GUI +工作階段與另行重新輸入之擁有者管理員 token 的回送儀表板操作,才能使用額度。遠端儀表板 +無法簽發該 GUI 工作階段,因此使用者必須在 OpenCodex 主機上執行本機 CLI 同意流程。 +::: + ## 檢查無頭安裝 在腳本與 agent 執行中使用這些唯讀檢查: diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index 67828d56b0..9330cbe36c 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -27,6 +27,8 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 儀表板才需要 admin token(`OPENCODEX_ADMIN_AUTH_TOKEN`,或自動產生的 `~/.opencodex/admin-api-token` 檔案)。 +消耗 Codex reset credit 是例外。這項不可逆操作除了 loopback GUI session,還要求擁有者重新輸入 admin token。遠端儀表板無法取得 GUI session,因此請透過本機 CLI 同意流程消耗 reset credit。 + 當遠端儀表板需要該憑證時,它會顯示標準的密碼表單,讓瀏覽器密碼管理員可以提議儲存與自動填入。 儀表板本身仍然只在記憶體中保留 token,不會寫入 `localStorage` 或 `sessionStorage`;是否儲存完全 由瀏覽器或密碼管理員決定。 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..17b2559a1c 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 前遭拒;可重複使用的管理權杖不能取代該同意。CLI 會持久重用同一 operation ID,直到收到 terminal response;timeout 後請重新執行同一命令,不要開始新的消耗。 ### `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 52d6db4d8e..299a613d76 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;需要重新輸入 owner admin token 驗證的 GUI session,或 CLI 的一次性本機同意 capability。單獨的管理認證或 `confirmed` 欄位不能取代。呼叫端必須提供由呼叫端穩定持有的標準小寫 UUIDv4 作為 `operationId`,並持久重用該值,直到收到包含 terminal code 的回應;quota refresh 是單獨的後續讀取 | 400 缺少/無效的 `accountId` 或 `operationId`;403 `agent_consent_required`;409 `reset_credit_operation_identity_changed`;上游狀態 passthrough;503 `server_busy`;507 `reset_credit_operation_history_full`(需要維護者處理,不得自動重試);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/gui/src/api.ts b/gui/src/api.ts index 1df043791b..4f0c1e9616 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -134,6 +134,11 @@ async function verifyAdminToken(token: string): ReturnType { } } +/** One-use owner proof for an irreversible dashboard action; never stores the token. */ +export async function requestResetCreditOwnerToken(): Promise { + return await requestAdminToken(verifyAdminToken); +} + function clearLegacySessionToken(): void { try { sessionStorage.removeItem(LEGACY_TOKEN_KEY); diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index e41dcc4912..c5f6d2ce29 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -11,21 +11,121 @@ import CodexPoolStrategySetting from "./CodexPoolStrategySetting"; import CodexAuthAdvancedSettings from "./CodexAuthAdvancedSettings"; import { useCodexAutoSwitch } from "../hooks/useCodexAutoSwitch"; import { readJsonIfOk } from "../fetch-json"; +import { createBoundedFetch } from "../bounded-fetch"; import { CodexAccountPoolCards, CodexAccountPoolReauthBanner } from "./codex-account-pool-cards"; import { CodexAccountSwitchModal } from "./codex-account-switch-modal"; import { CodexAccountResetModal } from "./codex-account-reset-modal"; import { CodexAccountPoolLoadStates, CodexAccountPoolMainCard, CodexAccountPoolPageHead } from "./codex-account-pool-main-card"; -import { redeemResetCredit } from "./codex-account-pool-handlers"; +import { redeemResetCredit, RESET_CREDIT_REQUEST_TIMEOUT_MS } from "./codex-account-pool-handlers"; import type { CodexAccountEntry } from "./codex-account-pool-types"; 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 { isBrowserUuid, newBrowserUuid } from "../lib/uuid"; +import { requestResetCreditOwnerToken } from "../api"; // Single definition lives with the controller that owns this data (WP3). export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; const DOCTOR_CMD = "ocx doctor"; +const LEGACY_RESET_OPERATION_STORAGE_KEY = "ocx.codexResetCreditOperation.v1"; +const RESET_OPERATION_STORAGE_PREFIX = "ocx.codexResetCreditOperation.v2."; +const MAX_PENDING_RESET_OPERATIONS = 128; + +interface PendingResetOperation { + accountId: string; + operationId: string; +} + +type PendingResetOperations = Record; + +function parsePendingResetOperations(raw: string | null): PendingResetOperations { + try { + const value = JSON.parse(raw ?? "{}") as Record; + return Object.fromEntries(Object.entries(value).slice(0, MAX_PENDING_RESET_OPERATIONS) + .filter((entry): entry is [string, string] => + entry[0].length > 0 && entry[0].length <= 256 && isBrowserUuid(entry[1]))); + } catch { + return {}; + } +} + +function resetOperationStorageKey(accountId: string): string { + return `${RESET_OPERATION_STORAGE_PREFIX}${encodeURIComponent(accountId)}`; +} + +function readPerAccountResetOperations(): PendingResetOperations { + const operations: PendingResetOperations = {}; + try { + for (let index = 0; index < localStorage.length + && Object.keys(operations).length < MAX_PENDING_RESET_OPERATIONS; index += 1) { + const key = localStorage.key(index); + if (!key?.startsWith(RESET_OPERATION_STORAGE_PREFIX)) continue; + const accountId = decodeURIComponent(key.slice(RESET_OPERATION_STORAGE_PREFIX.length)); + const operationId = localStorage.getItem(key); + if (accountId.length > 0 && accountId.length <= 256 && isBrowserUuid(operationId)) { + operations[accountId] = operationId; + } + } + } catch { + // A later per-account write must still succeed before any request is sent. + } + return operations; +} + +function readPendingResetOperations(): PendingResetOperations { + const current = readPerAccountResetOperations(); + let legacyLocal: PendingResetOperations = {}; + let legacySession: PendingResetOperations = {}; + try { + legacyLocal = parsePendingResetOperations(localStorage.getItem(LEGACY_RESET_OPERATION_STORAGE_KEY)); + } catch { /* a write will fail closed before dispatch */ } + try { + legacySession = parsePendingResetOperations(sessionStorage.getItem(LEGACY_RESET_OPERATION_STORAGE_KEY)); + } catch { /* preserve any readable local operations */ } + const merged = { ...legacySession, ...legacyLocal, ...current }; + if (Object.keys(legacyLocal).length === 0 && Object.keys(legacySession).length === 0) return merged; + + try { + for (const [accountId, operationId] of Object.entries(merged)) { + localStorage.setItem(resetOperationStorageKey(accountId), operationId); + } + for (const [accountId, operationId] of Object.entries(merged)) { + if (localStorage.getItem(resetOperationStorageKey(accountId)) !== operationId) { + throw new Error("reset-credit retry identity verification failed"); + } + } + localStorage.removeItem(LEGACY_RESET_OPERATION_STORAGE_KEY); + sessionStorage.removeItem(LEGACY_RESET_OPERATION_STORAGE_KEY); + } catch { + // Keep legacy state intact unless every migrated id is durably readable. + } + return merged; +} + +function writePendingResetOperation(accountId: string, operationId: string): boolean { + try { + const key = resetOperationStorageKey(accountId); + localStorage.setItem(key, operationId); + return localStorage.getItem(key) === operationId; + } catch { + return false; + } +} + +function clearPendingResetOperation(accountId: string, operationId: string): boolean { + try { + const key = resetOperationStorageKey(accountId); + const current = localStorage.getItem(key); + if (current === null) return true; + if (current !== operationId) return false; + localStorage.removeItem(key); + return localStorage.getItem(key) === null; + } catch { + return false; + } +} /** * Global ChatGPT / Codex account pool (main + extras), extracted from the Codex @@ -34,7 +134,7 @@ const DOCTOR_CMD = "ocx doctor"; * (the Codex Auth page passes its mode banner); `embedded` (WP090) omits page * title chrome while retaining the shared account actions in the Providers workspace. */ -export default function CodexAccountPool({ apiBase, accountModeState = null, banner = null, embedded = false, onActiveNeedsReauthChange, controller: injectedController, advancedExtras = null }: { +export default function CodexAccountPool({ apiBase, accountModeState = null, banner = null, embedded = false, onActiveNeedsReauthChange, controller: injectedController, advancedExtras = null, requestOwnerToken = requestResetCreditOwnerToken }: { apiBase: string; accountModeState?: CodexAccountModeState | null; banner?: ReactNode; @@ -48,6 +148,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban * Codex Auth page passes nothing and gets its own. */ controller?: CodexAccountPoolController; + /** Test seam; production asks for a verified owner-only management token. */ + requestOwnerToken?: () => Promise; }) { const t = useT(); const autoSwitch = useCodexAutoSwitch(apiBase, { @@ -73,10 +175,14 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const feedbackTimerRef = useRef | null>(null); const [refreshingQuota, setRefreshingQuota] = useState(false); const [resetPopup, setResetPopup] = 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 [guiResetConsumeAllowed, setGuiResetConsumeAllowed] = useState(null); + const resetDetailEpochRef = useRef(0); + const redeemingRef = useRef(false); const doctorCopy = useCopyFeedback(); const showActionFeedback = useCallback((text: string, tone: NoticeTone = "ok") => { @@ -239,35 +345,96 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban }; const openResetPopup = async (account: CodexAccountEntry) => { + const epoch = ++resetDetailEpochRef.current; setResetPopup(account); setResetConfirm(false); setCreditDetails(null); setCreditDetailsLoading(true); + setGuiResetConsumeAllowed(null); + const bounded = createBoundedFetch(RESET_CREDIT_REQUEST_TIMEOUT_MS); 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) { - const sorted = (data.credits ?? []).sort((a, b) => - new Date(a.granted_at).getTime() - new Date(b.granted_at).getTime() - ); - setCreditDetails(sorted); + const resp = await fetch( + `${apiBase}/api/codex-auth/reset-credits?accountId=${encodeURIComponent(account.id)}`, + { signal: bounded.signal }, + ); + const data = await readJsonIfOk<{ + credits?: { granted_at: string; expires_at: string }[]; + guiConsumeAllowed?: boolean; + }>(resp); + if (resetDetailEpochRef.current !== epoch) return; + if (!data) { + setGuiResetConsumeAllowed(false); + return; } - } catch { /* detail fetch is non-blocking */ } - finally { setCreditDetailsLoading(false); } + const sorted = (data.credits ?? []).sort((a, b) => + new Date(a.granted_at).getTime() - new Date(b.granted_at).getTime() + ); + setCreditDetails(sorted); + setGuiResetConsumeAllowed(data.guiConsumeAllowed === true); + } catch { + if (resetDetailEpochRef.current === epoch) setGuiResetConsumeAllowed(false); + } + finally { + bounded.clear(); + if (resetDetailEpochRef.current === epoch) setCreditDetailsLoading(false); + } }; const handleRedeem = async (accountId: string) => { + if (redeemingRef.current) return; + redeemingRef.current = true; setRedeeming(true); try { - const result = await redeemResetCredit(apiBase, accountId, t, load); - if (result.close) { + const ownerToken = await requestOwnerToken(); + if (!ownerToken) return; + const durableOperations = readPendingResetOperations(); + const operation: PendingResetOperation = { + accountId, + operationId: durableOperations[accountId] + ?? pendingResetOperations[accountId] + ?? newBrowserUuid(), + }; + const reservedOperations = { + ...pendingResetOperations, + ...durableOperations, + [operation.accountId]: operation.operationId, + }; + if (!writePendingResetOperation(operation.accountId, operation.operationId)) { + showActionFeedback(t("codexAuth.resetError"), "err"); + return; + } + setPendingResetOperations(reservedOperations); + const result = await redeemResetCredit( + apiBase, + accountId, + operation.operationId, + t, + load, + ownerToken, + ); + if (result.outcome === "terminal") { + if (!clearPendingResetOperation(operation.accountId, operation.operationId)) { + showActionFeedback(t("codexAuth.resetRetryStateStuck", { + outcome: result.toast ?? t("codexAuth.resetError"), + }), "warn"); + return; + } + setPendingResetOperations(current => { + if (current[operation.accountId] !== operation.operationId) return current; + const next = { ...current }; + delete next[operation.accountId]; + return next; + }); setResetPopup(null); setResetConfirm(false); } if (result.toast) { showActionFeedback(result.toast, result.ok ? "ok" : "err"); } + } catch { + showActionFeedback(t("codexAuth.resetError"), "err"); } finally { + redeemingRef.current = false; setRedeeming(false); } }; @@ -420,8 +587,16 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban resetConfirm={resetConfirm} creditDetails={creditDetails} creditDetailsLoading={creditDetailsLoading} + guiConsumeAllowed={guiResetConsumeAllowed} redeeming={redeeming} - onClose={() => { setResetPopup(null); setResetConfirm(false); setCreditDetails(null); }} + onClose={() => { + if (redeeming) return; + resetDetailEpochRef.current += 1; + setResetPopup(null); + setResetConfirm(false); + setCreditDetails(null); + setGuiResetConsumeAllowed(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..1639179778 100644 --- a/gui/src/components/codex-account-pool-handlers.ts +++ b/gui/src/components/codex-account-pool-handlers.ts @@ -1,47 +1,77 @@ import type { TFn } from "../i18n/shared"; import { readJsonIfOk } from "../fetch-json"; +import { createBoundedFetch } from "../bounded-fetch"; -function remainingCreditsToast( - t: TFn, - remaining: number | undefined, -): string { - if (remaining === undefined) return t("codexAuth.resetSuccessGeneric"); - return t("codexAuth.resetSuccess", { remaining: String(remaining) }); -} +const RESET_CREDIT_OWNER_TOKEN_HEADER = "x-opencodex-reset-credit-owner-token"; +const RESET_CREDIT_IDENTITY_CHANGED_CODE = "reset_credit_operation_identity_changed"; +const RESET_CREDIT_OPERATION_HISTORY_FULL_CODE = "reset_credit_operation_history_full"; +export const RESET_CREDIT_REQUEST_TIMEOUT_MS = 15_000; export async function redeemResetCredit( apiBase: string, accountId: string, + operationId: string, t: TFn, load: (refresh?: boolean) => Promise, -): Promise<{ ok: boolean; toast?: string; close?: boolean }> { + ownerToken: string, +): Promise<{ + ok: boolean; + outcome: "terminal" | "ambiguous"; + toast?: string; +}> { + const bounded = createBoundedFetch(RESET_CREDIT_REQUEST_TIMEOUT_MS); try { const resp = await fetch(`${apiBase}/api/codex-auth/reset-credits/consume`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ accountId }), + signal: bounded.signal, + headers: { + "Content-Type": "application/json", + [RESET_CREDIT_OWNER_TOKEN_HEADER]: ownerToken, + }, + body: JSON.stringify({ accountId, operationId }), }); - const result = await readJsonIfOk<{ code: string; remaining?: number }>(resp); - if (!result) return { ok: false, toast: t("codexAuth.resetError") }; + if (!resp.ok) { + try { + const rejected = await resp.json() as { code?: unknown }; + if (resp.status === 409 && rejected.code === RESET_CREDIT_IDENTITY_CHANGED_CODE) { + return { + ok: false, + outcome: "terminal", + toast: t("codexAuth.resetIdentityChanged"), + }; + } + if (resp.status === 507 && rejected.code === RESET_CREDIT_OPERATION_HISTORY_FULL_CODE) { + return { + ok: false, + outcome: "terminal", + toast: t("codexAuth.resetHistoryFull"), + }; + } + } catch { /* malformed rejections remain ambiguous */ } + return { ok: false, outcome: "ambiguous", toast: t("codexAuth.resetError") }; + } + const result = await readJsonIfOk<{ code: string }>(resp); + if (!result) return { ok: false, outcome: "ambiguous", toast: t("codexAuth.resetError") }; if (result.code === "reset" || result.code === "already_redeemed") { - await load(true); - // Authoritative remaining comes from the management endpoint (refreshed quota). - // Never invent a decrement from a stale modal snapshot. - const remaining = - typeof result.remaining === "number" && Number.isFinite(result.remaining) - ? Math.max(0, result.remaining) - : undefined; + try { await load(true); } catch { /* the consume outcome is already terminal */ } return { ok: true, - close: true, - toast: remainingCreditsToast(t, remaining), + outcome: "terminal", + toast: t(result.code === "already_redeemed" + ? "codexAuth.resetAlreadyRedeemed" + : "codexAuth.resetSuccessGeneric"), }; } - 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") }; + } finally { + bounded.clear(); } } diff --git a/gui/src/components/codex-account-reset-modal.tsx b/gui/src/components/codex-account-reset-modal.tsx index cc518029bc..dff9a76feb 100644 --- a/gui/src/components/codex-account-reset-modal.tsx +++ b/gui/src/components/codex-account-reset-modal.tsx @@ -10,6 +10,7 @@ export function CodexAccountResetModal({ resetConfirm, creditDetails, creditDetailsLoading, + guiConsumeAllowed, redeeming, onClose, onShowConfirm, @@ -20,6 +21,7 @@ export function CodexAccountResetModal({ resetConfirm: boolean; creditDetails: { granted_at: string; expires_at: string }[] | null; creditDetailsLoading: boolean; + guiConsumeAllowed: boolean | null; redeeming: boolean; onClose: () => void; onShowConfirm: () => void; @@ -36,8 +38,9 @@ export function CodexAccountResetModal({ const handleCancel = useCallback((e: React.SyntheticEvent) => { e.preventDefault(); + if (redeeming) return; onClose(); - }, [onClose]); + }, [onClose, redeeming]); return ( - + {guiConsumeAllowed === false && ( +

+ {t("codexAuth.resetCliOnly")} +

+ )}

{t("codexAuth.fifoNote")}

) : ( @@ -93,7 +102,7 @@ export function CodexAccountResetModal({

{t("codexAuth.irreversible")}

- + diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index f4e25636e1..baf227931f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1099,8 +1099,9 @@ export const de: Record = { "codexAuth.earnCreditsHint": "Gutschriften werden monatlich und über das Empfehlungsprogramm verdient.", "codexAuth.creditsExpireNote": "Gutschriften verfallen 30 Tage nach Erhalt.", "codexAuth.useOneCredit": "1 Gutschrift nutzen", + "codexAuth.resetCliOnly": "Reset-Gutschriften können nur im Loopback-Dashboard verwendet werden. Verwende bei einem Remote-Dashboard stattdessen den lokalen CLI-Zustimmungsablauf auf dem OpenCodex-Host.", "codexAuth.confirmResetTitle": "Reset-Gutschrift nutzen?", - "codexAuth.confirmResetDesc": "Dies setzt deine aktuellen Ratenbegrenzungen sofort zurück. Du hast noch {count} Gutschrift(en).", + "codexAuth.confirmResetDesc": "Dies setzt deine aktuellen Ratenbegrenzungen sofort zurück. Nach der Bestätigung musst du das OpenCodex-Admin-Token erneut eingeben, um die Zustimmung des Kontoinhabers nachzuweisen. Du hast noch {count} Gutschrift(en).", "codexAuth.irreversible": "Diese Aktion kann nicht rückgängig gemacht werden.", "codexAuth.useCredit": "Gutschrift nutzen", "codexAuth.redeeming": "Wird zurückgesetzt…", @@ -1110,6 +1111,9 @@ export const de: Record = { "codexAuth.resetNothingToReset": "Kein Ratenbegrenzungs-Fenster muss gerade zurückgesetzt werden.", "codexAuth.resetNoCredit": "Keine Reset-Gutschriften verfügbar.", "codexAuth.resetError": "Reset-Gutschrift konnte nicht eingelöst werden. Bitte erneut versuchen.", + "codexAuth.resetHistoryFull": "Der Verlauf der Reset-Vorgänge ist voll. Bitte einen Maintainer, die Kapazität zu erweitern oder eine genehmigte Bereinigungsrichtlinie anzuwenden, bevor du es erneut versuchst.", + "codexAuth.resetIdentityChanged": "Die Codex-Kontoidentität hat sich geändert. Öffne diesen Dialog erneut, um eine neue Reset-Anfrage zu bestätigen.", + "codexAuth.resetRetryStateStuck": "{outcome} Das Ergebnis ist endgültig, aber der lokale Wiederholungsstatus konnte nicht gelöscht werden. Behebe den Zugriff auf den Browser-Speicher, bevor du es erneut versuchst.", "codexAuth.fifoNote": "Die älteste Gutschrift wird zuerst verwendet.", "codexAuth.confirmWhichCredit": "Gutschrift vom {date} wird verwendet.", "codexAuth.creditNext": "Als nächstes", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 11338b9196..49a41bbf90 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1573,8 +1573,9 @@ export const en = { "codexAuth.earnCreditsHint": "Credits are earned monthly and via the referral program.", "codexAuth.creditsExpireNote": "Credits expire 30 days after earning.", "codexAuth.useOneCredit": "Use 1 Credit", + "codexAuth.resetCliOnly": "Reset-credit consumption is available only from the loopback dashboard. When using a remote dashboard, run the local CLI consent flow on the OpenCodex host instead.", "codexAuth.confirmResetTitle": "Use Reset Credit?", - "codexAuth.confirmResetDesc": "This will instantly reset your current rate limits. You have {count} credit(s) remaining.", + "codexAuth.confirmResetDesc": "This will instantly reset your current rate limits. After confirmation, re-enter the OpenCodex admin token to prove owner consent. You have {count} credit(s) remaining.", "codexAuth.irreversible": "This action cannot be undone.", "codexAuth.useCredit": "Use Credit", "codexAuth.redeeming": "Resetting...", @@ -1584,6 +1585,9 @@ export const en = { "codexAuth.resetNothingToReset": "No rate-limit window needs resetting right now.", "codexAuth.resetNoCredit": "No reset credits available.", "codexAuth.resetError": "Failed to redeem reset credit. Please try again.", + "codexAuth.resetHistoryFull": "Reset-credit operation history is full. Ask a maintainer to expand capacity or apply an approved retirement policy before trying again.", + "codexAuth.resetIdentityChanged": "The Codex account identity changed. Reopen this dialog to confirm a new reset-credit request.", + "codexAuth.resetRetryStateStuck": "{outcome} The result is final, but its local retry state could not be cleared. Fix browser storage access before trying again.", "codexAuth.fifoNote": "The oldest credit is used first.", "codexAuth.confirmWhichCredit": "Credit from {date} will be used.", "codexAuth.creditNext": "Next to use", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c4bc0321ce..1861a6615b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1512,8 +1512,9 @@ export const ja: Record = { "codexAuth.earnCreditsHint": "クレジットは毎月および紹介プログラム経由で獲得できます。", "codexAuth.creditsExpireNote": "クレジットは獲得から 30 日で失効します。", "codexAuth.useOneCredit": "1 クレジットを使用", + "codexAuth.resetCliOnly": "リセットクレジットを使用できるのはループバックのダッシュボードだけです。リモートダッシュボードでは OpenCodex ホスト上でローカル CLI の同意フローを使用してください。", "codexAuth.confirmResetTitle": "リセットクレジットを使用しますか?", - "codexAuth.confirmResetDesc": "現在のレート制限を即座にリセットします。残り {count} クレジットです。", + "codexAuth.confirmResetDesc": "現在のレート制限を即座にリセットします。確認後、所有者の同意を証明するため OpenCodex 管理者トークンを再入力します。残り {count} クレジットです。", "codexAuth.irreversible": "この操作は元に戻せません。", "codexAuth.useCredit": "クレジットを使用", "codexAuth.redeeming": "リセット中...", @@ -1523,6 +1524,9 @@ export const ja: Record = { "codexAuth.resetNothingToReset": "今リセットが必要なレート制限枠はありません。", "codexAuth.resetNoCredit": "利用可能なリセットクレジットはありません。", "codexAuth.resetError": "リセットクレジットの引き換えに失敗しました。もう一度お試しください。", + "codexAuth.resetHistoryFull": "リセットクレジット操作履歴が上限に達しました。再試行する前に、メンテナーに容量の拡張または承認済みの履歴削除ポリシーの適用を依頼してください。", + "codexAuth.resetIdentityChanged": "Codex アカウントの ID が変更されました。このダイアログを開き直し、新しいリセット要求を確認してください。", + "codexAuth.resetRetryStateStuck": "{outcome} 結果は確定していますが、ローカルの再試行状態を消去できませんでした。再試行する前にブラウザー ストレージへのアクセスを修正してください。", "codexAuth.fifoNote": "最も古いクレジットが先に使用されます。", "codexAuth.confirmWhichCredit": "{date} のクレジットが使用されます。", "codexAuth.creditNext": "次に使用", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index e717e7a5c8..6040f871a9 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1124,8 +1124,9 @@ export const ko: Record = { "codexAuth.earnCreditsHint": "크레딧은 매월 자동 지급되며 추천 프로그램으로도 획득할 수 있습니다.", "codexAuth.creditsExpireNote": "크레딧은 획득 후 30일 뒤 만료됩니다.", "codexAuth.useOneCredit": "크레딧 1개 사용", + "codexAuth.resetCliOnly": "리셋 크레딧 사용은 루프백 대시보드에서만 가능합니다. 원격 대시보드에서는 OpenCodex 호스트에서 로컬 CLI 동의 절차를 사용하세요.", "codexAuth.confirmResetTitle": "리셋 크레딧을 사용하시겠습니까?", - "codexAuth.confirmResetDesc": "현재 사용량 제한이 즉시 초기화됩니다. 남은 크레딧: {count}개.", + "codexAuth.confirmResetDesc": "현재 사용량 제한이 즉시 초기화됩니다. 확인 후 소유자 동의를 증명하기 위해 OpenCodex 관리자 토큰을 다시 입력합니다. 남은 크레딧: {count}개.", "codexAuth.irreversible": "이 작업은 되돌릴 수 없습니다.", "codexAuth.useCredit": "크레딧 사용", "codexAuth.redeeming": "초기화 중...", @@ -1135,6 +1136,9 @@ export const ko: Record = { "codexAuth.resetNothingToReset": "현재 초기화할 사용량 윈도우가 없습니다.", "codexAuth.resetNoCredit": "사용 가능한 리셋 크레딧이 없습니다.", "codexAuth.resetError": "리셋 크레딧 사용에 실패했습니다. 다시 시도해 주세요.", + "codexAuth.resetHistoryFull": "리셋 크레딧 작업 이력이 가득 찼습니다. 다시 시도하기 전에 유지관리자에게 용량 확대 또는 승인된 이력 폐기 정책 적용을 요청하세요.", + "codexAuth.resetIdentityChanged": "Codex 계정 식별 정보가 변경되었습니다. 이 대화상자를 다시 열어 새 리셋 요청을 확인하세요.", + "codexAuth.resetRetryStateStuck": "{outcome} 결과는 확정되었지만 로컬 재시도 상태를 지우지 못했습니다. 다시 시도하기 전에 브라우저 저장소 접근을 복구하세요.", "codexAuth.fifoNote": "가장 오래된 크레딧부터 사용됩니다.", "codexAuth.confirmWhichCredit": "{date}에 획득한 크레딧이 사용됩니다.", "codexAuth.creditNext": "다음 사용 대상", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b6f8c390dd..4905406d05 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1554,8 +1554,9 @@ export const ru: Record = { "codexAuth.earnCreditsHint": "Кредиты начисляются ежемесячно и по реферальной программе.", "codexAuth.creditsExpireNote": "Кредиты истекают через 30 дней после начисления.", "codexAuth.useOneCredit": "Использовать 1 кредит", + "codexAuth.resetCliOnly": "Кредит сброса можно использовать только в loopback-панели. Для удалённой панели используйте локальный CLI-процесс согласия на хосте OpenCodex.", "codexAuth.confirmResetTitle": "Использовать кредит сброса?", - "codexAuth.confirmResetDesc": "Текущие лимиты запросов будут мгновенно сброшены. У вас осталось кредитов: {count}.", + "codexAuth.confirmResetDesc": "Текущие лимиты запросов будут мгновенно сброшены. После подтверждения повторно введите токен администратора OpenCodex, чтобы подтвердить согласие владельца. У вас осталось кредитов: {count}.", "codexAuth.irreversible": "Это действие нельзя отменить.", "codexAuth.useCredit": "Использовать кредит", "codexAuth.redeeming": "Сброс...", @@ -1565,6 +1566,9 @@ export const ru: Record = { "codexAuth.resetNothingToReset": "Сейчас ни одно окно лимитов не требует сброса.", "codexAuth.resetNoCredit": "Нет доступных кредитов сброса.", "codexAuth.resetError": "Не удалось использовать кредит сброса. Попробуйте ещё раз.", + "codexAuth.resetHistoryFull": "Журнал операций reset credit заполнен. Перед новой попыткой попросите сопровождающего увеличить ёмкость или применить утверждённую политику удаления истории.", + "codexAuth.resetIdentityChanged": "Идентификатор аккаунта Codex изменился. Снова откройте диалог и подтвердите новый запрос сброса.", + "codexAuth.resetRetryStateStuck": "{outcome} Результат окончательный, но локальное состояние повтора не удалось очистить. Перед новой попыткой восстановите доступ к хранилищу браузера.", "codexAuth.fifoNote": "Первым используется самый старый кредит.", "codexAuth.confirmWhichCredit": "Будет использован кредит от {date}.", "codexAuth.creditNext": "Следующий к использованию", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 4348057496..c50e214d01 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1562,8 +1562,9 @@ export const tr: Record = { "codexAuth.earnCreditsHint": "Krediler aylık ve tavsiye programı ile kazanılır.", "codexAuth.creditsExpireNote": "Kredilerin süresi 30 gün içinde dolmaktadır.", "codexAuth.useOneCredit": "1 Kredi Kullan", + "codexAuth.resetCliOnly": "Sıfırlama kredisi yalnızca loopback panosundan kullanılabilir. Uzak panoda OpenCodex ana bilgisayarında yerel CLI onay akışını kullanın.", "codexAuth.confirmResetTitle": "Sıfırlama Kredisi Kullanılsın mı?", - "codexAuth.confirmResetDesc": "Bu işlem oran limitlerinizi anında sıfırlayacaktır. {count} krediniz kaldı.", + "codexAuth.confirmResetDesc": "Bu işlem oran limitlerinizi anında sıfırlayacaktır. Onaydan sonra sahip onayını kanıtlamak için OpenCodex yönetici belirtecini yeniden girin. {count} krediniz kaldı.", "codexAuth.irreversible": "Bu işlem geri alınamaz.", "codexAuth.useCredit": "Kredi Kullan", "codexAuth.redeeming": "Sıfırlanıyor...", @@ -1573,6 +1574,9 @@ export const tr: Record = { "codexAuth.resetNothingToReset": "Şu anda sıfırlanması gereken oran limiti yok.", "codexAuth.resetNoCredit": "Kullanılabilir sıfırlama kredisi yok.", "codexAuth.resetError": "Sıfırlama kredisi kullanılamadı.", + "codexAuth.resetHistoryFull": "Sıfırlama kredisi işlem geçmişi dolu. Yeniden denemeden önce bir bakım görevlisinden kapasiteyi artırmasını veya onaylanmış bir geçmiş kaldırma politikasını uygulamasını isteyin.", + "codexAuth.resetIdentityChanged": "Codex hesap kimliği değişti. Yeni bir sıfırlama isteğini onaylamak için bu iletişim kutusunu yeniden açın.", + "codexAuth.resetRetryStateStuck": "{outcome} Sonuç kesindir ancak yerel yeniden deneme durumu temizlenemedi. Yeniden denemeden önce tarayıcı depolama erişimini düzeltin.", "codexAuth.fifoNote": "En eski kredi ilk önce kullanılır.", "codexAuth.confirmWhichCredit": "{date} tarihli kredi kullanılacak.", "codexAuth.creditNext": "Sonraki kullanılacak", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index e194a06e22..a00c88944f 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1162,8 +1162,9 @@ export const zhTW: Record = { "codexAuth.earnCreditsHint": "額度每月自動發放,也可透過推薦計劃獲得。", "codexAuth.creditsExpireNote": "額度在獲得後 30 天過期。", "codexAuth.useOneCredit": "使用 1 個額度", + "codexAuth.resetCliOnly": "只有回送位址的儀表板可以使用重設額度。使用遠端儀表板時,請改在 OpenCodex 主機上執行本機 CLI 同意流程。", "codexAuth.confirmResetTitle": "使用重設額度?", - "codexAuth.confirmResetDesc": "這將立即重設您當前的使用限制。剩餘額度:{count} 個。", + "codexAuth.confirmResetDesc": "這將立即重設您當前的使用限制。確認後,請重新輸入 OpenCodex 管理員權杖以證明擁有者同意。剩餘額度:{count} 個。", "codexAuth.irreversible": "此操作無法復原。", "codexAuth.useCredit": "使用額度", "codexAuth.redeeming": "重設中...", @@ -1173,6 +1174,9 @@ export const zhTW: Record = { "codexAuth.resetNothingToReset": "當前沒有需要重設的使用視窗。", "codexAuth.resetNoCredit": "沒有可用的重設額度。", "codexAuth.resetError": "重設額度使用失敗,請重試。", + "codexAuth.resetHistoryFull": "重設額度操作記錄已滿。再次嘗試前,請由維護者擴充容量或套用已核准的記錄清理原則。", + "codexAuth.resetIdentityChanged": "Codex 帳號身分已變更。請重新開啟此對話框並確認新的重設要求。", + "codexAuth.resetRetryStateStuck": "{outcome} 結果已確定,但無法清除本機重試狀態。再次嘗試前,請修正瀏覽器儲存空間存取權限。", "codexAuth.fifoNote": "最早獲得的額度優先使用。", "codexAuth.confirmWhichCredit": "將使用 {date} 獲得的額度。", "codexAuth.creditNext": "即將使用", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 5d198f5be6..88c4a1a60f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1117,8 +1117,9 @@ export const zh: Record = { "codexAuth.earnCreditsHint": "额度每月自动发放,也可通过推荐计划获得。", "codexAuth.creditsExpireNote": "额度在获得后 30 天过期。", "codexAuth.useOneCredit": "使用 1 个额度", + "codexAuth.resetCliOnly": "仅可从环回地址的仪表板使用重置额度。使用远程仪表板时,请改在 OpenCodex 主机上运行本地 CLI 同意流程。", "codexAuth.confirmResetTitle": "使用重置额度?", - "codexAuth.confirmResetDesc": "这将立即重置您当前的使用限制。剩余额度:{count} 个。", + "codexAuth.confirmResetDesc": "这将立即重置您当前的使用限制。确认后,请重新输入 OpenCodex 管理员令牌以证明所有者同意。剩余额度:{count} 个。", "codexAuth.irreversible": "此操作不可撤销。", "codexAuth.useCredit": "使用额度", "codexAuth.redeeming": "重置中...", @@ -1128,6 +1129,9 @@ export const zh: Record = { "codexAuth.resetNothingToReset": "当前没有需要重置的使用窗口。", "codexAuth.resetNoCredit": "没有可用的重置额度。", "codexAuth.resetError": "重置额度使用失败,请重试。", + "codexAuth.resetHistoryFull": "重置额度操作历史已满。再次尝试前,请让维护者扩展容量或应用已批准的历史清理策略。", + "codexAuth.resetIdentityChanged": "Codex 账户身份已更改。请重新打开此对话框并确认新的重置请求。", + "codexAuth.resetRetryStateStuck": "{outcome} 结果已确定,但无法清除本地重试状态。再次尝试前,请修复浏览器存储访问权限。", "codexAuth.fifoNote": "最早获得的额度优先使用。", "codexAuth.confirmWhichCredit": "将使用 {date} 获得的额度。", "codexAuth.creditNext": "即将使用", diff --git a/gui/src/lib/uuid.ts b/gui/src/lib/uuid.ts new file mode 100644 index 0000000000..26781bd609 --- /dev/null +++ b/gui/src/lib/uuid.ts @@ -0,0 +1,28 @@ +const BROWSER_UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +export function isBrowserUuid(value: unknown): value is string { + return typeof value === "string" && BROWSER_UUID_V4.test(value); +} + +/** 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/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index ca70303e26..46fa18a674 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; -import { installApiAuthFetch, resetApiAuthFetchForTests } from "../src/api"; +import { + installApiAuthFetch, + requestResetCreditOwnerToken, + resetApiAuthFetchForTests, +} from "../src/api"; const LEGACY_TOKEN_KEY = "opencodex-api-token"; const globals = ["document", "window", "navigator", "sessionStorage", "fetch"] as const; @@ -115,6 +119,27 @@ test("validates prompted tokens with a safe read before retrying the failed requ expect(sessionStorage.length).toBe(0); }); +test("reset-credit owner proof validates a token without replacing or storing session auth", async () => { + const seen: Array<[string, string | null]> = []; + resetApiAuthFetchForTests(async (verifyToken) => { + expect(await verifyToken("owner-token")).toBe("accepted"); + return "owner-token"; + }); + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost/"); + const key = new Headers(init?.headers).get("X-OpenCodex-API-Key"); + seen.push([url.pathname, key]); + return url.pathname === "/api/settings" && key === "owner-token" + ? new Response("{}", { status: 200 }) + : new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + expect(await requestResetCreditOwnerToken()).toBe("owner-token"); + expect(seen).toEqual([["/api/settings", "owner-token"]]); + expect(sessionStorage.length).toBe(0); +}); + test("cross-origin /api/* requests do not receive the API key or token prompt", async () => { let promptCalls = 0; let phase: "seed" | "cross" = "seed"; 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..27e1469ca2 100644 --- a/gui/tests/codex-account-pool-handlers.test.ts +++ b/gui/tests/codex-account-pool-handlers.test.ts @@ -10,14 +10,22 @@ 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; +let requestHeaders: Headers; beforeEach(() => { originalFetch = globalThis.fetch; consumeBody = null; loadCalls = 0; + requestBody = null; + requestHeaders = new Headers(); 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)); + requestHeaders = new Headers(init?.headers); + return Response.json(consumeBody ?? { code: "error" }); + }, }); }); @@ -25,57 +33,162 @@ afterEach(() => { Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); }); -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. +test("reset success refreshes account data and ignores an untrusted remaining field", async () => { 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; - }); + }, "owner-proof"); expect(loadCalls).toBe(1); expect(result.ok).toBe(true); - expect(result.close).toBe(true); - expect(result.toast).toBe("codexAuth.resetSuccess:remaining=1"); - expect(result.toast).not.toContain("remaining=2"); - expect(result.toast).not.toContain("remaining=3"); + expect(result.outcome).toBe("terminal"); + expect(result.toast).toBe("codexAuth.resetSuccessGeneric"); + expect(requestBody).toMatchObject({ + accountId: "acct-1", + operationId, + }); + expect(requestHeaders.get("x-opencodex-reset-credit-owner-token")).toBe("owner-proof"); }); -test("already_redeemed does not decrement and uses the returned remaining count", async () => { +test("already_redeemed reports an idempotent terminal outcome without inventing a 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; - }); + }, "owner-proof"); expect(loadCalls).toBe(1); expect(result.ok).toBe(true); - expect(result.close).toBe(true); - expect(result.toast).toBe("codexAuth.resetSuccess:remaining=3"); - expect(result.toast).not.toContain("remaining=2"); + expect(result.outcome).toBe("terminal"); + expect(result.toast).toBe("codexAuth.resetAlreadyRedeemed"); }); 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, "owner-proof"); expect(result.ok).toBe(true); expect(result.toast).toBe("codexAuth.resetSuccessGeneric"); }); -test("already_redeemed without remaining also uses the generic success toast", async () => { +test("already_redeemed without remaining still uses the idempotent terminal 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, "owner-proof"); expect(result.ok).toBe(true); - expect(result.toast).toBe("codexAuth.resetSuccessGeneric"); - expect(result.toast).not.toBe("codexAuth.resetAlreadyRedeemed"); + expect(result.toast).toBe("codexAuth.resetAlreadyRedeemed"); +}); + +test("an account identity conflict is terminal for the stale client id and requires a fresh intent", async () => { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async () => Response.json({ + error: "The Codex account identity changed. Confirm a new reset-credit request.", + code: "reset_credit_operation_identity_changed", + }, { status: 409 }), + }); + const result = await redeemResetCredit("", "acct-1", crypto.randomUUID(), t, async () => { + loadCalls += 1; + return true; + }, "owner-proof"); + expect(result).toEqual({ + ok: false, + outcome: "terminal", + toast: "codexAuth.resetIdentityChanged", + }); + expect(loadCalls).toBe(0); +}); + +test("history saturation is terminal for the rejected id and gives maintainer guidance", async () => { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async () => Response.json({ + error: "Reset-credit operation history is full.", + code: "reset_credit_operation_history_full", + }, { status: 507 }), + }); + const result = await redeemResetCredit("", "acct-1", crypto.randomUUID(), t, async () => { + loadCalls += 1; + return true; + }, "owner-proof"); + expect(result).toEqual({ + ok: false, + outcome: "terminal", + toast: "codexAuth.resetHistoryFull", + }); + expect(loadCalls).toBe(0); }); 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, "owner-proof"); 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, "owner-proof"); + expect(result).toEqual({ + ok: false, + outcome: "ambiguous", + toast: "codexAuth.resetError", + }); +}); + +test("a stalled reset request aborts to an ambiguous retry after the GUI budget", async () => { + const timeoutDescriptor = Object.getOwnPropertyDescriptor(AbortSignal, "timeout"); + const anyDescriptor = Object.getOwnPropertyDescriptor(AbortSignal, "any"); + const timeoutController = new AbortController(); + let timeoutMs = 0; + Object.defineProperty(AbortSignal, "timeout", { + configurable: true, + value: (ms: number) => { + timeoutMs = ms; + queueMicrotask(() => timeoutController.abort(new DOMException("timed out", "TimeoutError"))); + return timeoutController.signal; + }, + }); + Object.defineProperty(AbortSignal, "any", { + configurable: true, + value: (signals: AbortSignal[]) => signals[1], + }); + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (_input: RequestInfo | URL, init?: RequestInit) => { + const signal = init?.signal; + expect(signal).toBeInstanceOf(AbortSignal); + return await new Promise((_resolve, reject) => { + const rejectAbort = () => reject(signal?.reason ?? new DOMException("aborted", "AbortError")); + if (signal?.aborted) rejectAbort(); + else signal?.addEventListener("abort", rejectAbort, { once: true }); + }); + }, + }); + try { + const result = await redeemResetCredit( + "", + "acct-1", + crypto.randomUUID(), + t, + async () => true, + "owner-proof", + ); + expect(timeoutMs).toBe(15_000); + expect(result).toEqual({ + ok: false, + outcome: "ambiguous", + toast: "codexAuth.resetError", + }); + } finally { + if (timeoutDescriptor) Object.defineProperty(AbortSignal, "timeout", timeoutDescriptor); + if (anyDescriptor) Object.defineProperty(AbortSignal, "any", anyDescriptor); + } +}); diff --git a/gui/tests/codex-account-pool-toast-tone.test.tsx b/gui/tests/codex-account-pool-toast-tone.test.tsx index a8e070bfc1..552b0f1744 100644 --- a/gui/tests/codex-account-pool-toast-tone.test.tsx +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; -import { act } from "react"; +import { act, StrictMode } from "react"; import type { Root } from "react-dom/client"; import { formatAccountPriority } from "../src/account-priority"; import CodexAccountPool from "../src/components/CodexAccountPool"; @@ -11,13 +11,15 @@ 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; 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", @@ -68,21 +70,28 @@ 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; originalFetch = globalThis.fetch; originalConfirm = window.confirm; window.confirm = () => true; + consumeAttempts = 0; + consumedOperationIds = []; 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" && !url.pathname.endsWith("/consume")) { - return Response.json({ credits: [] }); + return Response.json({ credits: [], guiConsumeAllowed: true }); } 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/")) { @@ -111,19 +120,110 @@ afterEach(async () => { await win.happyDOM?.close?.(); }); -async function mountPool(controller: CodexAccountPoolController) { +async function mountPool( + controller: CodexAccountPoolController, + strictMode = false, + requestOwnerToken: () => Promise = async () => "admin-secret", +) { const { createRoot } = await import("react-dom/client"); await act(async () => { root = createRoot(host); - root.render( + const element = ( - - , + + ); + root.render(strictMode ? {element} : element); }); await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); } +test("a remote dashboard disables reset-credit consumption and points to the local CLI", 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" && !url.pathname.endsWith("/consume")) { + return Response.json({ credits: [], guiConsumeAllowed: false }); + } + return baseFetch(input, init); + }, + }); + + await mountPool(makeController()); + 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"), + ) as HTMLButtonElement | undefined; + expect(useCredit).toBeTruthy(); + expect(useCredit?.disabled).toBe(true); + expect(host.textContent).toContain("Reset-credit consumption is available only from the loopback dashboard"); + expect(consumeAttempts).toBe(0); + expect([...host.querySelectorAll("button")].some(button => + (button.textContent ?? "").trim() === "Use Credit", + )).toBe(false); +}); + +test("a stalled reset-credit detail request leaves loading after the GUI budget", async () => { + const timeoutDescriptor = Object.getOwnPropertyDescriptor(AbortSignal, "timeout"); + const anyDescriptor = Object.getOwnPropertyDescriptor(AbortSignal, "any"); + const timeoutController = new AbortController(); + let timeoutMs = 0; + Object.defineProperty(AbortSignal, "timeout", { + configurable: true, + value: (ms: number) => { + timeoutMs = ms; + queueMicrotask(() => timeoutController.abort(new DOMException("timed out", "TimeoutError"))); + return timeoutController.signal; + }, + }); + Object.defineProperty(AbortSignal, "any", { + configurable: true, + value: (signals: AbortSignal[]) => signals[1], + }); + 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" && !url.pathname.endsWith("/consume")) { + const signal = init?.signal; + return await new Promise((_resolve, reject) => { + const rejectAbort = () => reject(signal?.reason ?? new DOMException("aborted", "AbortError")); + if (signal?.aborted) rejectAbort(); + else signal?.addEventListener("abort", rejectAbort, { once: true }); + }); + } + return baseFetch(input, init); + }, + }); + + try { + await mountPool(makeController()); + const reset = host.querySelector('button[aria-label="2 reset credit(s)"]') as HTMLButtonElement; + await act(async () => { + reset.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(timeoutMs).toBe(15_000); + const resetDialog = host.querySelector('dialog[aria-labelledby="codex-reset-title"]'); + expect(resetDialog?.textContent).not.toContain("Loading…"); + expect(resetDialog?.textContent).toContain("Reset-credit consumption is available only from the loopback dashboard"); + expect(consumeAttempts).toBe(0); + } finally { + if (timeoutDescriptor) Object.defineProperty(AbortSignal, "timeout", timeoutDescriptor); + if (anyDescriptor) Object.defineProperty(AbortSignal, "any", anyDescriptor); + } +}); + async function chooseOrder(selectId: string, value: string): Promise { const trigger = host.querySelector(`#${selectId}`) as HTMLButtonElement | null; expect(trigger).toBeTruthy(); @@ -261,3 +361,364 @@ 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("an owner-token failure reports an error and preserves the retry identity", async () => { + const operationId = "00000000-0000-4000-8000-000000000901"; + const storageKey = "ocx.codexResetCreditOperation.v2.pool-1"; + localStorage.setItem(storageKey, operationId); + let ownerTokenCalls = 0; + + await mountPool(makeController(), false, async () => { + ownerTokenCalls += 1; + if (ownerTokenCalls === 1) throw new Error("owner token unavailable"); + return "admin-secret"; + }); + + 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"), + ) as HTMLButtonElement; + await act(async () => { useCredit.click(); }); + const redeem = () => [...host.querySelectorAll("button")].find(button => + (button.textContent ?? "").trim() === "Use Credit", + ) as HTMLButtonElement; + + await act(async () => { redeem().click(); await new Promise(resolve => setTimeout(resolve, 40)); }); + + expect(host.querySelector(".codex-auth-page-head__feedback.is-err")?.textContent) + .toContain("Failed to redeem reset credit"); + expect(consumeAttempts).toBe(0); + expect(localStorage.getItem(storageKey)).toBe(operationId); + expect(host.querySelector("dialog")).toBeTruthy(); + expect(redeem().disabled).toBe(false); + + await act(async () => { redeem().click(); await new Promise(resolve => setTimeout(resolve, 40)); }); + + expect(ownerTokenCalls).toBe(2); + expect(consumedOperationIds).toEqual([operationId]); + expect(localStorage.getItem(storageKey)).toBeNull(); + expect(host.querySelector("dialog")).toBeNull(); +}); + +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, + }); + } +}); + +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); + expect(localStorage.getItem("ocx.codexResetCreditOperation.v2.pool-1")).not.toBeNull(); + 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; + sessionStorage.clear(); + 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(localStorage.getItem("ocx.codexResetCreditOperation.v2.pool-1")).toBeNull(); +}); + +test("a terminal identity conflict stays recoverable when durable retry cleanup fails", async () => { + const staleOperationId = "00000000-0000-4000-8000-000000000779"; + const storageKey = "ocx.codexResetCreditOperation.v2.pool-1"; + localStorage.setItem(storageKey, staleOperationId); + 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, + ); + return Response.json({ + error: "The Codex account identity changed. Confirm a new reset-credit request.", + code: "reset_credit_operation_identity_changed", + }, { status: 409 }); + } + return baseFetch(input, init); + }, + }); + const storage = localStorage; + const originalRemoveItem = storage.removeItem.bind(storage); + Object.defineProperty(storage, "removeItem", { + configurable: true, + value: (key: string) => { + if (key === storageKey) throw new Error("storage unavailable"); + return originalRemoveItem(key); + }, + }); + try { + await mountPool(makeController()); + 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"), + ) as HTMLButtonElement; + await act(async () => { useCredit.click(); }); + const redeem = [...host.querySelectorAll("button")].find(button => + (button.textContent ?? "").trim() === "Use Credit", + ) as HTMLButtonElement; + await act(async () => { redeem.click(); await new Promise(resolve => setTimeout(resolve, 40)); }); + + expect(consumedOperationIds).toEqual([staleOperationId]); + expect(localStorage.getItem(storageKey)).toBe(staleOperationId); + expect(host.querySelector("dialog")).toBeTruthy(); + expect(host.textContent).toContain("The Codex account identity changed"); + expect(host.textContent).toContain("local retry state could not be cleared"); + } finally { + Object.defineProperty(storage, "removeItem", { + configurable: true, + value: originalRemoveItem, + }); + } +}); + +test("a successful redeem remains visible when durable retry cleanup fails", async () => { + const operationId = "00000000-0000-4000-8000-000000000780"; + const storageKey = "ocx.codexResetCreditOperation.v2.pool-1"; + localStorage.setItem(storageKey, operationId); + 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, + ); + return Response.json({ code: "reset" }); + } + return baseFetch(input, init); + }, + }); + const storage = localStorage; + const originalRemoveItem = storage.removeItem.bind(storage); + Object.defineProperty(storage, "removeItem", { + configurable: true, + value: (key: string) => { + if (key === storageKey) throw new Error("storage unavailable"); + return originalRemoveItem(key); + }, + }); + try { + await mountPool(makeController()); + 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"), + ) as HTMLButtonElement; + await act(async () => { useCredit.click(); }); + const redeem = [...host.querySelectorAll("button")].find(button => + (button.textContent ?? "").trim() === "Use Credit", + ) as HTMLButtonElement; + await act(async () => { redeem.click(); await new Promise(resolve => setTimeout(resolve, 40)); }); + + expect(consumeAttempts).toBe(1); + expect(consumedOperationIds).toEqual([operationId]); + expect(localStorage.getItem(storageKey)).toBe(operationId); + expect(host.querySelector("dialog")).toBeTruthy(); + expect(host.textContent).toContain("Rate limits reset!"); + expect(host.textContent).toContain("local retry state could not be cleared"); + } finally { + Object.defineProperty(storage, "removeItem", { + configurable: true, + value: originalRemoveItem, + }); + } +}); + +test("a pre-upgrade session retry id is durably migrated before redemption", async () => { + const legacyOperationId = "00000000-0000-4000-8000-000000000777"; + sessionStorage.setItem( + "ocx.codexResetCreditOperation.v1", + JSON.stringify({ "pool-1": legacyOperationId }), + ); + + await mountPool(makeController()); + expect(localStorage.getItem("ocx.codexResetCreditOperation.v2.pool-1")) + .toBe(legacyOperationId); + expect(sessionStorage.getItem("ocx.codexResetCreditOperation.v1")).toBeNull(); + + 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)); }); + + expect(consumeAttempts).toBe(1); + expect(consumedOperationIds).toEqual([legacyOperationId]); + expect(localStorage.getItem("ocx.codexResetCreditOperation.v2.pool-1")).toBeNull(); +}); + +test("an identity-changed retry is cleared and the next confirmation mints a fresh id", async () => { + const staleOperationId = "00000000-0000-4000-8000-000000000778"; + localStorage.setItem("ocx.codexResetCreditOperation.v2.pool-1", staleOperationId); + 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, + ); + return consumeAttempts === 1 + ? Response.json({ + error: "The Codex account identity changed. Confirm a new reset-credit request.", + code: "reset_credit_operation_identity_changed", + }, { status: 409 }) + : Response.json({ code: "no_credit" }); + } + 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"), + ) as HTMLButtonElement; + await act(async () => { useCredit.click(); }); + const redeem = [...host.querySelectorAll("button")].find(button => + (button.textContent ?? "").trim() === "Use Credit", + ) as HTMLButtonElement; + await act(async () => { redeem.click(); await new Promise(resolve => setTimeout(resolve, 40)); }); + }; + + await mountPool(makeController(), true); + await redeemOnce(); + expect(consumedOperationIds).toEqual([staleOperationId]); + expect(localStorage.getItem("ocx.codexResetCreditOperation.v2.pool-1")).toBeNull(); + expect(host.textContent).toContain("Codex account identity changed"); + + await redeemOnce(); + expect(consumeAttempts).toBe(2); + expect(consumedOperationIds[1]).not.toBe(staleOperationId); + expect(localStorage.getItem("ocx.codexResetCreditOperation.v2.pool-1")).toBeNull(); +}); + +test("a reset-credit consume is refused when its retry identity cannot be stored durably", async () => { + const storage = localStorage; + const originalSetItem = storage.setItem.bind(storage); + Object.defineProperty(storage, "setItem", { + configurable: true, + value: () => { throw new Error("storage unavailable"); }, + }); + try { + await mountPool(makeController()); + 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)); }); + + expect(consumeAttempts).toBe(0); + expect(host.textContent).toContain("Failed to redeem reset credit"); + } finally { + Object.defineProperty(storage, "setItem", { + configurable: true, + value: originalSetItem, + }); + } +}); diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index be0e2e39ec..9adfec3e3f 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -58,6 +58,13 @@ 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 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; } export function classifyAccount(config: OcxConfig, name: string): ClassifyResult { diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index 5b8d57dda8..565c83dce7 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -1,4 +1,13 @@ import { writeSync } from "node:fs"; +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 { isCodexResetCreditConsumeCode } from "../codex/reset-credit-recovery"; +import type { AccountDeps } from "./account-api"; import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh"; import { CliUsageError, @@ -43,6 +52,7 @@ visible to anyone who can run ps: ocx account login --code - (same, for the login flow)`; const CODEX_NAMES = new Set(["openai", "codex", "chatgpt"]); +const RESET_CREDIT_OPERATION_HISTORY_FULL_CODE = "reset_credit_operation_history_full"; interface LoginStart { url?: string; @@ -221,7 +231,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"); @@ -231,13 +241,103 @@ 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 }) }, deps) - : await runtimeRequest(`/api/codex-auth/reset-credits?accountId=${encodeURIComponent(accountId)}`, {}, deps); + if (!isCodexResetCreditConsentAccountId(accountId)) { + throw new CliUsageError("Invalid account id format", USAGE); + } + let result: unknown; + let postResultWarning: string | undefined; + if (consume) { + if ((deps.isAgentDrivenImpl ?? isAgentDriven)()) { + throw new CliUsageError( + "reset-credit consumption requires a hand-typed user-confirmed run", + USAGE, + ); + } + let operationId: string; + try { + operationId = (deps.reserveResetCreditOperationImpl ?? reservePendingResetCreditOperation)(accountId); + } catch { + throw new CliUsageError("reset-credit retry state is unavailable", USAGE); + } + const clearOperation = (): boolean => { + try { + return (deps.clearResetCreditOperationImpl ?? clearPendingResetCreditOperation)( + accountId, + operationId, + ); + } catch { + return false; + } + }; + 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; } + } + const terminalCode = body && typeof body === "object" + ? (body as { code?: unknown }).code + : undefined; + if (terminalCode === RESET_CREDIT_OPERATION_HISTORY_FULL_CODE) { + if (!clearOperation()) { + throw new CliUsageError( + "Reset-credit operation history is full, but local retry state could not be cleared; repair the local retry store and ask a maintainer to expand capacity or apply an approved retirement policy", + USAGE, + ); + } + throw new CliUsageError( + "Reset-credit operation history is full; ask a maintainer to expand capacity or apply an approved retirement policy before confirming another request", + USAGE, + ); + } + if (terminalCode === "reset_credit_operation_identity_changed") { + if (!clearOperation()) { + throw new CliUsageError( + "Codex account identity changed, but reset-credit retry state could not be cleared; repair the local retry store before confirming a new request", + USAGE, + ); + } + throw new CliUsageError( + "Codex account identity changed; rerun the command to confirm a new reset-credit request", + USAGE, + ); + } + 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); + } + if (isCodexResetCreditConsumeCode(terminalCode)) { + if (!clearOperation()) { + postResultWarning = "Warning: the reset-credit terminal result is authoritative, but local retry state could not be cleared; repair the local retry store before confirming a new request."; + } + } + result = body; + } else { + result = await runtimeRequest( + `/api/codex-auth/reset-credits?accountId=${encodeURIComponent(accountId)}`, + {}, + deps, + ); + } printData(result, wantsJson); + if (postResultWarning) console.error(postResultWarning); } -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..feb6d394de --- /dev/null +++ b/src/cli/reset-credit-consent-client.ts @@ -0,0 +1,173 @@ +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; + directLocalHttpFetchImpl?: typeof directLocalHttpFetch; + readRuntime?: (pid: number) => RuntimePortState | null; + createNonce?: () => string; + now?: () => number; + timeoutMs?: number; +} + +const RESET_CREDIT_PROOF_TIMEOUT_MS = 10_000; +const RESET_CREDIT_CONSENT_POST_TIMEOUT_MS = 15_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 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, + 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 proofTimeoutMs = deps.timeoutMs ?? RESET_CREDIT_PROOF_TIMEOUT_MS; + const consentPostTimeoutMs = deps.timeoutMs ?? RESET_CREDIT_CONSENT_POST_TIMEOUT_MS; + const fetchImpl = ( + input: string, + init: RequestInit, + timeoutMs: number, + ): Promise => deps.fetchImpl + ? deps.fetchImpl(input, init) + : (deps.directLocalHttpFetchImpl ?? directLocalHttpFetch)(input, init, { timeoutMs }); + 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(proofTimeoutMs), + }, proofTimeoutMs); + } 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(consentPostTimeoutMs), + }, consentPostTimeoutMs); + return { kind: "response", response }; + } catch { + return { kind: "unavailable", reason: "transport" }; + } +} 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 c233630aa2..ddb02c8f47 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -102,10 +102,26 @@ 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"; import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort"; +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, @@ -258,6 +274,47 @@ 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; +} + +function manualResetCreditHistoryFullResponse(): Response { + return jsonResponse({ + error: "Reset-credit operation history is full. Ask a maintainer to expand capacity or apply an approved retirement policy before confirming another request.", + code: "reset_credit_operation_history_full", + }, 507); +} + +function manualResetCreditIdentityChangedResponse(): Response { + return jsonResponse({ + error: "The Codex account identity changed. Confirm a new reset-credit request.", + code: "reset_credit_operation_identity_changed", + }, 409); +} + +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, @@ -332,11 +389,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 }; @@ -1355,6 +1407,7 @@ export async function handleCodexAuthAPI( url: URL, config: OcxConfig, convergeCodexCatalog?: CodexAuthCatalogConvergence, + principal?: ManagementPrincipal, ): Promise { if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") { @@ -1682,7 +1735,10 @@ export async function handleCodexAuthAPI( if (!parsed.ok) { return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502); } - return jsonResponse(safeResetCreditsDto(parsed.value)); + return jsonResponse({ + ...safeResetCreditsDto(parsed.value), + guiConsumeAllowed: principal === "gui-session", + }); } finally { detachBodyAbort(); linkedSignal.cleanup(); @@ -1695,62 +1751,90 @@ 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 }; + if (principal !== "gui-reset-credit-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); + } + 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 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 identity: ManualResetCreditOperationIdentity = { + accountId, + chatgptAccountId: auth.chatgptAccountId, + operationId: requestedOperationId, + }; + const opened = openManualResetCreditOperation(identity); + if (opened.kind === "identity-mismatch") { + return manualResetCreditIdentityChangedResponse(); } - const result = safeResetCreditConsumeDto(await resp.json()); - // 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, - )); - } else { - const account = configuredPoolAccount(getRuntimeConfig(config), accountId); - ({ freshResetCredits } = await fetchPoolAccountQuota(accountId, true, account?.plan)); - } - return jsonResponse({ - code: result.code, - ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits) - ? { remaining: freshResetCredits } - : {}), - }); + if (opened.kind === "capacity") { + return manualResetCreditHistoryFullResponse(); } - return jsonResponse(result); + if (opened.kind === "unavailable") { + return manualResetCreditBusyResponse(); + } + if (opened.kind === "terminal") { + return jsonResponse({ code: opened.code }); + } + if (opened.kind !== "execute") { + return manualResetCreditBusyResponse(); + } + return runManualResetCreditFlight(auth.chatgptAccountId, async () => { + let code: CodexResetCreditConsumeCode; + 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") { + return manualResetCreditBusyResponse(); + } + // 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; } 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); + } + 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..2731faa99b --- /dev/null +++ b/src/codex/reset-credit-consume.ts @@ -0,0 +1,122 @@ +import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBody } from "../lib/bounded-body"; +import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort"; +import { + isCodexResetCreditConsumeCode, + 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; +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 isCodexResetCreditConsumeCode(code) + ? code + : 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..3b552555b4 --- /dev/null +++ b/src/codex/reset-credit-operation-ledger.ts @@ -0,0 +1,1327 @@ +import { createHash, randomUUID } from "node:crypto"; +import { chmodSync } from "node:fs"; +import { Database } from "bun:sqlite"; +import { NestedConfigMutationError, prepareConfigMutationDatabasePathForWrite } from "../config"; +import { initializeConfigGeneration } from "./generation"; +import { + compareCodexResetCreditRecoveryGenerationOrder, + isCodexResetCreditOperationId, + type CodexResetCreditConsumeCode, + type CodexResetCreditRecoveryGeneration, + type CodexReservedOperationId, +} from "./reset-credit-recovery"; +import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id"; + +export const MAX_RESET_CREDIT_OPERATION_ACCOUNTS = 128; +export const MAX_MANUAL_RESET_CREDIT_OPERATION_IDS = 4_096; +const MANUAL_RESET_CREDIT_HISTORY_HIGH_WATER_MARK = Math.ceil( + MAX_MANUAL_RESET_CREDIT_OPERATION_IDS * 0.9, +); +let reportedManualHistoryLevel = 0; +const ACCOUNT_KEY_PATTERN = /^[0-9a-f]{64}$/; +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"; +type ResetCreditOperationKind = "recovery" | "manual"; + +type ResetCreditOperationRecord = Readonly<{ + accountKey: string; + operationKind: ResetCreditOperationKind; + credentialGeneration?: number; + exhaustionGeneration?: number; + operationId: string; + joinedOperationId?: string; + state: ResetCreditOperationState; + code?: CodexResetCreditConsumeCode; + createdAt: number; + updatedAt: number; +}>; + +type ResetCreditOperationRow = { + account_key: unknown; + operation_kind: unknown; + credential_generation: unknown; + exhaustion_generation: unknown; + operation_id: unknown; + joined_operation_id: unknown; + state: unknown; + code: unknown; + created_at: unknown; + updated_at: unknown; +}; + +type ManualResetCreditOperationIdRecord = Readonly<{ + operationId: string; + accountKey: string; + canonicalOperationId: string; + terminalCode?: CodexResetCreditConsumeCode; + createdAt: number; + updatedAt: number; +}>; + +type ManualResetCreditOperationIdRow = { + operation_id: unknown; + account_key: unknown; + canonical_operation_id: unknown; + terminal_code: unknown; + created_at: unknown; + updated_at: unknown; +}; + +export type OpenResetCreditOperationResult = + | 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 = + | 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" | "identity-mismatch" | "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, + joined_operation_id TEXT, + 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 + AND joined_operation_id IS NULL) + OR + (operation_kind = 'manual' + AND credential_generation IS NULL AND exhaustion_generation IS NULL) + ), + CHECK (joined_operation_id IS NULL OR joined_operation_id <> operation_id) + ) STRICT, WITHOUT ROWID`; +const EXPECTED_SCHEMA_SQL = CREATE_TABLE.replace("main.", ""); +export const RESET_CREDIT_OPERATION_SCHEMA_SQL_FOR_TESTS = EXPECTED_SCHEMA_SQL; +const MANUAL_ID_TABLE_NAME = "reset_credit_manual_operation_ids"; +const CREATE_MANUAL_ID_TABLE = `CREATE TABLE main.reset_credit_manual_operation_ids ( + operation_id TEXT PRIMARY KEY, + account_key TEXT NOT NULL, + canonical_operation_id TEXT NOT NULL, + terminal_code TEXT CHECK ( + terminal_code IS NULL OR terminal_code IN ( + 'reset', 'already_redeemed', 'nothing_to_reset', 'no_credit' + ) + ), + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= created_at) + ) STRICT, WITHOUT ROWID`; +const EXPECTED_MANUAL_ID_SCHEMA_SQL = CREATE_MANUAL_ID_TABLE.replace("main.", ""); +export const RESET_CREDIT_MANUAL_OPERATION_ID_SCHEMA_SQL_FOR_TESTS = EXPECTED_MANUAL_ID_SCHEMA_SQL; +const PRIOR_TABLE_NAME = "reset_credit_operations_legacy_v2"; +const PRIOR_CREATE_TABLE = `CREATE TABLE 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`; +export const RESET_CREDIT_OPERATION_PRIOR_SCHEMA_SQL_FOR_TESTS = PRIOR_CREATE_TABLE; +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), + 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`; +export const RESET_CREDIT_OPERATION_LEGACY_SCHEMA_SQL_FOR_TESTS = LEGACY_CREATE_TABLE; +const SELECT_ALL = ` + SELECT account_key, operation_kind, credential_generation, + exhaustion_generation, operation_id, joined_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, operation_kind, credential_generation, + exhaustion_generation, operation_id, joined_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 ( + SELECT account_key + FROM main.reset_credit_operations + WHERE operation_id = ? OR joined_operation_id = ? + UNION + SELECT account_key + FROM main.reset_credit_manual_operation_ids + WHERE operation_id = ? + ) + LIMIT 2`; +const SELECT_ALL_MANUAL_IDS = ` + SELECT operation_id, account_key, canonical_operation_id, + terminal_code, created_at, updated_at + FROM main.reset_credit_manual_operation_ids + ORDER BY operation_id + LIMIT ${MAX_MANUAL_RESET_CREDIT_OPERATION_IDS + 1}`; +const SELECT_MANUAL_ID = ` + SELECT operation_id, account_key, canonical_operation_id, + terminal_code, created_at, updated_at + FROM main.reset_credit_manual_operation_ids + WHERE operation_id = ? + LIMIT 2`; +const SELECT_MANUAL_IDS_BY_CANONICAL = ` + SELECT operation_id, account_key, canonical_operation_id, + terminal_code, created_at, updated_at + FROM main.reset_credit_manual_operation_ids + WHERE account_key = ? AND canonical_operation_id = ? + ORDER BY operation_id + LIMIT ${MAX_MANUAL_RESET_CREDIT_OPERATION_IDS + 1}`; +const INSERT_MANUAL_ID = ` + INSERT INTO main.reset_credit_manual_operation_ids ( + operation_id, account_key, canonical_operation_id, + terminal_code, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?)`; +const SETTLE_MANUAL_IDS = ` + UPDATE main.reset_credit_manual_operation_ids + SET terminal_code = ?, updated_at = ? + WHERE account_key = ? AND canonical_operation_id = ? + AND (terminal_code IS NULL OR terminal_code = ?)`; +const INSERT_RECORD = ` + INSERT INTO main.reset_credit_operations ( + account_key, operation_kind, credential_generation, exhaustion_generation, + operation_id, joined_operation_id, state, code, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; +const REPLACE_RECORD = ` + UPDATE main.reset_credit_operations + SET operation_kind = ?, credential_generation = ?, exhaustion_generation = ?, + operation_id = ?, joined_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 operation_kind = ? AND operation_id = ? + AND credential_generation IS ? AND exhaustion_generation IS ?`; +const JOIN_MANUAL_OPERATION = ` + UPDATE main.reset_credit_operations + SET joined_operation_id = ?, updated_at = ? + WHERE account_key = ? AND operation_kind = 'manual' AND operation_id = ? + AND joined_operation_id IS NULL AND state IN ('pending', 'ambiguous')`; +const TOUCH_MANUAL_OPERATION = ` + UPDATE main.reset_credit_operations + SET updated_at = ? + WHERE account_key = ? AND operation_kind = 'manual' AND operation_id = ? + AND joined_operation_id IS NOT NULL AND state IN ('pending', 'ambiguous')`; + +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: "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: "joined_operation_id", type: "TEXT", notnull: 0, 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 MANUAL_ID_COLUMNS = Object.freeze([ + Object.freeze({ name: "operation_id", type: "TEXT", notnull: 1, pk: 1 }), + Object.freeze({ name: "account_key", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "canonical_operation_id", type: "TEXT", notnull: 1, pk: 0 }), + Object.freeze({ name: "terminal_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 }), + 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 PRIOR_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 }), +]); + +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; +} + +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; + const joinedOperationId = row.joined_operation_id; + if (typeof row.account_key !== "string" || !ACCOUNT_KEY_PATTERN.test(row.account_key) + || (row.operation_kind !== "recovery" && row.operation_kind !== "manual") + || !isCodexResetCreditOperationId(row.operation_id) + || (joinedOperationId !== null + && (!isCodexResetCreditOperationId(joinedOperationId) || joinedOperationId === 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 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) + || (recovery && joinedOperationId !== null)) { + 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] + : undefined; + if (terminal !== (terminalState !== undefined)) return undefined; + if (terminal && state !== terminalState) return undefined; + return Object.freeze({ + accountKey: row.account_key, + operationKind: row.operation_kind, + ...(recovery + ? { + credentialGeneration: row.credential_generation as number, + exhaustionGeneration: row.exhaustion_generation as number, + } + : {}), + operationId: row.operation_id, + ...(joinedOperationId === null ? {} : { joinedOperationId }), + state: state as ResetCreditOperationState, + ...(terminal ? { code: code as CodexResetCreditConsumeCode } : {}), + createdAt: row.created_at, + updatedAt: row.updated_at, + }); +} + +function parseManualIdRecord( + row: ManualResetCreditOperationIdRow | null, +): ManualResetCreditOperationIdRecord | undefined { + if (!row) return undefined; + const terminalCode = row.terminal_code; + if (!isCodexResetCreditOperationId(row.operation_id) + || typeof row.account_key !== "string" || !ACCOUNT_KEY_PATTERN.test(row.account_key) + || !isCodexResetCreditOperationId(row.canonical_operation_id) + || (terminalCode !== null + && (typeof terminalCode !== "string" + || !Object.prototype.hasOwnProperty.call(TERMINAL_STATE_BY_CODE, terminalCode))) + || !isGenerationNumber(row.created_at) + || !isGenerationNumber(row.updated_at) + || row.updated_at < row.created_at) { + return undefined; + } + return Object.freeze({ + operationId: row.operation_id, + accountKey: row.account_key, + canonicalOperationId: row.canonical_operation_id, + ...(terminalCode === null ? {} : { terminalCode: terminalCode as CodexResetCreditConsumeCode }), + createdAt: row.created_at, + updatedAt: row.updated_at, + }); +} + +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 === 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 !== 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(${tableName})`, + ).all(); + if (columns.length !== expectedColumns.length) { + throw new Error("invalid reset-credit operation ledger columns"); + } + for (let index = 0; index < expectedColumns.length; index += 1) { + const actual = 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(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(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", + joined_operation_id: null, + }); + 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, joined_operation_id, state, code, created_at, updated_at + ) + SELECT account_key, 'recovery', credential_generation, exhaustion_generation, + operation_id, NULL, state, code, created_at, updated_at + FROM main.${LEGACY_TABLE_NAME} + `); + database.exec(`DROP TABLE main.${LEGACY_TABLE_NAME}`); +} + +function migratePriorTable(database: Database): void { + assertColumnLayout(database, TABLE_NAME, PRIOR_COLUMNS); + assertNoLedgerTriggers(database, TABLE_NAME); + const priorRows = database.query, []>(` + 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} + `).all(); + if (priorRows.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 priorRows) { + const record = parseRecord({ ...row, joined_operation_id: null }); + 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 ${PRIOR_TABLE_NAME}`); + database.exec(CREATE_TABLE); + database.exec(` + INSERT INTO main.${TABLE_NAME} ( + account_key, operation_kind, credential_generation, exhaustion_generation, + operation_id, joined_operation_id, state, code, created_at, updated_at + ) + SELECT account_key, operation_kind, credential_generation, exhaustion_generation, + operation_id, NULL, state, code, created_at, updated_at + FROM main.${PRIOR_TABLE_NAME} + `); + database.exec(`DROP TABLE main.${PRIOR_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 isExactPriorSchema(database: Database, schema: SchemaObjectRow): boolean { + if (schema.type !== "table" || schema.name !== TABLE_NAME || schema.tbl_name !== TABLE_NAME + || schema.sql !== PRIOR_CREATE_TABLE) return false; + try { + assertColumnLayout(database, TABLE_NAME, PRIOR_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 && isExactPriorSchema(database, schemaRows[0]!)) { + migratePriorTable(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 ensureManualIdTable(database: Database): boolean { + 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(MANUAL_ID_TABLE_NAME, MANUAL_ID_TABLE_NAME); + let created = false; + if (schemaRows.length === 0) { + database.exec(CREATE_MANUAL_ID_TABLE); + created = true; + } else if (schemaRows.length !== 1 + || schemaRows[0]?.type !== "table" + || schemaRows[0]?.name !== MANUAL_ID_TABLE_NAME + || schemaRows[0]?.tbl_name !== MANUAL_ID_TABLE_NAME + || schemaRows[0]?.sql !== EXPECTED_MANUAL_ID_SCHEMA_SQL) { + throw new Error("invalid manual reset-credit operation identity schema"); + } + assertColumnLayout(database, MANUAL_ID_TABLE_NAME, MANUAL_ID_COLUMNS); + assertNoLedgerTriggers(database, MANUAL_ID_TABLE_NAME); + return created; +} + +function initializeTable(database: Database): Readonly<{ + recordCount: number; + manualIdCount: 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(); + const records = new Map(); + for (const row of rows) { + const record = parseRecord(row); + const ids = record ? [record.operationId, ...(record.joinedOperationId ? [record.joinedOperationId] : [])] : []; + if (!record || accountKeys.has(record.accountKey) || ids.some(id => operationIds.has(id))) { + throw new Error("invalid reset-credit operation ledger state"); + } + accountKeys.add(record.accountKey); + records.set(record.accountKey, record); + for (const id of ids) operationIds.add(id); + } + + const manualTableCreated = ensureManualIdTable(database); + if (manualTableCreated) { + for (const record of records.values()) { + if (record.operationKind !== "manual") continue; + const ids = [record.operationId, ...(record.joinedOperationId ? [record.joinedOperationId] : [])]; + for (const operationId of ids) { + insertManualIdRecord(database, Object.freeze({ + operationId, + accountKey: record.accountKey, + canonicalOperationId: record.operationId, + ...(record.code === undefined ? {} : { terminalCode: record.code }), + createdAt: record.createdAt, + updatedAt: record.updatedAt, + })); + } + } + } + + const manualRows = database.query(SELECT_ALL_MANUAL_IDS).all(); + if (manualRows.length > MAX_MANUAL_RESET_CREDIT_OPERATION_IDS) { + throw new Error("invalid manual reset-credit operation identity capacity"); + } + const manualIds = new Map(); + for (const row of manualRows) { + const record = parseManualIdRecord(row); + if (!record || manualIds.has(record.operationId)) { + throw new Error("invalid manual reset-credit operation identity state"); + } + manualIds.set(record.operationId, record); + } + for (const record of manualIds.values()) { + const canonical = manualIds.get(record.canonicalOperationId); + if (!canonical || canonical.operationId !== canonical.canonicalOperationId + || canonical.accountKey !== record.accountKey + || canonical.terminalCode !== record.terminalCode) { + throw new Error("invalid manual reset-credit operation identity state"); + } + if (record.terminalCode === undefined) { + const current = records.get(record.accountKey); + if (!current || current.operationKind !== "manual" || isTerminal(current) + || current.operationId !== record.canonicalOperationId) { + throw new Error("invalid manual reset-credit operation identity state"); + } + } + } + for (const record of records.values()) { + if (record.operationKind === "recovery") { + if (manualIds.has(record.operationId)) { + throw new Error("duplicate reset-credit operation ids"); + } + continue; + } + const expectedIds = [record.operationId, ...(record.joinedOperationId ? [record.joinedOperationId] : [])]; + for (const operationId of expectedIds) { + const identity = manualIds.get(operationId); + if (!identity || identity.accountKey !== record.accountKey + || identity.canonicalOperationId !== record.operationId + || identity.terminalCode !== record.code) { + throw new Error("invalid manual reset-credit operation identity state"); + } + } + } + return Object.freeze({ recordCount: rows.length, manualIdCount: manualRows.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 readManualIdRecord( + database: Database, + operationId: string, +): ManualResetCreditOperationIdRecord | undefined { + const rows = database.query(SELECT_MANUAL_ID) + .all(operationId); + if (rows.length > 1) throw new Error("duplicate manual reset-credit operation ids"); + const row = rows[0]; + const record = parseManualIdRecord(row ?? null); + if (row && !record) throw new Error("invalid manual reset-credit operation identity"); + return record; +} + +function sameManualIdRecord( + left: ManualResetCreditOperationIdRecord, + right: ManualResetCreditOperationIdRecord, +): boolean { + return left.operationId === right.operationId + && left.accountKey === right.accountKey + && left.canonicalOperationId === right.canonicalOperationId + && left.terminalCode === right.terminalCode + && left.createdAt === right.createdAt + && left.updatedAt === right.updatedAt; +} + +function assertStoredManualIdRecord( + database: Database, + expected: ManualResetCreditOperationIdRecord, +): void { + const stored = readManualIdRecord(database, expected.operationId); + if (!stored || !sameManualIdRecord(stored, expected)) { + throw new Error("manual reset-credit operation identity write did not persist"); + } +} + +function insertManualIdRecord( + database: Database, + record: ManualResetCreditOperationIdRecord, +): void { + const result = database.query(INSERT_MANUAL_ID).run( + record.operationId, + record.accountKey, + record.canonicalOperationId, + record.terminalCode ?? null, + record.createdAt, + record.updatedAt, + ); + if (result.changes !== 1) throw new Error("manual reset-credit operation identity insert failed"); + assertStoredManualIdRecord(database, record); +} + +function operationOwner(database: Database, operationId: string): string | undefined { + const rows = database.query<{ account_key: unknown }, [string, string, string]>(SELECT_KEY_BY_OPERATION_ID) + .all(operationId, operationId, 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 + && left.joinedOperationId === right.joinedOperationId + && 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 { + return compareCodexResetCreditRecoveryGenerationOrder({ + accountId: generation.accountId, + credentialGeneration: record.credentialGeneration!, + exhaustionGeneration: record.exhaustionGeneration!, + }, generation); +} + +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; +} + +type Synchronous = T extends PromiseLike ? never : T; + +function withLedger(operation: ( + database: Database, + recordCount: number, + manualIdCount: number, +) => 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); + const counts = initializeTable(database); + const value = operation(database, counts.recordCount, counts.manualIdCount); + 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 */ } + } +} + +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 NestedConfigMutationError; + console.warn(nested + ? "[opencodex] Reset-credit operation ledger refused a nested config mutation." + : "[opencodex] Reset-credit operation ledger is unavailable."); +} + +function reportManualHistoryCapacity(count: number): void { + const level = count >= MAX_MANUAL_RESET_CREDIT_OPERATION_IDS + ? MAX_MANUAL_RESET_CREDIT_OPERATION_IDS + : count >= MANUAL_RESET_CREDIT_HISTORY_HIGH_WATER_MARK + ? MANUAL_RESET_CREDIT_HISTORY_HIGH_WATER_MARK + : 0; + if (level === 0 || level <= reportedManualHistoryLevel) return; + reportedManualHistoryLevel = level; + try { + console.warn( + `[opencodex] Reset-credit manual operation history is at ${count}/${MAX_MANUAL_RESET_CREDIT_OPERATION_IDS} entries${ + level === MAX_MANUAL_RESET_CREDIT_OPERATION_IDS + ? "; new manual redemptions are disabled until a maintainer expands capacity or applies an approved retirement policy." + : "." + }`, + ); + } catch { + // Count-only operational reporting must never weaken the fail-closed result. + } +} + +/** + * 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(), +): 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) { + 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) { + if (isTerminal(current)) { + return Object.freeze({ + kind: "terminal" as const, + operationId: current.operationId as CodexReservedOperationId, + code: current.code!, + }); + } + 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) { + return Object.freeze({ kind: "capacity" as const }); + } + + const operationId = randomUUID(); + if (!isCodexResetCreditOperationId(operationId)) throw new Error("runtime generated invalid UUID"); + const values = [ + "recovery", + generation.credentialGeneration, + generation.exhaustionGeneration, + operationId, + null, + "pending", + null, + now, + now, + ] as const; + const result = current + ? 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, + state: "pending", + createdAt: now, + updatedAt: now, + })); + return Object.freeze({ + kind: "execute" as const, + operationId: operationId as CodexReservedOperationId, + resumed: false, + }); + }); + } catch (error) { + warnLedgerUnavailable(error); + return Object.freeze({ kind: "unavailable" }); + } +} + +function updateOperation( + owner: Readonly<{ + accountKey: string; + operationKind: ResetCreditOperationKind; + credentialGeneration?: number; + exhaustionGeneration?: number; + }>, + operationId: string, + update: (record: ResetCreditOperationRecord) => ResetCreditOperationRecord | undefined, + afterWrite?: (database: Database, updated: ResetCreditOperationRecord) => void, +): UpdateResetCreditOperationResult { + if (!isCodexResetCreditOperationId(operationId)) return Object.freeze({ kind: "mismatch" }); + try { + return withLedger(database => { + const current = readRecord(database, owner.accountKey); + if (!current + || current.operationKind !== owner.operationKind + || current.credentialGeneration !== owner.credentialGeneration + || current.exhaustionGeneration !== owner.exhaustionGeneration + || 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, + 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); + afterWrite?.(database, updated); + return Object.freeze({ kind: "updated" as const }); + }); + } 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, + now = Date.now(), +): UpdateResetCreditOperationResult { + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); + 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, + state: "ambiguous", + code: undefined, + updatedAt: Math.max(record.updatedAt, now), + }); + }); +} + +/** + * 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, + code: CodexResetCreditConsumeCode, + now = Date.now(), +): UpdateResetCreditOperationResult { + 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" }); + } + 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. + * + * 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(), +): OpenManualResetCreditOperationResult { + const owner = validateManualIdentity(identity); + if (!Number.isSafeInteger(now) || now < 0) throw new TypeError("invalid reset-credit operation timestamp"); + try { + return withLedger((database, recordCount, manualIdCount) => { + reportManualHistoryCapacity(manualIdCount); + const admitNewCallerId = () => { + if (manualIdCount >= MAX_MANUAL_RESET_CREDIT_OPERATION_IDS) { + return Object.freeze({ kind: "capacity" as const }); + } + const existingOwner = operationOwner(database, identity.operationId); + if (existingOwner !== undefined) { + return Object.freeze({ + kind: existingOwner === owner.accountKey ? "unavailable" as const : "identity-mismatch" as const, + }); + } + return undefined; + }; + + const reserve = (replaceCurrent: boolean): OpenManualResetCreditOperationResult => { + const rejected = admitNewCallerId(); + if (rejected) return rejected; + + 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, + null, + "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); + insertManualIdRecord(database, Object.freeze({ + operationId: identity.operationId, + accountKey: owner.accountKey, + canonicalOperationId: identity.operationId, + createdAt: now, + updatedAt: now, + })); + return Object.freeze({ + kind: "execute" as const, + operationId: identity.operationId as CodexReservedOperationId, + resumed: false, + }); + }; + + const knownIdentity = readManualIdRecord(database, identity.operationId); + if (knownIdentity) { + if (knownIdentity.accountKey !== owner.accountKey) { + return Object.freeze({ kind: "identity-mismatch" as const }); + } + if (knownIdentity.terminalCode !== undefined) { + return Object.freeze({ + kind: "terminal" as const, + operationId: knownIdentity.canonicalOperationId as CodexReservedOperationId, + code: knownIdentity.terminalCode, + }); + } + const current = readRecord(database, owner.accountKey); + if (!current || current.operationKind !== "manual" || isTerminal(current) + || current.operationId !== knownIdentity.canonicalOperationId) { + throw new Error("manual reset-credit operation identity lost its active owner"); + } + return Object.freeze({ + kind: "execute" as const, + operationId: current.operationId as CodexReservedOperationId, + resumed: true, + }); + } + + const current = readRecord(database, owner.accountKey); + if (current) { + if (current.operationKind !== "manual") { + return Object.freeze({ kind: "unavailable" as const }); + } + if (!isTerminal(current)) { + const rejected = admitNewCallerId(); + if (rejected) return rejected; + insertManualIdRecord(database, Object.freeze({ + operationId: identity.operationId, + accountKey: owner.accountKey, + canonicalOperationId: current.operationId, + createdAt: now, + updatedAt: now, + })); + const joined: ResetCreditOperationRecord = Object.freeze({ + ...current, + ...(current.joinedOperationId === undefined + ? { joinedOperationId: identity.operationId } + : {}), + updatedAt: Math.max(current.updatedAt, now), + }); + const result = current.joinedOperationId === undefined + ? database.query(JOIN_MANUAL_OPERATION).run( + identity.operationId, + joined.updatedAt, + owner.accountKey, + current.operationId, + ) + : database.query(TOUCH_MANUAL_OPERATION).run( + joined.updatedAt, + owner.accountKey, + current.operationId, + ); + if (result.changes !== 1) { + throw new Error("manual reset-credit join lost ownership"); + } + assertStoredRecord(database, joined); + // The upstream request keeps the original durable id. Every caller id + // is retained in the identity history; the first alias is also kept on + // the current row for compatibility with the previous schema. + return Object.freeze({ + kind: "execute" as const, + operationId: current.operationId as CodexReservedOperationId, + resumed: true, + }); + } + // Deliberate: a distinct caller id after a settled intent represents a + // new explicit redemption. Prior ids remain immutable in the history, + // so a delayed retry can never be reclassified as this new intent. + return reserve(true); + } + if (recordCount >= MAX_RESET_CREDIT_OPERATION_ACCOUNTS) { + return Object.freeze({ kind: "capacity" as const }); + } + return reserve(false); + }); + } catch (error) { + warnLedgerUnavailable(error); + return Object.freeze({ kind: "unavailable" }); + } +} + +/** + * 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(), +): 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) }); + }); +} + +/** + * 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, + 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, + state: TERMINAL_STATE_BY_CODE[code], + code, + updatedAt: Math.max(record.updatedAt, now), + }); + }, (database, updated) => { + const result = database.query(SETTLE_MANUAL_IDS).run( + code, + updated.updatedAt, + owner.accountKey, + identity.operationId, + code, + ); + if (result.changes < 1) { + throw new Error("manual reset-credit terminal identity update lost ownership"); + } + const rows = database.query( + SELECT_MANUAL_IDS_BY_CANONICAL, + ).all(owner.accountKey, identity.operationId); + if (rows.length < 1 || rows.length > MAX_MANUAL_RESET_CREDIT_OPERATION_IDS) { + throw new Error("invalid manual reset-credit terminal identity set"); + } + for (const row of rows) { + const stored = parseManualIdRecord(row); + if (!stored || stored.accountKey !== owner.accountKey + || stored.canonicalOperationId !== identity.operationId + || stored.terminalCode !== code + || stored.updatedAt !== updated.updatedAt) { + throw new Error("manual reset-credit terminal identity write did not persist"); + } + } + }); +} diff --git a/src/codex/reset-credit-recovery.ts b/src/codex/reset-credit-recovery.ts index 69771eddd7..82f3fd00fa 100644 --- a/src/codex/reset-credit-recovery.ts +++ b/src/codex/reset-credit-recovery.ts @@ -21,11 +21,38 @@ export type CodexResetCreditRevalidationResult = | { kind: "stale-generation" } | { kind: "no-credit" }; +export const CODEX_RESET_CREDIT_CONSUME_CODES = Object.freeze([ + "reset", + "already_redeemed", + "nothing_to_reset", + "no_credit", +] as const); + export type CodexResetCreditConsumeCode = - | "reset" - | "already_redeemed" - | "nothing_to_reset" - | "no_credit"; + (typeof CODEX_RESET_CREDIT_CONSUME_CODES)[number]; + +const CODEX_RESET_CREDIT_CONSUME_CODE_SET: ReadonlySet = + new Set(CODEX_RESET_CREDIT_CONSUME_CODES); + +export function isCodexResetCreditConsumeCode( + value: unknown, +): value is CodexResetCreditConsumeCode { + return typeof value === "string" && CODEX_RESET_CREDIT_CONSUME_CODE_SET.has(value); +} + +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}$/; + +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 +191,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; @@ -177,13 +203,6 @@ const CANCELLED_BEFORE_DISPATCH = freezeResult({ reason: "cancelled-before-dispatch", } as const); -const CONSUME_CODES: ReadonlySet = new Set([ - "reset", - "already_redeemed", - "nothing_to_reset", - "no_credit", -]); - const RESET_ELIGIBLE_CODES = { usage_limit_exceeded: true, insufficient_quota: true, @@ -238,7 +257,7 @@ function generationKey(generation: CodexResetCreditRecoveryGeneration): string { ]); } -function compareGenerationOrder( +export function compareCodexResetCreditRecoveryGenerationOrder( left: CodexResetCreditRecoveryGeneration, right: CodexResetCreditRecoveryGeneration, ): -1 | 0 | 1 { @@ -361,8 +380,8 @@ function consumeCode( ): CodexResetCreditConsumeCode | undefined { const code = ownStringField(input, "code"); const operationId = ownStringField(input, "operationId"); - return code && CONSUME_CODES.has(code) && operationId === expectedOperationId - ? code as CodexResetCreditConsumeCode + return isCodexResetCreditConsumeCode(code) && operationId === expectedOperationId + ? code : undefined; } @@ -475,7 +494,20 @@ export class CodexResetCreditRecoveryCoordinator { createLogicalTurn(): CodexResetCreditLogicalTurn { const operationId = crypto.randomUUID(); - if (!OPERATION_ID_PATTERN.test(operationId)) { + return this.registerLogicalTurn(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: CodexReservedOperationId): CodexResetCreditLogicalTurn { + 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 }); @@ -554,7 +586,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, @@ -779,7 +811,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 b71a1a34c7..9c3cab9e1f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2525,6 +2525,26 @@ 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 class NestedConfigMutationError extends Error { + constructor() { + super("prepareConfigMutationDatabasePathForWrite must not run inside withConfigMutationLockSync"); + this.name = "NestedConfigMutationError"; + } +} + +export function prepareConfigMutationDatabasePathForWrite(): string { + if (configMutationLockDepth > 0) { + throw new NestedConfigMutationError(); + } + return configMutationDatabasePath(); +} + let configMutationLockDepth = 0; let configMutationDatabase: Database | null = null; 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..b4cd7b7c3d --- /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 exact-request authorization; this is not proof of human presence. */ +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 0ead1af63d..b80cdc0b01 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -193,6 +193,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; +export const CODEX_RESET_CREDIT_GUI_OWNER_TOKEN_HEADER = + "x-opencodex-reset-credit-owner-token"; +const consumedResetCreditConsentCapabilities = new Map(); +const admittedResetCreditConsentRequests = new WeakSet(); interface GuiSessionRecord { csrfToken: string; @@ -275,17 +291,22 @@ export function issueGuiSession( * `admin-token` is the raw token from disk/env: anything running as the user can * read it, including a coding agent. `gui-session` is a session token this process * minted for a browser, and it only authorizes a mutation after the origin and the - * per-session CSRF token match. Consent-bearing routes must key off this value - * rather than off request headers, which the token holder can forge freely. + * per-session CSRF token match. `gui-reset-credit-session` additionally proves the + * browser re-entered the owner-only admin token; neither credential alone authorizes + * that irreversible action. Consent-bearing routes must key off this value rather + * than off request headers, which a raw-token caller can otherwise 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" + | "gui-reset-credit-session" | "local-read-capability" | "local-provider-reload-capability" + | "local-reset-credit-capability" | "system-restart-capability"; export interface LocalManagementAuthContext { @@ -416,6 +437,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 +499,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; @@ -439,7 +509,11 @@ export function managementPrincipal( if (equalSecret(actual, state.token)) return "admin-token"; if (!config) return null; removeExpiredSessions(state); - return state.sessions.has(actual) ? "gui-session" : null; + if (!state.sessions.has(actual)) return null; + const ownerToken = req.headers.get(CODEX_RESET_CREDIT_GUI_OWNER_TOKEN_HEADER)?.trim(); + return ownerToken && equalSecret(ownerToken, state.token) + ? "gui-reset-credit-session" + : "gui-session"; } export function requireManagementAuth( @@ -449,6 +523,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 8a0110d679..c56f3cb75b 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,224 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(requests).toHaveLength(before); }); + 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" }) }; + }, + }, + ); + + expect(result.code).toBe(0); + expect(requested).toEqual({ + accountId: "__main__", + 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("reset-credit identity change clears the stale id and requires a fresh confirmed run", async () => { + const staleId = "123e4567-e89b-42d3-a456-426614174000"; + const freshId = "123e4567-e89b-42d3-a456-426614174001"; + let pendingId: string | undefined = staleId; + const requested: string[] = []; + const deps: AccountDeps = { + ...defaultDeps(), + isAgentDrivenImpl: () => false, + reserveResetCreditOperationImpl: () => { + pendingId ??= freshId; + return pendingId; + }, + clearResetCreditOperationImpl: (_accountId, operationId) => { + if (pendingId !== operationId) return false; + pendingId = undefined; + return true; + }, + requestResetCreditConsentImpl: async (_accountId, operationId) => { + requested.push(operationId); + return requested.length === 1 + ? { + kind: "response", + response: json({ + error: "The Codex account identity changed. Confirm a new reset-credit request.", + code: "reset_credit_operation_identity_changed", + }, 409), + } + : { kind: "response", response: json({ code: "no_credit" }) }; + }, + }; + + const first = await run(["reset-credits", "main", "--consume", "--yes"], deps); + expect(first.code).toBe(2); + expect(first.stderr).toContain("identity changed"); + expect(pendingId).toBeUndefined(); + + const second = await run(["reset-credits", "main", "--consume", "--yes"], deps); + expect(second.code).toBe(0); + expect(requested).toEqual([staleId, freshId]); + expect(pendingId).toBeUndefined(); + }); + + test("reset-credit identity change remains explicit when stale retry cleanup fails", async () => { + const result = await run( + ["reset-credits", "main", "--consume", "--yes"], + { + ...defaultDeps(), + isAgentDrivenImpl: () => false, + reserveResetCreditOperationImpl: () => "123e4567-e89b-42d3-a456-426614174003", + clearResetCreditOperationImpl: () => false, + requestResetCreditConsentImpl: async () => ({ + kind: "response", + response: json({ + error: "The Codex account identity changed. Confirm a new reset-credit request.", + code: "reset_credit_operation_identity_changed", + }, 409), + }), + }, + ); + expect(result.code).toBe(2); + expect(result.stderr).toContain("identity changed"); + expect(result.stderr).toContain("retry state could not be cleared"); + }); + + test("reset-credit history saturation is explicit and clears the rejected local intent", async () => { + let clearCalls = 0; + const result = await run( + ["reset-credits", "main", "--consume", "--yes"], + { + ...defaultDeps(), + isAgentDrivenImpl: () => false, + reserveResetCreditOperationImpl: () => "123e4567-e89b-42d3-a456-426614174004", + clearResetCreditOperationImpl: () => { + clearCalls += 1; + return true; + }, + requestResetCreditConsentImpl: async () => ({ + kind: "response", + response: json({ + error: "Reset-credit operation history is full.", + code: "reset_credit_operation_history_full", + }, 507), + }), + }, + ); + expect(result.code).toBe(2); + expect(result.stderr).toContain("operation history is full"); + expect(result.stderr).toContain("ask a maintainer"); + expect(clearCalls).toBe(1); + }); + + test("reset-credit terminal results remain authoritative when retry cleanup fails", async () => { + const terminalCodes = ["reset", "already_redeemed", "nothing_to_reset", "no_credit"] as const; + const cleanupFailures = [ + () => false, + () => { throw new Error("retry store unavailable"); }, + ]; + + for (const code of terminalCodes) { + for (const clearResetCreditOperationImpl of cleanupFailures) { + const result = await run( + ["reset-credits", "main", "--consume", "--yes", "--json"], + { + ...defaultDeps(), + isAgentDrivenImpl: () => false, + reserveResetCreditOperationImpl: () => "123e4567-e89b-42d3-a456-426614174002", + clearResetCreditOperationImpl, + requestResetCreditConsentImpl: async () => ({ + kind: "response", + response: json({ code }), + }), + }, + ); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ code }); + expect(result.stderr).toContain("terminal result is authoritative"); + expect(result.stderr).toContain("retry state could not be cleared"); + } + } + }); + + 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 () => { + let consentCalls = 0; + const result = await run( + ["reset-credits", "../bad", "--consume", "--yes"], + { + ...defaultDeps(), + isAgentDrivenImpl: () => false, + requestResetCreditConsentImpl: async () => { + consentCalls += 1; + return { + kind: "unavailable", + reason: "invalid-identity", + }; + }, + }, + ); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("Invalid account id format"); + expect(consentCalls).toBe(0); + }); + 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 1496f3bc9a..85912fa41e 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 { createHash, randomUUID } from "node:crypto"; import { reconcileLiveStateStores, setLiveStateStoreConfig, @@ -69,10 +70,33 @@ 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"; +import { + MAX_MANUAL_RESET_CREDIT_OPERATION_IDS, + openManualResetCreditOperation, + settleManualResetCreditOperation, +} from "../src/codex/reset-credit-operation-ledger"; 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() }); +} +function fixtureResetCreditOperationId(index: number): string { + return `123e4567-e89b-42d3-a456-${index.toString(16).padStart(12, "0")}`; +} +function handleResetCreditConsume( + req: Request, + config: OcxConfig, + principal: ManagementPrincipal | undefined = "gui-reset-credit-session", +): Promise { + return handleCodexAuthAPI(req, new URL(req.url), config, undefined, principal); +} let previousOpencodexHome: string | undefined; let previousCodexHome: string | undefined; let previousManualImportEnv: string | undefined; @@ -540,7 +564,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; @@ -548,9 +572,9 @@ 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()); + return handleResetCreditConsume(req, makeConfig()); }; const pending = request(); @@ -568,7 +592,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(); @@ -663,7 +687,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 }); @@ -674,12 +698,12 @@ 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); - expect(response?.headers.get("Retry-After")).toBe("1"); - expect(await response?.json()).toMatchObject({ code: "server_busy" }); + const response = await handleResetCreditConsume(req, config); + expect(response?.status).toBe(200); + expect(response?.headers.get("Retry-After")).toBeNull(); + expect(await response?.json()).toEqual({ code: "reset" }); } finally { cleanup(); } @@ -2094,7 +2118,7 @@ describe("codex-auth API", () => { expect(await resp?.json()).toEqual({ error: "Invalid upstream reset-credit response" }); }); - test("reset-credit lookup returns only validated fields from a bounded response", async () => { + test("reset-credit lookup returns only validated fields and the caller GUI capability", async () => { const config = makeConfig(); seedPoolAccount(config, { id: "credit-fields", email: "fields@example.test" }); globalThis.fetch = (async () => Response.json({ @@ -2113,6 +2137,22 @@ describe("codex-auth API", () => { expect(await resp?.json()).toEqual({ credits: [{ granted_at: "2026-01-01T00:00:00Z", expires_at: "2026-02-01T00:00:00Z" }], available_count: 1, + guiConsumeAllowed: false, + }); + + const guiReq = new Request("http://localhost/api/codex-auth/reset-credits?accountId=credit-fields"); + const guiResp = await handleCodexAuthAPI( + guiReq, + new URL(guiReq.url), + config, + undefined, + "gui-session", + ); + expect(guiResp?.status).toBe(200); + expect(await guiResp?.json()).toEqual({ + credits: [{ granted_at: "2026-01-01T00:00:00Z", expires_at: "2026-02-01T00:00:00Z" }], + available_count: 1, + guiConsumeAllowed: true, }); }); @@ -2122,12 +2162,453 @@ 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" }); }); - test("reset-credit consume returns remaining from refreshed quota, not the consume payload", async () => { + for (const principal of [undefined, "admin-token", "gui-session"] 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 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 reports immutable history saturation as non-retryable", async () => { + const config = makeConfig(); + const accountId = "pool-history-full"; + const chatgptAccountId = "acct-history-full"; + seedPoolAccount(config, { + id: accountId, + email: "history-full@example.test", + chatgptAccountId, + }); + const first = { + accountId, + chatgptAccountId, + operationId: fixtureResetCreditOperationId(10_000), + }; + expect(openManualResetCreditOperation(first, 100)).toMatchObject({ kind: "execute" }); + expect(settleManualResetCreditOperation(first, "no_credit", 200)).toEqual({ kind: "updated" }); + + const database = new Database(join(TEST_DIR, "config-mutation.sqlite")); + try { + const insert = database.prepare(` + INSERT INTO reset_credit_manual_operation_ids ( + operation_id, account_key, canonical_operation_id, + terminal_code, created_at, updated_at + ) VALUES (?, ?, ?, 'no_credit', 1, 1) + `); + database.exec("BEGIN IMMEDIATE"); + for (let index = 1; index < MAX_MANUAL_RESET_CREDIT_OPERATION_IDS; index += 1) { + const operationId = fixtureResetCreditOperationId(10_000 + index); + insert.run( + operationId, + createHash("sha256").update(`history-full-${index}`).digest("hex"), + operationId, + ); + } + database.exec("COMMIT"); + insert.finalize(); + } catch (error) { + try { database.exec("ROLLBACK"); } catch { /* preserve fixture failure */ } + throw error; + } finally { + database.close(); + } + + let consumeCalls = 0; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + globalThis.fetch = (async () => { + consumeCalls += 1; + throw new Error("consume must not run at history capacity"); + }) 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, + operationId: fixtureResetCreditOperationId(20_000), + }), + }); + const resp = await handleResetCreditConsume(req, config); + expect(resp?.status).toBe(507); + expect(resp?.headers.get("retry-after")).toBeNull(); + expect(await resp?.json()).toEqual({ + error: "Reset-credit operation history is full. Ask a maintainer to expand capacity or apply an approved retirement policy before confirming another request.", + code: "reset_credit_operation_history_full", + }); + expect(consumeCalls).toBe(0); + } finally { + warn.mockRestore(); + } + }); + + 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: resetCreditConsumeBody("pool-aborted"), + signal: controller.signal, + }); + const resp = await handleResetCreditConsume(req, 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 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 handleResetCreditConsume(req, 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" }); + + const third = await request(); + expect(third?.status).toBe(200); + expect(await third?.json()).toEqual({ code: "already_redeemed" }); + 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, + ); + if (consumeCalls === 1) throw new Error("response lost after dispatch"); + return Response.json({ code: "reset" }); + } + 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 handleResetCreditConsume(req, config); + }; + const firstId = randomUUID(); + const joinedId = randomUUID(); + expect((await call(firstId))?.status).toBe(502); + const joinedResponse = await call(joinedId); + expect(joinedResponse?.status).toBe(200); + expect(await joinedResponse?.json()).toEqual({ code: "reset" }); + const joinedRetry = await call(joinedId); + expect(joinedRetry?.status).toBe(200); + expect(await joinedRetry?.json()).toEqual({ code: "reset" }); + const originalRetry = await call(firstId); + expect(originalRetry?.status).toBe(200); + expect(await originalRetry?.json()).toEqual({ code: "reset" }); + expect(consumeCalls).toBe(2); + expect(seenOperationIds).toEqual([firstId, firstId]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("a stale retry id reports an identity change without dispatching for a replacement credential", async () => { + const config = makeConfig(); + seedPoolAccount(config, { + id: "pool-identity-change", + email: "identity@example.test", + chatgptAccountId: "chatgpt-before", + }); + const operationId = randomUUID(); + let consumeCalls = 0; + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input).includes("/backend-api/wham/rate-limit-reset-credits/consume")) { + consumeCalls += 1; + throw new Error("response lost after dispatch"); + } + return originalFetch(input); + }) as typeof fetch; + const call = 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-identity-change", operationId }), + }); + return await handleResetCreditConsume(req, config); + }; + + expect((await call())?.status).toBe(502); + saveCodexAccountCredential("pool-identity-change", { + accessToken: "replacement-access", + refreshToken: "replacement-refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "chatgpt-after", + }); + const conflict = await call(); + expect(conflict?.status).toBe(409); + expect(await conflict?.json()).toEqual({ + error: "The Codex account identity changed. Confirm a new reset-credit request.", + code: "reset_credit_operation_identity_changed", + }); + expect(consumeCalls).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 handleResetCreditConsume(req, 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(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); + } finally { + releaseConsume?.(); + globalThis.fetch = originalFetch; + } + }); + + test("a distinct concurrent retry id is recorded before the consume flight rejects it", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-flight-alias", email: "flight-alias@example.test" }); + const firstId = randomUUID(); + const joinedId = randomUUID(); + const nextId = randomUUID(); + const seenOperationIds: string[] = []; + 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")) { + const operationId = (JSON.parse(String(init?.body)) as { redeem_request_id: string }) + .redeem_request_id; + seenOperationIds.push(operationId); + if (seenOperationIds.length === 1) { + consumeStarted(); + await consumeReleased; + return Response.json({ code: "nothing_to_reset" }); + } + return Response.json({ code: "reset" }); + } + return originalFetch(input, init); + }) as typeof fetch; + const request = (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-flight-alias", operationId }), + }); + return handleResetCreditConsume(req, config); + }; + + const first = request(firstId); + await started; + const joinedBusy = await request(joinedId); + expect(joinedBusy?.status).toBe(503); + expect(await joinedBusy?.json()).toEqual({ error: "server_busy", code: "server_busy" }); + expect(seenOperationIds).toEqual([firstId]); + + releaseConsume(); + const firstResponse = await first; + expect(firstResponse?.status).toBe(200); + expect(await firstResponse?.json()).toEqual({ code: "nothing_to_reset" }); + const joinedRetry = await request(joinedId); + expect(joinedRetry?.status).toBe(200); + expect(await joinedRetry?.json()).toEqual({ code: "nothing_to_reset" }); + expect(seenOperationIds).toEqual([firstId]); + + const next = await request(nextId); + expect(next?.status).toBe(200); + expect(await next?.json()).toEqual({ code: "reset" }); + const staleJoinedRetry = await request(joinedId); + expect(staleJoinedRetry?.status).toBe(200); + expect(await staleJoinedRetry?.json()).toEqual({ code: "nothing_to_reset" }); + expect(seenOperationIds).toEqual([firstId, nextId]); + } finally { + releaseConsume?.(); + globalThis.fetch = originalFetch; + } + }); + + 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. @@ -2139,6 +2620,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 }); } @@ -2155,19 +2640,19 @@ 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); + 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); @@ -2190,11 +2675,11 @@ 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); + 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; @@ -2224,9 +2709,9 @@ 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); + 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. @@ -2256,9 +2741,9 @@ 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); + 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); @@ -2290,9 +2775,9 @@ 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()); + 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); @@ -2327,9 +2812,9 @@ 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()); + 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); @@ -2338,7 +2823,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" }, })); @@ -2365,12 +2850,12 @@ 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()); + 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/codex-reset-credit-consume.test.ts b/tests/codex-reset-credit-consume.test.ts new file mode 100644 index 0000000000..f5dc2f5e8a --- /dev/null +++ b/tests/codex-reset-credit-consume.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { BOUNDED_BODY_MAX_BYTES } from "../src/lib/bounded-body"; +import { + CodexResetCreditConsumeError, + consumeCodexResetCredit, +} from "../src/codex/reset-credit-consume"; +import { CODEX_RESET_CREDIT_CONSUME_CODES } from "../src/codex/reset-credit-recovery"; + +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 CODEX_RESET_CREDIT_CONSUME_CODES) { + 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": String(BOUNDED_BODY_MAX_BYTES + 1) } }), + })).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 () => { + const error = await consumeCodexResetCredit(input(), { + fetchImpl: async () => new Response("private upstream text", { status: 429 }), + }).catch((caught: unknown) => caught) as CodexResetCreditConsumeError; + expect(error).toEqual(expect.objectContaining({ + name: "CodexResetCreditConsumeError", + reason: "upstream", + upstreamStatus: 429, + })); + expect(error.message).not.toContain("private upstream text"); + }); +}); 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..4fb125842e --- /dev/null +++ b/tests/codex-reset-credit-operation-ledger.test.ts @@ -0,0 +1,791 @@ +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_MANUAL_RESET_CREDIT_OPERATION_IDS, + MAX_RESET_CREDIT_OPERATION_ACCOUNTS, + RESET_CREDIT_MANUAL_OPERATION_ID_SCHEMA_SQL_FOR_TESTS, + RESET_CREDIT_OPERATION_LEGACY_SCHEMA_SQL_FOR_TESTS, + RESET_CREDIT_OPERATION_PRIOR_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 { + CodexResetCreditRecoveryCoordinator, + resetCodexResetCreditRecoveryProcessStateForTests, + type CodexReservedOperationId, + 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'"); + } 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 { + 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_manual_operation_ids; + DROP TABLE IF EXISTS reset_credit_operations; + `); + } + finally { database.close(); } +}); + +afterEach(async () => { + await resetCodexResetCreditRecoveryProcessStateForTests(); +}); + +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<{ 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({ + 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("migrates the prior manual schema before persisting a joined retry id", () => { + const original = fixtureOperationId(690); + const joined = fixtureOperationId(691); + const physicalAccount = "chatgpt-prior-manual"; + const key = createHash("sha256") + .update(`codex-reset-credit-manual-physical\0${physicalAccount}`) + .digest("hex"); + const database = new Database(databasePath(), { create: true }); + try { + database.exec(RESET_CREDIT_OPERATION_PRIOR_SCHEMA_SQL_FOR_TESTS); + database.prepare(` + INSERT INTO reset_credit_operations VALUES ( + ?, 'manual', NULL, NULL, ?, 'ambiguous', NULL, 100, 200 + ) + `).run(key, original); + } finally { + database.close(); + } + + expect(openManualResetCreditOperation({ + accountId: "pool-prior-manual", + chatgptAccountId: physicalAccount, + operationId: joined, + }, 300)).toEqual({ kind: "execute", operationId: original, resumed: true }); + + 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_v2' + `).get()).toBeNull(); + expect(migrated.query<{ operation_id: string; joined_operation_id: string }, []>(` + SELECT operation_id, joined_operation_id FROM reset_credit_operations + `).get()).toEqual({ operation_id: original, joined_operation_id: joined }); + expect(migrated.query<{ + operation_id: string; + canonical_operation_id: string; + terminal_code: string | null; + }, []>(` + SELECT operation_id, canonical_operation_id, terminal_code + FROM reset_credit_manual_operation_ids + ORDER BY operation_id + `).all()).toEqual([ + { operation_id: original, canonical_operation_id: original, terminal_code: null }, + { operation_id: joined, canonical_operation_id: original, terminal_code: null }, + ]); + } 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("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("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("fails closed when a manual id loses its canonical history mapping", () => { + const identity = { + accountId: "pool-manual-history-corrupt", + chatgptAccountId: "chatgpt-manual-history-corrupt", + operationId: fixtureOperationId(710), + }; + expect(openManualResetCreditOperation(identity, 100)).toMatchObject({ kind: "execute" }); + const missingCanonical = fixtureOperationId(711); + const database = new Database(databasePath()); + try { + database.prepare(` + UPDATE reset_credit_manual_operation_ids + SET canonical_operation_id = ? + WHERE operation_id = ? + `).run(missingCanonical, identity.operationId); + } finally { + database.close(); + } + + expect(openManualResetCreditOperation(identity, 200)).toEqual({ kind: "unavailable" }); + const verifier = new Database(databasePath(), { readonly: true }); + try { + expect(verifier.query<{ canonical_operation_id: string }, [string]>(` + SELECT canonical_operation_id + FROM reset_credit_manual_operation_ids + WHERE operation_id = ? + `).get(identity.operationId)?.canonical_operation_id).toBe(missingCanonical); + } finally { + verifier.close(); + } + }); + + test("manual operations preserve every joined caller id across later terminal intents", () => { + const first = { + accountId: "pool-manual-fence", + chatgptAccountId: "chatgpt-a", + operationId: fixtureOperationId(701), + }; + const joined = { ...first, operationId: fixtureOperationId(702) }; + expect(openManualResetCreditOperation(first, 100)).toMatchObject({ kind: "execute" }); + expect(openManualResetCreditOperation(joined, 200)) + .toEqual({ kind: "execute", operationId: first.operationId, resumed: true }); + const secondJoined = { + ...first, + accountId: "pool-manual-alias", + operationId: fixtureOperationId(703), + }; + expect(openManualResetCreditOperation(secondJoined, 300)) + .toEqual({ kind: "execute", operationId: first.operationId, resumed: true }); + // A third alias advanced durable time to 300. Settlement remains valid if + // the wall clock then moves backwards. + expect(settleManualResetCreditOperation(first, "reset", 250)).toEqual({ kind: "updated" }); + expect(openManualResetCreditOperation(first, 360)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "reset", + }); + expect(openManualResetCreditOperation(joined, 370)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "reset", + }); + expect(openManualResetCreditOperation(secondJoined, 375)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "reset", + }); + const next = { ...first, operationId: fixtureOperationId(709) }; + expect(openManualResetCreditOperation(next, 380)).toEqual({ + kind: "execute", + operationId: next.operationId, + resumed: false, + }); + expect(settleManualResetCreditOperation(next, "no_credit", 390)).toEqual({ kind: "updated" }); + expect(openManualResetCreditOperation(joined, 395)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "reset", + }); + expect(openManualResetCreditOperation(secondJoined, 396)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "reset", + }); + 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: "identity-mismatch" }); + 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 }); + 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); + expect(database.query<{ sql: string }, []>(` + SELECT sql FROM main.sqlite_schema + WHERE type = 'table' AND name = 'reset_credit_manual_operation_ids' + `).get()?.sql).toBe(RESET_CREDIT_MANUAL_OPERATION_ID_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 }); + 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.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"); + }); + + 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("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" }); + 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 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, operation_kind, credential_generation, exhaustion_generation, operation_id, + state, code, created_at, updated_at + ) VALUES (?, 'recovery', ?, ?, ?, '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"); + 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()); + let transactionOpen = false; + try { + holder.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + transactionOpen = true; + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-b" })) + .toEqual({ kind: "unavailable" }); + } finally { + try { + if (transactionOpen) holder.exec("ROLLBACK"); + } finally { + 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("keeps terminal manual ids immutable and fails closed when identity history is full", () => { + const first = { + accountId: "pool-manual-history-cap", + chatgptAccountId: "chatgpt-manual-history-cap", + operationId: fixtureOperationId(9000), + }; + expect(openManualResetCreditOperation(first, 100)).toMatchObject({ kind: "execute" }); + expect(settleManualResetCreditOperation(first, "reset", 200)).toEqual({ kind: "updated" }); + + const database = new Database(databasePath()); + try { + const insert = database.prepare(` + INSERT INTO reset_credit_manual_operation_ids ( + operation_id, account_key, canonical_operation_id, + terminal_code, created_at, updated_at + ) VALUES (?, ?, ?, 'no_credit', 1, 1) + `); + database.exec("BEGIN IMMEDIATE"); + for (let index = 1; index < MAX_MANUAL_RESET_CREDIT_OPERATION_IDS; index += 1) { + const operationId = fixtureOperationId(9000 + index); + const key = createHash("sha256") + .update(`manual-history-cap-${index}`) + .digest("hex"); + insert.run(operationId, key, operationId); + } + database.exec("COMMIT"); + } catch (error) { + try { database.exec("ROLLBACK"); } catch { /* preserve fixture error */ } + throw error; + } finally { + database.close(); + } + + expect(openManualResetCreditOperation(first, 300)).toEqual({ + kind: "terminal", + operationId: first.operationId, + code: "reset", + }); + expect(openManualResetCreditOperation({ + ...first, + operationId: fixtureOperationId(15000), + }, 400)).toEqual({ kind: "capacity" }); + const verifier = new Database(databasePath(), { readonly: true }); + try { + expect(verifier.query<{ count: number }, []>( + "SELECT COUNT(*) AS count FROM reset_credit_manual_operation_ids", + ).get()?.count).toBe(MAX_MANUAL_RESET_CREDIT_OPERATION_IDS); + } finally { + verifier.close(); + } + }); + + 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, operation_kind, credential_generation, exhaustion_generation, operation_id, + state, code, created_at, updated_at + ) 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}`; + const key = createHash("sha256") + .update(`codex-reset-credit-operation\0${accountId}`) + .digest("hex"); + const operationId = fixtureOperationId(index); + 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, operation_kind, credential_generation, exhaustion_generation, operation_id, + state, code, created_at, updated_at + ) VALUES (?, 'recovery', ?, ?, ?, 'pending', NULL, 1, 1)`) + .run( + key, + GENERATION.credentialGeneration, + GENERATION.exhaustionGeneration, + // 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(); + } + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-0" })) + .toEqual({ kind: "unavailable" }); + expect(openResetCreditOperation({ ...GENERATION, accountId: "pool-new" })) + .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..1f34ddbab2 --- /dev/null +++ b/tests/reset-credit-consent-client.test.ts @@ -0,0 +1,191 @@ +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, + capabilityVersion = CODEX_RESET_CREDIT_CONSENT_CAPABILITY_VERSION, +): 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: capabilityVersion, + }, { + 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("rejects a proven listener with a stale consent capability version", async () => { + const requests: string[] = []; + const result = await requestBoundCodexResetCreditConsent(accountId, operationId, { + findLive: async () => target, + readRuntime: () => ({ ...target, attestationSecret: secret }), + createNonce: () => nonce, + fetchImpl: async (input, init) => { + requests.push(String(input)); + return proofResponse(init, "v0"); + }, + }); + 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("allows the consent POST more time than the server consume budget", async () => { + const timeoutCalls: number[] = []; + const ioTimeoutCalls: number[] = []; + const originalTimeout = AbortSignal.timeout; + AbortSignal.timeout = ((ms: number) => { + const signal = originalTimeout(ms); + timeoutCalls.push(ms); + return signal; + }) as typeof AbortSignal.timeout; + try { + let calls = 0; + const result = await requestBoundCodexResetCreditConsent(accountId, operationId, { + findLive: async () => target, + readRuntime: () => ({ ...target, attestationSecret: secret }), + createNonce: () => nonce, + directLocalHttpFetchImpl: async (_input, init, io) => { + calls += 1; + ioTimeoutCalls.push(io.timeoutMs ?? -1); + return calls === 1 ? proofResponse(init) : Response.json({ code: "reset" }); + }, + }); + expect(result.kind).toBe("response"); + } finally { + AbortSignal.timeout = originalTimeout; + } + expect(timeoutCalls).toEqual([10_000, 15_000]); + expect(ioTimeoutCalls).toEqual([10_000, 15_000]); + }); + + 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/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(); + } +}); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 4bc5ac0b0f..72b3c56bf2 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -44,6 +44,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"; import { watchdogMs } from "./helpers/ci-watchdog"; const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; @@ -819,6 +820,7 @@ describe("server local API auth", () => { "pid", "port", "providerReloadCapability", + "resetCreditConsentCapability", "restartCapability", "service", "status", @@ -827,6 +829,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..f88186f521 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -5,10 +5,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; +import { requestBoundCodexResetCreditConsent } from "../src/cli/reset-credit-consent-client"; import type { OcxConfig } from "../src/types"; import { serveGuiFile, serveSessionBootstrap } from "../src/server/gui-static"; import { isProxyAdmissionSecret } from "../src/server/auth-cors"; import { + CODEX_RESET_CREDIT_GUI_OWNER_TOKEN_HEADER, initializeManagementAuthState, issueGuiSession, managementPrincipal, @@ -58,6 +60,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; @@ -372,7 +387,6 @@ describe("management and data-plane credential separation", () => { expiresAt, )!, }; - const request = new Request(`http://127.0.0.1:${local.port}${LOCAL_PROVIDER_RELOAD_PATH}`, { method: LOCAL_PROVIDER_RELOAD_METHOD, headers, @@ -467,6 +481,200 @@ 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 freshHeaders = (freshNonce: string) => ({ + ...headers, + [CODEX_RESET_CREDIT_CONSENT_NONCE_HEADER]: freshNonce, + [CODEX_RESET_CREDIT_CONSENT_CAPABILITY_HEADER]: createCodexResetCreditConsentCapability( + secret, + freshNonce, + 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: { + ...freshHeaders("M".repeat(43)), + [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: { + ...freshHeaders("N".repeat(43)), + [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: freshHeaders("O".repeat(43)), + }); + expect(requireManagementAuth(query, unavailable, remoteConfig(), local)?.status).toBe(503); + const body = new Request(request.url, { + method: CODEX_RESET_CREDIT_CONSENT_METHOD, + headers: { ...freshHeaders("P".repeat(43)), "content-length": "2" }, + body: "{}", + }); + expect(requireManagementAuth(body, unavailable, remoteConfig(), local)?.status).toBe(503); + const chunkedHeaders = new Headers(freshHeaders("Q".repeat(43))); + chunkedHeaders.delete("content-length"); + chunkedHeaders.set("transfer-encoding", "chunked"); + const chunked = new Request(request.url, { + method: CODEX_RESET_CREDIT_CONSENT_METHOD, + headers: chunkedHeaders, + }); + expect(requireManagementAuth(chunked, unavailable, remoteConfig(), local)?.status).toBe(503); + }); + + test("the CLI consent client reaches the live bodyless consume route through management auth", async () => { + const secret = "R".repeat(43); + const server = startServer(0, { localAttestationSecret: secret }); + const target = { + pid: process.pid, + port: server.port, + hostname: "127.0.0.1", + source: "runtime" as const, + }; + try { + const result = await requestBoundCodexResetCreditConsent( + "pool-live-consent", + "123e4567-e89b-42d3-a456-426614174000", + { + findLive: async () => target, + readRuntime: () => ({ ...target, attestationSecret: secret }), + }, + ); + + expect(result.kind).toBe("response"); + if (result.kind !== "response") throw new Error(`unexpected ${result.reason}`); + expect(result.response.status).toBe(404); + expect(await result.response.json()).toEqual({ error: "Unknown Codex account" }); + } finally { + await server.stop(true); + } + }, SERVER_BUDGET_MS); + + 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 +1048,102 @@ describe("management and data-plane credential separation", () => { } }); + test("live server requires GUI-session and owner-token proof for reset-credit consume", 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", + [CODEX_RESET_CREDIT_GUI_OWNER_TOKEN_HEADER]: "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(403); + expect(await guiResponse.json()).toMatchObject({ code: "agent_consent_required" }); + + const wrongOwnerResponse = 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 ?? "", + [CODEX_RESET_CREDIT_GUI_OWNER_TOKEN_HEADER]: "not-the-admin-secret", + }, + body: "{}", + }, + ); + expect(wrongOwnerResponse.status).toBe(403); + expect(await wrongOwnerResponse.json()).toEqual({ + error: "User consent is required to consume a reset credit", + code: "agent_consent_required", + }); + + const ownerProvedResponse = 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 ?? "", + [CODEX_RESET_CREDIT_GUI_OWNER_TOKEN_HEADER]: "admin-secret", + }, + body: "{}", + }, + ); + expect(ownerProvedResponse.status).toBe(400); + expect(await ownerProvedResponse.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);