diff --git a/devlog/_plan/260905_main_quota_guard/030_reserve_compatibility.md b/devlog/_plan/260905_main_quota_guard/030_reserve_compatibility.md index 0921802523..b659871a79 100644 --- a/devlog/_plan/260905_main_quota_guard/030_reserve_compatibility.md +++ b/devlog/_plan/260905_main_quota_guard/030_reserve_compatibility.md @@ -4,10 +4,10 @@ Loop archetype: spec satisfaction. Trigger: original owner request2; grounded by ## Contract -NEW `src/codex/reserve-availability.ts`: memory-only, identity-generation-bound Reserve observation. Public status is available/unavailable/unknown, without identity/credential data. Record only a completed owned main WHAM response associated with captured MainQuotaWriter. Require fresh matching identity, ordinary rate_limit.allowed=false, rate_limit_upsell.banner_type=luna_reserve, additional_rate_limits entry limit_name=gpt-reserve with allowed=true. Reject contradictory explicit account/user identifiers. Missing/stale/failed observations never grant access. Use a named bounded freshness TTL and existing owned refresh/single-flight route; do not probe unrelated pool accounts. No persisted entitlement grant. +NEW `src/codex/reserve-availability.ts`: memory-only, identity-generation-bound Reserve observation. The passive WHAM reader is insufficient: upstream backend-client/client/rate_limit_resets.rs:75 sends x-openai-codex-luna-reserve:1 only for capable clients. Use a dedicated bounded WHAM request with that header and an already-owned token/MainQuotaWriter; it introduces no credential-file reader. Require fresh matching identity, ordinary rate_limit.allowed=false, rate_limit_upsell.banner_type=luna_reserve, and exactly one additional entry limit_name=gpt-reserve with allowed=true. Reject contradictory explicit account/user identifiers. Missing/stale/failed observations never grant access. Cache at most60seconds in memory, share a bounded8second flight for the current identity, and isolate caller cancellation. No persisted entitlement grant or unrelated pool probing. MODIFY `src/codex/quota.ts` WHAM types to retain the optional allowed/banner/identity fields and additional Reserve window shape without folding it into ordinary percentages. Field chain: authenticated WHAM input -> reserve parser/recorder with MainQuotaWriter -> memory observation -> fresh availability getter -> catalog and explicit-main request gate -> safe DTO. Ordinary quota parser stays ordinary; no new use of Reserve percentages in99% policy. -MODIFY `src/codex/auth-api.ts`: successful identity-validated main WHAM path records Reserve availability with the already captured writer; unsuccessful/contradictory replies do not extend validity. Expose only safe availability in main account DTO if needed for actionable status. Keep usage refresh available while main is protected. Main identity changes invalidate observations through the existing generation API. +Keep the passive auth-api reader/cache unchanged; its only required edit is the recovery-scope allowlist below. The dedicated capability-aware request may publish its genuine ordinary quota through the existing parser/provenance setter, then the99% policy is rechecked before dispatch. Main identity changes invalidate Reserve observations through the existing generation API. Expose a safe status only if a real consumer needs it; no speculative DTO fields. ## Independent quota semantics @@ -20,7 +20,7 @@ MODIFY `src/codex/routing.ts`: + 'gpt-reserve': 'reserve', }; ``` -Creation: exact native wire model mapping. Serialization: existing scoped health/affinity structures; inspect every scope field/enum/string consumer at the next P. Deserialization: existing scope validators must accept reserve explicitly, not silently default it to shared. Consumers: global-first cooldown lookup, scoped health writes, affinity/pool cursor, probe claim/settlement and status/error formatting. Existing independent-scope predicates already cover non-shared; every spark-only exception must be classified rather than blindly duplicated. +Creation: exact native wire model mapping. Serialization/deserialization: no persisted or JSON-decoded scope enum exists; scoped health/affinity and claims are process-local typed values. Consumers: global-first cooldown lookup, scoped health writes, affinity/pool cursor, probe claim/settlement and status/error formatting. Existing independent-scope predicates already cover non-shared; the two ordinary-recovery Spark exclusions become an explicit undefined/shared allowlist. The cooldown label table includes Reserve. No other blanket scope rewrite is needed. Global Retry-After/default throttles remain account-wide and win over scope-specific evidence. A shared reset-derived cooldown does not imply Reserve exhaustion. Generic recovery claim and auth-api settlement currently exclude only spark; exclude reserve too so an ordinary success cannot clear Reserve health. Do not add an automatic Reserve recovery worker in this first slice. ## Catalog and exact request gate diff --git a/devlog/_plan/260905_main_quota_guard/031_reserve_dispatch_contract.md b/devlog/_plan/260905_main_quota_guard/031_reserve_dispatch_contract.md new file mode 100644 index 0000000000..bd49588db1 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/031_reserve_dispatch_contract.md @@ -0,0 +1,75 @@ +# Reserve P stale check and exact implementation contract + +Base c79ddb237, following runtime PR3552 and UI PR3560. No Reserve production edits yet. This document supersedes030 where new source evidence changes its initial outline. + +## Source decisions + +1. Upstream backend-client/client/rate_limit_resets.rs sends `x-openai-codex-luna-reserve: 1` only for a Reserve-capable usage reader. Reusing the passive auth-api cache cannot establish a grant. A new bounded request consumes an ALREADY OWNED token/writer and reads the fixed WHAM endpoint; it never reads auth files. +2. No genuine full Reserve row was present in the current local cache or pinned upstream models file. Installed Desktop app-primary at byte7352039 copies the whole matching Reserve-or-Luna picker preset while replacing its model with gpt-reserve. Adopt that as an explicitly documented OCX compatibility adaptation: prefer real observed Reserve metadata, otherwise the existing pinned/derived Luna metadata, MAIN SELECTOR ONLY and effective authless opt-in only. This is not a claim that all backend capabilities are identical. +3. Metadata and permission are separate. Offline `ocx sync` cannot read a different proxy process's in-memory grant. Catalog construction therefore exposes a manual choice without claiming availability; every compatibility request requires fresh upstream permission. Adapted rows carry provenance and never become evidence of a genuine Reserve observation on a later sync. +4. Quota scopes are process-local typed Maps, not persisted enums. Only mapping, generic recovery allowlists and human-readable labeling need changes; global cooldown precedence stays unchanged. + +## Structural decision + +Current: catalog/sync and inject reference each other; loopback/credential-header predicates live inside inject. Importing inject from new catalog code creates an avoidable cycle, and copying the predicates would drift. +Chosen: extract the existing `isLoopbackHostname` and `shouldInjectApiAuthHeader` unchanged to `src/codex/loopback-target.ts`, retaining inject imports/re-exports. Add a pure effective-authless predicate there (flag true, non-client role, no required header under the existing loopback rule). Dependencies become inject -> leaf and catalog/reserve -> leaf; public exports remain compatible. This is a feature-scoped extraction, not an injection redesign. CI existing injection/admission tests and new configuration matrix verify unchanged behavior. +Also move StoredAccountQuota and WHAM wire types unchanged/extended as specified to `src/codex/quota-types.ts`, re-export from quota.ts and import types directly in main-account-cache and reserve-availability. This removes the quota/cache type cycle as a third consumer is introduced; runtime serialization stays identical. The new availability module must NOT import the quota/config facade at runtime: a required observer callback publishes ordinary quota through the existing auth-context owner, preserving the downward dependency direction. + +## Main lane: availability boundary + +Write: NEW reserve-availability.ts and quota-types.ts; MODIFY quota.ts types only and main-account-cache.ts type import; NEW tests/codex-integration/reserve-availability.test.ts; both layout manifests; docs/records. Main owns public English/Korean guide/SoT updates. + +Exports: +```ts +export interface MainReserveAuthorization { + readonly writer: MainQuotaWriter; + readonly observedAt: number; + readonly expiresAt: number; +} +export function getMainReserveAuthorization( + input: { + token: {accessToken:string;chatgptAccountId:string}; + writer: MainQuotaWriter | undefined; + signal?: AbortSignal; + observeOrdinaryQuota: (data:WhamUsageResponse, writer:MainQuotaWriter) => void; + }, +): Promise; +export function isMainReserveAuthorizationLive(value:MainReserveAuthorization | undefined, token:{accessToken:string;chatgptAccountId:string}, now?:number): boolean; +export function observeMainReserveRevocation(data:WhamUsageResponse, writer:MainQuotaWriter | undefined): void; +``` +Use60s maximum cache age and existing WHAM_REQUEST_TIMEOUT_MS=8000 for the whole fetch/read budget; bound body to existing64KiB reader. Cache/flight keys include physical identity, generation AND a process-local HMAC of the exact owned bearer. Associate authorization objects with that credential key privately (e.g. a WeakMap), never public/disk fields. Every cache hit, join, publication and final materialization must match the exact current owned bearer/effective account; account identity alone cannot distinguish users sharing a workspace. Token replacement retires the old flight; refreshed tokens must obtain their own proof even for the same user. A caller's abort does not cancel another caller's shared read. Abort listeners and deadline timers are cleaned up. A late response cannot publish after deadline, identity/credential change or a newer revocation. + +Before dispatch, require writer still live and the supplied token/effective account matches the existing owned credential observation. JSON permission requires exact ordinary.allowed=false, banner=luna_reserve, exactly one reserve entry with allowed=true. Explicit account echo mismatch rejects; explicit user mismatch rejects when the owned access-token auth namespace provides chatgpt_user_id or user_id (per upstream login/token_data.rs). Missing echoes are not invented: trust comes from the authenticated account-scoped request plus the owned writer, not an arbitrary incoming header. +No token, response body, identity key or grant enters public DTO/log/disk. After identity/credential/deadline checks, invoke the required observer once for a genuine usage response; the auth-context callback uses captured config generation/writer with the existing parser/store. No Reserve percentage is folded into ordinary quota. Passive fresh ordinary.allowed=true or explicit Reserve.allowed=false revokes a cached authorization but can NEVER create one. A missing Reserve field on a non-capable passive read is not a grant or a revocation. + +## Auth worker lane + +Write: auth-context.ts, server/responses/core.ts and compact.ts plus NEW tests/codex-integration/reserve-auth-context.test.ts. No other lane files. +New compatibility handling is gated by effective authless opt-in and exact wire model gpt-reserve. Pin an unqualified Reserve request on a Codex-forward route to stored main; reject an explicit non-main account. Do not change unrelated/native-client default handling when the opt-in is off. Configured selectors use the existing router; no arbitrary bare native catalog expansion. +After existing ownership, pause/reauth/99% and global/Reserve cooldown admission, obtain the owned main token and writer, call getMainReserveAuthorization, then RECHECK99% policy (the WHAM read may have observed99) and cooldown before returning. No automatic fallback to another account or normal Luna. Existing user-configured combo behavior is not a new hidden fallback. +Caller-owned main can participate only when its token AND effective account match already-owned observation; reuse the supplied token/writer without a physical read. An unmatched caller gets an actionable unavailable error in this opt-in compatibility path. +Add optional reserveAuthorization to main/main-pool contexts only when handling Reserve. Actual materializers check isMainReserveAuthorizationLive against the ACTUAL outgoing token/effective account immediately before returning credential-bearing headers, alongside the existing hard-lock check. Refreshed context spreads do not vouch for a new credential: asynchronous materialization reacquires permission when the token changes. No global provider predicate weakening. +Custom-named canonical-forward routes skip resolveCodexAuthContext and synthesize kind:main. Thread `modelId` through the existing materializer options and all core/compact producer calls, including the final synchronous recheck. The transport predicate plus effective authless mode and exact model determine whether a proof is required; absence of a context marker cannot bypass it. Async materialization performs the same owned/matched-main-only permission and global/Reserve cooldown checks for this path; sync materialization refuses a missing/stale proof rather than guessing. Independently keyed providers still receive no policy config. Add actual-handler custom/gpt-reserve denial-with-zero-inference and keyed-provider success tests. +CodexReserveUnavailableError uses the existing cooldown-family mapping with its own safe actionable message (not a reauth or invented stored cooldown). It must not mint a probe or mark reauth. Add Reserve quota to an exhaustive Record formatter. Keep unknown/global label semantics unchanged. + +## Scope worker lane + +Write: routing.ts, auth-api.ts; NEW tests/codex-integration/reserve-quota-scope.test.ts (or extend existing cooldown test fixtures narrowly). No auth-context edits. +Add reserve to CodexQuotaScope and exact gpt-reserve mapping. Replace claim and settlement Spark-only exclusions with `scope === undefined || scope === 'shared'`. Do not modify global-first lookup, account-wide Retry-After/default handling or blanket success cleanup. +In the successful identity-validated main WHAM path, call observeMainReserveRevocation(data, mainQuotaWriter). It invalidates only matching cached grants from genuine newer evidence; no capability header or new grant is added to the passive reader. +Tests use an ADDED-account fixture to reach generic recovery claim filtering (main is never visited there), without enabling added-account Reserve requests. Check shared recovery preserves Reserve, independent-only starts no worker read, global/default wins, ordinary unleased success does not clear Reserve. Exact main cannot acquire a recovery probe; do not author an unreachable probe test. + +## Catalog worker lane + +Write: NEW catalog/reserve.ts and loopback-target.ts; MODIFY catalog/metadata.ts, catalog/sync.ts, catalog/native-models.ts (constant only), inject.ts (pure predicate imports/re-exports only); NEW tests/codex-integration/reserve-catalog.test.ts. No global native-list or capability-alias-map additions. +Export NATIVE_RESERVE_MODEL='gpt-reserve' from the existing native-models leaf. Auth/scope/main import the constant. Effective-authless helper must match actual loopback injection and refuse remote-client mode; reuse the extracted predicate, no heavy inject import from catalog. +The Codex-specific build input carries optional Reserve source and eligible main selectors. Under opt-in add only configured main-selector/gpt-reserve entries; added account selectors, bare discovery, API-key and generic Claude export stay unchanged. Prefer full actual Reserve raw source; otherwise derive/copy existing Luna metadata using established context caps. Mark fallback `opencodex_reserve_metadata_source:'gpt-5.6-luna'`; reject adapted rows as actual observations. Keep genuine source rows unmodified. Qualified supported_in_api=true means this OCX endpoint can accept the selector subject to permission, NOT public OpenAI API entitlement. Remove inherited plan/upgrade marketing. +Preserve marker/source through merge alignment and repeated normalization; do not widen Reserve efforts from generic models. Respect disabledModels including the exact selector. Existing strict generic-template selection must never choose Reserve. + +## Acceptance/verification + +Parent review prerequisites during B are specified in033/034: retained99 stays blocked past resetAt until fresh valid lower evidence; reject negative raw percentages as policy evidence before legacy clamping; the existing60s sweep performs bounded owned quota recovery while blocked, without a new periodic timer or inference. Hydrate before both merge-base reads and explicitly clear fixture timer ownership. Repair disable-save focus on the UI parent. Main commits repaired parents then cascades UI and Reserve branches before implementation. All changed heads require fresh final CI; no worktree identity change. + +All lanes author focused tests but run no local suites. Main runs typecheck, scoped static checks and exact-head CI. Positive capability header + response drives actual authorized main dispatch; absent/stale/mismatch/duplicate/malformed/timeout evidence yields zero inference sends.99% selected-window block and global cooldown still win. Concurrent callers share a bounded read; one abort and stale writer do not corrupt another. Catalog fallback is deterministic without proxy memory and is marked adaptation; observed source wins; repeated sync stays main-only. +Credential-specific scenarios additionally include two distinct tokens/users selecting the same workspace, token replacement during an in-flight usage read, and refresh replay with an old spread authorization. None can reuse or publish another credential's grant. +No currently Reserve-active account was available, so live Reserve inference cannot be claimed. Source-based Desktop authless gate + fixture-backed integration prove the patch mechanics. Final delivery still requires all stack heads green, no unresolved reviews, bottom-up authorized admin merges and ancestry. diff --git a/devlog/_plan/260905_main_quota_guard/032_reserve_audit.md b/devlog/_plan/260905_main_quota_guard/032_reserve_audit.md new file mode 100644 index 0000000000..192b019d88 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/032_reserve_audit.md @@ -0,0 +1,9 @@ +# Reserve audit synthesis + +Pauli's first plan audit returned FAIL with two accepted high blockers. + +1. Custom-named canonical-forward transports bypass auth-context resolution. Fix the real entry boundary, not just the ordinary resolver: main-only proof requirement travels with exact model and qualified transport into every materializer. Expand the auth lane to core/compact producer options, preserve independent keyed-provider behavior, and test the actual handler with zero inference sends on denial. +2. Account generation is not credential/user identity. Exact-token process-local HMAC joins writer identity/generation in cache/flight keys and authorization-object private provenance. Validate the outgoing credential at final materialization; a refresh cannot inherit old permission by object spread. Add same-workspace/different-token and in-flight replacement scenarios. + +Cross-blocker consistency: the outer transport decides when proof is required; the owned credential decides which proof can be used. Neither a caller-supplied model nor a copied context field can become authorization. No extra credential file reads are introduced. +No production code written before the updated audit. Parent quota hydration correction is a B prerequisite followed by stack cascade; expiry retirement remains the documented observed-window policy and the timer comment is rebutted by its real cleanup call chain. diff --git a/devlog/_plan/260905_main_quota_guard/033_parent_review_amendment.md b/devlog/_plan/260905_main_quota_guard/033_parent_review_amendment.md new file mode 100644 index 0000000000..5438c130c9 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/033_parent_review_amendment.md @@ -0,0 +1,21 @@ +# Parent review amendment: observed recovery, not a clock-only release + +Maintainer Ingwannu requested changes on runtime headfe2e10e15. Accept the stricter interpretation of the owner's fresh0 recovery request. This supersedes the earlier expiry-retirement decision in013/031/032; do not retain contradictory user documentation. + +## Runtime contract + +MODIFY main-account-hard-lock.ts: a retained selected-window99..100 remains blocked after its reset timestamp. Do not turn it into unknown solely because time passed. Omit expired resetAt from the blocked DTO. A fresh observed value below99, including0, releases; missing/invalid readings retain existing protected evidence.5h priority remains unchanged and never falls back to weekly. + +MODIFY auth-api.ts: add `runMainAccountHardLockRecovery(config)` to the EXISTING60s state-sweep afterTick registration. This creates no new periodic timer and is inert unless the flag is true and main is currently blocked. Skip existing reauth quarantine; coalesce concurrent calls in one bounded flight. Acquire the existing native-main runtime lease, obtain a valid stored token with the existing refresh machinery BEFORE acquiring the WHAM shared credential claim, then force a fresh owned WHAM read with `explicitRefresh:false`. Extend the private fetch attempt with an optional explicit-refresh override; normal/manual callers keep current defaults. Never acquire an exclusive token-refresh claim while holding the WHAM shared claim. Existing network bounds are30s credential refresh plus8s per WHAM attempt (at most one identity-change retry,46s total); native-claim setup retains its existing bounds. Release the runtime lease in finally and do not overlap a slow flight on a later tick. +Successful fresh quota updates release only the local99% policy; do not clear paused or unrelated cooldown state. A metadata200 must not clear a pre-existing reauth flag. Genuine terminal token-refresh failure may mark reauth to stop repeated bad-grant retries. Failed/unavailable quota reads retain the lock. No inference, credit consumption, new provider probing or account switching. + +## Other review corrections + +MODIFY quota.ts: hydrate before reading the existing merge base in both parsed and legacy writers. Reuse only the ordinary cache that survived its existing6h TTL; never repopulate it from policy-only evidence. Add cold partial-update regressions for both writer entrypoints. +MODIFY main-quota-provenance.test.ts: explicit fixture pending-timer clear/reset in afterEach, as requested. The production clearAccountQuota already cancels the same handle; this makes fixture ownership self-contained without changing production behavior. + +## Verification and delivery + +Update policy/window-observation expiry assertions to require retained block, followed by fresh0 release. Extend actual owned-WHAM tests for background recovery, disabled/no-block/reauth skip, coalescing, failed read retaining block, and preservation of other account restrictions. No local suites; source/test typechecks and exact-head CI. +Update structure and public English/Korean docs: while blocked, fresh quota is checked by the existing once-per-minute background cycle. Change prior expiry claims in unit notes to a superseded record, not an undocumented contradiction. +Commit this prerequisite on runtime PR3552, then cascade UI PR3560 and this unpushed Reserve plan branch with leases protecting remote heads. Run every resulting head's final CI before admin merge. Reserve implementation consumes this repaired base. diff --git a/devlog/_plan/260905_main_quota_guard/034_recovery_evidence_validation.md b/devlog/_plan/260905_main_quota_guard/034_recovery_evidence_validation.md new file mode 100644 index 0000000000..3ce9f1ffcc --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/034_recovery_evidence_validation.md @@ -0,0 +1,13 @@ +# Recovery evidence validation and UI focus follow-up + +Pauli identified a producer boundary missed by033: legacy normalizeUsagePercent clamps a negative reading to0. Such malformed input must not release retained policy99. + +Keep legacy parsing/clamping unchanged. Add `parseMainPolicyUsageQuota(data)` beside parseUsageQuota: reject the message as policy evidence when any normal primary/secondary/tertiary percentage is negative (including numeric header/string forms), otherwise use the canonical parser. Unknown/non-numeric/missing percentages retain existing parser behavior and short-window shape; genuine0 remains valid. Reserve/Spark additional buckets do not become ordinary policy evidence. + +Extend setAccountQuotaFromParsed with optional fifth `policyQuota` argument, defaulting to the typed quota input. For a live main writer, explicit null means retain matching existing trusted policy evidence, not replace it with normalized legacy0. A new/mismatched owner cannot inherit it. Untagged main writes continue invalidating policy provenance. Legacy accountQuota writes and their normalization are unchanged. + +The owned WHAM producer passes independently validated policy evidence with its existing raw data and plan. The header applicator checks the three canonical percent headers before losing their sign and passes null on negative input. The future Reserve observer callback does the same through parseMainPolicyUsageQuota. This is a conservative invalid-message rule for policy only, not a global legacy parser change. + +Add real-WHAM and header sequences: retained99 -> negative -1 (legacy may clamp0; policy remains blocked) -> genuine0 (policy ready, flag still on). Keep missing-short-window metadata cases and expiry-retained-block assertions intact. + +UI PR3560 received a valid focus finding: disabling an enabled switch must arm focus restoration too. Set the restoration intent before either enable or disable. If failed save requires an authoritative reload, keep focus on the setting while disabled and restore the toggle after a successful reload. Add success/failure focus assertions without changing acknowledgment semantics. Main applies this on the UI parent during the same stack cascade, before Reserve implementation. diff --git a/devlog/_plan/260905_main_quota_guard/039_reserve_verification.md b/devlog/_plan/260905_main_quota_guard/039_reserve_verification.md new file mode 100644 index 0000000000..6cef6eaba5 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/039_reserve_verification.md @@ -0,0 +1,15 @@ +# Reserve compatibility verification + +Stack base: UI080878d5d on runtimef42d86fca. Runtime exact-head Cross-platform CI33936759594 and all status checks passed; UI exact-head CI33937014820 remains in progress at this checkpoint. No local suites were run. + +Implemented the explicit main-only authless compatibility contract: manual qualified catalog entry; capability-aware bounded owned usage read; exact credential/observation-generation binding; private nontransferable proof; passive revocation-only reads; independent Reserve cooldown; main admission retained; final HTTP/WS dispatch guard after pacing and through retries; unsupported native helper use refused without inference. Public English/Korean guide describes activation and limits. + +Independent source reviews: Jason availability PASS; Dewey quota/passive-producer scope PASS; Herschel auth and actual-dispatch PASS; Copernicus helper closure PASS; Hilbert catalog finalization and historical-source ordering PASS. All reviewer blockers were resolved and re-reviewed. Detailed pre-publication security analysis remains in ignored scratch, not this public record. + +Static checks: root TypeScript; focused TypeScript over availability/auth/scope/passive/helper/dispatch/WS/catalog/lifecycle tests; privacy scan; diff check. All completed checks passed; changes after their check require proportionate refresh. The tests are authored and typechecked, not executed locally. Public docs build passed425pages before the helper-limit copy amendment; rebuild remains required. + +Final pre-publication refresh: root and all nine focused test-file TypeScript checks passed; final catalog ordering follow-up root/lifecycle typecheck passed; privacy/diff checks passed. Public docs rebuilt successfully425pages after helper-limit copy. UI080878d5d now has all exact-head status checks green, including Cross-platform CI33937014820. Reserve behavior still requires its own exact-head CI; no success is inferred from parent checks. + +Upstream root metadata compatibility is source-verified: reference protocol/src/openai_models.rs762 derives Deserialize for ModelsResponse without deny_unknown_fields; core/src/config/mod.rs2052 directly deserializes that type, requiring nonempty models. The root opencodex_reserve_source retains genuine metadata only, independent of picker emission; it is not an authorization or credential. + +No Reserve-active live account was used. Capability/grant/credential/dispatch scenarios and full catalog lifecycle are synthetic CI fixtures. Installed Desktop source establishes the authless picker gate and Reserve/Luna metadata adaptation, not live entitlement. Existing eight settings screenshots remain the UI evidence; this layer has no dashboard visual change. No installed app, live10100 service, account reset, release or deployment was changed. diff --git a/devlog/_plan/260905_main_quota_guard/041_stack_ci_repairs.md b/devlog/_plan/260905_main_quota_guard/041_stack_ci_repairs.md new file mode 100644 index 0000000000..2a8a6760b7 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/041_stack_ci_repairs.md @@ -0,0 +1,13 @@ +# Stack CI and parent review follow-up + +Runtime7043e2b42 addresses the maintainer's raw-range and monthly-provenance findings; independent review and static checks passed. UI cascaded to a7a0ab832; Reserve replayed cleanly to380966e5f. `git range-diff` proves all three Reserve commits unchanged by the cascade. Every resulting head needs fresh CI; earlier green runs are historical evidence only. + +Reserve run33938170402 at76affe17c failed test4/4 job101230129450 in five auth fixture cases. The fixture reused the same account/token between tests but reset only lifecycle tracking, leaving a valid process-local Reserve authorization. Consequently later fixtures used the legitimate cache instead of their new WHAM response; assertions saw zero reads or the previous grant. Add the existing clearMainAccountInfoCache invalidation in beforeEach/afterEach. This fixes fixture ownership without adding a test-only production reset or weakening assertions. The expected WHAM and refusal assertions remain exact. Other job results are still being collected; no failure is labeled a flake. + +Fresh C adversarial source audit by Nash found no cross-lane blocker on76affe17c. Cascade integration re-review is pending. No local suites, account changes or live-service mutations. + +Later checkpoints: CI12f2f1f1a test4/4 passed, confirming the deferred-observer repair removed the repeated timeout. Test3/4 then failed one existing source-string oracle in loopback-listener-admission.test.ts: its expected Claude handler call omitted the newly threaded admission. Update the exact expected call to include admission while retaining the listener-policy/CORS checks. No production behavior or assertion scope is relaxed. + +Runtime473934e9a validates persisted policy percentages; UIb3539dd9c and Reserve9966d25a9 cascade it cleanly. Range-diff reports all five Reserve commits identical across that cascade. Earlier CI results remain historical, not final-head approval. + +CI12f2f1f1a test2/4 job101233776066 failed all16 ingress cases during fixture setup, before any request: the fixture used internal __main__ as a public namespace target. The real config schema correctly requires @main and fell back to default config. Use MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET and assert the saved configuration loads with the intended hostname/selector before starting either listener. Preserve every runtime assertion and the actual config loader. diff --git a/devlog/_plan/260905_main_quota_guard/044_ingress_verification.md b/devlog/_plan/260905_main_quota_guard/044_ingress_verification.md new file mode 100644 index 0000000000..4255673cb0 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/044_ingress_verification.md @@ -0,0 +1,9 @@ +# Request-bound Reserve compatibility verification + +Runtime Reserve eligibility now consumes server-resolved receiving-listener admission, separate from catalog/injection target eligibility. Only trusted loopback source, opt-in and non-client role enables it. Missing/public admission remains legacy. The same source flows through core/compact resolution, replay and dispatch, sidecars, native helper planning, alpha/search and Claude Messages replay. No shared-config clone or mutation was added. Dispatch closures capture source by value but read the live flag/role, including off→on while pacing or WS open is pending. + +Herschel source/guard re-review PASS; Copernicus actual dual-listener fixture review PASS. Root TypeScript, affected test-file TypeScript, privacy/diff checks and public docs build425pages passed before the final test-observer ordering repair. No local test suite was run. Actual ingress tests are authored for CI: HTTP/compact/incoming WS/alpha search, public and sibling loopback listeners, credential/inference/WHAM counters, spoofed inputs, concurrent isolation and ordinary/keyed controls. Upstream inference is mocked. Local translated routes stop at the existing listener allowlist404, so those cases do not claim translated-handler execution coverage. + +CI78b56c5e3 confirmed all14 Reserve auth fixture cases pass after the prior isolation repair. The same job101231319246 later timed out in reserve-dispatch.test.ts, repeating in isolated processes. Five deferred HTTP/WS tests invoked void-returning Bun matchers before their manual trigger. They now attach native promise settlement observers, trigger, await settlement and assert the same rejection class/object. No production behavior or counter assertion is weakened. This is a source-based ordering repair; a new exact-head CI run must confirm that the timeout is gone. + +Herschel also re-reviewed all five final observer replacements: PASS, blocking_issues0, with explicit fulfillment failures and unchanged error/counter/cleanup assertions. The last repair was source-reviewed only; test execution remains CI-only as the owner reiterated. diff --git a/devlog/_plan/260905_main_quota_guard/047_claude_policy_verification.md b/devlog/_plan/260905_main_quota_guard/047_claude_policy_verification.md new file mode 100644 index 0000000000..9970abbf80 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/047_claude_policy_verification.md @@ -0,0 +1,7 @@ +# Claude replay policy verification + +Claude-specific routing/sidecar replay snapshots are preserved, while the original policy owner is passed separately as a read-only reference. Auth policy checks, materializers, retries/combo recursion, final guards and native helper eligibility consume that reference. No Proxy, prototype, whole-config replacement or generic transport change was introduced. Compact already retains the original config. + +The new217line server regression uses the actual primary loopback Messages endpoint, pauses after replay creation, changes the original opt-in flag, then requires a429 without inference or dispatch-time permission renewal. The still-off control requires successful inference. Reference identity and Claude-specific sidecar overrides are asserted; secondary-listener404 is not used as proof. + +Nash source/test re-review PASS, blocking_issues0. Root TypeScript and diff check passed. No local suites or test execution; the final commit must pass exact-head CI. Separate CI fixture corrections for the public @main sentinel and updated admission call expectation are in1e28a3a20. diff --git a/devlog/_plan/260905_main_quota_guard/049_delivery_dispatch.md b/devlog/_plan/260905_main_quota_guard/049_delivery_dispatch.md new file mode 100644 index 0000000000..d5efd3f865 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/049_delivery_dispatch.md @@ -0,0 +1,29 @@ +# Final delivery dispatch contract + +This is wp3, following the completed feature cycles. Bound checkout8841 only. Original authorization includes no-verify pushes and admin merge after green CI/no unresolved defects; it does not include deployment, reset credits, installed-app changes or other checkout cleanup. + +## Locked stack inventory + +- PR3552, codex/main-account-99-hard-lock → dev, head473934e9a691cd9c987f75a8527fbb788dfe8f8c; CI33939734355. +- PR3560, codex/main-account-99-settings → runtime branch, headb3539dd9c346c0a44b21fc4970228a65aee82555; CI33939735142. +- PR3578, codex/luna-reserve-compatibility → settings branch, head460991765a4bef2f8f3bd98d46135e5955c765e1; CI33940230688. + +These are P-time observations, not immutable future merge authority. Refresh head/base/state/labels, all status checks, reviews and review threads immediately before each external action. A changed head invalidates prior green evidence. No empty required-check list counts as success; require the actual aggregate ci and all selected platform jobs. Source review findings must be fixed/rebutted with evidence, never dismissed for convenience. Stale governance review requirements may only be bypassed under the owner's explicit admin authorization after every technical condition is satisfied; record that authorization, do not rewrite another review. + +## Bottom-up operations + +Use admin squash as040 planned. Before3552 merge, require its complete current-head CI (including all remaining macOS jobs) and no unresolved current technical finding. Merge with --match-head-commit. Fetch origin/dev and prove returned merge SHA is an ancestor of origin/dev. + +After squash, retarget3560 to dev and rebase only its UI layer from the freshly recorded old runtime/head range onto the fetched dev tip. The observed d48b32203..2ebe76de7 range contains four UI commits; preserve all of them. Then rebase Reserve's own commits from the recorded old UI head onto the new UI head. Preserve any newer collaborator commit; explicit force-with-lease must name the immediately observed remote tip. Inspect range-diff, ancestry and PR bases before pushing. This plan document can ride the next unavoidable Reserve restack; it must not be presented as part of the earlier remote head before publication. + +Require fresh full current-head CI and review re-verification after every cascade. Merge3560 only then, with --match-head-commit naming its rebased head; fetch/prove ancestry again. Retarget3578 to dev and rebase its own layer from the exact rebased UI head that was just merged (not the original P-time inventory head) onto fetched dev. Repeat range-diff/lease/base verification and full exact-head CI. Only then admin squash3578 with --match-head-commit and prove fetched dev ancestry. Never force integration branches or delete/move the managed checkout. + +No code repair is presumed. If CI or review exposes a concrete new defect, capture the failing head/log, amend the narrow repair contract, obtain source review, fix only that cause and reverify the affected stack. Do not run local suites, including focused tests; CI is the test authority. Static source checks are distinct from test execution. + +## Closeout and evidence + +Record source heads, full checks, reviewer closure and merge SHAs. Once all intended implementation is public, write the terminal evidence and move this unit to devlog/_fin. If that requires a separate docs-only closeout PR after code lands, use the same template/CI/admin-merge gates and state its documentation-only scope. Do not falsify evidence or amend a merged layer. Closeout may not trigger a release, service restart or deployment. + +Final C receipt must bind a remote verification command to the current source tree; it must not execute a local test suite. Confirm every merged SHA against freshly fetched dev, clean bound worktree and no remaining required work. Then complete delivery tasks/criteria with evidence, obtain the current-tree C receipt, close D to IDLE (which marks wp3 done), validate goalplan E8, and only then complete the host goal. Never hand-mark the unfinished phase done merely to satisfy E8. The final response must disclose that live Reserve-active inference was not exercised and that no local suite/deployment occurred, and include the already captured settings screenshot. Any unresolved prerequisite is reported without claiming completion. + +The user subsequently added mixed Team/Plus/Pro pool rotation to the same request. This extends the chain, not the current build slice: after wp3 genuinely closes, enter P and append/audit that unit before implementation. Completion of this original stack must not be reported as completion of the expanded request, and the host goal must not be marked complete while the pool follow-up remains owed. diff --git a/devlog/_plan/260905_main_quota_guard/052_loopback_target_parity.md b/devlog/_plan/260905_main_quota_guard/052_loopback_target_parity.md new file mode 100644 index 0000000000..113b6d0515 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/052_loopback_target_parity.md @@ -0,0 +1,7 @@ +# Reserve loopback-target parity repair + +The maintainer's PR3578 review identified a functional mismatch: the server accepts the single DNS root dot in `localhost.`, but the catalog/injection helper rejected it. The repair plan was independently source-reviewed before implementation. + +The target helper now performs the same single trailing-dot normalization as the server. Existing positive/negative tables assert both predicates against explicit expected results, including case/whitespace, `localhost.`, and the still-invalid `localhost..`. The server import is test-only; receiving-listener authority and Reserve entitlement checks are unchanged. + +The runtime/UI layers were cascaded onto the latest reviewed parent repair. No local test suite was executed. Fresh exact-head CI and review remain required before bottom-up admin landing. diff --git a/devlog/_plan/260905_main_quota_guard/059_runtime_landing.md b/devlog/_plan/260905_main_quota_guard/059_runtime_landing.md new file mode 100644 index 0000000000..18a6f747e3 --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/059_runtime_landing.md @@ -0,0 +1,9 @@ +# Runtime landing and first post-squash cascade + +PR3552 was admin-squashed into dev at `9fe986d84a598aa08eeef7731b9a50fa0ff6ab07` on 2026-09-05T05:24:21Z. Its final source head was `d48b32203c1170958037cf09c4b73dcda74d96be`. A fresh fetch followed by `git merge-base --is-ancestor` proved integration ancestry. + +Cross-platform CI33945054125 attempt2 succeeded before merge: four Linux shards, both macOS shards, gates, API/storage, all selected keyring/package jobs, and aggregate ci. Windows six-shard suites and macOS control were intentionally unselected by this workflow event, not executed successes. The owner-approved failed-job rerun retained already passing results after an earlier cancellation. All applicable PR checks were green, all five inline threads resolved, and the reviewed integration delta had zero source-review blockers. The owner's explicit admin authorization was used; no review was dismissed. + +PR3560 was retargeted to dev and all four UI commits rebased onto the runtime squash. Its new head is `fe9ed3b1b5c122cc0258fa85b077d6776aea0ab2`. All four range-diff entries are unchanged. The nine Reserve commits were then rebased onto that UI head, producing `7ff3a1976a569b48ad00dbda7be51eb6e83db08b` before this documentation commit; all nine range-diff entries are unchanged. Explicit leases and `--no-verify` protect each rewritten remote head. Fresh exact-head CI is required for both upper layers; earlier green runs do not certify the rewritten heads. + +No local test suites, deployment, live account modification, installed-app patch, or reset-credit action occurred. The original stack and its documentation closeout remain wp3 work; the additionally requested mixed-plan pool rotation starts at the following P, not inside this delivery build. diff --git a/devlog/_plan/260905_main_quota_guard/063_ui_landing.md b/devlog/_plan/260905_main_quota_guard/063_ui_landing.md new file mode 100644 index 0000000000..f9452dc82f --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/063_ui_landing.md @@ -0,0 +1,9 @@ +# UI landing and final Reserve base + +PR3560 was admin-squashed into dev at `a53775103e764e6644d41ec47d2e3e753e9f4613` on 2026-09-05T06:04:13Z. Its final source head was `fe9ed3b1b5c122cc0258fa85b077d6776aea0ab2`; fresh fetch plus `git merge-base --is-ancestor` verified integration ancestry. + +Exact-head Cross-platform CI33947155910 attempt1 passed before merge, including four Linux shards, both macOS shards, all selected package/keyring jobs and aggregate ci. The only suite/control skips were event-unselected Windows shards and macOS control. Every governing PR check passed, including target run33947154134. Duplicate target job101256171050 was cancelled by the documented PR-comment concurrency rule for a higher-priority waiting request; its cancelled result was not counted as success. The resolved focus finding and prior reviewed UI behavior remain unchanged. Parent repair/landing, dev retarget and fresh-CI conditions from the maintainer review were satisfied; the owner-authorized admin path was used without dismissing reviews. + +Reserve PR3578 now targets dev. Its ten own commits were rebased from the exact merged UI head onto `a53775103`, producing `c09e73760b7bf80fcb3d78ff46e3e5bf5e273505` before this documentation commit. All ten range-diff entries are unchanged. The forthcoming published head requires fresh exact-head CI and review closure before landing. + +Eleven synthetic UI screenshots remain in022_ui_evidence. No local suites, deployment or live account modifications occurred. The mixed-plan pool request remains the next audited work unit after the original delivery closes, not part of this Reserve rebase. diff --git a/devlog/_plan/260905_main_quota_guard/064_final_rebase.md b/devlog/_plan/260905_main_quota_guard/064_final_rebase.md new file mode 100644 index 0000000000..408d8767ea --- /dev/null +++ b/devlog/_plan/260905_main_quota_guard/064_final_rebase.md @@ -0,0 +1,25 @@ +# Final Reserve rebase onto published dev + +The owner requested rebase and admin landing of PR3578, then verification on dev, without +local suites. Original head ae3e1aea8 and its12 commits remain preserved in the original +managed worktree. A separate delivery branch rebases them onto ba9a45570 in the bound +delivery checkout; no installed application, service, account or credential is modified. + +The three conflict owners are responses/core.ts, fetch-helpers.ts and ws-upstream.ts. +Every send keeps the selected-account WS quota observer AND Reserve's beforeDispatch guard. +The existing public observer stays the fifth optional WS argument; admission is sixth. +Local refusal occurs before dialing and again before send, cleans up metadata/listeners, +and cannot enter HTTP fallback. Current metadata prelude and no-post-send-resend behavior +remain intact. No connection pooling or new Reserve grant semantics are introduced. + +The existing Reserve WS fixture now emits response.created for successful canonical WS +requests, matching the already-landed metadata prelude contract. Its call sites use the +sixth guard argument; positive coverage verifies both handshake checks and separate quota +observations before/after Response commit. Original refusal, zero-send, no-fallback and +listener-detachment assertions remain. These changes are authored for CI, not run locally. + +The two outstanding public Reserve findings are checked against latest source: canonical +provider/adapter references already document the account-only contract; ae3's terminal +vision helper fence remains present at Chat ingress and final dispatch. Final independent +review must verify these dispositions and the rebase delta before SHA-pinned admin merge. +Actual upstream Reserve availability was not manufactured or tested with a live account. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 0b874bde3c..6a37cf8a70 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -24,6 +24,12 @@ Auth page can restore it: absent rows are created from the canonical preset, dis rows are re-enabled without replacing saved mode or model settings, and noncanonical `openai` rows are not offered that recovery path. +Luna Reserve compatibility is a ChatGPT account capability on the canonical OpenAI forward path, +not an OpenAI API-key entitlement. Its manual stored-main selector requires effective local authless +Desktop mode and current credential-bound upstream permission; a catalog entry alone does not +authorize a request. See [Luna Reserve alongside routed models](/reference/cli/providers-accounts/#luna-reserve-alongside-routed-models) +for setup, restart order, authorization requirements, and unsupported helpers. + ### Providers overview pool capacity For Codex login in Pool mode, the Providers overview shows a configured-weight estimate of the diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 35224f82be..31757f38d7 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -95,6 +95,42 @@ Reserve가 활성화되지 않을 수 있습니다. 스위치를 끄면 원래 허용하는 사용량이 늘어나지는 않습니다. 계정의 사용량 새로고침으로 최신 수치를 확인할 수 있으며, 리셋 크레딧을 자동으로 소비하지는 않습니다. +### Luna Reserve와 다른 공급자 모델 함께 쓰기 + +선택 기능인 [Desktop 로그인 생략 모드](/guides/codex-integration/#authless-codex-desktop-opt-in)를 +쓰면 Desktop의 Reserve 전용 모델 선택 제한이 작동하지 않습니다. 대신 Desktop의 자동 Reserve +전환도 꺼지므로, Reserve는 직접 선택해야 합니다. + +기본 OpenAI 공급자를 ChatGPT 전달 모드로 켜 두고, 계정별 모델 선택기를 켠 뒤 저장된 메인 +계정의 공개 선택자 이름을 지정합니다. 로컬 루프백 로그인 생략 모드가 실제로 적용된 상태에서 +`ocx sync`를 실행하면 `<메인-선택자>/gpt-reserve`가 다른 공급자 모델과 함께 추가됩니다. +접두사 없는 `gpt-reserve`, 추가 계정 선택자, API 키용 모델 목록에는 추가하지 않습니다. +원격 클라이언트나 별도 접근 헤더가 필요한 리스너에서는 이 모드를 적용하지 않습니다. +공개 리스너와 로컬 리스너를 함께 켜도 Reserve 호환 모드는 로컬 리스너로 받은 요청에만 +적용됩니다. 같은 컴퓨터에서 보냈더라도 공개 리스너로 인증한 요청은 원래 경로를 유지하며, +요청 헤더로 로컬 정책을 고를 수는 없습니다. + +`ocx system settings --desktop-authless on`으로 Desktop 로그인 생략 모드를 켜고, +`ocx sync`를 실행한 다음 Codex Desktop을 완전히 종료했다가 다시 여세요. +다시 쓴 설정과 모델 목록을 읽으려면 이 순서가 필요합니다. 자세한 절차는 +[Desktop 로그인 생략 모드 가이드](/guides/codex-integration/#authless-codex-desktop-opt-in)를 따르세요. + +각 요청은 해당 자격 증명에 묶인 서버 허용 결과를 확인하며, 캐시는 최대 60초만 유지합니다. +메인 계정 사용량을 조회할 때 Reserve 기능 헤더를 보내고, 일반 사용량 불허·Luna Reserve 안내· +허용된 Reserve 항목 하나가 모두 있는지 확인합니다. 근거가 없거나 오래됐거나 계정이 맞지 않으면 +요청을 거절합니다. 다른 계정이나 일반 Luna로 몰래 바꾸지 않습니다. 일반 사용량 조회는 기존 +허용을 취소할 수 있지만 새로 허용하지는 않습니다. + +전체 쿨다운, 일시정지, 재인증, 99% 하드락은 여전히 적용됩니다. 소진된 메인 계정에서 Reserve를 +쓰려면 하드락을 꺼야 하지만, 껐다고 서버의 사용 권한이 생기지는 않습니다. +이 호환 경로는 대화와 대화 압축용입니다. 이미지 설명·웹 검색 보조 모델이나 독립 검색 릴레이에 +Reserve를 지정하는 용도는 지원하지 않으므로, 그 기능에는 다른 모델을 선택하세요. + +모델 정보는 실제 Reserve 관측값을 우선합니다. 없으면 Desktop의 Reserve/Luna 매핑을 참고한 +Luna 메타데이터임을 표시해 사용합니다. 목록에 보인다는 사실만으로 사용 가능하다고 보장하지 +않습니다. Desktop 소스와 테스트용 응답 경로를 확인했으며, 실제 Reserve 활성 계정으로는 이 +호환 경로를 검증하지 않았습니다. + ### `ocx account ` 실행 중인 프록시를 통해 제공자 계정과 API 키 풀을 나열하고 전환합니다. 제공되는 도움말 표면은 다음과 같습니다: diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 4e548593a6..1db98357d3 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -112,6 +112,14 @@ recursively within bounded traversal limits. When `store: false`, `item_referenc omitted because the destination cannot resolve an item it did not persist. Function/tool `call_id` pairs and `reasoning.effort` are preserved. +[Luna Reserve compatibility](/reference/cli/providers-accounts/#luna-reserve-alongside-routed-models) +uses this canonical ChatGPT-forward path, not key-auth or arbitrary Responses gateways. It retains +the safe caller-header allowlist and destination-scoped request normalization described here. +OpenCodex sends its Reserve capability header on the owned main-account usage lookup; that header +is not itself permission. Eligible compatibility requests recheck credential-bound authorization +at dispatch. Conversation and compaction are supported; vision helpers, web-search helpers, and +standalone search relay are not. + For `key` auth, [`retryOn429`](/reference/configuration/) applies here too: a pre-stream 429 waits and replays the identical request on the same key before any other handling, exactly like the translated `openai-chat` / Anthropic request path. Custom `runTurn` transports are not part diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 89a679a77e..3bcb6092bc 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -118,6 +118,40 @@ quota exhaustion may prevent Reserve activation. Disabling the switch restores n handling, not additional upstream entitlement. Use the account quota refresh action to obtain a fresh observation; no reset credit is consumed automatically. +### Luna Reserve alongside routed models + +The optional [authless Desktop mode](/guides/codex-integration/#authless-codex-desktop-opt-in) +keeps Desktop's native Reserve-only picker gate inactive. It also disables Desktop's automatic +Reserve handling: Reserve is an explicit model choice, not an automatic fallback. + +Keep the built-in OpenAI provider enabled in ChatGPT-forward mode, enable the account model picker, +and configure a public selector for the stored main account. With effective loopback authless mode +enabled, `ocx sync` includes `/gpt-reserve` alongside routed provider models. A bare +`gpt-reserve`, an added-account selector, and API-key model discovery are not added to the catalog. +The authless setting is ignored for remote-client routing or a listener that needs an admission header. +When public and local listeners run together, Reserve compatibility applies only to requests admitted +by the local listener. An authenticated public request stays on the normal path even if it originates +from the same machine; request headers cannot select the local policy. + +Enable authless Desktop mode with `ocx system settings --desktop-authless on`, run `ocx sync`, +then fully quit and reopen Codex Desktop so it reloads the rewritten configuration and catalog. +Follow the [canonical authless Desktop workflow](/guides/codex-integration/#authless-codex-desktop-opt-in). + +Each compatibility request checks a credential-bound server authorization, cached for at most +60 seconds. OpenCodex sends the Reserve capability header on an owned main-account usage read and +requires ordinary usage to be disallowed, the Luna Reserve banner, and exactly one allowed Reserve +bucket. Missing, denied, stale or mismatched evidence refuses the request; it does not switch accounts +or silently use ordinary Luna. Passive usage can revoke authorization but cannot create it. +Global cooldown, pause, reauthentication and the 99% hard lock still apply. Disable the hard lock if +you want to use Reserve on an exhausted main account; doing so does not grant server entitlement. +This compatibility path supports conversation requests and compaction, not Reserve as a vision or +web-search helper or a standalone search-relay model. Choose another model for those helpers. + +The picker prefers actual Reserve metadata. When none has been observed, it uses an explicitly marked +Luna metadata adaptation, following Desktop's Reserve-or-Luna preset mapping. A visible entry is not +proof of availability. Desktop source and fixture-backed paths were checked; a live Reserve-active +account was not used to validate this compatibility path. + ### `ocx account ` List and switch provider accounts and API-key pools through the running proxy. The shipped help diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9bea518578..3bf9cf6fb0 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -965,6 +965,17 @@ "quota-scoring.test.ts": "usage", "qwen-cloud-endpoints.test.ts": "gui", "qwen38-preserve-reasoning.test.ts": "providers", + "reserve-availability.test.ts": "codex-integration", + "reserve-auth-context.test.ts": "codex-integration", + "reserve-catalog.test.ts": "codex-integration", + "reserve-catalog-lifecycle.test.ts": "codex-integration", + "reserve-claude-policy.test.ts": "server", + "reserve-dispatch.test.ts": "codex-integration", + "reserve-dispatch-ws.test.ts": "responses", + "reserve-helper-boundary.test.ts": "codex-integration", + "reserve-ingress.test.ts": "server", + "reserve-passive-revocation.test.ts": "codex-integration", + "reserve-quota-scope.test.ts": "codex-integration", "rate-limit-reset-credits.test.ts": "gui", "rate-limit-retry.test.ts": "providers", "reasoning-effort.test.ts": "codex-integration", diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 7afab2fc6a..865711ba86 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -96,7 +96,9 @@ import { clearMainAccountInfoCache, getMainAccountCredentialPresence, getMainAccountInfoCache, + getMainQuotaCredentialGeneration, isMainAccountIdentityGenerationLive, + matchesMainQuotaCredential, observeMainQuotaCredential, setMainAccountCredentialPresence, setMainAccountInfoCache, @@ -104,6 +106,7 @@ import { } from "./main-account-cache"; export { clearMainAccountInfoCache } from "./main-account-cache"; import { getMainAccountHardLockStatus, type MainAccountHardLockStatus } from "./main-account-hard-lock"; +import { observeMainReserveRevocation } from "./reserve-availability"; import { maskEmail } from "../lib/privacy"; import { codexWarmupFailureReason, warmCodexAccount } from "./warmup"; export { maskEmail } from "../lib/privacy"; @@ -896,6 +899,7 @@ async function fetchMainAccountInfoWhileOwned( const mainQuotaWriter = requestAccountId === tokens.account_id ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) : undefined; + const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration(); try { const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id }, @@ -914,6 +918,12 @@ async function fetchMainAccountInfoWhileOwned( const data = (await resp.json()) as WhamUsageResponse; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); if (retried) return retried; + // A delayed response from a replaced bearer cannot revoke a newer Reserve grant, + // even in the same workspace or after an A→B→A credential transition. + if (mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() + && matchesMainQuotaCredential(tokens.access_token, tokens.account_id)) { + observeMainReserveRevocation(data, mainQuotaWriter); + } const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan()); const usage = { ...data, ...(plan ? { plan_type: plan } : {}) }; const quota = parseUsageQuota(usage); @@ -1440,10 +1450,9 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da } try { const result = await fetchPoolAccountQuota(claim.accountId, true, account.plan); - // Defence in depth: `spark` is already excluded at the claim site, since generic WHAM - // cannot prove a spark recovery. Keep the settle-side guard so a future claim change - // cannot silently start clearing spark on generic evidence. - const recovered = claim.scope !== "spark" + // Defence in depth: independent scopes are already excluded at the claim site. + // Generic WHAM must never clear Spark or Reserve even if claim selection changes. + const recovered = (claim.scope === undefined || claim.scope === "shared") && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan); settleCodexQuotaRecoveryProbe(claim, recovered, { credentialGeneration: result.freshCredentialGeneration, diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 5260169324..2f319b3144 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -8,7 +8,6 @@ import { isCodexAccountGenerationLive, } from "./account-store"; import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; -import { isCodexAccountPaused } from "./account-pause"; import { ConfigMutationLockError } from "../config"; import { NativeProfileError } from "./native-profile-types"; import { isCodexAccountUsable } from "./account-usability"; @@ -41,11 +40,11 @@ import { isDirectCallerEntitledToCodexModel, resolveCodexModelEntitlements, } from "./model-entitlements"; -import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./catalog/native-models"; import type { CodexCooldownSource, CodexQuotaScope } from "./routing"; import { maskAccountId } from "../lib/privacy"; import { formatErrorResponse } from "../bridge"; -import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; +import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, parseUsageQuota, parseMainPolicyUsageQuota, setAccountQuotaFromParsed } from "./quota"; import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { captureConfigGeneration } from "../lib/state-store-sweeper"; @@ -54,12 +53,17 @@ import { extractAccountId } from "../oauth/chatgpt"; import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock"; import { captureMainAccountIdentityGeneration, + captureMainQuotaWriter, getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential, type MainQuotaWriter, } from "./main-account-cache"; +import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported, isCodexReserveRequestEligible } from "./loopback-target"; +import type { DataPlaneAdmission } from "../server/auth-cors"; +import { getMainReserveAuthorization, isMainReserveAuthorizationLive, type MainReserveAuthorization } from "./reserve-availability"; +import { UpstreamRetryEvidenceError } from "../lib/upstream-retry"; const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512; const CODEX_APP_AFFINITY_KEY = randomBytes(32); @@ -108,7 +112,7 @@ export function codexPoolAffinityKey(headers: Headers): string | undefined { } export type CodexAuthContext = - | { kind: "main"; accountId: null } + | { kind: "main"; accountId: null; reserveAuthorization?: MainReserveAuthorization } | { kind: "pool"; accountId: string; @@ -139,6 +143,7 @@ export type CodexAuthContext = writerGeneration: number; /** Captured before async credential work; never reconstructed after the upstream response. */ mainQuotaWriter?: MainQuotaWriter; + reserveAuthorization?: MainReserveAuthorization; accessToken: string; chatgptAccountId: string; /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */ @@ -306,6 +311,135 @@ export class CodexMainAccountHardLockError extends CodexAccountCooldownError { } } +export class CodexReserveUnavailableError extends CodexAccountCooldownError { + constructor() { + super(MAIN_CODEX_ACCOUNT_ID, 0); + this.name = "CodexReserveUnavailableError"; + this.message = "Codex Reserve is unavailable for this main credential." + + " Use the stored main login or its matching caller credential, and retry when OpenAI grants Reserve access." + + " Reserve compatibility requires the effective local Desktop authless opt-in; it cannot switch accounts automatically."; + } +} + +/** A local unsupported-helper refusal; retain Reserve policy error mapping on delayed sends. */ +export class CodexReserveHelperUnsupportedError extends CodexReserveUnavailableError { + constructor() { + super(); + this.name = "CodexReserveHelperUnsupportedError"; + this.message = CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE; + } +} + +export type CodexAuthPolicyConfig = Readonly>; + +interface CodexAuthMaterializationOptions { + substituteMainCredential?: boolean; + config?: CodexAuthPolicyConfig; + modelId?: string; + /** Trusted receiving-listener admission; never inferred from request headers or config. */ + admission?: Pick; + signal?: AbortSignal; + nativeMainRefreshDependencies?: NativeMainRefreshDependencies; + beginCodexAccountSelection?: () => CodexAccountSelectionAdmission | undefined; +} + +function requiresReserveAuthorization( + config: CodexAuthPolicyConfig | undefined, + modelId: string | undefined, + admission: Pick | undefined, +): boolean { + return modelId === NATIVE_RESERVE_MODEL && !!config && isCodexReserveRequestEligible(config, admission); +} + +function assertReserveAdmission(config: CodexAuthPolicyConfig): void { + if (config.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID) || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) { + throw new CodexReserveUnavailableError(); + } + assertMainAccountPolicy(config); + const cooldown = getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, "reserve"); + if (cooldown?.cooldownUntil) { + throw new CodexAccountCooldownError(MAIN_CODEX_ACCOUNT_ID, cooldown.cooldownUntil, cooldown.cooldownSource, cooldown.quotaScope); + } +} + +async function authorizeReserveCredential( + token: { accessToken: string; chatgptAccountId: string }, + writer: MainQuotaWriter | undefined, + config: CodexAuthPolicyConfig, + signal?: AbortSignal, + existing?: MainReserveAuthorization, + writerGeneration = captureConfigGeneration(), +): Promise { + assertReserveAdmission(config); + if (!writer || !isMainQuotaWriterLive(writer) + || !matchesMainQuotaCredential(token.accessToken, token.chatgptAccountId)) { + throw new CodexReserveUnavailableError(); + } + const authorization = isMainReserveAuthorizationLive(existing, token) ? existing + : await getMainReserveAuthorization({ + token, writer, signal, + observeOrdinaryQuota(data, capturedWriter) { + setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, parseUsageQuota(data), writerGeneration, + capturedWriter, parseMainPolicyUsageQuota(data)); + }, + }); + // The capability read also publishes ordinary quota. A new 99% reading or cooldown wins. + assertReserveAdmission(config); + if (signal?.aborted) throw signal.reason; + if (!authorization || !isMainReserveAuthorizationLive(authorization, token)) throw new CodexReserveUnavailableError(); + return authorization; +} + +function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAccountId: string } { + return { + accessToken: headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? "", + chatgptAccountId: headers.get("chatgpt-account-id") ?? "", + }; +} + +function assertMaterializedReserve(headers: Headers, ctx: CodexAuthContext, options: CodexAuthMaterializationOptions): void { + if (!requiresReserveAuthorization(options.config, options.modelId, options.admission)) return; + assertReserveAdmission(options.config!); + if (ctx.kind === "pool" || !isMainReserveAuthorizationLive(ctx.reserveAuthorization, selectedCodexToken(headers))) { + throw new CodexReserveUnavailableError(); + } +} + +/** A dispatch never renews permission: the next request may obtain a fresh bounded proof. */ +export function createCodexReserveDispatchGuard( + ctx: CodexAuthContext, + config: CodexAuthPolicyConfig, + modelId: string, + admission?: Pick, + terminalHelper = false, +): ((headers: Headers) => void) | undefined { + // Snapshot the resolved source value, not the caller's mutable admission object. Config stays + // live so policy changes remain visible after pacing and retry backoff. + const source = admission?.source; + if (modelId !== NATIVE_RESERVE_MODEL || source !== "loopback") return undefined; + // Only immutable request facts decide whether to install the callback. Flag/role eligibility + // is checked inside it, including an opt-in enabled while a send waits for pacing or WS open. + const ingress = Object.freeze({ source }); + return headers => { + if (isCodexReserveHelperUnsupported(config, modelId, ingress, terminalHelper)) { + throw new CodexReserveHelperUnsupportedError(); + } + assertMaterializedReserve(headers, ctx, { config, modelId, admission: ingress }); + }; +} + +/** Retry history must not turn a later local admission refusal into a network failure. */ +export function unwrapUpstreamRetryEvidenceError(error: unknown): unknown { + const seen = new Set(); + while (error instanceof UpstreamRetryEvidenceError && !seen.has(error)) { + seen.add(error); + error = error.cause; + } + return error; +} + function assertMainAccountPolicy(config: Pick | undefined): void { if (!config) return; const status = getMainAccountHardLockStatus(config); @@ -358,13 +492,12 @@ 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; + if (err instanceof CodexMainAccountHardLockError || err instanceof CodexReserveUnavailableError) return err.message; const until = new Date(err.cooldownUntil).toISOString(); - const scope = err.quotaScope === "spark" - ? "Spark quota" - : err.quotaScope === "shared" - ? "shared native quota" - : null; + const scopeLabels: Record = { + spark: "Spark quota", shared: "shared native quota", reserve: "Reserve quota", + }; + const scope = err.quotaScope ? scopeLabels[err.quotaScope] : null; const selected = accountSelector ? `Selected Codex account selector (${accountSelector})` : `Selected Codex account (${cooldownAccountLabel(err.accountId)})`; @@ -384,7 +517,8 @@ export function cooldownErrorResponse( ): Response { const res = formatErrorResponse(429, "rate_limit_error", cooldownErrorMessage(err, accountSelector)); const headers = new Headers(res.headers); - if (!(err instanceof CodexMainAccountHardLockError) || err.resetAt !== undefined) { + if (!(err instanceof CodexReserveUnavailableError) + && (!(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 }); @@ -402,6 +536,7 @@ export class CodexThreadAffinityExpiredError extends Error { export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean { return !(cause instanceof CodexMainAccountHardLockError) + && !(cause instanceof CodexReserveUnavailableError) && !(cause instanceof CodexCredentialGenerationConflictError) && !(cause instanceof CodexCredentialRefreshLockTimeoutError) && !(cause instanceof CodexCredentialRefreshBusyError) @@ -414,6 +549,9 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): } export interface ResolveCodexAuthContextOptions { + admission?: Pick; + /** Live policy owner when the routing config is a caller-specific replay snapshot. */ + codexAuthPolicy?: CodexAuthPolicyConfig; excludeAccountId?: string; /** Resolve exactly this account without consulting or mutating Pool selection. */ accountId?: string; @@ -451,15 +589,21 @@ export async function resolveCodexAuthContext( options: ResolveCodexAuthContextOptions = {}, ): Promise { const writerGeneration = captureConfigGeneration(); + const policy = options.codexAuthPolicy ?? config; const requestScopedMainCredential = options.requestScopedMainCredential === true && hasCallerCodexBearer(headers); - const fixedAccountId = options.accountId; + const reserve = requiresReserveAuthorization(policy, options.modelId, options.admission); + if (reserve && (options.excludeAccountId !== undefined + || (options.accountId !== undefined && options.accountId !== MAIN_CODEX_ACCOUNT_ID))) { + throw new CodexReserveUnavailableError(); + } + const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId; const preserveRequestOwnedMainPin = requestScopedMainCredential && fixedAccountId === undefined && config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID && isEffectiveCodexAccountPinned(config) - && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) - && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(config)) + && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID) + && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy)) && requestOwnedMainPinHasQuotaHeadroom(config); if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) { throw new Error("Codex auth context cannot select and exclude an account simultaneously"); @@ -468,7 +612,14 @@ export async function resolveCodexAuthContext( if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); const substituteStoredMain = options.substituteMainCredentialForDirect === true; if (!substituteStoredMain) { - if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(config); + if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy); + if (reserve) { + const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy }); + const token = selectedCodexToken(selected); + const reserveAuthorization = await authorizeReserveCredential(token, captureMainQuotaWriter(token.chatgptAccountId), + policy, options.signal, undefined, writerGeneration); + return { kind: "main", accountId: null, reserveAuthorization }; + } if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) { const entitled = await ( options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel @@ -477,7 +628,7 @@ export async function resolveCodexAuthContext( throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model"); } } - if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(config); + if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy); return { kind: "main", accountId: null }; } @@ -497,8 +648,8 @@ export async function resolveCodexAuthContext( ) { throw new CodexMainProfileDrainingError(); } - if (config.codexMainAccountHardLock === true) reconcileMainCodexAccountRuntimeState(); - assertMainAccountPolicy(config); + if (policy.codexMainAccountHardLock === true) reconcileMainCodexAccountRuntimeState(); + assertMainAccountPolicy(policy); if (options.modelId && ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(options.modelId)) { const entitled = entitledCodexAccountIdsForModel( await (options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements)(config, { @@ -511,7 +662,7 @@ export async function resolveCodexAuthContext( throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model"); } } - assertMainAccountPolicy(config); + assertMainAccountPolicy(policy); return { kind: "main", accountId: null }; } finally { // The short selector reservation ends here. A successful claim remains owned by @@ -530,7 +681,7 @@ export async function resolveCodexAuthContext( || await ( options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel )(headers, options.modelId); - if (callerEntitled && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(config))) { + if (callerEntitled && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy))) { return { kind: "main", accountId: null }; } } @@ -538,7 +689,9 @@ export async function resolveCodexAuthContext( // selected stored credential even while the canonical OpenAI provider is globally Direct. // A request-owned bearer is deliberately not represented as `main-pool`: Pool account ids own // durable health, quota, and affinity state, while this credential exists for one request only. - if ((mode === "direct" && fixedAccountId === undefined) + if ((reserve && hasCallerCodexBearer(headers) && !options.substituteMainCredentialForDirect + && (requestScopedMainCredential || mode === "direct")) + || (mode === "direct" && fixedAccountId === undefined) || (requestScopedMainCredential && fixedAccountId === MAIN_CODEX_ACCOUNT_ID)) { return resolveCallerOwnedMainContext(); } @@ -593,6 +746,7 @@ export async function resolveCodexAuthContext( // A pre-drain selector reserves the native identity while reconciliation and // routing inspect it. Selectors arriving after the fence skip reconciliation // and may still route to non-main pool accounts without touching switch state. + if (reserve && !nativeMainReadsForbidden && !selectionAdmission) throw new CodexMainProfileDrainingError(); if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState(); const resolution = fixedAccountId !== undefined ? { status: "selected" as const, accountId: fixedAccountId } @@ -648,9 +802,9 @@ export async function resolveCodexAuthContext( throw new CodexMainProfileDrainingError(); } if (!nativeMainReadsForbidden && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID - && !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) + && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID) && (!modelEligibleAccountIds || modelEligibleAccountIds.has(MAIN_CODEX_ACCOUNT_ID))) { - assertMainAccountPolicy(config); + assertMainAccountPolicy(policy); } throw new CodexPoolAuthenticationError( modelEligibleAccountIds === undefined @@ -661,7 +815,7 @@ export async function resolveCodexAuthContext( ); } accountId = selected; - if (accountId === MAIN_CODEX_ACCOUNT_ID) assertMainAccountPolicy(config); + if (accountId === MAIN_CODEX_ACCOUNT_ID) assertMainAccountPolicy(policy); if (accountId === MAIN_CODEX_ACCOUNT_ID && nativeMainTrafficBlocked) { throw new CodexMainProfileDrainingError(); } @@ -684,7 +838,7 @@ export async function resolveCodexAuthContext( ); } if (fixedAccountId !== undefined) { - if (isCodexAccountPaused(config, accountId)) { + if (policy.pausedCodexAccountIds?.includes(accountId)) { throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); } if (isAccountNeedsReauth(accountId)) { @@ -745,7 +899,7 @@ export async function resolveCodexAuthContext( ...(options.nativeMainRefreshDependencies ?? {}), }); if (token) mainQuotaWriter = observeSelectedMainCredential(token, mainQuotaWriter); - assertMainAccountPolicy(config); + assertMainAccountPolicy(policy); } catch (cause) { if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId); else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId); @@ -763,11 +917,15 @@ export async function resolveCodexAuthContext( fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined, ); } + const reserveAuthorization = reserve + ? await authorizeReserveCredential(token, mainQuotaWriter, policy, options.signal, undefined, writerGeneration) + : undefined; return { kind: "main-pool", accountId, writerGeneration, mainQuotaWriter, + ...(reserveAuthorization ? { reserveAuthorization } : {}), accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}), @@ -853,7 +1011,7 @@ export class CodexMainSubstitutionUnavailableError extends Error { export function materializeCodexUpstreamAuth( headers: Headers, ctx: CodexAuthContext, - options: { substituteMainCredential?: boolean; config?: Pick } = {}, + options: CodexAuthMaterializationOptions = {}, ): Headers { const selected = new Headers(); for (const name of FORWARD_HEADERS) { @@ -867,6 +1025,7 @@ export function materializeCodexUpstreamAuth( ctx.mainQuotaWriter = observeSelectedMainCredential(ctx, ctx.mainQuotaWriter); assertMainAccountPolicy(options.config); } + assertMaterializedReserve(selected, ctx, options); return selected; } if (ctx.kind === "main" && options.substituteMainCredential !== true @@ -887,22 +1046,64 @@ export function materializeCodexUpstreamAuth( if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId); observeSelectedMainCredential(stored, writer); assertMainAccountPolicy(options.config); + assertMaterializedReserve(selected, ctx, options); return selected; } if (callerMatchesObservedMain(selected)) assertMainAccountPolicy(options.config); + assertMaterializedReserve(selected, ctx, options); return selected; } +/** The model producer, not an optional context marker, decides whether a grant is required. */ +async function materializeReserveUpstreamAuth( + headers: Headers, + ctx: CodexAuthContext, + options: CodexAuthMaterializationOptions, +): Promise { + if (ctx.kind === "pool") throw new CodexReserveUnavailableError(); + const config = options.config!; + assertReserveAdmission(config); + const writerGeneration = ctx.kind === "main-pool" ? ctx.writerGeneration : captureConfigGeneration(); + const storedMain = ctx.kind === "main" + && (options.substituteMainCredential === true || !hasCallerCodexBearer(headers)); + let admission: CodexAccountSelectionAdmission | undefined; + let writer = ctx.kind === "main-pool" ? ctx.mainQuotaWriter : undefined; + try { + if (storedMain) { + if (isNativeMainTrafficBlocked()) throw new CodexMainProfileDrainingError(); + admission = options.beginCodexAccountSelection?.(); + if (!admission || admission.mainProfileDraining || !admission.claimMainProfile() || isNativeMainTrafficBlocked()) { + throw new CodexMainProfileDrainingError(); + } + reconcileMainCodexAccountRuntimeState(); + writer = captureObservedMainWriter(); + assertReserveAdmission(config); + } + // Build the real credential first, without recursively requiring a not-yet-fetched proof. + // The ordinary hard lock remains enabled, including the post-refresh check. + const selected = await materializeCodexUpstreamAuthAsync(headers, ctx, { + ...options, modelId: undefined, substituteMainCredential: storedMain, + }); + const token = selectedCodexToken(selected); + if (ctx.kind === "main-pool") writer = ctx.mainQuotaWriter; + else if (!storedMain) writer = captureMainQuotaWriter(token.chatgptAccountId); + ctx.reserveAuthorization = await authorizeReserveCredential(token, writer, config, options.signal, + ctx.reserveAuthorization, writerGeneration); + assertMaterializedReserve(selected, ctx, options); + return selected; + } finally { + admission?.release(); + } +} + export async function materializeCodexUpstreamAuthAsync( headers: Headers, ctx: CodexAuthContext, - options: { - substituteMainCredential?: boolean; - config?: Pick; - signal?: AbortSignal; - nativeMainRefreshDependencies?: NativeMainRefreshDependencies; - } = {}, + options: CodexAuthMaterializationOptions = {}, ): Promise { + if (requiresReserveAuthorization(options.config, options.modelId, options.admission)) { + return materializeReserveUpstreamAuth(headers, ctx, options); + } if (ctx.kind !== "main" || options.substituteMainCredential !== true) { return materializeCodexUpstreamAuth(headers, ctx, options); } @@ -922,6 +1123,8 @@ export async function materializeCodexUpstreamAuthAsync( if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId); observeSelectedMainCredential(stored, writer); assertMainAccountPolicy(options.config); + // An opt-in enabled during token refresh must not turn a proof-less context into Reserve. + assertMaterializedReserve(selected, ctx, options); return selected; } @@ -929,9 +1132,11 @@ export async function materializeCodexUpstreamAuthAsync( export function headersForCodexAuthContext( headers: Headers, ctx: CodexAuthContext, - config?: Pick, + config?: CodexAuthPolicyConfig, + modelId?: string, + admission?: Pick, ): Headers { - return materializeCodexUpstreamAuth(headers, ctx, { config }); + return materializeCodexUpstreamAuth(headers, ctx, { config, modelId, admission }); } export function isCodexAuthContextUsable(ctx: CodexAuthContext, config: OcxConfig): boolean { diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 9915ee5621..491518cda6 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -35,7 +35,8 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import { generatedModelMetadata, readCatalog, readCodexCatalogPath } from "./parsing"; import type { CatalogModel, RawEntry } from "./parsing"; import { UPSTREAM_NATIVE_ENTRIES } from "./metadata"; -import { nativeOpenAiCapabilitySourceSlug, SELF_DESCRIBED_NATIVE_OPENAI_MODELS } from "./native-models"; +import { nativeOpenAiCapabilitySourceSlug, SELF_DESCRIBED_NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./native-models"; +import { isReserveCatalogProjection } from "./reserve"; import { loadBundledCodexCatalog } from "./bundled"; import type { BundledCatalogDeps, ReadonlyRawCatalog } from "./bundled"; import { deriveEntry } from "./sync"; @@ -340,6 +341,10 @@ export function clampedDefaultEffort(original: string, surviving: readonly strin return (atOrBelow.at(-1) ?? ranked[0]!).effort; } +function requiresExactReserveEfforts(entry: RawEntry): boolean { + return entry.slug === NATIVE_RESERVE_MODEL || isReserveCatalogProjection(entry); +} + export function clampEntryToCodexSupportedEfforts( entry: RawEntry, supported: ReadonlySet | null, @@ -350,6 +355,19 @@ export function clampEntryToCodexSupportedEfforts( : null; if (levels && levels.length > 0) { const kept = levels.filter(level => typeof level?.effort === "string" && supported.has(level.effort)); + if (requiresExactReserveEfforts(entry)) { + entry.supported_reasoning_levels = kept; + if (kept.length === 0) { + // The list-level clamp removes this incompatible row; never invent another ladder. + delete entry.default_reasoning_level; + } else if (!kept.some(level => level.effort === entry.default_reasoning_level)) { + entry.default_reasoning_level = clampedDefaultEffort( + typeof entry.default_reasoning_level === "string" ? entry.default_reasoning_level : "", + kept.map(level => level.effort!), + ); + } + return; + } entry.supported_reasoning_levels = kept.length > 0 ? kept : CODEX_REASONING_LEVELS @@ -380,7 +398,9 @@ export function clampCatalogModelsToObservedCodexSupport( const removed = new Set(); const affected: string[] = []; - for (const entry of models) { + for (let index = 0; index < models.length;) { + const entry = models[index]!; + const hadLadder = Array.isArray(entry.supported_reasoning_levels) && entry.supported_reasoning_levels.length > 0; const before = new Set( (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : []) .flatMap(level => typeof (level as { effort?: string })?.effort === "string" @@ -402,11 +422,14 @@ export function clampCatalogModelsToObservedCodexSupport( : null; const lost = [...before].filter(effort => !after.has(effort)); const defaultClamped = Boolean(beforeDefault && beforeDefault !== afterDefault); - if (lost.length > 0 || defaultClamped) { + const omitted = requiresExactReserveEfforts(entry) && hadLadder && after.size === 0; + if (lost.length > 0 || defaultClamped || omitted) { for (const effort of lost) removed.add(effort); if (defaultClamped && beforeDefault) removed.add(beforeDefault); if (typeof entry.slug === "string") affected.push(entry.slug); } + if (omitted) models.splice(index, 1); + else index += 1; } return { diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 62457b954f..a50dd9469f 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -38,10 +38,12 @@ import type { RawEntry } from "./parsing"; import { readCurrentCatalogOrCache, readCurrentCodexCatalog, readCurrentCodexModelsCache, unique } from "./bundled"; import { trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; +import { RESERVE_METADATA_SOURCE_FIELD } from "./reserve"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_GPT6_ASTRA_MODEL, + NATIVE_RESERVE_MODEL, NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, NATIVE_OPENAI_MODELS, SELF_DESCRIBED_NATIVE_OPENAI_MODELS, @@ -697,14 +699,44 @@ function observedAccountBoundNativeSlug(entry: RawEntry): string | undefined { const accountBound = trustedAccountBoundNativeCatalogSlug(entry); const slug = accountBound ?? (typeof entry.slug === "string" ? entry.slug : ""); if (!isAccountBoundOpenAiNativeSlug(slug) - || entry.supported_in_api !== true + || (entry.supported_in_api !== true && !(slug === NATIVE_RESERVE_MODEL && entry.supported_in_api === false)) + || (slug === NATIVE_RESERVE_MODEL && entry[RESERVE_METADATA_SOURCE_FIELD] !== undefined + && entry[RESERVE_METADATA_SOURCE_FIELD] !== NATIVE_RESERVE_MODEL) || !hasNativeCatalogRowShape(entry) - || (entry.visibility !== "list" && entry[ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER] !== true)) { + || (entry.visibility !== "list" && entry[ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER] !== true + && !(slug === NATIVE_RESERVE_MODEL && entry.visibility === "hide"))) { return undefined; } return slug; } +/** Prefer a genuine bare observation; an adapted OCX row must never become native evidence. */ +export function observedReserveCatalogSource( + entries: readonly RawEntry[], + mainSelectors: readonly string[], +): RawEntry | null { + const actual = entries.filter(entry => observedAccountBoundNativeSlug(entry) === NATIVE_RESERVE_MODEL); + const bare = actual.find(entry => entry.slug === NATIVE_RESERVE_MODEL); + const qualified = actual.find(entry => entry[RESERVE_METADATA_SOURCE_FIELD] === NATIVE_RESERVE_MODEL + && typeof entry.slug === "string" + && mainSelectors.includes(entry.slug.slice(0, entry.slug.indexOf("/")))); + const selected = bare ?? qualified; + if (!selected) return null; + const source = structuredClone(selected); + if (!bare) { + const prefix = `${String(source.slug).split("/")[0]} / `; + if (typeof source.display_name === "string" && source.display_name.startsWith(prefix)) { + source.display_name = source.display_name.slice(prefix.length); + } + } + source.slug = NATIVE_RESERVE_MODEL; + delete source.opencodex_catalog_kind; + delete source[RESERVE_METADATA_SOURCE_FIELD]; + delete source[ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER]; + delete source[ACCOUNT_BOUND_OBSERVED_SELECTORS_MARKER]; + return source; +} + /** * Return exact, previously observed account-native rows that are not in the static release set. * The result is used only to carry a hidden observation across startup cache invalidation. @@ -745,7 +777,8 @@ export function accountBoundNativeOpenAiSlugs( ): string[] { const observed = observedEntries.flatMap(entry => { const slug = observedAccountBoundNativeSlug(entry); - return slug === undefined ? [] : [slug]; + // Reserve belongs only to the Codex-specific opt-in builder, not generic native exports. + return slug === undefined || slug === NATIVE_RESERVE_MODEL ? [] : [slug]; }); return unique([...NATIVE_OPENAI_MODELS, ...observed]); } @@ -772,7 +805,7 @@ export function accountBoundNativeOpenAiSlugsBySelector( ); for (const entry of observedEntries) { const slug = observedAccountBoundNativeSlug(entry); - if (slug === undefined || SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) continue; + if (slug === undefined || slug === NATIVE_RESERVE_MODEL || SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) continue; const generated = trustedAccountBoundNativeCatalogSlug(entry); const generatedSelector = generated === undefined || typeof entry.slug !== "string" ? undefined diff --git a/src/codex/catalog/native-models.ts b/src/codex/catalog/native-models.ts index 835638fc49..691849fbdd 100644 --- a/src/codex/catalog/native-models.ts +++ b/src/codex/catalog/native-models.ts @@ -1,3 +1,6 @@ +/** Reserve wire identity, not a globally available native catalog registration. */ +export const NATIVE_RESERVE_MODEL = "gpt-reserve"; + /** ChatGPT/Codex wire id observed for the account-native Daybreak Blue surface. */ export const NATIVE_DAYBREAK_BLUE_MODEL = "gpt-daybreak-blue-latest"; diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index ceb86de551..0677da9a8a 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -596,6 +596,8 @@ export function ensureStrictCatalogFields( export type MultiAgentMode = "v1" | "default" | "v2"; export interface MultiAgentModeOptions { + /** Caller-owned source metadata already defines the default for these projected rows. */ + preserveDefaultMultiAgentVersion?: (entry: RawEntry) => boolean; /** * When the catalog is in v2 mode, stamp ChatGPT-native rows as v1 instead. * Routed parents get v2 (plaintext child tasks). Native Sol/Terra stay on v1 @@ -671,6 +673,7 @@ export function applyMultiAgentMode( // Restore upstream defaults: clear any stale forced multi_agent_version and // re-apply upstream pins from the snapshot for native entries that have one. for (const entry of entries) { + if (options.preserveDefaultMultiAgentVersion?.(entry)) continue; const slug = typeof entry.slug === "string" ? entry.slug : ""; const nativeAlias = entry.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND; const routedNativeSlug = slug.startsWith(`${OPENAI_CODEX_PROVIDER_ID}/`) diff --git a/src/codex/catalog/reserve.ts b/src/codex/catalog/reserve.ts new file mode 100644 index 0000000000..80e7bca6fe --- /dev/null +++ b/src/codex/catalog/reserve.ts @@ -0,0 +1,52 @@ +import type { OcxConfig } from "../../types"; +import { isEffectiveCodexDesktopAuthless } from "../loopback-target"; +import { CODEX_ACCOUNT_BOUND_CATALOG_KIND } from "./account-models"; +import { NATIVE_RESERVE_MODEL } from "./native-models"; +import type { RawEntry } from "./parsing"; + +export const RESERVE_METADATA_SOURCE_FIELD = "opencodex_reserve_metadata_source"; +/** Validated genuine source metadata on the existing catalog, never an authorization. */ +export const RESERVE_SOURCE_CATALOG_FIELD = "opencodex_reserve_source"; +export const RESERVE_LUNA_METADATA_SOURCE = "gpt-5.6-luna"; + +/** Metadata only: no process-local availability or credential state belongs in a catalog. */ +export interface ReserveCatalogProjection { + readonly source: RawEntry; + readonly mainSelectors: readonly string[]; +} + +/** The caller supplies a validated actual observation and an already context-capped Luna pin. */ +export function createReserveCatalogProjection( + config: Pick, + mainSelectors: readonly string[], + observedSource: RawEntry | null, + lunaSource: RawEntry | null, +): ReserveCatalogProjection | undefined { + if (!isEffectiveCodexDesktopAuthless(config) || mainSelectors.length === 0) return undefined; + const original = observedSource ?? lunaSource; + if (!original) return undefined; + const source = structuredClone(original); + source.slug = NATIVE_RESERVE_MODEL; + source.display_name = observedSource?.display_name ?? "Luna Reserve"; + source.description = "Manual main-account Reserve through OpenCodex; recent upstream permission is required for every request."; + // This qualified OCX endpoint accepts the selector, not an OpenAI API-key model grant. + source.supported_in_api = true; + source[RESERVE_METADATA_SOURCE_FIELD] = observedSource ? NATIVE_RESERVE_MODEL : RESERVE_LUNA_METADATA_SOURCE; + delete source.available_in_plans; + delete source.availability_nux; + delete source.upgrade; + delete source.opencodex_account_observed_native; + delete source.opencodex_account_observed_selectors; + return { source, mainSelectors: [...mainSelectors] }; +} + +/** Exact OCX account projection, never another provider's similarly named model. */ +export function isReserveCatalogProjection(entry: RawEntry): boolean { + return entry.opencodex_catalog_kind === CODEX_ACCOUNT_BOUND_CATALOG_KIND + && typeof entry.slug === "string" + && entry.slug.indexOf("/") > 0 + && entry.slug.indexOf("/") === entry.slug.lastIndexOf("/") + && entry.slug.endsWith(`/${NATIVE_RESERVE_MODEL}`) + && (entry[RESERVE_METADATA_SOURCE_FIELD] === NATIVE_RESERVE_MODEL + || entry[RESERVE_METADATA_SOURCE_FIELD] === RESERVE_LUNA_METADATA_SOURCE); +} diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 6288f9bf87..3f5f472baf 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -75,7 +75,15 @@ import { } from "../internal/catalog-writer"; import { codexRuntimeStatePath } from "../runtime"; import { accountBoundNativeDisplayName, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; -import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./native-models"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./native-models"; +import { observedReserveCatalogSource } from "./metadata"; +import { + createReserveCatalogProjection, + isReserveCatalogProjection, + RESERVE_LUNA_METADATA_SOURCE, + RESERVE_SOURCE_CATALOG_FIELD, + type ReserveCatalogProjection, +} from "./reserve"; export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5; @@ -437,6 +445,8 @@ export interface ObservedCatalogEntryBuildInput { readonly accountNativeSlugs?: readonly string[]; /** Per-selector account ids; unknown observations must not be copied to unrelated accounts. */ readonly accountNativeSlugsBySelector?: ReadonlyMap; + /** Codex-only manual selector metadata; deliberately independent of live permission. */ + readonly reserve?: ReserveCatalogProjection; } /** Build entries with the process-observed Codex feature state. */ @@ -493,6 +503,7 @@ export function buildCatalogEntriesFromObservedState({ openaiContextCap, accountNativeSlugs, accountNativeSlugsBySelector, + reserve, }: ObservedCatalogEntryBuildInput): RawEntry[] { // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog @@ -589,15 +600,17 @@ export function buildCatalogEntriesFromObservedState({ const selectorNativeSlugs = accountNativeSlugsBySelector?.get(selector) ?? accountNativeSlugs ?? gptSlugs; - const accountNativeEntries = selectorNativeSlugs.map(slug => ( + const accountNativeEntries = selectorNativeSlugs.filter(slug => slug !== NATIVE_RESERVE_MODEL).map(slug => ( nativeEntriesBySlug.get(slug) ?? deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap) )); + if (reserve?.mainSelectors.includes(selector)) accountNativeEntries.push(reserve.source); for (const [nativeIndex, native] of accountNativeEntries.entries()) { const nativeSlug = String(native.slug); if (disabledNativeAccountSlugs.has(nativeSlug)) continue; const e = JSON.parse(JSON.stringify(native)) as RawEntry; const catalogSlug = `${selector}/${nativeSlug}`; + if (nativeSlug === NATIVE_RESERVE_MODEL && disabledNativeAccountSlugs.has(catalogSlug)) continue; e.slug = catalogSlug; e.display_name = accountBoundNativeDisplayName(selector, native); // Codex ignores this OpenCodex extension; preserve the native comp_hash unchanged. @@ -671,6 +684,7 @@ export function buildCatalogEntriesFromObservedState({ } return applyMultiAgentMode(out, multiAgentMode, multiAgentV2Enabled, { keepNativeChatGptOnV1, + preserveDefaultMultiAgentVersion: isReserveCatalogProjection, }); } @@ -964,7 +978,7 @@ export function mergeCatalogEntriesFromObservedState({ const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m.priority) }); // Older natives kept from disk still need the mock top tiers (max + ultra always // for subagent max spawns; wire-clamped to the model's real top rung). - if (!isGpt56NativeSlug(slug)) ensureUltraReasoningLevel(preserved); + if (!isGpt56NativeSlug(slug) && slug !== NATIVE_RESERVE_MODEL) ensureUltraReasoningLevel(preserved); return preserved; }) : []; @@ -999,6 +1013,9 @@ export function mergeCatalogEntriesFromObservedState({ typeof entry.slug === "string" ? [[entry.slug, entry] as const] : [] )); const alignedAccountBoundEntries = detachedAccountBoundEntries.map(entry => { + // The explicit Reserve source is already chosen (actual row or documented Luna adaptation). + // A generic native merge must not replace its provenance or capability ladder. + if (isReserveCatalogProjection(entry)) return entry; const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry); const source = nativeSlug === undefined ? undefined : nativeSourceBySlug.get(nativeSlug); if (!source) return entry; @@ -1106,17 +1123,18 @@ export function mergeCatalogEntriesFromObservedState({ })); for (const slug of policy.nativeBackfillSlugs) observedNativeSlugs.add(slug); const mergedEntries = [...native, ...managedEntries].map(m => { - const normalized = normalizeServiceTiers(m); - if (!isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized, openaiContextCap); + const reserveProjection = isReserveCatalogProjection(m); + const normalized = reserveProjection ? m : normalizeServiceTiers(m); + if (!reserveProjection && !isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized, openaiContextCap); const exactCombo = isExactComboCatalogEntry(m, exactComboSlugs); - const e = ensureStrictCatalogFields(normalized, { + const e = reserveProjection ? normalized : ensureStrictCatalogFields(normalized, { preserveExactInputModalities: exactCombo, isRouted: finalRoutedEntrySet.has(m), }); // Mock-max universality (260709): preserved routed entries from disk may predate // the max rung — ensure it here so subagent max spawns validate on every // reasoning-capable entry. max only: 5.6 exact ladders (luna: no ultra) stay intact. - if (!exactCombo) { + if (!exactCombo && !reserveProjection) { const levels = Array.isArray(e.supported_reasoning_levels) ? e.supported_reasoning_levels as Array<{ effort?: string }> : []; @@ -1141,7 +1159,7 @@ export function mergeCatalogEntriesFromObservedState({ applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0, observedNativeSlugs), multiAgentMode, multiAgentV2Enabled, - { keepNativeChatGptOnV1 }, + { keepNativeChatGptOnV1, preserveDefaultMultiAgentVersion: isReserveCatalogProjection }, ); for (const entry of versionedEntries) { const kind = entry.opencodex_catalog_kind; @@ -1629,6 +1647,36 @@ function writeRetainedCatalogSync({ trustedAccountBoundNativeCatalogSlug(entry) !== undefined), ]; const accountTargets = new Map(codexAccountNamespaceEntries(config)); + const reserveMainSelectors = accountSelectors.filter(selector => + isMainCodexAccountTarget(accountTargets.get(selector) ?? "")); + // The active file can own a bare source even when the bundled catalog is the build base. + // A previously clamped qualified projection must not shorten a retained genuine ladder. + const reserveObservations = [ + ...(onDiskCatalog?.models ?? []), + ...(read.modelsCache?.models ?? []), + ...(catalog.models ?? []), + ]; + const retainedReserve = onDiskCatalog?.[RESERVE_SOURCE_CATALOG_FIELD]; + const retainedReserveSource = retainedReserve && typeof retainedReserve === "object" && !Array.isArray(retainedReserve) + ? observedReserveCatalogSource([retainedReserve as RawEntry], []) + : null; + const observedReserveSource = observedReserveCatalogSource( + // Cache invalidation carries historical bare observations alongside emitted models. + // Only unmarked observations are fresh enough to supersede the retained source. + reserveObservations.filter(entry => entry.slug === NATIVE_RESERVE_MODEL + && entry.opencodex_account_observed_native === undefined), reserveMainSelectors, + ) ?? retainedReserveSource ?? observedReserveCatalogSource(reserveObservations, reserveMainSelectors); + // This root is read only by OCX. Upstream ModelsResponse ignores unknown root fields. + // Retain before final runtime clamping: an omitted row must not turn into Luna next sync. + if (observedReserveSource) catalog[RESERVE_SOURCE_CATALOG_FIELD] = structuredClone(observedReserveSource); + else delete catalog[RESERVE_SOURCE_CATALOG_FIELD]; + const lunaSource = upstreamNativeEntry(RESERVE_LUNA_METADATA_SOURCE); + const reserve = createReserveCatalogProjection( + config, + reserveMainSelectors, + observedReserveSource, + lunaSource ? finishUpstreamNativeEntry(lunaSource, 9, openaiContextCap) : null, + ); const accountNativeSlugsBySelector = accountSelectors.length > 0 ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => { const target = accountTargets.get(selector); @@ -1710,6 +1758,7 @@ function writeRetainedCatalogSync({ openaiContextCap, accountNativeSlugs, accountNativeSlugsBySelector, + reserve, }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) : []; catalog.models = mergeCatalogEntriesFromObservedState({ diff --git a/src/codex/inject.ts b/src/codex/inject.ts index cb8e1434b3..37e631305b 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -77,6 +77,9 @@ import { type ManagedSubagentDefaults, } from "./subagent-defaults"; import type { OcxConfig } from "../types"; +import { isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target"; + +export { isLoopbackHostname, shouldInjectApiAuthHeader } from "./loopback-target"; // Ownership predicates live in `./injected-marker` so `journal.ts` can reach them // without importing this module back. Re-exported for existing external callers. @@ -233,23 +236,6 @@ function configuredManagedSubagentDefaults( * whatever `[table]` happened to be open last (e.g. `[plugins."chrome@openai-bundled"]`), so Codex * never saw a global model_provider and silently fell back to the `openai` (ChatGPT) provider. */ -/** - * True only for hostnames that bind loopback ONLY. Wildcard binds ("0.0.0.0", "::") are NOT - * loopback: they expose the proxy on every interface and therefore require the admission token. - * Do not use `providerBaseHost` for this decision — it folds wildcards to 127.0.0.1 because it - * answers "what address do I dial", which is a different question from "is this exposed". - */ -export function isLoopbackHostname(hostname: string | undefined): boolean { - const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase(); - return ( - normalized === "" || - normalized === "localhost" || - normalized === "127.0.0.1" || - normalized === "::1" || - normalized === "[::1]" - ); -} - export function providerBaseHost(hostname: string | undefined): string { const trimmed = (hostname ?? "127.0.0.1").trim(); const lower = trimmed.toLowerCase(); @@ -267,17 +253,6 @@ export function providerBaseHost(hostname: string | undefined): string { return trimmed.includes(":") ? `[${trimmed}]` : trimmed; } -export function shouldInjectApiAuthHeader( - config: Pick | undefined, -): boolean { - // The unauthenticated loopback listener is a loopback bind, so it admits without a - // credential (#1102). Emitting the env header anyway would be worse than useless: the - // directly-spawned app-server this exists for has no OPENCODEX_API_AUTH_TOKEN in its - // environment, and Codex would send an empty header value. - if (config?.unauthenticatedLoopbackListener?.enabled) return false; - return !isLoopbackHostname(config?.hostname); -} - export function buildProviderTableBlock( port: number, supportsWebsockets?: boolean, diff --git a/src/codex/loopback-target.ts b/src/codex/loopback-target.ts new file mode 100644 index 0000000000..94e81d42c3 --- /dev/null +++ b/src/codex/loopback-target.ts @@ -0,0 +1,54 @@ +import type { OcxConfig } from "../types"; +import type { DataPlaneAdmission } from "../server/auth-cors"; +import { NATIVE_RESERVE_MODEL } from "./catalog/native-models"; + +export const CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE = + "Luna Reserve compatibility is only available as a conversation model, not a vision helper. Choose another vision model."; + +/** Callers classify the concrete destination as canonical forward before using this predicate. */ +export function isCodexReserveHelperUnsupported( + config: Pick, + modelId: string, + admission: Pick | undefined, + terminalHelper: boolean, +): boolean { + return terminalHelper && modelId === NATIVE_RESERVE_MODEL && isCodexReserveRequestEligible(config, admission); +} + +/** Runtime authority comes from the receiving listener, not the catalog's injection target. */ +export function isCodexReserveRequestEligible( + config: Pick, + admission: Pick | undefined, +): boolean { + return config.codexDesktopAuthless === true && config.runtimeRole !== "client" + && admission?.source === "loopback"; +} + +/** Bind scope, not the dial address: wildcard listeners are never loopback-only. */ +export function isLoopbackHostname(hostname: string | undefined): boolean { + const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase().replace(/\.$/, ""); + return ( + normalized === "" || + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "[::1]" + ); +} + +export function shouldInjectApiAuthHeader( + config: Pick | undefined, +): boolean { + // The dedicated listener binds loopback and does not require an admission credential. + if (config?.unauthenticatedLoopbackListener?.enabled) return false; + return !isLoopbackHostname(config?.hostname); +} + +/** Match standalone injection, never a remote client's independently supplied routing target. */ +export function isEffectiveCodexDesktopAuthless( + config: Pick | undefined, +): boolean { + return config?.codexDesktopAuthless === true + && config.runtimeRole !== "client" + && !shouldInjectApiAuthHeader(config); +} diff --git a/src/codex/main-account-cache.ts b/src/codex/main-account-cache.ts index d87b7aa6b9..81b93dd528 100644 --- a/src/codex/main-account-cache.ts +++ b/src/codex/main-account-cache.ts @@ -1,5 +1,5 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; -import type { StoredAccountQuota } from "./quota"; +import type { StoredAccountQuota } from "./quota-types"; import { truncateRetainedUtf8 } from "../lib/admission"; const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024; @@ -20,6 +20,10 @@ let mainAccountIdentityGeneration = 0; let observedMainQuotaIdentityKey: string | undefined; const mainQuotaCredentialKey = randomBytes(32); let mainQuotaCredential: { bearerHmac: Buffer; writer: MainQuotaWriter } | undefined; +let mainQuotaCredentialGeneration = 0; + +/** Process-local transition fence; no credential material or persisted identity. */ +export function getMainQuotaCredentialGeneration(): number { return mainQuotaCredentialGeneration; } export type MainQuotaWriter = Readonly<{ identityKey: string; identityGeneration: number }>; @@ -35,6 +39,7 @@ export function observeMainQuotaIdentity(accountId: string): void { observedMainQuotaIdentityKey = identityKey; mainAccountIdentityGeneration += 1; mainQuotaCredential = undefined; + mainQuotaCredentialGeneration += 1; } export function captureMainQuotaWriter(accountId: string): MainQuotaWriter | undefined { @@ -48,10 +53,10 @@ export function captureMainQuotaWriter(accountId: string): MainQuotaWriter | und 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, - }; + const bearerHmac = createHmac("sha256", mainQuotaCredentialKey).update(accessToken).digest(); + if (!mainQuotaCredential || !isMainQuotaWriterLive(mainQuotaCredential.writer) + || !timingSafeEqual(bearerHmac, mainQuotaCredential.bearerHmac)) mainQuotaCredentialGeneration += 1; + mainQuotaCredential = { bearerHmac, writer }; return { ...writer }; } @@ -96,6 +101,7 @@ export function clearMainAccountInfoCache(): void { cachedMainAccountInfo = null; mainAccountIdentityGeneration += 1; mainQuotaCredential = undefined; + mainQuotaCredentialGeneration += 1; } /** Last physical credential presence observed while native-main ownership was held. */ diff --git a/src/codex/quota-types.ts b/src/codex/quota-types.ts new file mode 100644 index 0000000000..6c06de6ae9 --- /dev/null +++ b/src/codex/quota-types.ts @@ -0,0 +1,51 @@ +/** Quota wire/storage shapes. This leaf must not import credential or config owners. */ +export type StoredAccountQuota = { + weeklyPercent?: number; + monthlyPercent?: number; + weeklyResetAt?: number; + monthlyResetAt?: number; + /** Sub-day burst window, independent of the weekly window; duration supplies its meaning. */ + shortPercent?: number; + shortResetAt?: number; + /** Local short-usage observation time; partial/credit updates do not refresh it. */ + shortObservedAt?: number; + shortWindowSeconds?: number; + customWindows?: Array<{ label: string; percent: number; resetAt?: number }>; + resetCredits?: number; + /** Monthly usage came from an explicitly monthly PRIMARY, not supplementary tertiary, window. */ + monthlyIsPrimaryWindow?: boolean; + updatedAt: number; +}; + +export type WhamUsageWindow = { + used_percent?: number; + reset_at?: number; + limit_window_seconds?: number; +}; + +export type WhamAdditionalRateLimit = { + limit_name?: unknown; + metered_feature?: unknown; + rate_limit?: { + allowed?: unknown; + primary_window?: WhamUsageWindow | null; + secondary_window?: WhamUsageWindow | null; + } | null; +}; + +export type WhamUsageResponse = { + email?: string | null; + plan_type?: unknown; + account_id?: unknown; + user_id?: unknown; + rate_limit_upsell?: { banner_type?: unknown } | null; + rate_limit?: { + allowed?: unknown; + // WHAM sends explicit nulls for absent windows. + primary_window?: WhamUsageWindow | null; + secondary_window?: WhamUsageWindow | null; + tertiary_window?: WhamUsageWindow | null; + }; + rate_limit_reset_credits?: { available_count: number } | null; + additional_rate_limits?: WhamAdditionalRateLimit[] | null; +}; diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 8ff58d88fd..0648e4a717 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -6,39 +6,8 @@ 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; - monthlyPercent?: number; - weeklyResetAt?: number; - monthlyResetAt?: number; - /** - * A sub-day burst window, when upstream declares one (#1791). - * - * K12 and similar plans enforce a rolling 5-hour limit ALONGSIDE the weekly one. - * Not folding it into `weeklyPercent` stopped the mislabeling, but dropping it - * entirely hides a limit that genuinely blocks the account: a 429 at 100% here is - * real even while the weekly quota is untouched. - * - * `shortWindowSeconds` is retained because the duration is the only thing that makes - * this window self-describing; the slot it arrived in is not stable across plans. - */ - shortPercent?: number; - shortResetAt?: number; - /** Local observation time of shortPercent; unrelated quota/credit updates never refresh it. */ - shortObservedAt?: number; - shortWindowSeconds?: number; - customWindows?: Array<{ label: string; percent: number; resetAt?: number }>; - resetCredits?: number; - /** - * True when `monthlyPercent` came from an explicitly-monthly PRIMARY window — - * i.e. it is the account's governing quota reading, not a supplementary - * tertiary window. Tertiary-only monthly data lands in the same field but says - * nothing about the weekly quota that actually gates a non-Go/Free account, - * so recovery must be able to tell the two apart (#967 audit). - */ - monthlyIsPrimaryWindow?: boolean; - updatedAt: number; -}; +import type { StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types"; +export type { StoredAccountQuota, WhamUsageResponse } from "./quota-types"; /** Disk snapshot under OPENCODEX_HOME — quota and policy identity only, never credential tags. */ const QUOTA_CACHE_FILENAME = "codex-quota-cache.json"; @@ -57,36 +26,6 @@ let mainPolicyQuota: MainPolicyQuota | null = null; let diskHydrated = false; let persistTimer: ReturnType | null = null; -export type WhamUsageResponse = { - email?: string | null; - plan_type?: unknown; - rate_limit?: { - // Live WHAM payloads send explicit nulls for absent windows (issue #315 repro). - primary_window?: WhamUsageWindow | null; - secondary_window?: WhamUsageWindow | null; - tertiary_window?: WhamUsageWindow | null; - }; - rate_limit_reset_credits?: { - available_count: number; - } | null; - additional_rate_limits?: WhamAdditionalRateLimit[] | null; -}; - -type WhamAdditionalRateLimit = { - limit_name?: unknown; - metered_feature?: unknown; - rate_limit?: { - primary_window?: WhamUsageWindow | null; - secondary_window?: WhamUsageWindow | null; - } | null; -}; - -type WhamUsageWindow = { - used_percent?: number; - reset_at?: number; - limit_window_seconds?: number; -}; - const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60; /** * Shortest window still plausibly the WEEKLY quota (#1791). diff --git a/src/codex/reserve-availability.ts b/src/codex/reserve-availability.ts new file mode 100644 index 0000000000..7d03e840ac --- /dev/null +++ b/src/codex/reserve-availability.ts @@ -0,0 +1,177 @@ +import { createHmac, randomBytes } from "node:crypto"; +import { readBoundedResponseBody } from "../lib/bounded-body"; +import { + getMainQuotaCredentialGeneration, isMainQuotaWriterLive, matchesMainQuotaCredential, type MainQuotaWriter, +} from "./main-account-cache"; +import { NATIVE_RESERVE_MODEL } from "./catalog/native-models"; +import { WHAM_REQUEST_TIMEOUT_MS } from "./quota-recovery-timing"; +import type { WhamUsageResponse } from "./quota-types"; + +const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; +const AUTHORIZATION_TTL_MS = 60_000; +const credentialSalt = randomBytes(32); +type Token = { accessToken: string; chatgptAccountId: string }; +export interface MainReserveAuthorization { + readonly writer: MainQuotaWriter; + readonly observedAt: number; + readonly expiresAt: number; +} +type Input = { + token: Token; + writer: MainQuotaWriter | undefined; + signal?: AbortSignal; + observeOrdinaryQuota: (data: WhamUsageResponse, writer: MainQuotaWriter) => void; +}; +type Slot = { + key: string; + writer: MainQuotaWriter; + credentialGeneration: number; + revision: number; + authorization?: MainReserveAuthorization; + flight?: Promise; + controller?: AbortController; +}; +let current: Slot | undefined; +const authorizationKeys = new WeakMap(); + +function credentialKey(token: Token, writer: MainQuotaWriter): string { + return createHmac("sha256", credentialSalt).update(writer.identityKey) + .update(`:${writer.identityGeneration}:${getMainQuotaCredentialGeneration()}:`).update(token.accessToken).digest("hex"); +} +function owned(token: Token, writer: MainQuotaWriter): boolean { + return isMainQuotaWriterLive(writer) && matchesMainQuotaCredential(token.accessToken, token.chatgptAccountId); +} +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function userId(token: string): string | undefined { + try { + const payload: unknown = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8")); + const auth = record(payload) ? payload["https://api.openai.com/auth"] : undefined; + if (!record(auth)) return; + const value = auth.chatgpt_user_id ?? auth.user_id; + return typeof value === "string" && value.length > 0 ? value : undefined; + } catch { return; } +} +function identityMatches(data: WhamUsageResponse, token: Token): boolean { + if (data.account_id != null && data.account_id !== token.chatgptAccountId) return false; + const expectedUser = userId(token.accessToken); + return expectedUser === undefined || data.user_id == null || data.user_id === expectedUser; +} +function reserveLimits(data: WhamUsageResponse) { + return Array.isArray(data.additional_rate_limits) + ? data.additional_rate_limits.filter(entry => record(entry) && entry.limit_name === NATIVE_RESERVE_MODEL) + : []; +} +function grantsReserve(data: WhamUsageResponse): boolean { + const limits = reserveLimits(data); + return data.rate_limit?.allowed === false && data.rate_limit_upsell?.banner_type === "luna_reserve" + && limits.length === 1 && limits[0]?.rate_limit?.allowed === true; +} + +/** An object copied/spread onto a refreshed credential is not an authorization for that credential. */ +export function isMainReserveAuthorizationLive( + value: MainReserveAuthorization | undefined, token: Token, now = Date.now(), +): boolean { + if (!value || !owned(token, value.writer) || value.expiresAt <= now || value.observedAt > now) return false; + const key = credentialKey(token, value.writer); + return current?.authorization === value && authorizationKeys.get(value) === key && current.key === key; +} + +/** Passive usage may revoke, but never grant. Missing Reserve on a passive read is not revocation. */ +export function observeMainReserveRevocation(data: WhamUsageResponse, writer: MainQuotaWriter | undefined): void { + const slot = current; + if (!slot || !writer || !isMainQuotaWriterLive(writer) + || writer.identityKey !== slot.writer.identityKey || writer.identityGeneration !== slot.writer.identityGeneration) return; + if (data.rate_limit?.allowed !== true && !reserveLimits(data).some(limit => limit.rate_limit?.allowed === false)) return; + slot.revision += 1; + slot.authorization = undefined; + slot.controller?.abort(); +} + +async function waitForCaller(flight: Promise, signal?: AbortSignal): Promise { + if (!signal) return flight; + if (signal.aborted) return; + let abort!: () => void; + const aborted = new Promise(resolve => { abort = () => resolve(undefined); signal.addEventListener("abort", abort, { once: true }); }); + try { return await Promise.race([flight, aborted]); } + finally { signal.removeEventListener("abort", abort); } +} + +async function readAuthorization(slot: Slot, input: Input & { writer: MainQuotaWriter }): Promise { + const controller = new AbortController(); + slot.controller = controller; + const revision = slot.revision; + const deadline = Date.now() + WHAM_REQUEST_TIMEOUT_MS; + const live = () => current === slot && revision === slot.revision && !controller.signal.aborted + && Date.now() < deadline && slot.credentialGeneration === getMainQuotaCredentialGeneration() + && owned(input.token, input.writer); + let timer: ReturnType | undefined; + let onAbort!: () => void; + const stopped = new Promise(resolve => { + onAbort = () => resolve(undefined); + controller.signal.addEventListener("abort", onAbort, { once: true }); + timer = setTimeout(() => controller.abort(), WHAM_REQUEST_TIMEOUT_MS); + }); + const operation = (async () => { + const response = await fetch(USAGE_URL, { + method: "GET", redirect: "error", signal: controller.signal, + headers: { + authorization: `Bearer ${input.token.accessToken}`, + "chatgpt-account-id": input.token.chatgptAccountId, + "x-openai-codex-luna-reserve": "1", accept: "application/json", + }, + }); + if (!response.ok || !live()) { + void response.body?.cancel().catch(() => undefined); + return; + } + const body = await readBoundedResponseBody(response, { + signal: controller.signal, fatalUtf8: true, + totalTimeoutMs: Math.max(1, deadline - Date.now()), inactivityTimeoutMs: WHAM_REQUEST_TIMEOUT_MS, + }); + if (!body.displaySafe || body.truncated || !live()) return; + const raw: unknown = JSON.parse(body.text); + if (!record(raw)) return; + const data = raw as WhamUsageResponse; + if (!identityMatches(data, input.token)) return; + // Keep malformed additional containers away from legacy ordinary parsers. + if (data.additional_rate_limits != null && !Array.isArray(data.additional_rate_limits)) return; + input.observeOrdinaryQuota(data, input.writer); + if (!live() || !grantsReserve(data)) { slot.authorization = undefined; return; } + const observedAt = Date.now(); + const authorization = Object.freeze({ + writer: Object.freeze({ ...input.writer }), observedAt, expiresAt: observedAt + AUTHORIZATION_TTL_MS, + }); + authorizationKeys.set(authorization, slot.key); + slot.authorization = authorization; + return authorization; + })().catch(() => undefined); + try { return await Promise.race([operation, stopped]); } + finally { + clearTimeout(timer); + controller.signal.removeEventListener("abort", onAbort); + if (slot.controller === controller) slot.controller = undefined; + } +} + +/** Capability-aware read with an already-owned token; no auth-file access or inference. */ +export async function getMainReserveAuthorization(input: Input): Promise { + const writer = input.writer && { ...input.writer }; + const token = { ...input.token }; + if (input.signal?.aborted || !writer || !owned(token, writer)) return; + const key = credentialKey(token, writer); + if (!current || current.key !== key) { + current?.controller?.abort(); + current = { key, writer: { ...writer }, credentialGeneration: getMainQuotaCredentialGeneration(), revision: 0 }; + } + const slot = current; + if (isMainReserveAuthorizationLive(slot.authorization, token)) return slot.authorization; + if (!slot.flight) { + const flight = readAuthorization(slot, { ...input, token, writer }); + slot.flight = flight; + void flight.finally(() => { if (slot.flight === flight) slot.flight = undefined; }); + } + const result = await waitForCaller(slot.flight, input.signal); + return !input.signal?.aborted && isMainReserveAuthorizationLive(result, token) ? result : undefined; +} diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 9cba89d7e1..dbf9cab086 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { saveConfigPreservingClaudeCode } from "../config"; import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store"; import { codexAccountLogLabel } from "./account-label"; +import { NATIVE_RESERVE_MODEL } from "./catalog/native-models"; import { isCodexAccountPaused } from "./account-pause"; import { clearCodexAccountPin, codexAccountPriorityLookup, pinnedCodexAccountId } from "./account-priority"; import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability"; @@ -173,7 +174,7 @@ export type CodexCooldownSource = "retry-after" | "reset-derived" | "default"; * Add a new explicit group here only when its independent upstream quota is * confirmed, so shared limits never receive cross-model bypasses. */ -export type CodexQuotaScope = "shared" | "spark"; +export type CodexQuotaScope = "shared" | "spark" | "reserve"; export type CodexQuotaRecoveryProbeClaim = { accountId: string; @@ -208,6 +209,7 @@ function isModelDetourAffinityScope(scope: ThreadAffinityScope): scope is ModelD const NATIVE_MODEL_QUOTA_SCOPES: Readonly> = { "gpt-5.3-codex-spark": "spark", + [NATIVE_RESERVE_MODEL]: "reserve", }; export function codexQuotaScopeForModel(modelId: string | undefined): CodexQuotaScope | undefined { @@ -566,7 +568,7 @@ function canAcquireQuotaProbeLease(health: CodexUpstreamHealth | undefined, now: /** * Claim due reset-derived cooldown probes without consulting account selection. - * Pool credentials only: the main account has no quota-refresh single-flight. + * Added Pool credentials only; owned main usage recovery is handled separately. */ export function claimDueCodexQuotaRecoveryProbes( config: OcxConfig, @@ -593,11 +595,10 @@ export function claimDueCodexQuotaRecoveryProbes( { scope: undefined, health: upstreamHealth.get(account.id) }, ...[...(quotaScopedHealth.get(account.id) ?? [])].map(([scope, health]) => ({ scope, health })), ].filter((entry): entry is { scope?: CodexQuotaScope; health: CodexUpstreamHealth } => - // `spark` is deliberately never claimed. `GET /backend-api/wham/usage` takes no scope - // parameter and returns generic weekly/monthly windows, so its result can never prove a - // spark recovery — a claim here would spend an upstream call to settle `false` every - // time, and (with one claim per account per pass) delay the shared scope that CAN recover. - entry.scope !== "spark" + // Generic WHAM evidence can recover only ordinary quota, never Spark or Reserve. + // Do not spend this account's one claim per pass on an independent scope and + // delay the shared scope that the response can actually recover. + (entry.scope === undefined || entry.scope === "shared") && entry.health?.cooldownSource === "reset-derived" && canAcquireQuotaProbeLease(entry.health, now)) .sort((a, b) => diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index 71d6cd79a7..8551aa6f97 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -7,10 +7,11 @@ import { resolveCodexAuthContext, type CodexAccountSelectionAdmission, type CodexAuthContext, + type CodexAuthPolicyConfig, } from "../codex/auth-context"; import { recordCodexUpstreamOutcome, type CodexUpstreamOutcome } from "../codex/routing"; import { extractAccountId } from "../oauth/chatgpt"; -import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../server/auth-cors"; +import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "../server/auth-cors"; import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; import { CODEX_FORWARD_BASE_URL, @@ -78,7 +79,8 @@ export function listOpenAiForwardSidecarCandidates(config: OcxConfig): OpenAiFor function directSidecarHeaders( incomingHeaders: Headers, - config: OcxConfig, + config: CodexAuthPolicyConfig, + admission?: Pick, ): Headers | undefined { const bearer = incomingHeaders.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); if (!bearer) return undefined; @@ -90,7 +92,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 }, config); + const selected = headersForCodexAuthContext(incomingHeaders, { kind: "main", accountId: null }, config, undefined, admission); return selected; } @@ -100,10 +102,13 @@ export async function resolveFirstUsableOpenAiSidecar( config: OcxConfig, options: { exactAccount?: ExactOpenAiSidecarAccount; + admission?: Pick; + codexAuthPolicy?: CodexAuthPolicyConfig; beginCodexAccountSelection?: () => CodexAccountSelectionAdmission | undefined; } = {}, ): Promise { const { exactAccount } = options; + const policy = options.codexAuthPolicy ?? config; let callerBearerMayBeForwarded = true; try { validateForwardAdmissionCredential(incomingHeaders, config); @@ -117,11 +122,13 @@ export async function resolveFirstUsableOpenAiSidecar( // credential directly even when the provider is globally Direct, and never // consult Pool active state, affinity, probes, or alternates. const authContext = await resolveCodexAuthContext(incomingHeaders, config, "pool", { + codexAuthPolicy: policy, accountId: exactAccount.accountId, modelId: exactAccount.modelId, + admission: options.admission, beginCodexAccountSelection: options.beginCodexAccountSelection, }); - const selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, config); + const selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, policy, exactAccount.modelId, options.admission); 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 @@ -151,7 +158,7 @@ export async function resolveFirstUsableOpenAiSidecar( } if (candidate.accountMode === "direct") { if (!callerBearerMayBeForwarded || !hasCallerCodexBearer(incomingHeaders)) continue; - const headers = directSidecarHeaders(incomingHeaders, config); + const headers = directSidecarHeaders(incomingHeaders, policy, options.admission); if (!headers) continue; return { ...candidate, @@ -160,9 +167,11 @@ export async function resolveFirstUsableOpenAiSidecar( }; } const authContext = await resolveCodexAuthContext(incomingHeaders, config, candidate.accountMode, { + codexAuthPolicy: policy, + admission: options.admission, beginCodexAccountSelection: options.beginCodexAccountSelection, }); - const selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, config); + const selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, policy, undefined, options.admission); if (!isCodexAuthContextUsable(authContext, config)) continue; return { ...candidate, diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index afafe4f56e..d66a0df0b6 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -48,6 +48,8 @@ import { import { handleNativeChatCompletions, isNativeChatRouteEligible } from "./chat-native"; import { parseRequestEffortRowId } from "./effort-row"; import { parseSyntheticRowId } from "./fast-row"; +import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; +import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported } from "../codex/loopback-target"; type Rec = Record; @@ -218,6 +220,13 @@ async function handleChatCompletionsWithBudget( else internalBody.reasoning = next; } + const visionDescribeTerminal = req.headers.get("x-opencodex-vision-describe") === "1"; + // Concrete helper targets must fail before optional stored-main credential enrichment. + // Unresolved combos are checked after their concrete child route is selected in Responses. + if (settledRoute && !settledRoute.combo && isCanonicalOpenAiForwardProvider(settledRoute.provider) + && isCodexReserveHelperUnsupported(config, settledRoute.modelId, logIds?.admission, visionDescribeTerminal)) { + return chatCompletionsErrorResponse(400, CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, "invalid_request_error"); + } const headers = new Headers({ "content-type": "application/json" }); for (const name of FORWARD_HEADERS) { if (name === "authorization" && !directRoute) continue; @@ -284,7 +293,7 @@ async function handleChatCompletionsWithBudget( // Terminal vision-describe marker (roadmap 180): the bridge rebuilds // headers from the FORWARD_HEADERS allowlist, which would drop the raw // header — so the fact is detected here and carried as an option flag. - ...(req.headers.get("x-opencodex-vision-describe") === "1" ? { visionDescribeTerminal: true } : {}), + ...(visionDescribeTerminal ? { visionDescribeTerminal: true } : {}), translatorBudget, ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}), onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }), diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 595928c0c3..bf01cae50e 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -40,6 +40,7 @@ import { isDataPlaneAdmissionSecret, isProxyAdmissionSecret, type RequestPolicyView, + type DataPlaneAdmission, } from "./auth-cors"; import type { AdmissionLease } from "../lib/admission"; import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission"; @@ -592,7 +593,7 @@ export async function handleClaudeMessages( req: Request, config: OcxConfig, logCtx: RequestLogContext, - logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease }, + logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission }, requestPolicy: RequestPolicyView = config, ): Promise { const translatorBudget = createTranslatorBudget(); @@ -612,7 +613,7 @@ async function handleClaudeMessagesWithBudget( config: OcxConfig, logCtx: RequestLogContext, translatorBudget: TranslatorBudget, - logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease }, + logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission }, requestPolicy: RequestPolicyView = config, ): Promise { logCtx.surface = "claude"; @@ -827,6 +828,9 @@ async function handleClaudeMessagesWithBudget( addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta); }; const upstream = await handleResponses(internalReq, buildClaudeReplayConfig(config), logCtx, { + // Routing keeps Claude-only sidecar overrides; admission policy must follow the live owner. + codexAuthPolicy: config, + ...(logIds?.admission ? { admission: logIds.admission } : {}), ...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}), abortSignal: req.signal, promptCacheKeyIsSharedCohort: cacheKeySource === "system", diff --git a/src/server/index.ts b/src/server/index.ts index aedd6bf236..82f36d7b6a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1843,7 +1843,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { - const response = await handleSearch(req, config, logCtx, turnAdmissionLease); + const response = await handleSearch(req, config, logCtx, turnAdmissionLease, admission); addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined); return withCors(response, req, policy); @@ -1945,7 +1945,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server withCors( - await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease }, policy), + await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy), req, policy, )); diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index c8b5e3cbe1..a742fad98d 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -42,6 +42,8 @@ import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSide import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue"; import { applyCodexAuthContextToProvider, + createCodexReserveDispatchGuard, + unwrapUpstreamRetryEvidenceError, CodexMainProfileDrainingError, headersForCodexAuthContext, materializeCodexUpstreamAuthAsync, @@ -93,6 +95,8 @@ import { import type { DataPlaneAdmission } from "../auth-cors"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers"; +import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models"; +import { isCodexReserveRequestEligible } from "../../codex/loopback-target"; import { slugsEquivalent } from "../../providers/slug-codec"; import { decideTier, tierValueAfterDecision } from "../../providers/fastwire"; import { fastPolicyForModel } from "../../providers/service-tier"; @@ -223,6 +227,8 @@ export function compactResponseTooLargeError(): Response { async function refreshNativeMainCompactContext(args: { req: Request; config: OcxConfig; + modelId?: string; + admission?: DataPlaneAdmission; authCtx: CodexAuthContext; provider: OcxProviderConfig; codexAccountMode?: CodexAccountMode; @@ -256,7 +262,9 @@ async function refreshNativeMainCompactContext(args: { ); const headers = new Headers({ "content-type": "application/json" }); const selected = await materializeCodexUpstreamAuthAsync(req.headers, refreshedAuthCtx, { + admission: args.admission, config, + modelId: args.modelId, substituteMainCredential, signal: req.signal, nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, @@ -294,6 +302,8 @@ function isTerminalCompactPoolRefreshFailure(error: unknown): boolean { async function refreshPoolCompactContext(args: { req: Request; config: OcxConfig; + modelId?: string; + admission?: DataPlaneAdmission; authCtx: CodexAuthContext & { kind: "pool" }; provider: OcxProviderConfig; codexAccountMode?: CodexAccountMode; @@ -336,7 +346,9 @@ async function refreshPoolCompactContext(args: { ); const headers = new Headers({ "content-type": "application/json" }); const selected = await materializeCodexUpstreamAuthAsync(req.headers, refreshedAuthCtx, { + admission: args.admission, config, + modelId: args.modelId, substituteMainCredential, signal: req.signal, nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, @@ -384,11 +396,13 @@ async function resolveAlternateCompactContext(args: { selectedModelId: string | undefined; excludeAccountId: string | null; turnAdmissionLease?: AdmissionLease; + admission?: DataPlaneAdmission; }): Promise<{ authCtx: CodexAuthContext; provider: OcxProviderConfig; headers: Headers } | null> { const { req, config, route, selectedModelId, excludeAccountId, turnAdmissionLease } = args; if (!route.codexAccountMode || !excludeAccountId) return null; try { const authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { + admission: args.admission, ...(selectedModelId ? { modelId: selectedModelId } : {}), excludeAccountId, requestScopedMainCredential: hasForwardableCodexBearer(req.headers, config), @@ -400,7 +414,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, config); + const selected = headersForCodexAuthContext(req.headers, authCtx, config, selectedModelId, args.admission); for (const name of FORWARD_HEADERS) { const value = selected.get(name); if (value) headers.set(name, value); @@ -592,8 +606,11 @@ export async function handleResponsesCompact( // is substituted below instead of the caller bearer being forwarded. // #2132: and only when the route is a native Codex one, which is the only route that can // consume that credential. See the longer note in core.ts resolveResponsesCodexAuth. + const customReserveForward = selectedModelId === NATIVE_RESERVE_MODEL + && isCodexReserveRequestEligible(config, admission) + && isCanonicalOpenAiForwardProvider(route.provider); const substituteMainCredential = admission?.source === "bearer" - && route.codexAccountMode !== undefined; + && (route.codexAccountMode !== undefined || customReserveForward); const requestScopedMainCredential = route.codexAccountMode !== undefined && !substituteMainCredential && hasForwardableCodexBearer(req.headers, config); @@ -641,8 +658,9 @@ export async function handleResponsesCompact( let compactProvider = route.provider; let headers = new Headers({ "content-type": "application/json" }); try { - if (route.codexAccountMode) { - authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { + if (route.codexAccountMode || customReserveForward) { + if (route.codexAccountMode) authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { + admission, accountId: route.codexAccountId, modelId: selectedModelId, substituteMainCredentialForDirect: substituteMainCredential, @@ -653,7 +671,10 @@ export async function handleResponsesCompact( }); logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config); const selected = await materializeCodexUpstreamAuthAsync(req.headers, authCtx, { - config, + admission, + config: isCanonicalOpenAiForwardProvider(route.provider) ? config : undefined, + modelId: selectedModelId, + beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease), substituteMainCredential, signal: req.signal, nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, @@ -770,6 +791,7 @@ export async function handleResponsesCompact( sendProvider: OcxProviderConfig, sendHeaders: Headers, recovery: "normal" | "single", + sendAuthCtx: CodexAuthContext, ): Promise => { const doFetch = (upstreamRecovery?: UpstreamSendRecovery) => fetchWithHeaderTimeout( compactUrl, @@ -784,6 +806,8 @@ export async function handleResponsesCompact( providerFetch(sendProvider, undefined, { providerName: route.providerName, modelId: route.modelId, + beforeDispatch: isCanonicalOpenAiForwardProvider(sendProvider) + ? createCodexReserveDispatchGuard(sendAuthCtx, config, selectedModelId, admission) : undefined, }), // Every credential-bearing forward send gets manual redirects, not only // pool sends: direct mode carries the caller's credential too (#914). @@ -802,17 +826,30 @@ export async function handleResponsesCompact( // The account each outcome belongs to. Reassigned only when the alternate send below // actually happens, so every recorder call names the context that produced it. let outcomeCtx = authCtx; + const localDispatchRefusal = (error: unknown): Response | undefined => { + const response = mapCodexAuthContextErrorToResponse(unwrapUpstreamRetryEvidenceError(error), { + now: Date.now(), accountSelector: route.codexAccountNamespace, + }); + if (response) { + releaseUpstreamHostAdmission(compactHostAdmissionLease); + compactHostAdmissionLease = null; + releaseCodexAuthContextProbeLease(outcomeCtx); + } + return response; + }; let upstream: Response; let storedPool401ReplayAttempted = false; try { // Same connect timeout + keep-alive reset + transient-5xx recovery as /v1/responses — // compact hits the same ChatGPT host and must soft-avoid / clear affinity (#186). - upstream = await sendCompactAttempt(compactProvider, headers, "normal"); + upstream = await sendCompactAttempt(compactProvider, headers, "normal", authCtx); } catch (err) { if (req.signal.aborted) { recordCompactPoolOutcome(outcomeCtx, 499); return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); } + const localRefusal = localDispatchRefusal(err); + if (localRefusal) return localRefusal; const outcome = classifyTransportFailureKind(err); // Host-level evidence stands regardless of pool membership (#914 review). if (outcome === "connect_neutral") { @@ -845,7 +882,9 @@ export async function handleResponsesCompact( const poolReplay = poolAuthCtx ? await refreshPoolCompactContext({ req, + admission, config, + modelId: selectedModelId, authCtx: poolAuthCtx, provider: compactProvider, codexAccountMode: route.codexAccountMode, @@ -856,7 +895,9 @@ export async function handleResponsesCompact( const replay = poolReplay ?? await refreshNativeMainCompactContext({ req, + admission, config, + modelId: selectedModelId, authCtx, provider: compactProvider, codexAccountMode: route.codexAccountMode, @@ -887,12 +928,14 @@ export async function handleResponsesCompact( headers = replay.headers; logCtx.accountLogLabel = codexAuthContextLogLabel(replay.authCtx, config); try { - upstream = await sendCompactAttempt(compactProvider, headers, "single"); + upstream = await sendCompactAttempt(compactProvider, headers, "single", authCtx); } catch (err) { if (req.signal.aborted) { recordCompactPoolOutcome(outcomeCtx, 499); return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); } + const localRefusal = localDispatchRefusal(err); + if (localRefusal) return localRefusal; recordCompactPoolOutcome(outcomeCtx, classifyTransportFailureKind(err)); return formatErrorResponse(502, "upstream_error", "Failed to connect to compact upstream"); } @@ -920,6 +963,7 @@ export async function handleResponsesCompact( // throws, the first rejection is still intact and can be returned to the client. const alternate = await resolveAlternateCompactContext({ req, + admission, config, route, selectedModelId, @@ -958,12 +1002,14 @@ export async function handleResponsesCompact( outcomeCtx = alternate.authCtx; logCtx.accountLogLabel = codexAuthContextLogLabel(alternate.authCtx, config); try { - upstream = await sendCompactAttempt(alternate.provider, alternate.headers, "single"); + upstream = await sendCompactAttempt(alternate.provider, alternate.headers, "single", alternate.authCtx); } catch (err) { if (req.signal.aborted) { recordCompactPoolOutcome(outcomeCtx, 499); return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); } + const localRefusal = localDispatchRefusal(err); + if (localRefusal) return localRefusal; const outcome = classifyTransportFailureKind(err); // Host-level evidence stands regardless of pool membership (#914 review). if (outcome === "connect_neutral") { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index de5d2a2b02..684931c9f9 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -148,6 +148,8 @@ import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenA import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue"; import { applyCodexAuthContextToProvider, + createCodexReserveDispatchGuard, + unwrapUpstreamRetryEvidenceError, codexPoolAffinityKey, CodexAccountCooldownError, CodexAuthContextError, @@ -163,6 +165,7 @@ import { releaseCodexAuthContextProbeLease, stripCodexRuntimeProviderFields, type CodexAuthContext, + type CodexAuthPolicyConfig, } from "../../codex/auth-context"; import { entitledCodexAccountIdsForModel, @@ -206,6 +209,7 @@ import type { DataPlaneAdmission } from "../auth-cors"; import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported } from "../../codex/loopback-target"; import { providerContextCap } from "../../providers/context-cap"; import { fastPolicyForModel, @@ -984,6 +988,9 @@ interface CodexPoolAccountRetryArgs { parsed: OcxParsedRequest; logCtx: RequestLogContext; options: { + admission?: DataPlaneAdmission; + codexAuthPolicy?: CodexAuthPolicyConfig; + visionDescribeTerminal?: boolean; abortSignal?: AbortSignal; onCodexAuthContextResolved?: (ctx: CodexAuthContext) => void; deferCodexResetDerivedCooldown?: boolean; @@ -1180,6 +1187,8 @@ async function retryCodexPoolOnAlternateAccount( "pool", { excludeAccountId: firstAuthCtx.accountId, + admission: options.admission, + codexAuthPolicy: options.codexAuthPolicy, modelId: route.modelId, requestScopedMainCredential: hasForwardableCodexBearer(req.headers, config), beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), @@ -1251,7 +1260,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, config); + const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission); const retryProvider = applyCodexAuthContextToProvider( stripCodexRuntimeProviderFields(route.provider), retryAuthCtx, @@ -1321,6 +1330,8 @@ async function retryCodexPoolOnAlternateAccount( providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(retryAuthCtx, route.provider), + beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) + ? createCodexReserveDispatchGuard(retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, }), // Credential-bearing forward send: never follow a redirect into a // dead-host rejection after the credential was seen (#914). @@ -1499,6 +1510,8 @@ export interface ConsumedComboFailure { export interface HandleResponsesOptions { + /** Original live policy owner; separate from caller-specific routing/sidecar snapshots. */ + codexAuthPolicy?: CodexAuthPolicyConfig; turnAdmissionLease?: AdmissionLease; /** * How the caller proved data-plane admission (#1686). @@ -1877,6 +1890,8 @@ async function resolveResponsesCodexAuth( let authCtx: CodexAuthContext; if (route.codexAccountMode) { authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { + admission: options.admission, + codexAuthPolicy: options.codexAuthPolicy, accountId: route.codexAccountId, modelId: route.modelId, substituteMainCredentialForDirect: substituteMainCredential, @@ -1908,16 +1923,20 @@ async function resolveResponsesCodexAuth( // 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 mainPolicyConfig = isCanonicalOpenAiForwardProvider(route.provider) + ? options.codexAuthPolicy ?? config : undefined; const headers = await materializeCodexUpstreamAuthAsync(req.headers, authCtx, { + admission: options.admission, config: mainPolicyConfig, + modelId: route.modelId, + beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease), 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); + headersForCodexAuthContext(headers, authCtx, mainPolicyConfig, route.modelId, options.admission); if (!isCodexAuthContextUsable(authCtx, config)) { releaseCodexAuthContextProbeLease(authCtx); return { @@ -2018,7 +2037,9 @@ async function refreshPoolForwardAuth(args: { route.codexAccountMode, ); const headers = await materializeCodexUpstreamAuthAsync(req.headers, refreshedAuthCtx, { - config, + admission: options.admission, + config: options.codexAuthPolicy ?? config, + modelId: route.modelId, substituteMainCredential, signal: options.abortSignal, nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, @@ -2077,7 +2098,9 @@ async function refreshNativeMainForwardAuth(args: { route.codexAccountMode, ); const headers = await materializeCodexUpstreamAuthAsync(req.headers, refreshedAuthCtx, { - config, + admission: options.admission, + config: options.codexAuthPolicy ?? config, + modelId: route.modelId, substituteMainCredential, signal: options.abortSignal, nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, @@ -2805,7 +2828,13 @@ export async function handleResponses( const ownsBudget = options.translatorBudget === undefined; const translatorBudget = options.translatorBudget ?? createTranslatorBudget(); try { - const response = await handleResponsesInner(req, config, logCtx, { ...options, translatorBudget }); + const response = await handleResponsesInner(req, config, logCtx, { + ...options, + // Capture before combo replay rebuilds the Request headers; children carry options. + visionDescribeTerminal: options.visionDescribeTerminal === true + || req.headers.get("x-opencodex-vision-describe") === "1", + translatorBudget, + }); return ownsBudget ? finalizeOwnedTranslatorBudget(response, translatorBudget) : response; } catch (error) { if (ownsBudget) translatorBudget.dispose(); @@ -3381,6 +3410,12 @@ async function handleResponsesInner( } if (options.abortSignal?.aborted) return clientCancelledResponse(); + // Resolve aliases/combo children before refusing helpers; do not spend main auth or host budget. + if (isCanonicalOpenAiForwardProvider(route.provider) + && isCodexReserveHelperUnsupported(options.codexAuthPolicy ?? config, route.modelId, + options.admission, options.visionDescribeTerminal === true)) { + return formatErrorResponse(400, "invalid_request_error", CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE); + } // Refuse an input that cannot plausibly fit the model context window before spending auth, // circuit budget, or upstream bandwidth on a turn the provider will reject anyway (#1412). // @@ -3761,6 +3796,8 @@ async function handleResponsesInner( req.headers, config, { + admission: options.admission, + codexAuthPolicy: options.codexAuthPolicy, // Account-qualified native routes are passthrough, so their in-turn helper is vision. // Scope its cooldown and outcome to the helper model, not the routed text model. ...(route.codexAccountId !== undefined @@ -3790,11 +3827,12 @@ async function handleResponsesInner( // call must never plan another describe. The flag arrives from the Chat // surface (whose bridge rebuilds headers) or as the raw header for native // Responses callers. Marked + text-only routed model → strip, depth cap 1. - const visionDescribeTerminal = options.visionDescribeTerminal === true - || req.headers.get("x-opencodex-vision-describe") === "1"; + const visionDescribeTerminal = options.visionDescribeTerminal === true; const visionPlan = visionDescribeTerminal ? undefined - : planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar); + : planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar, { + admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, + }); const recordSidecarOutcome = openAiSidecar?.recordOutcome; if (visionPlan) { await describeImagesInPlace( @@ -4238,6 +4276,15 @@ async function handleResponsesInner( releaseCodexAuthContextProbeLease(authCtx); return clientCancelledResponse(); } + const localRefusal = mapCodexAuthContextErrorToResponse(unwrapUpstreamRetryEvidenceError(err), { + now: Date.now(), accountSelector: route.codexAccountNamespace, + }); + if (localRefusal) { + releaseUpstreamHostAdmission(hostAdmissionLease); + hostAdmissionLease = null; + releaseCodexAuthContextProbeLease(authCtx); + return localRefusal; + } const outcome = classifyTransportFailureKind(err); // Host-level evidence stands regardless of pool membership: a direct // forward send has no pool accounting, but the reachability failure is @@ -4290,6 +4337,8 @@ async function handleResponsesInner( providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), + beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) + ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, }), route.provider.authMode === "forward") // Every real attempt response — including an intermediate 5xx the @@ -4364,6 +4413,8 @@ async function handleResponsesInner( providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), + beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) + ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, }), route.provider.authMode === "forward") .then(response => { @@ -4466,6 +4517,8 @@ async function handleResponsesInner( providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), + beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) + ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, }), codex401ReplayKind === "stored" ? options.onStoredPool401ReplayDispatched : undefined, ), @@ -4573,6 +4626,8 @@ async function handleResponsesInner( providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), + beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) + ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, }), route.provider.authMode === "forward") .then(res => { @@ -4636,6 +4691,8 @@ async function handleResponsesInner( providerName: route.providerName, modelId: route.modelId, onCodexWsQuota: codexWsQuotaObserver(authCtx, route.provider), + beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider) + ? createCodexReserveDispatchGuard(authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined, }), route.provider.authMode === "forward") .then(res => { @@ -5355,7 +5412,9 @@ async function handleResponsesInner( // - runTurn: image bridge may run (it supports runTurn); web-search is skipped so runTurn // can proceed for web-search-only turns const wsPlan = !routedCompaction - ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar) + ? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar, { + admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, + }) : undefined; const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined; const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined; diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index a8caf3b412..e1423d3311 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -59,6 +59,8 @@ export interface ProviderFetchOptions { pacingSlotAcquired?: boolean; /** Captured selected-account observer, attached before the native WS send. */ onCodexWsQuota?: CodexWsQuotaObserver; + /** Synchronous admission at actual credential dispatch, after pacing/backoff. */ + beforeDispatch?: (headers: Headers) => void; } export function providerFetch( @@ -71,8 +73,10 @@ export function providerFetch( base.preconnect?.(...args); }; const httpFetch = Object.assign( - (input: Parameters[0], init?: RequestInit) => - base(input, { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }), + async (input: Parameters[0], init?: RequestInit) => { + options.beforeDispatch?.(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))); + return base(input, { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }); + }, { preconnect }, ) as typeof globalThis.fetch; // ChatGPT Codex backend: streaming turns ride the responses_websockets @@ -85,7 +89,7 @@ export function providerFetch( // used, protocol pin included: a WS turn that falls back is serving the // request over HTTP, and dropping the provider's `upstreamHttpVersion` // there would silently negotiate a transport the operator ruled out. - return codexWsUpstreamFetch(input, init, httpFetch, runtime, options.onCodexWsQuota); + return codexWsUpstreamFetch(input, init, httpFetch, runtime, options.onCodexWsQuota, options.beforeDispatch); } return httpFetch(input, init); }; diff --git a/src/server/responses/ws-upstream.ts b/src/server/responses/ws-upstream.ts index e2625fc7a6..da1ca0a3d9 100644 --- a/src/server/responses/ws-upstream.ts +++ b/src/server/responses/ws-upstream.ts @@ -255,6 +255,7 @@ export function codexWsUpstreamFetch( sseFallback: typeof globalThis.fetch, runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), onQuota?: CodexWsQuotaObserver, + beforeDispatch?: (headers: Headers) => void, ): Promise { const prepared = prepareCodexWsRequest(url, init); if (!prepared) return sseFallback(url, prepareCodexHttpInit(url, init)); @@ -283,6 +284,12 @@ export function codexWsUpstreamFetch( // keys on WS + originator, so callers without the tag simply keep their own // provenance and scheduling.) + // A local refusal is not a failed upgrade and must never enter the SSE fallback path. + try { + beforeDispatch?.(new Headers(headers)); + } catch (error) { + return Promise.reject(error); + } return new Promise((resolve, reject) => { let ws: WebSocket; try { @@ -367,10 +374,25 @@ export function codexWsUpstreamFetch( }; signal?.addEventListener("abort", onAbort, { once: true }); - ws.addEventListener("open", () => { + const onOpen = () => { if (settledPreOpen) return; clearTimeout(upgradeTimer); opened = true; + try { + beforeDispatch?.(new Headers(headers)); + } catch (error) { + // Settle and detach before close: a synchronous close event must not resend over SSE. + settledPreOpen = true; + terminal = true; + cleanup(); + ws.removeEventListener("open", onOpen); + ws.removeEventListener("message", onMessage); + ws.removeEventListener("close", onClose); + ws.removeEventListener("error", onError); + try { ws.close(); } catch { /* already closing */ } + reject(error); + return; + } sent = true; try { ws.send(frameText); @@ -394,9 +416,9 @@ export function codexWsUpstreamFetch( else if (!responseCommitted && !terminal) { preludeTimer = setTimeout(() => failStream("codex websocket response prelude timed out"), CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS); } - }); + }; - ws.addEventListener("message", (event) => { + const onMessage = (event: MessageEvent) => { if (!controller || terminal) return; received = true; const text = typeof event.data === "string" ? event.data : ""; @@ -465,9 +487,9 @@ export function codexWsUpstreamFetch( try { controller.close(); } catch { /* already closed */ } try { ws.close(); } catch { /* already closing */ } } - }); + }; - ws.addEventListener("close", (event: unknown) => { + const onClose = (event: unknown) => { cleanup(); if (!opened) { if (settledPreOpen) return; @@ -479,10 +501,14 @@ export function codexWsUpstreamFetch( return; } if (sent && !terminal) failStream(closedBeforeTerminalMessage(event)); - }); + }; - ws.addEventListener("error", () => { + const onError = () => { /* Bun always follows error with close; the close handler settles. */ - }); + }; + ws.addEventListener("open", onOpen); + ws.addEventListener("message", onMessage); + ws.addEventListener("close", onClose); + ws.addEventListener("error", onError); }); } diff --git a/src/server/search.ts b/src/server/search.ts index 54a74fa8bf..e09d15fc25 100644 --- a/src/server/search.ts +++ b/src/server/search.ts @@ -19,6 +19,9 @@ import { CodexThreadAffinityExpiredError, } from "../codex/auth-context"; import { codexAccountNamespaceForModel } from "../codex/account-namespace-match"; +import { NATIVE_RESERVE_MODEL } from "../codex/catalog/native-models"; +import { isCodexReserveRequestEligible } from "../codex/loopback-target"; +import type { DataPlaneAdmission } from "./auth-cors"; import { formatCodexProviderForLog } from "../codex/routing"; import { signalWithTimeout } from "../lib/abort"; import { readBoundedResponseBytes } from "../lib/bounded-body"; @@ -52,6 +55,7 @@ export async function handleSearch( config: OcxConfig, logCtx: RequestLogContext, turnAdmissionLease?: AdmissionLease, + admission?: DataPlaneAdmission, ): Promise { try { validateForwardAdmissionCredential(req.headers, config); } catch (err) { @@ -93,6 +97,10 @@ export async function handleSearch( } } + if (isCodexReserveRequestEligible(config, admission) && (exactAccount?.modelId ?? model) === NATIVE_RESERVE_MODEL) { + return formatErrorResponse(400, "invalid_request_error", + "Luna Reserve compatibility is only available as a conversation model, not the standalone search relay. Choose another search model."); + } const candidates = listOpenAiForwardSidecarCandidates(config); if (candidates.length === 0) { return formatErrorResponse( @@ -107,6 +115,7 @@ export async function handleSearch( try { upstream = await resolveFirstUsableOpenAiSidecar(candidates, req.headers, config, { exactAccount, + admission, beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease), }); if (!upstream) { diff --git a/src/vision/describe.ts b/src/vision/describe.ts index b919fb738a..83c51afaeb 100644 --- a/src/vision/describe.ts +++ b/src/vision/describe.ts @@ -7,11 +7,14 @@ import { sidecarEnter } from "../lib/sidecar-tracker"; import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-retry"; import { parseSidecarSSE } from "../web-search/parse"; import type { SidecarOutcomeRecorder } from "../web-search/executor"; +import { NATIVE_RESERVE_MODEL } from "../codex/catalog/native-models"; export interface VisionSettings { model: string; reasoning: VisionReasoningEffort; timeoutMs: number; + /** Effective Desktop authless compatibility does not grant auxiliary model use. */ + reserveCompatibility?: boolean; } /** A description, or an `error` string when it couldn't run (caller injects a graceful marker). */ @@ -58,6 +61,9 @@ export async function describeImage( abortSignal?: AbortSignal, recordOutcome?: SidecarOutcomeRecorder, ): Promise { + if (settings.reserveCompatibility && settings.model === NATIVE_RESERVE_MODEL) { + return { text: "", error: "Luna Reserve compatibility is only available as a conversation model, not a vision helper. Choose another vision helper model." }; + } const invalid = validateImageUrl(imageUrl); if (invalid) return { text: "", error: invalid }; diff --git a/src/vision/index.ts b/src/vision/index.ts index 6f1a7392a9..a4f8525cfd 100644 --- a/src/vision/index.ts +++ b/src/vision/index.ts @@ -6,7 +6,9 @@ import { describeImageAnthropic } from "./anthropic-describe"; import { describeImageRouted } from "./routed-describe"; import { isModelVisionSidecarConsumer as isModelTextOnly, modelAcceptsImageInput } from "./eligibility"; import { normalizeVisionReasoningForModel } from "./reasoning"; -import type { CodexAuthContext } from "../codex/auth-context"; +import type { CodexAuthContext, CodexAuthPolicyConfig } from "../codex/auth-context"; +import { isCodexReserveRequestEligible } from "../codex/loopback-target"; +import type { DataPlaneAdmission } from "../server/auth-cors"; import { resolveSidecarAuth } from "../sidecar/auth"; import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; import type { SidecarOutcomeRecorder } from "../web-search/executor"; @@ -295,6 +297,7 @@ export function planVisionSidecar( modelId: string, parsed: OcxParsedRequest, openAiSidecar?: ResolvedOpenAiForwardSidecar, + options: { admission?: Pick; codexAuthPolicy?: CodexAuthPolicyConfig } = {}, ): VisionPlan | undefined { if (!isModelTextOnly(provider, modelId)) return undefined; if (!messagesHaveImage(parsed)) return undefined; @@ -361,6 +364,7 @@ export function planVisionSidecar( backend, forwardSidecar: openAiSidecar, settings: { + ...(isCodexReserveRequestEligible(options.codexAuthPolicy ?? config, options.admission) ? { reserveCompatibility: true } : {}), model, reasoning: normalizeVisionReasoningForModel(model, cfg.reasoning) ?? DEFAULT_REASONING, timeoutMs: resolveVisionTimeoutMs(cfg.timeoutMs), diff --git a/src/web-search/executor.ts b/src/web-search/executor.ts index 840f062fbc..489fa8f399 100644 --- a/src/web-search/executor.ts +++ b/src/web-search/executor.ts @@ -7,11 +7,14 @@ import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream- import { withUpstreamHttpVersion } from "../lib/upstream-http-version"; import { parseSidecarSSE, type WebSearchResult } from "./parse"; import type { CodexUpstreamOutcome } from "../codex/routing"; +import { NATIVE_RESERVE_MODEL } from "../codex/catalog/native-models"; export interface SidecarSettings { model: string; reasoning: string; timeoutMs: number; + /** Effective Desktop authless compatibility does not grant auxiliary model use. */ + reserveCompatibility?: boolean; /** * True when the routed (downstream) model is text-only. The search model CAN see images, so it's * told to verbalize any relevant image results and include their URLs — otherwise a non-vision model @@ -49,6 +52,9 @@ export async function runWebSearch( abortSignal?: AbortSignal, recordOutcome?: SidecarOutcomeRecorder, ): Promise { + if (settings.reserveCompatibility && settings.model === NATIVE_RESERVE_MODEL) { + return { text: "", sources: [], error: "Luna Reserve compatibility is only available as a conversation model, not a search helper. Choose another search helper model." }; + } const headers: Record = { "Content-Type": "application/json" }; if (forwardProvider.headers) Object.assign(headers, forwardProvider.headers); for (const h of FORWARD_HEADERS) { diff --git a/src/web-search/index.ts b/src/web-search/index.ts index 719c22efac..e6bb7e3dbe 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -2,6 +2,9 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; import { modelInList, toolChoiceToolPredicate } from "../types"; import { isModelTextOnly } from "../vision"; import type { SidecarSettings } from "./executor"; +import type { CodexAuthPolicyConfig } from "../codex/auth-context"; +import { isCodexReserveRequestEligible } from "../codex/loopback-target"; +import type { DataPlaneAdmission } from "../server/auth-cors"; import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; import { resolveSidecarAuth } from "../sidecar/auth"; import { getAccountSet } from "../oauth/store"; @@ -215,6 +218,7 @@ export function planWebSearch( provider: OcxProviderConfig, modelId: string, openAiSidecar?: ResolvedOpenAiForwardSidecar, + options: { admission?: Pick; codexAuthPolicy?: CodexAuthPolicyConfig } = {}, ): SidecarPlan | undefined { if (!parsed._webSearch || isPassthrough) return undefined; if (!toolChoiceToolPredicate(parsed.options.toolChoice)(buildWebSearchTool())) return undefined; @@ -322,7 +326,10 @@ export function planWebSearch( backend: "openai", forwardSidecar: openAiSidecar, hostedTool: parsed._webSearch, - settings: { model: cfg.model ?? DEFAULT_SIDECAR_MODEL, reasoning, timeoutMs, describeImages }, + settings: { + model: cfg.model ?? DEFAULT_SIDECAR_MODEL, reasoning, timeoutMs, describeImages, + ...(isCodexReserveRequestEligible(options.codexAuthPolicy ?? config, options.admission) ? { reserveCompatibility: true } : {}), + }, maxSearches, routedModelStallTimeoutMs, stallTimeoutSec, diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 505929a9d4..abde56e457 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -84,6 +84,37 @@ completion markers nor retry delay; quota reads remain available. Main refresh c 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. +Exact `gpt-reserve` has a separate process-local quota scope. Only global/default and shared +ordinary scopes can receive a generic quota-recovery claim; ordinary success cannot clear Reserve. +Effective Desktop authless compatibility adds only configured main-selector Reserve catalog rows, +never global/native/API-key or added-account discovery. Prefer observed Reserve metadata; a +Luna-derived fallback is explicitly marked and never becomes an observed native source on resync. +Loopback injection and catalog eligibility share the pure `loopback-target` predicates. +Runtime eligibility is separate: only trusted receiving-listener admission with source loopback, +the opt-in flag and non-client role activates compatibility. A secondary listener's existence does +not affect public ingress. Admission flows through Responses, compact, WS handshake/turns, +translated replay and helper planning; missing admission is not inferred from a URL or Host header. +Claude's replay keeps its existing sidecar/routing overrides but passes the original live policy +reference separately. Policy flags/role/pause remain current through materialization and dispatch; +the replay snapshot must not hide a policy change while a send waits for pacing. + +Reserve availability belongs to `reserve-availability`, not the catalog. An already-owned main +token/writer makes a capability-aware fixed WHAM GET, bounded to8s/64KiB. Ordinary disallowed, +Luna Reserve banner and exactly one allowed Reserve bucket are all required. Optional account/user +echoes must match. The max60s grant and single-flight are bound privately to the exact credential, +identity generation and a WeakMap-backed proof; refresh, revocation or identity replacement cannot +reuse a spread/copied proof. Passive usage only revokes. Ordinary quota publication uses an injected +callback to the existing validated parser/store; no runtime import of the quota/config facade is +introduced into this leaf. Quota types live in `quota-types` to avoid a cache/facade type cycle. +Final materializers require proof based on the exact model plus transport-scoped live config, +including custom-named canonical-forward routes that synthesize a main context. The injected +transport guard rechecks actual headers after pacing, at every HTTP attempt and WebSocket create; +expiry/revocation fails closed without renewal inside a send. Nested retry evidence preserves local +policy errors instead of recording a network failure. A missing proof does not fall through to +ordinary Luna or another account. Native vision/search helpers and standalone search refuse Reserve +under this compatibility opt-in; ordinary helper/default behavior is unchanged. +Upstream remains the entitlement authority. + `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 diff --git a/tests/claude-integration/claude-sidecar-override.test.ts b/tests/claude-integration/claude-sidecar-override.test.ts index cfdbc46ca0..467ce8eb85 100644 --- a/tests/claude-integration/claude-sidecar-override.test.ts +++ b/tests/claude-integration/claude-sidecar-override.test.ts @@ -1,9 +1,10 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { parseRequest } from "../../src/responses/parser"; import { buildClaudeReplayConfig } from "../../src/server/claude-messages"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { planVisionSidecar } from "../../src/vision"; import { planWebSearch } from "../../src/web-search"; +import * as sidecarAuth from "../../src/sidecar/auth"; const routed: OcxProviderConfig = { adapter: "openai-chat", @@ -124,3 +125,40 @@ test("unset Claude overrides inherit the global sidecar backend and model", () = settings: { model: "global-vision" }, }); }); + +test("live policy eligibility remains separate from Claude helper override snapshots", () => { + const authSpy = spyOn(sidecarAuth, "resolveSidecarAuth").mockReturnValue({ isCodexAuth: true, isAnthropicAuth: false }); + try { + const config: OcxConfig = { + port: 0, defaultProvider: "routed", providers: { routed, forward }, codexDesktopAuthless: false, + webSearchSidecar: { backend: "openai", model: "global-search", timeoutMs: 12_345 }, + visionSidecar: { backend: "openai", model: "global-vision", timeoutMs: 23_456 }, + claudeCode: { + webSearchSidecar: { model: "gpt-reserve" }, + visionSidecar: { model: "gpt-reserve" }, + }, + }; + const replay = buildClaudeReplayConfig(config); + const admission = { source: "loopback" } as const; + const options = { admission, codexAuthPolicy: config }; + config.codexDesktopAuthless = true; + expect(replay.codexDesktopAuthless).toBe(false); + expect(planWebSearch(replay, request, false, routed, "text-model", openAiSidecar, options)).toMatchObject({ + settings: { model: "gpt-reserve", timeoutMs: 12_345, reserveCompatibility: true }, + }); + expect(planVisionSidecar(replay, routed, "text-model", request, openAiSidecar, options)).toMatchObject({ + settings: { model: "gpt-reserve", timeoutMs: 23_456, reserveCompatibility: true }, + }); + expect(planWebSearch(replay, request, false, routed, "text-model", openAiSidecar, { admission })?.settings.reserveCompatibility) + .toBeUndefined(); + config.runtimeRole = "client"; + expect(planWebSearch(replay, request, false, routed, "text-model", openAiSidecar, options)?.settings.reserveCompatibility) + .toBeUndefined(); + expect(planVisionSidecar(replay, routed, "text-model", request, openAiSidecar, options)?.settings.reserveCompatibility) + .toBeUndefined(); + expect(config.webSearchSidecar?.model).toBe("global-search"); + expect(config.visionSidecar?.model).toBe("global-vision"); + expect(replay.webSearchSidecar?.model).toBe("gpt-reserve"); + expect(replay.visionSidecar?.model).toBe("gpt-reserve"); + } finally { authSpy.mockRestore(); } +}); diff --git a/tests/codex-integration/reserve-auth-context.test.ts b/tests/codex-integration/reserve-auth-context.test.ts new file mode 100644 index 0000000000..b66c2377bf --- /dev/null +++ b/tests/codex-integration/reserve-auth-context.test.ts @@ -0,0 +1,346 @@ +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, CodexReserveUnavailableError, + cooldownErrorMessage, cooldownErrorResponse, headersForCodexAuthContext, + materializeCodexUpstreamAuthAsync, resolveCodexAuthContext, shouldMarkAccountNeedsReauthForCodexAuthFailure, + type CodexAuthContext, +} from "../../src/codex/auth-context"; +import { NATIVE_RESERVE_MODEL } from "../../src/codex/catalog/native-models"; +import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; +import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { clearAccountNeedsReauth, isAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth, recordCodexUpstreamOutcome } from "../../src/codex/routing"; +import * as mainAccount from "../../src/codex/main-account"; +import * as authCollision from "../../src/codex/auth-collision"; +import { isMainReserveAuthorizationLive, observeMainReserveRevocation } from "../../src/codex/reserve-availability"; +import { handleResponses } from "../../src/server/responses/core"; +import { handleResponsesCompact } from "../../src/server/responses/compact"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import type { DataPlaneAdmission } from "../../src/server/auth-cors"; +import type { WhamUsageResponse } from "../../src/codex/quota-types"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const MAIN = mainAccount.MAIN_CODEX_ACCOUNT_ID; +const accountId = "reserve-workspace-fixture"; +let home: string; +let oldHome: string | undefined; +let oldCodexHome: string | undefined; +let accessToken: string; +let usage: WhamUsageResponse; +let requests: Request[]; +let duringUsageRead: (() => void) | undefined; + +function token(user = "reserve-user-a"): string { + const payload = Buffer.from(JSON.stringify({ exp: 4_000_000_000, + "https://api.openai.com/auth": { chatgpt_account_id: accountId, chatgpt_user_id: user }, + })).toString("base64url"); + return `header.${payload}.signature`; +} + +function config(): OcxConfig { + return { + port: 0, defaultProvider: "openai", codexDesktopAuthless: true, codexMainAccountHardLock: true, + autoSwitchThreshold: 0, activeCodexAccountId: "unused-pool", codexAccounts: [], + providers: { + openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "pool", + baseUrl: "https://chatgpt.com/backend-api/codex" }, + "custom-native": { adapter: "openai-responses", authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex" }, + independent: { adapter: "openai-responses", authMode: "key", apiKey: "reserve-key-fixture", + baseUrl: "https://independent.example.test/v1" }, + }, + }; +} + +function caller(value = accessToken, workspace = accountId): Headers { + return new Headers({ authorization: `Bearer ${value}`, "chatgpt-account-id": workspace }); +} + +function writeMain(value = accessToken): void { + writeFileSync(join(home, "auth.json"), JSON.stringify({ + tokens: { access_token: value, refresh_token: "reserve-refresh-fixture", account_id: accountId }, + })); + reconcileMainCodexAccountRuntimeState(); + observeMainQuotaCredential(value, accountId); +} + +function quota(percent: number): void { + const writer = captureMainQuotaWriter(accountId); + if (!writer) throw new Error("fixture requires an owned identity"); + setAccountQuotaFromParsed(MAIN, { shortPercent: percent, shortWindowSeconds: 18_000 }, undefined, writer); +} + +const selection = () => ({ mainProfileDraining: false, claimMainProfile: () => true, release() {} }); +const loopbackAdmission = { kind: "loopback", source: "loopback" } as const; +const reserveOptions = { modelId: NATIVE_RESERVE_MODEL, beginCodexAccountSelection: selection, admission: loopbackAdmission }; + +function prohibitPhysicalReads(): void { + const fail = () => { throw new Error("unexpected physical-main credential read"); }; + spyOn(authCollision, "readCodexTokens").mockImplementation(fail); + spyOn(authCollision, "getMainChatgptAccountId").mockImplementation(fail); + spyOn(mainAccount, "getMainAccountToken").mockImplementation(fail); + spyOn(mainAccount, "getValidMainAccountToken").mockImplementation(fail); +} + +beforeEach(() => { + oldHome = process.env.OPENCODEX_HOME; + oldCodexHome = process.env.CODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-reserve-auth-")); + 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(); + clearMainAccountInfoCache(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearAccountNeedsReauth(MAIN); + resetMainCodexAccountIdentityTrackingForTests(); + mainAccount.setMainAccountPlan(null); + accessToken = token(); + writeMain(); + quota(20); + usage = { + account_id: accountId, user_id: "reserve-user-a", + rate_limit: { allowed: false, primary_window: { used_percent: 20, limit_window_seconds: 18_000 } }, + rate_limit_upsell: { banner_type: "luna_reserve" }, + additional_rate_limits: [{ limit_name: NATIVE_RESERVE_MODEL, rate_limit: { allowed: true } }], + }; + requests = []; + duringUsageRead = undefined; + spyOn(globalThis, "fetch").mockImplementation(Object.assign(async ( + input: Parameters[0], init?: Parameters[1], + ) => { + const request = input instanceof Request ? input : new Request(input, init); + requests.push(request); + if (request.url === "https://chatgpt.com/backend-api/wham/usage") { + duringUsageRead?.(); + return Response.json(usage); + } + if (request.url.endsWith("/responses/compact")) { + return Response.json({ id: "cmp_reserve_fixture", object: "response.compaction", output: [] }); + } + if (request.url.endsWith("/responses")) { + return Response.json({ id: "resp_reserve_fixture", object: "response", status: "completed", created_at: 1, + model: NATIVE_RESERVE_MODEL, output: [], usage: { input_tokens: 1, output_tokens: 0, total_tokens: 1 } }); + } + throw new Error("unexpected outbound fixture destination"); + }, { preconnect() {} })); +}); + +afterEach(async () => { + mock.restore(); + clearAccountQuota(); // Cancels this fixture's pending persistence timer before deleting its home. + clearMainAccountInfoCache(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearAccountNeedsReauth(MAIN); + resetMainCodexAccountIdentityTrackingForTests(); + mainAccount.setMainAccountPlan(null); + try { + await flushConfigDirHardeningForTests(); + } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = oldCodexHome; + removeTreeWithRetry(home); + } +}); + +describe("Reserve owned auth admission", () => { + test("unqualified Reserve pins stored main, requires capability WHAM, and carries private proof", async () => { + const cfg = config(); + const ctx = await resolveCodexAuthContext(new Headers(), cfg, "direct", reserveOptions); + expect(ctx).toMatchObject({ kind: "main-pool", accountId: MAIN, fixedAccount: true, quotaScope: "reserve" }); + if (ctx.kind !== "main-pool") throw new Error("expected owned main context"); + expect(isMainReserveAuthorizationLive(ctx.reserveAuthorization, ctx)).toBe(true); + expect(requests).toHaveLength(1); + expect(requests[0]!.headers.get("x-openai-codex-luna-reserve")).toBe("1"); + expect(requests[0]!.headers.get("authorization")).toBe(`Bearer ${accessToken}`); + expect(headersForCodexAuthContext(new Headers(), ctx, cfg, NATIVE_RESERVE_MODEL, loopbackAdmission).get("chatgpt-account-id")).toBe(accountId); + expect(cfg.activeCodexAccountId).toBe("unused-pool"); + }); + + test("explicit non-main selection and unmatched callers cannot manufacture a grant", async () => { + prohibitPhysicalReads(); + await expect(resolveCodexAuthContext(caller(), config(), "pool", { ...reserveOptions, accountId: "other" })) + .rejects.toBeInstanceOf(CodexReserveUnavailableError); + for (const headers of [caller(token("reserve-user-b")), caller(accessToken, "different-workspace")]) { + await expect(resolveCodexAuthContext(headers, config(), "direct", reserveOptions)) + .rejects.toBeInstanceOf(CodexReserveUnavailableError); + } + expect(requests).toHaveLength(0); + }); + + test("matched caller gets proof with no physical reads", async () => { + prohibitPhysicalReads(); + const ctx = await resolveCodexAuthContext(caller(), config(), "pool", { + ...reserveOptions, requestScopedMainCredential: true, + }); + expect(ctx.kind).toBe("main"); + expect(headersForCodexAuthContext(caller(), ctx, config(), NATIVE_RESERVE_MODEL, loopbackAdmission).get("authorization")) + .toBe(`Bearer ${accessToken}`); + expect(requests).toHaveLength(1); + }); + + test("effective-authless off leaves native-client default handling unchanged", async () => { + const cfg = config(); + cfg.runtimeRole = "client"; + await expect(resolveCodexAuthContext(caller("opaque-client"), cfg, "direct", reserveOptions)) + .resolves.toEqual({ kind: "main", accountId: null }); + expect(requests).toHaveLength(0); + }); + + test("a configured secondary listener cannot enable compatibility on public or unattributed ingress", async () => { + const cfg = config(); + cfg.hostname = "0.0.0.0"; + cfg.unauthenticatedLoopbackListener = { enabled: true, port: 10101 }; + const admissions: Array | undefined> = [ + undefined, { source: "dedicated" }, { source: "bearer" }, { source: "x-api-key" }, + ]; + prohibitPhysicalReads(); + for (const admission of admissions) { + const ctx = await resolveCodexAuthContext(caller(), cfg, "direct", { modelId: NATIVE_RESERVE_MODEL, admission }); + expect(ctx).toEqual({ kind: "main", accountId: null }); + const selected = await materializeCodexUpstreamAuthAsync(caller(), ctx, { + config: cfg, modelId: NATIVE_RESERVE_MODEL, admission, + }); + expect(headersForCodexAuthContext(selected, ctx, cfg, NATIVE_RESERVE_MODEL, admission).get("authorization")) + .toBe(`Bearer ${accessToken}`); + } + expect(requests).toHaveLength(0); + }); + + test("retained99 and global cooldown prevent even the permission read, without a probe", async () => { + quota(99); + await expect(resolveCodexAuthContext(new Headers(), config(), "pool", reserveOptions)) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); + expect(requests).toHaveLength(0); + quota(0); + recordCodexUpstreamOutcome(config(), MAIN, 429, { retryAfter: "3600", fixedAccount: true }); + const before = structuredClone(getCodexUpstreamHealth(MAIN)); + await expect(resolveCodexAuthContext(caller(), config(), "direct", reserveOptions)) + .rejects.toBeInstanceOf(CodexAccountCooldownError); + expect(getCodexUpstreamHealth(MAIN)).toEqual(before); + expect(requests).toHaveLength(0); + }); + + test("a granting WHAM response that observes99 still refuses Reserve", async () => { + usage.rate_limit!.primary_window!.used_percent = 99; + await expect(resolveCodexAuthContext(new Headers(), config(), "pool", reserveOptions)) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); + expect(requests).toHaveLength(1); + expect(getMainPolicyQuota()?.shortPercent).toBe(99); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + }); + + test("malformed negative WHAM cannot release99 observed while the permission read was pending", async () => { + usage.rate_limit!.primary_window!.used_percent = -1; + duringUsageRead = () => quota(99); + await expect(resolveCodexAuthContext(caller(), config(), "direct", reserveOptions)) + .rejects.toBeInstanceOf(CodexMainAccountHardLockError); + expect(getMainPolicyQuota()?.shortPercent).toBe(99); + expect(requests).toHaveLength(1); + }); + + test("Reserve cooldown arriving during permission read wins over a positive grant", async () => { + duringUsageRead = () => recordCodexUpstreamOutcome(config(), MAIN, 429, { + modelId: NATIVE_RESERVE_MODEL, resetAt: Date.now() + 3_600_000, fixedAccount: true, + }); + await expect(resolveCodexAuthContext(caller(), config(), "direct", reserveOptions)) + .rejects.toBeInstanceOf(CodexAccountCooldownError); + expect(requests).toHaveLength(1); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + }); + + test("final sync materialization refuses a synthetic or revoked proof", async () => { + const cfg = config(); + expect(() => headersForCodexAuthContext(caller(), { kind: "main", accountId: null }, cfg, NATIVE_RESERVE_MODEL, loopbackAdmission)) + .toThrow(CodexReserveUnavailableError); + const ctx = await resolveCodexAuthContext(caller(), cfg, "direct", reserveOptions); + observeMainReserveRevocation({ rate_limit: { allowed: true } }, captureMainQuotaWriter(accountId)); + expect(() => headersForCodexAuthContext(caller(), ctx, cfg, NATIVE_RESERVE_MODEL, loopbackAdmission)).toThrow(CodexReserveUnavailableError); + }); + + test("refreshed token cannot inherit spread authorization and must obtain its own permission", async () => { + const cfg = config(); + const ctx = await resolveCodexAuthContext(new Headers(), cfg, "pool", reserveOptions); + if (ctx.kind !== "main-pool") throw new Error("expected owned main context"); + const refreshed: CodexAuthContext = { ...ctx, accessToken: token("reserve-user-b") }; + expect(isMainReserveAuthorizationLive(ctx.reserveAuthorization, refreshed)).toBe(false); + expect(() => headersForCodexAuthContext(new Headers(), refreshed, cfg, NATIVE_RESERVE_MODEL, loopbackAdmission)) + .toThrow(CodexReserveUnavailableError); + usage.user_id = "reserve-user-b"; + usage.additional_rate_limits![0]!.rate_limit!.allowed = false; + await expect(materializeCodexUpstreamAuthAsync(new Headers(), refreshed, { + config: cfg, modelId: NATIVE_RESERVE_MODEL, admission: loopbackAdmission, + })) + .rejects.toBeInstanceOf(CodexReserveUnavailableError); + expect(requests).toHaveLength(2); + expect(requests[1]!.headers.get("authorization")).toBe(`Bearer ${refreshed.accessToken}`); + expect(isAccountNeedsReauth(MAIN)).toBe(false); + }); + + test("handler custom Reserve denial sends zero inference while the same caller's keyed model succeeds", async () => { + usage.additional_rate_limits = []; + const cfg = config(); + 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: "" }, { admission: loopbackAdmission }); + const refused = await post("custom-native/gpt-reserve"); + expect(refused.status).toBe(429); + expect(await refused.text()).toContain("Reserve is unavailable"); + expect(requests.map(request => new URL(request.url).pathname)).toEqual(["/backend-api/wham/usage"]); + const keyed = await post("independent/gpt-reserve"); + expect(keyed.status).toBe(200); + await keyed.text(); + expect(requests).toHaveLength(2); + expect(requests[1]!.url).toBe("https://independent.example.test/v1/responses"); + expect(requests[1]!.headers.get("authorization")).toBe("Bearer reserve-key-fixture"); + }); + + test("handler positive custom Reserve proof reaches inference exactly once", async () => { + const result = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { ...Object.fromEntries(caller()), "content-type": "application/json" }, + body: JSON.stringify({ model: "custom-native/gpt-reserve", input: "ping", stream: false }), + }), config(), { model: "", provider: "" }, { admission: loopbackAdmission }); + expect(result.status).toBe(200); + await result.text(); + expect(requests.map(request => new URL(request.url).pathname)) + .toEqual(["/backend-api/wham/usage", "/backend-api/codex/responses"]); + expect(requests[1]!.headers.get("authorization")).toBe(`Bearer ${accessToken}`); + }); + + test("custom canonical compact cannot skip permission because its context has no marker", async () => { + usage.additional_rate_limits = []; + const result = await handleResponsesCompact(new Request("http://localhost/v1/responses/compact", { + method: "POST", headers: { ...Object.fromEntries(caller()), "content-type": "application/json" }, + body: JSON.stringify({ model: "custom-native/gpt-reserve", input: [{ role: "user", content: "ping" }] }), + }), config(), { model: "", provider: "" }, undefined, loopbackAdmission); + expect(result.status).toBe(429); + await result.text(); + expect(requests.map(request => new URL(request.url).pathname)).toEqual(["/backend-api/wham/usage"]); + }); + + test("Reserve errors preserve cooldown-family HTTP formatting without fake reset or reauth", () => { + const error = new CodexReserveUnavailableError(); + expect(error).toBeInstanceOf(CodexAccountCooldownError); + expect(cooldownErrorMessage(error)).not.toContain("clear-cooldown"); + expect(cooldownErrorResponse(error).status).toBe(429); + expect(cooldownErrorResponse(error).headers.has("retry-after")).toBe(false); + expect(shouldMarkAccountNeedsReauthForCodexAuthFailure(error)).toBe(false); + expect(cooldownErrorMessage(new CodexAccountCooldownError(MAIN, Date.now() + 60_000, undefined, "reserve"))) + .toContain("Reserve quota"); + }); +}); diff --git a/tests/codex-integration/reserve-availability.test.ts b/tests/codex-integration/reserve-availability.test.ts new file mode 100644 index 0000000000..d5298ea2f7 --- /dev/null +++ b/tests/codex-integration/reserve-availability.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { + clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity, +} from "../../src/codex/main-account-cache"; +import { + getMainReserveAuthorization, isMainReserveAuthorizationLive, observeMainReserveRevocation, +} from "../../src/codex/reserve-availability"; +import type { WhamUsageResponse } from "../../src/codex/quota-types"; + +let originalFetch: typeof fetch; +let serial = 0; +function owned(user = "fixture-user-a", account = "fixture-reserve-main") { + const accessToken = `fixture.${Buffer.from(JSON.stringify({ nonce: ++serial, + "https://api.openai.com/auth": { chatgpt_user_id: user, chatgpt_account_id: account }, + })).toString("base64url")}.signature`; + observeMainQuotaIdentity(account); + const writer = observeMainQuotaCredential(accessToken, account); + if (!writer) throw new Error("Expected fixture-owned writer"); + return { token: { accessToken, chatgptAccountId: account }, writer }; +} +function grant(): WhamUsageResponse { + return { + rate_limit: { allowed: false, primary_window: { used_percent: 100, limit_window_seconds: 18_000 } }, + rate_limit_upsell: { banner_type: "luna_reserve" }, + additional_rate_limits: [{ limit_name: "gpt-reserve", rate_limit: { allowed: true } }], + }; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +function serve(handler: (init?: RequestInit) => Promise) { + let calls = 0; + globalThis.fetch = Object.assign(async (url: Parameters[0], init?: RequestInit) => { + expect(String(url)).toBe("https://chatgpt.com/backend-api/wham/usage"); + calls++; + return handler(init); + }, { preconnect: originalFetch.preconnect }); + return () => calls; +} +beforeEach(() => { originalFetch = globalThis.fetch; clearMainAccountInfoCache(); }); +afterEach(() => { globalThis.fetch = originalFetch; clearMainAccountInfoCache(); }); + +describe("owned main Reserve capability", () => { + test("requests capability with exact owned credentials and keeps proof private/credential-bound", async () => { + const input = owned(); + const data = grant(); + let observed = 0; + const calls = serve(async init => { + const headers = new Headers(init?.headers); + expect(init?.method).toBe("GET"); + expect(init?.redirect).toBe("error"); + expect(headers.get("authorization")).toBe(`Bearer ${input.token.accessToken}`); + expect(headers.get("chatgpt-account-id")).toBe(input.token.chatgptAccountId); + expect(headers.get("x-openai-codex-luna-reserve")).toBe("1"); + return Response.json(data); + }); + const observeOrdinaryQuota = (usage: WhamUsageResponse, writer: typeof input.writer) => { + observed++; expect(usage).toEqual(data); expect(writer).toEqual(input.writer); + }; + const authorization = await getMainReserveAuthorization({ ...input, observeOrdinaryQuota }); + expect(authorization).toBeDefined(); + expect(isMainReserveAuthorizationLive(authorization, input.token)).toBe(true); + expect(isMainReserveAuthorizationLive({ ...authorization! }, input.token)).toBe(false); + expect(Object.keys(authorization!).sort()).toEqual(["expiresAt", "observedAt", "writer"]); + expect(JSON.stringify(authorization)).not.toContain(input.token.accessToken); + expect(JSON.stringify(authorization)).not.toContain("fixture-user"); + expect(authorization!.expiresAt - authorization!.observedAt).toBe(60_000); + observeMainQuotaCredential(input.token.accessToken, input.token.chatgptAccountId); + expect(await getMainReserveAuthorization({ ...input, observeOrdinaryQuota })).toBe(authorization); + expect(calls()).toBe(1); expect(observed).toBe(1); + expect(isMainReserveAuthorizationLive(authorization, input.token, authorization!.expiresAt)).toBe(false); + expect(isMainReserveAuthorizationLive(authorization, input.token, authorization!.observedAt - 1)).toBe(false); + }); + + test.each(["unowned", "wrong bearer", "wrong account", "aborted"])("%s makes no metadata read", async kind => { + const input = owned(); + const controller = new AbortController(); + if (kind === "aborted") controller.abort(); + const calls = serve(async () => Response.json(grant())); + const token = { ...input.token }; + if (kind === "wrong bearer") token.accessToken = "fixture-unmatched"; + if (kind === "wrong account") token.chatgptAccountId = "fixture-other"; + const result = await getMainReserveAuthorization({ token, writer: kind === "unowned" ? undefined : input.writer, + signal: controller.signal, observeOrdinaryQuota: () => { throw new Error("must not observe"); } }); + expect(result).toBeUndefined(); expect(calls()).toBe(0); + }); + + test.each(["missing normal", "ordinary allowed", "string allowed", "missing banner", "missing reserve", + "reserve denied", "duplicate", "bad additional", "wrong account", "wrong user"])("%s is not permission", async kind => { + const input = owned(); + const data = grant(); + if (kind === "missing normal") delete data.rate_limit; + if (kind === "ordinary allowed") data.rate_limit!.allowed = true; + if (kind === "string allowed") data.additional_rate_limits![0]!.rate_limit!.allowed = "true"; + if (kind === "missing banner") delete data.rate_limit_upsell; + if (kind === "missing reserve") delete data.additional_rate_limits; + if (kind === "reserve denied") data.additional_rate_limits![0]!.rate_limit!.allowed = false; + if (kind === "duplicate") data.additional_rate_limits!.push({ ...data.additional_rate_limits![0] }); + if (kind === "bad additional") Reflect.set(data, "additional_rate_limits", "not an array"); + if (kind === "wrong account") data.account_id = "fixture-other"; + if (kind === "wrong user") data.user_id = "fixture-user-b"; + let observed = 0; + serve(async () => Response.json(data)); + expect(await getMainReserveAuthorization({ ...input, observeOrdinaryQuota: () => { observed++; } })).toBeUndefined(); + if (["wrong account", "wrong user", "bad additional"].includes(kind)) expect(observed).toBe(0); + }); + + test("matching optional identity echoes are accepted, including user_id token claim fallback", async () => { + const input = owned(); + input.token.accessToken = `fixture.${Buffer.from(JSON.stringify({ + "https://api.openai.com/auth": { user_id: "fixture-user-a" }, + })).toString("base64url")}.signature`; + observeMainQuotaCredential(input.token.accessToken, input.token.chatgptAccountId); + serve(async () => Response.json({ ...grant(), account_id: input.token.chatgptAccountId, user_id: "fixture-user-a" })); + expect(await getMainReserveAuthorization({ ...input, observeOrdinaryQuota: () => {} })).toBeDefined(); + }); + + test("concurrent callers share one bounded read; one caller abort does not cancel another", async () => { + const input = owned(); + const response = deferred(); + let observed = 0; + const calls = serve(async () => response.promise); + const controller = new AbortController(); + const common = { ...input, observeOrdinaryQuota: () => { observed++; } }; + const first = getMainReserveAuthorization({ ...common, signal: controller.signal }); + const second = getMainReserveAuthorization(common); + controller.abort(); + expect(await first).toBeUndefined(); + response.resolve(Response.json(grant())); + expect(await second).toBeDefined(); + expect(calls()).toBe(1); expect(observed).toBe(1); + }); + + test("new token/user in the same workspace cannot reuse or publish the previous flight", async () => { + const firstInput = owned(); + const response = deferred(); + let oldObserved = 0; + const calls = serve(async () => calls() === 1 ? response.promise : Response.json(grant())); + const first = getMainReserveAuthorization({ ...firstInput, observeOrdinaryQuota: () => { oldObserved++; } }); + const nextInput = owned("fixture-user-b"); + expect(nextInput.writer).toEqual(firstInput.writer); + const next = await getMainReserveAuthorization({ ...nextInput, observeOrdinaryQuota: () => {} }); + expect(next).toBeDefined(); + expect(await first).toBeUndefined(); + response.resolve(Response.json(grant())); + await Promise.resolve(); await Promise.resolve(); + expect(oldObserved).toBe(0); + expect(isMainReserveAuthorizationLive(next, firstInput.token)).toBe(false); + expect(isMainReserveAuthorizationLive(next, nextInput.token)).toBe(true); + expect(calls()).toBe(2); + }); + + test.each(["pending", "cached"])("%s A proof cannot resurrect after A→B→A without a B request", async phase => { + const input = owned(); + const response = deferred(); + let observed = 0; + const calls = serve(async () => phase === "pending" && calls() === 1 ? response.promise : Response.json(grant())); + const args = { ...input, observeOrdinaryQuota: () => { observed++; } }; + const pending = getMainReserveAuthorization(args); + const cached = phase === "cached" ? await pending : undefined; + owned("fixture-user-b"); + observeMainQuotaCredential(input.token.accessToken, input.token.chatgptAccountId); + expect(isMainReserveAuthorizationLive(cached, input.token)).toBe(false); + response.resolve(Response.json(grant())); + if (phase === "pending") { expect(await pending).toBeUndefined(); expect(observed).toBe(0); } + expect(await getMainReserveAuthorization(args)).toBeDefined(); + expect(calls()).toBe(2); + }); + + test("refresh token replacement cannot reuse an old spread proof or cache", async () => { + const first = owned(); + const calls = serve(async () => Response.json(grant())); + const old = await getMainReserveAuthorization({ ...first, observeOrdinaryQuota: () => {} }); + const refreshed = owned(); + expect(isMainReserveAuthorizationLive(old, refreshed.token)).toBe(false); + expect(isMainReserveAuthorizationLive({ ...old! }, refreshed.token)).toBe(false); + expect(await getMainReserveAuthorization({ ...refreshed, observeOrdinaryQuota: () => {} })).toBeDefined(); + expect(calls()).toBe(2); + }); + + test.each(["ordinary", "reserve"])("new passive %s refusal/recovery revokes without granting", async kind => { + const input = owned(); + serve(async () => Response.json(grant())); + const authorization = await getMainReserveAuthorization({ ...input, observeOrdinaryQuota: () => {} }); + observeMainReserveRevocation({ plan_type: "plus" }, input.writer); + expect(isMainReserveAuthorizationLive(authorization, input.token)).toBe(true); + observeMainReserveRevocation(kind === "ordinary" ? { rate_limit: { allowed: true } } + : { additional_rate_limits: [{ limit_name: "gpt-reserve", rate_limit: { allowed: false } }] }, input.writer); + expect(isMainReserveAuthorizationLive(authorization, input.token)).toBe(false); + observeMainReserveRevocation(grant(), input.writer); + expect(isMainReserveAuthorizationLive(authorization, input.token)).toBe(false); + }); + + test("revocation and identity replacement fence a pending response before ordinary publication", async () => { + for (const change of ["revoke", "identity"] as const) { + const input = owned(); + const response = deferred(); + let observed = 0; + serve(async () => response.promise); + const pending = getMainReserveAuthorization({ ...input, observeOrdinaryQuota: () => { observed++; } }); + if (change === "revoke") observeMainReserveRevocation({ rate_limit: { allowed: true } }, input.writer); + else observeMainQuotaIdentity("fixture-replacement"); + response.resolve(Response.json(grant())); + expect(await pending).toBeUndefined(); expect(observed).toBe(0); + } + }); + + test.each(["status", "network", "json", "oversized", "utf8"])("%s response fails closed", async kind => { + const input = owned(); + let observed = 0; + serve(async () => { + if (kind === "network") throw new Error("fixture transport failure"); + if (kind === "status") return new Response(null, { status: 401 }); + if (kind === "json") return new Response("not json"); + if (kind === "utf8") return new Response(new Uint8Array([0xff])); + return Response.json({ ...grant(), ignored: "x".repeat(65_536) }); + }); + expect(await getMainReserveAuthorization({ ...input, observeOrdinaryQuota: () => { observed++; } })).toBeUndefined(); + expect(observed).toBe(0); + }); + + test("whole-read deadline fences even an uncooperative fetch and its late body", async () => { + const input = owned(); + const response = deferred(); + let observed = 0; + const realTimeout = globalThis.setTimeout; + const timer = spyOn(globalThis, "setTimeout").mockImplementation(((...args: Parameters) => { + const [callback, ms, ...rest] = args; + return realTimeout(callback, ms === 8_000 ? 5 : ms, ...rest); + }) as typeof setTimeout); + serve(async () => response.promise); + try { + expect(await getMainReserveAuthorization({ ...input, observeOrdinaryQuota: () => { observed++; } })).toBeUndefined(); + response.resolve(Response.json(grant())); + await Promise.resolve(); await Promise.resolve(); + expect(observed).toBe(0); + } finally { timer.mockRestore(); response.resolve(Response.json(grant())); } + }); +}); diff --git a/tests/codex-integration/reserve-catalog-lifecycle.test.ts b/tests/codex-integration/reserve-catalog-lifecycle.test.ts new file mode 100644 index 0000000000..2a3e8d6e15 --- /dev/null +++ b/tests/codex-integration/reserve-catalog-lifecycle.test.ts @@ -0,0 +1,257 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { RawCatalog, RawEntry } from "../../src/codex/catalog/parsing"; +import { claimOwnedServiceHome, withOwnedServiceHomePreload } from "../helpers/owned-service-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath, repoRoot } from "../helpers/repo-root"; +import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; + +const roots: string[] = []; +const SOURCE = "opencodex_reserve_source"; +const MARKER = "opencodex_reserve_metadata_source"; +const SELECTOR = "personal/gpt-reserve"; + +interface Sandbox { + root: string; + catalogPath: string; + cachePath: string; + bundledPath: string; + env: Record; + preloadPath?: string; +} + +function nativeRow(slug = "gpt-5.5"): RawEntry { + return { + slug, display_name: "Fixture native", description: "Fixture", + priority: 9, visibility: "list", supported_in_api: true, + shell_type: "unified_exec", comp_hash: "fixture-comp-hash", + base_instructions: "Fixture instructions.", + model_messages: { instructions_template: "Fixture instructions." }, + supported_reasoning_levels: [{ effort: "medium", description: "Runtime medium" }], + default_reasoning_level: "medium", + }; +} + +function reserveRow(qualified: boolean, efforts = ["high", "xhigh"]): RawEntry { + const pin = JSON.parse(readFileSync(repoPath("src/codex/data/upstream-models.json"), "utf8")) as RawCatalog; + const luna = pin.models?.find(row => row.slug === "gpt-5.6-luna"); + if (!luna) throw new Error("Fixture requires the checked-in Luna source"); + return { + ...structuredClone(luna), + slug: qualified ? SELECTOR : "gpt-reserve", + display_name: qualified ? "personal / Genuine Reserve" : "Genuine Reserve", + supported_in_api: qualified, + visibility: qualified ? "list" : "hide", + multi_agent_version: "disabled", + comp_hash: "genuine-reserve-comp-hash", + supported_reasoning_levels: efforts.map(effort => ({ effort, description: `Genuine ${effort}` })), + default_reasoning_level: efforts.at(-1), + ...(qualified ? { opencodex_catalog_kind: "account-selector-v1", [MARKER]: "gpt-reserve" } : {}), + }; +} + +function writeRuntime(sandbox: Sandbox, efforts: string[]): void { + writeFileSync(sandbox.bundledPath, JSON.stringify({ models: [{ + ...nativeRow(), + supported_reasoning_levels: efforts.map(effort => ({ effort, description: `Runtime ${effort}` })), + default_reasoning_level: efforts[0], + }] })); +} + +function makeSandbox(models: RawEntry[] = [nativeRow()], rootFields: RawEntry = {}): Sandbox { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-reserve-lifecycle-"))); + roots.push(root); + const home = join(root, "home"); + const codexHome = join(root, "codex-home"); + const ocxHome = join(root, "ocx-home"); + const runtime = join(root, "runtime"); + for (const path of [home, codexHome, ocxHome, runtime]) mkdirSync(path, { recursive: true, mode: 0o700 }); + const owned = claimOwnedServiceHome(codexHome, ocxHome, home); + const bundledPath = join(root, "bundled-models.json"); + const runtimeScript = join(root, "codex-fixture.mjs"); + writeFileSync(runtimeScript, [ + 'import { readFileSync } from "node:fs";', + 'if (process.argv.includes("--version")) console.log("codex-cli 0.999.0");', + `else process.stdout.write(readFileSync(${JSON.stringify(bundledPath)}, "utf8"));`, + ].join("\n")); + const command = join(root, process.platform === "win32" ? "codex-fixture.cmd" : "codex-fixture"); + writeFileSync(command, process.platform === "win32" + ? `@echo off\r\n"${process.execPath}" "${runtimeScript}" %*\r\n` + : `#!/bin/sh\nexec "${process.execPath}" "${runtimeScript}" "$@"\n`); + if (process.platform !== "win32") chmodSync(command, 0o700); + const catalogPath = join(codexHome, "opencodex-catalog.json"); + writeFileSync(catalogPath, JSON.stringify({ ...rootFields, models })); + writeFileSync(join(codexHome, "config.toml"), 'cli_auth_credentials_store = "file"\n[features]\nmulti_agent_v2 = true\n'); + writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ + port: 10100, hostname: "127.0.0.1", defaultProvider: "external", + codexDesktopAuthless: true, codexAccountPickerEnabled: true, + codexAccountNamespaces: { personal: "@main" }, + multiAgentMode: "default", + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", liveModels: false }, + external: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, models: ["model"] }, + }, + })); + const sandbox: Sandbox = { + root, catalogPath, cachePath: join(codexHome, "models_cache.json"), bundledPath, + preloadPath: owned.preloadPath, + env: { + ...Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)), + ...owned.env, + CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, CODEX_CLI_PATH: command, + HOME: home, USERPROFILE: home, XDG_RUNTIME_DIR: runtime, + TMPDIR: runtime, TEMP: runtime, TMP: runtime, LOCALAPPDATA: join(home, "LocalAppData"), + BUN_OPTIONS: "", OPENAI_API_KEY: "", CODEX_ACCESS_TOKEN: "", + }, + }; + writeRuntime(sandbox, ["medium"]); + return sandbox; +} + +function sync(sandbox: Sandbox): RawCatalog { + const script = ` + const { readFileSync } = await import("node:fs"); + globalThis.fetch = async () => { throw new Error("Unexpected network access in Reserve catalog lifecycle"); }; + const { loadConfig } = await import("./src/config.ts"); + const { refreshCodexModelCatalog } = await import("./src/codex/refresh.ts"); + const { loadBundledCodexCatalog } = await import("./src/codex/catalog/bundled.ts"); + const bundled = loadBundledCodexCatalog(); + const expectedBundle = JSON.parse(readFileSync(${JSON.stringify(sandbox.bundledPath)}, "utf8")); + if (JSON.stringify(bundled) !== JSON.stringify(expectedBundle)) throw new Error("Expected the isolated runtime catalog"); + if (bundled?.models?.some(row => row.slug === "gpt-reserve")) throw new Error("Fixture must not seed bundled Reserve"); + const config = loadConfig(); + for (const provider of Object.values(config.providers)) provider.fetch = globalThis.fetch; + const result = await refreshCodexModelCatalog(config, undefined, { allowWhenDesiredDisabled: true }); + if (!result.catalogExists || !result.cacheSynced) throw new Error(JSON.stringify(result)); + console.log("RESERVE_CATALOG_LIFECYCLE_OK"); + `; + const child = spawnSync(process.execPath, withOwnedServiceHomePreload(["--eval", script], sandbox.preloadPath), { + cwd: repoRoot(), env: sandbox.env, encoding: "utf8", timeout: 30_000, + }); + expect({ status: child.status, error: child.error?.message, stderr: child.stderr }).toMatchObject({ status: 0, error: undefined }); + expect(child.stdout).toContain("RESERVE_CATALOG_LIFECYCLE_OK"); + return JSON.parse(readFileSync(sandbox.catalogPath, "utf8")) as RawCatalog; +} + +function selected(catalog: RawCatalog): RawEntry | undefined { + return catalog.models?.find(row => row.slug === SELECTOR); +} + +function retained(catalog: RawCatalog): RawEntry { + return catalog[SOURCE] as RawEntry; +} + +afterEach(() => { + const identity = resolveEffectiveUserIdentity(); + for (const root of roots.splice(0)) { + const database = resolveCodexCatalogSerializationDatabasePath(identity, join(root, "codex-home")); + for (const suffix of ["", "-journal", "-wal", "-shm"]) rmSync(`${database}${suffix}`, { force: true }); + removeTreeWithRetry(root); + } +}); + +describe("Reserve actual catalog finalization lifecycle", () => { + test("default Luna v1 survives the actual write and repeated cache invalidation", () => { + const sandbox = makeSandbox(); + const first = sync(sandbox); + expect(selected(first)).toMatchObject({ multi_agent_version: "v1", [MARKER]: "gpt-5.6-luna" }); + expect(first[SOURCE]).toBeUndefined(); + expect(first.models?.some(row => row.slug === "external/model")).toBe(true); + const second = sync(sandbox); + expect(selected(second)).toEqual(selected(first)); + }, 70_000); + + test("genuine bare active on-disk metadata wins over a bundled-only build base", () => { + const sandbox = makeSandbox([nativeRow(), reserveRow(false, ["medium"])]); + const result = sync(sandbox); + expect(selected(result)).toMatchObject({ multi_agent_version: "disabled", [MARKER]: "gpt-reserve", comp_hash: "genuine-reserve-comp-hash" }); + expect(retained(result)).toMatchObject({ slug: "gpt-reserve", multi_agent_version: "disabled" }); + expect(retained(result)[MARKER]).toBeUndefined(); + }, 40_000); + + test("historical cached A cannot replace fresh active B on the following sync", () => { + const cachedA = { + ...reserveRow(false, ["medium"]), + display_name: "Historical A", + comp_hash: "historical-a-hash", + opencodex_account_observed_native: true, + opencodex_account_observed_selectors: ["personal"], + }; + const activeB = { + ...reserveRow(false, ["high"]), + display_name: "Fresh B", + comp_hash: "fresh-b-hash", + }; + const sandbox = makeSandbox([nativeRow(), activeB]); + writeRuntime(sandbox, ["medium", "high"]); + writeFileSync(sandbox.cachePath, JSON.stringify({ models: [cachedA] })); + + const first = sync(sandbox); + expect(selected(first)).toMatchObject({ + display_name: "personal / Fresh B", comp_hash: "fresh-b-hash", + supported_reasoning_levels: [{ effort: "high", description: "Genuine high" }], + }); + expect(retained(first)).toMatchObject({ display_name: "Fresh B", comp_hash: "fresh-b-hash" }); + const cacheAfterFirst = JSON.parse(readFileSync(sandbox.cachePath, "utf8")) as RawCatalog; + // Prove the obsolete carried observation actually survives cache invalidation and + // competes with retained B on the next real CLI-process sync. + expect(cacheAfterFirst.models?.find(row => row.slug === "gpt-reserve")).toMatchObject({ + comp_hash: "historical-a-hash", opencodex_account_observed_native: true, + }); + expect(first.models?.some(row => row.slug === "gpt-reserve")).toBe(false); + + const second = sync(sandbox); + expect(retained(second)).toEqual(retained(first)); + expect(selected(second)).toEqual(selected(first)); + expect(second.models?.some(row => row.slug === "external/model")).toBe(true); + }, 70_000); + + test("qualified-only source survives omission, cache invalidation and effort recovery without Luna fallback", () => { + const sandbox = makeSandbox([nativeRow(), reserveRow(true)]); + const first = sync(sandbox); + expect(selected(first)).toBeUndefined(); + expect(retained(first)).toMatchObject({ + slug: "gpt-reserve", display_name: "Genuine Reserve", multi_agent_version: "disabled", + supported_reasoning_levels: [ + { effort: "high", description: "Genuine high" }, { effort: "xhigh", description: "Genuine xhigh" }, + ], + }); + expect(retained(first)[MARKER]).toBeUndefined(); + expect(retained(first).opencodex_catalog_kind).toBeUndefined(); + const cache = JSON.parse(readFileSync(sandbox.cachePath, "utf8")) as RawCatalog; + expect(cache.models?.some(row => row.slug === SELECTOR || row.slug === "gpt-reserve")).toBe(false); + const second = sync(sandbox); + expect(selected(second)).toBeUndefined(); + expect(retained(second)).toEqual(retained(first)); + + writeRuntime(sandbox, ["high"]); + const partial = sync(sandbox); + expect(selected(partial)).toMatchObject({ + multi_agent_version: "disabled", [MARKER]: "gpt-reserve", default_reasoning_level: "high", + supported_reasoning_levels: [{ effort: "high", description: "Genuine high" }], + }); + expect(retained(partial)).toEqual(retained(first)); + + writeRuntime(sandbox, ["high", "xhigh"]); + const restored = sync(sandbox); + expect(selected(restored)).toMatchObject({ default_reasoning_level: "xhigh", supported_reasoning_levels: retained(first).supported_reasoning_levels }); + const replacement = reserveRow(false, ["low"]); + replacement.display_name = "Fresh source"; + writeFileSync(sandbox.catalogPath, JSON.stringify({ ...restored, models: [...restored.models!, replacement] })); + writeRuntime(sandbox, ["low"]); + const refreshed = sync(sandbox); + expect(selected(refreshed)).toMatchObject({ display_name: "personal / Fresh source", default_reasoning_level: "low" }); + expect(retained(refreshed).supported_reasoning_levels).toEqual([{ effort: "low", description: "Genuine low" }]); + }, 170_000); + + test("a retained adaptation is rejected rather than promoted to genuine source", () => { + const adapted = { ...reserveRow(false, ["medium"]), [MARKER]: "gpt-5.6-luna" }; + const sandbox = makeSandbox([nativeRow()], { [SOURCE]: adapted }); + const result = sync(sandbox); + expect(selected(result)).toMatchObject({ multi_agent_version: "v1", [MARKER]: "gpt-5.6-luna" }); + expect(result[SOURCE]).toBeUndefined(); + }, 40_000); +}); diff --git a/tests/codex-integration/reserve-catalog.test.ts b/tests/codex-integration/reserve-catalog.test.ts new file mode 100644 index 0000000000..5abd28744d --- /dev/null +++ b/tests/codex-integration/reserve-catalog.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, test } from "bun:test"; +import type { OcxConfig } from "../../src/types"; +import { isLoopbackHostname as isServerLoopbackHostname } from "../../src/server/auth-cors"; +import { + isEffectiveCodexDesktopAuthless, + isLoopbackHostname, + shouldInjectApiAuthHeader, +} from "../../src/codex/loopback-target"; +import { NATIVE_RESERVE_MODEL } from "../../src/codex/catalog/native-models"; +import { + accountBoundNativeOpenAiSlugs, + accountBoundNativeOpenAiSlugsBySelector, + observedAccountBoundNativeEntries, + observedReserveCatalogSource, + upstreamNativeEntry, +} from "../../src/codex/catalog/metadata"; +import { + buildCatalogEntriesFromObservedState, + finishUpstreamNativeEntry, + mergeCatalogEntriesFromObservedState, + type ObservedCatalogEntryBuildInput, + type ObservedCatalogMergeInput, +} from "../../src/codex/catalog/sync"; +import { + createReserveCatalogProjection, + isReserveCatalogProjection, + RESERVE_METADATA_SOURCE_FIELD, + RESERVE_LUNA_METADATA_SOURCE, +} from "../../src/codex/catalog/reserve"; +import { findSupportedNativeTemplate, type RawEntry } from "../../src/codex/catalog/parsing"; +import { clampCatalogModelsToObservedCodexSupport } from "../../src/codex/catalog/effort"; + +function config(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + providers: {}, + defaultProvider: "openai", + codexDesktopAuthless: true, + codexAccountPickerEnabled: true, + codexAccountNamespaces: { personal: "@main", second: "pool-account" }, + codexAccounts: [{ id: "pool-account", alias: "Second", addedAt: 0 }], + ...overrides, + } as OcxConfig; +} + +function luna(): RawEntry { + return finishUpstreamNativeEntry(upstreamNativeEntry(RESERVE_LUNA_METADATA_SOURCE)!, 9); +} + +function actualReserve(overrides: RawEntry = {}): RawEntry { + return { + ...luna(), + slug: NATIVE_RESERVE_MODEL, + display_name: "Observed Reserve", + supported_in_api: false, + visibility: "hide", + supported_reasoning_levels: [{ effort: "medium", description: "Observed effort" }], + default_reasoning_level: "medium", + comp_hash: null, + available_in_plans: ["reserve"], + upgrade: { model: "do-not-inherit" }, + availability_nux: { message: "do-not-inherit" }, + ...overrides, + }; +} + +function build( + state: OcxConfig = config(), + observations: RawEntry[] = [], + overrides: Partial = {}, +): RawEntry[] { + const mainSelectors = ["personal"]; + return buildCatalogEntriesFromObservedState({ + template: null, + gptSlugs: [], + goModels: [{ provider: "external", id: "model", owned_by: "external" }], + wsEnabled: false, + multiAgentMode: "default", + exactComboSlugs: new Set(), + accountSelectors: ["personal", "second"], + accountNativeSlugsBySelector: new Map([["personal", []], ["second", []]]), + suppressedBareNativeSlugs: new Set(), + disabledNativeAccountSlugs: new Set(), + multiAgentV2Enabled: false, + reserve: createReserveCatalogProjection( + state, + mainSelectors, + observedReserveCatalogSource(observations, mainSelectors), + luna(), + ), + ...overrides, + }); +} + +function merge(rows: RawEntry[], overrides: Partial = {}): RawEntry[] { + return mergeCatalogEntriesFromObservedState({ + catalogModels: [], + baselineCatalogModels: [], + routedEntries: rows.filter(row => !isReserveCatalogProjection(row)), + accountBoundEntries: rows.filter(isReserveCatalogProjection), + baseline: new Map(), + featured: [], + wsEnabled: false, + template: null, + disabledModels: new Set(), + selectedModelsByProvider: new Map(), + gatheredProviderNames: new Set(["external"]), + degradedProviderNames: new Set(), + legacyCustomModelSlugs: new Set(), + multiAgentMode: "default", + multiAgentV2Enabled: false, + exactComboSlugs: new Set(), + hasPhysicalComboProvider: false, + includeNativeOpenAi: true, + policy: { nativeBackfillSlugs: [], unsupportedNativeEntries: "drop", warningPolicy: "suppress" }, + ...overrides, + }); +} + +describe("Reserve effective authless configuration", () => { + test.each([undefined, "", "localhost", " LOCALHOST ", "localhost.", " LOCALHOST. ", "127.0.0.1", "::1", "[::1]"])( + "loopback %s admits only the explicit opt-in", hostname => { + expect(isLoopbackHostname(hostname)).toBe(true); + expect(isServerLoopbackHostname(hostname)).toBe(true); + expect(shouldInjectApiAuthHeader({ hostname })).toBe(false); + expect(isEffectiveCodexDesktopAuthless(config({ hostname }))).toBe(true); + expect(isEffectiveCodexDesktopAuthless(config({ hostname, codexDesktopAuthless: false }))).toBe(false); + expect(isEffectiveCodexDesktopAuthless(config({ hostname, codexDesktopAuthless: undefined }))).toBe(false); + }, + ); + test.each(["localhost..", "0.0.0.0", "::", "[::]", "192.0.2.10", "proxy.example"])( + "non-loopback %s keeps admission and hides Reserve", hostname => { + const state = config({ hostname }); + expect(isLoopbackHostname(hostname)).toBe(false); + expect(isServerLoopbackHostname(hostname)).toBe(false); + expect(shouldInjectApiAuthHeader(state)).toBe(true); + expect(isEffectiveCodexDesktopAuthless(state)).toBe(false); + expect(build(state).map(row => row.slug)).toEqual(["external/model"]); + }, + ); + test("dedicated loopback listener is effective, but a remote client is never effective", () => { + const state = config({ hostname: "0.0.0.0", unauthenticatedLoopbackListener: { enabled: true, port: 10101 } }); + expect(isEffectiveCodexDesktopAuthless(state)).toBe(true); + expect(isEffectiveCodexDesktopAuthless({ ...state, runtimeRole: "client" })).toBe(false); + expect(isEffectiveCodexDesktopAuthless(undefined)).toBe(false); + expect(build({ ...state, runtimeRole: "client" }).map(row => row.slug)).toEqual(["external/model"]); + }); +}); + +describe("Reserve catalog metadata is not permission", () => { + test("offline inputs expose only the main selector and retain external models", () => { + const first = build(); + expect(first.map(row => row.slug)).toEqual(["personal/gpt-reserve", "external/model"]); + const reserve = first[0]!; + expect(reserve[RESERVE_METADATA_SOURCE_FIELD]).toBe("gpt-5.6-luna"); + expect(reserve.supported_in_api).toBe(true); + expect(reserve.available_in_plans).toBeUndefined(); + expect(reserve.supported_reasoning_levels).toEqual(luna().supported_reasoning_levels); + expect(build()).toEqual(first); + expect(build(config({ codexDesktopAuthless: false })).map(row => row.slug)).toEqual(["external/model"]); + expect(createReserveCatalogProjection(config(), [], null, luna())).toBeUndefined(); + }); + + test("a real hidden Reserve source wins without mutating its metadata", () => { + const original = actualReserve(); + const before = structuredClone(original); + const reserve = build(config(), [original])[0]!; + expect(reserve[RESERVE_METADATA_SOURCE_FIELD]).toBe("gpt-reserve"); + expect(reserve.display_name).toBe("personal / Observed Reserve"); + expect(reserve.comp_hash).toBeNull(); + expect(reserve.supported_reasoning_levels).toEqual([{ effort: "medium", description: "Observed effort" }]); + expect(reserve.available_in_plans).toBeUndefined(); + expect(reserve.upgrade).toBeUndefined(); + expect(reserve.availability_nux).toBeUndefined(); + expect(original).toEqual(before); + expect(observedAccountBoundNativeEntries([original])).toEqual([original]); + expect(findSupportedNativeTemplate({ models: [original] })).toBeNull(); + }); + + test("adapted rows never become real observations or generic native exports", () => { + const adapted = build()[0]!; + const disguised = { ...adapted, slug: NATIVE_RESERVE_MODEL }; + expect(observedReserveCatalogSource([adapted, disguised], ["personal"])).toBeNull(); + expect(observedAccountBoundNativeEntries([disguised])).toEqual([]); + const actual = actualReserve(); + expect(accountBoundNativeOpenAiSlugs([actual])).not.toContain(NATIVE_RESERVE_MODEL); + for (const slugs of accountBoundNativeOpenAiSlugsBySelector(config(), [actual]).values()) { + expect(slugs).not.toContain(NATIVE_RESERVE_MODEL); + } + expect(observedReserveCatalogSource([{ slug: NATIVE_RESERVE_MODEL, supported_in_api: false }], ["personal"])).toBeNull(); + }); + + test("observed source overrides a previous adaptation, without copying another selector", () => { + const adapted = merge(build()); + const original = actualReserve({ display_name: "Fresh Reserve" }); + const next = build(config(), [...adapted, original]); + expect(next.find(isReserveCatalogProjection)?.display_name).toBe("personal / Fresh Reserve"); + expect(next.find(isReserveCatalogProjection)?.[RESERVE_METADATA_SOURCE_FIELD]).toBe("gpt-reserve"); + const qualified = next.find(isReserveCatalogProjection)!; + expect(observedReserveCatalogSource([qualified], ["renamed"])).toBeNull(); + const direct = build(config(), [original], { disabledNativeAccountSlugs: new Set(["personal/gpt-reserve"]) }); + expect(direct.map(row => row.slug)).toEqual(["external/model"]); + }); + + test("merge retains the actual source and never widens its reasoning or compression metadata", () => { + const rows = build(config(), [actualReserve()]); + const result = merge(rows, { catalogModels: [actualReserve({ supported_reasoning_levels: [{ effort: "ultra" }] })] }); + const reserve = result.find(isReserveCatalogProjection)!; + expect(reserve.comp_hash).toBeNull(); + expect(reserve.supported_reasoning_levels).toEqual([{ effort: "medium", description: "Observed effort" }]); + expect(reserve[RESERVE_METADATA_SOURCE_FIELD]).toBe("gpt-reserve"); + expect(merge(build(config(), result), { catalogModels: result })).toEqual(result); + }); + + test("repeated adaptation remains deterministic and disabling removes only the choice", () => { + const first = merge(build()); + expect(merge(build(config(), first), { catalogModels: first })).toEqual(first); + const off = merge(build(config({ codexDesktopAuthless: false })), { catalogModels: first }); + expect(off.map(row => row.slug)).toEqual(["external/model"]); + for (const disabled of ["personal/gpt-reserve", "gpt-reserve"]) { + const result = merge(build(), { disabledModels: new Set([disabled]) }); + expect(result.find(isReserveCatalogProjection)?.visibility).toBe("hide"); + expect(result.find(row => row.slug === "external/model")?.visibility).toBe("list"); + } + }); + + test("default multi-agent mode preserves the selected source; explicit overrides still apply", () => { + expect(merge(build()).find(isReserveCatalogProjection)?.multi_agent_version).toBe("v1"); + const disabled = actualReserve({ multi_agent_version: "disabled" }); + const rows = build(config(), [disabled], { multiAgentV2Enabled: true }); + expect(rows.find(isReserveCatalogProjection)?.multi_agent_version).toBe("disabled"); + expect(merge(rows, { multiAgentV2Enabled: true }).find(isReserveCatalogProjection)?.multi_agent_version).toBe("disabled"); + for (const mode of ["v1", "v2"] as const) { + const explicit = build(config(), [disabled], { multiAgentMode: mode }); + expect(merge(explicit, { multiAgentMode: mode }).find(isReserveCatalogProjection)?.multi_agent_version).toBe(mode); + } + }); + + test("final clamp omits incompatible Reserve in-place without inventing efforts", () => { + const rows = merge(build(config(), [actualReserve({ + supported_reasoning_levels: [{ effort: "xhigh", description: "Only xhigh" }], + default_reasoning_level: "xhigh", + })])); + const before = [...rows]; + const diagnostic = clampCatalogModelsToObservedCodexSupport(rows, new Set(["medium"])); + expect(rows).not.toEqual(before); + expect(rows.map(row => row.slug)).toEqual(["external/model"]); + expect(diagnostic.affectedModels).toContain("personal/gpt-reserve"); + expect(diagnostic.removedEfforts).toContain("xhigh"); + }); + + test("partial effort intersection keeps only source efforts and a surviving default", () => { + const rows = merge(build(config(), [actualReserve({ + supported_reasoning_levels: [ + { effort: "low", description: "Source low" }, + { effort: "high", description: "Source high" }, + ], + // Supported by the runtime but not by this source's actual ladder. + default_reasoning_level: "medium", + })])); + clampCatalogModelsToObservedCodexSupport(rows, new Set(["medium", "high"])); + expect(rows.find(isReserveCatalogProjection)).toMatchObject({ + supported_reasoning_levels: [{ effort: "high", description: "Source high" }], + default_reasoning_level: "high", + }); + }); +}); diff --git a/tests/codex-integration/reserve-dispatch.test.ts b/tests/codex-integration/reserve-dispatch.test.ts new file mode 100644 index 0000000000..e7777d8ed3 --- /dev/null +++ b/tests/codex-integration/reserve-dispatch.test.ts @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + CodexAccountCooldownError, CodexReserveUnavailableError, CodexReserveHelperUnsupportedError, + cooldownErrorResponse, createCodexReserveDispatchGuard, + resolveCodexAuthContext, unwrapUpstreamRetryEvidenceError, +} from "../../src/codex/auth-context"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; +import { clearAccountQuota } from "../../src/codex/quota"; +import { clearAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { clearCodexUpstreamHealth, getCodexUpstreamHealth, recordCodexUpstreamOutcome } from "../../src/codex/routing"; +import { isMainReserveAuthorizationLive, observeMainReserveRevocation } from "../../src/codex/reserve-availability"; +import { clearUpstreamHostHealth, getUpstreamHostHealth, upstreamHostHealthKey } from "../../src/codex/upstream-host-health"; +import { providerFetch, fetchWithHeaderTimeout } from "../../src/server/responses/fetch-helpers"; +import { handleResponses } from "../../src/server/responses/core"; +import { handleResponsesCompact } from "../../src/server/responses/compact"; +import { UpstreamRetryEvidenceError } from "../../src/lib/upstream-retry"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import type { DataPlaneAdmission } from "../../src/server/auth-cors"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const accountId = "reserve-dispatch-workspace"; +const accessToken = "reserve-dispatch-owned-fixture"; +const URL = "https://chatgpt.com/backend-api/codex/responses"; +const loopbackAdmission = { kind: "loopback", source: "loopback" } as const; +let home: string; +let oldHome: string | undefined; +let oldCodexHome: string | undefined; +let now: number; +let usageReads: number; +let inferenceSends: number; +let inference: () => Response | Promise; + +function config(): OcxConfig { + return { + port: 0, defaultProvider: "custom", codexDesktopAuthless: true, codexMainAccountHardLock: true, + providers: { custom: { + adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", + } }, + }; +} + +function headers(token = accessToken, workspace = accountId): Headers { + return new Headers({ authorization: `Bearer ${token}`, "chatgpt-account-id": workspace }); +} + +function revoke(): void { + observeMainReserveRevocation({ rate_limit: { allowed: true } }, captureMainQuotaWriter(accountId)); +} + +async function authorize() { + const cfg = config(); + const ctx = await resolveCodexAuthContext(headers(), cfg, "direct", { modelId: "gpt-reserve", admission: loopbackAdmission }); + if (ctx.kind !== "main" || !ctx.reserveAuthorization) throw new Error("fixture expected an owned private grant"); + const guard = createCodexReserveDispatchGuard(ctx, cfg, "gpt-reserve", loopbackAdmission); + if (!guard) throw new Error("fixture expected a dispatch guard"); + return { ctx, cfg, guard }; +} + +beforeEach(() => { + oldHome = process.env.OPENCODEX_HOME; + oldCodexHome = process.env.CODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-reserve-dispatch-")); + 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(); + clearMainAccountInfoCache(); + clearAccountNeedsReauth("__main__"); + clearCodexUpstreamHealth(); + clearUpstreamHostHealth(); + observeMainQuotaIdentity(accountId); + observeMainQuotaCredential(accessToken, accountId); + now = Date.now(); + spyOn(Date, "now").mockImplementation(() => now); + usageReads = 0; + inferenceSends = 0; + inference = () => Response.json({ id: "resp_dispatch_fixture", object: "response", status: "completed", + created_at: 1, model: "gpt-reserve", output: [], usage: { input_tokens: 1, output_tokens: 0, total_tokens: 1 } }); + spyOn(globalThis, "fetch").mockImplementation(Object.assign(async ( + input: Parameters[0], init?: Parameters[1], + ) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.url === "https://chatgpt.com/backend-api/wham/usage") { + usageReads += 1; + return Response.json({ + account_id: accountId, + rate_limit: { allowed: false, primary_window: { used_percent: 20, limit_window_seconds: 18_000 } }, + rate_limit_upsell: { banner_type: "luna_reserve" }, + additional_rate_limits: [{ limit_name: "gpt-reserve", rate_limit: { allowed: true } }], + }); + } + if (request.url === URL || request.url === `${URL}/compact`) { + inferenceSends += 1; + return inference(); + } + throw new Error("unexpected dispatch fixture destination"); + }, { preconnect() {} })); +}); + +afterEach(async () => { + mock.restore(); + clearAccountQuota(); + clearMainAccountInfoCache(); + clearAccountNeedsReauth("__main__"); + clearCodexUpstreamHealth(); + clearUpstreamHostHealth(); + try { + await flushConfigDirHardeningForTests(); + } finally { + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = oldCodexHome; + removeTreeWithRetry(home); + } +}); + +describe("Reserve dispatch-time permission", () => { + test("a positive conversation grant cannot authorize a terminal helper enabled during pacing", async () => { + const { ctx, cfg } = await authorize(); + const token = { accessToken, chatgptAccountId: accountId }; + expect(isMainReserveAuthorizationLive(ctx.reserveAuthorization, token)).toBe(true); + cfg.codexDesktopAuthless = false; + const guard = createCodexReserveDispatchGuard(ctx, cfg, "gpt-reserve", loopbackAdmission, true); + expect(guard).toBeDefined(); + const executor = providerFetch(cfg.providers.custom!, "1.3.14", { beforeDispatch: guard }); + let release!: () => void; + const paced = new Promise(resolve => { release = resolve; }); + executor.waitForPacing = () => paced; + const pending = fetchWithHeaderTimeout(URL, { method: "POST", headers: headers(), body: "{}" }, + new AbortController().signal, 1000, false, executor); + const observed = pending.then( + () => ({ status: "fulfilled" as const }), + (error: unknown) => ({ status: "rejected" as const, error }), + ); + cfg.codexDesktopAuthless = true; + release(); + const outcome = await observed; + expect(outcome.status).toBe("rejected"); + if (outcome.status !== "rejected") throw new Error("Expected terminal helper refusal"); + expect(outcome.error).toBeInstanceOf(CodexReserveHelperUnsupportedError); + if (!(outcome.error instanceof CodexReserveHelperUnsupportedError)) throw outcome.error; + const response = cooldownErrorResponse(outcome.error); + expect(response.status).toBe(429); + expect(response.headers.has("retry-after")).toBe(false); + expect(await response.text()).toContain("only available as a conversation model"); + expect(isMainReserveAuthorizationLive(ctx.reserveAuthorization, token)).toBe(true); + expect(inferenceSends).toBe(0); + expect(usageReads).toBe(1); + expect(getCodexUpstreamHealth("__main__")).toBeNull(); + expect(getUpstreamHostHealth(upstreamHostHealthKey("custom", "https://chatgpt.com"))).toBeNull(); + }); + + test("off-to-on during pacing activates the installed guard without obtaining a new grant", async () => { + const cfg = config(); + cfg.codexDesktopAuthless = false; + const guard = createCodexReserveDispatchGuard({ kind: "main", accountId: null }, cfg, "gpt-reserve", loopbackAdmission); + expect(guard).toBeDefined(); + const executor = providerFetch(cfg.providers.custom!, "1.3.14", { beforeDispatch: guard }); + let release!: () => void; + const paced = new Promise(resolve => { release = resolve; }); + executor.waitForPacing = () => paced; + const pending = fetchWithHeaderTimeout(URL, { method: "POST", headers: headers(), body: "{}" }, + new AbortController().signal, 1000, false, executor); + const observed = pending.then( + () => ({ status: "fulfilled" as const }), + (error: unknown) => ({ status: "rejected" as const, error }), + ); + cfg.codexDesktopAuthless = true; + release(); + const outcome = await observed; + expect(outcome.status).toBe("rejected"); + if (outcome.status !== "rejected") throw new Error("Expected dispatch refusal"); + expect(outcome.error).toBeInstanceOf(CodexReserveUnavailableError); + expect(inferenceSends).toBe(0); + expect(usageReads).toBe(0); + }); + + test("an installed guard leaves a still-disabled request on its original unproved path", async () => { + const cfg = config(); + cfg.codexDesktopAuthless = false; + const guard = createCodexReserveDispatchGuard({ kind: "main", accountId: null }, cfg, "gpt-reserve", loopbackAdmission); + expect(guard).toBeDefined(); + const response = await providerFetch(cfg.providers.custom!, "1.3.14", { beforeDispatch: guard })(URL, { + method: "POST", headers: headers(), body: "{}", + }); + expect(response.status).toBe(200); + await response.text(); + expect(inferenceSends).toBe(1); + expect(usageReads).toBe(0); + }); + + test("dispatch freezes admission source while keeping policy config live", async () => { + const { ctx, cfg } = await authorize(); + const admission: Pick = { source: "loopback" }; + const guard = createCodexReserveDispatchGuard(ctx, cfg, "gpt-reserve", admission); + expect(guard).toBeDefined(); + admission.source = "dedicated"; + revoke(); + expect(() => guard!(headers())).toThrow(CodexReserveUnavailableError); + cfg.codexDesktopAuthless = false; + expect(() => guard!(headers())).not.toThrow(); + expect(usageReads).toBe(1); + expect(inferenceSends).toBe(0); + }); + + test("public or missing admission never creates a compatibility guard despite a secondary listener", async () => { + const { ctx, cfg } = await authorize(); + cfg.hostname = "0.0.0.0"; + cfg.unauthenticatedLoopbackListener = { enabled: true, port: 10101 }; + const admissions: Array | undefined> = [ + undefined, { source: "dedicated" }, { source: "bearer" }, { source: "x-api-key" }, + ]; + for (const admission of admissions) { + expect(createCodexReserveDispatchGuard(ctx, cfg, "gpt-reserve", admission)).toBeUndefined(); + } + expect(createCodexReserveDispatchGuard(ctx, cfg, "gpt-reserve", loopbackAdmission)).toBeDefined(); + }); + + test("cached proof expiring during pacing refuses before HTTP and never renews", async () => { + const { ctx, cfg, guard } = await authorize(); + const executor = providerFetch(cfg.providers.custom!, "1.3.14", { beforeDispatch: guard }); + let release!: () => void; + const paced = new Promise(resolve => { release = resolve; }); + executor.waitForPacing = () => paced; + const pending = fetchWithHeaderTimeout(URL, { method: "POST", headers: headers(), body: "{}" }, + new AbortController().signal, 1000, false, executor); + const observed = pending.then( + () => ({ status: "fulfilled" as const }), + (error: unknown) => ({ status: "rejected" as const, error }), + ); + now = ctx.reserveAuthorization!.expiresAt + 1; + release(); + const outcome = await observed; + expect(outcome.status).toBe("rejected"); + if (outcome.status !== "rejected") throw new Error("Expected dispatch refusal"); + expect(outcome.error).toBeInstanceOf(CodexReserveUnavailableError); + expect(inferenceSends).toBe(0); + expect(usageReads).toBe(1); + }); + + test("HTTP guards the actual init override, not the earlier Request credential", async () => { + const { cfg, guard } = await authorize(); + const executor = providerFetch(cfg.providers.custom!, "1.3.14", { beforeDispatch: guard }); + const request = new Request(URL, { method: "POST", headers: headers(), body: "{}" }); + await expect(executor(request, { headers: headers("different-token") })) + .rejects.toBeInstanceOf(CodexReserveUnavailableError); + await expect(executor(request, { headers: headers(accessToken, "different-workspace") })) + .rejects.toBeInstanceOf(CodexReserveUnavailableError); + expect(inferenceSends).toBe(0); + const response = await executor(new Request(URL, { headers: headers("wrong-inherited-token") }), { headers: headers() }); + expect(response.status).toBe(200); + await response.text(); + expect(inferenceSends).toBe(1); + expect(usageReads).toBe(1); + }); + + test("unguarded unrelated transport remains unchanged", async () => { + const { ctx, cfg } = await authorize(); + expect(createCodexReserveDispatchGuard(ctx, cfg, "gpt-5.6-luna", loopbackAdmission)).toBeUndefined(); + const provider: OcxProviderConfig & { fetch: typeof fetch } = { + adapter: "openai-responses", authMode: "key", baseUrl: "https://independent.example.test/v1", + fetch: Object.assign(async () => new Response("keyed-ok"), { preconnect() {} }), + }; + now = ctx.reserveAuthorization!.expiresAt + 1; + const response = await providerFetch(provider)("https://independent.example.test/v1/responses", { headers: headers("keyed") }); + expect(await response.text()).toBe("keyed-ok"); + }); + + test("nested reset and502 wrappers preserve the original local refusal", () => { + const refusal = new CodexReserveUnavailableError(); + const nested = new UpstreamRetryEvidenceError([502], new UpstreamRetryEvidenceError([], refusal, true)); + expect(unwrapUpstreamRetryEvidenceError(nested)).toBe(refusal); + const transport = new Error("network failed"); + expect(unwrapUpstreamRetryEvidenceError(transport)).toBe(transport); + }); + + for (const endpoint of ["responses", "compact"] as const) { + for (const firstFailure of ["reset", "502"] as const) { + test(`${endpoint}: ${firstFailure} then revoked proof maps to429 without a second inference or health mutation`, async () => { + inference = () => { + // Permission changes after the first real attempt, before the retry wrapper dispatches. + revoke(); + if (firstFailure === "reset") throw Object.assign(new Error("fixture connection reset"), { code: "ECONNRESET" }); + return new Response("gateway failed", { status: 502, headers: { "retry-after": "0" } }); + }; + const request = new Request(`http://localhost/v1/responses${endpoint === "compact" ? "/compact" : ""}`, { + method: "POST", headers: { ...Object.fromEntries(headers()), "content-type": "application/json" }, + body: JSON.stringify({ model: "custom/gpt-reserve", input: [{ role: "user", content: "ping" }], stream: false }), + }); + const response = endpoint === "compact" + ? await handleResponsesCompact(request, config(), { model: "", provider: "" }, undefined, loopbackAdmission) + : await handleResponses(request, config(), { model: "", provider: "" }, { admission: loopbackAdmission }); + expect(response.status).toBe(429); + expect(await response.text()).toContain("Reserve is unavailable"); + expect(inferenceSends).toBe(1); + expect(usageReads).toBe(1); + expect(getCodexUpstreamHealth("__main__")).toBeNull(); + expect(getUpstreamHostHealth(upstreamHostHealthKey("custom", "https://chatgpt.com"))).toBeNull(); + }); + } + } + + test("global cooldown activated between attempts is authoritative without quota-read renewal", async () => { + const cfg = config(); + let recorded: ReturnType; + inference = () => { + recordCodexUpstreamOutcome(cfg, "__main__", 429, { retryAfter: "3600", fixedAccount: true }); + recorded = structuredClone(getCodexUpstreamHealth("__main__")); + return new Response("gateway failed", { status: 502, headers: { "retry-after": "0" } }); + }; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { ...Object.fromEntries(headers()), "content-type": "application/json" }, + body: JSON.stringify({ model: "custom/gpt-reserve", input: "ping", stream: false }), + }), cfg, { model: "", provider: "" }, { admission: loopbackAdmission }); + expect(response.status).toBe(429); + expect(await response.text()).toContain("cooling down"); + expect(getCodexUpstreamHealth("__main__")).toEqual(recorded!); + expect(inferenceSends).toBe(1); + expect(usageReads).toBe(1); + }); + + test("guard rechecks a live global cooldown against already granted actual headers", async () => { + const { cfg, guard } = await authorize(); + recordCodexUpstreamOutcome(cfg, "__main__", 429, { retryAfter: "3600", fixedAccount: true }); + expect(() => guard(headers())).toThrow(CodexAccountCooldownError); + expect(inferenceSends).toBe(0); + }); +}); diff --git a/tests/codex-integration/reserve-helper-boundary.test.ts b/tests/codex-integration/reserve-helper-boundary.test.ts new file mode 100644 index 0000000000..34b4ac6974 --- /dev/null +++ b/tests/codex-integration/reserve-helper-boundary.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { describeImage } from "../../src/vision/describe"; +import { planVisionSidecar } from "../../src/vision"; +import { runWebSearch } from "../../src/web-search/executor"; +import { planWebSearch } from "../../src/web-search"; +import * as sidecarAuth from "../../src/sidecar/auth"; +import { parseRequest } from "../../src/responses/parser"; +import { handleSearch } from "../../src/server/search"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import type { DataPlaneAdmission } from "../../src/server/auth-cors"; + +const forward: OcxProviderConfig = { + adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", +}; +const routed: OcxProviderConfig = { + adapter: "openai-chat", baseUrl: "https://fixture.example.test/v1", noVisionModels: ["blind"], +}; +const headers = new Headers({ authorization: "Bearer fixture-helper-token" }); +const loopbackAdmission = { kind: "loopback", source: "loopback" } as const; +const sidecar = { providerName: "openai" as const, provider: forward, accountMode: "direct" as const, + authContext: { kind: "main" as const, accountId: null }, headers }; +function config(): OcxConfig { + return { port: 0, defaultProvider: "openai", providers: { openai: forward }, codexDesktopAuthless: true, + codexAccountPickerEnabled: true, codexAccountNamespaces: { personal: "@main" }, + visionSidecar: { backend: "openai", model: "gpt-reserve" }, + webSearchSidecar: { backend: "openai", model: "gpt-reserve" } }; +} +afterEach(() => mock.restore()); + +describe("Reserve native helper boundary", () => { + test.each(["vision", "search"] as const)("%s helper refuses before fetch or outcome recording", async kind => { + const fetchSpy = spyOn(globalThis, "fetch"); + const outcome = mock(() => {}); + const settings = { model: "gpt-reserve", reasoning: "medium" as const, timeoutMs: 1_000, reserveCompatibility: true }; + const result = kind === "vision" + ? await describeImage("data:image/png;base64,AA==", undefined, "fixture", forward, headers, settings, undefined, outcome) + : await runWebSearch("fixture", { type: "web_search" }, forward, headers, settings, undefined, outcome); + expect(result.error).toContain("only available as a conversation model"); + expect(fetchSpy).not.toHaveBeenCalled(); expect(outcome).not.toHaveBeenCalled(); + }); + + test.each(["vision", "search"] as const)("%s helper preserves opt-in-off dispatch", async kind => { + spyOn(console, "warn").mockImplementation(() => {}); + const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 503 })); + const settings = { model: "gpt-reserve", reasoning: "medium" as const, timeoutMs: 1_000 }; + const result = kind === "vision" + ? await describeImage("data:image/png;base64,AA==", undefined, "fixture", forward, headers, settings) + : await runWebSearch("fixture", { type: "web_search" }, forward, headers, settings); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(result.error).toContain("503"); + }); + + test.each(["enabled", "disabled", "remote", "missing", "dedicated", "bearer", "x-api-key", "secondary-loopback"] as const)("%s native plan carries only ingress-bound compatibility", mode => { + spyOn(sidecarAuth, "resolveSidecarAuth").mockReturnValue({ isCodexAuth: true, isAnthropicAuth: false }); + const cfg = config(); + if (mode === "disabled") cfg.codexDesktopAuthless = false; + if (mode === "remote") cfg.runtimeRole = "client"; + cfg.hostname = "0.0.0.0"; + cfg.unauthenticatedLoopbackListener = { enabled: true, port: 15142 }; + const source: DataPlaneAdmission["source"] = mode === "dedicated" || mode === "bearer" || mode === "x-api-key" + ? mode : "loopback"; + const options = { admission: mode === "missing" ? undefined : { source } }; + const parsed = parseRequest({ model: "external/blind", tools: [{ type: "web_search" }], input: [{ + role: "user", content: [{ type: "input_text", text: "fixture" }, { type: "input_image", image_url: "data:image/png;base64,AA==" }], + }] }); + const vision = planVisionSidecar(cfg, routed, "blind", parsed, sidecar, options); + const search = planWebSearch(cfg, parsed, false, routed, "blind", sidecar, options); + expect(vision?.settings.model).toBe("gpt-reserve"); + expect(search?.settings.model).toBe("gpt-reserve"); + const expected = mode === "enabled" || mode === "secondary-loopback" ? true : undefined; + expect(vision?.settings.reserveCompatibility).toBe(expected); + expect(search?.settings.reserveCompatibility).toBe(expected); + }); + + test.each(["gpt-reserve", "personal/gpt-reserve"])("standalone %s refuses before native credential resolution", async model => { + const fetchSpy = spyOn(globalThis, "fetch"); + const result = await handleSearch(new Request("http://localhost/v1/alpha/search", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model, query: "fixture" }), + }), config(), { model: "", provider: "" }, undefined, loopbackAdmission); + expect(result.status).toBe(400); + expect(await result.text()).toContain("not the standalone search relay"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test("standalone opt-in-off retains its existing provider check", async () => { + const cfg = config(); cfg.codexDesktopAuthless = false; cfg.providers = {}; + const result = await handleSearch(new Request("http://localhost/v1/alpha/search", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "gpt-reserve" }), + }), cfg, { model: "", provider: "" }); + expect(await result.text()).toContain("none is configured"); + }); +}); diff --git a/tests/codex-integration/reserve-passive-revocation.test.ts b/tests/codex-integration/reserve-passive-revocation.test.ts new file mode 100644 index 0000000000..47fe15ffc0 --- /dev/null +++ b/tests/codex-integration/reserve-passive-revocation.test.ts @@ -0,0 +1,154 @@ +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 { fetchMainAccountInfo } from "../../src/codex/auth-api"; +import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { setMainAccountPlan } from "../../src/codex/main-account"; +import { + captureMainQuotaWriter, + clearMainAccountInfoCache, + getMainQuotaCredentialGeneration, + observeMainQuotaCredential, +} from "../../src/codex/main-account-cache"; +import { clearAccountQuota } from "../../src/codex/quota"; +import { + getMainReserveAuthorization, + isMainReserveAuthorizationLive, +} from "../../src/codex/reserve-availability"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const ACCOUNT = "fixture-passive-reserve-main"; +const TOKEN_A = "fixture-passive-reserve-token-a"; +const TOKEN_B = "fixture-passive-reserve-token-b"; +let directory: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +let previousFetch: typeof fetch; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +function writeCredential(accessToken: string): void { + writeFileSync(join(directory, "auth.json"), JSON.stringify({ tokens: { + access_token: accessToken, account_id: ACCOUNT, + } })); +} + +function ordinaryResponse(): Response { + return Response.json({ + plan_type: "plus", + rate_limit: { allowed: true, primary_window: { used_percent: 10, limit_window_seconds: 18_000 } }, + }); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + previousFetch = globalThis.fetch; + directory = mkdtempSync(join(tmpdir(), "ocx-reserve-passive-")); + process.env.OPENCODEX_HOME = directory; + process.env.CODEX_HOME = directory; + clearAccountQuota(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + setMainAccountPlan(null); + const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(() => aclOk); + setAsyncIcaclsRunnerForTests(async () => aclOk); +}); + +afterEach(async () => { + globalThis.fetch = previousFetch; + // Clear the quota persistence timer while the fixture still owns both homes. + 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(directory); + } +}); + +describe("passive WHAM Reserve revocation producer", () => { + test.each([false, true])("late A cannot revoke the new grant after token replacement, return to A=%s", async returnToA => { + writeCredential(TOKEN_A); + const started = deferred(); + const delayed = deferred(); + let passiveCalls = 0; + let capabilityCalls = 0; + const currentToken = returnToA ? TOKEN_A : TOKEN_B; + globalThis.fetch = Object.assign(async (input: Parameters[0], init?: RequestInit) => { + expect(String(input)).toBe("https://chatgpt.com/backend-api/wham/usage"); + const headers = new Headers(init?.headers); + expect(headers.get("chatgpt-account-id")).toBe(ACCOUNT); + if (headers.get("x-openai-codex-luna-reserve") === "1") { + capabilityCalls += 1; + expect(headers.get("authorization")).toBe(`Bearer ${currentToken}`); + return Response.json({ + rate_limit: { allowed: false }, + rate_limit_upsell: { banner_type: "luna_reserve" }, + additional_rate_limits: [{ limit_name: "gpt-reserve", rate_limit: { allowed: true } }], + }); + } + passiveCalls += 1; + expect(headers.get("x-openai-codex-luna-reserve")).toBeNull(); + expect(headers.get("authorization")).toBe(`Bearer ${passiveCalls === 1 ? TOKEN_A : currentToken}`); + if (passiveCalls === 1) { + started.resolve(); + return delayed.promise; + } + return ordinaryResponse(); + }, { preconnect: previousFetch.preconnect }); + + const pending = fetchMainAccountInfo(true); + try { + await Promise.race([started.promise, pending.then(() => { throw new Error("Passive WHAM never started"); })]); + const oldWriter = captureMainQuotaWriter(ACCOUNT); + const oldEpoch = getMainQuotaCredentialGeneration(); + expect(oldWriter).toBeDefined(); + writeCredential(TOKEN_B); + observeMainQuotaCredential(TOKEN_B, ACCOUNT); + if (returnToA) writeCredential(TOKEN_A); + const writer = observeMainQuotaCredential(currentToken, ACCOUNT); + expect(writer).toEqual(oldWriter); // Workspace identity did not change. + expect(getMainQuotaCredentialGeneration()).toBeGreaterThan(oldEpoch); + const token = { accessToken: currentToken, chatgptAccountId: ACCOUNT }; + const authorization = await getMainReserveAuthorization({ token, writer, observeOrdinaryQuota: () => {} }); + expect(authorization).toBeDefined(); + expect(isMainReserveAuthorizationLive(authorization, token)).toBe(true); + + delayed.resolve(ordinaryResponse()); + const info = await pending; + // Only Reserve revocation is fenced; the existing ordinary producer still completes. + expect(info.quota).toMatchObject({ shortPercent: 10, shortWindowSeconds: 18_000 }); + expect(isMainReserveAuthorizationLive(authorization, token)).toBe(true); + expect(passiveCalls).toBe(1); + expect(capabilityCalls).toBe(1); + + // Positive control: a newly started passive read of the current bearer can revoke. + await fetchMainAccountInfo(true); + expect(passiveCalls).toBe(2); + expect(isMainReserveAuthorizationLive(authorization, token)).toBe(false); + } finally { + delayed.resolve(ordinaryResponse()); + await pending; + } + }); +}); diff --git a/tests/codex-integration/reserve-quota-scope.test.ts b/tests/codex-integration/reserve-quota-scope.test.ts new file mode 100644 index 0000000000..cee32d753a --- /dev/null +++ b/tests/codex-integration/reserve-quota-scope.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearCodexCooldownRecoveryProbeState, + runCodexCooldownRecoveryProbes, +} from "../../src/codex/auth-api"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearAccountQuota } from "../../src/codex/quota"; +import { + CODEX_QUOTA_PROBE_INTERVAL_MS, + claimDueCodexQuotaRecoveryProbes, + clearCodexUpstreamHealth, + codexQuotaScopeForModel, + getCodexQuotaHealthSnapshot, + recordCodexUpstreamOutcome, + type CodexQuotaScope, +} from "../../src/codex/routing"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const START = 1_800_000_000_000; +const DUE = START + CODEX_QUOTA_PROBE_INTERVAL_MS + 2; +const MODELS = { + shared: "gpt-5.6-sol", + spark: "gpt-5.3-codex-spark", + reserve: "gpt-reserve", +} satisfies Record; + +// Added-account state deliberately exercises the generic worker's claim filter. +// It does not represent an allowed added-account Reserve dispatch. +function makeConfig(): OcxConfig { + return { + port: 0, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + defaultProvider: "openai", + activeCodexAccountId: "reserve-fixture", + accountPoolStrategy: "fill-first", + codexAccounts: [{ id: "reserve-fixture", email: "reserve@example.test", plan: "team", isMain: false }], + } as OcxConfig; +} + +function cool(config: OcxConfig, scope: CodexQuotaScope, now = START): void { + recordCodexUpstreamOutcome(config, "reserve-fixture", 429, { + modelId: MODELS[scope], + resetAt: now + 60 * 60_000, + fixedAccount: true, + now, + }); +} + +describe("Reserve quota scope", () => { + let directory: string; + let previousHome: string | undefined; + let previousCodexHome: string | undefined; + let previousFetch: typeof fetch; + let calls: number; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + previousFetch = globalThis.fetch; + directory = mkdtempSync(join(tmpdir(), "ocx-reserve-quota-scope-")); + process.env.OPENCODEX_HOME = directory; + process.env.CODEX_HOME = join(directory, "codex"); + mkdirSync(process.env.CODEX_HOME, { recursive: true }); + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearCodexCooldownRecoveryProbeState(); + saveCodexAccountCredential("reserve-fixture", { + accessToken: "reserve-quota-fixture-access", + refreshToken: "reserve-quota-fixture-refresh", + expiresAt: Date.now() + 60 * 60_000, + chatgptAccountId: "reserve-quota-fixture-account", + }); + calls = 0; + globalThis.fetch = Object.assign(async (input: Parameters[0], init?: RequestInit) => { + calls += 1; + expect(String(input)).toBe("https://chatgpt.com/backend-api/wham/usage"); + expect(new Headers(init?.headers).get("x-openai-codex-luna-reserve")).toBeNull(); + return Response.json({ + plan_type: "team", + rate_limit: { secondary_window: { used_percent: 10, reset_at: 1_900_000_000 } }, + }); + }, { preconnect: previousFetch.preconnect }); + }); + + afterEach(() => { + globalThis.fetch = previousFetch; + // Cancels the quota writer's pending persistence timer before restoring homes. + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearCodexCooldownRecoveryProbeState(); + 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(directory); + }); + + test("maps only the exact Reserve wire model into its independent scope", () => { + expect(codexQuotaScopeForModel("gpt-reserve")).toBe("reserve"); + expect(codexQuotaScopeForModel(" GPT-RESERVE ")).toBe("reserve"); + expect(codexQuotaScopeForModel("gpt-reserve-preview")).toBe("shared"); + expect(codexQuotaScopeForModel("main/gpt-reserve")).toBe("shared"); + expect(codexQuotaScopeForModel("gpt-5.3-codex-spark")).toBe("spark"); + expect(codexQuotaScopeForModel("gpt-5.6-luna")).toBe("shared"); + expect(codexQuotaScopeForModel(undefined)).toBeUndefined(); + }); + + test("shared and Spark reset-derived limits do not imply Reserve exhaustion", () => { + const config = makeConfig(); + cool(config, "shared"); + cool(config, "spark"); + expect(getCodexQuotaHealthSnapshot("reserve-fixture", "reserve", START + 1)).toBeNull(); + cool(config, "reserve", START + 1); + for (const scope of ["shared", "spark", "reserve"] as const) { + expect(getCodexQuotaHealthSnapshot("reserve-fixture", scope, START + 2)).toMatchObject({ + quotaScope: scope, + cooldownSource: "reset-derived", + }); + } + }); + + test.each(["shared", "spark"] as const)("Reserve exhaustion leaves %s quota usable", scope => { + const config = makeConfig(); + cool(config, "reserve"); + expect(getCodexQuotaHealthSnapshot("reserve-fixture", scope, START + 1)).toBeNull(); + }); + + test.each(["retry-after", "default"] as const)("%s remains account-wide and wins over Reserve scope", source => { + const config = makeConfig(); + cool(config, "reserve"); + recordCodexUpstreamOutcome(config, "reserve-fixture", 429, { + modelId: "gpt-reserve", + fixedAccount: true, + now: START + 1, + ...(source === "retry-after" ? { retryAfter: "60", resetAt: START + 60 * 60_000 } : {}), + }); + for (const scope of ["shared", "spark", "reserve"] as const) { + expect(getCodexQuotaHealthSnapshot("reserve-fixture", scope, START + 2)).toEqual({ + cooldownUntil: START + 60_001, + cooldownSource: source, + }); + } + // Expiring the shorter global throttle reveals, rather than erases, Reserve's cooldown. + expect(getCodexQuotaHealthSnapshot("reserve-fixture", "reserve", START + 60_002)) + .toMatchObject({ quotaScope: "reserve", cooldownSource: "reset-derived" }); + }); + + test("ordinary unleased native success does not clear Reserve health", () => { + const config = makeConfig(); + cool(config, "reserve"); + const before = getCodexQuotaHealthSnapshot("reserve-fixture", "reserve", START + 1); + expect(before).not.toBeNull(); + for (const modelId of ["gpt-5.6-luna", "gpt-5.3-codex-spark", undefined]) { + recordCodexUpstreamOutcome(config, "reserve-fixture", 200, { modelId, now: START + 2 }); + expect(getCodexQuotaHealthSnapshot("reserve-fixture", "reserve", START + 3)).toEqual(before); + } + }); + + test("generic recovery never claims a Reserve-only cooldown or reads upstream", async () => { + const config = makeConfig(); + cool(config, "reserve"); + expect(claimDueCodexQuotaRecoveryProbes(config, 4, DUE)).toEqual([]); + await runCodexCooldownRecoveryProbes(config, DUE); + expect(calls).toBe(0); + expect(getCodexQuotaHealthSnapshot("reserve-fixture", "reserve", DUE + 1)).not.toBeNull(); + }); + + test("generic recovery still clears an unscoped legacy reset without clearing Reserve", async () => { + const config = makeConfig(); + cool(config, "reserve"); + recordCodexUpstreamOutcome(config, "reserve-fixture", 429, { + resetAt: START + 60 * 60_000, + fixedAccount: true, + now: START + 1, + }); + expect(getCodexQuotaHealthSnapshot("reserve-fixture", "shared", DUE)) + .toMatchObject({ cooldownSource: "reset-derived" }); + await runCodexCooldownRecoveryProbes(config, DUE); + expect(calls).toBe(1); + expect(getCodexQuotaHealthSnapshot("reserve-fixture", "shared", DUE + 1)).toBeNull(); + expect(getCodexQuotaHealthSnapshot("reserve-fixture", "reserve", DUE + 1)) + .toMatchObject({ quotaScope: "reserve", cooldownSource: "reset-derived" }); + }); + + test.each([false, true])("shared WHAM recovery preserves Reserve, older Reserve=%s", async reserveFirst => { + const config = makeConfig(); + cool(config, reserveFirst ? "reserve" : "shared"); + cool(config, reserveFirst ? "shared" : "reserve", START + 1); + const before = getCodexQuotaHealthSnapshot("reserve-fixture", "reserve", DUE); + expect(before).not.toBeNull(); + await runCodexCooldownRecoveryProbes(config, DUE); + expect(calls).toBe(1); + expect(getCodexQuotaHealthSnapshot("reserve-fixture", "shared", DUE + 1)).toBeNull(); + expect(getCodexQuotaHealthSnapshot("reserve-fixture", "reserve", DUE + 1)).toEqual(before); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 18e85cd235..d28994d8d1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -802,6 +802,17 @@ "quota-scoring.test.ts": "usage", "qwen-cloud-endpoints.test.ts": "gui", "qwen38-preserve-reasoning.test.ts": "providers", + "reserve-availability.test.ts": "codex-integration", + "reserve-auth-context.test.ts": "codex-integration", + "reserve-catalog.test.ts": "codex-integration", + "reserve-catalog-lifecycle.test.ts": "codex-integration", + "reserve-claude-policy.test.ts": "server", + "reserve-dispatch.test.ts": "codex-integration", + "reserve-dispatch-ws.test.ts": "responses", + "reserve-helper-boundary.test.ts": "codex-integration", + "reserve-ingress.test.ts": "server", + "reserve-passive-revocation.test.ts": "codex-integration", + "reserve-quota-scope.test.ts": "codex-integration", "rate-limit-reset-credits.test.ts": "gui", "rate-limit-retry.test.ts": "providers", "reasoning-effort.test.ts": "codex-integration", diff --git a/tests/helpers/reserve-ingress-fixture.ts b/tests/helpers/reserve-ingress-fixture.ts new file mode 100644 index 0000000000..fa9654e085 --- /dev/null +++ b/tests/helpers/reserve-ingress-fixture.ts @@ -0,0 +1,268 @@ +import { expect, spyOn } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../../src/config"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET } from "../../src/codex/account-namespace-match"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import * as mainAccount from "../../src/codex/main-account"; +import * as authCollision from "../../src/codex/auth-collision"; +import * as liveStores from "../../src/lib/state-store-registrations"; +import { clearAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { clearAccountQuota } from "../../src/codex/quota"; +import { clearMainAccountInfoCache, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; +import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; +import { isNativeMainTrafficBlocked, waitForNativeMainStartupGate } from "../../src/codex/native-profile-startup"; +import { startServer } from "../../src/server"; +import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { findAvailablePort } from "../../src/server/ports"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { isTestHomeGuardArmed } from "../../src/lib/test-home-guard"; +import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "./fake-chatgpt-jwt"; +import { ownedServiceHomeInspection } from "./owned-service-home-inspection"; +import { removeTreeWithRetry } from "./remove-tree"; +import { INTERNAL_DEADLINE_MS } from "./test-budget"; + +export const PROXY_KEY = "ocx_data_reserve_ingress_fixture"; +export const ACCOUNT = "reserve-ingress-owned-account"; +export const ACCESS = fakeChatGptJwt({ exp: 4_000_000_000, + "https://api.openai.com/auth": { chatgpt_account_id: ACCOUNT, chatgpt_user_id: "owned-fixture-user" } }); +export const EXTERNAL = fakeChatGptJwt({ exp: 4_000_000_000, + "https://api.openai.com/auth": { chatgpt_account_id: "external-fixture-account" } }); +export type Transport = "responses" | "compact" | "search" | "ws" | "chat" | "messages"; +export type SeenInference = { path: string; authorization: string | null; model: unknown }; +export type Counters = { wham: number; credential: number; tokenRead: number; inference: SeenInference[] }; + +export function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +function clearState(): void { + clearComboSelectionState(); + clearComboTargetCooldowns(); + clearAccountQuota(); // Cancels pending quota persistence before fixture-home teardown. + clearMainAccountInfoCache(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearAccountNeedsReauth(mainAccount.MAIN_CODEX_ACCOUNT_ID); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + mainAccount.setMainAccountPlan(null); +} + +/** Actual sibling listeners, native platform locks, owned homes; no external socket fallback. */ +export async function reserveIngressFixture(options: { + primaryLoopback?: boolean; + configure?: (config: OcxConfig) => void; +} = {}) { + expect(isTestHomeGuardArmed()).toBe(true); + const names = ["OPENCODEX_HOME", "CODEX_HOME", "OPENCODEX_API_AUTH_TOKEN", "OPENCODEX_ADMIN_AUTH_TOKEN"] as const; + const oldEnv = names.map(name => [name, process.env[name]] as const); + const root = mkdtempSync(join(tmpdir(), "ocx-reserve-ingress-")); + const codexHome = join(root, "codex"); + const configHome = join(root, "ocx"); + mkdirSync(codexHome); mkdirSync(configHome); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = configHome; + process.env.OPENCODEX_API_AUTH_TOKEN = PROXY_KEY; + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "reserve-ingress-admin-fixture"; + const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(() => aclOk); + setAsyncIcaclsRunnerForTests(async () => aclOk); + clearState(); + writeFileSync(join(codexHome, "config.toml"), 'cli_auth_credentials_store = "file"\n'); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: { + access_token: ACCESS, account_id: ACCOUNT, refresh_token: "reserve-ingress-refresh-fixture", + } })); + const nativeFetch = globalThis.fetch; + const restores: Array<() => void> = []; + const counters: Counters = { wham: 0, credential: 0, tokenRead: 0, inference: [] }; + let liveConfig: OcxConfig | undefined; + let server: ReturnType | undefined; + let allowReserve = false; + let holdUsage: ReturnType> | undefined; + let usageStarted = deferred(); + let holdCredential: ReturnType> | undefined; + let credentialStarted = deferred(); + const sockets = new Set(); + const unexpected: string[] = []; + + const close = async () => { + holdUsage?.resolve(); + holdCredential?.resolve(); + for (const socket of sockets) socket.close(); + try { await server?.stop(true); } + finally { + globalThis.fetch = nativeFetch; + for (const restore of restores.reverse()) restore(); + clearState(); + try { await flushConfigDirHardeningForTests(); } + finally { + setIcaclsRunnerForTests(null); setAsyncIcaclsRunnerForTests(null); + for (const [name, value] of oldEnv) { + if (value === undefined) delete process.env[name]; else process.env[name] = value; + } + removeTreeWithRetry(root); + } + } + expect(unexpected).toEqual([]); + }; + + try { + const realSetLive = liveStores.setLiveStateStoreConfig; + const liveSpy = spyOn(liveStores, "setLiveStateStoreConfig").mockImplementation(config => { + liveConfig = config; + realSetLive(config); + }); + restores.push(() => liveSpy.mockRestore()); + const realToken = mainAccount.getValidMainAccountToken; + const tokenSpy = spyOn(mainAccount, "getValidMainAccountToken").mockImplementation(async options => { + counters.credential++; + expect(process.env.CODEX_HOME).toBe(codexHome); + credentialStarted.resolve(); + if (holdCredential) await holdCredential.promise; + return realToken(options); + }); + restores.push(() => tokenSpy.mockRestore()); + const realRead = authCollision.readCodexTokensResult; + const readSpy = spyOn(authCollision, "readCodexTokensResult").mockImplementation(() => { + counters.tokenRead++; + expect(process.env.CODEX_HOME).toBe(codexHome); + return realRead(); + }); + restores.push(() => readSpy.mockRestore()); + globalThis.fetch = Object.assign(async (input: Parameters[0], init?: RequestInit) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (url.href === "https://chatgpt.com/backend-api/wham/usage") { + counters.wham++; + expect(request.headers.get("x-openai-codex-luna-reserve")).toBe("1"); + expect(request.headers.get("authorization")).toBe(`Bearer ${ACCESS}`); + usageStarted.resolve(); + if (holdUsage) await holdUsage.promise; + return Response.json({ account_id: ACCOUNT, + rate_limit: { allowed: !allowReserve, primary_window: { used_percent: 20, limit_window_seconds: 18_000 } }, + ...(allowReserve ? { rate_limit_upsell: { banner_type: "luna_reserve" }, + additional_rate_limits: [{ limit_name: "gpt-reserve", rate_limit: { allowed: true } }] } : {}), + }); + } + const native = url.origin === "https://chatgpt.com" && url.pathname.startsWith("/backend-api/codex/"); + const keyed = url.origin === "https://reserve-keyed.example.test"; + if ((native || keyed) && url.pathname.endsWith("/models")) return Response.json({ models: [] }); + if ((native || keyed) && ["/responses", "/responses/compact", "/alpha/search"].some(path => url.pathname.endsWith(path))) { + const body = await request.json() as { model?: unknown; stream?: boolean }; + counters.inference.push({ path: url.pathname, authorization: request.headers.get("authorization"), model: body.model }); + if (url.pathname.endsWith("/alpha/search")) return Response.json({ results: [{ title: "fixture", url: "https://example.test/" }] }); + const response = { id: "resp_reserve_ingress", object: "response", status: "completed", model: body.model, + output: [{ id: "msg_fixture", type: "message", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "fixture response", annotations: [] }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } }; + if (url.pathname.endsWith("/compact")) return Response.json({ ...response, object: "response.compaction" }); + if (!body.stream) return Response.json(response); + const events = [{ type: "response.created", response: { ...response, status: "in_progress" } }, + { type: "response.output_text.delta", item_id: "msg_fixture", output_index: 0, content_index: 0, delta: "fixture response" }, + { type: "response.completed", response }]; + return new Response(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), + { headers: { "content-type": "text/event-stream" } }); + } + // Actual ingress uses nativeFetch below. Never send an unrecognized runtime request live. + unexpected.push(`${url.origin}${url.pathname}`); + throw new Error("Unexpected outbound request in Reserve ingress fixture"); + }, { preconnect() { /* Never open an upstream socket from a fixture hint. */ } }); + + const localPort = await findAvailablePort(0, "127.0.0.1"); + const publicPort = await findAvailablePort(0, "0.0.0.0", { reservedPort: localPort }); + expect(publicPort).not.toBe(localPort); + const hostname = options.primaryLoopback ? "127.0.0.1" : "0.0.0.0"; + const config: OcxConfig = { port: publicPort, hostname, defaultProvider: "openai", + openaiProviderTierVersion: 2, codexDesktopAuthless: true, codexMainAccountHardLock: false, + websockets: true, subagentModels: [], codexAccounts: [], codexAccountNamespaces: { main: MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET }, + unauthenticatedLoopbackListener: { enabled: true, port: localPort }, + providers: { + openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", upstreamWebsocket: false, + baseUrl: "https://chatgpt.com/backend-api/codex" }, + keyed: { adapter: "openai-responses", authMode: "key", apiKey: "sk-ingress-fixture", baseUrl: "https://reserve-keyed.example.test/v1" }, + } }; + options.configure?.(config); + saveConfig(config); + expect(loadConfig()).toMatchObject({ hostname, codexDesktopAuthless: config.codexDesktopAuthless, + codexAccountNamespaces: { main: MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET } }); + server = startServer(publicPort, { inspectNativeCodexOwnership: ownedServiceHomeInspection("Reserve dual-listener fixture") }); + await waitForNativeMainStartupGate(); + expect(isNativeMainTrafficBlocked()).toBe(false); + reconcileMainCodexAccountRuntimeState(); + observeMainQuotaCredential(ACCESS, ACCOUNT); + expect(liveConfig).toBeDefined(); + expect(liveConfig?.hostname).toBe(hostname); + const baselineDisk = readFileSync(join(configHome, "config.json"), "utf8"); + const baselineConfig = JSON.stringify(liveConfig); + counters.wham = 0; counters.credential = 0; counters.tokenRead = 0; + const publicBase = `http://127.0.0.1:${server.port}`; + const localBase = `http://127.0.0.1:${localPort}`; + + const request = async (listener: "public" | "local", transport: Transport, model: string, + headers: Record = {}, extra: Record = {}) => { + const base = listener === "public" ? publicBase : localBase; + const body = { model, input: "fixture request", stream: false, ...extra }; + if (transport !== "ws") { + const paths = { responses: "/v1/responses", compact: "/v1/responses/compact", search: "/v1/alpha/search", + chat: "/v1/chat/completions", messages: "/v1/messages" }; + const path = paths[transport]; + const payload = transport === "search" ? { model, query: "fixture query", ...extra } + : transport === "chat" || transport === "messages" + ? { model, messages: [{ role: "user", content: "fixture request" }], max_tokens: 32, stream: false, ...extra } + : body; + const response = await nativeFetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(payload), signal: AbortSignal.timeout(INTERNAL_DEADLINE_MS) }); + return { status: response.status, text: await response.text(), opened: false }; + } + return new Promise<{ status: number; text: string; opened: boolean }>((resolve, reject) => { + const socket = new WebSocket(`${base.replace("http:", "ws:")}/v1/responses`, { headers } as unknown as string[]); + sockets.add(socket); + let opened = false; + let settled = false; + const settle = (value?: { status: number; text: string; opened: boolean }, error?: Error) => { + if (settled) return; + settled = true; clearTimeout(timer); socket.close(); sockets.delete(socket); + if (error) reject(error); else resolve(value!); + }; + const timer = setTimeout(() => settle(undefined, new Error("Reserve ingress WS terminal timeout")), INTERNAL_DEADLINE_MS); + socket.addEventListener("open", () => { opened = true; socket.send(JSON.stringify({ ...body, type: "response.create", stream: true })); }); + socket.addEventListener("error", () => settle(undefined, new Error("Reserve ingress WS handshake/transport failed"))); + socket.addEventListener("message", event => { + const text = String(event.data); + try { + const data = JSON.parse(text) as { type?: string; status?: number | string }; + if (data.type === "error" || data.type === "response.failed") { + settle({ status: typeof data.status === "number" ? data.status : 500, text, opened }); + } else if (data.type === "response.completed" || (!data.type && data.status === "completed")) { + settle({ status: 200, text, opened }); + } + } catch { settle(undefined, new Error("Malformed Reserve ingress WS frame")); } + }); + socket.addEventListener("close", () => { if (!settled) settle(undefined, new Error("Reserve ingress WS closed before terminal")); }); + }); + }; + return { counters, request, close, publicBase, localBase, + allow: () => { allowReserve = true; }, + hold: () => { holdUsage = deferred(); usageStarted = deferred(); return { started: usageStarted.promise, release: () => holdUsage?.resolve() }; }, + holdCredential: () => { + holdCredential = deferred(); credentialStarted = deferred(); + return { started: credentialStarted.promise, release: () => holdCredential?.resolve() }; + }, + setAuthless: (enabled: boolean) => { + if (!liveConfig) throw new Error("Fixture server did not publish its live config"); + liveConfig.codexDesktopAuthless = enabled; + }, + assertConfigUnchanged: () => { + expect(JSON.stringify(liveConfig)).toBe(baselineConfig); + expect(readFileSync(join(configHome, "config.json"), "utf8")).toBe(baselineDisk); + }, + }; + } catch (error) { await close(); throw error; } +} diff --git a/tests/responses/reserve-dispatch-ws.test.ts b/tests/responses/reserve-dispatch-ws.test.ts new file mode 100644 index 0000000000..6bbf6cbb82 --- /dev/null +++ b/tests/responses/reserve-dispatch-ws.test.ts @@ -0,0 +1,286 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { codexWsUpstreamFetch } from "../../src/server/responses/ws-upstream"; +import { providerFetch } from "../../src/server/responses/fetch-helpers"; +import { CodexReserveHelperUnsupportedError, CodexReserveUnavailableError, createCodexReserveDispatchGuard } from "../../src/codex/auth-context"; +import { clearAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { clearCodexUpstreamHealthForAccount } from "../../src/codex/routing"; +import { clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; +import { getMainReserveAuthorization, isMainReserveAuthorizationLive } from "../../src/codex/reserve-availability"; +import type { OcxProviderConfig } from "../../src/types"; + +const URL = "https://chatgpt.com/backend-api/codex/responses"; +const realWebSocket = globalThis.WebSocket; + +class DelayedWebSocket extends EventTarget { + static instances: DelayedWebSocket[] = []; + static constructed?: (socket: DelayedWebSocket) => void; + readonly sent: string[] = []; + readonly listeners = new Set(); + closed = false; + constructor(readonly url: string, readonly options: { headers: Record }) { + super(); + DelayedWebSocket.instances.push(this); + DelayedWebSocket.constructed?.(this); + } + override addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void { + if (listener) this.listeners.add(listener); + super.addEventListener(type, listener, options); + } + override removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions): void { + if (listener) this.listeners.delete(listener); + super.removeEventListener(type, listener, options); + } + send(frame: string): void { this.sent.push(frame); } + close(): void { + if (this.closed) return; + this.closed = true; + this.dispatchEvent(new Event("close")); + } +} + +function install(): void { + globalThis.WebSocket = DelayedWebSocket as unknown as typeof WebSocket; +} + +function init(signal?: AbortSignal): RequestInit { + return { + method: "POST", signal, + headers: { authorization: "Bearer fixture-reserve", "chatgpt-account-id": "fixture-workspace" }, + body: JSON.stringify({ model: "gpt-reserve", input: "ping", stream: true }), + }; +} + +afterEach(() => { + for (const socket of DelayedWebSocket.instances) socket.close(); + DelayedWebSocket.instances = []; + DelayedWebSocket.constructed = undefined; + globalThis.WebSocket = realWebSocket; +}); + +describe("synchronous Reserve dispatch callbacks on WebSocket", () => { + test.each([true, false])("valid-proof terminal helper with enabled-at-open=%s cannot confuse helper permission with conversation permission", async enabledAtOpen => { + install(); + clearAccountNeedsReauth("__main__"); + clearCodexUpstreamHealthForAccount("__main__"); + clearMainAccountInfoCache(); + const token = { accessToken: "fixture-reserve", chatgptAccountId: "fixture-workspace" }; + observeMainQuotaIdentity(token.chatgptAccountId); + const writer = observeMainQuotaCredential(token.accessToken, token.chatgptAccountId); + let whamReads = 0; + let observations = 0; + let fallbacks = 0; + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(Object.assign(async ( + input: Parameters[0], options?: RequestInit, + ) => { + const request = new Request(input, options); + expect(request.url).toBe("https://chatgpt.com/backend-api/wham/usage"); + expect(request.headers.get("authorization")).toBe("Bearer fixture-reserve"); + expect(request.headers.get("chatgpt-account-id")).toBe("fixture-workspace"); + expect(request.headers.get("x-openai-codex-luna-reserve")).toBe("1"); + whamReads++; + return Response.json({ account_id: token.chatgptAccountId, rate_limit: { allowed: false }, + rate_limit_upsell: { banner_type: "luna_reserve" }, + additional_rate_limits: [{ limit_name: "gpt-reserve", rate_limit: { allowed: true } }], + }); + }, { preconnect() {} })); + try { + const proof = await getMainReserveAuthorization({ token, writer, observeOrdinaryQuota() { observations++; } }); + expect(isMainReserveAuthorizationLive(proof, token)).toBe(true); + if (!proof) throw new Error("Expected genuine positive conversation proof"); + const config = { codexDesktopAuthless: false }; + const ctx = { kind: "main" as const, accountId: null, reserveAuthorization: proof }; + const guard = createCodexReserveDispatchGuard(ctx, config, "gpt-reserve", { source: "loopback" }, true); + expect(guard).toBeDefined(); + const fallback = Object.assign(async () => { fallbacks++; return new Response("unexpected fallback"); }, { preconnect() {} }); + const pending = codexWsUpstreamFetch(URL, init(), fallback, "1.4.0", undefined, guard); + const observed = pending.then( + response => ({ status: "fulfilled" as const, response }), + (error: unknown) => ({ status: "rejected" as const, error }), + ); + expect(DelayedWebSocket.instances).toHaveLength(1); // Off at handshake, so the late guard must be exercised. + const socket = DelayedWebSocket.instances[0]!; + config.codexDesktopAuthless = enabledAtOpen; + socket.dispatchEvent(new Event("open")); + if (!enabledAtOpen) socket.dispatchEvent(new MessageEvent("message", { + data: JSON.stringify({ type: "response.created", response: { id: "fixture-response" } }), + })); + const outcome = await observed; + if (enabledAtOpen) { + expect(outcome.status).toBe("rejected"); + if (outcome.status !== "rejected") throw new Error("Expected terminal helper refusal"); + expect(outcome.error).toBeInstanceOf(CodexReserveHelperUnsupportedError); + expect(socket.sent).toEqual([]); + expect(socket.closed).toBe(true); + expect(socket.listeners.size).toBe(0); + } else { + if (outcome.status !== "fulfilled") throw outcome.error; + expect(outcome.response.status).toBe(200); + expect(socket.sent).toHaveLength(1); + expect(JSON.parse(socket.sent[0]!)).toMatchObject({ type: "response.create", model: "gpt-reserve" }); + await outcome.response.body?.cancel(); + } + expect(isMainReserveAuthorizationLive(proof, token)).toBe(true); + expect(whamReads).toBe(1); + expect(observations).toBe(1); + expect(fallbacks).toBe(0); + } finally { fetchSpy.mockRestore(); clearMainAccountInfoCache(); } + }); + + test("off-to-on during delayed WS open refuses the unproved create frame without fallback", async () => { + install(); + clearAccountNeedsReauth("__main__"); + clearCodexUpstreamHealthForAccount("__main__"); + const config = { codexDesktopAuthless: false }; + const guard = createCodexReserveDispatchGuard({ kind: "main", accountId: null }, config, "gpt-reserve", { source: "loopback" }); + expect(guard).toBeDefined(); + let fallbacks = 0; + const fallback = Object.assign(async () => { fallbacks += 1; return new Response("unexpected"); }, { preconnect() {} }); + const pending = codexWsUpstreamFetch(URL, init(), fallback, "1.4.0", undefined, guard); + const observed = pending.then( + () => ({ status: "fulfilled" as const }), + (error: unknown) => ({ status: "rejected" as const, error }), + ); + const socket = DelayedWebSocket.instances[0]!; + config.codexDesktopAuthless = true; + socket.dispatchEvent(new Event("open")); + const outcome = await observed; + expect(outcome.status).toBe("rejected"); + if (outcome.status !== "rejected") throw new Error("Expected dispatch refusal"); + expect(outcome.error).toBeInstanceOf(CodexReserveUnavailableError); + expect(socket.sent).toEqual([]); + expect(socket.closed).toBe(true); + expect(socket.listeners.size).toBe(0); + expect(fallbacks).toBe(0); + }); + + test("a still-disabled delayed WS open retains ordinary create behavior with an installed guard", async () => { + install(); + const config = { codexDesktopAuthless: false }; + const guard = createCodexReserveDispatchGuard({ kind: "main", accountId: null }, config, "gpt-reserve", { source: "loopback" }); + expect(guard).toBeDefined(); + let fallbacks = 0; + const fallback = Object.assign(async () => { fallbacks += 1; return new Response("unexpected"); }, { preconnect() {} }); + const pending = codexWsUpstreamFetch(URL, init(), fallback, "1.4.0", undefined, guard); + const socket = DelayedWebSocket.instances[0]!; + socket.dispatchEvent(new Event("open")); + socket.dispatchEvent(new MessageEvent("message", { + data: JSON.stringify({ type: "response.created", response: { id: "fixture-response" } }), + })); + const response = await pending; + expect(response.status).toBe(200); + expect(socket.sent).toHaveLength(1); + expect(fallbacks).toBe(0); + await response.body?.cancel(); + }); + + test("handshake refusal rejects the original error without dialing or HTTP fallback", async () => { + install(); + const refusal = new Error("local permission refused"); + let fallbacks = 0; + const fallback = Object.assign(async () => { fallbacks += 1; return new Response("unexpected"); }, { preconnect() {} }); + await expect(codexWsUpstreamFetch(URL, init(), fallback, "1.4.0", undefined, () => { throw refusal; })) + .rejects.toBe(refusal); + expect(DelayedWebSocket.instances).toHaveLength(0); + expect(fallbacks).toBe(0); + }); + + test("delayed-open refusal closes and detaches before synchronous close, with no create or fallback", async () => { + install(); + const refusal = new Error("proof revoked during upgrade"); + const abort = new AbortController(); + const removeAbort = spyOn(abort.signal, "removeEventListener"); + let checks = 0; + let fallbacks = 0; + const fallback = Object.assign(async () => { fallbacks += 1; return new Response("unexpected"); }, { preconnect() {} }); + const pending = codexWsUpstreamFetch(URL, init(abort.signal), fallback, "1.4.0", undefined, headers => { + expect(headers.get("authorization")).toBe("Bearer fixture-reserve"); + expect(headers.get("chatgpt-account-id")).toBe("fixture-workspace"); + if (++checks === 2) throw refusal; + }); + const observed = pending.then( + () => ({ status: "fulfilled" as const }), + (error: unknown) => ({ status: "rejected" as const, error }), + ); + const socket = DelayedWebSocket.instances[0]!; + socket.dispatchEvent(new Event("open")); + const outcome = await observed; + expect(outcome.status).toBe("rejected"); + if (outcome.status !== "rejected") throw new Error("Expected dispatch refusal"); + expect(outcome.error).toBe(refusal); + expect(checks).toBe(2); + expect(socket.sent).toEqual([]); + expect(socket.closed).toBe(true); + expect(socket.listeners.size).toBe(0); + expect(removeAbort).toHaveBeenCalledWith("abort", expect.any(Function)); + abort.abort(); + socket.dispatchEvent(new Event("open")); + expect(fallbacks).toBe(0); + removeAbort.mockRestore(); + }); + + test("allowed dispatch preserves the handshake guard and separate live quota observer", async () => { + install(); + const seen: string[] = []; + const quotaValues: string[] = []; + let fallbacks = 0; + const fallback = Object.assign(async () => { fallbacks += 1; return new Response("unexpected"); }, { preconnect() {} }); + const pending = codexWsUpstreamFetch(URL, init(), fallback, "1.4.0", headers => { + quotaValues.push(headers.get("x-codex-primary-used-percent")!); + }, headers => { + seen.push(headers.get("authorization")!); + }); + const socket = DelayedWebSocket.instances[0]!; + socket.dispatchEvent(new Event("open")); + expect(quotaValues).toEqual([]); + expect(seen).toEqual(["Bearer fixture-reserve", "Bearer fixture-reserve"]); + socket.dispatchEvent(new MessageEvent("message", { + data: JSON.stringify({ type: "codex.rate_limits", rate_limits: { primary: { used_percent: 37 } } }), + })); + socket.dispatchEvent(new MessageEvent("message", { + data: JSON.stringify({ type: "response.created", response: { id: "fixture-response" } }), + })); + const response = await pending; + expect(response.status).toBe(200); + expect(response.headers.get("x-codex-primary-used-percent")).toBe("37"); + expect(quotaValues).toEqual(["37"]); + socket.dispatchEvent(new MessageEvent("message", { + data: JSON.stringify({ type: "codex.rate_limits", rate_limits: { primary: { used_percent: 49 } } }), + })); + expect(quotaValues).toEqual(["37", "49"]); + expect(response.headers.get("x-codex-primary-used-percent")).toBe("37"); + expect(seen).toEqual(["Bearer fixture-reserve", "Bearer fixture-reserve"]); + expect(socket.sent).toHaveLength(1); + expect(JSON.parse(socket.sent[0]!)).toMatchObject({ type: "response.create", model: "gpt-reserve" }); + expect(fallbacks).toBe(0); + await response.body?.cancel(); + }); + + test("an upgrade failure's HTTP fallback still runs the dispatch guard", async () => { + install(); + const refusal = new Error("permission expired before fallback"); + let permitted = true; + let httpSends = 0; + let constructed!: (socket: DelayedWebSocket) => void; + const created = new Promise(resolve => { constructed = resolve; }); + DelayedWebSocket.constructed = constructed; + const provider: OcxProviderConfig & { fetch: typeof fetch } = { + adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", + fetch: Object.assign(async () => { httpSends += 1; return new Response("unexpected"); }, { preconnect() {} }), + }; + const executor = providerFetch(provider, "1.4.0", { beforeDispatch: () => { if (!permitted) throw refusal; } }); + const pending = executor(URL, init()); + const observed = pending.then( + () => ({ status: "fulfilled" as const }), + (error: unknown) => ({ status: "rejected" as const, error }), + ); + const socket = await created; + permitted = false; + socket.close(); + const outcome = await observed; + expect(outcome.status).toBe("rejected"); + if (outcome.status !== "rejected") throw new Error("Expected dispatch refusal"); + expect(outcome.error).toBe(refusal); + expect(socket.sent).toEqual([]); + expect(httpSends).toBe(0); + }); +}); diff --git a/tests/server/loopback-listener-admission.test.ts b/tests/server/loopback-listener-admission.test.ts index e0cf1f27cd..e7e676fef9 100644 --- a/tests/server/loopback-listener-admission.test.ts +++ b/tests/server/loopback-listener-admission.test.ts @@ -71,7 +71,7 @@ describe("loopback listener policy view", () => { "await handleClaudeCountTokens(req, config, policy)", ); expect(source.slice(messagesStart, chatStart)).toContain( - "await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease }, policy)", + "await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy)", ); for (const branch of [ source.slice(countTokensStart, messagesStart), diff --git a/tests/server/reserve-claude-policy.test.ts b/tests/server/reserve-claude-policy.test.ts new file mode 100644 index 0000000000..dad309467a --- /dev/null +++ b/tests/server/reserve-claude-policy.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import * as authContext from "../../src/codex/auth-context"; +import * as liveStores from "../../src/lib/state-store-registrations"; +import * as pacing from "../../src/providers/request-pacing"; +import { clearAccountQuota } from "../../src/codex/quota"; +import { clearMainAccountInfoCache, observeMainQuotaCredential } from "../../src/codex/main-account-cache"; +import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; +import { clearAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import { setMainAccountPlan } from "../../src/codex/main-account"; +import { isNativeMainTrafficBlocked, waitForNativeMainStartupGate } from "../../src/codex/native-profile-startup"; +import { startServer } from "../../src/server"; +import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { INTERNAL_DEADLINE_MS, SERVER_BUDGET_MS } from "../helpers/test-budget"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +function clearState(): void { + clearAccountQuota(); + clearMainAccountInfoCache(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearAccountNeedsReauth("__main__"); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + pacing.resetProviderRequestPacingForTest(); + setMainAccountPlan(null); +} + +/** Independent primary-loopback fixture: /v1/messages is not allowed on the secondary listener. */ +async function claudePolicyFixture() { + const names = ["OPENCODEX_HOME", "CODEX_HOME", "OPENCODEX_API_AUTH_TOKEN", "OPENCODEX_ADMIN_AUTH_TOKEN"] as const; + const oldEnv = names.map(name => [name, process.env[name]] as const); + const root = mkdtempSync(join(tmpdir(), "ocx-reserve-claude-policy-")); + const codexHome = join(root, "codex"); + const configHome = join(root, "ocx"); + mkdirSync(codexHome); mkdirSync(configHome); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = configHome; + process.env.OPENCODEX_API_AUTH_TOKEN = "ocx_data_claude_policy_fixture"; + process.env.OPENCODEX_ADMIN_AUTH_TOKEN = "claude-policy-admin-fixture"; + const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + setIcaclsRunnerForTests(() => aclOk); + setAsyncIcaclsRunnerForTests(async () => aclOk); + clearState(); + const accountId = "claude-policy-owned-account"; + const accessToken = fakeChatGptJwt({ exp: 4_000_000_000, + "https://api.openai.com/auth": { chatgpt_account_id: accountId } }); + writeFileSync(join(codexHome, "config.toml"), 'cli_auth_credentials_store = "file"\n'); + writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: { + access_token: accessToken, account_id: accountId, refresh_token: "claude-policy-refresh-fixture", + } })); + const nativeFetch = globalThis.fetch; + const restores: Array<() => void> = []; + const entered = deferred(); + const release = deferred(); + const abort = new AbortController(); + let liveConfig: OcxConfig | undefined; + let replayConfig: OcxConfig | undefined; + let policy: authContext.CodexAuthPolicyConfig | undefined; + let receivedAdmission: string | undefined; + let server: ReturnType | undefined; + const counters = { wham: 0, inference: 0 }; + const unexpected: string[] = []; + + const close = async () => { + release.resolve(); + abort.abort(); + try { await server?.stop(true); } + finally { + globalThis.fetch = nativeFetch; + for (const restore of restores.reverse()) restore(); + clearState(); + try { await flushConfigDirHardeningForTests(); } + finally { + setIcaclsRunnerForTests(null); setAsyncIcaclsRunnerForTests(null); + for (const [name, value] of oldEnv) { + if (value === undefined) delete process.env[name]; else process.env[name] = value; + } + removeTreeWithRetry(root); + } + } + expect(unexpected).toEqual([]); + }; + + try { + const realSetLive = liveStores.setLiveStateStoreConfig; + const liveSpy = spyOn(liveStores, "setLiveStateStoreConfig").mockImplementation(config => { + liveConfig = config; + realSetLive(config); + }); + restores.push(() => liveSpy.mockRestore()); + globalThis.fetch = Object.assign(async (input: Parameters[0], init?: RequestInit) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (url.href === "https://chatgpt.com/backend-api/wham/usage") { + counters.wham++; + return Response.json({ rate_limit: { allowed: true } }); + } + if (url.origin === "https://chatgpt.com" && url.pathname.endsWith("/models")) return Response.json({ models: [] }); + if (url.href === "https://chatgpt.com/backend-api/codex/responses") { + counters.inference++; + expect(request.headers.get("authorization")).toBe(`Bearer ${accessToken}`); + const body = await request.json() as { model: string; stream?: boolean }; + expect(body.model).toBe("gpt-reserve"); + const response = { id: "resp_claude_policy", object: "response", status: "completed", model: body.model, + output: [{ id: "msg_fixture", type: "message", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "fixture response", annotations: [] }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } }; + if (!body.stream) return Response.json(response); + const events = [{ type: "response.created", response: { ...response, status: "in_progress" } }, + { type: "response.output_text.delta", item_id: "msg_fixture", output_index: 0, content_index: 0, delta: "fixture response" }, + { type: "response.completed", response }]; + return new Response(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), + { headers: { "content-type": "text/event-stream" } }); + } + unexpected.push(`${url.origin}${url.pathname}`); + throw new Error("Unexpected outbound request in Claude policy fixture"); + }, { preconnect() {} }); + + saveConfig({ port: 0, hostname: "127.0.0.1", defaultProvider: "openai", openaiProviderTierVersion: 2, + codexDesktopAuthless: false, codexMainAccountHardLock: false, subagentModels: [], codexAccounts: [], + providers: { openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + upstreamWebsocket: false, baseUrl: "https://chatgpt.com/backend-api/codex" } }, + webSearchSidecar: { enabled: false, model: "global-search", timeoutMs: 12_345 }, + visionSidecar: { enabled: false, model: "global-vision", timeoutMs: 23_456 }, + claudeCode: { enabled: true, modelMap: { "reserve-policy-test": "openai/gpt-reserve" }, + webSearchSidecar: { model: "claude-search" }, visionSidecar: { model: "claude-vision" } }, + }); + server = startServer(0, { inspectNativeCodexOwnership: ownedServiceHomeInspection("Claude live-policy fixture") }); + await waitForNativeMainStartupGate(); + expect(isNativeMainTrafficBlocked()).toBe(false); + reconcileMainCodexAccountRuntimeState(); + observeMainQuotaCredential(accessToken, accountId); + if (!liveConfig) throw new Error("fixture expected the live server config"); + expect(liveConfig.hostname).toBe("127.0.0.1"); + expect(liveConfig.unauthenticatedLoopbackListener?.enabled).not.toBe(true); + const realResolve = authContext.resolveCodexAuthContext; + const authSpy = spyOn(authContext, "resolveCodexAuthContext").mockImplementation((headers, config, mode, options) => { + replayConfig = config; + policy = options?.codexAuthPolicy; + receivedAdmission = options?.admission?.source; + return realResolve(headers, config, mode, options); + }); + restores.push(() => authSpy.mockRestore()); + const realPacing = pacing.waitForProviderRequestSlot; + const pacingSpy = spyOn(pacing, "waitForProviderRequestSlot").mockImplementation(async (name, provider, model, signal) => { + if (name === "openai" && model === "gpt-reserve") { + entered.resolve(); + await release.promise; + } + return realPacing(name, provider, model, signal); + }); + restores.push(() => pacingSpy.mockRestore()); + counters.wham = 0; counters.inference = 0; + const original = liveConfig; + const request = () => nativeFetch(`http://127.0.0.1:${server!.port}/v1/messages`, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "reserve-policy-test", max_tokens: 32, + messages: [{ role: "user", content: "fixture request" }], stream: false }), + signal: AbortSignal.any([abort.signal, AbortSignal.timeout(INTERNAL_DEADLINE_MS)]), + }).then(async response => ({ status: response.status, text: await response.text() })); + return { original, request, entered: entered.promise, release: release.resolve, counters, close, + assertReplayBoundary: () => { + expect(receivedAdmission).toBe("loopback"); + expect(policy).toBe(original); + expect(replayConfig).not.toBe(original); + expect(replayConfig?.codexDesktopAuthless).toBe(false); + expect(replayConfig?.webSearchSidecar).toMatchObject({ model: "claude-search", timeoutMs: 12_345, enabled: false }); + expect(replayConfig?.visionSidecar).toMatchObject({ model: "claude-vision", timeoutMs: 23_456, enabled: false }); + expect(original.webSearchSidecar?.model).toBe("global-search"); + expect(original.visionSidecar?.model).toBe("global-vision"); + }, + }; + } catch (error) { await close(); throw error; } +} + +describe("Claude replay preserves live Reserve policy", () => { + for (const enableWhilePaced of [true, false]) { + test(`primary loopback Messages: ${enableWhilePaced ? "off-to-on refuses" : "still-off dispatches"} after replay creation`, async () => { + const fixture = await claudePolicyFixture(); + try { + const observed = fixture.request().then( + response => ({ kind: "response" as const, response }), + (error: unknown) => ({ kind: "error" as const, error }), + ); + const first = await Promise.race([ + fixture.entered.then(() => "paced" as const), observed.then(() => "finished-before-pacing" as const), + ]); + expect(first).toBe("paced"); + fixture.assertReplayBoundary(); + if (enableWhilePaced) fixture.original.codexDesktopAuthless = true; + fixture.release(); + const outcome = await observed; + if (outcome.kind !== "response") throw outcome.error; + expect(outcome.response.status).toBe(enableWhilePaced ? 429 : 200); + expect(outcome.response.text).toContain(enableWhilePaced ? "Reserve is unavailable" : "fixture response"); + expect(fixture.counters).toEqual({ wham: 0, inference: enableWhilePaced ? 0 : 1 }); + fixture.assertReplayBoundary(); + } finally { await fixture.close(); } + }, SERVER_BUDGET_MS); + } +}); diff --git a/tests/server/reserve-ingress.test.ts b/tests/server/reserve-ingress.test.ts new file mode 100644 index 0000000000..5ca09a58de --- /dev/null +++ b/tests/server/reserve-ingress.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, test } from "bun:test"; +import { isCodexReserveRequestEligible } from "../../src/codex/loopback-target"; +import type { DataPlaneAdmission } from "../../src/server/auth-cors"; +import { captureMainQuotaWriter } from "../../src/codex/main-account-cache"; +import { getMainReserveAuthorization, isMainReserveAuthorizationLive } from "../../src/codex/reserve-availability"; +import { ACCESS, ACCOUNT, EXTERNAL, PROXY_KEY, reserveIngressFixture, type Counters } from "../helpers/reserve-ingress-fixture"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; + +type Credential = "dedicated" | "bearer" | "external"; +function headers(credential: Credential): Record { + if (credential === "bearer") return { authorization: `Bearer ${PROXY_KEY}` }; + return { "x-opencodex-api-key": PROXY_KEY, + authorization: `Bearer ${credential === "external" ? EXTERNAL : ACCESS}`, + "chatgpt-account-id": credential === "external" ? "external-fixture-account" : ACCOUNT }; +} +function snapshot(counters: Counters) { + return { wham: counters.wham, credential: counters.credential, tokenRead: counters.tokenRead, inference: counters.inference.length }; +} +function delta(counters: Counters, before: ReturnType) { + return { wham: counters.wham - before.wham, credential: counters.credential - before.credential, + tokenRead: counters.tokenRead - before.tokenRead, inference: counters.inference.length - before.inference }; +} + +describe("Reserve eligibility trusts receiving-listener admission", () => { + test("default/off/client/missing admission stay off; credential source cannot become loopback", () => { + const loopback = { source: "loopback" } as const; + expect(isCodexReserveRequestEligible({}, loopback)).toBe(false); + expect(isCodexReserveRequestEligible({ codexDesktopAuthless: false }, loopback)).toBe(false); + expect(isCodexReserveRequestEligible({ codexDesktopAuthless: true, runtimeRole: "client" }, loopback)).toBe(false); + expect(isCodexReserveRequestEligible({ codexDesktopAuthless: true }, undefined)).toBe(false); + for (const source of ["dedicated", "bearer", "x-api-key"] satisfies Array) { + expect(isCodexReserveRequestEligible({ codexDesktopAuthless: true }, { source })).toBe(false); + } + expect(isCodexReserveRequestEligible({ codexDesktopAuthless: true }, loopback)).toBe(true); + }); + + for (const transport of ["responses", "compact", "ws", "search"] as const) { + for (const model of ["gpt-reserve", "main/gpt-reserve"]) { + test(`${transport} ${model}: public localhost traffic stays public; credential-bearing sibling stays local`, async () => { + const fixture = await reserveIngressFixture(); + try { + // Both sockets are dialled from 127.0.0.1. Only the RECEIVING listener differs. + for (const credential of ["dedicated", "bearer", "external"] as const) { + const before = snapshot(fixture.counters); + const result = await fixture.request("public", transport, model, headers(credential)); + const observed = delta(fixture.counters, before); + // Search's pre-existing bearer-forwarding guard fires before compatibility. + const searchAdmissionBearer = transport === "search" && credential === "bearer"; + expect(result.status).toBe(searchAdmissionBearer ? 401 : 200); + expect(observed.wham).toBe(0); + expect(observed.inference).toBe(searchAdmissionBearer ? 0 : 1); + if (transport === "ws") expect(result.opened).toBe(true); + if (model === "gpt-reserve" && credential !== "bearer") { + expect(observed.credential).toBe(0); + expect(fixture.counters.inference.at(-1)?.authorization) + .toBe(`Bearer ${credential === "external" ? EXTERNAL : ACCESS}`); + } + fixture.assertConfigUnchanged(); + } + for (const credential of ["dedicated", "bearer"] as const) { + const before = snapshot(fixture.counters); + const result = await fixture.request("local", transport, model, headers(credential)); + const observed = delta(fixture.counters, before); + const search = transport === "search"; + // A bare Direct route's existing guard rejects our proxy secret before Reserve. + // Exact-account routes use stored Pool credentials and do reach compatibility. + const earlyBearerRefusal = credential === "bearer" && (search || model === "gpt-reserve"); + expect(result.status).toBe(earlyBearerRefusal ? 401 : search ? 400 : 429); + expect(observed.wham).toBe(search || earlyBearerRefusal ? 0 : 1); + expect(observed.inference).toBe(0); + if (search) expect(observed.credential).toBe(0); + if (transport === "ws") expect(result.opened).toBe(true); + if (!search && !earlyBearerRefusal) expect(result.text).toContain("Reserve"); + fixture.assertConfigUnchanged(); + } + } finally { await fixture.close(); } + }, SERVER_BUDGET_MS); + } + } + + test("uncredentialed local Reserve acquires owned token; unmatched caller cannot acquire or infer", async () => { + const fixture = await reserveIngressFixture(); + try { + let before = snapshot(fixture.counters); + const denied = await fixture.request("local", "responses", "gpt-reserve"); + expect(denied.status).toBe(429); + expect(delta(fixture.counters, before)).toMatchObject({ wham: 1, inference: 0 }); + expect(delta(fixture.counters, before).credential).toBeGreaterThan(0); + before = snapshot(fixture.counters); + const unmatched = await fixture.request("local", "responses", "gpt-reserve", headers("external")); + expect(unmatched.status).toBe(429); + expect(delta(fixture.counters, before)).toMatchObject({ wham: 0, credential: 0, inference: 0 }); + fixture.assertConfigUnchanged(); + } finally { await fixture.close(); } + }, SERVER_BUDGET_MS); + + test("authorized local Reserve reaches HTTP, compact and actual WS inference", async () => { + const fixture = await reserveIngressFixture(); + try { + fixture.allow(); + for (const transport of ["responses", "compact", "ws"] as const) { + const before = snapshot(fixture.counters); + const result = await fixture.request("local", transport, "main/gpt-reserve", headers("dedicated")); + expect(result.status).toBe(200); + expect(delta(fixture.counters, before).inference).toBe(1); + expect(fixture.counters.inference.at(-1)?.authorization).toBe(`Bearer ${ACCESS}`); + expect(fixture.counters.inference.at(-1)?.model).toBe("gpt-reserve"); + if (transport === "ws") expect(result.opened).toBe(true); + } + expect(fixture.counters.wham).toBe(1); + fixture.assertConfigUnchanged(); + } finally { await fixture.close(); } + }, SERVER_BUDGET_MS); + + test("spoofed Host/forwarded headers and body admission/proof fields cannot select ingress", async () => { + const fixture = await reserveIngressFixture(); + try { + const spoof = { admission: { kind: "loopback", source: "loopback" }, source: "loopback", + reserveAuthorization: { expiresAt: 4_000_000_000_000 }, codexDesktopAuthless: true }; + const before = snapshot(fixture.counters); + const publicResult = await fixture.request("public", "responses", "gpt-reserve", { + ...headers("external"), host: new URL(fixture.publicBase).host, + "x-forwarded-for": "127.0.0.1", "x-forwarded-host": "localhost", + "x-opencodex-admission-source": "loopback", + }, spoof); + expect(publicResult.status).toBe(200); + expect(delta(fixture.counters, before)).toMatchObject({ wham: 0, credential: 0, inference: 1 }); + const localBefore = snapshot(fixture.counters); + const localResult = await fixture.request("local", "responses", "gpt-reserve", { + ...headers("dedicated"), "x-opencodex-admission-source": "dedicated", + }, { ...spoof, admission: { kind: "environment", source: "dedicated" }, codexDesktopAuthless: false }); + expect(localResult.status).toBe(429); + expect(delta(fixture.counters, localBefore)).toMatchObject({ wham: 1, inference: 0 }); + fixture.assertConfigUnchanged(); + } finally { await fixture.close(); } + }, SERVER_BUDGET_MS); + + test("a pending local permission read cannot contaminate concurrent public requests or shared config", async () => { + const fixture = await reserveIngressFixture(); + const gate = fixture.hold(); + const local = fixture.request("local", "responses", "gpt-reserve", headers("dedicated")); + try { + await Promise.race([gate.started, local.then(() => { throw new Error("Local request skipped permission read"); })]); + fixture.assertConfigUnchanged(); + const before = snapshot(fixture.counters); + const results = await Promise.all([ + fixture.request("public", "responses", "gpt-reserve", headers("external")), + fixture.request("public", "compact", "main/gpt-reserve", headers("dedicated")), + fixture.request("public", "ws", "gpt-reserve", headers("external")), + ]); + expect(results.map(result => result.status)).toEqual([200, 200, 200]); + expect(delta(fixture.counters, before)).toMatchObject({ wham: 0, inference: 3 }); + fixture.assertConfigUnchanged(); + gate.release(); + expect((await local).status).toBe(429); + expect(fixture.counters.wham).toBe(1); + expect(fixture.counters.inference).toHaveLength(3); + fixture.assertConfigUnchanged(); + } finally { gate.release(); await local.catch(() => undefined); await fixture.close(); } + }, SERVER_BUDGET_MS); + + test.each(["gpt-5.5", "keyed/gpt-reserve"])("ordinary/keyed %s is unchanged on both listeners", async model => { + const fixture = await reserveIngressFixture(); + try { + for (const listener of ["public", "local"] as const) { + for (const transport of ["responses", "compact", "ws"] as const) { + const before = snapshot(fixture.counters); + expect((await fixture.request(listener, transport, model, headers("dedicated"))).status).toBe(200); + expect(delta(fixture.counters, before)).toMatchObject({ wham: 0, credential: 0, inference: 1 }); + expect(fixture.counters.inference.at(-1)?.authorization) + .toBe(`Bearer ${model.startsWith("keyed/") ? "sk-ingress-fixture" : ACCESS}`); + } + } + fixture.assertConfigUnchanged(); + } finally { await fixture.close(); } + }, SERVER_BUDGET_MS); + + test.each(["chat", "messages"] as const)("translated %s: public has no Reserve WHAM; local allowlist refuses", async transport => { + const fixture = await reserveIngressFixture(); + try { + const before = snapshot(fixture.counters); + const publicResult = await fixture.request("public", transport, "gpt-reserve", headers("dedicated")); + expect(publicResult.status).toBe(200); + expect(delta(fixture.counters, before)).toMatchObject({ wham: 0, inference: 1 }); + const localBefore = snapshot(fixture.counters); + const localResult = await fixture.request("local", transport, "gpt-reserve", headers("dedicated")); + expect(localResult.status).toBe(404); + expect(delta(fixture.counters, localBefore)).toEqual({ wham: 0, credential: 0, tokenRead: 0, inference: 0 }); + // This local 404 does NOT prove admission propagation inside the translated handler. + fixture.assertConfigUnchanged(); + } finally { await fixture.close(); } + }, SERVER_BUDGET_MS); +}); + +describe("terminal routed vision helpers cannot spend Reserve", () => { + const terminal = { "x-opencodex-vision-describe": "1" }; + + test.each([ + ["chat", "openai/gpt-reserve"], ["chat", "main/gpt-reserve"], + ["responses", "openai/gpt-reserve"], ["responses", "main/gpt-reserve"], + ] as const)("%s %s refuses before credential enrichment", async (transport, model) => { + // Chat is intentionally not served by the secondary listener; use an actual primary + // loopback bind so this tests the handler, not the secondary listener's 404 allowlist. + const fixture = await reserveIngressFixture({ primaryLoopback: true }); + try { + fixture.allow(); // A permission denial must not accidentally make this test green. + const before = snapshot(fixture.counters); + const result = await fixture.request("public", transport, model, terminal); + expect(result.status).toBe(400); + expect(result.text).toContain("only available as a conversation model"); + expect(JSON.parse(result.text).error.type).toBe("invalid_request_error"); + expect(delta(fixture.counters, before)).toEqual({ wham: 0, credential: 0, tokenRead: 0, inference: 0 }); + fixture.assertConfigUnchanged(); + } finally { await fixture.close(); } + }, SERVER_BUDGET_MS); + + test.each(["chat", "responses"] as const)("%s marker survives combo child reconstruction", async transport => { + const fixture = await reserveIngressFixture({ primaryLoopback: true, configure: config => { + config.combos = { helper: { strategy: "failover", targets: [{ provider: "openai", model: "gpt-reserve" }] } }; + } }); + try { + fixture.allow(); + const before = snapshot(fixture.counters); + const result = await fixture.request("public", transport, "combo/helper", { + ...headers("dedicated"), ...terminal, + }); + expect(result.status).toBe(400); + expect(result.text).toContain("only available as a conversation model"); + expect(JSON.parse(result.text).error.type).toBe("invalid_request_error"); + expect(delta(fixture.counters, before)).toMatchObject({ wham: 0, inference: 0 }); + // Chat may enrich the unresolved combo with main auth before the concrete child is + // selected. Only the child's Reserve permission/inference work must remain zero. + fixture.assertConfigUnchanged(); + } finally { await fixture.close(); } + }, SERVER_BUDGET_MS); + + test.each(["chat", "responses"] as const)("%s keyed combo child is not refused because a later candidate is Reserve", async transport => { + const fixture = await reserveIngressFixture({ primaryLoopback: true, configure: config => { + config.combos = { helper: { strategy: "failover", targets: [ + { provider: "keyed", model: "gpt-reserve" }, { provider: "openai", model: "gpt-reserve" }, + ] } }; + } }); + try { + fixture.allow(); + const result = await fixture.request("public", transport, "combo/helper", { ...headers("dedicated"), ...terminal }); + expect(result.status).toBe(200); + expect(fixture.counters.wham).toBe(0); + expect(fixture.counters.inference).toHaveLength(1); + expect(fixture.counters.inference[0]).toMatchObject({ model: "gpt-reserve", authorization: "Bearer sk-ingress-fixture" }); + fixture.assertConfigUnchanged(); + } finally { await fixture.close(); } + }, SERVER_BUDGET_MS); + + test("off-to-on during owned auth refuses a helper even after positive Reserve authorization", async () => { + const fixture = await reserveIngressFixture({ primaryLoopback: true, configure: config => { + config.codexDesktopAuthless = false; + } }); + fixture.allow(); + const gate = fixture.holdCredential(); + const pending = fixture.request("public", "responses", "main/gpt-reserve", terminal); + const observed = pending.then( + result => ({ status: "fulfilled" as const, result }), + (error: unknown) => ({ status: "rejected" as const, error }), + ); + try { + await Promise.race([gate.started, observed.then(() => { throw new Error("Request skipped awaited owned auth"); })]); + expect(fixture.counters.credential).toBe(1); + expect(fixture.counters.wham).toBe(0); + fixture.setAuthless(true); + gate.release(); + const outcome = await observed; + if (outcome.status !== "fulfilled") throw outcome.error; + expect(outcome.result.status).toBe(429); // Late dispatch policy refusal, not a transport failure. + expect(outcome.result.text).toContain("only available as a conversation model"); + expect(JSON.parse(outcome.result.text).error.type).toBe("rate_limit_error"); + expect(fixture.counters.wham).toBe(1); + expect(fixture.counters.inference).toEqual([]); + const token = { accessToken: ACCESS, chatgptAccountId: ACCOUNT }; + const proof = await getMainReserveAuthorization({ token, writer: captureMainQuotaWriter(ACCOUNT), + observeOrdinaryQuota() { throw new Error("Expected already cached positive proof, not another WHAM read"); }, + }); + expect(isMainReserveAuthorizationLive(proof, token)).toBe(true); + expect(fixture.counters.wham).toBe(1); + expect(fixture.counters.inference).toEqual([]); + } finally { gate.release(); await observed; await fixture.close(); } + }, SERVER_BUDGET_MS); + + for (const transport of ["chat", "responses"] as const) { + test.each(["still-off", "conversation", "keyed"] as const)(`${transport} %s control retains inference`, async control => { + const fixture = await reserveIngressFixture({ primaryLoopback: true, configure: config => { + if (control === "still-off") config.codexDesktopAuthless = false; + } }); + try { + fixture.allow(); + const model = control === "keyed" ? "keyed/gpt-reserve" : "main/gpt-reserve"; + const result = await fixture.request("public", transport, model, control === "conversation" ? {} : terminal); + expect(result.status).toBe(200); + expect(fixture.counters.wham).toBe(control === "conversation" ? 1 : 0); + expect(fixture.counters.inference).toHaveLength(1); + expect(fixture.counters.inference[0]).toMatchObject({ model: "gpt-reserve", + authorization: `Bearer ${control === "keyed" ? "sk-ingress-fixture" : ACCESS}` }); + fixture.assertConfigUnchanged(); + } finally { await fixture.close(); } + }, SERVER_BUDGET_MS); + } +});