From b3b21a0bea9286a462fda893a6347dff9de46e3b Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 08:11:53 +0900 Subject: [PATCH 01/11] docs: plan main-account quota protection and reserve investigation --- .../_plan/260905_main_quota_guard/000_plan.md | 56 ++++++++++++++++ .../001_source_findings.md | 36 ++++++++++ .../002_audit_synthesis.md | 11 +++ .../260905_main_quota_guard/010_policy.md | 67 +++++++++++++++++++ .../260905_main_quota_guard/020_settings.md | 53 +++++++++++++++ .../260905_main_quota_guard/030_delivery.md | 22 ++++++ 6 files changed, 245 insertions(+) create mode 100644 devlog/_plan/260905_main_quota_guard/000_plan.md create mode 100644 devlog/_plan/260905_main_quota_guard/001_source_findings.md create mode 100644 devlog/_plan/260905_main_quota_guard/002_audit_synthesis.md create mode 100644 devlog/_plan/260905_main_quota_guard/010_policy.md create mode 100644 devlog/_plan/260905_main_quota_guard/020_settings.md create mode 100644 devlog/_plan/260905_main_quota_guard/030_delivery.md diff --git a/devlog/_plan/260905_main_quota_guard/000_plan.md b/devlog/_plan/260905_main_quota_guard/000_plan.md new file mode 100644 index 0000000000..91d8fa6e46 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/000_plan.md @@ -0,0 +1,56 @@ +# Main-account quota protection and Reserve compatibility + +## Loop specification + +- Archetype: spec-satisfaction repair; HOTL, bound checkout 8841. +- Trigger: owner requests a 99% main-account hard-lock switch beside Ultra Fast and an investigation/patch for using other models alongside Luna reserve. +- Goal: stop new main-account admissions at observed 99% usage while keeping unrelated routes available, with explicit opt-in consequences and truthful Reserve compatibility. +- Non-goals: upstream entitlement bypass, invented ordinary-usage recovery, quota reset redemption, modifying live port 10100, releases, replacing Codex binaries, or editing the reference corpus. +- Verifier: exact-head GitHub CI, TypeScript check, existing GUI build/lint, isolated browser interaction and screenshots. No local test suites, including focused suites or test:changed. User explicitly authorizes no-verify pushes and admin merges after green CI/review. +- Stop: every registered criterion evidenced and stack merged bottom-up; otherwise report actual missing external authority, not completion. +- Memory: this unit plus the session-bound goalplan/ledger. +- Resources: existing local tools and GitHub credentials; no purchases; 4-hour reassessment checkpoint. Model and effort inherited for all lanes. Main reclaims a packet after two distinct worker failures; delegation changes are P amendments. +- Terminal outcomes: DONE, NOOP with evidence, BLOCKED/NEEDS_HUMAN for a real external prerequisite, UNSAFE for an entitlement bypass, BUDGET_EXHAUSTED only at the stated bound. + +## Baseline and ownership + +Base `d6b457462` matches fetched `origin/dev`. Checkout began clean/detached and was adopted in place as `codex/main-account-99-hard-lock`. +Installed locked root/GUI dependencies without changing manifests. Initial typecheck lacked bun-types; after frozen install, `bun run typecheck` exited 0. No tests executed. + +```text +src/types/config.ts + src/config.ts persisted opt-in +src/codex/quota.ts + auth-api.ts observed quota and physical identity +src/codex/account-usability.ts Pool exclusion +src/codex/auth-context.ts final native-main admission +src/server/management/config-routes.ts settings transaction/DTO +gui/src/pages/codex-set-multiauth.tsx existing advanced settings placement +gui/src/components/ switch/dialog/main-card status +tests/codex-integration/ + tests/config/ CI-only behavioral regression +``` + +Reuse existing config mutation/rollback, quota parsing, account identity reconciliation, native dialogs and UI tokens. Do not add a framework, second settings API, or credential store. + +## Dependency-ordered work phases + +1. wp0: source-grounded docs-only roadmap and independent audit; lock before production edits. +2. wp1 / `010_policy.md`: main quota protection contracts, admission and management, with regression coverage. Bottom PR targets dev and works without the UI layer. +3. wp2 / `020_settings.md`: switch, confirmation, main-card state and supported Reserve compatibility documentation; depends on the policy contract. Upper PR targets the bottom branch. +4. wp3 / `030_delivery.md`: exact-head review/CI and bottom-up authorized admin merge, followed by fetched ancestry and closure evidence. + +The Reserve client gate is a separate feasibility decision, not permission to misrepresent server state. If source establishes a safe OCX-only compatibility patch, concretize it as a P amendment before writing. If it requires modifying the installed Desktop client or publishing to an unspecified upstream repository, record the boundary and ask for that specific decision after completing in-scope work; do not claim same-picker coexistence. + +## Acceptance + +- Off/absent flag preserves current routing. Enabled flag blocks observed main usage >=99 across relevant non-expired windows, including short-only observations. Unknown data is not invented as 0 or 100. +- Main exclusion cannot prevent usage refresh or profile recovery. Explicit main and Direct paths cannot evade a measured block; unrelated caller credentials cannot inherit main's quota. +- Observations are identity-bound; account changes and restart cannot attach another account's cached reading. Window expiry and a fresh lower observation release only this policy, not pause/cooldown/reauth. +- UI distinguishes enabled from currently blocked. Cancel/Escape do not save; save errors preserve actual server state; success requires explicit acknowledgment. Main status remains visible outside Advanced. +- Do not claim 1% is reserved: parallel/in-flight/direct-to-upstream use can reach 100 before observation. Luna reserve cannot be used while this policy blocks the main account. +- Keep server Reserve grants and `ordinary_usage_allowed` unchanged. +- Every merge requires reviewed exact-head CI and an origin/dev ancestor check. + +## Continuity + +wp0 roadmap build: independent audit PASS after three accepted amendments (outbound guard reachability, six-hour durability, effective workspace matching). Baseline root typecheck, GUI build and GUI i18n lint passed. No production edits or local suites. + +Next wp1 P: reread 010 against current tree, name exact worker API boundaries, include the discovered Direct sidecar header path in `src/providers/openai-sidecar.ts`, then independently audit before building. Reserve Desktop investigation remains read-only and may add a later bounded compatibility cycle if evidence supports it. diff --git a/devlog/_plan/260905_main_quota_guard/001_source_findings.md b/devlog/_plan/260905_main_quota_guard/001_source_findings.md new file mode 100644 index 0000000000..84493bfd4a --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/001_source_findings.md @@ -0,0 +1,36 @@ +# Source findings + +## Why the existing threshold is insufficient + +`src/codex/auth-context.ts:67` reads `autoSwitchThreshold` for request-owned main pins. It is a selection preference, not a refusal. `src/codex/routing.ts` permits terminal main fallback and scores unknown usage as 101; it cannot be reused as a 99% predicate. A short-only 99% observation still scores unknown. + +`src/codex/native-main-admission.ts` covers credential and management claims, not only billable work. Blocking it would prevent the quota refresh needed to recover. + +`src/codex/account-lifecycle.ts:63` reconciles the stable `__main__` alias with physical identity. `src/codex/auth-api.ts:866` already owns identity-checked WHAM reads. Request-owned native bearers must not introduce new physical-main reads. + +## Settings/UI + +`gui/src/pages/codex-set-multiauth.tsx:197` places account picker, request-user-input and Ultra Fast under `advancedExtras`. `src/server/management/config-routes.ts:383` owns partial PUT validation, persistence and rollback. The new setting must return its confirmed value; do not inherit Ultra Fast's missing PUT acknowledgment fallback. + +`gui/src/components/codex-account-pool-main-card.tsx` owns persistent main status, `gui/src/hooks/useCodexAccountPool.ts` owns its DTO, and native `` patterns already exist in `codex-account-switch-modal.tsx`. + +## Reference Codex TUI, inspected 2026-09-05 + +Reference prefix: local `121_openai-codex/codex-rs/tui/src/chatwidget/` beneath the user's Codex research corpus; not this repository's runtime. + +- `backend_banners.rs:61`: picker restriction is current model `gpt-reserve` plus missing ordinary-usage recovery, not a numeric 100% comparison. +- `model_popups.rs:82,199`: both picker entry points replace all catalog choices with the Reserve-only picker. +- `backend_banners.rs:307`: recovery requires a full identity-validated backend response with `ordinary_usage_allowed` and no remaining blocking state. +- Consequently, catalog injection alone cannot fix that TUI restriction. Exposing other native models by falsifying recovery would misrepresent upstream authorization. +- Desktop behavior still requires separate source evidence; TUI evidence is not Desktop proof. + +## Reserve follow-up + +`src/codex/inject.ts:191,319` already supports the explicit `codexDesktopAuthless` loopback mode, which uses a custom provider with `requires_openai_auth=false`. Reference app-server `model-provider/src/provider.rs:401` then reports no native account requirement. Whether Desktop's Reserve picker follows this state remains unverified. +`src/router.ts:633` accepts an explicitly configured `main/gpt-reserve` namespace, but routing acceptance is not Reserve entitlement. Static native listing omits Reserve, and unknown account-native discovery currently requires supported_in_api=true, unlike the existing Reserve-shaped test fixture. No live Reserve inference or Desktop coexistence has been proven. Do not synthesize availability or change upstream recovery flags; validate this seam before planning any compatibility implementation. + +## Necessity and limits + +Do nothing/configure-only fails because the existing threshold can return to main. Reuse the existing eligibility and native-auth resolution owners. A small policy leaf is justified to share raw-window/identity logic between routing and the status DTO without importing management or Lab into core paths. + +The feature is local request admission, not a reservation of the remaining quota. Known bypass: requests already admitted or sent directly to OpenAI. Final upstream authority remains OpenAI; no client code can promise that a displayed 99 never advances to 100. diff --git a/devlog/_plan/260905_main_quota_guard/002_audit_synthesis.md b/devlog/_plan/260905_main_quota_guard/002_audit_synthesis.md new file mode 100644 index 0000000000..3284d5e1c4 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/002_audit_synthesis.md @@ -0,0 +1,11 @@ +# Roadmap audit synthesis + +Independent reviewer Kuhn returned FAIL with three high blockers. Accepted all three; no rebuttal and no production edits. + +1. Outbound enforcement: the plan described intent but the actual materialization APIs lacked config. Added explicit options.config threading through core/compact, post-await recheck and race tests. The old unused assertion is explicitly insufficient. Follow-up source search also found legacy `headersForCodexAuthContext` paths in core/compact/ws-bridge; implementation must carry the same config or a live policy closure there rather than treating them as harmless wrappers. +2. Durable evidence: the old cache expires after six hours, contradicting missing-reset retention. Added independent identity-tagged `mainPolicyQuota` envelope member, retained across rotation TTL and unrelated persistence, with one shared partial merge rule and restart tests. +3. Workspace identity: credential equality alone does not imply the selected workspace matches. Added both-token-and-selected-identity matching, conflicting-header exclusion, zero-new-auth-read tests and explicit unmatched-keyring limitation. + +Cross-blocker consistency: final materialization reads the same current policy/status getter; its retained evidence is identity-bound and never recovered by trusting an unsigned caller claim. Maintenance reads remain allowed. Legacy routing reads retain their original semantics. + +Baseline checks actually observed by main: root typecheck exit0; GUI build exit0 (existing large-chunk advisory); GUI lint:i18n exit0. No local suites. Source-level findings refer to base d6b457462. diff --git a/devlog/_plan/260905_main_quota_guard/010_policy.md b/devlog/_plan/260905_main_quota_guard/010_policy.md new file mode 100644 index 0000000000..9a0102750b --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/010_policy.md @@ -0,0 +1,67 @@ +# Identity-bound main-account 99% policy + +Depends on wp0. C4 care for quota/credential boundary; existing authentication and upstream grants remain authoritative. + +## Contract and complete field chain + +NEW config `codexMainAccountHardLock?: boolean` in `src/types/config.ts`; `src/config.ts` parses optional boolean with malformed input treated as off. Persist through existing `saveConfigPreservingClaudeCode`; GET and PUT `/api/settings` return `codexMainAccountHardLock: config.codexMainAccountHardLock === true`. PUT rejects nonboolean input, captures presence/value, deletes when false, and restores exactly on save failure. Creation: settings PUT/hand-edited JSON; serialization: existing atomic config writer; deserialization: Zod loader; consumers: policy helper, account usability, native auth resolution, settings/main DTO, GUI in wp2. No new endpoint. + +NEW `src/codex/main-account-hard-lock.ts`: shared policy leaf with named threshold 99 and status `{enabled:boolean, state:'off'|'unknown'|'ready'|'blocked', resetAt?:number}`. Read identity-bound quota only; never auth files, management imports or Lab. Check finite raw weekly/monthly/short values under the parser's plan-window contract. Ignore a window whose valid seconds-or-milliseconds reset is in the past. A finite >=99 observation with no reset remains blocked until a fresh observation lowers it; unknown/nonfinite data does not create a block. Additional model-specific windows do not become a global-main quota. No time-based expiry that silently admits still-exhausted main. + +Status chain: creation in policy helper -> main DTO `mainAccountHardLock` in `auth-api.ts` and settings GET/PUT -> ordinary JSON -> optional typed `CodexAccountEntry.mainAccountHardLock` in wp2 -> main badge and setting description. Never include raw account IDs, credential fingerprints or secrets in status DTOs. + +## Provenance, before/after + +Existing `StoredAccountQuota` and its public DTO spreads remain unchanged. Extend the version-1 private quota disk envelope with optional main identity ownership, NOT each public quota object. Existing untagged snapshots remain usable by legacy rotation, but never by the new hard-lock getter. Avoid broad quota-scoring behavior changes in this feature. + +MODIFY `src/codex/main-account-cache.ts`: add memory-only observed physical-main identity and credential equality observation, derived exclusively from token material already read under native ownership. Credential comparison uses a process-local keyed equality tag, never raw token retention/logging/persistence. Reuse the existing identity generation. Publish identity observation during existing `reconcileMainCodexAccountRuntimeState` read and confirmed native transition in `account-lifecycle.ts`; credential equality observation is captured only at existing owned token materialization/WHAM reads. No request-owned path reads the physical credential. + +MODIFY `src/codex/quota.ts`: +```diff +-setAccountQuotaFromParsed(accountId, quota, writerGeneration) ++setAccountQuotaFromParsed(accountId, quota, writerGeneration, mainWriter?) +-applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration) ++applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration, mainWriter?) +``` +`mainWriter` captures physical identity key and identity generation BEFORE the asynchronous request. For main, reject stale explicitly tagged writers; only merge ownership-matching main windows. Untagged writes cannot create or preserve policy trust. Hydrate before comparing/persisting ownership. Persist owner alongside quota. A new identity cannot bless old untagged windows through credits-only updates. New identity-bound getter returns null on missing/mismatched provenance without credential I/O. Pool writes/readers remain unchanged. + +Durability is independent of the legacy six-hour rotation-cache TTL: use an optional envelope member `mainPolicyQuota: {identityKey:string, quota:StoredAccountQuota}` retained separately from the ordinary `quotas` map. Hydrate its bounded known window fields without the six-hour age discard. Retain it on unrelated persistence even if legacy main dropped from `quotas`; only an identity-matched new observation, explicit quota clear/identity transition, or untrusted main write can replace/invalidate it. Its getter still requires current observed identity equality. Reuse the existing window merge rule as one pure merger if necessary rather than maintaining two divergent partial-update algorithms. Do not carry untagged legacy values into `mainPolicyQuota`. Never persist credential equality tags. Future reset and missing-reset 99% observations remain protected across a restart beyond six hours; passed reset timestamps are ignored per-window by the policy predicate. + +MODIFY `auth-api.ts` successful main WHAM path: derive writer from already-read `requestAccountId` and credential, capture before fetch, pass after existing identity revalidation. Its main DTO consumes the same status helper. Keep refresh/reauth semantics unchanged. +MODIFY `auth-context.ts` main-pool variant: carry captured `mainQuotaWriter` as internal request state; capture before async refresh/materialization, validate returned identity; never serialize it publicly. Stored Direct substitution must check policy after its existing identity-owned operation. No independent auth read is introduced. +MODIFY `src/server/responses/core.ts` existing writers at first quota rejection and ordinary upstream response, plus `src/server/responses/compact.ts` alternate-response writer: pass the captured main writer without recreating it from mutable global state. + +## Routing and refusal + +MODIFY `account-usability.ts`: before a physical main is selected, return false if the identity-bound policy blocks. This preserves ordinary alternate-account selection and prevents pins/fallback scores admitting blocked main. Do not add this to native-main-admission: maintenance must remain possible. + +MODIFY `auth-context.ts`: enforce at every selected-main exit and immediately before credential use, including fixed selector, stored Direct substitution and request-owned main pin/fallback when the supplied bearer is positively matched to an already observed native credential. Unrelated/opaque/unmatched caller-owned credentials must not inherit physical-main quota; document that observation boundary. An unsigned account-id claim alone is not credential equality proof. + +Use an actionable policy error that existing HTTP/WebSocket/sidecar error mapping preserves. Prefer a dedicated `CodexMainAccountHardLockError` compatible with the current cooldown hierarchy, with explicit main-policy message rather than falsely telling the user to clear upstream cooldown. It must not mint a recovery probe, auto-redeem a credit, mark reauth, or change paused state. Verify all catch sites via `CodexAccountCooldownError` search; special-case formatting once in its canonical formatter. Do not use the unused `assertCodexAuthContextNotCooled` as the only enforcement call site. + +Actual final materialization seam: extend the existing options of `materializeCodexUpstreamAuth` and `materializeCodexUpstreamAuthAsync` with optional `config?: Pick`; supply the shared live config at all production calls in `src/server/responses/core.ts` and `src/server/responses/compact.ts`. Check after stored identity observation and immediately before returning the selected headers, including refresh replays. Legacy public callers that omit config retain compatibility; normal production call sites must never omit it. Sidecar resolvers already call `resolveCodexAuthContext` and are covered there; inspect whether they capture credentials across an await and require an additional pre-send guard. + +Caller-owned positive matching requires both the process-local credential equality tag and the selected account identity to match the already observed native credential. A conflicting `chatgpt-account-id` header must not be treated as matching even with the same bearer. No unsigned JWT/header by itself creates a policy identity or writes a quota owner. + +The equality observation tuple is `{bearerHmac,effectiveUpstreamAccountId,identityGeneration}`. WHAM provenance describes the actual account header sent with the owned token, not a conflicting JWT-derived account ID. If owned identity derivation and effective sent identity disagree or are unknown, do not publish hard-lock provenance; never fix this by trusting arbitrary incoming headers. The retained legacy rotation getter is intentionally out of scope: the no-cross-account guarantee here applies to the NEW hard-lock policy, not a claim to have redesigned all prior rotation state. + +## Bypass statement + +Tier: runtime local admission; executing surface: authenticated native forwarding and account selection. Known bypass: already admitted requests, direct upstream traffic, and an unmatched caller-owned credential that cannot be proved to belong to stored main without violating isolation. Residual: observed 99% does not reserve the remaining 1%. Wording: hard-lock of newly admitted identity-matched main requests, not an account-wide reservation. Final upstream enforcement: OpenAI remains authoritative; this patch does not claim to change its allowance or Reserve grants. + +## Regression matrix (CI only) + +Extend existing `tests/codex-integration/codex-auth-context.test.ts`, quota/parser or account lifecycle tests and an existing settings route test where practical; if a new focused file is clearer, register it in both layout manifests. + +- Off/absent/invalid flag; 98.99 vs exactly99 vs100; short-only99; unknown/NaN; monthly-plan windows; custom-only window; seconds/ms expired reset; missing reset. +- A99 restart/B does not block B; same-A restart only trusts tagged ownership after observed identity; legacy untagged data is unknown; stale A writer after A->B->A rejected; partial/credits-only cannot cross provenance. +- Restart after six hours with same account and missing/future reset retains the block; unrelated pool persistence cannot delete retained policy evidence. Quota/config changing between selection and materialization is rechecked before a send. Same bearer with a different explicit workspace header is not classified as stored main. +- Eligible pool alternative continues; all-blocked/exact-main/Direct fail before upstream send; a same-token request-owned main is protected with zero physical reads; unrelated/spoofed bearer is unaffected and cannot taint policy provenance. +- Fresh lower quota/reset releases only policy; pause/cooldown/reauth survives toggle changes; quota refresh remains admissible. +- Settings GET/PUT acknowledgment, type rejection, false deletion, persistence rollback and no unrelated config loss. +- main DTO exposes status but never identity/equality tags. +- Existing core-Lab boundary remains intact. + +## Verification + +`bun run typecheck` already ran baseline exit0 after locked dependency install; tsconfig includes src. No local suites are authorized. Use exact-head CI for the above behavioral matrix and full regression suite. Main may use an isolated non-test-runner runtime scenario to inspect actual threshold activation only if it is not a disguised suite. Persist static/CI outputs in the unit evidence record before D. Update `structure/08_openai-provider-tiers.md` with the admission and caller-isolation contract. diff --git a/devlog/_plan/260905_main_quota_guard/020_settings.md b/devlog/_plan/260905_main_quota_guard/020_settings.md new file mode 100644 index 0000000000..ae2663a687 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/020_settings.md @@ -0,0 +1,53 @@ +# Codex settings and Reserve consequences + +Depends on wp1 (`codexMainAccountHardLock` and main hard-lock status contract). + +## Design Read + +```yaml +name: OpenCodex main-account quota protection +colors: + primary: '#0d0d0d' + accent: '#9a4a08' + background: '#ffffff' +typography: + heading: { fontFamily: var(--font-ui), fontSize: var(--text-body) } + body: { fontFamily: var(--font-ui), fontSize: var(--text-body) } +iconography: + system: existing gui/src/icons.tsx + weight: existing + domain: library-subset +``` + +Reading this as an existing developer-console settings surface, not a redesign. Reuse the current monochrome light/dark tokens, compact cards, switches and native dialogs. Amber describes a current policy block, not the mere existence of the opt-in. +Do: keep toggle peer-level with Ultra Fast, short primary label, consequence text before save, persistent main-card status. +Do not: add illustrations, a new theme, a threshold editor, a wizard, quota promises, or emoji. +DESIGN_VARIANCE 2; MOTION_INTENSITY 1; density D8. Repeated expert use needs stable controls, not expressive composition. Utility dashboard is exempt from image concept generation. + +## File delta + +NEW `gui/src/components/MainAccountHardLockSetting.tsx`: reuse bounded fetch and visible polling. Server state is boolean plus policy status; local state is dialog/saving/error. Load disables interaction until known. Clicking an off toggle opens confirmation WITHOUT mutation. Confirm PUTs `{codexMainAccountHardLock:true}` and accepts only `ok:true` plus explicit boolean acknowledgment. Disable saves false without an enable warning. Invalidate old GET generations on every mutation; stale GETs cannot revert success. Native dialog traps focus, Escape/cancel closes without write, and closing restores focus to switch. Save failure remains visible and retryable. + +MODIFY `gui/src/pages/codex-set-multiauth.tsx`: +```diff + ++ +``` + +MODIFY `gui/src/hooks/useCodexAccountPool.ts`: extend `CodexAccountEntry` with the optional server-owned `mainAccountHardLock` status from wp1. Do not derive policy from rounded QuotaBars. +MODIFY `gui/src/components/codex-account-pool-main-card.tsx`: when enabled show blocked/unknown/monitoring text, with a named recovery action. Suppress a misleading main activation offer when blocked. Keep usage refresh and disable-setting path available. +MODIFY all discovered `gui/src/i18n/{locale}.ts`: identical key sets for title, description, confirmation title/body, enable, saved, disabled, load/save failure, blocked and unknown/monitoring status. English is source; Korean is native concise prose. +NEW scoped CSS only if current card/dialog classes cannot fit 390px, 768px, 1280px viewports. No global token changes. +NEW `gui/tests/main-account-hard-lock-setting.test.tsx`: load, explicit acknowledgment, cancel, Escape, failed save, disable and stale GET/mutation ordering. CI execution only. +MODIFY `docs-site/src/content/docs/reference/cli/providers-accounts.md` and its `ko/` counterpart. Document 99 observation gate, no inflight reservation guarantee, reserve tradeoff and separately scoped external-provider alternative. Audit other translations for contradictions; do not claim same-picker compatibility without client evidence. + +## Interaction copy contract + +Title: Block main account at 99% usage. +Body: Stop new main-account requests when an observed usage window reaches 99%. Added accounts and other providers remain available. +Confirmation: While blocked, this account cannot use Luna reserve either. Keeping ordinary usage below exhaustion may prevent Reserve activation. Requests already running or outside this proxy may still consume the remainder. Disable this setting to resume normal handling; upstream limits still apply. +No claim that Reserve grants other native models or that the Desktop picker has been unlocked. + +## Verification + +Use existing GUI scripts `bun run lint:i18n`, `bun run lint`, `bun run build`; read package definitions at P. Do not run local tests. Browser: real component against isolated fixture API; exercise off -> dialog -> cancel, confirm -> enabled, load/save failures, blocked state, disable; observe screenshots at desktop/mobile in English and Korean. Capture no real account data. CI owns component regressions. C requires clean observed render and independently reviewed state transitions. diff --git a/devlog/_plan/260905_main_quota_guard/030_delivery.md b/devlog/_plan/260905_main_quota_guard/030_delivery.md new file mode 100644 index 0000000000..40c5312f8d --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/030_delivery.md @@ -0,0 +1,22 @@ +# Reviewable stack delivery + +Depends on wp1 and wp2. No production code in this cycle unless a verified defect requires a new scoped repair plan. + +## Branch and PR operations + +Bottom `codex/main-account-99-hard-lock` -> dev: policy/config/API/status/regression contracts. +Upper `codex/main-account-99-settings` -> bottom: consumer UI, translations, screenshots and usage docs. +Use `.github/PULL_REQUEST_TEMPLATE.md` sections unchanged. Each body carries the stack order and exact verification evidence. GUI body embeds a durable screenshot. Explicitly disclose no local suites by owner instruction; never tick an assertion that a local suite passed. + +PUSH: `git push --no-verify -u origin `; rewritten stack tips use explicit `--force-with-lease=:` and only after preserving peer changes. Never force dev/main/preview. + +## Verification and merge + +Refresh `gh pr view` head/base, full statusCheckRollup, reviews and review threads. Independently audit security-sensitive policy/identity and UI contracts in English. Correct findings, cascade any bottom edits into upper, push and verify new heads. +CI is the test authority: inspect real workflows/logs for test/typecheck coverage at exact head, not just an empty required-check list. Diagnose failures before retrying. +On green/no unresolved blockers, user authorizes admin squash merge bottom. Record bypass authorization on PR. Immediately fetch dev; require `git merge-base --is-ancestor origin/dev` exit 0. +Retarget/cascade upper after squash so it contains only its own changes over dev; reverify exact head CI before the upper admin merge. Repeat ancestry proof. + +## Durable closeout + +MODIFY this unit's numbered evidence doc with PRs, exact heads, checks, screenshots, review conclusions and merge SHAs. Move unit to devlog/_fin only when terminal outcome is recorded and all intended implementation is visible in public history. Goalplan completion follows actual D close and evidenced criteria; do not hand-mark a missing FSM phase as done. From 32fd02b0917b95958ff09021151ab91703eca5a3 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 08:28:42 +0900 Subject: [PATCH 02/11] feat(codex): add window-aware 99 percent main-account hard lock --- .../_plan/260905_main_quota_guard/000_plan.md | 2 +- .../001_source_findings.md | 11 + .../260905_main_quota_guard/010_policy.md | 2 +- .../011_policy_dispatch_contract.md | 55 +++ .../012_implementation_review.md | 24 ++ .../013_window_priority_steering.md | 9 + .../014_runtime_evidence.md | 36 ++ .../260905_main_quota_guard/020_settings.md | 2 +- scripts/test-layout/layout.json | 4 + src/codex/account-lifecycle.ts | 14 +- src/codex/account-usability.ts | 2 + src/codex/auth-api.ts | 11 +- src/codex/auth-context.ts | 111 ++++- src/codex/main-account-cache.ts | 56 +++ src/codex/main-account-hard-lock.ts | 50 +++ src/codex/quota.ts | 108 ++++- src/config.ts | 1 + src/providers/openai-sidecar.ts | 11 +- src/server/management/config-routes.ts | 19 +- src/server/responses/compact.ts | 18 +- src/server/responses/core.ts | 39 +- src/types/config.ts | 2 + structure/08_openai-provider-tiers.md | 21 + .../main-account-hard-lock-auth.test.ts | 374 +++++++++++++++++ .../main-account-hard-lock-policy.test.ts | 122 ++++++ .../main-quota-provenance.test.ts | 393 ++++++++++++++++++ .../settings-main-account-hard-lock.test.ts | 108 +++++ tests/fixtures/test-layout-expected.json | 4 + 28 files changed, 1559 insertions(+), 50 deletions(-) create mode 100644 devlog/_plan/260905_main_quota_guard/011_policy_dispatch_contract.md create mode 100644 devlog/_plan/260905_main_quota_guard/012_implementation_review.md create mode 100644 devlog/_plan/260905_main_quota_guard/013_window_priority_steering.md create mode 100644 devlog/_plan/260905_main_quota_guard/014_runtime_evidence.md create mode 100644 src/codex/main-account-hard-lock.ts create mode 100644 tests/codex-integration/main-account-hard-lock-auth.test.ts create mode 100644 tests/codex-integration/main-account-hard-lock-policy.test.ts create mode 100644 tests/codex-integration/main-quota-provenance.test.ts create mode 100644 tests/config/settings-main-account-hard-lock.test.ts diff --git a/devlog/_plan/260905_main_quota_guard/000_plan.md b/devlog/_plan/260905_main_quota_guard/000_plan.md index 91d8fa6e46..1c5c0ea117 100644 --- a/devlog/_plan/260905_main_quota_guard/000_plan.md +++ b/devlog/_plan/260905_main_quota_guard/000_plan.md @@ -41,7 +41,7 @@ The Reserve client gate is a separate feasibility decision, not permission to mi ## Acceptance -- Off/absent flag preserves current routing. Enabled flag blocks observed main usage >=99 across relevant non-expired windows, including short-only observations. Unknown data is not invented as 0 or 100. +- Off/absent flag preserves current routing. Enabled flag uses the 5h/short window when present, otherwise weekly, otherwise monthly-only usage; it blocks at >=99 on that selected window. Other windows cannot trigger this local policy. Unknown data is not invented as 0 or 100. Owner steering is recorded in 013. - Main exclusion cannot prevent usage refresh or profile recovery. Explicit main and Direct paths cannot evade a measured block; unrelated caller credentials cannot inherit main's quota. - Observations are identity-bound; account changes and restart cannot attach another account's cached reading. Window expiry and a fresh lower observation release only this policy, not pause/cooldown/reauth. - UI distinguishes enabled from currently blocked. Cancel/Escape do not save; save errors preserve actual server state; success requires explicit acknowledgment. Main status remains visible outside Advanced. diff --git a/devlog/_plan/260905_main_quota_guard/001_source_findings.md b/devlog/_plan/260905_main_quota_guard/001_source_findings.md index 84493bfd4a..07bc02a170 100644 --- a/devlog/_plan/260905_main_quota_guard/001_source_findings.md +++ b/devlog/_plan/260905_main_quota_guard/001_source_findings.md @@ -29,6 +29,17 @@ Reference prefix: local `121_openai-codex/codex-rs/tui/src/chatwidget/` beneath `src/codex/inject.ts:191,319` already supports the explicit `codexDesktopAuthless` loopback mode, which uses a custom provider with `requires_openai_auth=false`. Reference app-server `model-provider/src/provider.rs:401` then reports no native account requirement. Whether Desktop's Reserve picker follows this state remains unverified. `src/router.ts:633` accepts an explicitly configured `main/gpt-reserve` namespace, but routing acceptance is not Reserve entitlement. Static native listing omits Reserve, and unknown account-native discovery currently requires supported_in_api=true, unlike the existing Reserve-shaped test fixture. No live Reserve inference or Desktop coexistence has been proven. Do not synthesize availability or change upstream recovery flags; validate this seam before planning any compatibility implementation. +## Installed Desktop source, 26.901.22334 / build 7746 + +Read the existing application archive without extraction, installation, application writes or restart. Member offsets below are zero-based UTF-8 bytes, not source line numbers. + +- `webview/assets/app-initial-f1c3ba37268a.js`, offset4132166: Reserve eligibility rejects an auth method other than `chatgpt`, besides feature/plan/identity/version checks. +- Same member, offset4133005: active Reserve requires ordinary `rate_limit.allowed=false`, a `gpt-reserve` additional limit with `allowed=true`, and `luna_reserve` banner. +- Same member, offset4451408: account/auth projection reads `account` plus `requiresOpenaiAuth` from app-server. The reference provider's `account_state` reports no native account when `requires_openai_auth=false`. +- `webview/assets/app-primary-b1300cb15eed.js`, offset7352039: active Reserve replaces the whole picker list with the single Reserve row; it has no per-provider exception. + +Source conclusion: an effective authless custom provider disables this native Reserve-only picker gate, AND disables Desktop automatic Reserve handling. No installed-client patch is necessary for that particular gate. Explicit Reserve plus routed-model coexistence still needs independently verified catalog/routing/quota compatibility. No live Reserve-entitled session was used; do not label these source checks as live success. + ## Necessity and limits Do nothing/configure-only fails because the existing threshold can return to main. Reuse the existing eligibility and native-auth resolution owners. A small policy leaf is justified to share raw-window/identity logic between routing and the status DTO without importing management or Lab into core paths. diff --git a/devlog/_plan/260905_main_quota_guard/010_policy.md b/devlog/_plan/260905_main_quota_guard/010_policy.md index 9a0102750b..b757dd7d22 100644 --- a/devlog/_plan/260905_main_quota_guard/010_policy.md +++ b/devlog/_plan/260905_main_quota_guard/010_policy.md @@ -6,7 +6,7 @@ Depends on wp0. C4 care for quota/credential boundary; existing authentication a NEW config `codexMainAccountHardLock?: boolean` in `src/types/config.ts`; `src/config.ts` parses optional boolean with malformed input treated as off. Persist through existing `saveConfigPreservingClaudeCode`; GET and PUT `/api/settings` return `codexMainAccountHardLock: config.codexMainAccountHardLock === true`. PUT rejects nonboolean input, captures presence/value, deletes when false, and restores exactly on save failure. Creation: settings PUT/hand-edited JSON; serialization: existing atomic config writer; deserialization: Zod loader; consumers: policy helper, account usability, native auth resolution, settings/main DTO, GUI in wp2. No new endpoint. -NEW `src/codex/main-account-hard-lock.ts`: shared policy leaf with named threshold 99 and status `{enabled:boolean, state:'off'|'unknown'|'ready'|'blocked', resetAt?:number}`. Read identity-bound quota only; never auth files, management imports or Lab. Check finite raw weekly/monthly/short values under the parser's plan-window contract. Ignore a window whose valid seconds-or-milliseconds reset is in the past. A finite >=99 observation with no reset remains blocked until a fresh observation lowers it; unknown/nonfinite data does not create a block. Additional model-specific windows do not become a global-main quota. No time-based expiry that silently admits still-exhausted main. +NEW `src/codex/main-account-hard-lock.ts`: shared policy leaf with named threshold 99 and status `{enabled:boolean, state:'off'|'unknown'|'ready'|'blocked', resetAt?:number}`. Read identity-bound quota only; never auth files, management imports or Lab. Owner steering in 013: use the 5h/short tuple when present, otherwise weekly, otherwise monthly-only; do not take the maximum across windows. Ignore the selected reading when its valid seconds-or-milliseconds reset is in the past, without falling back to another window. A finite >=99 observation with no reset remains blocked until a fresh observation lowers it; unknown/nonfinite data does not create a block. Additional model-specific windows do not become a global-main quota. No time-based expiry that silently admits still-exhausted main. Status chain: creation in policy helper -> main DTO `mainAccountHardLock` in `auth-api.ts` and settings GET/PUT -> ordinary JSON -> optional typed `CodexAccountEntry.mainAccountHardLock` in wp2 -> main badge and setting description. Never include raw account IDs, credential fingerprints or secrets in status DTOs. diff --git a/devlog/_plan/260905_main_quota_guard/011_policy_dispatch_contract.md b/devlog/_plan/260905_main_quota_guard/011_policy_dispatch_contract.md new file mode 100644 index 0000000000..3ef1815202 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/011_policy_dispatch_contract.md @@ -0,0 +1,55 @@ +# wp1 stale check and disjoint build contract + +Previous D: roadmap lock 522e388b7, no production changes. Current tree still matches that code baseline. This P concretizes worker boundaries and the newly inspected sidecar caller. + +## Lane A: provenance + +Write scope: `src/codex/main-account-cache.ts`, `src/codex/quota.ts`, `src/codex/account-lifecycle.ts`; a NEW focused `tests/codex-integration/main-quota-provenance.test.ts` only. Main owns layout registration. + +New cache exports (all no filesystem reads): +```ts +export type MainQuotaWriter = Readonly<{identityKey:string; identityGeneration:number}>; +export function observeMainQuotaIdentity(accountId:string): void; +export function captureMainQuotaWriter(accountId:string): MainQuotaWriter | undefined; +export function observeMainQuotaCredential(accessToken:string, accountId:string): MainQuotaWriter | undefined; +export function matchesMainQuotaCredential(accessToken:string, effectiveAccountId:string | undefined): boolean; +export function isMainQuotaWriterLive(writer:MainQuotaWriter): boolean; +export function getObservedMainQuotaIdentityKey(): string | undefined; +``` +`observeMainQuotaIdentity` is called from existing owned identity reconciliation / confirmed transitions, never from untrusted caller data. `observeMainQuotaCredential` does NOT change observed physical identity; it only captures equality for an already matching account. HMAC tuple is generation-scoped and memory-only. Identity key may be stable SHA256 of account identity for disk comparison; never persist token hashes. Clearing main info invalidates credential equality and generation, not falsely certifies a new identity. + +Quota exports: add optional fourth `mainWriter?: MainQuotaWriter` to the parsed setter/header applier; export `getMainPolicyQuota(): StoredAccountQuota | null`. Preserve public quota shape and legacy readers. Identity-tagged policy snapshot has separate lifetime from rotation TTL. Reject stale tagged writes and retain one shared merge semantics. Untagged main writes invalidate policy provenance; pool writes do not. + +## Lane B: native admission and all outbound callers + +Write scope: `src/codex/auth-context.ts`, `src/codex/account-usability.ts`, `src/server/responses/core.ts`, `src/server/responses/compact.ts`, `src/providers/openai-sidecar.ts`; NEW `tests/codex-integration/main-account-hard-lock-auth.test.ts`. Main owns layout registration. + +Consume the cache/quotas API above and the main-owned helper below. Main-pool context adds optional `mainQuotaWriter?: MainQuotaWriter` for backward compatibility, captured from the owned returned token before sending. Carry it unchanged to all three quota-header writers (core twice, compact once). Reconcile physical identity on the already owned path; never add a physical read to caller-owned traffic. + +Extend materializer options with optional config, and legacy `headersForCodexAuthContext(headers,ctx,config?)` likewise. Supply config at every actual core/compact and `openai-sidecar.ts` call. The `ws-bridge.ts` wrapper has no production caller (symbol search), so it is not an enforcement site. Direct sidecar currently bypasses `resolveCodexAuthContext`; pass config to `directSidecarHeaders` and through the canonical header materializer so it cannot bypass matched-main policy. + +After awaited stored token refresh, observe credential only if matching already-owned identity, then evaluate current config/quota immediately before returning headers. Matched caller means exact credential equality AND effective outgoing account ID equality. Quota/config changes after selection are observable at materialization. A live socket is an already-admitted request; this work does not revoke its existing stream. + +`CodexMainAccountHardLockError` extends the existing cooldown class, carries a safe policy message and no account PII. Canonical cooldown formatter recognizes the subtype and returns the policy-specific recovery instruction. Do not write upstream cooldown or mint probes for this error. If main is the only otherwise eligible account and is blocked, surface the policy error, not reauth. + +## Main lane: config, status, policy and integration + +Write scope: `src/types/config.ts`, `src/config.ts`, `src/server/management/config-routes.ts`, `src/codex/auth-api.ts`, NEW `src/codex/main-account-hard-lock.ts`, NEW `tests/config/settings-main-account-hard-lock.test.ts`, NEW `tests/codex-integration/main-account-hard-lock-policy.test.ts`, both test layout manifests, unit docs and structure SoT. + +Policy API: +```ts +export interface MainAccountHardLockStatus { + enabled:boolean; + state:'off'|'unknown'|'ready'|'blocked'; + resetAt?:number; // Unix milliseconds for the selected blocking window, absent if unknown +} +export function getMainAccountHardLockStatus(config:Pick, now?:number): MainAccountHardLockStatus; +export function isMainAccountHardLocked(config:Pick, now?:number): boolean; +``` +Read `getMainPolicyQuota`; no credential getters. Owner steering in013 selects short/5h first, otherwiseweekly, otherwisemonthly; never select a different window because the chosen reading expired. Recognize 99 exactly, never score unknown=101. Status shared in settings and main DTO. + +At WHAM request construction, capture `observeMainQuotaCredential(tokens.access_token,tokens.account_id)` only if `requestAccountId === tokens.account_id`, then carry `mainWriter` over fetch and existing identity revalidation to the parsed quota setter. Incoming data never manufactures that provenance. + +## Delegation/verification boundaries + +All lanes read 010 + this contract and relevant skills. No local test suites, no commits/pushes/FSM/goals/delegation from workers. Write regression files but main runs static checks once after integration and CI runs all tests. Main independently inspects diffs and audits before publication. No lane may edit another lane's files; report necessary boundary changes instead. diff --git a/devlog/_plan/260905_main_quota_guard/012_implementation_review.md b/devlog/_plan/260905_main_quota_guard/012_implementation_review.md new file mode 100644 index 0000000000..490305f3bf --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/012_implementation_review.md @@ -0,0 +1,24 @@ +# wp1 implementation review and repair + +## Provenance/policy round 1 + +Independent reviewer Ohm returned FAIL; accepted both findings after tracing the actual parser/merge flow. + +- High: supplementary tertiary-only monthly headers must not clear a retained weekly99 policy observation. Legacy merge clears weekly on any monthly-only snapshot; policy recovery needs actual monthly-primary provenance. Add a parser -> tagged store -> policy regression. Preserve policy weekly unless a lower weekly reading, passed reset or proven governing monthly-primary replacement occurs. +- Medium: tagged credits-only update must not repopulate the expired ordinary rotation cache from long-lived policy evidence, especially while hard-lock is off. Use separate existing bases for legacy and policy merges; never copy durable-only policy values into `accountQuota`. + +RCA: one shared result cannot represent two distinct retention contracts. Keep one window-merging implementation with an explicit narrow policy-mode distinction for supplementary monthly updates, but evaluate it separately against legacy and policy bases. Preserve default-off legacy carry-forward; exclude untagged/cross-identity fields only from the new policy record. No generic cache redesign. + +Required repair verification: typed checks after source settles; CI regression proves tertiary-only update leaves policy blocked, expired legacy cache remains absent after credits-only update, and actual monthly-primary replacement still clears obsolete weekly policy. Same reviewer re-verifies blocker closure before publication. + +Observed before repair: root typecheck exit0; all four new regression files passed standalone TypeScript7 checking with --ignoreConfig. No local tests executed. Privacy scan passed before final integration; final scan remains due if subsequent edits affect it. + +## Native admission round 1 + +Reviewer Tesla returned FAIL with one accepted high blocker: the common Responses resolver also runs for key-authenticated non-Codex providers, assigning a synthetic main context. Passing the policy config unconditionally into its materializer would incorrectly reject routed providers when the caller happened to present the matched main bearer. +RCA: credential identity is necessary but not sufficient; the selected destination must actually consume Codex credentials. Gate both final materialization checks by the existing canonical Codex-forward transport predicate, including custom-named canonical providers. Do not weaken native/Direct/exact-main protection. Add a handler-level regression with the same caller, blocked native request and successful independently keyed route. +This is compatible with the provenance repairs: those determine whose quota; this repair determines whether that quota applies to this destination. Reuse the same reviewer for closure. No broader routing redesign is authorized. + +## Provenance/policy round 2 + +Original tertiary-clearing and legacy-TTL findings are closed; user5h-first selection is accepted. Reviewer found a different producer gap: Go/Free WHAM monthly-primary parsing does not emit `monthlyIsPrimaryWindow`, so a same-owner transition from weekly to monthly leaves stale weekly evidence selected. Accept the finding. Preserve the existing provenance flag whenever a genuinely explicit monthly primary is parsed, including Go/Free; supplementary-only monthly remains insufficient. Add parser -> same-owner store -> policy coverage for weekly98 to monthly99 and weekly99 to monthly20, with no short tuple. This fixes the producer rather than weakening the policy merger. diff --git a/devlog/_plan/260905_main_quota_guard/013_window_priority_steering.md b/devlog/_plan/260905_main_quota_guard/013_window_priority_steering.md new file mode 100644 index 0000000000..248cda3ba4 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/013_window_priority_steering.md @@ -0,0 +1,9 @@ +# Window-priority steering + +Owner clarification during wp1: accounts with a 5h window must use that window; accounts with weekly quota use weekly. If both exist, 5h wins. The initial maximum-across-windows policy is superseded before publication. + +Acceptance changes (not reduced verification): choose the observed short/5h tuple when present, otherwise weekly, otherwise monthly for monthly-only accounts. A high secondary window cannot activate this local 99% policy. Upstream limits still apply independently. An expired or unknown selected window is unknown, not permission to substitute a different high window. Retain known short-window shape across partial snapshots using the existing provenance-aware merger. + +Implementation: only the main-owned policy helper and policy tests change; identity, destination, maintenance and no-suite rules remain unchanged. UI copy in wp2 must say 5h first, weekly otherwise; monthly-only accounts retain their governing window. Add short98/weekly100 -> ready, short99/weekly20 -> blocked, expired short/weekly99 -> unknown, weekly98/monthly100 -> ready, and monthly-only99 -> blocked. + +All reviewers/workers receive this steering; their existing identity/tertiary/TTL findings remain applicable when the selected account has no short window. diff --git a/devlog/_plan/260905_main_quota_guard/014_runtime_evidence.md b/devlog/_plan/260905_main_quota_guard/014_runtime_evidence.md new file mode 100644 index 0000000000..b4f0f98af5 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/014_runtime_evidence.md @@ -0,0 +1,36 @@ +# Runtime implementation checkpoint + +## Changed-file evidence + +| File | Change and impact | +| --- | --- | +| src/types/config.ts | Optional off-by-default main hard-lock config contract. | +| src/config.ts | Boolean parsing; malformed hand edits stay off. | +| src/codex/main-account-hard-lock.ts | Identity-bound raw-window policy; owner-directed 5h first, weekly otherwise, monthly-only fallback. | +| src/codex/main-account-cache.ts | Memory-only owned identity/generation and keyed credential equality. | +| src/codex/quota.ts | Separately retained private policy evidence, distinct legacy/policy merge bases, governing monthly provenance. | +| src/codex/account-lifecycle.ts | Publish identity from existing owned reconciliation and confirmed transitions. | +| src/codex/account-usability.ts | Exclude blocked main from ordinary selection. | +| src/codex/auth-context.ts | Refuse matched main at admission/materialization, carry writer provenance, preserve safe policy error formatting. | +| src/codex/auth-api.ts | Capture WHAM provenance before request, publish safe main status. | +| src/server/management/config-routes.ts | Partial boolean PUT, exact rollback and acknowledged setting/status DTO. | +| src/server/responses/core.ts | Destination-gated policy propagation, replay/header writer integration; independent providers unaffected. | +| src/server/responses/compact.ts | Matching compact/replay propagation and policy error mapping. | +| src/providers/openai-sidecar.ts | Include Direct sidecars in the same materializer policy. | +| structure/08_openai-provider-tiers.md | Updated policy scope, observation limits and selected-window contract. | +| tests/codex-integration/main-account-hard-lock-policy.test.ts | Authored boundary/window-priority/unknown/reset scenarios. | +| tests/codex-integration/main-quota-provenance.test.ts | Authored identity, restart, TTL, partial merge and monthly transition scenarios. | +| tests/codex-integration/main-account-hard-lock-auth.test.ts | Authored native/refusal/alternate/caller isolation and actual handler destination scenarios. | +| tests/config/settings-main-account-hard-lock.test.ts | Authored acknowledgment/persistence/rollback/malformed setting scenarios. | +| scripts/test-layout/layout.json | Register the four new domain tests. | +| tests/fixtures/test-layout-expected.json | Mirror the test-layout registrations. | + +## Observed verification + +- Root `bun run typecheck`: exit0 after the final runtime/producer repairs. +- Standalone `bun x tsc --ignoreConfig --noEmit --module ESNext --target ESNext --moduleResolution bundler --skipLibCheck --strict --types bun-types` against the four new test paths: exit0. This checks test types, not test behavior. +- `git diff --check`: exit0. +- Independent Ohm review: all provenance/TTL/tertiary/monthly-producer findings closed; final VERDICT PASS. +- Independent Tesla review: unrelated-provider refusal finding closed; final VERDICT PASS. +- No local test suite was executed. Exact-head CI is pending publication and is required before runtime completion/merge. +- Installed Desktop Reserve-gate source evidence is in001; live Reserve success is not claimed. diff --git a/devlog/_plan/260905_main_quota_guard/020_settings.md b/devlog/_plan/260905_main_quota_guard/020_settings.md index ae2663a687..cfa4dcd50c 100644 --- a/devlog/_plan/260905_main_quota_guard/020_settings.md +++ b/devlog/_plan/260905_main_quota_guard/020_settings.md @@ -44,7 +44,7 @@ MODIFY `docs-site/src/content/docs/reference/cli/providers-accounts.md` and its ## Interaction copy contract Title: Block main account at 99% usage. -Body: Stop new main-account requests when an observed usage window reaches 99%. Added accounts and other providers remain available. +Body: Stop new main-account requests at 99% of the 5h window, or weekly usage when no 5h window exists. Monthly-only accounts use monthly usage. Added accounts and other providers remain available. Confirmation: While blocked, this account cannot use Luna reserve either. Keeping ordinary usage below exhaustion may prevent Reserve activation. Requests already running or outside this proxy may still consume the remainder. Disable this setting to resume normal handling; upstream limits still apply. No claim that Reserve grants other native models or that the Desktop picker has been unlocked. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5e579748ef..babb74815c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -764,6 +764,9 @@ "loopback-listener-admission.test.ts": "server", "loopback-listener-integration.test.ts": "server", "management-api-logs-metrics.test.ts": "server", + "main-account-hard-lock-auth.test.ts": "codex-integration", + "main-account-hard-lock-policy.test.ts": "codex-integration", + "main-quota-provenance.test.ts": "codex-integration", "management-client-config-route.test.ts": "server", "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", @@ -1071,6 +1074,7 @@ "session-affinity.test.ts": "server", "session-lane-recall-harness.test.ts": "server", "settings-oauth-open-browser.test.ts": "config", + "settings-main-account-hard-lock.test.ts": "config", "settings-startup-health-seam.test.ts": "config", "settings-stream-mode.test.ts": "config", "shutdown-drain.test.ts": "service", diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 8f750a719f..1a40253848 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -13,7 +13,7 @@ import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; import { clearAccountQuota } from "./quota"; import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing"; import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; -import { clearMainAccountCredentialPresence, clearMainAccountInfoCache } from "./main-account-cache"; +import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaIdentity } from "./main-account-cache"; import { forgetCodexAccountPause } from "./account-pause"; import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority"; import { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh"; @@ -66,9 +66,13 @@ export function reconcileMainCodexAccountRuntimeState(): boolean { if (currentAccountId === null) return false; const previousAccountId = observedMainChatgptAccountId; observedMainChatgptAccountId = currentAccountId; - if (previousAccountId === undefined || previousAccountId === currentAccountId) return false; + if (previousAccountId === undefined || previousAccountId === currentAccountId) { + observeMainQuotaIdentity(currentAccountId); + return false; + } purgeMainCodexAccountRuntimeState(); + observeMainQuotaIdentity(currentAccountId); return true; } @@ -81,11 +85,15 @@ export function applyConfirmedMainCodexAccountTransition( toAccountId: string, ): boolean { if (!fromAccountId || !toAccountId || fromAccountId === toAccountId) { - if (toAccountId) observedMainChatgptAccountId = toAccountId; + if (toAccountId) { + observedMainChatgptAccountId = toAccountId; + observeMainQuotaIdentity(toAccountId); + } return false; } observedMainChatgptAccountId = toAccountId; purgeMainCodexAccountRuntimeState(); + observeMainQuotaIdentity(toAccountId); return true; } diff --git a/src/codex/account-usability.ts b/src/codex/account-usability.ts index c2565a41aa..3ce27475ee 100644 --- a/src/codex/account-usability.ts +++ b/src/codex/account-usability.ts @@ -9,6 +9,7 @@ import { import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./account-id"; import type { OcxConfig } from "../types"; import { isNativeMainTrafficBlocked } from "./native-profile-startup"; +import { isMainAccountHardLocked } from "./main-account-hard-lock"; export interface CodexAccountUsabilityOptions { /** Route using cached runtime state only; the caller must reject selected main before auth. */ @@ -26,6 +27,7 @@ export function isCodexAccountUsable( ): boolean { if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId)) return false; if (accountId === MAIN_CODEX_ACCOUNT_ID) { + if (isMainAccountHardLocked(config)) return false; // Startup recovery owns the physical auth/vault boundary. Never parse or select // native __main__ while an encrypted switch journal is pending or inconclusive. if (!options.nativeMainSelectionOnly && isNativeMainTrafficBlocked()) return false; diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 809004927e..1ee5ad9cf8 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -93,11 +93,13 @@ import { getMainAccountCredentialPresence, getMainAccountInfoCache, isMainAccountIdentityGenerationLive, + observeMainQuotaCredential, setMainAccountCredentialPresence, setMainAccountInfoCache, type MainAccountInfo, } from "./main-account-cache"; export { clearMainAccountInfoCache } from "./main-account-cache"; +import { getMainAccountHardLockStatus, type MainAccountHardLockStatus } from "./main-account-hard-lock"; import { maskEmail } from "../lib/privacy"; import { codexWarmupFailureReason, warmCodexAccount } from "./warmup"; export { maskEmail } from "../lib/privacy"; @@ -884,6 +886,11 @@ async function fetchMainAccountInfoWhileOwned( if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) { return { info: cached, credentialChecked: true, hasCredential: true }; } + // Bind quota to the owned credential and the account actually selected by WHAM's header. + // A conflicting legacy token/account tuple is not evidence for the new policy. + const mainQuotaWriter = requestAccountId === tokens.account_id + ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) + : undefined; try { const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id }, @@ -929,7 +936,7 @@ async function fetchMainAccountInfoWhileOwned( // score and auto-switch the main account exactly like a pool account (Option A). setMainAccountPlan(result.plan); if (result.quota) { - setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration); + setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter); } return { info: result, @@ -1037,6 +1044,7 @@ export interface CodexAuthAccountDto { healthSummary: string; healthAction?: string; quotaProbeSkipped?: true; + mainAccountHardLock?: MainAccountHardLockStatus; } interface FreshPoolPlanUpdate { @@ -1708,6 +1716,7 @@ export async function listCodexAuthAccountsSnapshot( logLabel: "main", isMain: true, paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), + mainAccountHardLock: getMainAccountHardLockStatus(runtimeConfig), priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), hasCredential: hasMainCredential, needsReauth: mainNeedsReauth, diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 5351b94e2c..5260169324 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -51,6 +51,15 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { captureConfigGeneration } from "../lib/state-store-sweeper"; import { retainedUtf8Bytes } from "../lib/admission"; import { extractAccountId } from "../oauth/chatgpt"; +import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock"; +import { + captureMainAccountIdentityGeneration, + getObservedMainQuotaIdentityKey, + isMainQuotaWriterLive, + matchesMainQuotaCredential, + observeMainQuotaCredential, + type MainQuotaWriter, +} from "./main-account-cache"; const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512; const CODEX_APP_AFFINITY_KEY = randomBytes(32); @@ -128,6 +137,8 @@ export type CodexAuthContext = kind: "main-pool"; accountId: string; writerGeneration: number; + /** Captured before async credential work; never reconstructed after the upstream response. */ + mainQuotaWriter?: MainQuotaWriter; accessToken: string; chatgptAccountId: string; /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ @@ -283,6 +294,53 @@ export class CodexAccountCooldownError extends Error { } } +export class CodexMainAccountHardLockError extends CodexAccountCooldownError { + readonly resetAt?: number; + + constructor(resetAt?: number) { + super(MAIN_CODEX_ACCOUNT_ID, resetAt ?? 0); + this.name = "CodexMainAccountHardLockError"; + this.resetAt = resetAt; + this.message = "Codex main account is blocked by the 99% main-account quota policy." + + " Choose another account, wait for quota to reset, or disable codexMainAccountHardLock in Settings."; + } +} + +function assertMainAccountPolicy(config: Pick | undefined): void { + if (!config) return; + const status = getMainAccountHardLockStatus(config); + if (status.state === "blocked") throw new CodexMainAccountHardLockError(status.resetAt); +} + +/** No auth-file I/O: an unsigned claim alone never identifies a caller as stored main. */ +function callerMatchesObservedMain(headers: Headers): boolean { + const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + if (!bearer) return false; + const effectiveAccountId = headers.get("chatgpt-account-id") + ?? extractAccountId(undefined, bearer); + return matchesMainQuotaCredential(bearer, effectiveAccountId); +} + +function captureObservedMainWriter(): MainQuotaWriter | undefined { + const identityKey = getObservedMainQuotaIdentityKey(); + return identityKey === undefined ? undefined : { + identityKey, + identityGeneration: captureMainAccountIdentityGeneration(), + }; +} + +function observeSelectedMainCredential( + token: { accessToken: string; chatgptAccountId: string }, + writer: MainQuotaWriter | undefined, +): MainQuotaWriter | undefined { + if (!writer) return undefined; + // Carry an explicitly stale writer through to quota's rejection fence; turning it into an + // untagged write would instead invalidate the replacement account's trusted observation. + if (!isMainQuotaWriterLive(writer)) return writer; + const observed = observeMainQuotaCredential(token.accessToken, token.chatgptAccountId); + return observed?.identityKey === writer.identityKey ? writer : undefined; +} + /** * Human-readable account label for a client-visible error. NEVER the raw id: the proxy * supports non-loopback binds (auth-cors.ts `isApiAuthRequired` requires a token there @@ -300,6 +358,7 @@ export function cooldownAccountLabel(accountId: string): string { * injected `openai_base_url` in config.toml. */ export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string { + if (err instanceof CodexMainAccountHardLockError) return err.message; const until = new Date(err.cooldownUntil).toISOString(); const scope = err.quotaScope === "spark" ? "Spark quota" @@ -325,7 +384,9 @@ export function cooldownErrorResponse( ): Response { const res = formatErrorResponse(429, "rate_limit_error", cooldownErrorMessage(err, accountSelector)); const headers = new Headers(res.headers); - headers.set("Retry-After", String(Math.max(1, Math.ceil((err.cooldownUntil - now) / 1000)))); + if (!(err instanceof CodexMainAccountHardLockError) || err.resetAt !== undefined) { + headers.set("Retry-After", String(Math.max(1, Math.ceil((err.cooldownUntil - now) / 1000)))); + } return new Response(res.body, { status: res.status, headers }); } @@ -340,7 +401,8 @@ export class CodexThreadAffinityExpiredError extends Error { } export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean { - return !(cause instanceof CodexCredentialGenerationConflictError) + return !(cause instanceof CodexMainAccountHardLockError) + && !(cause instanceof CodexCredentialGenerationConflictError) && !(cause instanceof CodexCredentialRefreshLockTimeoutError) && !(cause instanceof CodexCredentialRefreshBusyError) && !(cause instanceof CodexCredentialRefreshStaleError) @@ -397,6 +459,7 @@ export async function resolveCodexAuthContext( && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID && isEffectiveCodexAccountPinned(config) && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) + && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(config)) && requestOwnedMainPinHasQuotaHeadroom(config); if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) { throw new Error("Codex auth context cannot select and exclude an account simultaneously"); @@ -405,6 +468,7 @@ export async function resolveCodexAuthContext( if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); const substituteStoredMain = options.substituteMainCredentialForDirect === true; if (!substituteStoredMain) { + if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(config); if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) { const entitled = await ( options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel @@ -413,6 +477,7 @@ export async function resolveCodexAuthContext( throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model"); } } + if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(config); return { kind: "main", accountId: null }; } @@ -432,6 +497,8 @@ export async function resolveCodexAuthContext( ) { throw new CodexMainProfileDrainingError(); } + if (config.codexMainAccountHardLock === true) reconcileMainCodexAccountRuntimeState(); + assertMainAccountPolicy(config); if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) { const entitled = entitledCodexAccountIdsForModel( await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config, { @@ -444,6 +511,7 @@ export async function resolveCodexAuthContext( throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model"); } } + assertMainAccountPolicy(config); return { kind: "main", accountId: null }; } finally { // The short selector reservation ends here. A successful claim remains owned by @@ -462,7 +530,9 @@ export async function resolveCodexAuthContext( || await ( options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel )(headers, options.modelId); - if (callerEntitled) return { kind: "main", accountId: null }; + if (callerEntitled && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(config))) { + return { kind: "main", accountId: null }; + } } // An explicit namespace binding is stronger than the provider's default mode. It must use the // selected stored credential even while the canonical OpenAI provider is globally Direct. @@ -577,6 +647,11 @@ export async function resolveCodexAuthContext( if (nativeMainReadsForbidden && !options.excludeAccountId) { throw new CodexMainProfileDrainingError(); } + if (!nativeMainReadsForbidden && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID + && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) + && (!modelEligibleAccountIds || modelEligibleAccountIds.has(MAIN_CODEX_ACCOUNT_ID))) { + assertMainAccountPolicy(config); + } throw new CodexPoolAuthenticationError( modelEligibleAccountIds === undefined ? undefined @@ -586,6 +661,7 @@ export async function resolveCodexAuthContext( ); } accountId = selected; + if (accountId === MAIN_CODEX_ACCOUNT_ID) assertMainAccountPolicy(config); if (accountId === MAIN_CODEX_ACCOUNT_ID && nativeMainTrafficBlocked) { throw new CodexMainProfileDrainingError(); } @@ -662,14 +738,18 @@ export async function resolveCodexAuthContext( if (accountId === MAIN_CODEX_ACCOUNT_ID) { // Main account in rotation: refresh auth.json before upstream I/O and fail closed if it vanished. let token: { accessToken: string; chatgptAccountId: string } | null; + let mainQuotaWriter = captureObservedMainWriter(); try { token = await (options.getValidMainAccountToken ?? getValidMainAccountToken)({ signal: options.signal, ...(options.nativeMainRefreshDependencies ?? {}), }); + if (token) mainQuotaWriter = observeSelectedMainCredential(token, mainQuotaWriter); + assertMainAccountPolicy(config); } catch (cause) { if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId); else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId); + if (cause instanceof CodexMainAccountHardLockError) throw cause; if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) { markAccountNeedsReauth(accountId, writerGeneration); } @@ -687,6 +767,7 @@ export async function resolveCodexAuthContext( kind: "main-pool", accountId, writerGeneration, + mainQuotaWriter, accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), @@ -772,7 +853,7 @@ export class CodexMainSubstitutionUnavailableError extends Error { export function materializeCodexUpstreamAuth( headers: Headers, ctx: CodexAuthContext, - options: { substituteMainCredential?: boolean } = {}, + options: { substituteMainCredential?: boolean; config?: Pick } = {}, ): Headers { const selected = new Headers(); for (const name of FORWARD_HEADERS) { @@ -782,6 +863,10 @@ export function materializeCodexUpstreamAuth( if (ctx.kind === "pool" || ctx.kind === "main-pool") { selected.set("authorization", `Bearer ${ctx.accessToken}`); selected.set("chatgpt-account-id", ctx.chatgptAccountId); + if (ctx.kind === "main-pool") { + ctx.mainQuotaWriter = observeSelectedMainCredential(ctx, ctx.mainQuotaWriter); + assertMainAccountPolicy(options.config); + } return selected; } if (ctx.kind === "main" && options.substituteMainCredential !== true @@ -791,6 +876,8 @@ export function materializeCodexUpstreamAuth( if (accountId) selected.set("chatgpt-account-id", accountId); } if (ctx.kind === "main" && options.substituteMainCredential === true) { + if (options.config?.codexMainAccountHardLock === true) reconcileMainCodexAccountRuntimeState(); + const writer = captureObservedMainWriter(); const stored = getMainAccountToken(); // Fail BEFORE any upstream I/O. Falling through here would send the admission secret. if (!stored?.accessToken || !isMainAccountTokenLive()) { @@ -798,8 +885,11 @@ export function materializeCodexUpstreamAuth( } selected.set("authorization", `Bearer ${stored.accessToken}`); if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId); + observeSelectedMainCredential(stored, writer); + assertMainAccountPolicy(options.config); return selected; } + if (callerMatchesObservedMain(selected)) assertMainAccountPolicy(options.config); return selected; } @@ -808,6 +898,7 @@ export async function materializeCodexUpstreamAuthAsync( ctx: CodexAuthContext, options: { substituteMainCredential?: boolean; + config?: Pick; signal?: AbortSignal; nativeMainRefreshDependencies?: NativeMainRefreshDependencies; } = {}, @@ -820,6 +911,8 @@ export async function materializeCodexUpstreamAuthAsync( const value = headers.get(name); if (value) selected.set(name, value); } + if (options.config?.codexMainAccountHardLock === true) reconcileMainCodexAccountRuntimeState(); + const writer = captureObservedMainWriter(); const stored = await getValidMainAccountToken({ signal: options.signal, ...(options.nativeMainRefreshDependencies ?? {}), @@ -827,12 +920,18 @@ export async function materializeCodexUpstreamAuthAsync( if (!stored?.accessToken) throw new CodexMainSubstitutionUnavailableError(); selected.set("authorization", `Bearer ${stored.accessToken}`); if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId); + observeSelectedMainCredential(stored, writer); + assertMainAccountPolicy(options.config); return selected; } /** @deprecated Prefer materializeCodexUpstreamAuth; kept for call sites without admission context. */ -export function headersForCodexAuthContext(headers: Headers, ctx: CodexAuthContext): Headers { - return materializeCodexUpstreamAuth(headers, ctx); +export function headersForCodexAuthContext( + headers: Headers, + ctx: CodexAuthContext, + config?: Pick, +): Headers { + return materializeCodexUpstreamAuth(headers, ctx, { config }); } export function isCodexAuthContextUsable(ctx: CodexAuthContext, config: OcxConfig): boolean { diff --git a/src/codex/main-account-cache.ts b/src/codex/main-account-cache.ts index 0eb837b4f8..d87b7aa6b9 100644 --- a/src/codex/main-account-cache.ts +++ b/src/codex/main-account-cache.ts @@ -1,3 +1,4 @@ +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import type { StoredAccountQuota } from "./quota"; import { truncateRetainedUtf8 } from "../lib/admission"; @@ -16,6 +17,60 @@ export interface CachedMainAccountInfo extends MainAccountInfo { let cachedMainAccountInfo: CachedMainAccountInfo | null = null; let cachedMainCredentialPresence: boolean | null = null; let mainAccountIdentityGeneration = 0; +let observedMainQuotaIdentityKey: string | undefined; +const mainQuotaCredentialKey = randomBytes(32); +let mainQuotaCredential: { bearerHmac: Buffer; writer: MainQuotaWriter } | undefined; + +export type MainQuotaWriter = Readonly<{ identityKey: string; identityGeneration: number }>; + +function mainQuotaIdentityKey(accountId: string): string { + return createHash("sha256").update("opencodex-main-quota-v1\0").update(accountId).digest("hex"); +} + +/** Only an existing owned physical-identity read may publish this observation. */ +export function observeMainQuotaIdentity(accountId: string): void { + if (!accountId) return; + const identityKey = mainQuotaIdentityKey(accountId); + if (identityKey === observedMainQuotaIdentityKey) return; + observedMainQuotaIdentityKey = identityKey; + mainAccountIdentityGeneration += 1; + mainQuotaCredential = undefined; +} + +export function captureMainQuotaWriter(accountId: string): MainQuotaWriter | undefined { + if (!accountId) return undefined; + const identityKey = mainQuotaIdentityKey(accountId); + if (identityKey !== observedMainQuotaIdentityKey) return undefined; + return { identityKey, identityGeneration: mainAccountIdentityGeneration }; +} + +/** Credential material must come from an already-owned read, never an incoming request. */ +export function observeMainQuotaCredential(accessToken: string, accountId: string): MainQuotaWriter | undefined { + const writer = captureMainQuotaWriter(accountId); + if (!accessToken || !writer) return undefined; + mainQuotaCredential = { + bearerHmac: createHmac("sha256", mainQuotaCredentialKey).update(accessToken).digest(), + writer, + }; + return { ...writer }; +} + +export function matchesMainQuotaCredential(accessToken: string, effectiveAccountId: string | undefined): boolean { + const observed = mainQuotaCredential; + if (!accessToken || !effectiveAccountId || !observed || !isMainQuotaWriterLive(observed.writer)) return false; + if (mainQuotaIdentityKey(effectiveAccountId) !== observed.writer.identityKey) return false; + const candidate = createHmac("sha256", mainQuotaCredentialKey).update(accessToken).digest(); + return timingSafeEqual(candidate, observed.bearerHmac); +} + +export function isMainQuotaWriterLive(writer: MainQuotaWriter): boolean { + return writer.identityKey === observedMainQuotaIdentityKey + && writer.identityGeneration === mainAccountIdentityGeneration; +} + +export function getObservedMainQuotaIdentityKey(): string | undefined { + return observedMainQuotaIdentityKey; +} export function captureMainAccountIdentityGeneration(): number { return mainAccountIdentityGeneration; @@ -40,6 +95,7 @@ export function setMainAccountInfoCache(value: CachedMainAccountInfo): void { export function clearMainAccountInfoCache(): void { cachedMainAccountInfo = null; mainAccountIdentityGeneration += 1; + mainQuotaCredential = undefined; } /** Last physical credential presence observed while native-main ownership was held. */ diff --git a/src/codex/main-account-hard-lock.ts b/src/codex/main-account-hard-lock.ts new file mode 100644 index 0000000000..3392fed148 --- /dev/null +++ b/src/codex/main-account-hard-lock.ts @@ -0,0 +1,50 @@ +import type { OcxConfig } from "../types"; +import { getMainPolicyQuota } from "./quota"; + +export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 99; + +export interface MainAccountHardLockStatus { + enabled: boolean; + state: "off" | "unknown" | "ready" | "blocked"; + /** Unix milliseconds; absent when a blocking observation has no known reset. */ + resetAt?: number; +} + +type PolicyConfig = Pick; + +function resetTimestamp(value: number | undefined): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined; + return value < 10_000_000_000 ? value * 1000 : value; +} + +/** Observed admission policy, not a reservation of the account's remaining quota. */ +export function getMainAccountHardLockStatus( + config: PolicyConfig, + now = Date.now(), +): MainAccountHardLockStatus { + if (config.codexMainAccountHardLock !== true) return { enabled: false, state: "off" }; + const quota = getMainPolicyQuota(); + if (!quota) return { enabled: true, state: "unknown" }; + // Account window priority is deliberate: a 5h account uses that window, even if + // its weekly bar is higher. An unknown/expired selected window does not change the choice. + const hasShort = quota.shortPercent !== undefined || quota.shortResetAt !== undefined + || quota.shortWindowSeconds !== undefined; + const hasWeekly = quota.weeklyPercent !== undefined || quota.weeklyResetAt !== undefined; + const [percent, rawReset] = hasShort + ? [quota.shortPercent, quota.shortResetAt] + : hasWeekly ? [quota.weeklyPercent, quota.weeklyResetAt] : [quota.monthlyPercent, quota.monthlyResetAt]; + const resetAt = resetTimestamp(rawReset); + // The routing score's unknown sentinel is 101. It is never a raw quota observation. + if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100 + || (resetAt !== undefined && resetAt <= now)) return { enabled: true, state: "unknown" }; + if (percent < MAIN_ACCOUNT_HARD_LOCK_PERCENT) return { enabled: true, state: "ready" }; + return { + enabled: true, + state: "blocked", + ...(resetAt !== undefined ? { resetAt } : {}), + }; +} + +export function isMainAccountHardLocked(config: PolicyConfig, now = Date.now()): boolean { + return getMainAccountHardLockStatus(config, now).state === "blocked"; +} diff --git a/src/codex/quota.ts b/src/codex/quota.ts index b13f915c56..353911609d 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -3,6 +3,8 @@ import { join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper"; import { isThirtyDayOnlyCodexPlan } from "./plan"; +import { MAIN_CODEX_ACCOUNT_ID } from "./account-id"; +import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache"; export type StoredAccountQuota = { weeklyPercent?: number; @@ -38,7 +40,7 @@ export type StoredAccountQuota = { updatedAt: number; }; -/** Disk snapshot under OPENCODEX_HOME — usage percents only (no emails/tokens). */ +/** Disk snapshot under OPENCODEX_HOME — quota and policy identity only, never credential tags. */ const QUOTA_CACHE_FILENAME = "codex-quota-cache.json"; /** Keep last-known bars across restarts; WHAM still refreshes on TTL in live/prime paths. */ const QUOTA_DISK_MAX_AGE_MS = 6 * 60 * 60_000; @@ -47,8 +49,11 @@ const QUOTA_PERSIST_DEBOUNCE_MS = 250; type QuotaDiskFile = { version: 1; quotas: Record; + mainPolicyQuota?: MainPolicyQuota; }; +type MainPolicyQuota = { identityKey: string; quota: StoredAccountQuota }; +let mainPolicyQuota: MainPolicyQuota | null = null; let diskHydrated = false; let persistTimer: ReturnType | null = null; @@ -276,11 +281,42 @@ export function setAccountQuotaFromParsed( accountId: string, quota: Omit | null, writerGeneration = captureConfigGeneration(), + mainWriter?: MainQuotaWriter, ): void { if (!quota) return; if (!mayCommitAccountQuota(accountId, writerGeneration)) return; - const existing = accountQuota.get(accountId); - const next: StoredAccountQuota = { updatedAt: Date.now() }; + const isMain = accountId === MAIN_CODEX_ACCOUNT_ID; + if (isMain && mainWriter && !isMainQuotaWriterLive(mainWriter)) return; + const legacyExisting = accountQuota.get(accountId); + hydrateAccountQuotasFromDisk(); + const updatedAt = Date.now(); + // Legacy rotation keeps its existing carry behavior, but never inherits policy-only + // evidence that outlived its disk TTL. Policy has a separate, identity-checked base. + const next = mergeAccountQuota(quota, legacyExisting, updatedAt); + accountQuota.set(accountId, next); + if (isMain) { + const policyExisting = mainWriter && mainPolicyQuota?.identityKey === mainWriter.identityKey + ? mainPolicyQuota.quota + : undefined; + mainPolicyQuota = mainWriter + ? { + identityKey: mainWriter.identityKey, + quota: structuredClone(mergeAccountQuota(quota, policyExisting, updatedAt, true)), + } + : null; + } + schedulePersistAccountQuotas(); + notifyCodexQuotaSnapshot(accountId, next); +} + +/** One partial-window merge contract for legacy quota and identity-bound policy evidence. */ +function mergeAccountQuota( + quota: Omit, + existing: StoredAccountQuota | undefined, + updatedAt: number, + requireMonthlyPrimaryForWeeklyReplacement = false, +): StoredAccountQuota { + const next: StoredAccountQuota = { updatedAt }; const creditsOnly = quota.resetCredits !== undefined && !snapshotHasUsage(quota); if (creditsOnly) { @@ -295,16 +331,16 @@ export function setAccountQuotaFromParsed( if (existing?.shortWindowSeconds !== undefined) next.shortWindowSeconds = existing.shortWindowSeconds; if (existing?.customWindows !== undefined) next.customWindows = existing.customWindows; next.resetCredits = quota.resetCredits; - accountQuota.set(accountId, next); - schedulePersistAccountQuotas(); - return; + return next; } if (snapshotHasWeekly(quota)) { if (quota.weeklyPercent !== undefined) next.weeklyPercent = quota.weeklyPercent; if (quota.weeklyResetAt !== undefined) next.weeklyResetAt = quota.weeklyResetAt; - } else if (snapshotHasMonthly(quota) && !snapshotHasWeekly(quota)) { - // Monthly-only snapshots intentionally clear stale weekly values (issue #382). + } else if (snapshotHasMonthly(quota) + && (!requireMonthlyPrimaryForWeeklyReplacement || quota.monthlyIsPrimaryWindow === true)) { + // Legacy monthly-only clearing is unchanged (#382). Policy needs a governing + // monthly-primary observation: a tertiary-only header cannot retract weekly99. } else if (existing?.weeklyPercent !== undefined) { next.weeklyPercent = existing.weeklyPercent; if (existing.weeklyResetAt !== undefined) next.weeklyResetAt = existing.weeklyResetAt; @@ -346,9 +382,7 @@ export function setAccountQuotaFromParsed( if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits; else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits; - accountQuota.set(accountId, next); - schedulePersistAccountQuotas(); - notifyCodexQuotaSnapshot(accountId, next); + return next; } /** @@ -486,10 +520,11 @@ export function applyAccountQuotaFromUpstreamHeaders( accountId: string, headers: Headers, writerGeneration = captureConfigGeneration(), + mainWriter?: MainQuotaWriter, ): void { const quota = parseUpstreamQuotaHeaders(headers); if (!quota) return; - setAccountQuotaFromParsed(accountId, quota, writerGeneration); + setAccountQuotaFromParsed(accountId, quota, writerGeneration, mainWriter); } export function updateAccountQuota( @@ -506,6 +541,7 @@ export function updateAccountQuota( const nextWeekly = normalizeUsagePercent(weekly); const nextMonthly = normalizeUsagePercent(monthly); if (nextWeekly === undefined && nextMonthly === undefined && resetCredits === undefined) return; + hydrateAccountQuotasFromDisk(); const quota: StoredAccountQuota = { ...(existing?.weeklyPercent !== undefined ? { weeklyPercent: existing.weeklyPercent } : {}), @@ -543,6 +579,8 @@ export function updateAccountQuota( if (resetCredits !== undefined) quota.resetCredits = resetCredits; accountQuota.set(accountId, quota); + // This legacy writer has no physical credential provenance. + if (accountId === MAIN_CODEX_ACCOUNT_ID) mainPolicyQuota = null; schedulePersistAccountQuotas(); // Observed like the other committed write. This function has no in-repo caller today, but it // is re-exported as public API through src/codex/auth-api.ts, so a future caller would @@ -552,6 +590,26 @@ export function updateAccountQuota( notifyCodexQuotaSnapshot(accountId, quota); } +/** Bounded, known policy fields only: disk input cannot extend a DTO or retain credentials. */ +function readMainPolicyQuota(value: unknown): MainPolicyQuota | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const entry = value as Record; + if (typeof entry.identityKey !== "string" || !/^[a-f0-9]{64}$/.test(entry.identityKey)) return null; + if (!entry.quota || typeof entry.quota !== "object" || Array.isArray(entry.quota)) return null; + const raw = entry.quota as Record; + if (typeof raw.updatedAt !== "number" || !Number.isFinite(raw.updatedAt) || raw.updatedAt < 0) return null; + const quota: StoredAccountQuota = { updatedAt: raw.updatedAt }; + for (const field of [ + "weeklyPercent", "monthlyPercent", "shortPercent", "weeklyResetAt", "monthlyResetAt", + "shortResetAt", "shortWindowSeconds", "resetCredits", + ] as const) { + const number = raw[field]; + if (typeof number === "number" && Number.isFinite(number) && number >= 0) quota[field] = number; + } + if (quota.monthlyPercent !== undefined && raw.monthlyIsPrimaryWindow === true) quota.monthlyIsPrimaryWindow = true; + return { identityKey: entry.identityKey, quota }; +} + function hydrateAccountQuotasFromDisk(): void { if (diskHydrated) return; diskHydrated = true; @@ -561,6 +619,8 @@ function hydrateAccountQuotasFromDisk(): void { const raw = readFileSync(path, "utf8"); const parsed = JSON.parse(raw) as QuotaDiskFile; if (!parsed || parsed.version !== 1 || !parsed.quotas || typeof parsed.quotas !== "object") return; + // Policy evidence deliberately outlives the legacy six-hour rotation-cache TTL. + mainPolicyQuota = readMainPolicyQuota(parsed.mainPolicyQuota); const now = Date.now(); for (const [accountId, quota] of Object.entries(parsed.quotas)) { if (!quota || typeof quota !== "object" || typeof quota.updatedAt !== "number") continue; @@ -581,7 +641,11 @@ function schedulePersistAccountQuotas(): void { for (const [accountId, quota] of accountQuota.entries()) { quotas[accountId] = quota; } - const body: QuotaDiskFile = { version: 1, quotas }; + const body: QuotaDiskFile = { + version: 1, + quotas, + ...(mainPolicyQuota ? { mainPolicyQuota } : {}), + }; atomicWriteFile(join(getConfigDir(), QUOTA_CACHE_FILENAME), `${JSON.stringify(body)}\n`); } catch { // Best-effort persistence only. @@ -594,6 +658,13 @@ export function getAccountQuota(accountId: string): StoredAccountQuota | null { return accountQuota.get(accountId) ?? null; } +/** No physical-auth reads; unrelated legacy quota consumers cannot mutate this evidence. */ +export function getMainPolicyQuota(): StoredAccountQuota | null { + hydrateAccountQuotasFromDisk(); + if (!mainPolicyQuota || mainPolicyQuota.identityKey !== getObservedMainQuotaIdentityKey()) return null; + return structuredClone(mainPolicyQuota.quota); +} + export function listAccountQuotas(): IterableIterator<[string, StoredAccountQuota]> { hydrateAccountQuotasFromDisk(); return accountQuota.entries(); @@ -623,13 +694,16 @@ function forgetCodexQuotaBaseline(accountId?: string): void { export function clearAccountQuota(accountId?: string): void { if (accountId) { + hydrateAccountQuotasFromDisk(); accountQuota.delete(accountId); + if (accountId === MAIN_CODEX_ACCOUNT_ID) mainPolicyQuota = null; schedulePersistAccountQuotas(); forgetCodexQuotaBaseline(accountId); return; } accountQuota.clear(); forgetCodexQuotaBaseline(); + mainPolicyQuota = null; diskHydrated = false; if (persistTimer) { clearTimeout(persistTimer); @@ -723,11 +797,11 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit { const name = String(additional.limit_name ?? "").toLowerCase(); diff --git a/src/config.ts b/src/config.ts index 68764d2f71..0e2a4d9ba0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1113,6 +1113,7 @@ const configSchema = z.object({ // Ultra Fast is opt-in for the same reason and degrades the same way: a malformed hand // edit turns the tier off rather than rejecting the config that carries it. ultraFastTier: z.boolean().optional().catch(false), + codexMainAccountHardLock: z.boolean().optional().catch(false), // Future versions remain opaque through passthrough-compatible whole-config saves. // Only version 1 grants deletion authority in the rebase path. configRebaseProvenance: z.unknown().optional(), diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index 00ca95dd1b..71d6cd79a7 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -78,6 +78,7 @@ export function listOpenAiForwardSidecarCandidates(config: OcxConfig): OpenAiFor function directSidecarHeaders( incomingHeaders: Headers, + config: OcxConfig, ): Headers | undefined { const bearer = incomingHeaders.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); if (!bearer) return undefined; @@ -89,7 +90,7 @@ function directSidecarHeaders( // intentional ChatGPT-auth operation instead of silently reclassifying any JWT-shaped // provider credential as a Codex bearer. if (!requestedAccountId || requestedAccountId !== derivedAccountId) return undefined; - const selected = headersForCodexAuthContext(incomingHeaders, { kind: "main", accountId: null }); + const selected = headersForCodexAuthContext(incomingHeaders, { kind: "main", accountId: null }, config); return selected; } @@ -120,6 +121,7 @@ export async function resolveFirstUsableOpenAiSidecar( modelId: exactAccount.modelId, beginCodexAccountSelection: options.beginCodexAccountSelection, }); + const selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, config); if ((authContext.kind !== "pool" && authContext.kind !== "main-pool") || !isCodexAuthContextUsable(authContext, config)) { // Exact selection is fail-closed. A generation/runtime-state race must not fall through @@ -129,7 +131,7 @@ export async function resolveFirstUsableOpenAiSidecar( return { ...candidate, authContext, - headers: headersForCodexAuthContext(incomingHeaders, authContext), + headers: selectedHeaders, recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome( config, authContext.accountId, @@ -149,7 +151,7 @@ export async function resolveFirstUsableOpenAiSidecar( } if (candidate.accountMode === "direct") { if (!callerBearerMayBeForwarded || !hasCallerCodexBearer(incomingHeaders)) continue; - const headers = directSidecarHeaders(incomingHeaders); + const headers = directSidecarHeaders(incomingHeaders, config); if (!headers) continue; return { ...candidate, @@ -160,11 +162,12 @@ export async function resolveFirstUsableOpenAiSidecar( const authContext = await resolveCodexAuthContext(incomingHeaders, config, candidate.accountMode, { beginCodexAccountSelection: options.beginCodexAccountSelection, }); + const selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, config); if (!isCodexAuthContextUsable(authContext, config)) continue; return { ...candidate, authContext, - headers: headersForCodexAuthContext(incomingHeaders, authContext), + headers: selectedHeaders, ...(authContext.kind === "pool" || authContext.kind === "main-pool" ? { recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome( diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 15305bc5e2..885fe10408 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -49,6 +49,7 @@ import { codexQuotaAutoRefreshStatus, runCodexQuotaAutoRefresh, } from "../../codex/quota-auto-refresh"; +import { getMainAccountHardLockStatus } from "../../codex/main-account-hard-lock"; import { codexAccountPickerEnabled, initializeDefaultCodexAccountNamespaces, @@ -322,6 +323,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { - const { req, authCtx, provider, codexAccountMode, substituteMainCredential, options } = args; + const { req, config, authCtx, provider, codexAccountMode, substituteMainCredential, options } = args; if (authCtx.kind !== "main-pool") { return { ok: false, response: formatErrorResponse(401, "authentication_error", "No native main credential to refresh") }; } @@ -255,6 +256,7 @@ async function refreshNativeMainCompactContext(args: { ); const headers = new Headers({ "content-type": "application/json" }); const selected = await materializeCodexUpstreamAuthAsync(req.headers, refreshedAuthCtx, { + config, substituteMainCredential, signal: req.signal, nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, @@ -273,7 +275,9 @@ async function refreshNativeMainCompactContext(args: { if (req.signal.aborted) { return { ok: false, response: formatErrorResponse(499, "client_cancelled", "Client cancelled compact request") }; } - return { ok: false, response: nativeMainRefreshFailureResponse(error) }; + return { ok: false, response: mapCodexAuthContextErrorToResponse(error, { + now: Date.now(), + }) ?? nativeMainRefreshFailureResponse(error) }; } } @@ -289,6 +293,7 @@ function isTerminalCompactPoolRefreshFailure(error: unknown): boolean { */ async function refreshPoolCompactContext(args: { req: Request; + config: OcxConfig; authCtx: CodexAuthContext & { kind: "pool" }; provider: OcxProviderConfig; codexAccountMode?: CodexAccountMode; @@ -298,7 +303,7 @@ async function refreshPoolCompactContext(args: { | { ok: true; authCtx: CodexAuthContext; provider: OcxProviderConfig; headers: Headers } | { ok: false; response: Response; quarantine: boolean; quarantineGeneration?: number } > { - const { req, authCtx, provider, codexAccountMode, substituteMainCredential, options } = args; + const { req, config, authCtx, provider, codexAccountMode, substituteMainCredential, options } = args; const reauthResponse = () => formatErrorResponse( 401, "authentication_error", @@ -331,6 +336,7 @@ async function refreshPoolCompactContext(args: { ); const headers = new Headers({ "content-type": "application/json" }); const selected = await materializeCodexUpstreamAuthAsync(req.headers, refreshedAuthCtx, { + config, substituteMainCredential, signal: req.signal, nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, @@ -394,7 +400,7 @@ async function resolveAlternateCompactContext(args: { if (authCtx.accountId === excludeAccountId) return null; const provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode); const headers = new Headers({ "content-type": "application/json" }); - const selected = headersForCodexAuthContext(req.headers, authCtx); + const selected = headersForCodexAuthContext(req.headers, authCtx, config); for (const name of FORWARD_HEADERS) { const value = selected.get(name); if (value) headers.set(name, value); @@ -647,6 +653,7 @@ export async function handleResponsesCompact( }); logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config); const selected = await materializeCodexUpstreamAuthAsync(req.headers, authCtx, { + config, substituteMainCredential, signal: req.signal, nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, @@ -838,6 +845,7 @@ export async function handleResponsesCompact( const poolReplay = poolAuthCtx ? await refreshPoolCompactContext({ req, + config, authCtx: poolAuthCtx, provider: compactProvider, codexAccountMode: route.codexAccountMode, @@ -848,6 +856,7 @@ export async function handleResponsesCompact( const replay = poolReplay ?? await refreshNativeMainCompactContext({ req, + config, authCtx, provider: compactProvider, codexAccountMode: route.codexAccountMode, @@ -937,6 +946,7 @@ export async function handleResponsesCompact( authCtx.accountId, upstream.headers, authCtx.writerGeneration, + authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined, ); } recordCompactPoolOutcome(authCtx, upstream.status, { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index a9631da4d1..110a165a0e 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1219,6 +1219,7 @@ async function retryCodexPoolOnAlternateAccount( firstAuthCtx.accountId, firstResponse.headers, firstAuthCtx.writerGeneration, + firstAuthCtx.kind === "main-pool" ? firstAuthCtx.mainQuotaWriter : undefined, ); } const deferFirstOutcome = shouldDeferCodexResetDerivedCooldown( @@ -1240,7 +1241,7 @@ async function retryCodexPoolOnAlternateAccount( // Only a combo reset-derived outcome is deferred. Retry-After, defaults, and // ordinary requests must block the first account before the alternate send. if (!deferFirstOutcome) recordFirstOutcome(); - const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx); + const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx, config); const retryProvider = applyCodexAuthContextToProvider( stripCodexRuntimeProviderFields(route.provider), retryAuthCtx, @@ -1893,6 +1894,19 @@ async function resolveResponsesCodexAuth( authCtx = { kind: "main", accountId: null }; options.onCodexAuthContextResolved?.(undefined); } + // This resolver also builds a synthetic main context for unrelated keyed routes. Only + // the actual Codex-forward transport consumes main quota; provider names are not proof + // (custom-named canonical-forward providers must retain the same protection). + const mainPolicyConfig = isCanonicalOpenAiForwardProvider(route.provider) ? config : undefined; + const headers = await materializeCodexUpstreamAuthAsync(req.headers, authCtx, { + config: mainPolicyConfig, + substituteMainCredential, + signal: options.abortSignal, + nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, + }); + // Awaiting even a cached materialization yields. Preserve the policy error if the live + // quota/config changed during that yield, before usability could mislabel it as reauth. + headersForCodexAuthContext(headers, authCtx, mainPolicyConfig); if (!isCodexAuthContextUsable(authCtx, config)) { releaseCodexAuthContextProbeLease(authCtx); return { @@ -1903,11 +1917,7 @@ async function resolveResponsesCodexAuth( return { ok: true, authCtx, - headers: await materializeCodexUpstreamAuthAsync(req.headers, authCtx, { - substituteMainCredential, - signal: options.abortSignal, - nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, - }), + headers, substituteMainCredential, }; } catch (err) { @@ -1950,6 +1960,7 @@ function isTerminalPoolRefreshFailure(error: unknown): boolean { */ async function refreshPoolForwardAuth(args: { req: Request; + config: OcxConfig; route: RouteResult; authCtx: CodexAuthContext & { kind: "pool" }; substituteMainCredential: boolean; @@ -1958,7 +1969,7 @@ async function refreshPoolForwardAuth(args: { | { ok: true; authCtx: CodexAuthContext; provider: OcxProviderConfig; headers: Headers } | { ok: false; response: Response; quarantine: boolean; quarantineGeneration?: number } > { - const { req, route, authCtx, substituteMainCredential, options } = args; + const { req, config, route, authCtx, substituteMainCredential, options } = args; try { const refreshed = await forceRefreshCodexPoolToken(authCtx.accountId, { rejectedGeneration: authCtx.generation, @@ -1996,6 +2007,7 @@ async function refreshPoolForwardAuth(args: { route.codexAccountMode, ); const headers = await materializeCodexUpstreamAuthAsync(req.headers, refreshedAuthCtx, { + config, substituteMainCredential, signal: options.abortSignal, nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, @@ -2022,6 +2034,7 @@ async function refreshPoolForwardAuth(args: { async function refreshNativeMainForwardAuth(args: { req: Request; + config: OcxConfig; route: RouteResult; authCtx: CodexAuthContext; substituteMainCredential: boolean; @@ -2030,7 +2043,7 @@ async function refreshNativeMainForwardAuth(args: { | { ok: true; authCtx: CodexAuthContext; provider: OcxProviderConfig; headers: Headers } | { ok: false; response: Response } > { - const { req, route, authCtx, substituteMainCredential, options } = args; + const { req, config, route, authCtx, substituteMainCredential, options } = args; if (authCtx.kind !== "main-pool") { return { ok: false, response: formatErrorResponse(401, "authentication_error", "No native main credential to refresh") }; } @@ -2053,6 +2066,7 @@ async function refreshNativeMainForwardAuth(args: { route.codexAccountMode, ); const headers = await materializeCodexUpstreamAuthAsync(req.headers, refreshedAuthCtx, { + config, substituteMainCredential, signal: options.abortSignal, nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, @@ -2062,7 +2076,9 @@ async function refreshNativeMainForwardAuth(args: { if (options.abortSignal?.aborted || req.signal.aborted) { return { ok: false, response: clientCancelledResponse() }; } - return { ok: false, response: nativeMainRefreshFailureResponse(error) }; + return { ok: false, response: mapCodexAuthContextErrorToResponse(error, { + now: Date.now(), accountSelector: route.codexAccountNamespace, + }) ?? nativeMainRefreshFailureResponse(error) }; } } @@ -4353,10 +4369,10 @@ async function handleResponsesInner( try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed */ } const poolAuthCtx = authCtx.kind === "pool" ? authCtx : undefined; const poolReplay = poolAuthCtx - ? await refreshPoolForwardAuth({ req, route, authCtx: poolAuthCtx, substituteMainCredential, options }) + ? await refreshPoolForwardAuth({ req, config, route, authCtx: poolAuthCtx, substituteMainCredential, options }) : undefined; const replay = poolReplay - ?? await refreshNativeMainForwardAuth({ req, route, authCtx, substituteMainCredential, options }); + ?? await refreshNativeMainForwardAuth({ req, config, route, authCtx, substituteMainCredential, options }); if (!replay.ok) { // Compact already records this; core historically returned without recording, // so a dead grant stayed selectable and every request repeated the same doomed @@ -4754,6 +4770,7 @@ async function handleResponsesInner( authCtx.accountId, upstreamResponse.headers, authCtx.writerGeneration, + authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined, ); if (terminalBodyWillRecord) { options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => { diff --git a/src/types/config.ts b/src/types/config.ts index dd71f29f52..8cf1246979 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -393,6 +393,8 @@ export interface OcxConfig { * "no fast tier was requested". */ ultraFastTier?: boolean; + /** Stop new identity-matched main-account requests at observed 99% usage. Default off. */ + codexMainAccountHardLock?: boolean; /** Explicit top-level deletion intent used by stale whole-config rebases. */ configRebaseProvenance?: OcxConfigRebaseProvenance | Record; /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 86365066b5..dcd66180b6 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -80,6 +80,27 @@ Paused or reauthentication-required accounts are skipped, simultaneous 5-hour/weekly resets share one warmup, transient failures retry after five minutes, and account deletion removes its setting and completion markers. +`codexMainAccountHardLock` is a separate opt-in local admission policy, off by default. +It blocks newly admitted identity-matched main-account requests at 99% of the 5h/short window +when present, otherwise the weekly window (monthly for monthly-only accounts). It does not take +the maximum across those windows. Pool alternatives remain eligible; explicit main selection and stored Direct +substitution do not override it. It neither pauses the account nor clears upstream cooldown/reauth +state, and management quota refresh remains available. Passed reset timestamps and fresh lower +readings release this policy. Missing observations are reported as unknown, not fabricated headroom. + +The policy reads a separately retained identity-tagged quota snapshot, so the legacy rotation +cache's six-hour expiry does not silently release a known block. A confirmed account transition +invalidates old evidence. Request-owned bearers are matched only against a credential and effective +workspace already observed under native ownership; an unrelated or unmatched keyring credential +is not attributed to stored main and introduces no physical-main read. Credential equality tags +remain process-local and never enter disk, logs, or management DTOs. + +This is not a reservation of the last 1%: already-admitted, parallel, unmatched-keyring, or direct +upstream traffic can still reach exhaustion. While blocked, main cannot use Luna reserve either. +Keeping ordinary usage below exhaustion may prevent Reserve activation; the policy never changes +OpenAI's Reserve grants or `ordinary_usage_allowed` response. Settings and the main-account DTO +report enabled state separately from current `off`, `unknown`, `ready`, or `blocked` status. + `codexAccountPriorities` is a persisted Pool *ordering* boundary and never an eligibility one. It maps an account id to an integer from -100 to 100, higher used earlier, with absence meaning 0. Selection narrows the already-eligible list to the highest tier that still holds an account with quota headroom diff --git a/tests/codex-integration/main-account-hard-lock-auth.test.ts b/tests/codex-integration/main-account-hard-lock-auth.test.ts new file mode 100644 index 0000000000..d0959817c1 --- /dev/null +++ b/tests/codex-integration/main-account-hard-lock-auth.test.ts @@ -0,0 +1,374 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + CodexAccountCooldownError, + CodexMainAccountHardLockError, + cooldownErrorMessage, + cooldownErrorResponse, + headersForCodexAuthContext, + materializeCodexUpstreamAuthAsync, + resolveCodexAuthContext, + shouldMarkAccountNeedsReauthForCodexAuthFailure, +} from "../../src/codex/auth-context"; +import { isCodexAccountUsable } from "../../src/codex/account-usability"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearAccountNeedsReauth, isAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import * as mainAccount from "../../src/codex/main-account"; +import * as authCollision from "../../src/codex/auth-collision"; +import { + captureMainQuotaWriter, + matchesMainQuotaCredential, + observeMainQuotaCredential, + observeMainQuotaIdentity, +} from "../../src/codex/main-account-cache"; +import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth } from "../../src/codex/routing"; +import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar } from "../../src/providers/openai-sidecar"; +import { mapCodexAuthContextErrorToResponse } from "../../src/server/responses/codex-auth-error"; +import { handleResponses } from "../../src/server/responses/core"; +import { handleResponsesCompact } from "../../src/server/responses/compact"; +import { setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const MAIN = mainAccount.MAIN_CODEX_ACCOUNT_ID; +const accountId = "hard-lock-main-fixture"; +let home: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +let tokenExpiry: number; + +function bearer(expired = false): string { + const payload = Buffer.from(JSON.stringify({ + exp: tokenExpiry - (expired ? 86_460 : 0), + "https://api.openai.com/auth": { chatgpt_account_id: accountId }, + })).toString("base64url"); + return `header.${payload}.signature`; +} + +function config(): OcxConfig { + return { + port: 10100, + defaultProvider: "openai", + codexMainAccountHardLock: true, + autoSwitchThreshold: 0, + activeCodexAccountId: MAIN, + providers: { openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + } }, + codexAccounts: [], + }; +} + +function writeMain(token = bearer()): void { + writeFileSync(join(home, "auth.json"), JSON.stringify({ + tokens: { access_token: token, refresh_token: "fixture-refresh", account_id: accountId }, + })); + reconcileMainCodexAccountRuntimeState(); +} + +function quota(percent: number): void { + const writer = captureMainQuotaWriter(accountId); + if (!writer) throw new Error("fixture identity must be observed first"); + setAccountQuotaFromParsed(MAIN, { shortPercent: percent }, undefined, writer); +} + +function caller(token = bearer(), effectiveAccountId = accountId): Headers { + return new Headers({ authorization: `Bearer ${token}`, "chatgpt-account-id": effectiveAccountId }); +} + +function forbidPhysicalReads(): void { + const forbidden = () => { throw new Error("caller-owned path read physical main"); }; + spyOn(authCollision, "readCodexTokens").mockImplementation(forbidden); + spyOn(authCollision, "getMainChatgptAccountId").mockImplementation(forbidden); + spyOn(mainAccount, "getMainAccountToken").mockImplementation(forbidden); + spyOn(mainAccount, "getValidMainAccountToken").mockImplementation(forbidden); + spyOn(mainAccount, "isMainAccountCredentialUsable").mockImplementation(forbidden); +} + +function addAlternative(cfg: OcxConfig): void { + cfg.codexAccounts = [{ id: "hard-lock-pool", email: "pool@example.test", isMain: false }]; + saveCodexAccountCredential("hard-lock-pool", { + accessToken: "fixture-pool-access", + refreshToken: "fixture-pool-refresh", + expiresAt: Date.now() + 86_400_000, + chatgptAccountId: "fixture-pool-account", + }); +} + +beforeEach(() => { + tokenExpiry = Math.floor(Date.now() / 1000) + 86_400; + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-main-hard-lock-auth-")); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = home; + setIcaclsRunnerForTests(() => ({ success: true, exitCode: 0, timedOut: false, stdout: "" })); + resetMainCodexAccountIdentityTrackingForTests(); + clearAccountQuota(); + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountNeedsReauth(MAIN); + clearAccountNeedsReauth("hard-lock-pool"); + mainAccount.setMainAccountPlan(null); + writeMain(); +}); + +afterEach(() => { + mock.restore(); + clearAccountQuota(); + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountNeedsReauth(MAIN); + clearAccountNeedsReauth("hard-lock-pool"); + resetMainCodexAccountIdentityTrackingForTests(); + mainAccount.setMainAccountPlan(null); + setIcaclsRunnerForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); +}); + +describe("main quota policy at native admission", () => { + test("short-only 99 blocks exact main and main-only Pool without probe or reauth", async () => { + quota(99); + const cfg = config(); + const refresh = spyOn(mainAccount, "getValidMainAccountToken"); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: MAIN })) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); + expect(refresh).not.toHaveBeenCalled(); + expect(getCodexUpstreamHealth(MAIN)).toBeNull(); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + expect(isCodexAccountUsable(cfg, MAIN, { nativeMainSelectionOnly: true })).toBe(false); + }); + + test("eligible added account continues when main is blocked", async () => { + const cfg = config(); + addAlternative(cfg); + quota(99); + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool")) + .resolves.toMatchObject({ kind: "pool", accountId: "hard-lock-pool" }); + }); + + test("request-owned main pin detours to a stored alternative with no physical reads", async () => { + const cfg = config(); + addAlternative(cfg); + cfg.activeCodexAccountPinned = MAIN; + observeMainQuotaCredential(bearer(), accountId); + quota(99); + forbidPhysicalReads(); + await expect(resolveCodexAuthContext(caller(), cfg, "pool", { requestScopedMainCredential: true })) + .resolves.toMatchObject({ kind: "pool", accountId: "hard-lock-pool" }); + }); + + test("Direct and exact caller-owned matching main fail without physical reads", async () => { + observeMainQuotaCredential(bearer(), accountId); + quota(99); + forbidPhysicalReads(); + await expect(resolveCodexAuthContext(caller(), config(), "direct")) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); + await expect(resolveCodexAuthContext(caller(), config(), "pool", { + requestScopedMainCredential: true, accountId: MAIN, + })).rejects.toBeInstanceOf(CodexMainAccountHardLockError); + await expect(resolveCodexAuthContext(caller(), config(), "pool", { + requestScopedMainCredential: true, + })).rejects.toBeInstanceOf(CodexMainAccountHardLockError); + }); + + test("unmatched, spoofed-claim, and conflicting-workspace callers do not inherit main policy", async () => { + observeMainQuotaCredential(bearer(), accountId); + quota(99); + forbidPhysicalReads(); + for (const headers of [caller("opaque-other"), caller(`${bearer()}-different`), caller(bearer(), "other-workspace")]) { + const ctx = await resolveCodexAuthContext(headers, config(), "direct"); + expect(headersForCodexAuthContext(headers, ctx, config()).get("authorization")) + .toBe(headers.get("authorization")); + } + expect(getMainPolicyQuota()?.shortPercent).toBe(99); + }); + + test("selection writer survives to headers; live quota and toggle are checked at materialization", async () => { + const cfg = config(); + quota(98.99); + const ctx = await resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: MAIN }); + expect(ctx.kind).toBe("main-pool"); + if (ctx.kind !== "main-pool") throw new Error("expected stored main context"); + expect(ctx.mainQuotaWriter).toEqual(captureMainQuotaWriter(accountId)); + expect(matchesMainQuotaCredential(ctx.accessToken, ctx.chatgptAccountId)).toBe(true); + cfg.codexMainAccountHardLock = false; + quota(99); + expect(headersForCodexAuthContext(new Headers(), ctx, cfg).get("authorization")).toBe(`Bearer ${bearer()}`); + cfg.codexMainAccountHardLock = true; + expect(() => headersForCodexAuthContext(new Headers(), ctx, cfg)).toThrow(CodexMainAccountHardLockError); + quota(98.99); + expect(() => headersForCodexAuthContext(new Headers(), ctx, cfg)).not.toThrow(); + }); + + test("quota changing while selected main refresh awaits rejects without quarantining it", async () => { + quota(98.99); + await expect(resolveCodexAuthContext(new Headers(), config(), "pool", { + accountId: MAIN, + getValidMainAccountToken: async () => { + await Promise.resolve(); + quota(99); + return { accessToken: bearer(), chatgptAccountId: accountId }; + }, + })).rejects.toBeInstanceOf(CodexMainAccountHardLockError); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + expect(getCodexUpstreamHealth(MAIN)).toBeNull(); + }); + + test("actual Direct substitution rechecks after awaited native refresh", async () => { + writeMain(bearer(true)); + quota(98.99); + const cfg = config(); + cfg.codexMainAccountHardLock = false; + await expect(materializeCodexUpstreamAuthAsync(caller("proxy-admission"), { kind: "main", accountId: null }, { + config: cfg, + substituteMainCredential: true, + nativeMainRefreshDependencies: { refreshToken: async () => { + await Promise.resolve(); + quota(99); + cfg.codexMainAccountHardLock = true; + return { access: bearer(), refresh: "rotated-fixture", expires: Date.now() + 86_400_000, accountId }; + } }, + })).rejects.toBeInstanceOf(CodexMainAccountHardLockError); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + }); + + test("Direct substitution resolver refuses blocked main with an actionable policy error", async () => { + quota(99); + await expect(resolveCodexAuthContext(caller("proxy-admission"), config(), "direct", { + substituteMainCredentialForDirect: true, + beginCodexAccountSelection: () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }), + })).rejects.toBeInstanceOf(CodexMainAccountHardLockError); + }); + + test("Direct sidecar uses the same matched-main policy", async () => { + const cfg = config(); + cfg.providers.openai!.codexAccountMode = "direct"; + observeMainQuotaCredential(bearer(), accountId); + quota(99); + forbidPhysicalReads(); + await expect(resolveFirstUsableOpenAiSidecar(listOpenAiForwardSidecarCandidates(cfg), caller(), cfg)) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); + }); + + test("Responses applies matched-main policy only to the selected Codex-forward transport", async () => { + const cfg = config(); + cfg.providers.openai!.codexAccountMode = "direct"; + cfg.providers["fixture-native"] = { + adapter: "openai-responses", authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + cfg.providers.independent = { + adapter: "openai-responses", authMode: "key", + baseUrl: "https://independent.example.test/v1", apiKey: "independent-fixture-key", + }; + observeMainQuotaCredential(bearer(), accountId); + quota(99); + const sends: Array<{ url: string; authorization: string | null }> = []; + spyOn(globalThis, "fetch").mockImplementation(Object.assign(async ( + input: Parameters[0], init?: Parameters[1], + ) => { + const request = input instanceof Request ? input : new Request(input, init); + sends.push({ url: request.url, authorization: request.headers.get("authorization") }); + return Response.json({ + id: "resp_policy_transport", object: "response", status: "completed", created_at: 1, + model: "fixture-model", output: [], usage: { input_tokens: 1, output_tokens: 0, total_tokens: 1 }, + }); + }, { preconnect() {} })); + const post = (model: string) => handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { ...Object.fromEntries(caller()), "content-type": "application/json" }, + body: JSON.stringify({ model, input: "ping", stream: false }), + }), cfg, { model: "", provider: "" }); + + for (const model of ["gpt-5.6-sol", "fixture-native/gpt-5.6-sol"]) { + const blocked = await post(model); + expect(blocked.status).toBe(429); + expect(await blocked.text()).toContain("codexMainAccountHardLock"); + } + expect(sends).toEqual([]); + const keyed = await post("independent/fixture-model"); + expect(keyed.status).toBe(200); + expect(await keyed.json()).toMatchObject({ id: "resp_policy_transport", status: "completed" }); + expect(sends).toEqual([{ + url: "https://independent.example.test/v1/responses", + authorization: "Bearer independent-fixture-key", + }]); + expect(getMainPolicyQuota()?.shortPercent).toBe(99); + }); + + test("Compact keeps independently keyed OpenAI traffic outside matched-main policy", async () => { + const cfg = config(); + cfg.providers.openai!.codexAccountMode = "direct"; + cfg.providers["openai-apikey"] = { + adapter: "openai-responses", authMode: "key", + baseUrl: "https://api.openai.com/v1", apiKey: "compact-fixture-key", + }; + observeMainQuotaCredential(bearer(), accountId); + quota(99); + const sends: Array<{ url: string; authorization: string | null }> = []; + spyOn(globalThis, "fetch").mockImplementation(Object.assign(async ( + input: Parameters[0], init?: Parameters[1], + ) => { + const request = input instanceof Request ? input : new Request(input, init); + sends.push({ url: request.url, authorization: request.headers.get("authorization") }); + return Response.json({ id: "cmp_policy_transport", object: "response.compaction", output: [] }); + }, { preconnect() {} })); + const post = (model: string) => handleResponsesCompact(new Request("http://localhost/v1/responses/compact", { + method: "POST", + headers: { ...Object.fromEntries(caller()), "content-type": "application/json" }, + body: JSON.stringify({ model, input: [{ role: "user", content: "ping" }] }), + }), cfg, { model: "", provider: "" }); + + const blocked = await post("gpt-5.6-sol"); + expect(blocked.status).toBe(429); + expect(await blocked.text()).toContain("codexMainAccountHardLock"); + expect(sends).toEqual([]); + const keyed = await post("openai-apikey/gpt-5.6-sol"); + expect(keyed.status).toBe(200); + expect(await keyed.json()).toMatchObject({ id: "cmp_policy_transport" }); + expect(sends).toEqual([{ + url: "https://api.openai.com/v1/responses/compact", + authorization: "Bearer compact-fixture-key", + }]); + expect(getMainPolicyQuota()?.shortPercent).toBe(99); + }); + + test("stale writer is retained for rejection rather than converted into an untrusted write", async () => { + quota(98.99); + const ctx = await resolveCodexAuthContext(new Headers(), config(), "pool", { accountId: MAIN }); + if (ctx.kind !== "main-pool") throw new Error("expected stored main context"); + const writer = ctx.mainQuotaWriter; + observeMainQuotaIdentity("replacement-account"); + headersForCodexAuthContext(new Headers(), ctx, config()); + expect(ctx.mainQuotaWriter).toEqual(writer); + }); + + test("canonical cooldown mapping preserves policy instructions without a fake reset deadline", async () => { + const error = new CodexMainAccountHardLockError(); + expect(error).toBeInstanceOf(CodexAccountCooldownError); + expect(shouldMarkAccountNeedsReauthForCodexAuthFailure(error)).toBe(false); + expect(cooldownErrorMessage(error)).toContain("codexMainAccountHardLock"); + expect(cooldownErrorMessage(error)).not.toContain("clear-cooldown"); + const response = mapCodexAuthContextErrorToResponse(error, { now: Date.now() }); + expect(response?.status).toBe(429); + expect(response?.headers.has("retry-after")).toBe(false); + expect(await response?.text()).not.toContain(accountId); + const now = Date.now(); + expect(cooldownErrorResponse(new CodexMainAccountHardLockError(now + 60_000), now).headers.get("retry-after")) + .toBe("60"); + }); +}); diff --git a/tests/codex-integration/main-account-hard-lock-policy.test.ts b/tests/codex-integration/main-account-hard-lock-policy.test.ts new file mode 100644 index 0000000000..c3beb359a9 --- /dev/null +++ b/tests/codex-integration/main-account-hard-lock-policy.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "../../src/codex/main-account-hard-lock"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; +import { clearAccountQuota, setAccountQuotaFromParsed, type StoredAccountQuota } from "../../src/codex/quota"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const now = Date.UTC(2026, 8, 5); +const enabled = { codexMainAccountHardLock: true }; +let home: string; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-main-policy-")); + process.env.OPENCODEX_HOME = home; + clearAccountQuota(); + clearMainAccountInfoCache(); + observeMainQuotaIdentity("policy-account-a"); +}); + +afterEach(() => { + clearAccountQuota(); + clearMainAccountInfoCache(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +function observe(quota: Omit): void { + const writer = captureMainQuotaWriter("policy-account-a"); + if (!writer) throw new Error("fixture identity was not observed"); + setAccountQuotaFromParsed("__main__", quota, undefined, writer); +} + +describe("identity-bound main-account hard-lock policy", () => { + test("absent and disabled preserve admission even at 100", () => { + observe({ weeklyPercent: 100 }); + expect(getMainAccountHardLockStatus({}, now)).toEqual({ enabled: false, state: "off" }); + expect(isMainAccountHardLocked({ codexMainAccountHardLock: false }, now)).toBe(false); + }); + + test("unknown is not a fabricated empty or exhausted quota", () => { + expect(getMainAccountHardLockStatus(enabled, now)).toEqual({ enabled: true, state: "unknown" }); + setAccountQuotaFromParsed("__main__", { weeklyPercent: 100 }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe("unknown"); + }); + + test.each([98.99, 99, 100])("raw %s percent is compared without GUI rounding", percent => { + observe({ weeklyPercent: percent }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe(percent < 99 ? "ready" : "blocked"); + }); + + test("a short-only 99 reading blocks despite the rotation scorer's unknown sentinel", () => { + observe({ shortPercent: 99 }); + expect(isMainAccountHardLocked(enabled, now)).toBe(true); + }); + + test("reset times accept seconds and milliseconds and unlock at the boundary", () => { + observe({ weeklyPercent: 99, weeklyResetAt: (now + 60_000) / 1000, shortPercent: 100, shortResetAt: now + 120_000 }); + expect(getMainAccountHardLockStatus(enabled, now)).toEqual({ enabled: true, state: "blocked", resetAt: now + 120_000 }); + expect(getMainAccountHardLockStatus(enabled, now + 60_000).state).toBe("blocked"); + expect(getMainAccountHardLockStatus(enabled, now + 120_000).state).toBe("unknown"); + }); + + test("one missing reset prevents a false scheduled-unlock promise", () => { + observe({ weeklyPercent: 99, monthlyPercent: 99, monthlyResetAt: now + 60_000 }); + expect(getMainAccountHardLockStatus(enabled, now)).toEqual({ enabled: true, state: "blocked" }); + expect(isMainAccountHardLocked(enabled, now + 24 * 60 * 60_000)).toBe(true); + }); + + test("a fresh lower reading clears this policy", () => { + observe({ weeklyPercent: 99 }); + observe({ weeklyPercent: 0 }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe("ready"); + }); + + test("5h usage wins over a higher weekly window", () => { + observe({ shortPercent: 98, shortWindowSeconds: 18_000, weeklyPercent: 100 }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe("ready"); + observe({ shortPercent: 99, shortWindowSeconds: 18_000, weeklyPercent: 20 }); + expect(isMainAccountHardLocked(enabled, now)).toBe(true); + }); + + test("an expired 5h window does not fall back to the high weekly bar", () => { + observe({ shortPercent: 99, shortWindowSeconds: 18_000, shortResetAt: now / 1000, weeklyPercent: 100 }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe("unknown"); + }); + + test("a known 5h shape with no percentage stays unknown instead of selecting weekly", () => { + observe({ shortWindowSeconds: 18_000, weeklyPercent: 100 }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe("unknown"); + }); + + test("weekly-only accounts do not use a higher monthly bar", () => { + observe({ weeklyPercent: 98, monthlyPercent: 100 }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe("ready"); + }); + + test("monthly-only accounts use their available window", () => { + observe({ monthlyPercent: 99 }); + expect(isMainAccountHardLocked(enabled, now)).toBe(true); + }); + + test("model-specific custom windows do not become a global main block", () => { + observe({ weeklyPercent: 12, customWindows: [{ label: "Spark", percent: 100 }] }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe("ready"); + }); + + test.each([Number.NaN, Number.POSITIVE_INFINITY, -1, 101])("invalid observation %s is unknown", percent => { + observe({ weeklyPercent: percent }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe("unknown"); + }); + + test("another physical identity cannot inherit a retained block", () => { + observe({ weeklyPercent: 100 }); + observeMainQuotaIdentity("policy-account-b"); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe("unknown"); + }); +}); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts new file mode 100644 index 0000000000..93a4b6b19e --- /dev/null +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -0,0 +1,393 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; +import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; +import { + applyConfirmedMainCodexAccountTransition, + reconcileMainCodexAccountRuntimeState, + resetMainCodexAccountIdentityTrackingForTests, +} from "../../src/codex/account-lifecycle"; +import * as authCollision from "../../src/codex/auth-collision"; +import { + captureMainQuotaWriter, + clearMainAccountInfoCache, + getObservedMainQuotaIdentityKey, + isMainQuotaWriterLive, + matchesMainQuotaCredential, + observeMainQuotaCredential, + observeMainQuotaIdentity, + type MainQuotaWriter, +} from "../../src/codex/main-account-cache"; +import { + applyAccountQuotaFromUpstreamHeaders, + clearAccountQuota, + getAccountQuota, + getMainPolicyQuota, + listAccountQuotas, + parseUsageQuota, + setAccountQuotaFromParsed, + updateAccountQuota, + type StoredAccountQuota, +} from "../../src/codex/quota"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let testDir: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +let pendingPersist: { run: () => void; timer: ReturnType } | undefined; +let timerSpy: ReturnType; + +// Exercise the real debounced serializer deterministically, without sleeping or exporting +// a production flush hook. Only quota's 250ms timeout is captured; all others stay native. +function installPersistenceClock() { + const nativeSetTimeout = globalThis.setTimeout; + return spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[] + ) => { + if (delay !== 250) return nativeSetTimeout(callback, delay, ...args); + const timer = nativeSetTimeout(() => {}, 60_000); + pendingPersist = { run: () => callback(...args), timer }; + return timer; + }) as typeof setTimeout); +} + +function flushPersistence(): string { + if (!pendingPersist) throw new Error("Expected a scheduled quota persistence"); + const pending = pendingPersist; + pendingPersist = undefined; + clearTimeout(pending.timer); + pending.run(); + return readFileSync(join(testDir, "codex-quota-cache.json"), "utf8"); +} + +function writerFor(accountId = "fixture-main-a"): MainQuotaWriter { + observeMainQuotaIdentity(accountId); + const writer = captureMainQuotaWriter(accountId); + if (!writer) throw new Error("Expected an observed main quota writer"); + return writer; +} + +function writeSnapshot(value: unknown): void { + writeFileSync(join(testDir, "codex-quota-cache.json"), JSON.stringify(value)); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-main-provenance-")); + process.env.OPENCODEX_HOME = testDir; + process.env.CODEX_HOME = testDir; + clearAccountQuota(); + resetMainCodexAccountIdentityTrackingForTests(); + clearMainAccountInfoCache(); + observeMainQuotaIdentity("fixture-unobserved-for-this-test"); + pendingPersist = undefined; + timerSpy = installPersistenceClock(); +}); + +afterEach(() => { + clearAccountQuota(); + clearMainAccountInfoCache(); + timerSpy.mockRestore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(testDir); +}); + +describe("main quota credential provenance", () => { + test("credential observation cannot establish or switch physical identity", () => { + const writer = writerFor(); + expect(observeMainQuotaCredential("fixture-bearer-b", "fixture-main-b")).toBeUndefined(); + expect(captureMainQuotaWriter("fixture-main-b")).toBeUndefined(); + expect(getObservedMainQuotaIdentityKey()).toBe(writer.identityKey); + expect(observeMainQuotaCredential("", "fixture-main-a")).toBeUndefined(); + }); + + test("credential equality requires exact bearer, effective workspace, and live generation", () => { + const writer = writerFor(); + observeMainQuotaCredential("fixture-bearer-a", "fixture-main-a"); + expect(matchesMainQuotaCredential("fixture-bearer-a", "fixture-main-a")).toBe(true); + expect(matchesMainQuotaCredential("fixture-bearer-a", "fixture-main-b")).toBe(false); + expect(matchesMainQuotaCredential("fixture-bearer-b", "fixture-main-a")).toBe(false); + expect(matchesMainQuotaCredential("fixture-bearer-a", undefined)).toBe(false); + observeMainQuotaIdentity("fixture-main-a"); + expect(isMainQuotaWriterLive(writer)).toBe(true); + clearMainAccountInfoCache(); + expect(isMainQuotaWriterLive(writer)).toBe(false); + expect(matchesMainQuotaCredential("fixture-bearer-a", "fixture-main-a")).toBe(false); + expect(getObservedMainQuotaIdentityKey()).toBe(writer.identityKey); + }); + + test("replacement owned token supersedes equality without changing account quota ownership", () => { + const writer = writerFor(); + observeMainQuotaCredential("fixture-old-token", "fixture-main-a"); + observeMainQuotaCredential("fixture-new-token", "fixture-main-a"); + expect(matchesMainQuotaCredential("fixture-old-token", "fixture-main-a")).toBe(false); + expect(matchesMainQuotaCredential("fixture-new-token", "fixture-main-a")).toBe(true); + expect(isMainQuotaWriterLive(writer)).toBe(true); + }); + + test("policy lookup and equality matching never read physical auth", () => { + const writer = writerFor(); + observeMainQuotaCredential("fixture-bearer-a", "fixture-main-a"); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 99 }, undefined, writer); + const physicalRead = spyOn(authCollision, "readCodexTokensResult").mockImplementation(() => { + throw new Error("Physical auth read forbidden"); + }); + try { + expect(matchesMainQuotaCredential("fixture-bearer-a", "fixture-main-a")).toBe(true); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + expect(physicalRead).not.toHaveBeenCalled(); + } finally { + physicalRead.mockRestore(); + } + }); +}); + +describe("main policy quota writes", () => { + test("legacy data remains public but cannot be blessed by a tagged credits-only write", () => { + const writer = writerFor(); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 99, shortPercent: 100, resetCredits: 8 }); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(99); + expect(getMainPolicyQuota()).toBeNull(); + setAccountQuotaFromParsed(MAIN, { resetCredits: 2 }, undefined, writer); + expect(getMainPolicyQuota()).toEqual({ resetCredits: 2, updatedAt: expect.any(Number) }); + expect(getAccountQuota(MAIN)).toMatchObject({ weeklyPercent: 99, shortPercent: 100, resetCredits: 2 }); + }); + + test("different identity cannot inherit old windows and ABA writers are rejected", () => { + const oldA = writerFor(); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 99, monthlyPercent: 100 }, undefined, oldA); + const writerB = writerFor("fixture-main-b"); + expect(getMainPolicyQuota()).toBeNull(); + setAccountQuotaFromParsed(MAIN, { resetCredits: 1 }, undefined, writerB); + expect(getMainPolicyQuota()?.weeklyPercent).toBeUndefined(); + const newA = writerFor(); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 10 }, undefined, newA); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 100 }, undefined, oldA); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(10); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(10); + expect(isMainQuotaWriterLive(oldA)).toBe(false); + }); + + test("shared merger preserves partial fields, explicit zero, and monthly-only weekly clearing", () => { + const writer = writerFor(); + setAccountQuotaFromParsed(MAIN, { + weeklyPercent: 99, shortPercent: 98, shortWindowSeconds: 18_000, resetCredits: 4, + }, undefined, writer); + setAccountQuotaFromParsed(MAIN, { resetCredits: 0 }, undefined, writer); + expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 99, shortPercent: 98, resetCredits: 0 }); + setAccountQuotaFromParsed(MAIN, { monthlyPercent: 15, monthlyIsPrimaryWindow: true }, undefined, writer); + expect(getMainPolicyQuota()?.weeklyPercent).toBeUndefined(); + expect(getMainPolicyQuota()).toMatchObject({ monthlyPercent: 15, monthlyIsPrimaryWindow: true, shortPercent: 98 }); + expect(getAccountQuota(MAIN)).toEqual(getMainPolicyQuota()); + }); + + test("tertiary-only monthly headers preserve weekly99 policy; monthly-primary can replace it", () => { + const writer = writerFor(); + const enabled = { codexMainAccountHardLock: true }; + applyAccountQuotaFromUpstreamHeaders(MAIN, new Headers({ + "x-codex-primary-used-percent": "99", + "x-codex-primary-window-minutes": "10080", + }), undefined, writer); + expect(getMainAccountHardLockStatus(enabled).state).toBe("blocked"); + + applyAccountQuotaFromUpstreamHeaders(MAIN, new Headers({ + "x-codex-tertiary-used-percent": "5", + }), undefined, writer); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBeUndefined(); + expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 99, monthlyPercent: 5 }); + expect(getMainPolicyQuota()?.monthlyIsPrimaryWindow).toBeUndefined(); + expect(getMainAccountHardLockStatus(enabled).state).toBe("blocked"); + + applyAccountQuotaFromUpstreamHeaders(MAIN, new Headers({ + "x-codex-primary-used-percent": "6", + "x-codex-primary-window-minutes": "43200", + }), undefined, writer); + expect(getMainPolicyQuota()?.weeklyPercent).toBeUndefined(); + expect(getMainPolicyQuota()).toMatchObject({ monthlyPercent: 6, monthlyIsPrimaryWindow: true }); + expect(getMainAccountHardLockStatus(enabled).state).toBe("ready"); + }); + + for (const plan of ["go", "free"]) { + for (const [weekly, monthly, state] of [[98, 99, "blocked"], [99, 20, "ready"]] as const) { + test(`${plan} monthly-primary ${monthly} replaces same-owner weekly ${weekly}`, () => { + const writer = writerFor(); + setAccountQuotaFromParsed(MAIN, parseUsageQuota({ + plan_type: "plus", + rate_limit: { primary_window: { used_percent: weekly, limit_window_seconds: 604_800 } }, + }), undefined, writer); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(weekly); + const monthlyQuota = parseUsageQuota({ + plan_type: plan, + rate_limit: { primary_window: { used_percent: monthly, limit_window_seconds: 2_592_000 } }, + }); + expect(monthlyQuota).toEqual({ monthlyPercent: monthly, monthlyIsPrimaryWindow: true }); + setAccountQuotaFromParsed(MAIN, monthlyQuota, undefined, writer); + expect(getMainPolicyQuota()).toEqual({ + monthlyPercent: monthly, monthlyIsPrimaryWindow: true, updatedAt: expect.any(Number), + }); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe(state); + }); + } + + test(`${plan} supplementary monthly is not a monthly-primary replacement`, () => { + const writer = writerFor(); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 99 }, undefined, writer); + const monthlyQuota = parseUsageQuota({ plan_type: plan, rate_limit: { + primary_window: { limit_window_seconds: 2_592_000 }, + tertiary_window: { used_percent: 20 }, + } }); + expect(monthlyQuota).toEqual({ monthlyPercent: 20 }); + setAccountQuotaFromParsed(MAIN, monthlyQuota, undefined, writer); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe("blocked"); + }); + } + + test("header writer carries provenance and untagged main writes invalidate it", () => { + const writer = writerFor(); + const headers = new Headers({ "x-codex-primary-used-percent": "99" }); + applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + setAccountQuotaFromParsed("fixture-pool", { weeklyPercent: 7 }); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + applyAccountQuotaFromUpstreamHeaders(MAIN, headers); + expect(getMainPolicyQuota()).toBeNull(); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 99 }, undefined, writer); + updateAccountQuota(MAIN, 20); + expect(getMainPolicyQuota()).toBeNull(); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(20); + expect(JSON.parse(flushPersistence()).mainPolicyQuota).toBeUndefined(); + }); + + test("public quota mutation and serializers cannot expose or mutate policy provenance", () => { + const writer = writerFor(); + observeMainQuotaCredential("fixture-private-bearer", "fixture-main-a"); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 99 }, undefined, writer); + getAccountQuota(MAIN)!.weeklyPercent = 0; + getMainPolicyQuota()!.weeklyPercent = 0; + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + const publicJson = JSON.stringify(Object.fromEntries(listAccountQuotas())); + expect(publicJson).not.toContain("identityKey"); + const disk = flushPersistence(); + expect(disk).not.toContain("fixture-private-bearer"); + expect(disk).not.toContain("fixture-main-a"); + expect(disk).not.toContain("bearerHmac"); + expect(disk).not.toContain("identityGeneration"); + expect(Object.keys(JSON.parse(disk).mainPolicyQuota).sort()).toEqual(["identityKey", "quota"]); + }); +}); + +describe("main policy quota durability and lifecycle", () => { + for (const resetAt of [undefined, 4_000_000_000]) { + test(`restart beyond six hours retains ${resetAt ? "future-reset" : "missing-reset"} policy evidence only for observed A`, () => { + const writer = writerFor(); + const quota: StoredAccountQuota = { + weeklyPercent: 99, updatedAt: Date.now() - 7 * 60 * 60_000, + ...(resetAt ? { weeklyResetAt: resetAt } : {}), + }; + writeSnapshot({ version: 1, quotas: { [MAIN]: quota }, mainPolicyQuota: { identityKey: writer.identityKey, quota } }); + const script = ` + import { getAccountQuota, getMainPolicyQuota } from ${JSON.stringify(repoPath("src/codex/quota.ts"))}; + import { observeMainQuotaIdentity, matchesMainQuotaCredential } from ${JSON.stringify(repoPath("src/codex/main-account-cache.ts"))}; + const before = getMainPolicyQuota(); + observeMainQuotaIdentity("fixture-main-b"); + const other = getMainPolicyQuota(); + observeMainQuotaIdentity("fixture-main-a"); + console.log(JSON.stringify({ before, other, legacy: getAccountQuota("__main__"), policy: getMainPolicyQuota(), + credentialMatches: matchesMainQuotaCredential("fixture-bearer-a", "fixture-main-a") })); + `; + const child = Bun.spawnSync({ + cmd: [process.execPath, "--eval", script], cwd: repoRoot(), env: process.env, timeout: 10_000, + }); + expect(child.exitCode).toBe(0); + const result = JSON.parse(child.stdout.toString()); + expect(result.before).toBeNull(); + expect(result.other).toBeNull(); + expect(result.legacy).toBeNull(); + expect(result.policy).toEqual(quota); + expect(result.credentialMatches).toBe(false); + }); + } + + test("unrelated persistence hydrates and retains policy after legacy TTL expiry", () => { + const writer = writerFor(); + const quota = { weeklyPercent: 99, updatedAt: Date.now() - 7 * 60 * 60_000 }; + writeSnapshot({ version: 1, quotas: { [MAIN]: quota }, mainPolicyQuota: { identityKey: writer.identityKey, quota } }); + setAccountQuotaFromParsed("fixture-pool", { weeklyPercent: 12 }); + const saved = JSON.parse(flushPersistence()); + expect(saved.quotas[MAIN]).toBeUndefined(); + expect(saved.mainPolicyQuota.quota).toEqual(quota); + expect(getMainPolicyQuota()).toEqual(quota); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: false }).state).toBe("off"); + expect(getAccountQuota(MAIN)).toBeNull(); + setAccountQuotaFromParsed(MAIN, { resetCredits: 0 }, undefined, writer); + expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 99, resetCredits: 0 }); + expect(getAccountQuota(MAIN)).toEqual({ resetCredits: 0, updatedAt: expect.any(Number) }); + const afterCredits = JSON.parse(flushPersistence()); + expect(afterCredits.quotas[MAIN].weeklyPercent).toBeUndefined(); + expect(afterCredits.mainPolicyQuota.quota.weeklyPercent).toBe(99); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe("blocked"); + clearAccountQuota("fixture-pool"); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + clearAccountQuota(MAIN); + expect(getMainPolicyQuota()).toBeNull(); + expect(JSON.parse(flushPersistence()).mainPolicyQuota).toBeUndefined(); + }); + + test("clear before first hydration cannot resurrect disk policy", () => { + const writer = writerFor(); + writeSnapshot({ version: 1, quotas: {}, mainPolicyQuota: { + identityKey: writer.identityKey, quota: { weeklyPercent: 99, updatedAt: Date.now() }, + } }); + clearAccountQuota(MAIN); + expect(getMainPolicyQuota()).toBeNull(); + }); + + test("legacy untagged disk quota remains untrusted after owned identity observation", () => { + const writer = writerFor(); + writeSnapshot({ version: 1, quotas: { [MAIN]: { weeklyPercent: 99, updatedAt: Date.now() } } }); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(99); + expect(getMainPolicyQuota()).toBeNull(); + setAccountQuotaFromParsed(MAIN, { resetCredits: 1 }, undefined, writer); + expect(getMainPolicyQuota()?.weeklyPercent).toBeUndefined(); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(99); + }); + + test("disk policy accepts only bounded known fields and valid owner keys", () => { + const writer = writerFor(); + writeSnapshot({ version: 1, quotas: {}, mainPolicyQuota: { identityKey: writer.identityKey, quota: { + weeklyPercent: 99, monthlyPercent: "100", shortPercent: null, shortResetAt: -1, + updatedAt: 1, bearerHmac: "must-not-load", customWindows: [{ label: "untrusted", percent: 100 }], + } } }); + expect(getMainPolicyQuota()).toEqual({ weeklyPercent: 99, updatedAt: 1 }); + clearAccountQuota(); + writeSnapshot({ version: 1, quotas: {}, mainPolicyQuota: { + identityKey: "not-an-identity-key", quota: { weeklyPercent: 99, updatedAt: 1 }, + } }); + expect(getMainPolicyQuota()).toBeNull(); + }); + + test("owned reconciliation publishes identity and confirmed transitions purge policy and equality", () => { + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: { + access_token: "fixture-bearer-a", account_id: "fixture-main-a", + } })); + expect(reconcileMainCodexAccountRuntimeState()).toBe(false); + const writer = observeMainQuotaCredential("fixture-bearer-a", "fixture-main-a"); + expect(writer).toBeDefined(); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 99 }, undefined, writer); + writeFileSync(join(testDir, "auth.json"), "{"); + expect(reconcileMainCodexAccountRuntimeState()).toBe(false); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(99); + expect(applyConfirmedMainCodexAccountTransition("fixture-main-a", "fixture-main-b")).toBe(true); + expect(captureMainQuotaWriter("fixture-main-b")).toBeDefined(); + expect(getMainPolicyQuota()).toBeNull(); + expect(matchesMainQuotaCredential("fixture-bearer-a", "fixture-main-a")).toBe(false); + }); +}); diff --git a/tests/config/settings-main-account-hard-lock.test.ts b/tests/config/settings-main-account-hard-lock.test.ts new file mode 100644 index 0000000000..3bc3225130 --- /dev/null +++ b/tests/config/settings-main-account-hard-lock.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getConfigPath, loadConfig, saveConfig } from "../../src/config"; +import { handleManagementAPI, type ManagementApiDeps } from "../../src/server/management-api"; +import { invalidateStartupHealthCache } from "../../src/server/startup-health-cache"; +import type { OcxConfig } from "../../src/types"; +import { startupHealthFixture } from "../helpers/startup-health"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let home: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +const config = (): OcxConfig => ({ + port: 10100, + defaultProvider: "example", + providers: { example: { adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "fixture" } }, +}); + +function request(cfg: OcxConfig, body?: unknown, overrides: Partial = {}) { + const req = new Request("http://127.0.0.1:10100/api/settings", { + method: body === undefined ? "GET" : "PUT", + headers: { host: "127.0.0.1:10100", "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + return handleManagementAPI(req, new URL(req.url), cfg, { + getCachedStartupHealth: async () => startupHealthFixture(), + ...overrides, + }); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-hard-lock-settings-")); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = home; + invalidateStartupHealthCache(); +}); + +afterEach(() => { + invalidateStartupHealthCache(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); +}); + +describe("main-account 99 percent setting", () => { + test("GET reports off without an implicit opt-in", async () => { + const response = await request(config()); + expect(await response!.json()).toMatchObject({ + codexMainAccountHardLock: false, + mainAccountHardLock: { enabled: false, state: "off" }, + }); + }); + + test("PUT acknowledges the stored boolean and survives reload", async () => { + const cfg = config(); + saveConfig(cfg); + const response = await request(cfg, { codexMainAccountHardLock: true }); + expect(response!.status).toBe(200); + expect(await response!.json()).toMatchObject({ ok: true, codexMainAccountHardLock: true }); + expect(loadConfig().codexMainAccountHardLock).toBe(true); + expect(cfg.providers.example.baseUrl).toBe("https://example.test/v1"); + }); + + test("disabling deletes only this key and preserves other account controls", async () => { + const cfg = { ...config(), codexMainAccountHardLock: true, pausedCodexAccountIds: ["__main__"], autoSwitchThreshold: 73 }; + saveConfig(cfg); + const response = await request(cfg, { codexMainAccountHardLock: false }); + expect(await response!.json()).toMatchObject({ ok: true, codexMainAccountHardLock: false }); + expect(Object.hasOwn(cfg, "codexMainAccountHardLock")).toBe(false); + const disk = JSON.parse(readFileSync(getConfigPath(), "utf8")); + expect(Object.hasOwn(disk, "codexMainAccountHardLock")).toBe(false); + expect(cfg.pausedCodexAccountIds).toEqual(["__main__"]); + expect(cfg.autoSwitchThreshold).toBe(73); + }); + + test.each(["true", 99, null, [], {}])("rejects nonboolean %j without mutation", async value => { + const cfg = config(); + const response = await request(cfg, { codexMainAccountHardLock: value }); + expect(response!.status).toBe(400); + expect(Object.hasOwn(cfg, "codexMainAccountHardLock")).toBe(false); + }); + + test("persistence failure restores absent and present values exactly", async () => { + for (const previous of [undefined, false, true]) { + const cfg = config(); + if (previous !== undefined) cfg.codexMainAccountHardLock = previous; + await expect(request(cfg, { codexMainAccountHardLock: previous !== true }, { + saveConfigPreservingClaudeCode: () => { throw new Error("fixture save failure"); }, + })).rejects.toThrow("fixture save failure"); + expect(cfg.codexMainAccountHardLock).toBe(previous); + expect(Object.hasOwn(cfg, "codexMainAccountHardLock")).toBe(previous !== undefined); + } + }); + + test("malformed hand edits remain off", () => { + saveConfig(config()); + const path = getConfigPath(); + const disk = JSON.parse(readFileSync(path, "utf8")); + writeFileSync(path, JSON.stringify({ ...disk, codexMainAccountHardLock: "yes" })); + expect(loadConfig().codexMainAccountHardLock).toBe(false); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 36602be986..f7f482c981 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -601,6 +601,9 @@ "loopback-listener-admission.test.ts": "server", "loopback-listener-integration.test.ts": "server", "management-api-logs-metrics.test.ts": "server", + "main-account-hard-lock-auth.test.ts": "codex-integration", + "main-account-hard-lock-policy.test.ts": "codex-integration", + "main-quota-provenance.test.ts": "codex-integration", "management-client-config-route.test.ts": "server", "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", @@ -908,6 +911,7 @@ "session-affinity.test.ts": "server", "session-lane-recall-harness.test.ts": "server", "settings-oauth-open-browser.test.ts": "config", + "settings-main-account-hard-lock.test.ts": "config", "settings-startup-health-seam.test.ts": "config", "settings-stream-mode.test.ts": "config", "shutdown-drain.test.ts": "service", From b9a71c078195acc8f9132a9d732a5c559e4fa8e9 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 08:29:13 +0900 Subject: [PATCH 03/11] test(codex): cover automatic zero-reset unlock and rearming --- .../013_window_priority_steering.md | 2 ++ .../main-account-hard-lock-policy.test.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/devlog/_plan/260905_main_quota_guard/013_window_priority_steering.md b/devlog/_plan/260905_main_quota_guard/013_window_priority_steering.md index 248cda3ba4..966720c615 100644 --- a/devlog/_plan/260905_main_quota_guard/013_window_priority_steering.md +++ b/devlog/_plan/260905_main_quota_guard/013_window_priority_steering.md @@ -7,3 +7,5 @@ Acceptance changes (not reduced verification): choose the observed short/5h tupl Implementation: only the main-owned policy helper and policy tests change; identity, destination, maintenance and no-suite rules remain unchanged. UI copy in wp2 must say 5h first, weekly otherwise; monthly-only accounts retain their governing window. Add short98/weekly100 -> ready, short99/weekly20 -> blocked, expired short/weekly99 -> unknown, weekly98/monthly100 -> ready, and monthly-only99 -> blocked. All reviewers/workers receive this steering; their existing identity/tertiary/TTL findings remain applicable when the selected account has no short window. + +Second owner clarification: a fresh 0% reset must automatically release the block. The opt-in remains enabled and rearms at99. Explicit regression sequence for both short and weekly:99 blocked ->0 ready with enabled=true ->99 blocked again. No manual clear or toggle cycle is required. diff --git a/tests/codex-integration/main-account-hard-lock-policy.test.ts b/tests/codex-integration/main-account-hard-lock-policy.test.ts index c3beb359a9..4b36fa67a8 100644 --- a/tests/codex-integration/main-account-hard-lock-policy.test.ts +++ b/tests/codex-integration/main-account-hard-lock-policy.test.ts @@ -71,10 +71,13 @@ describe("identity-bound main-account hard-lock policy", () => { expect(isMainAccountHardLocked(enabled, now + 24 * 60 * 60_000)).toBe(true); }); - test("a fresh lower reading clears this policy", () => { - observe({ weeklyPercent: 99 }); - observe({ weeklyPercent: 0 }); - expect(getMainAccountHardLockStatus(enabled, now).state).toBe("ready"); + test.each(["shortPercent", "weeklyPercent"] as const)("%s resets to zero, unlocks, and rearms at 99 without disabling", field => { + observe({ [field]: 99 }); + expect(isMainAccountHardLocked(enabled, now)).toBe(true); + observe({ [field]: 0 }); + expect(getMainAccountHardLockStatus(enabled, now)).toEqual({ enabled: true, state: "ready" }); + observe({ [field]: 99 }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe("blocked"); }); test("5h usage wins over a higher weekly window", () => { From dcbc9a8c20ed052f7f09bed25d90676f481d057c Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 08:40:15 +0900 Subject: [PATCH 04/11] fix(codex): retain unknown 5h window shape and verify quota reset contracts --- .../012_implementation_review.md | 8 + .../014_runtime_evidence.md | 9 + scripts/test-layout/layout.json | 1 + src/codex/quota.ts | 29 +-- .../codex-main-rotation.test.ts | 8 + .../main-quota-window-observation.test.ts | 179 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + .../loopback-listener-integration.test.ts | 29 +-- 8 files changed, 239 insertions(+), 25 deletions(-) create mode 100644 tests/codex-integration/main-quota-window-observation.test.ts diff --git a/devlog/_plan/260905_main_quota_guard/012_implementation_review.md b/devlog/_plan/260905_main_quota_guard/012_implementation_review.md index 490305f3bf..b0090bd66f 100644 --- a/devlog/_plan/260905_main_quota_guard/012_implementation_review.md +++ b/devlog/_plan/260905_main_quota_guard/012_implementation_review.md @@ -22,3 +22,11 @@ This is compatible with the provenance repairs: those determine whose quota; thi ## Provenance/policy round 2 Original tertiary-clearing and legacy-TTL findings are closed; user5h-first selection is accepted. Reviewer found a different producer gap: Go/Free WHAM monthly-primary parsing does not emit `monthlyIsPrimaryWindow`, so a same-owner transition from weekly to monthly leaves stale weekly evidence selected. Accept the finding. Preserve the existing provenance flag whenever a genuinely explicit monthly primary is parsed, including Go/Free; supplementary-only monthly remains insufficient. Add parser -> same-owner store -> policy coverage for weekly98 to monthly99 and weekly99 to monthly20, with no short tuple. This fixes the producer rather than weakening the policy merger. + +## Fresh C acceptance audit + +Feynman found a reachable parser gap under the owner's5h-first rule: a declared5h primary window without a percentage loses its shape, so a weekly99 secondary becomes the selected policy window. Accept as a blocker. Preserve declared short-window duration/reset independently of percentage validity in WHAM and equivalent header parsing; allow a metadata-only parsed snapshot to reach the store where needed. No fabricated0 value. Add a real owned WHAM -> writer -> storage -> policy regression and header counterpart; the direct helper test alone was insufficient. + +The legacy rotation exact-context fixture extension also encountered standalone TypeScript errors in unchanged fetch mocks (`fetch.preconnect`), not in the new expectations. Do not repair unrelated legacy typing or claim that isolated legacy file typechecked. CI owns its actual execution. + +Main follow-up on the metadata repair: an existing trusted short99 tuple must not be erased by a later metadata-only short snapshot. Unknown is not a lower reading. Preserve an already observed short tuple as a whole when the incoming short percentage is absent; do not pair the old percentage with a different new reset deadline. Fresh0 overwrites it, and the retained original reset deadline still expires normally. Add this countercase to WHAM/header tests alongside the new-window unknown case. diff --git a/devlog/_plan/260905_main_quota_guard/014_runtime_evidence.md b/devlog/_plan/260905_main_quota_guard/014_runtime_evidence.md index b4f0f98af5..55bddd6cc4 100644 --- a/devlog/_plan/260905_main_quota_guard/014_runtime_evidence.md +++ b/devlog/_plan/260905_main_quota_guard/014_runtime_evidence.md @@ -34,3 +34,12 @@ - Independent Tesla review: unrelated-provider refusal finding closed; final VERDICT PASS. - No local test suite was executed. Exact-head CI is pending publication and is required before runtime completion/merge. - Installed Desktop Reserve-gate source evidence is in001; live Reserve success is not claimed. + +## CI round1:373915800 + +Run33929679810, test2/4 job101205597376: two exact-context assertions in `codex-main-rotation.test.ts:158,186` failed because they did not include the intentionally added internal `mainQuotaWriter`. The batch reported136pass/2fail. Other gates, including the actual CI typecheck/GUI tests/privacy/build job, passed at this point; remaining jobs were not yet complete. +Repair classification: required contract-fixture extension. Preserve exact whole-object equality and every prior credential/routing assertion; add explicit writer key-format and generation-type checks. Identity/ABA semantics remain covered by new provenance tests. No production change and no local suite run. + +The same run's test1/4 job101205597387 also failed native-search listener setup with EADDRINUSE at the secondary bind (`server/index.ts:2375`), before the request/assertions. Its fixture chooses a free secondary port, releases it, then binds the public listener with0; that draw can claim the reserved secondary port. The existing `findAvailablePort(...,{reservedPort})` contract already addresses this exact collision in a sibling test. Reuse it for all equivalent ordinary loopback-start fixtures, leaving intentional bind-failure tests unchanged. This changes test port selection only, not production startup, timeouts, retries or assertions. No flake is excused merely by retrying. + +Repair checkpoint: preserve declared short-window metadata and retain an already measured tuple on missing usage; `main-quota-window-observation.test.ts` adds owned WHAM/header coverage and is registered in both manifests. Fresh C reviewer Feynman closed the parser finding and reviewed both CI fixture repairs, VERDICT PASS. Root typecheck, standalone new observation-test typecheck, privacy scan and diff check all exited0. No local suite. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index babb74815c..eb986b6fa2 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -767,6 +767,7 @@ "main-account-hard-lock-auth.test.ts": "codex-integration", "main-account-hard-lock-policy.test.ts": "codex-integration", "main-quota-provenance.test.ts": "codex-integration", + "main-quota-window-observation.test.ts": "codex-integration", "management-client-config-route.test.ts": "server", "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 353911609d..824a56a691 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -215,6 +215,8 @@ function normalizeResetAt(value: unknown): number | undefined { function hasKnownQuotaValue(quota: Omit): boolean { return [quota.weeklyPercent, quota.monthlyPercent, quota.shortPercent] .some(value => typeof value === "number" && Number.isFinite(value)) + // Known short-window shape with unknown usage still selects that window for policy. + || snapshotHasShort(quota) || !!quota.customWindows?.some(window => Number.isFinite(window.percent)); } @@ -361,7 +363,8 @@ function mergeAccountQuota( if (existing.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true; } - if (snapshotHasShort(quota)) { + const preserveKnownShort = quota.shortPercent === undefined && finitePercent(existing?.shortPercent); + if (snapshotHasShort(quota) && !preserveKnownShort) { if (quota.shortPercent !== undefined) { next.shortPercent = quota.shortPercent; if (Number.isFinite(quota.shortPercent)) next.shortObservedAt = next.updatedAt; @@ -369,8 +372,8 @@ function mergeAccountQuota( if (quota.shortResetAt !== undefined) next.shortResetAt = quota.shortResetAt; if (quota.shortWindowSeconds !== undefined) next.shortWindowSeconds = quota.shortWindowSeconds; } else { - // Header and reset-credit updates are partial snapshots. Preserve the last full WHAM - // burst tuple when those updates do not carry enough window metadata to replace it. + // Unknown usage is not a lower reading. Retain the entire known tuple: pairing + // its percentage with new metadata would silently extend or shorten its reset. if (existing?.shortPercent !== undefined) next.shortPercent = existing.shortPercent; if (existing?.shortObservedAt !== undefined) next.shortObservedAt = existing.shortObservedAt; if (existing?.shortResetAt !== undefined) next.shortResetAt = existing.shortResetAt; @@ -469,7 +472,7 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit { accessToken: "main_access", chatgptAccountId: "main_acct", writerGeneration: expect.any(Number), + mainQuotaWriter: { + identityKey: expect.stringMatching(/^[a-f0-9]{64}$/), + identityGeneration: expect.any(Number), + }, }); expect(isCodexAuthContextUsable(ctx, config)).toBe(true); const headers = headersForCodexAuthContext(new Headers(), ctx); @@ -189,6 +193,10 @@ describe("main account rotation (Option A)", () => { accessToken: "replacement_access", chatgptAccountId: "replacement_acct", writerGeneration: expect.any(Number), + mainQuotaWriter: { + identityKey: expect.stringMatching(/^[a-f0-9]{64}$/), + identityGeneration: expect.any(Number), + }, }); expect(isCodexAccountInCooldown(MAIN_CODEX_ACCOUNT_ID)).toBe(false); expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); diff --git a/tests/codex-integration/main-quota-window-observation.test.ts b/tests/codex-integration/main-quota-window-observation.test.ts new file mode 100644 index 0000000000..b2dcd6f0ba --- /dev/null +++ b/tests/codex-integration/main-quota-window-observation.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; +import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; +import { fetchMainAccountInfo } from "../../src/codex/auth-api"; +import { setMainAccountPlan } from "../../src/codex/main-account"; +import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { + captureMainQuotaWriter, clearMainAccountInfoCache, matchesMainQuotaCredential, observeMainQuotaIdentity, +} from "../../src/codex/main-account-cache"; +import { + applyAccountQuotaFromUpstreamHeaders, clearAccountQuota, getAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed, +} from "../../src/codex/quota"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let testDir: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +let previousFetch: typeof fetch; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + previousFetch = globalThis.fetch; + testDir = mkdtempSync(join(tmpdir(), "ocx-main-window-")); + process.env.OPENCODEX_HOME = testDir; + process.env.CODEX_HOME = testDir; + clearAccountQuota(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + setMainAccountPlan(null); +}); + +afterEach(async () => { + globalThis.fetch = previousFetch; + clearAccountQuota(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + setMainAccountPlan(null); + try { + await flushConfigDirHardeningForTests(); + } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(testDir); + } +}); + +function writerFor() { + observeMainQuotaIdentity("fixture-main-a"); + const writer = captureMainQuotaWriter("fixture-main-a"); + if (!writer) throw new Error("Expected an observed main quota writer"); + return writer; +} + +describe("declared short-window producer evidence", () => { + const cases = [ + { name: "missing usage with weekly99", usage: undefined, weekly: true }, + { name: "invalid usage with weekly99", usage: "unreadable", weekly: true }, + { name: "metadata-only short window", usage: undefined, weekly: false }, + ]; + for (const sample of cases) { + test(`owned WHAM fetch preserves ${sample.name} as unknown short-window policy`, async () => { + resetLifecycleDrainStateForTests(); + const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(() => aclOk); + setAsyncIcaclsRunnerForTests(async () => aclOk); + const accessToken = "test-main"; + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: { + access_token: accessToken, account_id: "fixture-main-a", + } })); + let calls = 0; + const stubFetch: typeof fetch = Object.assign(async ( + input: Parameters[0], init?: Parameters[1], + ) => { + expect(String(input)).toBe("https://chatgpt.com/backend-api/wham/usage"); + expect(new Headers(init?.headers).get("chatgpt-account-id")).toBe("fixture-main-a"); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${accessToken}`); + calls += 1; + if (calls === 3 || calls === 5) { + return Response.json({ plan_type: "plus", rate_limit: { primary_window: { + used_percent: calls === 3 ? 99 : 0, limit_window_seconds: 18_000, + reset_at: calls === 3 ? 3_000_000_000 : 4_000_000_000, + } } }); + } + return Response.json({ plan_type: "plus", rate_limit: calls === 1 + ? { primary_window: { used_percent: 99, limit_window_seconds: 604_800 } } + : { + primary_window: { + used_percent: sample.usage, limit_window_seconds: calls === 4 ? 3_600 : 18_000, reset_at: 4_000_000_000, + }, + ...(sample.weekly ? { secondary_window: { used_percent: 99, limit_window_seconds: 604_800 } } : {}), + }, + }); + }, { preconnect: globalThis.fetch.preconnect }); + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(stubFetch); + try { + await fetchMainAccountInfo(true); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe("blocked"); + const info = await fetchMainAccountInfo(true); + expect(calls).toBe(2); + expect(info.quota).toMatchObject({ shortWindowSeconds: 18_000, shortResetAt: 4_000_000_000 }); + expect(info.quota).not.toHaveProperty("shortPercent"); + expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 99, shortWindowSeconds: 18_000 }); + expect(getMainPolicyQuota()).not.toHaveProperty("shortPercent"); + expect(matchesMainQuotaCredential(accessToken, "fixture-main-a")).toBe(true); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true })).toEqual({ enabled: true, state: "unknown" }); + // The paired case: unknown metadata must not erase a previously measured short99. + await fetchMainAccountInfo(true); + await fetchMainAccountInfo(true); + expect(calls).toBe(4); + for (const stored of [getAccountQuota(MAIN), getMainPolicyQuota()]) { + expect(stored).toMatchObject({ shortPercent: 99, shortWindowSeconds: 18_000, shortResetAt: 3_000_000_000 }); + } + const enabled = { codexMainAccountHardLock: true }; + expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("blocked"); + expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000).state).toBe("unknown"); + await fetchMainAccountInfo(true); + expect(calls).toBe(5); + expect(getMainPolicyQuota()).toMatchObject({ shortPercent: 0, shortResetAt: 4_000_000_000 }); + expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("ready"); + } finally { + fetchSpy.mockRestore(); + resetLifecycleDrainStateForTests(); + setMainAccountPlan(null); + try { + await flushConfigDirHardeningForTests(); + } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + } + } + }); + + test(`headers preserve ${sample.name} instead of falling back to weekly99`, () => { + const writer = writerFor(); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 99 }, undefined, writer); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true }).state).toBe("blocked"); + const headers = new Headers({ + "x-codex-primary-window-minutes": "300", "x-codex-primary-reset-at": "4000000000", + ...(sample.usage === undefined ? {} : { "x-codex-primary-used-percent": sample.usage }), + ...(sample.weekly ? { "x-codex-secondary-used-percent": "99" } : {}), + }); + applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); + expect(getMainPolicyQuota()).toMatchObject({ + weeklyPercent: 99, shortWindowSeconds: 18_000, shortResetAt: 4_000_000_000, + }); + expect(getMainPolicyQuota()).not.toHaveProperty("shortPercent"); + expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true })).toEqual({ enabled: true, state: "unknown" }); + applyAccountQuotaFromUpstreamHeaders(MAIN, new Headers({ + "x-codex-primary-used-percent": "99", "x-codex-primary-window-minutes": "300", + "x-codex-primary-reset-at": "3000000000", + }), undefined, writer); + headers.set("x-codex-primary-window-minutes", "60"); + applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); + for (const stored of [getAccountQuota(MAIN), getMainPolicyQuota()]) { + expect(stored).toMatchObject({ shortPercent: 99, shortWindowSeconds: 18_000, shortResetAt: 3_000_000_000 }); + } + const enabled = { codexMainAccountHardLock: true }; + expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("blocked"); + expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000).state).toBe("unknown"); + headers.set("x-codex-primary-used-percent", "0"); + applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); + expect(getMainPolicyQuota()).toMatchObject({ shortPercent: 0, shortWindowSeconds: 3_600, shortResetAt: 4_000_000_000 }); + expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("ready"); + }); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f7f482c981..b641003fb7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -604,6 +604,7 @@ "main-account-hard-lock-auth.test.ts": "codex-integration", "main-account-hard-lock-policy.test.ts": "codex-integration", "main-quota-provenance.test.ts": "codex-integration", + "main-quota-window-observation.test.ts": "codex-integration", "management-client-config-route.test.ts": "server", "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", diff --git a/tests/server/loopback-listener-integration.test.ts b/tests/server/loopback-listener-integration.test.ts index 46fb44e213..416fea6f4c 100644 --- a/tests/server/loopback-listener-integration.test.ts +++ b/tests/server/loopback-listener-integration.test.ts @@ -65,6 +65,13 @@ async function freePort(): Promise { return await findAvailablePort(0, "127.0.0.1"); } +/** Port 0 on the public listener can otherwise claim the just-released loopback port. */ +async function startLoopbackTestServer(loopbackPort: number) { + const publicPort = await findAvailablePort(0, "0.0.0.0", { reservedPort: loopbackPort }); + expect(publicPort).not.toBe(loopbackPort); + return startServer(publicPort); +} + function firstNonLoopbackIPv4(): string | null { for (const entries of Object.values(networkInterfaces())) { for (const entry of entries ?? []) { @@ -224,7 +231,7 @@ describe("unauthenticated loopback listener", () => { test("admits without a credential while the public listener does not", async () => { const loopbackPort = await freePort(); saveConfig(baseConfig(loopbackPort)); - const server = startServer(0); + const server = await startLoopbackTestServer(loopbackPort); try { // Same request, two sockets, two answers. This is the whole feature. const viaPublic = await fetch(`http://127.0.0.1:${server.port}/v1/models`); @@ -247,7 +254,7 @@ describe("unauthenticated loopback listener", () => { } const loopbackPort = await freePort(); saveConfig(baseConfig(loopbackPort)); - const server = startServer(0); + const server = await startLoopbackTestServer(loopbackPort); try { const refused = await new Promise(resolve => { const socket = connect({ host: address, port: loopbackPort }); @@ -272,7 +279,7 @@ describe("unauthenticated loopback listener", () => { test("serves only the allowlisted routes, using each route's real method", async () => { const loopbackPort = await freePort(); saveConfig(baseConfig(loopbackPort)); - const server = startServer(0); + const server = await startLoopbackTestServer(loopbackPort); const base = `http://127.0.0.1:${loopbackPort}`; try { // Each entry uses the METHOD its handler actually accepts. Probing a POST route with GET @@ -317,7 +324,7 @@ describe("unauthenticated loopback listener", () => { test("admits POST /v1/alpha/search so native web search reaches the relay (#3192)", async () => { const loopbackPort = await freePort(); saveConfig(baseConfig(loopbackPort)); - const server = startServer(0); + const server = await startLoopbackTestServer(loopbackPort); const body = '{"query":"x"}'; const headers = { "content-type": "application/json" }; try { @@ -352,7 +359,7 @@ describe("unauthenticated loopback listener", () => { test("admits the exact standalone Images POST routes so they reach the relay (#3428)", async () => { const loopbackPort = await freePort(); saveConfig(baseConfig(loopbackPort)); - const server = startServer(0); + const server = await startLoopbackTestServer(loopbackPort); const headers = { "content-type": "application/json" }; try { for (const path of ["/v1/images/generations", "/v1/images/edits"]) { @@ -392,7 +399,7 @@ describe("unauthenticated loopback listener", () => { test("admits standalone realtime voice WebSocket upgrades, HTTP stays rejected", async () => { const loopbackPort = await freePort(); saveConfig(baseConfig(loopbackPort)); - const server = startServer(0); + const server = await startLoopbackTestServer(loopbackPort); const base = `http://127.0.0.1:${loopbackPort}`; const upgradeHeaders = { connection: "upgrade", @@ -421,7 +428,7 @@ describe("unauthenticated loopback listener", () => { test("admits WebRTC voice call-create POSTs and keyed sideband upgrades (openai/codex #35830)", async () => { const loopbackPort = await freePort(); saveConfig(baseConfig(loopbackPort)); - const server = startServer(0); + const server = await startLoopbackTestServer(loopbackPort); const base = `http://127.0.0.1:${loopbackPort}`; const upgradeHeaders = { connection: "upgrade", @@ -460,7 +467,7 @@ describe("unauthenticated loopback listener", () => { test("admits POST /v1/responses and its compact sibling without a credential", async () => { const loopbackPort = await freePort(); saveConfig(baseConfig(loopbackPort)); - const server = startServer(0); + const server = await startLoopbackTestServer(loopbackPort); const base = `http://127.0.0.1:${loopbackPort}`; const publicBase = `http://127.0.0.1:${server.port}`; try { @@ -497,7 +504,7 @@ describe("unauthenticated loopback listener", () => { test("upgrades a Responses WebSocket on the listener that received it", async () => { const loopbackPort = await freePort(); saveConfig({ ...baseConfig(loopbackPort), websockets: true } as unknown as OcxConfig); - const server = startServer(0); + const server = await startLoopbackTestServer(loopbackPort); try { // What this proves: the loopback listener completes a Responses WebSocket handshake // without a credential, and the public one does not. @@ -518,7 +525,7 @@ describe("unauthenticated loopback listener", () => { test("applies the loopback Host and Origin gate, not the public same-origin rule", async () => { const loopbackPort = await freePort(); saveConfig(baseConfig(loopbackPort)); - const server = startServer(0); + const server = await startLoopbackTestServer(loopbackPort); const url = `http://127.0.0.1:${loopbackPort}/v1/models`; try { // The kernel refuses remote TCP, but a victim's browser connects locally on an @@ -541,7 +548,7 @@ describe("unauthenticated loopback listener", () => { test("stopping the server closes both listeners", async () => { const loopbackPort = await freePort(); saveConfig(baseConfig(loopbackPort)); - const server = startServer(0); + const server = await startLoopbackTestServer(loopbackPort); const publicPort = server.port; await server.stop(true); From 655c123658eaf4a12696695caf7d1cff0608700a Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 5 Sep 2026 08:47:28 +0900 Subject: [PATCH 05/11] test(codex): align monthly-primary quota provenance contract --- .../_plan/260905_main_quota_guard/014_runtime_evidence.md | 2 ++ tests/gui/rate-limit-reset-credits.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/260905_main_quota_guard/014_runtime_evidence.md b/devlog/_plan/260905_main_quota_guard/014_runtime_evidence.md index 55bddd6cc4..2030532bad 100644 --- a/devlog/_plan/260905_main_quota_guard/014_runtime_evidence.md +++ b/devlog/_plan/260905_main_quota_guard/014_runtime_evidence.md @@ -43,3 +43,5 @@ Repair classification: required contract-fixture extension. Preserve exact whole The same run's test1/4 job101205597387 also failed native-search listener setup with EADDRINUSE at the secondary bind (`server/index.ts:2375`), before the request/assertions. Its fixture chooses a free secondary port, releases it, then binds the public listener with0; that draw can claim the reserved secondary port. The existing `findAvailablePort(...,{reservedPort})` contract already addresses this exact collision in a sibling test. Reuse it for all equivalent ordinary loopback-start fixtures, leaving intentional bind-failure tests unchanged. This changes test port selection only, not production startup, timeouts, retries or assertions. No flake is excused merely by retrying. Repair checkpoint: preserve declared short-window metadata and retain an already measured tuple on missing usage; `main-quota-window-observation.test.ts` adds owned WHAM/header coverage and is registered in both manifests. Fresh C reviewer Feynman closed the parser finding and reviewed both CI fixture repairs, VERDICT PASS. Root typecheck, standalone new observation-test typecheck, privacy scan and diff check all exited0. No local suite. + +CI round2 at a7759cee0, run33930372485 test3/4 job101208017799:193pass/1fail in a batch. The legacy Go monthly-primary fixture in tests/gui/rate-limit-reset-credits.test.ts expected no provenance marker; the approved producer fix now intentionally retains it for same-account weekly-to-monthly transitions. Extend exact equality with monthlyIsPrimaryWindow=true and correct its outdated comment. Other original values/assertions remain unchanged; supplementary-monthly negative cases still omit the marker. No production delta. diff --git a/tests/gui/rate-limit-reset-credits.test.ts b/tests/gui/rate-limit-reset-credits.test.ts index 5378a17f47..07e3ee8ec4 100644 --- a/tests/gui/rate-limit-reset-credits.test.ts +++ b/tests/gui/rate-limit-reset-credits.test.ts @@ -180,9 +180,9 @@ describe("rate-limit reset credits", () => { tertiary_window: { used_percent: 50, reset_at: 1788000000 }, }, }); - // No provenance flag on the Go/Free branch: the monthly window governs those plans - // regardless of which window produced the reading, so recovery never consults it. - expect(quota).toEqual({ monthlyPercent: 30, monthlyResetAt: 1787401330 }); + // The same account can move from weekly to monthly. Preserve the observed primary + // provenance so policy storage can retire its obsolete weekly tuple on that transition. + expect(quota).toEqual({ monthlyPercent: 30, monthlyResetAt: 1787401330, monthlyIsPrimaryWindow: true }); }); it("keeps legacy tertiary monthly next to a duration-less weekly primary", () => { From 058840a3a139c40481b944a0cdeb158ae7fd4641 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 10:38:09 +0900 Subject: [PATCH 06/11] fix(codex): require fresh quota evidence for automatic main recovery --- .../_plan/260905_main_quota_guard/000_plan.md | 2 +- .../260905_main_quota_guard/010_policy.md | 2 + .../012_implementation_review.md | 2 +- .../013_window_priority_steering.md | 2 + .../015_recovery_review.md | 9 + scripts/test-layout/layout.json | 2 + src/codex/auth-api.ts | 57 +++- src/codex/main-account-hard-lock.ts | 10 +- src/codex/main-account.ts | 4 +- src/codex/quota.ts | 28 +- structure/08_openai-provider-tiers.md | 7 +- .../main-account-hard-lock-policy.test.ts | 10 +- .../main-account-hard-lock-recovery.test.ts | 295 ++++++++++++++++++ .../main-quota-evidence-validation.test.ts | 121 +++++++ .../main-quota-provenance.test.ts | 2 + .../main-quota-window-observation.test.ts | 60 +++- tests/fixtures/test-layout-expected.json | 2 + 17 files changed, 591 insertions(+), 24 deletions(-) create mode 100644 devlog/_plan/260905_main_quota_guard/015_recovery_review.md create mode 100644 tests/codex-integration/main-account-hard-lock-recovery.test.ts create mode 100644 tests/codex-integration/main-quota-evidence-validation.test.ts diff --git a/devlog/_plan/260905_main_quota_guard/000_plan.md b/devlog/_plan/260905_main_quota_guard/000_plan.md index 1c5c0ea117..d6553ef87c 100644 --- a/devlog/_plan/260905_main_quota_guard/000_plan.md +++ b/devlog/_plan/260905_main_quota_guard/000_plan.md @@ -43,7 +43,7 @@ The Reserve client gate is a separate feasibility decision, not permission to mi - Off/absent flag preserves current routing. Enabled flag uses the 5h/short window when present, otherwise weekly, otherwise monthly-only usage; it blocks at >=99 on that selected window. Other windows cannot trigger this local policy. Unknown data is not invented as 0 or 100. Owner steering is recorded in 013. - Main exclusion cannot prevent usage refresh or profile recovery. Explicit main and Direct paths cannot evade a measured block; unrelated caller credentials cannot inherit main's quota. -- Observations are identity-bound; account changes and restart cannot attach another account's cached reading. Window expiry and a fresh lower observation release only this policy, not pause/cooldown/reauth. +- Observations are identity-bound; account changes and restart cannot attach another account's cached reading. Only a fresh valid lower observation releases this policy, not clock-only expiry, pause/cooldown/reauth. The existing minute sweep refreshes blocked main usage without inference or reset credits. - UI distinguishes enabled from currently blocked. Cancel/Escape do not save; save errors preserve actual server state; success requires explicit acknowledgment. Main status remains visible outside Advanced. - Do not claim 1% is reserved: parallel/in-flight/direct-to-upstream use can reach 100 before observation. Luna reserve cannot be used while this policy blocks the main account. - Keep server Reserve grants and `ordinary_usage_allowed` unchanged. diff --git a/devlog/_plan/260905_main_quota_guard/010_policy.md b/devlog/_plan/260905_main_quota_guard/010_policy.md index b757dd7d22..d9da63d1d6 100644 --- a/devlog/_plan/260905_main_quota_guard/010_policy.md +++ b/devlog/_plan/260905_main_quota_guard/010_policy.md @@ -2,6 +2,8 @@ Depends on wp0. C4 care for quota/credential boundary; existing authentication and upstream grants remain authoritative. +Review amendment (033/034 in the Reserve plan layer) supersedes all expiry-retirement statements below: retained99 remains blocked after resetAt until a fresh valid lower reading arrives. Expired resetAt is omitted from the DTO. The existing60s sweep performs bounded/coalesced owned quota refresh; no inference, reset credits or new periodic timer. Raw negative readings are rejected as policy evidence before legacy clamping. Both quota writers hydrate before reading merge bases. + ## Contract and complete field chain NEW config `codexMainAccountHardLock?: boolean` in `src/types/config.ts`; `src/config.ts` parses optional boolean with malformed input treated as off. Persist through existing `saveConfigPreservingClaudeCode`; GET and PUT `/api/settings` return `codexMainAccountHardLock: config.codexMainAccountHardLock === true`. PUT rejects nonboolean input, captures presence/value, deletes when false, and restores exactly on save failure. Creation: settings PUT/hand-edited JSON; serialization: existing atomic config writer; deserialization: Zod loader; consumers: policy helper, account usability, native auth resolution, settings/main DTO, GUI in wp2. No new endpoint. diff --git a/devlog/_plan/260905_main_quota_guard/012_implementation_review.md b/devlog/_plan/260905_main_quota_guard/012_implementation_review.md index b0090bd66f..a07815ad8d 100644 --- a/devlog/_plan/260905_main_quota_guard/012_implementation_review.md +++ b/devlog/_plan/260905_main_quota_guard/012_implementation_review.md @@ -9,7 +9,7 @@ Independent reviewer Ohm returned FAIL; accepted both findings after tracing the RCA: one shared result cannot represent two distinct retention contracts. Keep one window-merging implementation with an explicit narrow policy-mode distinction for supplementary monthly updates, but evaluate it separately against legacy and policy bases. Preserve default-off legacy carry-forward; exclude untagged/cross-identity fields only from the new policy record. No generic cache redesign. -Required repair verification: typed checks after source settles; CI regression proves tertiary-only update leaves policy blocked, expired legacy cache remains absent after credits-only update, and actual monthly-primary replacement still clears obsolete weekly policy. Same reviewer re-verifies blocker closure before publication. +Required repair verification: typed checks after source settles; CI regression proves tertiary-only update leaves policy blocked, expired legacy cache remains absent after credits-only update, and actual monthly-primary replacement still clears obsolete weekly policy. Same reviewer re-verifies blocker closure before publication. Later review amendment033/034 supersedes clock-only policy expiry mentioned below: retained99 requires fresh valid lower evidence, refreshed by the existing minute sweep. Observed before repair: root typecheck exit0; all four new regression files passed standalone TypeScript7 checking with --ignoreConfig. No local tests executed. Privacy scan passed before final integration; final scan remains due if subsequent edits affect it. diff --git a/devlog/_plan/260905_main_quota_guard/013_window_priority_steering.md b/devlog/_plan/260905_main_quota_guard/013_window_priority_steering.md index 966720c615..ee6dd20d9e 100644 --- a/devlog/_plan/260905_main_quota_guard/013_window_priority_steering.md +++ b/devlog/_plan/260905_main_quota_guard/013_window_priority_steering.md @@ -1,5 +1,7 @@ # Window-priority steering +Superseded expiry decision: review amendment033/034 now requires a fresh valid lower reading to release retained99. Clock-only expiry is not recovery evidence; the existing minute sweep refreshes blocked main usage automatically. The priority and genuine0/rearming decisions below remain unchanged. + Owner clarification during wp1: accounts with a 5h window must use that window; accounts with weekly quota use weekly. If both exist, 5h wins. The initial maximum-across-windows policy is superseded before publication. Acceptance changes (not reduced verification): choose the observed short/5h tuple when present, otherwise weekly, otherwise monthly for monthly-only accounts. A high secondary window cannot activate this local 99% policy. Upstream limits still apply independently. An expired or unknown selected window is unknown, not permission to substitute a different high window. Retain known short-window shape across partial snapshots using the existing provenance-aware merger. diff --git a/devlog/_plan/260905_main_quota_guard/015_recovery_review.md b/devlog/_plan/260905_main_quota_guard/015_recovery_review.md new file mode 100644 index 0000000000..1715652088 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/015_recovery_review.md @@ -0,0 +1,9 @@ +# Runtime recovery review follow-up + +Averroes found that successful getValidMainAccountToken clears reauth even when a concurrent request marks quarantine during background refresh. The later explicitRefresh:false WHAM gate is too late. + +Expand the parent repair narrowly into main-account.ts: add an optional preserveReauth dependency option, default false for existing callers. The metadata-only recovery caller passes true, so successful refresh cannot clear a concurrent quarantine. Its existing post-refresh check skips WHAM when quarantine appeared. Ordinary/manual refresh semantics remain unchanged. Add a deferred token-refresh regression that marks reauth before success, requires the flag to remain set, zero WHAM calls, retained block and zero remaining runtime leases. + +This is a prerequisite correction within the approved fresh-recovery contract, not a new subsystem. Re-review before pushing. Root and focused-test TypeScript checks, privacy scan and diff check passed before this follow-up; rerun affected static checks after it. No local suites have run. Behavioral verification remains exact-head CI. + +Stable follow-up: Averroes re-reviewed all three changed files and the deferred token-endpoint regression, VERDICT PASS, blocking_issues0. Root typecheck and focused recovery-test TypeScript check passed again; privacy scan and diff check passed. Earlier focused TypeScript check also covered policy, provenance, raw evidence and actual WHAM/header observation tests. The tests were typechecked, not executed. No CI success is claimed for this new commit before push. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index eb986b6fa2..4e225b70d8 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -766,6 +766,8 @@ "management-api-logs-metrics.test.ts": "server", "main-account-hard-lock-auth.test.ts": "codex-integration", "main-account-hard-lock-policy.test.ts": "codex-integration", + "main-account-hard-lock-recovery.test.ts": "codex-integration", + "main-quota-evidence-validation.test.ts": "codex-integration", "main-quota-provenance.test.ts": "codex-integration", "main-quota-window-observation.test.ts": "codex-integration", "management-client-config-route.test.ts": "server", diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 1ee5ad9cf8..7afab2fc6a 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -69,6 +69,7 @@ import { isCompleteCodexQuotaRecoverySnapshot, isCodexQuotaExhausted, listAccountQuotas, + parseMainPolicyUsageQuota, parseUsageQuota, setAccountQuotaFromParsed, updateAccountQuota, @@ -84,7 +85,10 @@ export { updateAccountQuota, } from "./quota"; import { extractAccountId } from "../oauth/chatgpt"; -import { getMainAccountPlan, isMainAccountTokenVerifiablyLive, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; +import { + getMainAccountPlan, getValidMainAccountToken, isMainAccountTokenVerifiablyLive, + MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan, +} from "./main-account"; import { captureConfigGeneration, registerStateSweepAfterTick } from "../lib/state-store-sweeper"; import { reconcileLiveStateStores } from "../lib/state-store-registrations"; import { @@ -820,6 +824,7 @@ async function fetchMainAccountInfoAttempt( retriesRemaining: number, existingNativeMainLease?: AdmissionLease, nativeMainSharedClaimHeld = false, + explicitRefresh: boolean = forceRefresh, ): Promise { const nativeMainLease = existingNativeMainLease ?? tryAcquireNativeMainProfileClaim(); if (!nativeMainLease) { @@ -832,7 +837,7 @@ async function fetchMainAccountInfoAttempt( } try { const operation = async () => ({ - ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease), + ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh), identityGeneration: captureMainAccountIdentityGeneration(), }); if (nativeMainSharedClaimHeld) return await operation(); @@ -910,7 +915,9 @@ async function fetchMainAccountInfoWhileOwned( const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); if (retried) return retried; const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan()); - const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) }); + const usage = { ...data, ...(plan ? { plan_type: plan } : {}) }; + const quota = parseUsageQuota(usage); + const policyQuota = parseMainPolicyUsageQuota(usage); const freshResetCredits = quota?.resetCredits; // Tag the count with the identity it was read from, so a later response that omits the // summary can restore the badge without ever crossing an account boundary. @@ -936,7 +943,7 @@ async function fetchMainAccountInfoWhileOwned( // score and auto-switch the main account exactly like a pool account (Option A). setMainAccountPlan(result.plan); if (result.quota) { - setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter); + setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota); } return { info: result, @@ -1451,10 +1458,50 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da return cooldownRecoveryInFlight; } +let mainHardLockRecoveryInFlight: Promise | null = null; + +/** Metadata-only recovery on the existing sweep; failures retain the observed policy block. */ +export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise { + if (mainHardLockRecoveryInFlight) return mainHardLockRecoveryInFlight; + if (getMainAccountHardLockStatus(config).state !== "blocked" + || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; + const lease = tryAcquireNativeMainProfileClaim(); + if (!lease) return; + mainHardLockRecoveryInFlight = (async () => { + reconcileMainCodexAccountRuntimeState(); + if (getMainAccountHardLockStatus(config).state !== "blocked" + || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; + const identityGeneration = captureMainAccountIdentityGeneration(); + const writerGeneration = captureConfigGeneration(); + try { + // Refresh can require an exclusive credential claim: never hold WHAM's shared + // claim while obtaining a valid token. The runtime lease spans both operations. + if (!await getValidMainAccountToken({ preserveReauth: true })) return; + } catch (error) { + if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth" + && isMainAccountIdentityGenerationLive(identityGeneration)) { + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration); + } + return; + } + if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; + await fetchMainAccountInfoAttempt(true, 1, lease, false, false); + })().catch(() => { + // Best-effort background metadata read; no cooldown/pause or policy clearing on failure. + }).finally(() => { + lease.release(); + mainHardLockRecoveryInFlight = null; + }); + return mainHardLockRecoveryInFlight; +} + export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): void { registerStateSweepAfterTick({ name: "codex-cooldown-recovery", - afterTick: () => { void runCodexCooldownRecoveryProbes(config); }, + afterTick: () => { + void runCodexCooldownRecoveryProbes(config); + void runMainAccountHardLockRecovery(config); + }, }); } diff --git a/src/codex/main-account-hard-lock.ts b/src/codex/main-account-hard-lock.ts index 3392fed148..b8c150a273 100644 --- a/src/codex/main-account-hard-lock.ts +++ b/src/codex/main-account-hard-lock.ts @@ -6,7 +6,7 @@ export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 99; export interface MainAccountHardLockStatus { enabled: boolean; state: "off" | "unknown" | "ready" | "blocked"; - /** Unix milliseconds; absent when a blocking observation has no known reset. */ + /** Unix milliseconds; absent when a blocking observation has no future reset. */ resetAt?: number; } @@ -35,13 +35,15 @@ export function getMainAccountHardLockStatus( : hasWeekly ? [quota.weeklyPercent, quota.weeklyResetAt] : [quota.monthlyPercent, quota.monthlyResetAt]; const resetAt = resetTimestamp(rawReset); // The routing score's unknown sentinel is 101. It is never a raw quota observation. - if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100 - || (resetAt !== undefined && resetAt <= now)) return { enabled: true, state: "unknown" }; + if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) { + return { enabled: true, state: "unknown" }; + } if (percent < MAIN_ACCOUNT_HARD_LOCK_PERCENT) return { enabled: true, state: "ready" }; return { enabled: true, state: "blocked", - ...(resetAt !== undefined ? { resetAt } : {}), + // A predicted reset is not evidence of recovery. Only a fresh lower reading releases. + ...(resetAt !== undefined && resetAt > now ? { resetAt } : {}), }; } diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index d43c27f61d..a412046797 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -57,6 +57,8 @@ type MainAuthJsonCredential = { export interface NativeMainRefreshDependencies { refreshToken?: (refreshToken: string, options: { signal: AbortSignal }) => Promise; signal?: AbortSignal; + /** Metadata-only refresh must not retract a concurrent traffic reauth quarantine. */ + preserveReauth?: boolean; } export class MainAuthJsonChangedDuringRefreshError extends Error { @@ -283,7 +285,7 @@ async function resolveMainAccountToken( throw new MainAccountTokenRefreshError(reason, { cause }); } const result = persistRefreshedMainAuthJson(locked, refreshed); - clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); return result; }), { waitMs: 30_000, signal }, diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 824a56a691..f427b8b955 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -202,6 +202,11 @@ export function normalizeUsagePercent(value: unknown): number | undefined { return Math.max(0, Math.min(100, numeric)); } +/** Validate policy evidence before legacy normalization can turn a negative into zero. */ +function isNegativeUsagePercent(value: unknown): boolean { + return (typeof value === "number" || typeof value === "string") && Number(value) < 0; +} + function normalizeResetAt(value: unknown): number | undefined { const numeric = typeof value === "number" ? value @@ -284,13 +289,14 @@ export function setAccountQuotaFromParsed( quota: Omit | null, writerGeneration = captureConfigGeneration(), mainWriter?: MainQuotaWriter, + policyQuota: Omit | null = quota, ): void { if (!quota) return; if (!mayCommitAccountQuota(accountId, writerGeneration)) return; const isMain = accountId === MAIN_CODEX_ACCOUNT_ID; if (isMain && mainWriter && !isMainQuotaWriterLive(mainWriter)) return; - const legacyExisting = accountQuota.get(accountId); hydrateAccountQuotasFromDisk(); + const legacyExisting = accountQuota.get(accountId); const updatedAt = Date.now(); // Legacy rotation keeps its existing carry behavior, but never inherits policy-only // evidence that outlived its disk TTL. Policy has a separate, identity-checked base. @@ -300,10 +306,12 @@ export function setAccountQuotaFromParsed( const policyExisting = mainWriter && mainPolicyQuota?.identityKey === mainWriter.identityKey ? mainPolicyQuota.quota : undefined; - mainPolicyQuota = mainWriter + mainPolicyQuota = mainWriter && (policyQuota || policyExisting) ? { identityKey: mainWriter.identityKey, - quota: structuredClone(mergeAccountQuota(quota, policyExisting, updatedAt, true)), + quota: policyQuota + ? structuredClone(mergeAccountQuota(policyQuota, policyExisting, updatedAt, true)) + : policyExisting!, } : null; } @@ -525,7 +533,10 @@ export function applyAccountQuotaFromUpstreamHeaders( ): void { const quota = parseUpstreamQuotaHeaders(headers); if (!quota) return; - setAccountQuotaFromParsed(accountId, quota, writerGeneration, mainWriter); + const policyQuota = [ + "x-codex-primary-used-percent", "x-codex-secondary-used-percent", "x-codex-tertiary-used-percent", + ].some(name => isNegativeUsagePercent(headers.get(name))) ? null : quota; + setAccountQuotaFromParsed(accountId, quota, writerGeneration, mainWriter, policyQuota); } export function updateAccountQuota( @@ -538,11 +549,11 @@ export function updateAccountQuota( writerGeneration = captureConfigGeneration(), ): void { if (!mayCommitAccountQuota(accountId, writerGeneration)) return; - const existing = accountQuota.get(accountId); const nextWeekly = normalizeUsagePercent(weekly); const nextMonthly = normalizeUsagePercent(monthly); if (nextWeekly === undefined && nextMonthly === undefined && resetCredits === undefined) return; hydrateAccountQuotasFromDisk(); + const existing = accountQuota.get(accountId); const quota: StoredAccountQuota = { ...(existing?.weeklyPercent !== undefined ? { weeklyPercent: existing.weeklyPercent } : {}), @@ -733,6 +744,13 @@ export function reconcileCodexQuotaAccounts(context: GenerationContext): number return removed; } +/** Ordinary main policy rejects the entire message if any normal window is negative. */ +export function parseMainPolicyUsageQuota(data: WhamUsageResponse): Omit | null { + const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; + if (windows.some(window => isNegativeUsagePercent(window?.used_percent))) return null; + return parseUsageQuota(data); +} + export function parseUsageQuota(data: WhamUsageResponse): Omit | null { const resetCredits = typeof data.rate_limit_reset_credits?.available_count === "number" ? data.rate_limit_reset_credits.available_count diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index dcd66180b6..120a8b8ffc 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -85,8 +85,11 @@ It blocks newly admitted identity-matched main-account requests at 99% of the 5h when present, otherwise the weekly window (monthly for monthly-only accounts). It does not take the maximum across those windows. Pool alternatives remain eligible; explicit main selection and stored Direct substitution do not override it. It neither pauses the account nor clears upstream cooldown/reauth -state, and management quota refresh remains available. Passed reset timestamps and fresh lower -readings release this policy. Missing observations are reported as unknown, not fabricated headroom. +state, and management quota refresh remains available. Only a fresh valid reading below 99%, including +0%, releases a measured block; passing a reset timestamp alone does not. While blocked, the existing +once-per-minute background sweep refreshes owned main usage, with bounded/coalesced reads and no +inference or reset-credit consumption. Failed, missing or negative readings do not release the block. +Previously unobserved usage is unknown, not fabricated headroom. The policy reads a separately retained identity-tagged quota snapshot, so the legacy rotation cache's six-hour expiry does not silently release a known block. A confirmed account transition diff --git a/tests/codex-integration/main-account-hard-lock-policy.test.ts b/tests/codex-integration/main-account-hard-lock-policy.test.ts index 4b36fa67a8..a01b181bba 100644 --- a/tests/codex-integration/main-account-hard-lock-policy.test.ts +++ b/tests/codex-integration/main-account-hard-lock-policy.test.ts @@ -58,11 +58,13 @@ describe("identity-bound main-account hard-lock policy", () => { expect(isMainAccountHardLocked(enabled, now)).toBe(true); }); - test("reset times accept seconds and milliseconds and unlock at the boundary", () => { + test("reset times accept seconds and milliseconds but recovery requires fresh evidence", () => { observe({ weeklyPercent: 99, weeklyResetAt: (now + 60_000) / 1000, shortPercent: 100, shortResetAt: now + 120_000 }); expect(getMainAccountHardLockStatus(enabled, now)).toEqual({ enabled: true, state: "blocked", resetAt: now + 120_000 }); expect(getMainAccountHardLockStatus(enabled, now + 60_000).state).toBe("blocked"); - expect(getMainAccountHardLockStatus(enabled, now + 120_000).state).toBe("unknown"); + expect(getMainAccountHardLockStatus(enabled, now + 120_000)).toEqual({ enabled: true, state: "blocked" }); + observe({ shortPercent: 0 }); + expect(getMainAccountHardLockStatus(enabled, now + 120_000)).toEqual({ enabled: true, state: "ready" }); }); test("one missing reset prevents a false scheduled-unlock promise", () => { @@ -89,7 +91,9 @@ describe("identity-bound main-account hard-lock policy", () => { test("an expired 5h window does not fall back to the high weekly bar", () => { observe({ shortPercent: 99, shortWindowSeconds: 18_000, shortResetAt: now / 1000, weeklyPercent: 100 }); - expect(getMainAccountHardLockStatus(enabled, now).state).toBe("unknown"); + expect(getMainAccountHardLockStatus(enabled, now)).toEqual({ enabled: true, state: "blocked" }); + observe({ shortPercent: 0 }); + expect(getMainAccountHardLockStatus(enabled, now).state).toBe("ready"); }); test("a known 5h shape with no percentage stays unknown instead of selecting weekly", () => { diff --git a/tests/codex-integration/main-account-hard-lock-recovery.test.ts b/tests/codex-integration/main-account-hard-lock-recovery.test.ts new file mode 100644 index 0000000000..f0a803ef76 --- /dev/null +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -0,0 +1,295 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + fetchMainAccountInfo, registerCodexCooldownRecoveryProbeWorker, runMainAccountHardLockRecovery, +} from "../../src/codex/auth-api"; +import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; +import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { captureMainQuotaWriter, clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; +import { setMainAccountPlan } from "../../src/codex/main-account"; +import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { clearCodexUpstreamHealth, getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome } from "../../src/codex/routing"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import * as sweeper from "../../src/lib/state-store-sweeper"; +import { + acquireNativeMainProfileDrain, getNativeMainProfileRequestCount, resetLifecycleDrainStateForTests, +} from "../../src/server/lifecycle"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const accountId = "fixture-recovery-main"; +const whamUrl = "https://chatgpt.com/backend-api/wham/usage"; +const tokenUrl = "https://auth.openai.com/oauth/token"; +let home: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +let previousFetch: typeof fetch; + +function config(): OcxConfig { + return { port: 10100, defaultProvider: "openai", providers: {}, codexMainAccountHardLock: true }; +} + +function bearer(expired = false): string { + const payload = Buffer.from(JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + (expired ? -120 : 86_400), + "https://api.openai.com/auth": { chatgpt_account_id: accountId }, + })).toString("base64url"); + return `header.${payload}.signature`; +} + +function writeMain(expired = false): void { + writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { + access_token: bearer(expired), refresh_token: "fixture-refresh", account_id: accountId, + } })); + reconcileMainCodexAccountRuntimeState(); +} + +function block(): void { + const writer = captureMainQuotaWriter(accountId); + if (!writer) throw new Error("Fixture identity must be observed"); + setAccountQuotaFromParsed(MAIN, { shortPercent: 99, shortWindowSeconds: 18_000, shortResetAt: 1 }, undefined, writer); +} + +function usage(percent = 0): Response { + return Response.json({ plan_type: "plus", rate_limit: { + primary_window: { used_percent: percent, limit_window_seconds: 18_000, reset_at: 1 }, + } }); +} + +function fetchWith(handler: (url: string, init?: RequestInit) => Promise) { + const calls: string[] = []; + globalThis.fetch = Object.assign(async (input: Parameters[0], init?: RequestInit) => { + const url = String(input); + calls.push(url); + expect([whamUrl, tokenUrl]).toContain(url); + expect(getNativeMainProfileRequestCount()).toBe(1); + return handler(url, init); + }, { preconnect: previousFetch.preconnect }); + return calls; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + previousFetch = globalThis.fetch; + home = mkdtempSync(join(tmpdir(), "ocx-main-recovery-")); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = home; + const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(() => aclOk); + setAsyncIcaclsRunnerForTests(async () => aclOk); + clearAccountQuota(); + clearAccountNeedsReauth(MAIN); + clearCodexUpstreamHealth(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + setMainAccountPlan(null); + writeMain(); + block(); +}); + +afterEach(async () => { + globalThis.fetch = previousFetch; + clearAccountQuota(); + clearAccountNeedsReauth(MAIN); + clearCodexUpstreamHealth(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + setMainAccountPlan(null); + try { + await flushConfigDirHardeningForTests(); + } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); + } +}); + +describe("main hard-lock background recovery", () => { + test("existing sweep hook forces fresh WHAM past cache/reset without adding a timer", async () => { + let percent = 99; + const calls = fetchWith(async () => usage(percent)); + const cfg = config(); + await fetchMainAccountInfo(true); + expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "blocked" }); + percent = 0; + let afterTick: (() => void) | undefined; + const registration = spyOn(sweeper, "registerStateSweepAfterTick").mockImplementation(entry => { + afterTick = entry.afterTick; + return () => {}; + }); + const timer = spyOn(globalThis, "setInterval"); + try { + registerCodexCooldownRecoveryProbeWorker(cfg); + expect(afterTick).toBeDefined(); + afterTick!(); + await runMainAccountHardLockRecovery(cfg); + expect(timer).not.toHaveBeenCalled(); + expect(calls).toEqual([whamUrl, whamUrl]); + expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "ready" }); + expect(getNativeMainProfileRequestCount()).toBe(0); + } finally { + registration.mockRestore(); + timer.mockRestore(); + } + }); + + test.each(["disabled", "unknown", "ready", "reauth", "draining"] as const)("%s main makes no network request", async state => { + const cfg = config(); + if (state === "disabled") cfg.codexMainAccountHardLock = false; + if (state === "unknown") clearAccountQuota(); + if (state === "ready") { + setAccountQuotaFromParsed(MAIN, { shortPercent: 0 }, undefined, captureMainQuotaWriter(accountId)); + } + if (state === "reauth") markAccountNeedsReauth(MAIN); + const drain = state === "draining" ? acquireNativeMainProfileDrain("fixture") : null; + const calls = fetchWith(async () => usage()); + try { + await runMainAccountHardLockRecovery(cfg); + expect(calls).toEqual([]); + expect(getNativeMainProfileRequestCount()).toBe(0); + if (state === "reauth") expect(isAccountNeedsReauth(MAIN)).toBe(true); + } finally { drain?.release(); } + }); + + test("overlapping ticks share one flight and release its runtime lease", async () => { + const entered = deferred(); + const response = deferred(); + const calls = fetchWith(async () => { entered.resolve(); return response.promise; }); + const first = runMainAccountHardLockRecovery(config()); + try { + await entered.promise; + const second = runMainAccountHardLockRecovery(config()); + expect(calls).toEqual([whamUrl]); + expect(getNativeMainProfileRequestCount()).toBe(1); + response.resolve(usage()); + await Promise.all([first, second]); + expect(calls).toEqual([whamUrl]); + expect(getMainAccountHardLockStatus(config()).state).toBe("ready"); + } finally { response.resolve(usage()); await first; } + expect(getNativeMainProfileRequestCount()).toBe(0); + block(); + await runMainAccountHardLockRecovery(config()); + expect(calls).toEqual([whamUrl, whamUrl]); + }); + + test("expired stored token refresh completes before WHAM shared ownership", async () => { + writeMain(true); + const fresh = bearer(); + const calls = fetchWith(async (url, init) => { + if (url === tokenUrl) return Response.json({ access_token: fresh, refresh_token: "fixture-rotated", expires_in: 86_400 }); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${fresh}`); + expect(new Headers(init?.headers).get("chatgpt-account-id")).toBe(accountId); + expect(JSON.parse(readFileSync(join(home, "auth.json"), "utf8")).tokens.access_token).toBe(fresh); + return usage(); + }); + await runMainAccountHardLockRecovery(config()); + expect(calls).toEqual([tokenUrl, whamUrl]); + expect(getMainAccountHardLockStatus(config()).state).toBe("ready"); + expect(getNativeMainProfileRequestCount()).toBe(0); + }); + + test("reauth arriving during token refresh survives success and skips WHAM", async () => { + writeMain(true); + const retained = getMainPolicyQuota(); + const entered = deferred(); + const response = deferred(); + const refreshed = { access_token: bearer(), refresh_token: "fixture-rotated", expires_in: 86_400 }; + const calls = fetchWith(async url => { + if (url !== tokenUrl) return usage(); + entered.resolve(); + return response.promise; + }); + const recovery = runMainAccountHardLockRecovery(config()); + try { + await Promise.race([entered.promise, recovery.then(() => { + throw new Error("Recovery ended before reaching the token endpoint"); + })]); + expect(getNativeMainProfileRequestCount()).toBe(1); + markAccountNeedsReauth(MAIN); + response.resolve(Response.json(refreshed)); + await recovery; + expect(JSON.parse(readFileSync(join(home, "auth.json"), "utf8")).tokens.access_token).toBe(refreshed.access_token); + expect(isAccountNeedsReauth(MAIN)).toBe(true); + expect(calls).toEqual([tokenUrl]); + expect(getMainPolicyQuota()).toEqual(retained); + expect(getMainAccountHardLockStatus(config())).toEqual({ enabled: true, state: "blocked" }); + expect(getNativeMainProfileRequestCount()).toBe(0); + } finally { + response.resolve(Response.json(refreshed)); + await recovery; + } + }); + + test.each(["terminal", "transient"] as const)("%s refresh failure retains block and only terminal quarantines", async kind => { + writeMain(true); + const retained = getMainPolicyQuota(); + const calls = fetchWith(async () => Response.json({ error: kind === "terminal" ? "invalid_grant" : "server_error" }, + { status: kind === "terminal" ? 400 : 503 })); + await runMainAccountHardLockRecovery(config()); + expect(calls).toEqual([tokenUrl]); + expect(getMainPolicyQuota()).toEqual(retained); + expect(isAccountNeedsReauth(MAIN)).toBe(kind === "terminal"); + expect(getNativeMainProfileRequestCount()).toBe(0); + if (kind === "terminal") { + await runMainAccountHardLockRecovery(config()); + expect(calls).toEqual([tokenUrl]); + } + }); + + test.each(["http", "transport", "metadata", "negative", "missing-token"] as const)("%s failure retains policy evidence", async kind => { + const retained = getMainPolicyQuota(); + if (kind === "missing-token") unlinkSync(join(home, "auth.json")); + const calls = fetchWith(async () => { + if (kind === "transport") throw new Error("fixture network failure"); + if (kind === "http") return new Response(null, { status: 503 }); + if (kind === "metadata") return Response.json({ plan_type: "plus" }); + return usage(-1); + }); + await runMainAccountHardLockRecovery(config()); + expect(calls).toEqual(kind === "missing-token" ? [] : [whamUrl]); + expect(getMainPolicyQuota()).toEqual(retained); + expect(getMainAccountHardLockStatus(config()).state).toBe("blocked"); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + expect(getNativeMainProfileRequestCount()).toBe(0); + }); + + test("fresh zero releases policy without unpausing or clearing unrelated cooldown", async () => { + const cfg = config(); + cfg.pausedCodexAccountIds = [MAIN]; + const now = Date.now(); + recordCodexUpstreamOutcome(cfg, MAIN, 429, { now, retryAfter: "3600" }); + const cooldown = getCodexQuotaHealthSnapshot(MAIN, "shared", now); + expect(cooldown).not.toBeNull(); + fetchWith(async () => usage()); + await runMainAccountHardLockRecovery(cfg); + expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "ready" }); + expect(cfg.pausedCodexAccountIds).toEqual([MAIN]); + expect(getCodexQuotaHealthSnapshot(MAIN, "shared", now)).toEqual(cooldown); + }); + + test("a reauth mark arriving during metadata read is not cleared by its 200", async () => { + fetchWith(async () => { markAccountNeedsReauth(MAIN); return usage(); }); + await runMainAccountHardLockRecovery(config()); + expect(isAccountNeedsReauth(MAIN)).toBe(true); + expect(getMainAccountHardLockStatus(config()).state).toBe("ready"); + expect(getNativeMainProfileRequestCount()).toBe(0); + }); +}); diff --git a/tests/codex-integration/main-quota-evidence-validation.test.ts b/tests/codex-integration/main-quota-evidence-validation.test.ts new file mode 100644 index 0000000000..043fee9542 --- /dev/null +++ b/tests/codex-integration/main-quota-evidence-validation.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; +import { + clearAccountQuota, getAccountQuota, getMainPolicyQuota, parseMainPolicyUsageQuota, + parseUsageQuota, setAccountQuotaFromParsed, updateAccountQuota, type WhamUsageResponse, +} from "../../src/codex/quota"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let home: string; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-main-evidence-")); + process.env.OPENCODEX_HOME = home; + clearAccountQuota(); + clearMainAccountInfoCache(); +}); + +afterEach(() => { + clearAccountQuota(); + clearMainAccountInfoCache(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +function writerFor(accountId = "fixture-main-a") { + observeMainQuotaIdentity(accountId); + const writer = captureMainQuotaWriter(accountId); + if (!writer) throw new Error("Expected an observed main quota writer"); + return writer; +} + +describe("raw policy evidence validation", () => { + for (const slot of ["primary_window", "secondary_window", "tertiary_window"] as const) { + test.each([-1, "-1", " -0.01 ", Number.NEGATIVE_INFINITY])(`${slot} rejects negative %s before clamping`, value => { + // JSON input can carry strings despite WHAM's nominal number type. + const data = JSON.parse(JSON.stringify({ rate_limit: { + primary_window: { used_percent: 99 }, [slot]: { used_percent: value }, + } })) as WhamUsageResponse; + // Infinity is not JSON-representable; exercise the typed boundary directly too. + if (value === Number.NEGATIVE_INFINITY) data.rate_limit![slot] = { used_percent: value }; + expect(parseMainPolicyUsageQuota(data)).toBeNull(); + if (slot === "primary_window" && value !== Number.NEGATIVE_INFINITY) { + expect(parseUsageQuota(data)?.weeklyPercent).toBe(0); + } + }); + } + + test("unknown short shape and genuine zero preserve the canonical parser contract", () => { + const data: WhamUsageResponse = { rate_limit: { + primary_window: { limit_window_seconds: 18_000 }, secondary_window: { used_percent: 99 }, + } }; + expect(parseMainPolicyUsageQuota(data)).toEqual({ shortWindowSeconds: 18_000, weeklyPercent: 99 }); + expect(parseMainPolicyUsageQuota({ rate_limit: { primary_window: { used_percent: 0 } } })) + .toEqual({ weeklyPercent: 0 }); + }); + + test("additional Reserve/Spark buckets neither invalidate nor supply ordinary policy usage", () => { + const data: WhamUsageResponse = { + rate_limit: { primary_window: { used_percent: 0 } }, + additional_rate_limits: [{ metered_feature: "codex_bengalfox", rate_limit: { + primary_window: { used_percent: -1, limit_window_seconds: 604_800 }, + } }], + }; + expect(parseMainPolicyUsageQuota(data)?.weeklyPercent).toBe(0); + delete data.rate_limit; + const quota = parseMainPolicyUsageQuota(data); + expect(quota?.shortPercent).toBeUndefined(); + expect(quota?.weeklyPercent).toBeUndefined(); + expect(quota?.monthlyPercent).toBeUndefined(); + }); + + test("null policy evidence preserves only the matching owner and untagged writes invalidate", () => { + const writer = writerFor(); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 99 }, undefined, writer); + const retained = getMainPolicyQuota(); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 0 }, undefined, writer, null); + expect(getAccountQuota(MAIN)?.weeklyPercent).toBe(0); + expect(getMainPolicyQuota()).toEqual(retained); + const other = writerFor("fixture-main-b"); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 0 }, undefined, other, null); + expect(getMainPolicyQuota()).toBeNull(); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 99 }, undefined, other); + setAccountQuotaFromParsed(MAIN, { weeklyPercent: 0 }, undefined, undefined, null); + expect(getMainPolicyQuota()).toBeNull(); + }); +}); + +describe("cold partial writers hydrate only the surviving legacy cache", () => { + for (const writerKind of ["parsed", "legacy"] as const) { + for (const expired of [false, true]) { + test(`${writerKind} credits-only write ${expired ? "does not revive expired" : "retains fresh"} ordinary windows`, () => { + const writer = writerFor(); + const quota = { + shortPercent: 99, shortResetAt: 2_000_000_000, shortWindowSeconds: 18_000, + weeklyPercent: 50, weeklyResetAt: 2_100_000_000, + monthlyPercent: 25, monthlyResetAt: 2_200_000_000, resetCredits: 4, + updatedAt: Date.now() - (expired ? 7 : 1) * 60 * 60_000, + }; + writeFileSync(join(home, "codex-quota-cache.json"), JSON.stringify({ + version: 1, quotas: { [MAIN]: quota }, mainPolicyQuota: { identityKey: writer.identityKey, quota }, + })); + // Do not read either cache before this first write: that would hide the cold-start defect. + if (writerKind === "parsed") setAccountQuotaFromParsed(MAIN, { resetCredits: 0 }, undefined, writer); + else updateAccountQuota(MAIN, undefined, undefined, undefined, undefined, 0); + expect(getAccountQuota(MAIN)).toEqual(expired + ? { resetCredits: 0, updatedAt: expect.any(Number) } + : { ...quota, resetCredits: 0, updatedAt: expect.any(Number) }); + if (writerKind === "parsed") { + expect(getMainPolicyQuota()).toEqual({ ...quota, resetCredits: 0, updatedAt: expect.any(Number) }); + } else expect(getMainPolicyQuota()).toBeNull(); + }); + } + } +}); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index 93a4b6b19e..3f920b51cf 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -89,6 +89,8 @@ beforeEach(() => { }); afterEach(() => { + if (pendingPersist) clearTimeout(pendingPersist.timer); + pendingPersist = undefined; clearAccountQuota(); clearMainAccountInfoCache(); timerSpy.mockRestore(); diff --git a/tests/codex-integration/main-quota-window-observation.test.ts b/tests/codex-integration/main-quota-window-observation.test.ts index b2dcd6f0ba..248283ed84 100644 --- a/tests/codex-integration/main-quota-window-observation.test.ts +++ b/tests/codex-integration/main-quota-window-observation.test.ts @@ -65,6 +65,62 @@ function writerFor() { } describe("declared short-window producer evidence", () => { + for (const slot of ["primary", "secondary", "tertiary"] as const) { + test(`owned WHAM ${slot} negative cannot release short99, but genuine zero can`, async () => { + const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(() => aclOk); + setAsyncIcaclsRunnerForTests(async () => aclOk); + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: { + access_token: "fixture-main-token", account_id: "fixture-main-a", + } })); + let calls = 0; + globalThis.fetch = Object.assign(async (input: Parameters[0]) => { + expect(String(input)).toBe("https://chatgpt.com/backend-api/wham/usage"); + calls += 1; + return Response.json({ plan_type: "plus", rate_limit: { + primary_window: { used_percent: calls === 1 ? 99 : calls === 2 && slot === "primary" ? -1 : 0, + limit_window_seconds: 18_000, reset_at: 1 }, + secondary_window: { used_percent: calls === 2 && slot === "secondary" ? "-1" : 0, + limit_window_seconds: 604_800 }, + tertiary_window: { used_percent: calls === 2 && slot === "tertiary" ? -1 : 0 }, + } }); + }, { preconnect: previousFetch.preconnect }); + const enabled = { codexMainAccountHardLock: true }; + await fetchMainAccountInfo(true); + const retained = getMainPolicyQuota(); + expect(getMainAccountHardLockStatus(enabled)).toEqual({ enabled: true, state: "blocked" }); + await fetchMainAccountInfo(true); + expect(getAccountQuota(MAIN)?.shortPercent).toBe(0); + expect(getMainPolicyQuota()).toEqual(retained); + expect(getMainAccountHardLockStatus(enabled).state).toBe("blocked"); + await fetchMainAccountInfo(true); + expect(calls).toBe(3); + expect(getMainAccountHardLockStatus(enabled)).toEqual({ enabled: true, state: "ready" }); + }); + + test(`header ${slot} negative cannot release short99, but genuine zero can`, () => { + const writer = writerFor(); + const enabled = { codexMainAccountHardLock: true }; + const headers = new Headers({ + "x-codex-primary-used-percent": "99", "x-codex-primary-window-minutes": "300", + "x-codex-primary-reset-at": "1", "x-codex-secondary-used-percent": "0", + "x-codex-tertiary-used-percent": "0", + }); + applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); + const retained = getMainPolicyQuota(); + expect(getMainAccountHardLockStatus(enabled)).toEqual({ enabled: true, state: "blocked" }); + headers.set("x-codex-primary-used-percent", "0"); + headers.set(`x-codex-${slot}-used-percent`, "-1"); + applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); + expect(getAccountQuota(MAIN)?.shortPercent).toBe(0); + expect(getMainPolicyQuota()).toEqual(retained); + expect(getMainAccountHardLockStatus(enabled).state).toBe("blocked"); + headers.set(`x-codex-${slot}-used-percent`, "0"); + applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); + expect(getMainAccountHardLockStatus(enabled)).toEqual({ enabled: true, state: "ready" }); + }); + } + const cases = [ { name: "missing usage with weekly99", usage: undefined, weekly: true }, { name: "invalid usage with weekly99", usage: "unreadable", weekly: true }, @@ -125,7 +181,7 @@ describe("declared short-window producer evidence", () => { } const enabled = { codexMainAccountHardLock: true }; expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("blocked"); - expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000).state).toBe("unknown"); + expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000)).toEqual({ enabled: true, state: "blocked" }); await fetchMainAccountInfo(true); expect(calls).toBe(5); expect(getMainPolicyQuota()).toMatchObject({ shortPercent: 0, shortResetAt: 4_000_000_000 }); @@ -169,7 +225,7 @@ describe("declared short-window producer evidence", () => { } const enabled = { codexMainAccountHardLock: true }; expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("blocked"); - expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000).state).toBe("unknown"); + expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000)).toEqual({ enabled: true, state: "blocked" }); headers.set("x-codex-primary-used-percent", "0"); applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); expect(getMainPolicyQuota()).toMatchObject({ shortPercent: 0, shortWindowSeconds: 3_600, shortResetAt: 4_000_000_000 }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b641003fb7..e4058769da 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -603,6 +603,8 @@ "management-api-logs-metrics.test.ts": "server", "main-account-hard-lock-auth.test.ts": "codex-integration", "main-account-hard-lock-policy.test.ts": "codex-integration", + "main-account-hard-lock-recovery.test.ts": "codex-integration", + "main-quota-evidence-validation.test.ts": "codex-integration", "main-quota-provenance.test.ts": "codex-integration", "main-quota-window-observation.test.ts": "codex-integration", "management-client-config-route.test.ts": "server", From da535a734059355201934da37d0838f16a578bf8 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 11:13:53 +0900 Subject: [PATCH 07/11] fix(codex): validate main policy ranges and governing monthly evidence --- .../016_policy_input_review.md | 9 ++ src/codex/quota.ts | 33 +++- structure/08_openai-provider-tiers.md | 4 +- .../main-quota-evidence-validation.test.ts | 68 +++++++- .../main-quota-provenance.test.ts | 4 +- .../main-quota-window-observation.test.ts | 148 ++++++++++++++++-- 6 files changed, 236 insertions(+), 30 deletions(-) create mode 100644 devlog/_plan/260905_main_quota_guard/016_policy_input_review.md diff --git a/devlog/_plan/260905_main_quota_guard/016_policy_input_review.md b/devlog/_plan/260905_main_quota_guard/016_policy_input_review.md new file mode 100644 index 0000000000..3fa3e28d05 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/016_policy_input_review.md @@ -0,0 +1,9 @@ +# Policy input-boundary review + +Maintainer re-review on f42d86fca identified two input-boundary corrections: raw numeric values outside0..100 must not become trusted policy after legacy clamping, and supplementary-only monthly usage cannot act as a governing monthly fallback. Keep legacy display/rotation parsing unchanged. The policy producer independently validates raw range and monthly provenance; invalid or empty filtered evidence retains an existing trusted block and is unknown when no trusted snapshot exists. + +Equivalent WHAM/header regressions are required alongside valid monthly-only/primary-monthly controls and the existing99→invalid→0/rearm contract. No local suites. Root/focused TypeScript and independent review precede the parent push; both upper layers must cascade and every changed head must pass CI before merge. + +Independent source review found no production blocker, but identified an older provenance assertion expecting supplementary monthly in policy. Corrected it to require retained weekly99, absent policy monthly and preserved legacy monthly5; the subsequent primary-monthly replacement control remains intact. No assertion was weakened to accept the old false block. + +Final Averroes re-review PASS, blocking_issues0. Root TypeScript plus the three affected test-file TypeScript checks, privacy scan and diff check passed. No local suite or test execution. The new parent commit requires fresh exact-head CI and upper-layer cascade. diff --git a/src/codex/quota.ts b/src/codex/quota.ts index f427b8b955..bf4c4ebafa 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -202,9 +202,14 @@ export function normalizeUsagePercent(value: unknown): number | undefined { return Math.max(0, Math.min(100, numeric)); } -/** Validate policy evidence before legacy normalization can turn a negative into zero. */ -function isNegativeUsagePercent(value: unknown): boolean { - return (typeof value === "number" || typeof value === "string") && Number(value) < 0; +/** Reject numeric policy evidence before legacy clamping can fabricate a valid reading. */ +function isInvalidPolicyUsagePercent(value: unknown): boolean { + if (typeof value === "number") return !Number.isFinite(value) || value < 0 || value > 100; + if (typeof value !== "string" || value.trim() === "") return false; + const numeric = Number(value); + // Non-numeric metadata stays unknown; explicit nonfinite spellings are invalid evidence. + if (Number.isNaN(numeric)) return /^[+-]?(?:nan|infinity)$/i.test(value.trim()); + return !Number.isFinite(numeric) || numeric < 0 || numeric > 100; } function normalizeResetAt(value: unknown): number | undefined { @@ -535,7 +540,7 @@ export function applyAccountQuotaFromUpstreamHeaders( if (!quota) return; const policyQuota = [ "x-codex-primary-used-percent", "x-codex-secondary-used-percent", "x-codex-tertiary-used-percent", - ].some(name => isNegativeUsagePercent(headers.get(name))) ? null : quota; + ].some(name => isInvalidPolicyUsagePercent(headers.get(name))) ? null : filterMainPolicyMonthlyQuota(quota); setAccountQuotaFromParsed(accountId, quota, writerGeneration, mainWriter, policyQuota); } @@ -744,11 +749,25 @@ export function reconcileCodexQuotaAccounts(context: GenerationContext): number return removed; } -/** Ordinary main policy rejects the entire message if any normal window is negative. */ +/** Supplementary monthly bars are not governing policy evidence without plan/primary proof. */ +function filterMainPolicyMonthlyQuota( + quota: Omit | null, + monthlyOnlyPlan = false, +): Omit | null { + if (!quota || monthlyOnlyPlan || quota.monthlyIsPrimaryWindow === true) return quota; + const filtered = { ...quota }; + delete filtered.monthlyPercent; + delete filtered.monthlyResetAt; + delete filtered.monthlyIsPrimaryWindow; + // Null retains the matching prior observation; an empty object would merge away evidence. + return hasKnownQuotaValue(filtered) || filtered.resetCredits !== undefined ? filtered : null; +} + +/** Ordinary main policy rejects an entire message containing any invalid numeric window. */ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): Omit | null { const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; - if (windows.some(window => isNegativeUsagePercent(window?.used_percent))) return null; - return parseUsageQuota(data); + if (windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent))) return null; + return filterMainPolicyMonthlyQuota(parseUsageQuota(data), isThirtyDayOnlyCodexPlan(data.plan_type)); } export function parseUsageQuota(data: WhamUsageResponse): Omit | null { diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 120a8b8ffc..ac69abc9b9 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -88,7 +88,9 @@ substitution do not override it. It neither pauses the account nor clears upstre state, and management quota refresh remains available. Only a fresh valid reading below 99%, including 0%, releases a measured block; passing a reset timestamp alone does not. While blocked, the existing once-per-minute background sweep refreshes owned main usage, with bounded/coalesced reads and no -inference or reset-credit consumption. Failed, missing or negative readings do not release the block. +inference or reset-credit consumption. Failed, missing, non-finite or out-of-range readings do not +release the block. Policy validation precedes legacy clamping. Supplementary monthly data cannot +become the fallback governing window without a monthly-only plan or explicit primary-monthly evidence. Previously unobserved usage is unknown, not fabricated headroom. The policy reads a separately retained identity-tagged quota snapshot, so the legacy rotation diff --git a/tests/codex-integration/main-quota-evidence-validation.test.ts b/tests/codex-integration/main-quota-evidence-validation.test.ts index 043fee9542..1e9535ed17 100644 --- a/tests/codex-integration/main-quota-evidence-validation.test.ts +++ b/tests/codex-integration/main-quota-evidence-validation.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; +import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; import { clearAccountQuota, getAccountQuota, getMainPolicyQuota, parseMainPolicyUsageQuota, @@ -38,20 +39,50 @@ function writerFor(accountId = "fixture-main-a") { describe("raw policy evidence validation", () => { for (const slot of ["primary_window", "secondary_window", "tertiary_window"] as const) { - test.each([-1, "-1", " -0.01 ", Number.NEGATIVE_INFINITY])(`${slot} rejects negative %s before clamping`, value => { - // JSON input can carry strings despite WHAM's nominal number type. - const data = JSON.parse(JSON.stringify({ rate_limit: { + test.each([ + -1, "-1", " -0.01 ", 101, "101", 100.01, " 100.01 ", + Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, + "NaN", "Infinity", "-Infinity", "1e400", "-1e400", + ])(`${slot} rejects invalid numeric %s before clamping`, value => { + // Simulate deserialized external data, including numbers JSON serialization would erase. + const data = { rate_limit: { primary_window: { used_percent: 99 }, [slot]: { used_percent: value }, - } })) as WhamUsageResponse; - // Infinity is not JSON-representable; exercise the typed boundary directly too. - if (value === Number.NEGATIVE_INFINITY) data.rate_limit![slot] = { used_percent: value }; + } } as WhamUsageResponse; expect(parseMainPolicyUsageQuota(data)).toBeNull(); - if (slot === "primary_window" && value !== Number.NEGATIVE_INFINITY) { - expect(parseUsageQuota(data)?.weeklyPercent).toBe(0); + if (slot === "primary_window" && Number.isFinite(Number(value))) { + expect(parseUsageQuota(data)?.weeklyPercent).toBe(Number(value) < 0 ? 0 : 100); } + const writer = writerFor(); + const publish = (input: WhamUsageResponse) => setAccountQuotaFromParsed( + MAIN, parseUsageQuota(input), undefined, writer, parseMainPolicyUsageQuota(input), + ); + const cfg = { codexMainAccountHardLock: true }; + publish(data); + expect(getMainAccountHardLockStatus(cfg).state).toBe("unknown"); + publish({ rate_limit: { primary_window: { used_percent: 99 } } }); + const retained = getMainPolicyQuota(); + publish(data); + expect(getMainPolicyQuota()).toEqual(retained); + publish({ rate_limit: { primary_window: { used_percent: 0 } } }); + expect(getMainAccountHardLockStatus(cfg).state).toBe("ready"); + publish({ rate_limit: { primary_window: { used_percent: 99 } } }); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); }); } + test.each([0, "0", -0, "-0", 98.99, "98.99", 99, "99", 100, "100"])("valid boundary %s remains policy evidence", value => { + const data = { rate_limit: { primary_window: { used_percent: value } } } as WhamUsageResponse; + expect(parseMainPolicyUsageQuota(data)).toEqual({ weeklyPercent: Number(value) === 0 ? 0 : Number(value) }); + }); + + test.each([undefined, null, "", " ", "unreadable", "99oops"])("non-numeric %s preserves unknown short shape", value => { + const data = { rate_limit: { + primary_window: { used_percent: value, limit_window_seconds: 18_000 }, + secondary_window: { used_percent: 99 }, tertiary_window: { used_percent: 99 }, + } } as WhamUsageResponse; + expect(parseMainPolicyUsageQuota(data)).toEqual({ shortWindowSeconds: 18_000, weeklyPercent: 99 }); + }); + test("unknown short shape and genuine zero preserve the canonical parser contract", () => { const data: WhamUsageResponse = { rate_limit: { primary_window: { limit_window_seconds: 18_000 }, secondary_window: { used_percent: 99 }, @@ -76,6 +107,27 @@ describe("raw policy evidence validation", () => { expect(quota?.monthlyPercent).toBeUndefined(); }); + test.each([undefined, "plus", "team"])("%s supplementary monthly cannot supply policy or erase retained evidence", plan => { + const data: WhamUsageResponse = { plan_type: plan, rate_limit: { + tertiary_window: { used_percent: 99, reset_at: 2_000_000_000 }, + } }; + expect(parseUsageQuota(data)).toEqual({ monthlyPercent: 99, monthlyResetAt: 2_000_000_000 }); + expect(parseMainPolicyUsageQuota(data)).toBeNull(); + // Monthly duration without a primary reading does not bless the tertiary fallback. + data.rate_limit!.primary_window = { limit_window_seconds: 2_592_000 }; + expect(parseMainPolicyUsageQuota(data)).toBeNull(); + data.rate_limit!.primary_window = { used_percent: 0, limit_window_seconds: 18_000 }; + expect(parseMainPolicyUsageQuota(data)).toEqual({ shortPercent: 0, shortWindowSeconds: 18_000 }); + }); + + test.each(["go", "free", " Go ", "FREE"])("%s monthly-only plan retains monthly evidence without fabricating primary provenance", plan => { + const quota = parseMainPolicyUsageQuota({ plan_type: plan, rate_limit: { + tertiary_window: { used_percent: 99, reset_at: 2_000_000_000 }, + } }); + expect(quota).toEqual({ monthlyPercent: 99, monthlyResetAt: 2_000_000_000 }); + expect(quota?.monthlyIsPrimaryWindow).toBeUndefined(); + }); + test("null policy evidence preserves only the matching owner and untagged writes invalidate", () => { const writer = writerFor(); setAccountQuotaFromParsed(MAIN, { weeklyPercent: 99 }, undefined, writer); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index 3f920b51cf..8d32b92304 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -203,7 +203,9 @@ describe("main policy quota writes", () => { "x-codex-tertiary-used-percent": "5", }), undefined, writer); expect(getAccountQuota(MAIN)?.weeklyPercent).toBeUndefined(); - expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 99, monthlyPercent: 5 }); + expect(getAccountQuota(MAIN)?.monthlyPercent).toBe(5); + expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 99 }); + expect(getMainPolicyQuota()?.monthlyPercent).toBeUndefined(); expect(getMainPolicyQuota()?.monthlyIsPrimaryWindow).toBeUndefined(); expect(getMainAccountHardLockStatus(enabled).state).toBe("blocked"); diff --git a/tests/codex-integration/main-quota-window-observation.test.ts b/tests/codex-integration/main-quota-window-observation.test.ts index 248283ed84..95dd3432e4 100644 --- a/tests/codex-integration/main-quota-window-observation.test.ts +++ b/tests/codex-integration/main-quota-window-observation.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { fetchMainAccountInfo } from "../../src/codex/auth-api"; +import * as authCollision from "../../src/codex/auth-collision"; import { setMainAccountPlan } from "../../src/codex/main-account"; import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; @@ -66,7 +67,9 @@ function writerFor() { describe("declared short-window producer evidence", () => { for (const slot of ["primary", "secondary", "tertiary"] as const) { - test(`owned WHAM ${slot} negative cannot release short99, but genuine zero can`, async () => { + const invalidValues = [-1, "-1", -0.01, " -0.01 ", 101, "101", 100.01, "100.01", + Infinity, -Infinity, "Infinity", "-Infinity", "NaN", "1e400", "-1e400"]; + test.each(invalidValues)(`owned WHAM ${slot} invalid %s stays unknown or retains short99 until valid zero`, async value => { const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; setIcaclsRunnerForTests(() => aclOk); setAsyncIcaclsRunnerForTests(async () => aclOk); @@ -74,53 +77,172 @@ describe("declared short-window producer evidence", () => { access_token: "fixture-main-token", account_id: "fixture-main-a", } })); let calls = 0; + let invalid = true; + let percent = 0; globalThis.fetch = Object.assign(async (input: Parameters[0]) => { expect(String(input)).toBe("https://chatgpt.com/backend-api/wham/usage"); calls += 1; - return Response.json({ plan_type: "plus", rate_limit: { - primary_window: { used_percent: calls === 1 ? 99 : calls === 2 && slot === "primary" ? -1 : 0, + const data = { plan_type: "plus", rate_limit: { + primary_window: { used_percent: percent, limit_window_seconds: 18_000, reset_at: 1 }, - secondary_window: { used_percent: calls === 2 && slot === "secondary" ? "-1" : 0, - limit_window_seconds: 604_800 }, - tertiary_window: { used_percent: calls === 2 && slot === "tertiary" ? -1 : 0 }, - } }); + secondary_window: { used_percent: 0, limit_window_seconds: 604_800 }, + tertiary_window: { used_percent: 0 }, + } }; + // Raw JSON overflow reaches resp.json as a nonfinite number, not JSON.stringify's null. + const body = JSON.stringify(data, (key, item: unknown) => key === `${slot}_window` && invalid + ? { ...(item as object), used_percent: typeof value === "number" && !Number.isFinite(value) ? "raw-overflow" : value } + : item).replace('"raw-overflow"', value === -Infinity ? "-1e400" : "1e400"); + return new Response(body, { headers: { "Content-Type": "application/json" } }); }, { preconnect: previousFetch.preconnect }); const enabled = { codexMainAccountHardLock: true }; await fetchMainAccountInfo(true); + expect(getMainAccountHardLockStatus(enabled).state).toBe("unknown"); + invalid = false; + percent = 99; + await fetchMainAccountInfo(true); const retained = getMainPolicyQuota(); expect(getMainAccountHardLockStatus(enabled)).toEqual({ enabled: true, state: "blocked" }); + invalid = true; + percent = 0; await fetchMainAccountInfo(true); - expect(getAccountQuota(MAIN)?.shortPercent).toBe(0); + if (slot === "primary" && Number.isFinite(Number(value))) { + expect(getAccountQuota(MAIN)?.shortPercent).toBe(Number(value) < 0 ? 0 : 100); + } expect(getMainPolicyQuota()).toEqual(retained); expect(getMainAccountHardLockStatus(enabled).state).toBe("blocked"); + invalid = false; await fetchMainAccountInfo(true); - expect(calls).toBe(3); expect(getMainAccountHardLockStatus(enabled)).toEqual({ enabled: true, state: "ready" }); + percent = 99; + await fetchMainAccountInfo(true); + expect(calls).toBe(5); + expect(getMainAccountHardLockStatus(enabled).state).toBe("blocked"); }); - test(`header ${slot} negative cannot release short99, but genuine zero can`, () => { + test.each(invalidValues)(`header ${slot} invalid %s stays unknown or retains short99 until valid zero`, value => { const writer = writerFor(); const enabled = { codexMainAccountHardLock: true }; const headers = new Headers({ - "x-codex-primary-used-percent": "99", "x-codex-primary-window-minutes": "300", + "x-codex-primary-used-percent": "0", "x-codex-primary-window-minutes": "300", "x-codex-primary-reset-at": "1", "x-codex-secondary-used-percent": "0", "x-codex-tertiary-used-percent": "0", }); + headers.set(`x-codex-${slot}-used-percent`, String(value)); + applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); + expect(getMainAccountHardLockStatus(enabled).state).toBe("unknown"); + headers.set(`x-codex-${slot}-used-percent`, "0"); + headers.set("x-codex-primary-used-percent", "99"); applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); const retained = getMainPolicyQuota(); expect(getMainAccountHardLockStatus(enabled)).toEqual({ enabled: true, state: "blocked" }); headers.set("x-codex-primary-used-percent", "0"); - headers.set(`x-codex-${slot}-used-percent`, "-1"); + headers.set(`x-codex-${slot}-used-percent`, String(value)); applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); - expect(getAccountQuota(MAIN)?.shortPercent).toBe(0); + if (slot === "primary" && Number.isFinite(Number(value))) { + expect(getAccountQuota(MAIN)?.shortPercent).toBe(Number(value) < 0 ? 0 : 100); + } expect(getMainPolicyQuota()).toEqual(retained); expect(getMainAccountHardLockStatus(enabled).state).toBe("blocked"); headers.set(`x-codex-${slot}-used-percent`, "0"); applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); expect(getMainAccountHardLockStatus(enabled)).toEqual({ enabled: true, state: "ready" }); + headers.set("x-codex-primary-used-percent", "99"); + applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); + expect(getMainAccountHardLockStatus(enabled).state).toBe("blocked"); }); } + for (const transport of ["wham", "headers"] as const) { + for (const shape of ["supplementary", "primary", "missing-primary", "go", "free"] as const) { + test(`${transport} ${shape} monthly requires governing evidence, preserving legacy bars`, async () => { + const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(() => aclOk); + setAsyncIcaclsRunnerForTests(async () => aclOk); + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: { + access_token: "fixture-main-token", account_id: "fixture-main-a", + } })); + const writer = writerFor(); + const enabled = { codexMainAccountHardLock: true }; + const plan = shape === "go" || shape === "free" ? shape : "plus"; + // Even a cached plan must not cause headers to perform a physical auth lookup. + setMainAccountPlan(plan); + const accepted = shape === "primary" || (transport === "wham" && (shape === "go" || shape === "free")); + let percent = 99; + const publish = async () => { + if (transport === "headers") { + const headers = new Headers({ "x-codex-tertiary-used-percent": String(percent), + "x-codex-tertiary-reset-at": "2000000000" }); + if (shape === "primary" || shape === "missing-primary") { + headers.set("x-codex-primary-window-minutes", "43200"); + if (shape === "primary") headers.set("x-codex-primary-used-percent", String(percent)); + } + const physicalRead = spyOn(authCollision, "readCodexTokensResult").mockImplementation(() => { + throw new Error("Header observation must not read native credentials"); + }); + try { + applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); + expect(physicalRead).not.toHaveBeenCalled(); + } finally { physicalRead.mockRestore(); } + } else await fetchMainAccountInfo(true); + }; + const calls: string[] = []; + globalThis.fetch = Object.assign(async (input: Parameters[0]) => { + calls.push(String(input)); + return Response.json({ plan_type: plan, rate_limit: { + tertiary_window: { used_percent: percent, reset_at: 2_000_000_000 }, + ...(shape === "primary" || shape === "missing-primary" ? { primary_window: { + limit_window_seconds: 2_592_000, ...(shape === "primary" ? { used_percent: percent } : {}), + } } : {}), + } }); + }, { preconnect: previousFetch.preconnect }); + await publish(); + expect(getAccountQuota(MAIN)?.monthlyPercent).toBe(99); + expect(getMainAccountHardLockStatus(enabled).state).toBe(accepted ? "blocked" : "unknown"); + if (!accepted) { + expect(getMainPolicyQuota()).toBeNull(); + // Filtered-empty input must neither replace an existing block nor alter its timestamp. + setAccountQuotaFromParsed(MAIN, { monthlyPercent: 99, monthlyIsPrimaryWindow: true }, undefined, + captureMainQuotaWriter("fixture-main-a")); + } + const retained = getMainPolicyQuota(); + percent = 0; + await publish(); + expect(getAccountQuota(MAIN)?.monthlyPercent).toBe(0); + expect(getMainAccountHardLockStatus(enabled).state).toBe(accepted ? "ready" : "blocked"); + if (!accepted) expect(getMainPolicyQuota()).toEqual(retained); + percent = 99; + await publish(); + expect(getMainAccountHardLockStatus(enabled).state).toBe("blocked"); + expect(calls).toEqual(transport === "headers" ? [] : Array(3).fill("https://chatgpt.com/backend-api/wham/usage")); + }); + } + } + + test.each([0, "0", 98.99, "98.99", 99, "99", 100, "100"])("owned WHAM and headers accept valid boundary %s", async value => { + const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(() => aclOk); + setAsyncIcaclsRunnerForTests(async () => aclOk); + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ tokens: { + access_token: "fixture-main-token", account_id: "fixture-main-a", + } })); + let calls = 0; + globalThis.fetch = Object.assign(async (input: Parameters[0]) => { + expect(String(input)).toBe("https://chatgpt.com/backend-api/wham/usage"); + calls += 1; + return Response.json({ plan_type: "plus", rate_limit: { primary_window: { used_percent: value } } }); + }, { preconnect: previousFetch.preconnect }); + await fetchMainAccountInfo(true); + const cfg = { codexMainAccountHardLock: true }; + expect(getMainPolicyQuota()?.weeklyPercent).toBe(Number(value)); + expect(getMainAccountHardLockStatus(cfg).state).toBe(Number(value) < 99 ? "ready" : "blocked"); + clearAccountQuota(); + applyAccountQuotaFromUpstreamHeaders(MAIN, new Headers({ "x-codex-primary-used-percent": String(value) }), + undefined, writerFor()); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(Number(value)); + expect(getMainAccountHardLockStatus(cfg).state).toBe(Number(value) < 99 ? "ready" : "blocked"); + expect(calls).toBe(1); + }); + const cases = [ { name: "missing usage with weekly99", usage: undefined, weekly: true }, { name: "invalid usage with weekly99", usage: "unreadable", weekly: true }, From ad13b13b7b6d7e84dac2cc95bfa77677886c31a9 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 11:40:12 +0900 Subject: [PATCH 08/11] fix(codex): validate persisted main policy percentages by field --- .../017_persisted_policy_validation.md | 7 ++ src/codex/quota.ts | 9 ++- .../main-quota-evidence-validation.test.ts | 77 +++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 devlog/_plan/260905_main_quota_guard/017_persisted_policy_validation.md diff --git a/devlog/_plan/260905_main_quota_guard/017_persisted_policy_validation.md b/devlog/_plan/260905_main_quota_guard/017_persisted_policy_validation.md new file mode 100644 index 0000000000..cd4d43f96f --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/017_persisted_policy_validation.md @@ -0,0 +1,7 @@ +# Persisted policy field validation + +The policy disk decoder now validates usage percentages as finite0..100 independently from timestamps, durations and credits. It does not clamp invalid percentages or alter the ordinary rotation-cache decoder. An invalid percentage alone cannot invent a higher-priority window and shadow another valid blocking percentage; independently valid declared window metadata remains meaningful and unknown usage does not switch windows. + +Cold identity-matched disk regressions cover invalid numeric/nonnumber values, valid0/99/100, weekly/monthly fallback, metadata/credit independence and updatedAt rejection. They exercise real hydration rather than a policy setter. No local suites. Independent review and exact-head CI are required, followed by both upper-layer cascades. + +Averroes source/test re-review PASS, blocking_issues0. Root TypeScript and diff check passed. Test execution remains CI-only; no fresh-head CI success is claimed before publication. diff --git a/src/codex/quota.ts b/src/codex/quota.ts index bf4c4ebafa..82c388187b 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -616,9 +616,14 @@ function readMainPolicyQuota(value: unknown): MainPolicyQuota | null { const raw = entry.quota as Record; if (typeof raw.updatedAt !== "number" || !Number.isFinite(raw.updatedAt) || raw.updatedAt < 0) return null; const quota: StoredAccountQuota = { updatedAt: raw.updatedAt }; + for (const field of ["weeklyPercent", "monthlyPercent", "shortPercent"] as const) { + const number = raw[field]; + if (typeof number === "number" && Number.isFinite(number) && number >= 0 && number <= 100) { + quota[field] = number; + } + } for (const field of [ - "weeklyPercent", "monthlyPercent", "shortPercent", "weeklyResetAt", "monthlyResetAt", - "shortResetAt", "shortWindowSeconds", "resetCredits", + "weeklyResetAt", "monthlyResetAt", "shortResetAt", "shortWindowSeconds", "resetCredits", ] as const) { const number = raw[field]; if (typeof number === "number" && Number.isFinite(number) && number >= 0) quota[field] = number; diff --git a/tests/codex-integration/main-quota-evidence-validation.test.ts b/tests/codex-integration/main-quota-evidence-validation.test.ts index 1e9535ed17..bd229fa7e7 100644 --- a/tests/codex-integration/main-quota-evidence-validation.test.ts +++ b/tests/codex-integration/main-quota-evidence-validation.test.ts @@ -171,3 +171,80 @@ describe("cold partial writers hydrate only the surviving legacy cache", () => { } } }); + +/** Write external disk input without priming either cache through a getter or setter. */ +function writeColdPolicy(fields: Record) { + clearAccountQuota(); + const writer = writerFor(); + const quota = { updatedAt: Date.now(), ...fields }; + const body = JSON.stringify({ + version: 1, quotas: { [MAIN]: quota }, mainPolicyQuota: { identityKey: writer.identityKey, quota }, + }, (_key, value: unknown) => value === Infinity ? "positive-overflow" + : value === -Infinity ? "negative-overflow" : value) + .replaceAll('"positive-overflow"', "1e400").replaceAll('"negative-overflow"', "-1e400"); + writeFileSync(join(home, "codex-quota-cache.json"), body); + return quota; +} + +describe("cold persisted policy percentage ranges", () => { + const cfg = { codexMainAccountHardLock: true }; + for (const field of ["shortPercent", "weeklyPercent", "monthlyPercent"] as const) { + test.each([-1, -0.01, 101, 100.01, Infinity, -Infinity, null, "99", "101", "NaN", "Infinity", false, {}, []] + .map(value => ({ value })))( + `${field}=%j cannot shadow a valid blocking window or invent a window`, ({ value }) => { + const blocking = field === "weeklyPercent" + ? { monthlyPercent: 99, monthlyIsPrimaryWindow: true } + : { weeklyPercent: 99 }; + const disk = writeColdPolicy({ ...blocking, [field]: value }); + expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "blocked" }); + expect(getMainPolicyQuota()).toEqual({ updatedAt: disk.updatedAt, ...blocking }); + // The ordinary disk cache is intentionally not sanitized by the policy parser. + expect(getAccountQuota(MAIN)).toEqual(disk); + + const isolated = writeColdPolicy({ [field]: value, monthlyIsPrimaryWindow: true }); + expect(getMainPolicyQuota()).toEqual({ updatedAt: isolated.updatedAt }); + expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "unknown" }); + }, + ); + + test.each([0, 98.99, 99, 100])(`${field}=%s survives disk hydration without clamping`, value => { + const disk = writeColdPolicy({ [field]: value }); + expect(getMainPolicyQuota()).toEqual(disk); + expect(getMainAccountHardLockStatus(cfg).state).toBe(value < 99 ? "ready" : "blocked"); + }); + } + + test("valid short zero keeps priority over weekly99 after hydration", () => { + const disk = writeColdPolicy({ weeklyPercent: 99, shortPercent: 0 }); + expect(getMainPolicyQuota()).toEqual(disk); + expect(getMainAccountHardLockStatus(cfg).state).toBe("ready"); + }); + + test("rejected short usage retains independently valid unknown-window metadata", () => { + const disk = writeColdPolicy({ weeklyPercent: 99, shortPercent: 101, + shortWindowSeconds: 18_000, shortResetAt: 2_000_000_000, resetCredits: 150 }); + expect(getMainPolicyQuota()).toEqual({ updatedAt: disk.updatedAt, weeklyPercent: 99, + shortWindowSeconds: 18_000, shortResetAt: 2_000_000_000, resetCredits: 150 }); + expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "unknown" }); + }); + + test.each([0, 150, 2_000_000_000])("metadata and credits retain nonnegative %s independently of usage ranges", value => { + const disk = writeColdPolicy({ shortPercent: 100, weeklyPercent: 99, monthlyPercent: 0, + shortResetAt: value, weeklyResetAt: value, monthlyResetAt: value, + shortWindowSeconds: value, resetCredits: value, monthlyIsPrimaryWindow: true }); + expect(getMainPolicyQuota()).toEqual(disk); + }); + + test.each([-1, Infinity, -Infinity, "150", null])("invalid metadata %s cannot erase valid percentage evidence", value => { + const disk = writeColdPolicy({ weeklyPercent: 99, shortResetAt: value, weeklyResetAt: value, + monthlyResetAt: value, shortWindowSeconds: value, resetCredits: value }); + expect(getMainPolicyQuota()).toEqual({ updatedAt: disk.updatedAt, weeklyPercent: 99 }); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + }); + + test.each([-1, Infinity, -Infinity, "0", null])("invalid updatedAt %s still rejects the entire policy record", value => { + writeColdPolicy({ weeklyPercent: 99, updatedAt: value }); + expect(getMainPolicyQuota()).toBeNull(); + expect(getMainAccountHardLockStatus(cfg).state).toBe("unknown"); + }); +}); From 67a026bee4aa55229b6fff0d95b318214ec1368c Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 12:45:39 +0900 Subject: [PATCH 09/11] fix(codex): integrate hard lock with quota warmups and hermetic CI fixtures --- .../018_dev_integration.md | 11 + scripts/test-layout/layout.json | 1 + src/codex/account-lifecycle.ts | 2 +- src/codex/quota-auto-refresh-state.ts | 16 + src/codex/quota-auto-refresh.ts | 50 +-- src/codex/quota.ts | 5 +- structure/08_openai-provider-tiers.md | 4 + tests/claude-integration/claude-cli.test.ts | 19 +- ...-quota-auto-refresh-main-admission.test.ts | 290 ++++++++++++++++++ .../codex-quota-auto-refresh.test.ts | 24 ++ tests/fixtures/test-layout-expected.json | 1 + tests/usage/quota-reset-observation.test.ts | 21 +- 12 files changed, 420 insertions(+), 24 deletions(-) create mode 100644 devlog/_plan/260905_main_quota_guard/018_dev_integration.md create mode 100644 src/codex/quota-auto-refresh-state.ts create mode 100644 tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts diff --git a/devlog/_plan/260905_main_quota_guard/018_dev_integration.md b/devlog/_plan/260905_main_quota_guard/018_dev_integration.md new file mode 100644 index 0000000000..66b7454513 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/018_dev_integration.md @@ -0,0 +1,11 @@ +# Current dev integration and CI fixture repair + +Runtime branch rebased onto593978db0. Conflict resolution preserved both settings/doc sections and the new quota-reset observer: ordinary committed usage notifies once; the policy merger remains pure; baseline forgetting and policy clearing coexist. Credits-only writes retain their original notification exclusion, with a minute0/59/60 regression against false rolling-reset detection. + +The new dev quota auto-refresh feature makes billable warmups, so main hard-lock now gates that path too. The state leaf removes the lifecycle/facade cycle; reconciliation runs under runtime ownership, token refresh precedes shared ownership, prepared credentials and final restrictions are checked, and false-only skip preserves completion/retry state. Added-account and existing warmup fallback behavior remain unchanged. + +CI33939734355 macOS2/2 timed out without an assertion after an environment-assembly fixture. The executed synthetic merge was d1ef2aa4, not a bare head checkout. Source inspection identified unstubbed filesystem/Keychain detection; the unit file now supplies absent-I/O defaults while retaining explicit detection overrides and every assertion. The connected subscription case uses AUTH_PRESENT rather than unsupported dependency fields. Lorentz re-review PASS. This is a hermeticity repair; the historical hang's exact cause is not proven without a process sample. No production CLI timeout or runtime behavior was changed. + +No local suites. New-head CI, integration re-review and upper-layer cascade remain mandatory before merge; old successes do not authorize the rewritten stack. + +Averroes reviewed the completed warmup/state-leaf/observer integration and regressions: PASS, blocking_issues0. Lorentz independently reviewed the hermetic Claude unit fixture: PASS. Root TypeScript and diff checks passed; all behavioral execution remains CI-only. The new main warmup admission test is registered in both layout manifests. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4e225b70d8..da6f5909b8 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -438,6 +438,7 @@ "codex-prompt-text-probe.test.ts": "codex-integration", "codex-quota-parser-parity.test.ts": "codex-integration", "codex-quota-auto-refresh.test.ts": "codex-integration", + "codex-quota-auto-refresh-main-admission.test.ts": "codex-integration", "codex-quota-prime.test.ts": "codex-integration", "codex-quota-rejection.test.ts": "codex-integration", "codex-refresh.test.ts": "codex-integration", diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 1a40253848..75f748a805 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -16,7 +16,7 @@ import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaIdentity } from "./main-account-cache"; import { forgetCodexAccountPause } from "./account-pause"; import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority"; -import { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh"; +import { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state"; import { codexAccountNamespaceEntries, codexAccountPickerEnabled } from "./account-namespaces"; import type { OcxConfig } from "../types"; diff --git a/src/codex/quota-auto-refresh-state.ts b/src/codex/quota-auto-refresh-state.ts new file mode 100644 index 0000000000..43bb606d63 --- /dev/null +++ b/src/codex/quota-auto-refresh-state.ts @@ -0,0 +1,16 @@ +/** Shared bookkeeping leaf; lifecycle cleanup must not load warmup/credential owners. */ +/** Completed/due markers use epoch milliseconds; persisted legacy markers may use seconds. */ +export type CodexQuotaAutoRefreshWindows = { fiveHour?: number; weekly?: number }; + +export const completedByAccount = new Map(); +export const retryAfterByAccount = new Map(); + +export function forgetCodexQuotaAutoRefreshAccount(accountId: string): void { + completedByAccount.delete(accountId); + retryAfterByAccount.delete(accountId); +} + +export function resetCodexQuotaAutoRefreshStateForTests(): void { + completedByAccount.clear(); + retryAfterByAccount.clear(); +} diff --git a/src/codex/quota-auto-refresh.ts b/src/codex/quota-auto-refresh.ts index f4bebebdad..88291e0cdd 100644 --- a/src/codex/quota-auto-refresh.ts +++ b/src/codex/quota-auto-refresh.ts @@ -5,23 +5,28 @@ import { normalizeResetAt } from "../providers/quota-wire"; import { providerCodexAccountMode } from "../providers/registry"; import type { OcxConfig } from "../types"; import { isSelectableCodexPoolAccount } from "./account-id"; +import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; import { isCodexAccountPaused } from "./account-pause"; import { isAccountNeedsReauth } from "./account-runtime-state"; import { getValidCodexToken } from "./account-store"; -import { getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "./main-account"; +import { getMainAccountToken, getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "./main-account"; +import { isMainAccountHardLocked } from "./main-account-hard-lock"; import { tryAcquireNativeMainProfileClaim } from "./native-main-admission"; import { withNativeMainSharedClaim } from "./native-main-claim"; import { resolveNativeProfileContext } from "./native-profile-store"; import { getAccountQuota, type StoredAccountQuota } from "./quota"; import { warmCodexAccount } from "./warmup"; +import { + completedByAccount, retryAfterByAccount, resetCodexQuotaAutoRefreshStateForTests, + type CodexQuotaAutoRefreshWindows, +} from "./quota-auto-refresh-state"; +export type { CodexQuotaAutoRefreshWindows } from "./quota-auto-refresh-state"; +export { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state"; export const FIVE_HOUR_WINDOW_SECONDS = 5 * 60 * 60; const RETRY_MS = 5 * 60_000; const CONCURRENCY = 4; -/** Completed/due markers use epoch milliseconds; persisted legacy markers may use seconds. */ -export type CodexQuotaAutoRefreshWindows = { fiveHour?: number; weekly?: number }; - export interface CodexQuotaAutoRefreshStatus { fiveHourAvailable: boolean; weeklyAvailable: boolean; @@ -31,7 +36,8 @@ export interface CodexQuotaAutoRefreshStatus { export interface CodexQuotaAutoRefreshRunDeps { getQuota?: (accountId: string) => StoredAccountQuota | null; - warmAccount?: (config: OcxConfig, accountId: string) => Promise; + /** Only false means skipped; existing void callbacks still report a successful warmup. */ + warmAccount?: (config: OcxConfig, accountId: string) => Promise; persistCompleted?: ( config: OcxConfig, accountId: string, @@ -40,8 +46,6 @@ export interface CodexQuotaAutoRefreshRunDeps { } let inFlight: Promise | null = null; -const completedByAccount = new Map(); -const retryAfterByAccount = new Map(); export function codexQuotaAutoRefreshStatus( config: OcxConfig, @@ -88,7 +92,13 @@ export function dueCodexQuotaAutoRefreshWindows( return due.fiveHour === undefined && due.weekly === undefined ? null : due; } -async function warmAccount(config: OcxConfig, accountId: string): Promise { +function mainWarmupRestricted(config: OcxConfig): boolean { + return isMainAccountHardLocked(config) + || isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) + || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); +} + +async function warmAccount(config: OcxConfig, accountId: string): Promise { if (accountId !== MAIN_CODEX_ACCOUNT_ID) { await warmCodexAccount(await getValidCodexToken(accountId)); return; @@ -96,9 +106,16 @@ async function warmAccount(config: OcxConfig, accountId: string): Promise const lease = tryAcquireNativeMainProfileClaim(); if (!lease) throw new Error("native main busy"); try { - await withNativeMainSharedClaim(resolveNativeProfileContext(), async () => { - const token = await getValidMainAccountToken(); - if (!token) throw new Error("main account unavailable"); + reconcileMainCodexAccountRuntimeState(); + if (mainWarmupRestricted(config)) return false; + // Refresh may need exclusive ownership. Finish it before the warmup's shared ownership. + const prepared = await getValidMainAccountToken({ preserveReauth: true }); + if (!prepared) throw new Error("main account unavailable"); + return await withNativeMainSharedClaim(resolveNativeProfileContext(), async (): Promise => { + const token = getMainAccountToken(); + if (!token || token.accessToken !== prepared.accessToken + || token.chatgptAccountId !== prepared.chatgptAccountId) return false; + if (mainWarmupRestricted(config)) return false; await warmCodexAccount(token); }); } finally { @@ -167,6 +184,7 @@ export async function runCodexQuotaAutoRefresh( const due = accountIds.flatMap(accountId => { if (isCodexAccountPaused(config, accountId) || isAccountNeedsReauth(accountId) + || (accountId === MAIN_CODEX_ACCOUNT_ID && isMainAccountHardLocked(config)) || (retryAfterByAccount.get(accountId) ?? 0) > now) return []; const windows = dueCodexQuotaAutoRefreshWindows(config, accountId, quotaFor(accountId), now); return windows ? [{ accountId, windows }] : []; @@ -174,7 +192,7 @@ export async function runCodexQuotaAutoRefresh( for (let index = 0; index < due.length; index += CONCURRENCY) { await Promise.all(due.slice(index, index + CONCURRENCY).map(async ({ accountId, windows }) => { try { - await warm(config, accountId); + if (await warm(config, accountId) === false) return; retryAfterByAccount.delete(accountId); const completed = { ...completedByAccount.get(accountId), ...windows }; completedByAccount.set(accountId, completed); @@ -195,13 +213,7 @@ export function registerCodexQuotaAutoRefreshWorker(config: OcxConfig): () => vo }); } -export function forgetCodexQuotaAutoRefreshAccount(accountId: string): void { - completedByAccount.delete(accountId); - retryAfterByAccount.delete(accountId); -} - export function resetCodexQuotaAutoRefreshForTests(): void { inFlight = null; - completedByAccount.clear(); - retryAfterByAccount.clear(); + resetCodexQuotaAutoRefreshStateForTests(); } diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 82c388187b..90b814d708 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -321,7 +321,10 @@ export function setAccountQuotaFromParsed( : null; } schedulePersistAccountQuotas(); - notifyCodexQuotaSnapshot(accountId, next); + // Credits carry the previous usage tuple; they must not refresh its observation clock. + if (!(quota.resetCredits !== undefined && !snapshotHasUsage(quota))) { + notifyCodexQuotaSnapshot(accountId, next); + } } /** One partial-window merge contract for legacy quota and identity-bound policy evidence. */ diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index ac69abc9b9..505929a9d4 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -79,6 +79,10 @@ field-patches the completed timestamp; the next normal quota poll reports the ac Paused or reauthentication-required accounts are skipped, simultaneous 5-hour/weekly resets share one warmup, transient failures retry after five minutes, and account deletion removes its setting and completion markers. +Main-account hard-lock also gates these billable warmups. A policy/identity skip changes neither +completion markers nor retry delay; quota reads remain available. Main refresh completes before +shared credential ownership, then prepared credentials and restrictions are rechecked. Lifecycle +cleanup uses the dependency-free quota-auto-refresh state leaf, avoiding a reconciliation cycle. `codexMainAccountHardLock` is a separate opt-in local admission policy, off by default. It blocks newly admitted identity-matched main-account requests at 99% of the 5h/short window diff --git a/tests/claude-integration/claude-cli.test.ts b/tests/claude-integration/claude-cli.test.ts index 9bbe7cff95..86574b11b9 100644 --- a/tests/claude-integration/claude-cli.test.ts +++ b/tests/claude-integration/claude-cli.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { - buildClaudeEnv, + buildClaudeEnv as buildClaudeEnvWithIo, buildNativeClaudeEnv, claudeLaunchPlan, claudeLaunchPreflight, @@ -41,6 +41,21 @@ const AUTH_PRESENT = { }, }; +/** Environment assembly tests must not probe the runner's files or macOS Keychain. */ +function buildClaudeEnv( + ...[config, target, base, windows = {}, deps = {}]: Parameters +) { + return buildClaudeEnvWithIo(config, target, base, windows, { + ...deps, + authDetect: { + readClaudeJson: () => undefined, + credentialsFileExists: () => false, + keychainProbe: () => "absent" as const, + ...deps.authDetect, + }, + }); +} + describe("ocx claude proxy liveness", () => { test("retries the initial liveness probe before spawning a proxy", async () => { const seen: (number | undefined)[] = []; @@ -191,7 +206,7 @@ describe("ocx claude env assembly", () => { const env = buildClaudeEnv(cfg(), { baseUrl: "https://hub.example.test", admissionToken: "ocx_data_connected", - }, {}, {}, { mode: "subscription", origin: "explicit" }); + }, {}, {}, AUTH_PRESENT); expect(env.ANTHROPIC_BASE_URL).toBe("https://hub.example.test"); expect(env.ANTHROPIC_AUTH_TOKEN).toBe("ocx_data_connected"); }); diff --git a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts new file mode 100644 index 0000000000..7b2f65b762 --- /dev/null +++ b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts @@ -0,0 +1,290 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; +import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; +import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; +import { setMainAccountPlan } from "../../src/codex/main-account"; +import * as mainAccount from "../../src/codex/main-account"; +import * as nativeClaim from "../../src/codex/native-main-claim"; +import { clearAccountQuota, flushQuotaObservationsForTests, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { resetCodexQuotaAutoRefreshForTests, runCodexQuotaAutoRefresh, type CodexQuotaAutoRefreshWindows } from "../../src/codex/quota-auto-refresh"; +import { getNativeMainProfileRequestCount, resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const accountId = "fixture-auto-main"; +const responsesUrl = "https://chatgpt.com/backend-api/codex/responses"; +const tokenUrl = "https://auth.openai.com/oauth/token"; +let home: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +let previousFetch: typeof fetch; +let now: number; + +function config(): OcxConfig { + return { defaultProvider: "openai", codexMainAccountHardLock: true, providers: { openai: { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool", + } }, codexAccounts: [], codexQuotaAutoRefresh: { [MAIN]: { fiveHour: true, weekly: true } } }; +} + +function bearer(expired = false): string { + const payload = Buffer.from(JSON.stringify({ exp: Math.floor(now / 1000) + (expired ? -120 : 86_400), + "https://api.openai.com/auth": { chatgpt_account_id: accountId } })).toString("base64url"); + return `header.${payload}.signature`; +} + +function writeMain(accessToken = bearer(), workspace = accountId): void { + writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { + access_token: accessToken, refresh_token: "fixture-refresh", account_id: workspace, + } })); +} + +function observe(percent: number): void { + const writer = captureMainQuotaWriter(accountId); + if (!writer) throw new Error("Expected observed fixture identity"); + setAccountQuotaFromParsed(MAIN, { shortPercent: percent, shortWindowSeconds: 18_000, + shortResetAt: 1, weeklyPercent: 0, weeklyResetAt: 1 }, undefined, writer); +} + +function recordMarkers(cfg: OcxConfig, id: string, completed: CodexQuotaAutoRefreshWindows): boolean { + cfg.codexQuotaAutoRefresh = { ...cfg.codexQuotaAutoRefresh, [id]: { + ...cfg.codexQuotaAutoRefresh?.[id], + ...(completed.fiveHour !== undefined ? { lastFiveHourResetAt: completed.fiveHour } : {}), + ...(completed.weekly !== undefined ? { lastWeeklyResetAt: completed.weekly } : {}), + } }; + return true; +} + +function completedResponse(): Response { + return new Response('data: {"type":"response.completed"}\n\n', { headers: { "Content-Type": "text/event-stream" } }); +} + +function installFetch(handler: (url: string, init?: RequestInit) => Promise) { + const calls: string[] = []; + globalThis.fetch = Object.assign(async (input: Parameters[0], init?: RequestInit) => { + calls.push(String(input)); + expect([tokenUrl, responsesUrl]).toContain(String(input)); + expect(getNativeMainProfileRequestCount()).toBe(1); + return handler(String(input), init); + }, { preconnect: previousFetch.preconnect }); + return calls; +} + +function interceptShared(onOwned: () => void): () => void { + const original = nativeClaim.withNativeMainSharedClaim; + const spy = spyOn(nativeClaim, "withNativeMainSharedClaim").mockImplementation(async ( + context: Parameters[0], operation: () => Promise, options?: Parameters[2], + ): Promise => original(context, async () => { onOwned(); return operation(); }, options)); + return () => spy.mockRestore(); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +beforeEach(() => { + now = Date.now(); + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + previousFetch = globalThis.fetch; + home = mkdtempSync(join(tmpdir(), "ocx-auto-main-admission-")); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = home; + const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(() => aclOk); + setAsyncIcaclsRunnerForTests(async () => aclOk); + resetLifecycleDrainStateForTests(); + resetCodexQuotaAutoRefreshForTests(); + resetMainCodexAccountIdentityTrackingForTests(); + clearAccountQuota(); + clearAccountNeedsReauth(MAIN); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + writeMain(); + reconcileMainCodexAccountRuntimeState(); + observe(0); +}); + +afterEach(async () => { + globalThis.fetch = previousFetch; + clearAccountQuota(); + await flushQuotaObservationsForTests(); + clearAccountNeedsReauth(MAIN); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + resetMainCodexAccountIdentityTrackingForTests(); + resetCodexQuotaAutoRefreshForTests(); + resetLifecycleDrainStateForTests(); + try { await flushConfigDirHardeningForTests(); } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); + } +}); + +describe("quota auto-refresh native-main admission", () => { + test("owned reconciliation activates retained99 before token preparation when current identity was not observed", async () => { + const cfg = config(); + const writer = captureMainQuotaWriter(accountId); + if (!writer) throw new Error("Expected fixture's persisted policy owner"); + clearAccountQuota(); + resetMainCodexAccountIdentityTrackingForTests(); + clearMainAccountInfoCache(); + // Simulate a process which has not observed the current physical account yet. + observeMainQuotaIdentity("fixture-unrelated-observation"); + writeMain(bearer(true)); + const quota = { shortPercent: 99, shortWindowSeconds: 18_000, shortResetAt: 1, + weeklyPercent: 0, weeklyResetAt: 1, updatedAt: now }; + writeFileSync(join(home, "codex-quota-cache.json"), JSON.stringify({ + version: 1, quotas: { [MAIN]: quota }, mainPolicyQuota: { identityKey: writer.identityKey, quota }, + })); + expect(getMainAccountHardLockStatus(cfg).state).toBe("unknown"); + const token = spyOn(mainAccount, "getValidMainAccountToken"); + const calls = installFetch(async () => completedResponse()); + try { + await runCodexQuotaAutoRefresh(cfg, now, { persistCompleted: recordMarkers }); + expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "blocked" }); + expect(token).not.toHaveBeenCalled(); + expect(calls).toEqual([]); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]).toEqual({ fiveHour: true, weekly: true }); + expect(getNativeMainProfileRequestCount()).toBe(0); + } finally { token.mockRestore(); } + writeMain(); + observe(0); + await runCodexQuotaAutoRefresh(cfg, now + 1, { persistCompleted: recordMarkers }); + expect(calls).toEqual([responsesUrl]); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(1); + }); + + test("retained99 skips main and markers after reset while an added account completes; fresh0 admits main immediately", async () => { + const cfg = config(); + cfg.codexAccounts = [{ id: "pool-a", email: "pool@example.test", isMain: false }]; + cfg.codexQuotaAutoRefresh!["pool-a"] = { weekly: true }; + setAccountQuotaFromParsed("pool-a", { weeklyPercent: 0, weeklyResetAt: 1 }); + observe(99); + const warmed: string[] = []; + await runCodexQuotaAutoRefresh(cfg, now, { warmAccount: async (_cfg, id) => { warmed.push(id); }, persistCompleted: recordMarkers }); + expect(warmed).toEqual(["pool-a"]); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]).toEqual({ fiveHour: true, weekly: true }); + expect(cfg.codexQuotaAutoRefresh?.["pool-a"]?.lastWeeklyResetAt).toBe(1); + observe(0); + const calls = installFetch(async () => completedResponse()); + await runCodexQuotaAutoRefresh(cfg, now + 1, { persistCompleted: recordMarkers }); + expect(calls).toEqual([responsesUrl]); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]).toMatchObject({ lastFiveHourResetAt: 1, lastWeeklyResetAt: 1 }); + expect(getNativeMainProfileRequestCount()).toBe(0); + }); + + test("policy off preserves main warmup and its existing model fallback", async () => { + const cfg = config(); + cfg.codexMainAccountHardLock = false; + observe(99); + const models: string[] = []; + const calls = installFetch(async (_url, init) => { + models.push(JSON.parse(String(init?.body)).model); + return models.length === 1 ? new Response(null, { status: 400 }) : completedResponse(); + }); + await runCodexQuotaAutoRefresh(cfg, now, { persistCompleted: recordMarkers }); + expect(calls).toEqual([responsesUrl, responsesUrl]); + expect(models).toEqual(["gpt-5.4-mini", "gpt-5.5"]); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(1); + expect(getNativeMainProfileRequestCount()).toBe(0); + }); + + test("expired token refresh precedes shared claim and inference uses the prepared credential", async () => { + const cfg = config(); + writeMain(bearer(true)); + const fresh = bearer(); + const order: string[] = []; + const restore = interceptShared(() => { order.push("shared"); }); + const calls = installFetch(async (url, init) => { + if (url === tokenUrl) { + order.push("refresh"); + return Response.json({ access_token: fresh, refresh_token: "fixture-rotated", expires_in: 86_400 }); + } + order.push("inference"); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${fresh}`); + expect(new Headers(init?.headers).get("chatgpt-account-id")).toBe(accountId); + return completedResponse(); + }); + try { + await runCodexQuotaAutoRefresh(cfg, now, { persistCompleted: recordMarkers }); + expect(calls).toEqual([tokenUrl, responsesUrl]); + expect(order).toEqual(["refresh", "shared", "inference"]); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(1); + expect(getNativeMainProfileRequestCount()).toBe(0); + } finally { restore(); } + }); + + test.each(["policy", "pause", "reauth"] as const)("%s during refresh skips inference and completion without delaying later eligibility", async restriction => { + const cfg = config(); + writeMain(bearer(true)); + const entered = deferred(); + const response = deferred(); + const fresh = { access_token: bearer(), refresh_token: "fixture-rotated", expires_in: 86_400 }; + const calls = installFetch(async url => { + if (url !== tokenUrl) return completedResponse(); + entered.resolve(); + return response.promise; + }); + const run = runCodexQuotaAutoRefresh(cfg, now, { persistCompleted: recordMarkers }); + try { + await Promise.race([entered.promise, run.then(() => { throw new Error("Token endpoint was never reached"); })]); + if (restriction === "policy") observe(99); + else if (restriction === "pause") cfg.pausedCodexAccountIds = [MAIN]; + else markAccountNeedsReauth(MAIN); + response.resolve(Response.json(fresh)); + await run; + expect(JSON.parse(readFileSync(join(home, "auth.json"), "utf8")).tokens.access_token).toBe(fresh.access_token); + expect(calls).toEqual([tokenUrl]); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]).toEqual({ fiveHour: true, weekly: true }); + expect(getNativeMainProfileRequestCount()).toBe(0); + if (restriction === "reauth") expect(isAccountNeedsReauth(MAIN)).toBe(true); + if (restriction === "policy") expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + } finally { response.resolve(Response.json(fresh)); await run; } + observe(0); + cfg.pausedCodexAccountIds = []; + clearAccountNeedsReauth(MAIN); + await runCodexQuotaAutoRefresh(cfg, now + 1, { persistCompleted: recordMarkers }); + expect(calls).toEqual([tokenUrl, responsesUrl]); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(1); + }); + + test.each(["bearer", "workspace", "missing", "policy", "pause", "reauth"] as const)("%s changing at shared-claim acquisition skips inference and markers", async change => { + const cfg = config(); + const restore = interceptShared(() => { + if (change === "bearer") writeMain("fixture-replacement-token"); + else if (change === "workspace") writeMain(bearer(), "fixture-other-workspace"); + else if (change === "missing") writeFileSync(join(home, "auth.json"), "{}"); + else if (change === "policy") observe(99); + else if (change === "pause") cfg.pausedCodexAccountIds = [MAIN]; + else markAccountNeedsReauth(MAIN); + }); + const calls = installFetch(async () => completedResponse()); + try { + await runCodexQuotaAutoRefresh(cfg, now, { persistCompleted: recordMarkers }); + expect(calls).toEqual([]); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]).toEqual({ fiveHour: true, weekly: true }); + expect(getNativeMainProfileRequestCount()).toBe(0); + } finally { restore(); } + writeMain(); + observe(0); + cfg.pausedCodexAccountIds = []; + clearAccountNeedsReauth(MAIN); + await runCodexQuotaAutoRefresh(cfg, now + 1, { persistCompleted: recordMarkers }); + expect(calls).toEqual([responsesUrl]); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(1); + }); +}); diff --git a/tests/codex-integration/codex-quota-auto-refresh.test.ts b/tests/codex-integration/codex-quota-auto-refresh.test.ts index 4c46a0080b..e906d8605f 100644 --- a/tests/codex-integration/codex-quota-auto-refresh.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh.test.ts @@ -210,6 +210,30 @@ describe("Codex quota window auto refresh", () => { }); }); + test("only false skips completion and backoff; existing void success still completes", async () => { + const cfg = config(); + let attempts = 0; + let writes = 0; + const deps = { + getQuota: (id: string) => id === "pool-a" ? quota() : null, + warmAccount: async (): Promise => { + attempts += 1; + if (attempts === 1) return false; + }, + persistCompleted: (target: OcxConfig, id: string, completed: CodexQuotaAutoRefreshWindows) => { + writes += 1; + return recordMarkers(target, id, completed); + }, + }; + await runCodexQuotaAutoRefresh(cfg, NOW, deps); + expect(writes).toBe(0); + expect(cfg.codexQuotaAutoRefresh?.["pool-a"]?.lastWeeklyResetAt).toBeUndefined(); + await runCodexQuotaAutoRefresh(cfg, NOW + 1, deps); + expect(attempts).toBe(2); + expect(writes).toBe(1); + expect(cfg.codexQuotaAutoRefresh?.["pool-a"]?.lastWeeklyResetAt).toBe(RESET_SECONDS); + }); + test("does not schedule pool-account warmups in Direct mode", async () => { const cfg = config(); cfg.providers.openai.codexAccountMode = "direct"; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e4058769da..2fa6e55152 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -275,6 +275,7 @@ "codex-prompt-text-probe.test.ts": "codex-integration", "codex-quota-parser-parity.test.ts": "codex-integration", "codex-quota-auto-refresh.test.ts": "codex-integration", + "codex-quota-auto-refresh-main-admission.test.ts": "codex-integration", "codex-quota-prime.test.ts": "codex-integration", "codex-quota-rejection.test.ts": "codex-integration", "codex-refresh.test.ts": "codex-integration", diff --git a/tests/usage/quota-reset-observation.test.ts b/tests/usage/quota-reset-observation.test.ts index 3026e2a59b..555cdcc406 100644 --- a/tests/usage/quota-reset-observation.test.ts +++ b/tests/usage/quota-reset-observation.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -94,6 +94,25 @@ describe("codex quota seam", () => { expect(captured).toEqual([]); }); + test("credits-only refresh does not make later natural rolling decay look like a reset", async () => { + const start = Date.now(); + let now = start; + const clock = spyOn(Date, "now").mockImplementation(() => now); + try { + setAccountQuotaFromParsed(ACCOUNT, { shortPercent: 96, shortResetAt: start + 5 * HOUR, shortWindowSeconds: 18_000 }); + await flushQuotaObservationsForTests(); + expect(captured).toEqual([]); + now = start + 59 * 60_000; + setAccountQuotaFromParsed(ACCOUNT, { resetCredits: 3 }); + await flushQuotaObservationsForTests(); + expect(captured).toEqual([]); + now = start + HOUR; + setAccountQuotaFromParsed(ACCOUNT, { shortPercent: 4, shortResetAt: start + 6 * HOUR, shortWindowSeconds: 18_000 }); + await flushQuotaObservationsForTests(); + expect(captured).toEqual([]); + } finally { clock.mockRestore(); } + }); + test("a cleared row followed by a fresh low percent fires nothing", async () => { setAccountQuotaFromParsed(ACCOUNT, { weeklyPercent: 91, weeklyResetAt: Date.now() + 3 * 24 * HOUR }); await settle(); From fbe6324cebe6cca065752daa0d1c378cf8255b7f Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 13:12:25 +0900 Subject: [PATCH 10/11] fix(codex): preserve quota observation provenance across policy integration --- .../019_quota_provenance_integration.md | 9 ++++++ src/codex/quota.ts | 2 +- ...-quota-auto-refresh-main-admission.test.ts | 24 ++++++++------- .../codex-quota-auto-refresh.test.ts | 2 +- .../main-quota-evidence-validation.test.ts | 10 +++---- .../main-quota-provenance.test.ts | 4 +-- .../main-quota-window-observation.test.ts | 29 ++++++++++++++++--- 7 files changed, 56 insertions(+), 24 deletions(-) create mode 100644 devlog/_plan/260905_main_quota_guard/019_quota_provenance_integration.md diff --git a/devlog/_plan/260905_main_quota_guard/019_quota_provenance_integration.md b/devlog/_plan/260905_main_quota_guard/019_quota_provenance_integration.md new file mode 100644 index 0000000000..f584cdcaf0 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/019_quota_provenance_integration.md @@ -0,0 +1,9 @@ +# Moving-base quota provenance integration + +Parent03ee2f119 had no Cross-platform CI run because newer dev changes conflicted in quota.ts and quota-auto-refresh.ts; only target/label/hygiene workflows appeared. Merge-tree against fresh dev808b3dca3 verified the conflict rather than treating missing CI as green or queued. + +Rebase preserves dev's shortObservedAt provenance: fresh short usage stamps it; partial/credits-only writes keep it. The hard-lock merger still preserves a known short tuple when usage is unknown, and the policy disk decoder retains the new nonnegative timestamp without using age to silently release99. Canonical auto-refresh markers remain epoch milliseconds with legacy seconds normalization. The existing state leaf/public exports remain intact. + +Own regression expectations are aligned with these new contracts, retaining exact assertions and seconds-input/milliseconds-output controls. Reserve's later quota-types extraction must retain shortObservedAt. No local suites; a new mergeable current-head CI run and source re-review are required before any merge. + +Averroes source and five-file test-expectation re-review PASS, blocking_issues0. Root TypeScript and diff checks passed. No test execution occurred locally; freshness/carry/hydration and canonical marker behavior still require current-head CI. diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 90b814d708..d483654b3a 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -626,7 +626,7 @@ function readMainPolicyQuota(value: unknown): MainPolicyQuota | null { } } for (const field of [ - "weeklyResetAt", "monthlyResetAt", "shortResetAt", "shortWindowSeconds", "resetCredits", + "weeklyResetAt", "monthlyResetAt", "shortResetAt", "shortObservedAt", "shortWindowSeconds", "resetCredits", ] as const) { const number = raw[field]; if (typeof number === "number" && Number.isFinite(number) && number >= 0) quota[field] = number; diff --git a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts index 7b2f65b762..d676690c09 100644 --- a/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh-main-admission.test.ts @@ -19,6 +19,8 @@ import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const accountId = "fixture-auto-main"; +const RESET_SECONDS = 1_700_000_000; +const RESET_MILLISECONDS = 1_700_000_000_000; const responsesUrl = "https://chatgpt.com/backend-api/codex/responses"; const tokenUrl = "https://auth.openai.com/oauth/token"; let home: string; @@ -49,7 +51,7 @@ function observe(percent: number): void { const writer = captureMainQuotaWriter(accountId); if (!writer) throw new Error("Expected observed fixture identity"); setAccountQuotaFromParsed(MAIN, { shortPercent: percent, shortWindowSeconds: 18_000, - shortResetAt: 1, weeklyPercent: 0, weeklyResetAt: 1 }, undefined, writer); + shortResetAt: RESET_SECONDS, weeklyPercent: 0, weeklyResetAt: RESET_SECONDS }, undefined, writer); } function recordMarkers(cfg: OcxConfig, id: string, completed: CodexQuotaAutoRefreshWindows): boolean { @@ -145,8 +147,8 @@ describe("quota auto-refresh native-main admission", () => { // Simulate a process which has not observed the current physical account yet. observeMainQuotaIdentity("fixture-unrelated-observation"); writeMain(bearer(true)); - const quota = { shortPercent: 99, shortWindowSeconds: 18_000, shortResetAt: 1, - weeklyPercent: 0, weeklyResetAt: 1, updatedAt: now }; + const quota = { shortPercent: 99, shortWindowSeconds: 18_000, shortResetAt: RESET_SECONDS, + weeklyPercent: 0, weeklyResetAt: RESET_SECONDS, updatedAt: now }; writeFileSync(join(home, "codex-quota-cache.json"), JSON.stringify({ version: 1, quotas: { [MAIN]: quota }, mainPolicyQuota: { identityKey: writer.identityKey, quota }, })); @@ -165,25 +167,25 @@ describe("quota auto-refresh native-main admission", () => { observe(0); await runCodexQuotaAutoRefresh(cfg, now + 1, { persistCompleted: recordMarkers }); expect(calls).toEqual([responsesUrl]); - expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(1); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(RESET_MILLISECONDS); }); test("retained99 skips main and markers after reset while an added account completes; fresh0 admits main immediately", async () => { const cfg = config(); cfg.codexAccounts = [{ id: "pool-a", email: "pool@example.test", isMain: false }]; cfg.codexQuotaAutoRefresh!["pool-a"] = { weekly: true }; - setAccountQuotaFromParsed("pool-a", { weeklyPercent: 0, weeklyResetAt: 1 }); + setAccountQuotaFromParsed("pool-a", { weeklyPercent: 0, weeklyResetAt: RESET_SECONDS }); observe(99); const warmed: string[] = []; await runCodexQuotaAutoRefresh(cfg, now, { warmAccount: async (_cfg, id) => { warmed.push(id); }, persistCompleted: recordMarkers }); expect(warmed).toEqual(["pool-a"]); expect(cfg.codexQuotaAutoRefresh?.[MAIN]).toEqual({ fiveHour: true, weekly: true }); - expect(cfg.codexQuotaAutoRefresh?.["pool-a"]?.lastWeeklyResetAt).toBe(1); + expect(cfg.codexQuotaAutoRefresh?.["pool-a"]?.lastWeeklyResetAt).toBe(RESET_MILLISECONDS); observe(0); const calls = installFetch(async () => completedResponse()); await runCodexQuotaAutoRefresh(cfg, now + 1, { persistCompleted: recordMarkers }); expect(calls).toEqual([responsesUrl]); - expect(cfg.codexQuotaAutoRefresh?.[MAIN]).toMatchObject({ lastFiveHourResetAt: 1, lastWeeklyResetAt: 1 }); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]).toMatchObject({ lastFiveHourResetAt: RESET_MILLISECONDS, lastWeeklyResetAt: RESET_MILLISECONDS }); expect(getNativeMainProfileRequestCount()).toBe(0); }); @@ -199,7 +201,7 @@ describe("quota auto-refresh native-main admission", () => { await runCodexQuotaAutoRefresh(cfg, now, { persistCompleted: recordMarkers }); expect(calls).toEqual([responsesUrl, responsesUrl]); expect(models).toEqual(["gpt-5.4-mini", "gpt-5.5"]); - expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(1); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(RESET_MILLISECONDS); expect(getNativeMainProfileRequestCount()).toBe(0); }); @@ -223,7 +225,7 @@ describe("quota auto-refresh native-main admission", () => { await runCodexQuotaAutoRefresh(cfg, now, { persistCompleted: recordMarkers }); expect(calls).toEqual([tokenUrl, responsesUrl]); expect(order).toEqual(["refresh", "shared", "inference"]); - expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(1); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(RESET_MILLISECONDS); expect(getNativeMainProfileRequestCount()).toBe(0); } finally { restore(); } }); @@ -259,7 +261,7 @@ describe("quota auto-refresh native-main admission", () => { clearAccountNeedsReauth(MAIN); await runCodexQuotaAutoRefresh(cfg, now + 1, { persistCompleted: recordMarkers }); expect(calls).toEqual([tokenUrl, responsesUrl]); - expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(1); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(RESET_MILLISECONDS); }); test.each(["bearer", "workspace", "missing", "policy", "pause", "reauth"] as const)("%s changing at shared-claim acquisition skips inference and markers", async change => { @@ -285,6 +287,6 @@ describe("quota auto-refresh native-main admission", () => { clearAccountNeedsReauth(MAIN); await runCodexQuotaAutoRefresh(cfg, now + 1, { persistCompleted: recordMarkers }); expect(calls).toEqual([responsesUrl]); - expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(1); + expect(cfg.codexQuotaAutoRefresh?.[MAIN]?.lastWeeklyResetAt).toBe(RESET_MILLISECONDS); }); }); diff --git a/tests/codex-integration/codex-quota-auto-refresh.test.ts b/tests/codex-integration/codex-quota-auto-refresh.test.ts index e906d8605f..7bbe2ae73e 100644 --- a/tests/codex-integration/codex-quota-auto-refresh.test.ts +++ b/tests/codex-integration/codex-quota-auto-refresh.test.ts @@ -231,7 +231,7 @@ describe("Codex quota window auto refresh", () => { await runCodexQuotaAutoRefresh(cfg, NOW + 1, deps); expect(attempts).toBe(2); expect(writes).toBe(1); - expect(cfg.codexQuotaAutoRefresh?.["pool-a"]?.lastWeeklyResetAt).toBe(RESET_SECONDS); + expect(cfg.codexQuotaAutoRefresh?.["pool-a"]?.lastWeeklyResetAt).toBe(NOW); }); test("does not schedule pool-account warmups in Direct mode", async () => { diff --git a/tests/codex-integration/main-quota-evidence-validation.test.ts b/tests/codex-integration/main-quota-evidence-validation.test.ts index bd229fa7e7..1ed42205fe 100644 --- a/tests/codex-integration/main-quota-evidence-validation.test.ts +++ b/tests/codex-integration/main-quota-evidence-validation.test.ts @@ -150,7 +150,7 @@ describe("cold partial writers hydrate only the surviving legacy cache", () => { test(`${writerKind} credits-only write ${expired ? "does not revive expired" : "retains fresh"} ordinary windows`, () => { const writer = writerFor(); const quota = { - shortPercent: 99, shortResetAt: 2_000_000_000, shortWindowSeconds: 18_000, + shortPercent: 99, shortResetAt: 2_000_000_000, shortWindowSeconds: 18_000, shortObservedAt: 1_700_000_000_000, weeklyPercent: 50, weeklyResetAt: 2_100_000_000, monthlyPercent: 25, monthlyResetAt: 2_200_000_000, resetCredits: 4, updatedAt: Date.now() - (expired ? 7 : 1) * 60 * 60_000, @@ -222,22 +222,22 @@ describe("cold persisted policy percentage ranges", () => { test("rejected short usage retains independently valid unknown-window metadata", () => { const disk = writeColdPolicy({ weeklyPercent: 99, shortPercent: 101, - shortWindowSeconds: 18_000, shortResetAt: 2_000_000_000, resetCredits: 150 }); + shortWindowSeconds: 18_000, shortResetAt: 2_000_000_000, shortObservedAt: 1_700_000_000_000, resetCredits: 150 }); expect(getMainPolicyQuota()).toEqual({ updatedAt: disk.updatedAt, weeklyPercent: 99, - shortWindowSeconds: 18_000, shortResetAt: 2_000_000_000, resetCredits: 150 }); + shortWindowSeconds: 18_000, shortResetAt: 2_000_000_000, shortObservedAt: 1_700_000_000_000, resetCredits: 150 }); expect(getMainAccountHardLockStatus(cfg)).toEqual({ enabled: true, state: "unknown" }); }); test.each([0, 150, 2_000_000_000])("metadata and credits retain nonnegative %s independently of usage ranges", value => { const disk = writeColdPolicy({ shortPercent: 100, weeklyPercent: 99, monthlyPercent: 0, shortResetAt: value, weeklyResetAt: value, monthlyResetAt: value, - shortWindowSeconds: value, resetCredits: value, monthlyIsPrimaryWindow: true }); + shortWindowSeconds: value, shortObservedAt: value, resetCredits: value, monthlyIsPrimaryWindow: true }); expect(getMainPolicyQuota()).toEqual(disk); }); test.each([-1, Infinity, -Infinity, "150", null])("invalid metadata %s cannot erase valid percentage evidence", value => { const disk = writeColdPolicy({ weeklyPercent: 99, shortResetAt: value, weeklyResetAt: value, - monthlyResetAt: value, shortWindowSeconds: value, resetCredits: value }); + monthlyResetAt: value, shortWindowSeconds: value, shortObservedAt: value, resetCredits: value }); expect(getMainPolicyQuota()).toEqual({ updatedAt: disk.updatedAt, weeklyPercent: 99 }); expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); }); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index 8d32b92304..8262b242d7 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -368,9 +368,9 @@ describe("main policy quota durability and lifecycle", () => { const writer = writerFor(); writeSnapshot({ version: 1, quotas: {}, mainPolicyQuota: { identityKey: writer.identityKey, quota: { weeklyPercent: 99, monthlyPercent: "100", shortPercent: null, shortResetAt: -1, - updatedAt: 1, bearerHmac: "must-not-load", customWindows: [{ label: "untrusted", percent: 100 }], + updatedAt: 1, shortObservedAt: 1234, bearerHmac: "must-not-load", customWindows: [{ label: "untrusted", percent: 100 }], } } }); - expect(getMainPolicyQuota()).toEqual({ weeklyPercent: 99, updatedAt: 1 }); + expect(getMainPolicyQuota()).toEqual({ weeklyPercent: 99, shortObservedAt: 1234, updatedAt: 1 }); clearAccountQuota(); writeSnapshot({ version: 1, quotas: {}, mainPolicyQuota: { identityKey: "not-an-identity-key", quota: { weeklyPercent: 99, updatedAt: 1 }, diff --git a/tests/codex-integration/main-quota-window-observation.test.ts b/tests/codex-integration/main-quota-window-observation.test.ts index 95dd3432e4..75ba3a395d 100644 --- a/tests/codex-integration/main-quota-window-observation.test.ts +++ b/tests/codex-integration/main-quota-window-observation.test.ts @@ -23,11 +23,16 @@ let testDir: string; let previousHome: string | undefined; let previousCodexHome: string | undefined; let previousFetch: typeof fetch; +let observationTime: number; +let restoreObservationClock: () => void; beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; previousCodexHome = process.env.CODEX_HOME; previousFetch = globalThis.fetch; + observationTime = Date.now(); + const clock = spyOn(Date, "now").mockImplementation(() => observationTime); + restoreObservationClock = () => clock.mockRestore(); testDir = mkdtempSync(join(tmpdir(), "ocx-main-window-")); process.env.OPENCODEX_HOME = testDir; process.env.CODEX_HOME = testDir; @@ -39,6 +44,7 @@ beforeEach(() => { }); afterEach(async () => { + restoreObservationClock(); globalThis.fetch = previousFetch; clearAccountQuota(); clearMainAccountInfoCache(); @@ -292,21 +298,28 @@ describe("declared short-window producer evidence", () => { expect(info.quota).not.toHaveProperty("shortPercent"); expect(getMainPolicyQuota()).toMatchObject({ weeklyPercent: 99, shortWindowSeconds: 18_000 }); expect(getMainPolicyQuota()).not.toHaveProperty("shortPercent"); + expect(getMainPolicyQuota()).not.toHaveProperty("shortObservedAt"); expect(matchesMainQuotaCredential(accessToken, "fixture-main-a")).toBe(true); expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true })).toEqual({ enabled: true, state: "unknown" }); // The paired case: unknown metadata must not erase a previously measured short99. + const firstShortObservedAt = observationTime; await fetchMainAccountInfo(true); + expect(getMainPolicyQuota()?.shortObservedAt).toBe(firstShortObservedAt); + observationTime += 60_000; await fetchMainAccountInfo(true); expect(calls).toBe(4); for (const stored of [getAccountQuota(MAIN), getMainPolicyQuota()]) { - expect(stored).toMatchObject({ shortPercent: 99, shortWindowSeconds: 18_000, shortResetAt: 3_000_000_000 }); + expect(stored).toMatchObject({ shortPercent: 99, shortWindowSeconds: 18_000, + shortResetAt: 3_000_000_000, shortObservedAt: firstShortObservedAt }); } const enabled = { codexMainAccountHardLock: true }; expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("blocked"); expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000)).toEqual({ enabled: true, state: "blocked" }); + observationTime += 60_000; await fetchMainAccountInfo(true); expect(calls).toBe(5); - expect(getMainPolicyQuota()).toMatchObject({ shortPercent: 0, shortResetAt: 4_000_000_000 }); + expect(getMainPolicyQuota()).toMatchObject({ shortPercent: 0, shortResetAt: 4_000_000_000, shortObservedAt: observationTime }); + expect(getAccountQuota(MAIN)?.shortObservedAt).toBe(observationTime); expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("ready"); } finally { fetchSpy.mockRestore(); @@ -335,22 +348,30 @@ describe("declared short-window producer evidence", () => { weeklyPercent: 99, shortWindowSeconds: 18_000, shortResetAt: 4_000_000_000, }); expect(getMainPolicyQuota()).not.toHaveProperty("shortPercent"); + expect(getMainPolicyQuota()).not.toHaveProperty("shortObservedAt"); expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true })).toEqual({ enabled: true, state: "unknown" }); + const firstShortObservedAt = observationTime; applyAccountQuotaFromUpstreamHeaders(MAIN, new Headers({ "x-codex-primary-used-percent": "99", "x-codex-primary-window-minutes": "300", "x-codex-primary-reset-at": "3000000000", }), undefined, writer); + expect(getMainPolicyQuota()?.shortObservedAt).toBe(firstShortObservedAt); + observationTime += 60_000; headers.set("x-codex-primary-window-minutes", "60"); applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); for (const stored of [getAccountQuota(MAIN), getMainPolicyQuota()]) { - expect(stored).toMatchObject({ shortPercent: 99, shortWindowSeconds: 18_000, shortResetAt: 3_000_000_000 }); + expect(stored).toMatchObject({ shortPercent: 99, shortWindowSeconds: 18_000, + shortResetAt: 3_000_000_000, shortObservedAt: firstShortObservedAt }); } const enabled = { codexMainAccountHardLock: true }; expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("blocked"); expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000)).toEqual({ enabled: true, state: "blocked" }); headers.set("x-codex-primary-used-percent", "0"); + observationTime += 60_000; applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); - expect(getMainPolicyQuota()).toMatchObject({ shortPercent: 0, shortWindowSeconds: 3_600, shortResetAt: 4_000_000_000 }); + expect(getMainPolicyQuota()).toMatchObject({ shortPercent: 0, shortWindowSeconds: 3_600, + shortResetAt: 4_000_000_000, shortObservedAt: observationTime }); + expect(getAccountQuota(MAIN)?.shortObservedAt).toBe(observationTime); expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("ready"); }); } From d48b32203c1170958037cf09c4b73dcda74d96be Mon Sep 17 00:00:00 2001 From: t Date: Sat, 5 Sep 2026 13:36:05 +0900 Subject: [PATCH 11/11] fix(codex): preserve legacy quota contracts beside private policy --- .../017_persisted_policy_validation.md | 2 +- .../050_ci_contract_repairs.md | 9 +++++++++ src/codex/quota.ts | 6 +++--- src/server/management-api.ts | 6 +++--- src/server/management/route-registry.ts | 4 ++-- .../main-quota-window-observation.test.ts | 18 +++++++++--------- tests/gui/rate-limit-reset-credits.test.ts | 4 +++- tests/usage/quota-reset-notify.test.ts | 15 ++++++++------- 8 files changed, 38 insertions(+), 26 deletions(-) create mode 100644 devlog/_plan/260905_main_quota_guard/050_ci_contract_repairs.md diff --git a/devlog/_plan/260905_main_quota_guard/017_persisted_policy_validation.md b/devlog/_plan/260905_main_quota_guard/017_persisted_policy_validation.md index cd4d43f96f..839e64429f 100644 --- a/devlog/_plan/260905_main_quota_guard/017_persisted_policy_validation.md +++ b/devlog/_plan/260905_main_quota_guard/017_persisted_policy_validation.md @@ -1,6 +1,6 @@ # Persisted policy field validation -The policy disk decoder now validates usage percentages as finite0..100 independently from timestamps, durations and credits. It does not clamp invalid percentages or alter the ordinary rotation-cache decoder. An invalid percentage alone cannot invent a higher-priority window and shadow another valid blocking percentage; independently valid declared window metadata remains meaningful and unknown usage does not switch windows. +The policy disk decoder now validates usage percentages as finite [0, 100] independently from timestamps, durations and credits. It does not clamp invalid percentages or alter the ordinary rotation-cache decoder. An invalid percentage alone cannot invent a higher-priority window and shadow another valid blocking percentage; independently valid declared window metadata remains meaningful and unknown usage does not switch windows. Cold identity-matched disk regressions cover invalid numeric/nonnumber values, valid0/99/100, weekly/monthly fallback, metadata/credit independence and updatedAt rejection. They exercise real hydration rather than a policy setter. No local suites. Independent review and exact-head CI are required, followed by both upper-layer cascades. diff --git a/devlog/_plan/260905_main_quota_guard/050_ci_contract_repairs.md b/devlog/_plan/260905_main_quota_guard/050_ci_contract_repairs.md new file mode 100644 index 0000000000..fc5aa0f4f0 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/050_ci_contract_repairs.md @@ -0,0 +1,9 @@ +# CI contract reconciliation + +CI33943946291 exposed an integration regression: policy retention was applied to legacy reset-only short tuples. It is now restricted to the policy merge; legacy rotation keeps dev's unknown-tuple behavior, credits/weekly carry and fresh observation-time stamping. The upstream routing assertion remains unchanged, while own tests exactly distinguish legacy unknown data from retained policy99. + +The same run exposed an undeclared existing GET /api/quota-resets and a lazy-dispatch guard that the route scanner could not assign a method. The route is declared under its actual read-only handler; exact-only matching reuses the existing namespace helper without changing other namespaces, authorization, handler semantics or lazy imports. GET200/invalid-limit400 remain covered; child/prefix/POST cases require null. No scanner exemption or weakened gate was introduced. + +CI33944061586 also found an exact header snapshot missing dev's new shortObservedAt. The assertion now requires that numeric field and equality with the same write's updatedAt, preserving all window values. The finite-range wording nit in017 is corrected in this already-required update. + +No local suites. Fresh exact-head CI and upper cascade are mandatory; failures are treated as contract evidence, not flakes. diff --git a/src/codex/quota.ts b/src/codex/quota.ts index d483654b3a..8ff58d88fd 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -332,7 +332,7 @@ function mergeAccountQuota( quota: Omit, existing: StoredAccountQuota | undefined, updatedAt: number, - requireMonthlyPrimaryForWeeklyReplacement = false, + policyEvidence = false, ): StoredAccountQuota { const next: StoredAccountQuota = { updatedAt }; const creditsOnly = quota.resetCredits !== undefined && !snapshotHasUsage(quota); @@ -356,7 +356,7 @@ function mergeAccountQuota( if (quota.weeklyPercent !== undefined) next.weeklyPercent = quota.weeklyPercent; if (quota.weeklyResetAt !== undefined) next.weeklyResetAt = quota.weeklyResetAt; } else if (snapshotHasMonthly(quota) - && (!requireMonthlyPrimaryForWeeklyReplacement || quota.monthlyIsPrimaryWindow === true)) { + && (!policyEvidence || quota.monthlyIsPrimaryWindow === true)) { // Legacy monthly-only clearing is unchanged (#382). Policy needs a governing // monthly-primary observation: a tertiary-only header cannot retract weekly99. } else if (existing?.weeklyPercent !== undefined) { @@ -379,7 +379,7 @@ function mergeAccountQuota( if (existing.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true; } - const preserveKnownShort = quota.shortPercent === undefined && finitePercent(existing?.shortPercent); + const preserveKnownShort = policyEvidence && quota.shortPercent === undefined && finitePercent(existing?.shortPercent); if (snapshotHasShort(quota) && !preserveKnownShort) { if (quota.shortPercent !== undefined) { next.shortPercent = quota.shortPercent; diff --git a/src/server/management-api.ts b/src/server/management-api.ts index f1749bc78e..c703a33e07 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -101,8 +101,8 @@ const managementConvergenceBindings = new WeakMap { - if (!pathInManagementNamespace(ctx.url.pathname, "/api/quota-resets")) return null; + if (!pathInManagementNamespace(ctx.url.pathname, "/api/quota-resets", false)) return null; const { handleQuotaResetRoutes } = await import("./management/quota-reset-routes"); return handleQuotaResetRoutes(ctx); } diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index bf3768ff9b..2665aa9178 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -1,7 +1,7 @@ /** * Declared inventory of every reachable management route. * - * DECLARED, not harvested. A grep cannot see this surface: 18 routes are registered + * DECLARED, not harvested. A grep cannot see this surface: 19 routes are registered * through a regex, an `endsWith`, a `pathname.slice`, a prefix decode, a path constant, or a * negated `pathname !== "…"` guard, and two of those are live routes whose only textual * trace is the negated form. For `GET /api/storage` an equality scan finds solely the dead @@ -303,7 +303,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/system/memory", module: "server/management/system-routes", mutates: false }, { method: "GET", path: "/api/system/windows-replace-retries", module: "server/management/system-routes", mutates: false }, { method: "POST", path: "/api/system/restart", module: "server/management/system-routes", mutates: true }, - // --- Routes an equality scan of their own file cannot see (18). --- + // --- Routes an equality scan of their own file cannot see (19). --- // Each carries `mechanism`; the reconciliation test counts these separately. { method: "GET", path: "/api/storage", module: "server/management/storage-log-guard-routes", mutates: false, mechanism: "negated-guard" }, { method: "GET", path: "/api/routing-analytics", module: "server/management/routing-analytics-routes", mutates: false, mechanism: "negated-guard" }, diff --git a/tests/codex-integration/main-quota-window-observation.test.ts b/tests/codex-integration/main-quota-window-observation.test.ts index 75ba3a395d..ddacc0010a 100644 --- a/tests/codex-integration/main-quota-window-observation.test.ts +++ b/tests/codex-integration/main-quota-window-observation.test.ts @@ -301,17 +301,17 @@ describe("declared short-window producer evidence", () => { expect(getMainPolicyQuota()).not.toHaveProperty("shortObservedAt"); expect(matchesMainQuotaCredential(accessToken, "fixture-main-a")).toBe(true); expect(getMainAccountHardLockStatus({ codexMainAccountHardLock: true })).toEqual({ enabled: true, state: "unknown" }); - // The paired case: unknown metadata must not erase a previously measured short99. + // Unknown metadata replaces the legacy tuple but must retain trusted policy short99. const firstShortObservedAt = observationTime; await fetchMainAccountInfo(true); expect(getMainPolicyQuota()?.shortObservedAt).toBe(firstShortObservedAt); observationTime += 60_000; await fetchMainAccountInfo(true); expect(calls).toBe(4); - for (const stored of [getAccountQuota(MAIN), getMainPolicyQuota()]) { - expect(stored).toMatchObject({ shortPercent: 99, shortWindowSeconds: 18_000, - shortResetAt: 3_000_000_000, shortObservedAt: firstShortObservedAt }); - } + expect(getAccountQuota(MAIN)).toEqual({ weeklyPercent: 99, shortWindowSeconds: 3_600, + shortResetAt: 4_000_000_000, updatedAt: observationTime }); + expect(getMainPolicyQuota()).toEqual({ weeklyPercent: 99, shortPercent: 99, shortWindowSeconds: 18_000, + shortResetAt: 3_000_000_000, shortObservedAt: firstShortObservedAt, updatedAt: observationTime }); const enabled = { codexMainAccountHardLock: true }; expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("blocked"); expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000)).toEqual({ enabled: true, state: "blocked" }); @@ -359,10 +359,10 @@ describe("declared short-window producer evidence", () => { observationTime += 60_000; headers.set("x-codex-primary-window-minutes", "60"); applyAccountQuotaFromUpstreamHeaders(MAIN, headers, undefined, writer); - for (const stored of [getAccountQuota(MAIN), getMainPolicyQuota()]) { - expect(stored).toMatchObject({ shortPercent: 99, shortWindowSeconds: 18_000, - shortResetAt: 3_000_000_000, shortObservedAt: firstShortObservedAt }); - } + expect(getAccountQuota(MAIN)).toEqual({ weeklyPercent: 99, shortWindowSeconds: 3_600, + shortResetAt: 4_000_000_000, updatedAt: observationTime }); + expect(getMainPolicyQuota()).toEqual({ weeklyPercent: 99, shortPercent: 99, shortWindowSeconds: 18_000, + shortResetAt: 3_000_000_000, shortObservedAt: firstShortObservedAt, updatedAt: observationTime }); const enabled = { codexMainAccountHardLock: true }; expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000 - 1).state).toBe("blocked"); expect(getMainAccountHardLockStatus(enabled, 3_000_000_000_000)).toEqual({ enabled: true, state: "blocked" }); diff --git a/tests/gui/rate-limit-reset-credits.test.ts b/tests/gui/rate-limit-reset-credits.test.ts index 07e3ee8ec4..d88f532464 100644 --- a/tests/gui/rate-limit-reset-credits.test.ts +++ b/tests/gui/rate-limit-reset-credits.test.ts @@ -500,7 +500,8 @@ describe("rate-limit reset credits", () => { "x-codex-secondary-reset-at": "1788000000", }); applyAccountQuotaFromUpstreamHeaders("burst-A", headers); - expect(getAccountQuota("burst-A")).toEqual({ + const stored = getAccountQuota("burst-A"); + expect(stored).toEqual({ shortPercent: 97, shortResetAt: 1787401330, shortObservedAt: expect.any(Number), @@ -509,6 +510,7 @@ describe("rate-limit reset credits", () => { weeklyResetAt: 1788000000, updatedAt: expect.any(Number), }); + expect(stored?.shortObservedAt).toBe(stored?.updatedAt); }); it("an exhausted burst window does not poison the weekly reading", () => { diff --git a/tests/usage/quota-reset-notify.test.ts b/tests/usage/quota-reset-notify.test.ts index d6a15cbde6..2dc77cc5aa 100644 --- a/tests/usage/quota-reset-notify.test.ts +++ b/tests/usage/quota-reset-notify.test.ts @@ -396,9 +396,9 @@ describe("GET /api/quota-resets", () => { } as OcxConfig; } - async function get(path: string): Promise { + async function get(path: string, method = "GET"): Promise { const req = new Request(`http://localhost${path}`, { - method: "GET", + method, headers: { host: "localhost" }, }); return handleManagementAPI(req, new URL(req.url), managementConfig(), { @@ -438,11 +438,12 @@ describe("GET /api/quota-resets", () => { expect(response?.status).toBe(400); }); - test("an unrelated management path is left to the rest of the chain", async () => { - // The handler is prefix-guarded, so it must return null rather than answering for - // everything: returning a response here would shadow every other route. - const response = await get("/api/quota-resets/extra"); - expect(response?.status).not.toBe(200); + test.each([ + ["/api/quota-resets/extra", "GET"], + ["/api/quota-resets-extra", "GET"], + ["/api/quota-resets", "POST"], + ])("%s %s is left to the rest of the chain", async (path, method) => { + expect(await get(path!, method)).toBeNull(); }); });