diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c9c6af8afd..87eccc7cf3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,15 +270,20 @@ jobs: - '.github/workflows/ci.yml' gui: - 'gui/**' - # Building both Linux package formats and booting their real payloads is - # substantially heavier than the Rust-only desktop-shell check. Keep it - # scoped to inputs that can change the packaged shell, dashboard or - # standalone sidecar. The workflow names itself so edits to this lane - # cannot skip their own E2E. + # Building both Linux package formats and booting their real payloads takes + # about 15 minutes, far more than the Rust-only desktop-shell check. On a + # pull request it runs only for inputs that change how the package is + # assembled or launched: the shell itself, the standalone sidecar build + # and its runtime locator, the native keyring staging, and the dependency + # set. Ordinary src/** and gui/** changes no longer select it on a pull + # request; they are still covered on every promotion push to main and + # preview and by workflow_dispatch, which always request this job. + # The workflow names itself so edits to this lane cannot skip their own E2E. desktop: - 'desktop/**' - - 'gui/**' - - 'src/**' + - 'src/lib/standalone.ts' + - 'src/lib/keyring-native.ts' + - 'src/lib/bun-runtime.ts' - 'scripts/build-standalone.ts' - 'scripts/standalone-keyring.ts' - 'scripts/standalone-targets.ts' diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 33e014aeeb0..74ed32ff04c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.73.0" +version = "2.74.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 274c198474d..81e8e2b51da 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.73.0" +version = "2.74.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4e4efd1341e..b8e6e9fe7d7 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -254,7 +254,14 @@ pub fn run() { popup::hide(app); startup::open_dashboard(app); })) - .plugin(tauri_plugin_opener::init()) + // Links are opened by the webviews' own new-window handler (`window.rs`), not by the + // plugin's injected click interceptor, which calls an IPC command the loopback dashboard + // is not granted and so swallowed every `target="_blank"` click. + .plugin( + tauri_plugin_opener::Builder::new() + .open_js_links_on_click(false) + .build(), + ) .plugin(tauri_plugin_process::init()) // The argument is what makes a login launch recognisable. Nothing else in a bare launch // distinguishes it from a person opening the app, and D7 needs the difference. @@ -316,6 +323,7 @@ pub fn run() { // to the loopback dashboard by `capabilities/dashboard-zoom.json`. .zoom_hotkeys_enabled(true) .on_navigation(window::navigation_allowed(app.handle().clone())) + .on_new_window(window::open_new_windows_in_default_browser()) // A hidden window still loads pages: wry builds this one with WebView2 // IsVisible=false, and the bootstrap page navigates to the dashboard URL // afterwards, so the eval that a later show or hide would rely on has nowhere diff --git a/desktop/src-tauri/src/popup.rs b/desktop/src-tauri/src/popup.rs index 3f5d2ff087d..5eac99d8d37 100644 --- a/desktop/src-tauri/src/popup.rs +++ b/desktop/src-tauri/src/popup.rs @@ -205,6 +205,7 @@ fn ensure(app: &AppHandle, endpoint: ProxyEndpoint) -> tauri::Result impl Fn(&Url) -> bool + Send + 'static { move |url| { if !same_origin(url, endpoint) { + // WKWebView asks this policy before it would create a window for a `_blank` link, so + // refusing here without opening is what left the popup's external links dead on macOS. + window::open_in_default_browser(url); return false; } if is_close_url(url, endpoint) { diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index 1eb2b474653..b5a90c8cbf5 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -1,5 +1,6 @@ use crate::{auth::Auth, exit, AppState}; -use tauri::{AppHandle, Manager, Url, WebviewWindow, WindowEvent}; +use tauri::webview::{NewWindowFeatures, NewWindowResponse}; +use tauri::{AppHandle, Manager, Runtime, Url, WebviewWindow, WindowEvent}; pub fn webview_user_agent() -> String { let platform = if cfg!(target_os = "macos") { @@ -50,14 +51,47 @@ pub fn navigation_allowed(app: AppHandle) -> impl Fn(&Url) -> bool { if url.scheme() == "http" && url.host_str() == Some(endpoint.host) { return url.port_or_known_default() == Some(endpoint.port); } - if matches!(url.scheme(), "http" | "https") { - let _ = tauri_plugin_opener::open_url(url.as_str(), None::<&str>); - } + open_in_default_browser(url); } false } } +/// Whether a URL the dashboard asked for belongs in the user's default browser. +/// +/// Only web addresses leave the app. Anything else a page could name (`file:`, `javascript:`, +/// a custom scheme) has no business being handed to the OS launcher from a webview. +fn opens_in_default_browser(url: &Url) -> bool { + matches!(url.scheme(), "http" | "https") +} + +pub fn open_in_default_browser(url: &Url) { + if opens_in_default_browser(url) { + let _ = tauri_plugin_opener::open_url(url.as_str(), None::<&str>); + } +} + +/// What a `window.open` or `target="_blank"` link from a shell webview does. +/// +/// The shell never grows a second webview: every such request is answered in the default +/// browser and the in-app window is denied. Without this handler the pinned wry answers the +/// request itself, and on no platform does that reach a browser: WebView2 marks it handled and +/// drops it, WebKitGTK creates nothing, and WKWebView only gets there when its navigation policy +/// happens to see the URL first. That is how the dashboard's "didn't open? open the login page" +/// link, and the device-code logins that rely on it, did nothing in the app. +/// +/// It is also why the opener plugin's click interceptor is switched off in `lib.rs`: that script +/// cancels a `_blank` click and asks for `plugin:opener|open_url` over IPC, which the loopback +/// dashboard is not granted, so the click was consumed and nothing opened. Routing links here +/// instead keeps the decision in one Rust function and adds no IPC grant to a remote origin. +pub fn open_new_windows_in_default_browser( +) -> impl Fn(Url, NewWindowFeatures) -> NewWindowResponse + Send + 'static { + |url, _features| { + open_in_default_browser(&url); + NewWindowResponse::Deny + } +} + /// The bundled `frontendDist` origin. /// /// Tauri serves it as `tauri://localhost` on macOS and Linux, and as `http://tauri.localhost` on @@ -150,13 +184,33 @@ pub fn set_tray_policy(app: &AppHandle, visible: bool) { #[cfg(test)] mod tests { - use super::{is_app_origin, is_update_page_url, webview_user_agent}; + use super::{is_app_origin, is_update_page_url, opens_in_default_browser, webview_user_agent}; use tauri::Url; fn url(value: &str) -> Url { Url::parse(value).expect("a url") } + #[test] + fn only_web_addresses_are_handed_to_the_default_browser() { + for value in [ + "https://auth.openai.com/oauth/authorize?client_id=x", + "https://github.com/login/device", + "http://127.0.0.1:1455/auth/callback", + ] { + assert!(opens_in_default_browser(&url(value)), "{value}"); + } + for value in [ + "about:blank", + "file:///etc/passwd", + "javascript:alert(1)", + "tauri://localhost/index.html", + "mailto:someone@example.com", + ] { + assert!(!opens_in_default_browser(&url(value)), "{value}"); + } + } + #[test] fn the_app_origin_is_allowed_by_both_spellings_on_every_platform() { // The custom scheme everywhere, and the http spelling WebView2 needs on Windows. The diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 69e4305acc8..87ae5a99695 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.73.0", + "version": "2.74.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/devlog/_fin/260930_desktop_external_links/010_plan.md b/devlog/_fin/260930_desktop_external_links/010_plan.md new file mode 100644 index 00000000000..74d1389b409 --- /dev/null +++ b/devlog/_fin/260930_desktop_external_links/010_plan.md @@ -0,0 +1,69 @@ +# 260930 desktop external links — plan + +## Conclusion + +Links the loopback dashboard asks to open in a new window (OAuth "didn't open?" fallback, +device-code verification links, `window.open`) now leave the desktop app through one Rust +handler that hands http/https URLs to the OS default browser. Nothing new is granted over IPC. + +## Problem + +User report: pressing a login button in the desktop app often does not open the default browser. + +Two read-only gpt-6.1-sol lanes and direct reads of the pinned crates (tauri 2.11.6, wry 0.55.1, +tauri-plugin-opener 2.5.3) agree: + +1. The server-side launch (`/api/oauth/login` -> `openUrl`) is intact for browser flows, but its + result is discarded, and device-code flows (Copilot, Kimi, Nous, Meta Muse, Kiro, Codex device) + never launch server-side by design. Both depend on the GUI's `target="_blank"` link. +2. `tauri_plugin_opener::init()` injects a click listener that `preventDefault()`s every `_blank` + click and invokes `plugin:opener|open_url`. The dashboard is the remote origin + `http://127.0.0.1:*`; its capabilities (`dashboard-titlebar.json`, `dashboard-zoom.json`) grant + no opener permission, so the IPC is denied after the click was already cancelled: nothing opens. +3. No webview installs `on_new_window`. Without it wry drops `window.open` on WebView2 + (`SetHandled(true)`) and WebKitGTK (no `create` handler); WKWebView only reaches the browser + because its navigation policy sees the URL first. + +## Options considered + +- A. Grant `opener:allow-open-url` to the remote dashboard origin. Fixes anchors only, leaves + `window.open` broken on Windows/Linux, and widens the IPC surface of a remote origin. +- B (chosen). Disable the plugin's JS interceptor (`open_js_links_on_click(false)`) and install an + `on_new_window` handler on the main window and the tray popup that opens http/https in the default + browser and returns `NewWindowResponse::Deny`. One decision point in Rust, no new grant, covers + both anchors and `window.open` on all three platforms. + +## Diff-level plan + +- `desktop/src-tauri/src/window.rs`: extract `opens_in_default_browser(&Url)` (http/https only) + and `open_in_default_browser`; reuse it in `navigation_allowed`; add + `open_new_windows_in_default_browser()` returning the handler; unit test for the scheme filter. +- `desktop/src-tauri/src/lib.rs`: build the opener plugin with `open_js_links_on_click(false)`; + add `.on_new_window(...)` to the main window builder. +- `desktop/src-tauri/src/popup.rs`: add `.on_new_window(...)` to the tray popup builder, and open + external http/https URLs in `popup_navigation_allowed` before refusing them. Audit round 1 (FAIL) + found that WKWebView consults this policy before it would create a window for a `_blank` link, so + a refusal without opening kept the popup's external links dead on macOS; round 2 passed. + +Bundled pages (`desktop/ui`) contain no `_blank` anchors or opener calls, so disabling the +interceptor removes nothing they relied on. `mailto:`/`tel:` were never reachable from the +dashboard (same denied IPC) and stay out of the external-open filter. + +## Verification + +Same steps as the CI `desktop` job: placeholder sidecar and resource files (gitignored), then +`cargo fmt --check`, `cargo clippy --all-targets -D warnings`, `cargo test` for +`desktop/src-tauri`. Exact-head PR CI is the merge gate. A packaged-app click test is not +available locally; the behavior claim rests on the pinned wry/opener sources cited above. + +## Outcome + +Implemented as planned plus the audit fold in `popup.rs`. Local proof on macOS arm64: +`cargo fmt --check`, `cargo clippy --all-targets -D warnings` and `cargo test` (190 passed, +including `only_web_addresses_are_handed_to_the_default_browser`) exit 0; the desktop, +release-contract and repo-hygiene Bun suites (24 files, 334 pass, 2 platform skips), +`privacy:scan` and `structure:check` pass. Windows and Linux behavior is source-reviewed +against wry 0.55.1 and covered by the hosted desktop CI job, not by a packaged click test. + +What this does not change: server-side `openUrl` still discards its launch result, and +device-code logins still do not auto-open a browser; both remain separate follow-ups. diff --git a/devlog/_plan/260930_codex_credits_bar/000_plan.md b/devlog/_plan/260930_codex_credits_bar/000_plan.md new file mode 100644 index 00000000000..6e990e56653 --- /dev/null +++ b/devlog/_plan/260930_codex_credits_bar/000_plan.md @@ -0,0 +1,84 @@ +# 000 — Codex credits balance on Codex Set account cards + +## Objective + +Show each Codex login's credits balance ("Credits remaining 62,500" on chatgpt.com Codex +usage settings) as a compact row directly under the existing Week quota row on the +Codex Set → Multi-auth main card and every pool card, behind one persisted page-wide +switch, the way the retired Codex Spark quota switch worked (#2649, bf73afee50). + +Scope change recorded 2026-09-30: the GPT-5.5 retirement originally bundled with this +unit was removed by the owner ("5.5는 내가 나중에 패치할께 그냥 크레딧만 진행"). For +that later patch: the live `/backend-api/codex/models?client_version=0.170.0` roster still +lists `gpt-5.5` with `visibility: list` and +`upgrade: { model: "gpt-5.6-sol", retirement_at: "2026-10-14T19:00:00Z" }`. + +## Evidence (000-range research) + +- `GET https://chatgpt.com/backend-api/wham/usage` (Bearer Codex access token + + `ChatGPT-Account-Id`) returns top-level keys `account_id, additional_rate_limits, + chatpass, code_review_rate_limit, credits, email, model_usage, plan_type, promo, + rate_limit, rate_limit_reached_type, rate_limit_reset_credits, spend_control, user_id`. + Probed 2026-09-30 with the main login; only key names and value types were printed. +- `credits` = `{ has_credits: boolean, unlimited: boolean, overage_limit_reached: boolean, + balance: string, approx_local_messages: [number, number], approx_cloud_messages: + [number, number] }`. `balance` is a decimal STRING and is fractional on pool accounts + (e.g. "62498.725", "62479.806261"). +- The official Codex usage page (read through Aside, chatgpt.com/codex/cloud/settings/usage) + renders "Credits remaining 62,500 — Credits extend usage beyond your plan limits." as a + plain number with no bar and no denominator. +- opencodex already fetches this exact response: main in + `src/codex/auth-api/main-account-probe.ts` (`fetchMainAccountInfoWhileOwned`, publish block + after `credentialIsCurrent()`), pool in `src/codex/auth-api/pool-quota-probe.ts` + (`publishPoolQuotaResponse`-style block that parses `WhamUsageResponse`). The response's + `credits` object is currently ignored. +- The closest analogue is `rate_limit_reset_credits.available_count`: main keeps it + memory-only and bound to the physical ChatGPT account id + (`rememberMainResetCredits` / `mainResetCreditsForCurrentIdentity`) because the + `__main__` alias can change identity while the proxy is down. + +## Decisions + +- D1 Storage: new sibling module `src/codex/credits.ts` with a process-local map + keyed by opencodex account id (`__main__` or pool id) and tagged with the identity it + was read from. Never persisted, never logged, never folded into `StoredAccountQuota` + (quota participates in routing, recovery and persistence; credits are display only). + No TTL: the row shows the last observation for the same identity, like reset credits. + Explicit `credits: null` clears; an absent field keeps the previous observation. +- D2 Exposure: optional `credits` on `CodexAuthAccountDto`, emitted only when + `config.showCodexCredits === true`. `/api/provider-quotas` stays unchanged (its + projection is an allowlist). The switch controls exposure only, not probing. +- D3 Switch: `showCodexCredits?: boolean`, default off (absent = off), following the + Spark precedent and the `oauthOpenBrowser` settings chain (type, zod degrade-not-reject + schema, diagnostics, GET/PUT `/api/settings` with validate-mutate-persist-rollback). + Toggle sits in the Codex Auth page head beside Pause exhausted / Refresh quotas. +- D4 Row: same `.quota-row` grid as Week: label "Credits", reset columns reused for + "remaining", a bar, and the formatted balance in the value column. There is no + denominator, so the bar is a STATUS bar, not a percentage: full (ok tone) when a + positive balance or `unlimited`; empty when the balance is zero or + `overage_limit_reached`. Value column: locale-formatted balance (max 2 fraction + digits), "Unlimited", or balance plus "· Overage limit reached". The title tooltip + carries the approx local/cloud message ranges. No `%`, no `role=progressbar`. + Architect D5 proposed a number-only row; the owner explicitly asked for "비슷한 바" + (a bar like the Week one), so the status bar is kept and documented. + +## Work-phase map (dependency order) + +| Work-phase | Doc | Delivers | +|---|---|---| +| wp0 | 000 (this), 010, 020 | Locked roadmap | +| wp2 | 010_phase1_credits_implementation.md | Parser + store + DTO + setting + GUI row + toggle + tests + docs | +| wp3 | 020_phase2_pr_ci_merge.md | PR from template with screenshot, exact-head CI, merge into `dev` | + +## Constraints + +- File-size ratchet: `gui/src/styles.css` has one line of headroom → new CSS lives in a + new stylesheet. Keep additions in the large files minimal; put logic in siblings. +- New test files must be registered in `scripts/test-layout/layout.json` `explicit` and + `tests/fixtures/test-layout-expected.json`. +- i18n: every key lands in all ten locales (en, ko, ja, zh, zh-TW, de, fr, ru, tr, vi). +- Privacy: never log WHAM bodies, balances, tokens, or account ids. +- SoT sync: `structure/providers/openai-accounts.md` (credits projection) and + `structure/config.md` (new setting) if they enumerate settings/DTO fields; + `bun run structure:check` must pass. + diff --git a/devlog/_plan/260930_codex_credits_bar/010_phase1_credits_implementation.md b/devlog/_plan/260930_codex_credits_bar/010_phase1_credits_implementation.md new file mode 100644 index 00000000000..7971063c6e3 --- /dev/null +++ b/devlog/_plan/260930_codex_credits_bar/010_phase1_credits_implementation.md @@ -0,0 +1,175 @@ +# 010 — Phase 1: credits parse, store, DTO, setting, GUI row + +Consumes 000 decisions D1–D4. Two disjoint write scopes so two workers can run in parallel. + +## 010.S Server scope (worker S) + +NEW `src/codex/credits.ts` +```ts +export interface CodexCredits { + hasCredits?: boolean; + unlimited?: boolean; + overageLimitReached?: boolean; + /** Decimal string exactly as upstream sent it after validation, e.g. "62498.725". */ + balance?: string; + approxLocalMessages?: [number, number]; + approxCloudMessages?: [number, number]; +} +/** undefined = field absent (keep previous); null = explicit null or unusable (clear). */ +export function parseCodexCredits(raw: unknown): CodexCredits | null | undefined; +export function rememberCodexCredits(accountId: string, identity: string, parsed: CodexCredits | null | undefined): void; +export function codexCreditsFor(accountId: string, identity: string | null): CodexCredits | undefined; +export function pruneCodexCredits(liveAccountIds: Iterable): void; +export function resetCodexCreditsForTests(): void; +``` +Rules: balance accepted when a string matching `/^\d+(\.\d+)?$/` or a finite +non-negative number (stringified); otherwise the field is dropped. Approx ranges must be +two finite non-negative numbers. An object with no usable field parses to `null`. +Identity mismatch in `codexCreditsFor` deletes the entry and returns undefined. + +MODIFY `src/codex/quota.ts`: `WhamUsageResponse` gains `credits?: unknown` (typing only). + +MODIFY `src/codex/auth-api/main-account-probe.ts`: next to +`rememberMainResetCredits(requestAccountId, freshResetCredits)` (credential-current branch +only) add +`if (requestAccountId !== null) rememberCodexCredits(MAIN_CODEX_ACCOUNT_ID, requestAccountId, parseCodexCredits(data.credits));` + +MODIFY `src/codex/auth-api/pool-quota-probe.ts`: in the WHAM publish function, after the +`mayPublish` guard and only when `isCodexAccountGenerationLive(accountId, generation)`, +`rememberCodexCredits(accountId, accountId, parseCodexCredits(data.credits));` — this must +run whether or not `parseUsageQuota` produced a quota. + +MODIFY `src/codex/auth-api/account-list.ts`: +- `CodexAuthAccountDto` gains `credits?: CodexCredits`. +- helper `codexCreditsDtoField(config, accountId, identity)` (in credits.ts or here) returns + `{ credits }` only when `config.showCodexCredits === true` and a value exists. +- `poolAccountDto`: spread `codexCreditsDtoField(config, account.id, account.id)`. +- main DTO: spread `codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getMainChatgptAccountId())`. +- `listCodexAuthAccountsSnapshot`: `pruneCodexCredits([MAIN_CODEX_ACCOUNT_ID, ...pool ids])`. + +Setting chain (mirror `oauthOpenBrowser` / retired `showCodexSparkQuota`): +- `src/types/config.ts`: `showCodexCredits?: boolean` with a doc comment (display only, default off). +- `src/config/schema/config-schema.ts`: `showCodexCredits: z.boolean().optional().catch(false)`. +- `src/config/diagnostics.ts` / `src/config/load-degrade.ts`: add the same malformed-value + diagnostic/degrade treatment the neighbouring booleans have, if that chain is exhaustive. +- `src/server/management/config-routes.ts`: GET `/api/settings` returns + `showCodexCredits: config.showCodexCredits === true`; PUT accepts a boolean, adds it to the + "provide ..." list and type check, mutates, persists and restores prior value/presence on + save failure. Check `src/server/auth-cors.ts` ~1260 for a second settings projection. + +Tests (new files, registered in both layout manifests, domain `codex-integration`): +- `tests/codex-integration/codex-credits.test.ts`: parser (integer string, fractional + string, numeric, zero, negative/garbage, absent, null, empty object, approx ranges), + store identity binding (mismatch clears), null clears, absent keeps, prune, DTO field + on/off. +- `tests/codex-integration/codex-credits-settings.test.ts`: GET default false, PUT true + persists and GET reflects it, PUT non-boolean → 400, save failure rolls back. + +## 010.G GUI scope (worker G) + +- MODIFY `gui/src/hooks/useCodexAccountPool.ts`: `CodexAccountEntry.credits?: CodexCredits` + (GUI copy of the type). +- NEW `gui/src/components/CodexCreditsRow.tsx`: renders + `
` containing one + `.quota-row.quota-row--codex-credits` with: label `t("codexAuth.credits")`, reset-label + column `t("codexAuth.creditsRemaining")`, empty day/time columns, a `.bar` whose fill is + 100% (`ok` tone) for positive balance or unlimited and 0% otherwise, and a value span + with the formatted balance (`Intl.NumberFormat(locale, { maximumFractionDigits: 2 })`), + `t("codexAuth.creditsUnlimited")`, or balance + ` · ` + `t("codexAuth.creditsOverage")`. + `title` = approx ranges via `t("codexAuth.creditsApprox", {...})` when present. Returns + null when `credits` is undefined or has neither balance nor unlimited. +- NEW `gui/src/styles/codex-credits.css` (imported by the component): pull the slot up + under the quota slot (no double padding) and keep the value column nowrap. +- MODIFY `gui/src/components/codex-account-pool-main-card.tsx` and + `gui/src/components/codex-account-pool-cards.tsx`: render + `` right after the + non-pending ``. +- NEW `gui/src/hooks/useCodexCreditsVisibility.ts`: GET `/api/settings` with + AbortController (undefined until loaded), `toggle()` optimistic PUT + `{ showCodexCredits }`, reconcile with server answer, revert on failure, then reload + accounts (callback). Feedback via existing `showActionFeedback`. +- MODIFY `CodexAccountPool.tsx` + `CodexAccountPoolPageHead`: pass + `creditsVisible/creditsBusy/onToggleCredits`; render the labelled `toggle` switch + exactly like the Spark one (class `codex-auth-credits-toggle`, styles in the new css). +- i18n (all ten locales): `codexAuth.credits`, `codexAuth.creditsRemaining`, + `codexAuth.creditsUnlimited`, `codexAuth.creditsOverage`, `codexAuth.creditsApprox`, + `codexAuth.creditsToggle`, `codexAuth.creditsToggleHint`, `codexAuth.creditsShown`, + `codexAuth.creditsHidden`, `codexAuth.creditsToggleFailed`. +- Tests: `gui/tests/codex-credits-row.test.tsx` (render positive/fractional/zero/unlimited/ + overage/undefined; no "%"), plus a toggle hook/page-head test if the gui test harness + supports it. + +## Accept criteria (activation scenarios) + +1. Main + pool WHAM responses carrying `credits` populate the store (unit tests call the + publish functions or the store directly with fixture payloads). +2. `showCodexCredits` absent → `/api/codex-auth/accounts` rows carry no `credits`; + true → rows carry `credits.balance` as a string. +3. Toggle PUT persists to config and survives reload; bad body → 400; save failure → prior + value restored. +4. GUI: switch on → row under Week with "62,500" style value; off → no row. +5. Gates: `bun run typecheck`, focused tests, `bun run test:changed`, + `bun run structure:check`, `bun run privacy:scan`, `cd gui && bun test` focused, + `bun run lint:gui`, `bun run build:gui`, file-size ratchet test. + + +## 010.R Architect reflection fold (supersedes the matching lines above) + +Architect verdict on the first revision: MISALIGNED with seven gaps; main dispositions: + +1. ACCEPT — `WhamUsageResponse` lives in `src/codex/quota-types.ts:108` (`quota.ts` re-exports). + Add `credits?: unknown` there, not in `quota.ts`. +2. ACCEPT — pool identity is the credential's `quotaHistoryIdentity`, which rotates when a + record's `chatgptAccountId` changes (`src/codex/account-store.ts:409,470`). Write with + `ctx.poolWriter?.historyIdentity` (skip the write when no writer was captured); read with + `poolQuotaHistoryIdentity(account.id)` (`account-store.ts:343`). Main stays bound to the + physical ChatGPT account id (`requestAccountId` on write, `getMainChatgptAccountId()` on read). +3. ACCEPT — pool insertion point: `commitPoolQuotaResponse` in + `src/codex/auth-api/pool-quota-probe.ts`, immediately after the `mayPublish` early return + and BEFORE the `if (!quota)` return, guarded by + `isCodexAccountGenerationLive(accountId, generation)`, so credits-only payloads publish. +4. ACCEPT — "directly under Week": add an optional `afterWeekly?: ReactNode` prop to + `QuotaBars` (compact layout only). It renders right after the row whose + `windowKey === "weekly"`; when there is no weekly row it renders after the last row; when + there are no rows it renders alone inside the same slot. `CodexCreditsRow` therefore renders + only a bare `.quota-row.quota-row--codex-credits` (no own slot), and the two cards pass + `afterWeekly={}` instead of appending a sibling. +5. ACCEPT — state precedence: overage first (empty bar, value = balance when present then + "· Overage limit reached", or the overage text alone), then unlimited (full bar, + "Unlimited"), then balance (full when > 0, empty at 0). An overage-only or unlimited-only + observation still renders; only "nothing usable" returns null. +6. ACCEPT — settings chain is not conditional: add `showCodexCreditsError` next to + `oauthOpenBrowserError` and register it in the `boundaryError` chain at + `src/config/diagnostics.ts:647`; schema entry `z.boolean().optional().catch(false)` in + `config-schema.ts`. `oauthOpenBrowser` has no `warnDegraded*` helper, so none is added. + `/api/config` `safeConfigDTO` (`src/server/auth-cors.ts:1260`) also projects + `showCodexCredits: config.showCodexCredits === true` for parity with its neighbours. +7. ACCEPT — tests must exercise wiring, not only the store: main probe publish with a + credits fixture (current credential → stored; stale credential → not stored), pool + `commitPoolQuotaResponse` with credits-only payload, dead generation (not stored), + identity rotation (read returns undefined), and omission (previous kept). SoT sync is + mandatory: update `structure/providers/openai-accounts.md` (credits projection + identity + binding) and `structure/config.md` (new setting); review GUI ownership doc for the + Codex Set cards; regenerate `structure/INDEX.md` only if `manifest.json` changes. + + +## 010.A Independent audit fold (reviewer 01a0f17d, VERDICT NEAR-PASS) + +1. ACCEPT — the cards gate on the switch as well as the DTO: `CodexAccountPool` passes + `creditsVisible` to the main card and pool cards, and they pass `afterWeekly` only when + `creditsVisible === true && account.credits`. A successful disable followed by a failed + account reload must hide the row (GUI test). +2. ACCEPT — `afterWeekly` integration tests in `gui/tests/codex-credits-row.test.tsx`: order + Week → Credits → Monthly/custom; no-week fallback; credits with `quota: null` still renders + (the cards stop passing `pending` when there are credits to show and the account is not + loading); stacked layout unchanged (prop ignored). +3. ACCEPT — validation adds `cd gui && bun run lint:i18n`, `cd gui && bun test tests`, and root + `bun run test` (or the AGENTS.md resource exception, recorded with exact commands). Public + docs: the Codex Set / multi-account guide in `docs-site/` (English + existing translated + pages that describe the Codex Auth page head) gets one paragraph on the credits switch; + `structure/gui-and-management-api.md` reviewed for the `/api/settings` key list. +4. ACCEPT — before merge, dispatch an explicit security review of the final diff (credential- + bound publication, `safeConfigDTO`, no logging) and record it in the PR Verification. +5. ACCEPT — successful PUT `/api/settings` response (`config-routes.ts` ~724) includes + `showCodexCredits`. + diff --git a/devlog/_plan/260930_codex_credits_bar/020_phase2_pr_ci_merge.md b/devlog/_plan/260930_codex_credits_bar/020_phase2_pr_ci_merge.md new file mode 100644 index 00000000000..c1e5342610c --- /dev/null +++ b/devlog/_plan/260930_codex_credits_bar/020_phase2_pr_ci_merge.md @@ -0,0 +1,18 @@ +# 020 — Phase 2: PR, exact-head CI, merge + +1. Commit wp2 in small commits on `codex/codex-credits-bar`; rebase onto latest + `origin/dev` if it moved; rerun focused gates after rebase. +2. Capture a GUI screenshot (switch on, main + pool rows) from a local proxy started with + an isolated `OPENCODEX_HOME` or the live dashboard after deploying nothing; upload it + through the `pr-assets` branch and link by commit SHA (never commit it to the PR branch). +3. Open the PR against `dev` with `.github/PULL_REQUEST_TEMPLATE.md` sections (Summary, + Verification, Checklist) filled, screenshot embedded. +4. Watch required checks on the exact PR head; fix failures at root cause; never treat + skipped/cancelled/queued as passing. +5. Merge (owner authorized) once required checks are green on the exact head; record the + merge SHA; close the unit into `devlog/_fin/` in a follow-up only if the repo + convention requires it. + +6. Security review gate (010.A item 4): an independent reviewer reads the final diff for + credential-bound publication, config serialization and logging before merge; its verdict + is quoted in the PR Verification section. diff --git a/devlog/_plan/260930_grok47_build_unify/000_plan.md b/devlog/_plan/260930_grok47_build_unify/000_plan.md new file mode 100644 index 00000000000..882d6847d1e --- /dev/null +++ b/devlog/_plan/260930_grok47_build_unify/000_plan.md @@ -0,0 +1,154 @@ +# 260930 Grok 4.7 build-fast unification — plan (revision 4) + +xAI's Grok OAuth gateway lists two Grok 4.7 ids, `grok-4.7` and `grok-4.7-build-fast`, so the xAI model list +shows the same model twice. Live probes (010_probe-evidence.md) show one model on two serving lanes: the +same effort ladder, image input, 500k limit and advertised defaults, while build-fast returns its first +token in about half the time and streams about 1.6x faster. They also show that today's Grok 4.7 Fast +(`service_tier: priority` on `grok-4.7`) costs about 5.9x the ticks per output token with no measurable +speed gain, while build-fast without priority costs about 2x. This unit keeps one visible row, `grok-4.7`, +and makes its Fast selection (`xai/grok-4.7--fast`, a caller `service_tier: priority`, or global +`fastMode`) dispatch `grok-4.7-build-fast` without a service tier on the Grok OAuth lane. API-key users keep +priority processing, and explicit `grok-4.7-build-fast` requests keep routing. + +## Loop spec + +- Archetype: satisfy-spec, single work-phase wp1, one PABCD cycle, one PR to dev. +- Trigger: user request 2026-09-30 "xai 프로바이더에 grok 4.7 이랑 grok 4.7 build가 두개 있는데 ... 4.6 의 처리방법 + 속도 이런걸 너가 마음껏 프로브 해보고 하나로 통합하는 작업하고 pr 올려놔", cxc-loop HOTL, unlimited gpt-6.1-sol dispatch. +- Goal: one Grok 4.7 row plus its --fast row; Fast on OAuth uses the measured faster and cheaper lane. +- Non-goals: Cursor/Devin/Command Code/OpenCode Go 4.7 rows; priority behavior of other xAI models (4.6 etc.); + key-auth behavior; a build-fast price row; native Chat (OAuth is ineligible, chat-native-eligibility.ts:37); + merge, release, service restart, config mutation. +- Verifier: focused bun tests (new + touched files below), `bun run typecheck`, `bun run test:changed`, + `bun run structure:check`, `bun run privacy:scan`; exact-head hosted CI on the PR. +- Stop: PR open to dev, template complete, exact-head CI reported. +- Memory artifact: this unit (000 plan, 010 probe evidence); goalplan unify-the-duplicated-xai-grok-4-7-rows-in-openco. +- Terminal outcomes: DONE (PR + CI green or fixed); BLOCKED (push refused). NOOP ruled out by 010. +- Escalation: CI failure needing a design change; evidence that the swap breaks OAuth continuations. +- HOTL bounds: repo edits, local bun gates, gh; write scope = files below; no user-set token/time budget. + +## Architect consultation + +Handle 01a0f176-87b5-7142-89ab-d8297ec852d8 (Descartes, gpt-6.1-sol). Proposal D1–D9; reflection on revision 1: +MISALIGNED (gaps 1–5). Dispositions, revision 2: + +- D1 amend (gap 1 partly rebutted): hide with the existing publication hook `shouldExposeProviderModel` + (model-visibility.ts:189; grok-4.20-multi-agent-beta-latest precedent). It filters every discovered row + before the cache write on the xAI path (provider-models.ts:771 -> setCached at :788), and the cache is + in-memory only (model-cache.ts:51), so a stale pre-upgrade cache cannot survive the restart that loads + this code. Rows re-added by an explicit `retainModels` entry, a combo target, or a user-created custom row + are explicit user configuration and stay visible on purpose; that keeps D5's routing promise without a + selection-rewrite rule. Known limitation recorded in docs: a user who had enabled only build-fast must + enable grok-4.7 (and use its Fast row). +- D2 accept, revision 3 (round-2 gap 1 accepted): the logical id owns ALL policy, through serialization. + `parsed.modelId` and `route.modelId` stay `grok-4.7`, so every adapter lookup keyed on them — effort + remap (passthrough.ts:285, reasoning.ts:284-296; openai-chat.ts:151), sampling strips (passthrough.ts:342, + openai-chat.ts:142-148), summary delivery (passthrough.ts:354,493), web-search normalization (:419) and + identity naming (:283) — resolves against grok-4.7 exactly as today, including operator overrides. + Only the serialized `model` field changes: the helper writes `raw.model` (the passthrough forwards the + raw body, passthrough.ts:543) and sets a new private `parsed._wireModelOverride`, which the openai-chat + adapter reads in its one `model:` line (openai-chat.ts:108). No other consumer reads the override. +- D3 amend -> **B** (gap 2 accepted): C loses the agreed gate against B on TTFT, and 010's cost table shows + priority multiplies ticks per output token ~5.9x on both ids. Fast = build-fast with the service tier removed. +- D4 amend: provider-owned helper `src/providers/xai-fast-model.ts` applied at the tail of + `applyFinalRouteRequestNormalization` (after `decideTier` and `applyServiceTierGate`, core-normalize.ts:235-248). + Every OAuth inbound reaches it: Responses/WebSocket (websocket-handler.ts:333), Chat (chat-completions.ts:427) + and Claude (claude-messages.ts:1221) through handleResponses -> request-prepare.ts:1181; retries rebuild + from the same `parsed` (adapter-dispatch.ts:473, passthrough-dispatch.ts:1028). It sets + `tierDecision = {kind:"drop"}` so both writers omit service_tier (canonical-forward.ts:27-28; openai-chat.ts:74-124), + and replaces the observation's fast wire with an internal + `{kind:"model-variant", canonicalToWire:{priority:"grok-4.7-build-fast"}, foreignCallerTiers:"drop"}` + and `responseTierAuthoritative:false`, captured before the adapters serialize. +- New FastWire kind `model-variant` (types/provider.ts:201): internal only — the runtime validator keeps + rejecting it in config (fastwire.ts:523), FAST_WIRE_ADAPTERS maps it to openai-chat/openai-responses, + usage/log.ts:646/666 accepts it on read. A helper `emittedFastWire(parsed, body)` in fastwire.ts reports + `model-variant` + the model id when the serialized body carries the variant, otherwise the old + service-tier/null result; passthrough.ts:537 and openai-chat.ts:235 call it (net zero lines in + openai-chat.ts, cap 822). createAdapterTierMetadata then records fastOutcome applied / confirmation + assumed, the Cursor precedent (cursor.ts:138-145), instead of a false "downgraded". +- D5 accept: explicit build-fast requests route unchanged; build-fast gains the probed OAuth Responses + `modelWireDefaults` entry so a legacy direct request stops falling back to Chat. `modelSupportsServiceTier` + stays unset for build-fast (priority costs ~6x for no measured gain), so no build-fast --fast row appears. +- D6 accept: predicate `route.providerName === "xai" && route.provider.authMode === "oauth"`, the transport's + own gateway selector (xai-transport.ts:124,136,176). +- D7 accept (gap 4 usage; corrected in revision 4 per audit finding 3): the attempt stays keyed to logical + grok-4.7 (request-transport.ts:804) and `logCtx.wireModel` records build-fast. An applied/assumed + outcome does map to requestedServiceTier "priority" regardless of kind (cost.ts:450), but xAI's priority + price rule requires a response-confirmed tier (expected-prices.ts:619, cost.ts:526), and the model-variant + observation sets `responseTierAuthoritative:false`, so it can never confirm. The estimate therefore stays + at grok-4.7's base rate — a comparison figure, as every OAuth estimate already is. Tested with a complete + outcome, including an upstream echo of "priority". No build-fast price row. +- Compaction (revision 4, audit finding 1): routed compaction reaches handleResponses with + `_compactionRequest` (compact.ts:1414-1438, parser.ts:627/661) and follows the same Fast mapping on + purpose: it is the same conversation on the same model, and 010 shows the alternative (priority on + grok-4.7) costs ~5.9x for no speed. A captured-body regression pins it. +- Client model echo (revision 4, audit finding 4): translated deliveries (Chat, Claude, buffered) answer with + the logical `grok-4.7` (adapter-delivery.ts:262); the Responses passthrough relays the upstream's own + `model`, which is `grok-4.7-build-fast` for Fast turns, the same way plain grok-4.7 turns already relay + `grok-4.7-build` (010). Intentional and asserted in tests; no response rewriting. +- D8 accept (gap 5): serialized-body tests below, plus visibility tests on the discovery path. +- D9 accept: structure/providers/xai-grok.md plus the public docs-site page that describes Grok/xAI Fast. + +## File change map (dependency order) + +1. src/types/provider.ts — FastWire.kind adds "model-variant" with a doc line. +2. src/providers/fastwire.ts — FAST_WIRE_ADAPTERS["model-variant"]; `emittedFastWire(parsed, body)` helper. +3. src/usage/log.ts — accept "model-variant" in normalizeAttemptTierOutcome. +4. src/providers/xai-fast-model.ts (new) — XAI_OAUTH_FAST_MODELS map, `xaiOauthFastModel(providerName, provider, modelId)`, + `applyXaiOauthFastModel(parsed, route, logCtx)` (writes raw.model + parsed._wireModelOverride, never parsed.modelId). + It is idempotent per final route: when the current route does not qualify but a previous route in the + same request set `_wireModelOverride` (combo/fallback re-normalization), it restores `raw.model = + route.modelId` and clears the override — core-normalize.ts:136 only rewrites `raw.model` when + `route.modelId !== parsed.modelId`, so a same-id fallback (e.g. xai/grok-4.7 on key auth) would otherwise + inherit build-fast. On that restore it also deletes `logCtx.wireModel` only when it still equals the + value the helper installed (another normalizer's later annotation is preserved). Tested for both the + outbound model and the logged identity. +4a. src/types (OcxParsedRequest) — optional `_wireModelOverride?: string`. +5. src/server/responses/core-normalize.ts — call (4) after applyServiceTierGate. +6. src/adapters/openai-responses/passthrough.ts, src/adapters/openai-chat.ts — use `emittedFastWire`; + openai-chat `model:` line prefers `parsed._wireModelOverride` (same line, net zero). +7. src/codex/catalog/model-visibility.ts — hide build-fast via the map values of (4). +8. src/providers/registry/entries-core.ts — comments; build-fast OAuth Responses modelWireDefaults. +9. Tests: grok-47-build-fast-metadata.test.ts (wire parity now expected, tier still absent); + new tests/providers/xai/grok-47-fast-model.test.ts — helper matrix (OAuth+Fast swaps; no Fast, fastMode + false, key auth, grok-4.6, explicit build-fast, non-xai unchanged), emittedFastWire + createAdapterTierMetadata + outcome applied/assumed and log normalization round-trip, visibility hook; new + tests/providers/xai/grok-47-fast-model-wire.test.ts — execution tests that capture the ACTUAL + outbound body from a mocked upstream (harness chosen by the explorer lane) for: Responses inbound, + Chat-translated, Claude-translated, WebSocket inbound, OAuth 401 replay, and a combo child targeting + xai/grok-4.7; each asserts model = build-fast, no service_tier, and that effort remap / strips were keyed + on grok-4.7 (a divergent operator override on grok-4.7 is honored while build-fast's registry row differs); + and asserts the logged attempt tierOutcome is model-variant/applied/assumed. Key auth, no Fast and + fastMode:false keep today's body. A pricing case proves a model-variant outcome does not trigger the + priority multiplier (complete applied/assumed/non-authoritative outcome with a "priority" echo, through + the real xAI estimate), a routed compaction (`compaction_trigger`) request under global Fast and caller + priority, and the client-visible model on passthrough vs translated delivery. Continuation: 010 shows no + model-id boundary (local expansion keyed by response id, state.ts:1056); a proxy-level test proves a + previous_response_id turn after a Fast toggle expands history and sends the variant. + Register both files in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json. +10. structure/providers/xai-grok.md; structure/transports/responses-wire-shapes.md:68 (Grok 4.7 no longer forwards + service_tier for OAuth Fast); review the other manifest owners of FastWire/adapters/usage and edit only + inaccurate text; docs-site English page for Grok Fast (+ locales if the paragraph exists there). + +## Acceptance + +- A1 visibility: discovery for xai returning both ids yields one grok-4.7 row (hook test on the list the + discovery path filters; activation = discovery output containing build-fast). +- A2 Fast dispatch: an OAuth grok-4.7 request with Fast intent serializes `model: grok-4.7-build-fast` and no + `service_tier` in both adapters; tier outcome = model-variant / applied / assumed. +- A3 no regression: key auth, no Fast, fastMode false, grok-4.6, explicit build-fast unchanged; existing xai, + fastwire and usage suites pass. +- A4 gates exit 0: focused tests, typecheck, test:changed, structure:check, privacy:scan. + + +## Reflection + +Architect 01a0f176-87b5-7142-89ab-d8297ec852d8, three rounds on this plan: + +- Revision 1: MISALIGNED, gaps 1-5 (visibility retention, D3 gate selects B, native Chat bypass, policy + keying and usage identity, execution tests/docs). Dispositions recorded in "Architect consultation". +- Revision 2: MISALIGNED, 2 gaps (passthrough effort remap keyed on physical id; execution-level tests and a + pricing case). Both accepted in revision 3 (logical id kept in parsed.modelId; test list expanded). +- Revision 3: MISALIGNED, 1 gap (stale logCtx.wireModel after a non-qualifying re-normalization). Accepted + and folded above (revision 3.1). All earlier objections were reported resolved; continuation stays an + evidence item carried into A/C. diff --git a/devlog/_plan/260930_grok47_build_unify/010_probe-evidence.md b/devlog/_plan/260930_grok47_build_unify/010_probe-evidence.md new file mode 100644 index 00000000000..540c63bc56f --- /dev/null +++ b/devlog/_plan/260930_grok47_build_unify/010_probe-evidence.md @@ -0,0 +1,98 @@ +# 010 — Live probe evidence (2026-09-30, KST) + +All probes ran against the user's Grok OAuth subscription (credential source grok-oauth). "Direct" rows call +`https://cli-chat-proxy.grok.com/v1` with the same compatibility headers `src/providers/xai-transport.ts` +builds; "proxy" rows go through the running ocx 2.69.0 on 127.0.0.1:10100. No tokens, account/team ids or +request bodies are recorded here; raw per-request rows were kept in the gitignored `.tmp/grok47/` scratch +directory. Lanes: capability (86 requests), speed (streaming, interleaved round-robin, sequential), +cost/continuation (14 + follow-up). + +## Identity and capability (direct unless noted) + +| Check | grok-4.7 | grok-4.7-build-fast | grok-4.6 (reference) | +|---|---|---|---| +| Listed by upstream `/v1/models` | yes | yes | yes | +| Advertised context / backend / default effort | 256000 / responses / high | 256000 / responses / high | 256000 / responses / high | +| Advertised effort ladder | low..xhigh | low..xhigh | — | +| Served-model echo (Responses and Chat) | `grok-4.7-build` | `grok-4.7-build-fast` | `grok-4.6-build` | +| `grok-4.7-build` requested directly | 404 "does not exist or your team … does not have access" | — | — | +| Effort accepted (R and C) | minimal, low, medium, high, xhigh | minimal, low, medium, high, xhigh (C xhigh not run) | low..high checked | +| Effort rejected | none ("does not support reasoning_effort value none"), max ("Invalid reasoning effort.") | identical | — | +| 32×32 PNG input | 200 | 200 | — | +| 16×16 PNG input | 400 "below the minimum of 512 pixels" | identical | — | +| `stop` / `presence_penalty` on direct Responses | 200 / 200 | 200 / 200 | — | +| ~521k-token prompt | 400 "(521246 tokens > 500000 tokens)" | identical message | — | +| `service_tier: priority` echo (R and C) | priority | priority | priority | + +The gateway's advertised 256000 context disagrees with the enforced 500000 limit on both ids; the registry +keeps the measured 500000 for both. Parameter acceptance on the direct Responses wire does not overturn the +existing Chat-wire `stop`/penalty rejections recorded in the registry; those lists are unchanged. + +Proxy today (before this unit): `xai/grok-4.7-build-fast` routes over the openai-chat adapter (no wire pin), +and both `xai/grok-4.7-build-fast--fast` and an explicit priority tier return 200 with the tier echo visible in +telemetry but not in the client body. The dashboard lists build-fast as a second, disabled Grok 4.7 row. + +## Speed (direct, streaming, long prompt: 40 one-line facts, sequential) + +Round 1 of the interleaved matrix (later rounds were still running when this doc was written; see the +update block below). TTFT is time to the first visible text delta; "all tok/s" counts reasoning + visible +output over total time. + +| Model | Wire | Tier | Effort | TTFT s | Total s | all tok/s | +|---|---|---|---|---:|---:|---:| +| grok-4.7 | R | default | low | 36.9 | 41.3 | 84.6 | +| grok-4.7 | R | priority | low | 35.7 | 40.0 | 83.1 | +| grok-4.7-build-fast | R | default | low | 18.6 | 21.3 | 126.8 | +| grok-4.7-build-fast | R | priority | low | 22.5 | 24.8 | 140.0 | +| grok-4.7 | R | default | high | 47.6 | 52.3 | 87.6 | +| grok-4.7 | R | priority | high | 59.4 | 63.4 | 89.8 | +| grok-4.7-build-fast | R | default | high | 31.3 | 33.8 | 144.2 | +| grok-4.7-build-fast | R | priority | high | 27.3 | 29.1 | 158.3 | +| grok-4.7 | C | default / priority | low | 30.0 / 41.5 | 34.4 / 46.4 | 76.5 / 79.7 | +| grok-4.7-build-fast | C | default / priority | low | 28.5 / 19.5 | 31.3 / 22.1 | 134.9 / 145.8 | +| grok-4.6 | R | default / priority | low | 58.7 / 12.3 | 63.1 / 22.6 | 67.4 / 55.1 | + +Short prompts (non-streaming, 20 facts, effort low, N=2): grok-4.7 6.1–6.6 s, build-fast 3.4–4.0 s. +Visible-text streaming rate on Responses: grok-4.7 ~106–119 tok/s, build-fast ~177–209 tok/s. + +Reading: build-fast is 1.5–1.7x faster end to end at every effort and on both wires. Priority processing on +`grok-4.7` produced no measurable speedup. Priority on build-fast was mixed (TTFT worse at low, better at +high, throughput +10%). + +## Cost ticks (direct, non-streaming, effort low, N=2, identical 1259-token input) + +| Model | Tier echo | Output tokens | Cost ticks | Ticks per output token | +|---|---|---:|---:|---:| +| grok-4.7 | default | 334 / 334 | 9.50M / 9.50M | 28.4k | +| grok-4.7 | priority | 336 / 308 | 56.1M / 52.8M | ~169k | +| grok-4.7-build-fast | default | 318 / 315 | 18.3M / 18.2M | ~57.8k | +| grok-4.7-build-fast | priority | 321 / 329 | 108.6M / 110.6M | ~337k | + +`cost_in_usd_ticks` is what the gateway charges against the subscription's usage allowance. Priority multiplies +it ~5.9x on both ids; build-fast without priority costs ~2x base. + +## Decision (see 000_plan.md D3) + +One model, two serving lanes. Keep `grok-4.7` as the only visible row. Its Fast selection on the OAuth lane +dispatches `grok-4.7-build-fast` with no service tier: the fastest measured lane at a third of the cost of +today's Fast (priority on grok-4.7), which bought no speed. Key auth keeps priority (build-fast is not on the +public API). Explicit build-fast requests keep working and gain the probed OAuth Responses wire. + + +## Continuation across the two ids + +The Grok OAuth gateway echoes `store: false` even when `store: true` is requested, and a direct +`previous_response_id` returns 404 even for a same-model control, so upstream-held continuation is not +available on this lane at all. OpenCodex already covers that: with cached history it expands the input +locally and strips `previous_response_id` (request-prepare.ts:315/397, passthrough.ts:267), and the state +lookup is keyed by response id and client scope, not model (state.ts:1056). Codex itself sends full input +with `store:false`. + +| Recipe | 4.7 → 4.7 | 4.7 → build-fast | build-fast → 4.7 | +|---|---|---|---| +| Direct, full replay, store:false | recalled | recalled | recalled | +| Proxy, previous_response_id (local expansion) | recalled | recalled | missed once (N=1) | + +The one proxy miss started on build-fast while it still fell back to the Chat wire (no wire pin before this +unit); the same direction recalled under direct full replay. The model switch itself showed no boundary. +This unit also pins build-fast to the OAuth Responses wire, removing that confounder. diff --git a/devlog/_plan/260930_local_test_stability/000_README.md b/devlog/_plan/260930_local_test_stability/000_README.md new file mode 100644 index 00000000000..c1f44b0ede9 --- /dev/null +++ b/devlog/_plan/260930_local_test_stability/000_README.md @@ -0,0 +1,7 @@ +# 260930 Local test stability in Codex-managed worktrees + +Status: open. Loop session `01a0f144-fb4d-7c32-940c-8b8a99874ba2` (wp2, after #6304 closed wp1), branch `codex/test-guard-managed-worktree` from `origin/dev` `2405624f39`. + +| Doc | Work-phase | Content | +|---|---|---| +| 010_diagnosis_and_plan.md | wp2 | Two host-dependent failure classes, their causes, the diff, verification | diff --git a/devlog/_plan/260930_local_test_stability/010_diagnosis_and_plan.md b/devlog/_plan/260930_local_test_stability/010_diagnosis_and_plan.md new file mode 100644 index 00000000000..10087195ca2 --- /dev/null +++ b/devlog/_plan/260930_local_test_stability/010_diagnosis_and_plan.md @@ -0,0 +1,37 @@ +# 010 Diagnosis and diff-level plan + +## Symptoms (2026-09-30, while verifying #6304) + +1. `bun run test:changed` from the Codex-managed worktree `~/.codex/worktrees/77b7/opencodex`: 863 failures. A sample file (`tests/codex-integration/model-visibility-management-api.test.ts`) fails 23/23 on its own with `refusing to remove a path inside the real Codex home (~/.codex) from a test process: ".../tests/codex-integration/.tmp-model-visibility-management-" resolves there`. The same head cloned to `/private/tmp` drops to 56 failures. +2. `tests/service/shutdown-launcher.test.ts` fails SIGINT/SIGTERM/SIGHUP at both `dev` `6538001e2f` and the #6304 head: `The proxy answered /healthz but did not inject Codex config ... Proxy already running on port 10100; requested a second instance on port `. + +## Causes + +1. `protectedRemovalReason` (`src/lib/test-home-guard.ts`) refuses any removal target inside `~/.codex`. Forty test files keep their fixture directory next to the test (`join(import.meta.dir, ".tmp-...")`) and clean it through `removeTreeWithRetry`. When the checkout itself is a Codex-app worktree under `~/.codex/worktrees/`, every such cleanup is refused, although the directory is repository content the test created, not Codex state. +2. The launcher test gives the child a fresh `OPENCODEX_HOME` with no `config.json`. `handleStart` probes the configured port (`findProxyOwnerBeforeJournalRecovery({ probeConfiguredPort: true })`), which defaults to 10100. On a developer machine running ocx there, the child finds that live owner, takes the sibling path, and by design never injects Codex config, so the test's precondition never arrives. CI has no proxy on 10100, which is why it only fails locally. + +## Diff + +- `src/lib/test-home-guard.ts`: a removal target strictly inside the checkout that loaded the guard (`resolve(import.meta.dir, "../..")`, both canonical and lexical spellings) is not a protected location. Equality and ancestor checks still run first for every protected tree, so the checkout root itself and anything above it stay refused; outside a checkout that lives under a protected tree the exemption changes nothing. Computed lazily so production module load does no extra work. +- `tests/ci-workflows/test-home-guard.test.ts`: a probe with a sentinel real home whose `.codex` is a symlink to the checkout's parent, so the checkout sits inside the protected Codex home. Asserts a fixture path under `tests/` is allowed, while the checkout root, its parent and a sibling of the checkout are refused. Skipped where symlinks are unavailable, like the existing symlink probe. +- `tests/service/shutdown-launcher.test.ts`: write `{ "port": }` to the fresh home's `config.json` so the configured-port probe targets the test's own port, not the host's 10100. +- `structure/`: record the checkout exemption where the guard is described, if a doc owns it. + +## Verification + +- `bun test tests/ci-workflows/test-home-guard.test.ts tests/service/shutdown-launcher.test.ts` from the managed worktree, with the host proxy live on 10100. +- `bun test ./tests/codex-integration/model-visibility-management-api.test.ts` from the managed worktree (23/23 failing before). +- `bun run test:changed` from the managed worktree: failures must fall to the `/private/tmp` level and contain no `refusing to remove`. +- `bun run typecheck`, `bun run structure:check`, `bun run privacy:scan`; exact-head CI; after merge, the `dev` push CI for both merge commits. + +## Residual, out of scope + +The `/private/tmp` full run still showed 5 s timeouts in the Claude picker/intercept TLS suites and service-state suites when 1566 files share one machine; each passes in isolation and on the sharded CI. Recorded, not changed here. + + +## Audit fold (NEAR-PASS, two blockers) + +1. The exemption is tied to one protected tree. It lifts only the "inside `protectedPath`" refusal, and only when a checkout root spelling is strictly inside that same `protectedPath` and the candidate is strictly inside that checkout root. Equality, ancestor and real-home checks stay unconditional. A bunfs build (`import.meta.dir` under `/$bunfs/root`, root `/`) or a checkout at `$HOME` never satisfies "checkout inside the tree", so the guard is unchanged there. Probe: `OCX_REAL_HOME=` still refuses `/.codex/x`. +2. Each spelling (canonical, lexical) is judged on its own and any refusal wins. Probe: a symlink inside the checkout that points at the protected tree outside the checkout is refused through its canonical form. + +Notes taken: the launcher test also pins `GROK_HOME` and `OCX_OWNER_REGISTRY_DIR` into the fixture home (the `sibling-home-client-sync` pattern) so inherited real state cannot mark the child a sibling; the `protectedRemovalReason` doc comment is updated; verification adds a run from a checkout outside `~/.codex`. diff --git a/devlog/_plan/260930_minimax_m31_flash_preview/000_README.md b/devlog/_plan/260930_minimax_m31_flash_preview/000_README.md new file mode 100644 index 00000000000..dafc69ea276 --- /dev/null +++ b/devlog/_plan/260930_minimax_m31_flash_preview/000_README.md @@ -0,0 +1,9 @@ +# 260930 MiniMax-M3.1-Flash-Preview + +Status: open. Loop session `01a0f144-fb4d-7c32-940c-8b8a99874ba2`, branch `codex/minimax-m3-1-flash-preview` from `origin/dev` `6538001e2f`. + +MiniMax published `MiniMax-M3.1-Flash-Preview` on 2026-09-27. This unit adds it to the `minimax` and `minimax-cn` presets with the reasoning policy the endpoint actually enforces, records the pricing status, and lands it on `dev` through one PR. + +| Doc | Work-phase | Content | +|---|---|---| +| 010_evidence_and_plan.md | wp1 | Sourced facts, live probe results, diff-level plan, verification | diff --git a/devlog/_plan/260930_minimax_m31_flash_preview/010_evidence_and_plan.md b/devlog/_plan/260930_minimax_m31_flash_preview/010_evidence_and_plan.md new file mode 100644 index 00000000000..e7c94ed0835 --- /dev/null +++ b/devlog/_plan/260930_minimax_m31_flash_preview/010_evidence_and_plan.md @@ -0,0 +1,54 @@ +# 010 Evidence and diff-level plan + +## Sources (read 2026-09-30) + +- `platform.minimax.io/docs/guides/text-generation` and the CN mirror `platform.minimaxi.com/docs/guides/text-generation`: model id `MiniMax-M3.1-Flash-Preview`, context window 1,000,000, multimodal chat input, "available only through Token Plan and MiniMax Code for now". Thinking is always on; `effort` accepts `low | medium | high | xhigh | max`; an omitted effort defaults to `max`. `thinking: {"type":"disabled"}` or `effort: "none"` returns 400 `requires adaptive thinking`. On the OpenAI-compatible protocol thinking is always returned in `reasoning_content`. +- `agent.minimax.io/tools/m3-1-flash-preview`: text, image and video input, text output; prompt caching supported. +- Pricing: `platform.minimax.io/docs/guides/pricing-paygo` and the enterprise tab of `/subscribe/token-plan` list only MiniMax-M3 and M2.7 rows. `/docs/guides/pricing-token-plan`: Plus $22, Max $55, Ultra $132 per month (the `/subscribe/token-plan` comparison table shows $20/$50/$120, the annual-billing rate); Credits 1,000 = $1 charged at the resource's pay-as-you-go list price. No per-token list price exists for M3.1-Flash-Preview. OrcaRouter's 2026-09-27 write-up reaches the same conclusion. The public guide links the pricing page instead of restating plan prices (CodeRabbit on #6304). + +## Live probe (jun-macbookpro, configured `minimax` key, `https://api.minimax.io/v1`) + +| Request | Result | +|---|---| +| `GET /v1/models` | 200; lists M3/M2.7/M2.5/M2.1/M2 only (the preview is not enumerated) | +| chat, no effort | 200, `reasoning_content` present | +| chat, `reasoning_effort` low / medium / high / xhigh / max | 200 each | +| chat, `reasoning_effort: "none"` | 400, code 2013, "reasoning effort none is not allowed" | +| chat, `thinking: {"type":"disabled"}` | 400, code 2013 | +| `reasoning_split: true`, stream and non-stream | 200; deltas carry `reasoning_content`; no `reasoning_details` at all | +| tool round with `reasoning_content` replay | 200 | +| tool round with `reasoning_details` replay | 200 | +| low-effort tool call | 200 with no reasoning; the next round without replayed reasoning is accepted | + +## Decisions + +1. Add the id to `MINIMAX_MODELS` (both presets share it) with a 1,000,000 window. Keep `MiniMax-M3` as the default model: the preview only answers to Token Plan keys, and the presets also accept pay-as-you-go keys. +2. Efforts `low..max`, default `max` (the vendor default when the field is omitted). No effort map and no `thinkingToggleModels` entry: identity labels go out as `reasoning_effort`, `minimal` clamps to `low`, `ultra` becomes `max`, and `none` omits the field, so the wire can never carry a disabled-thinking value. +3. Keep it on `preserveReasoningContentModels` (replay as `reasoning_content`, probed OK) but off `reasoningSplitModels` and `reasoningDetailsModels`: it ignores `reasoning_split` and never emits `reasoning_details`. `requiresReasoningPlaceholderModels` stays `[]` because low-effort tool rounds legitimately carry no reasoning. +4. Pricing: add metadata rows (context, modalities, reasoning) for `minimax` and `minimax-cn` with no cost. The expected-price overlay only registers verified list prices, and none is published, so usage for this model reports unpriced instead of a guessed number. The providers guide states the Token Plan-only availability and the absent list price. + +## Diff + +- `src/providers/registry/model-seeds.ts`: `MINIMAX_M31_FLASH_PREVIEW`, extend `MINIMAX_MODELS` and the window map, `MINIMAX_REASONING_SPLIT_MODELS` (all but the preview). +- `src/providers/registry/entries-extended.ts`: both presets get the efforts/default for the preview and use the split list for `reasoningSplitModels` / `reasoningDetailsModels`. +- `scripts/model-metadata.source.json` + regenerated `src/generated/model-metadata.ts`. +- `tests/providers/provider-registry-parity.test.ts`: registry contract for both presets. +- `tests/providers/minimax-reasoning-split.test.ts`: wire test for the preview (effort passthrough, none/minimal never disable thinking, no `reasoning_split`, replay as `reasoning_content`). +- `docs-site/src/content/docs/guides/providers.md`: availability and pricing note. + +## Verification + +`bun run typecheck`; the two test files above plus `tests/codex-integration/model-metadata-sync.test.ts`, `tests/codex-integration/codex-catalog.test.ts`, `tests/codex-integration/reasoning-metadata.test.ts`, `tests/usage/usage-cost.test.ts`; `bun run test:changed`; `bun run privacy:scan`; `bun run structure:check`. Then exact-head CI on the PR and merge. + + +## Audit round 1 fold (reviewer FAIL, one blocker) + +Blocker: `GET /v1/models` omits the preview, and `mergeConfiguredModelsIntoLiveCatalog` (`src/codex/catalog/model-visibility.ts`) drops a configured id the live roster omits unless `shouldRetainConfiguredProviderModel` keeps it. Separately, saved configs carry a full copy of the old `models` list (confirmed on jun-macbookpro: exactly the eight pre-change ids), and `enrichProviderFromRegistry` never rewrites a present list, so the id never reaches existing installs. + +Fold: + +5. `src/codex/catalog/model-hints.ts`: add `minimax` and `minimax-cn` entries holding `MiniMax-M3.1-Flash-Preview` to `CALLABLE_CONFIGURED_COMPATIBILITY_MODELS` (the Kimi/xAI/CodeBuddy pattern). +6. New `src/providers/stale-model-roster-migration.ts`, wired into `projectStartupConfigRepairs` in `src/providers/model-rename-startup.ts` beside the context-window and vision repairs. Same restraint: on a provider that still carries the registry adapter, replace a saved `models` list only when it is byte-for-byte the previous registry seed (the eight ids, same order); a hand-edited list is left alone. When the list is refreshed, fill `modelContextWindows` and `modelDefaultReasoningEfforts` for the added id only when that key is absent. Test in `tests/providers/model-roster-seed-repair.test.ts` (registered in `layout.json` and `test-layout-expected.json`), plus a catalog test that mocks a `/models` roster omitting the preview and asserts the row survives. `structure/providers-and-adapters.md` gets the paragraph beside the vision/context repair text. +7. Parity test: exclude the preview from the 204,800 loop, update the exact list assertions. + +Verification adds the two new tests and `tests/providers/context-window-seed-repair.test.ts`, `tests/providers/vision-classification-seed-repair.test.ts`. diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index 1ee7d02c42d..aa0c8b988ec 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -383,6 +383,8 @@ Si la lecture authentifiée des quotas avec le nouveau jeton OAuth confirme un q `ocx account refresh openai` et `ocx account list openai --quota --refresh` consultent uniquement les quotas. La validation du modèle consomme du quota et nécessite une session humaine du tableau de bord : après récupération, ouvrez `ocx gui` et cliquez sur **Refresh quotas**. Sur un hôte sans interface graphique, accédez à son tableau de bord depuis votre navigateur ; le jeton administrateur seul n’autorise pas la validation. Un compte en pause peut être validé sans être repris ni sélectionné. Les erreurs d’autorisation restent visibles jusqu’à une validation ou une réauthentification réussie. +Dans **Codex Set → Multi-auth**, activez le commutateur **Crédits Codex** dans l’en-tête **Codex Auth** pour afficher la dernière observation de chaque compte principal et du pool juste sous Week. Désactivé par défaut, il est enregistré dans `showCodexCredits`. Le solde utilise le format numérique local ; les mentions illimité ou plafond de dépassement atteint apparaissent si elles sont signalées. Sans plafond total fourni, la barre indique la disponibilité et non un pourcentage. Le commutateur ne contrôle que l’affichage ; une nouvelle connexion attend sa propre observation. + La revalidation en arrière-plan est distincte et désactivée par défaut. Elle nécessite Token Guardian, la politique `proactive` du fournisseur `openai` et `tokenGuardian.codexWarmupEnabled`, et ignore les comptes dont la validation d’inscription est en attente. ### Pourquoi un compte a cessé de servir les requêtes diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 7ddd7fe18ff..27881bc9c5d 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -922,6 +922,17 @@ ocx service install # persistent: auto-starts on login and respawns on crash `ocx status` shows whether the proxy is running and prints the same restart hint when it is not; `ocx doctor` reports restart safety (service/shim coverage). +### Codex autostart shim + +Run `ocx codex-shim install` to install the optional launcher wrapper. It runs +`ocx ensure` before ordinary Codex launches and then forwards the original arguments +and exit status. The command also works with the standalone `ocx` shipped in desktop +packages: both the installation probe and the installed wrapper use that executable, +without requiring a separate Bun installation or a source checkout. + +Use `ocx codex-shim status` to inspect it and `ocx codex-shim uninstall` to restore +the saved Codex launcher. + ## Routed models during Codex reserve mode Codex Pool can optionally protect stored pool accounts at a selected 5-hour or weekly usage @@ -989,6 +1000,8 @@ If the new OAuth credential's authenticated usage lookup confirms an exhausted 5 `ocx account refresh openai` and `ocx account list openai --quota --refresh` only read usage. Model validation spends quota and requires a human dashboard session: open `ocx gui` and click **Refresh quotas** after recovery. For a headless host, access its dashboard from your browser; an admin token alone does not authorize validation. Validation can complete while an account is paused without resuming or selecting it. Model authorization failures remain visible until successful validation or reauthentication clears them. +In **Codex Set → Multi-auth**, enable the **Codex credits** switch in the **Codex Auth** header to display each main and pool account’s latest observed credits directly below Week. It is off by default and persists as `showCodexCredits`. The balance is a locale-formatted number, with Unlimited or an overage warning when reported; the bar indicates availability, not a percentage, because no total credit limit is supplied. Hiding credits changes display only, and a new login waits for its own observation. + Background revalidation is separate and off by default. It requires Token Guardian, the `openai` provider's `proactive` refresh policy, and `tokenGuardian.codexWarmupEnabled`. It skips accounts awaiting deferred registration validation. ### Cancelling main-account device reauthentication diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 6f57e04ca8a..64f06a5dd2c 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -61,6 +61,18 @@ embedded dashboard and your normal browser. The tray also provides update checks On macOS, closing the dashboard keeps the app running in the menu bar. Open OpenCodex again from Dock or Finder to restore the dashboard without restarting the proxy. +## Startup safety on macOS + +Startup safety reports **Desktop app** protection when OpenCodex's recorded ownership, +**Start at Login** registration, and live supervision of its bundled proxy all match. +A missing or stale check remains **At risk**. If the desktop app owns the proxy but +protection cannot be verified, reopen OpenCodex and check **Start at Login**. Service +and launcher installation or repair stays disabled while that ownership remains; +`ocx restore` is still available to undo Codex routing. + +Normal desktop updates replace the bundled CLI with the fixed startup probe. No local +patch needs to be preserved across an update. + ## Keeping the proxy running The app keeps the proxy it started running. When that proxy restarts itself — after diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index ee2191f61b5..0e98269a586 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -289,6 +289,12 @@ paste the redirect URL or authorization code back. During device approval that f enter the displayed code on the provider's verification page instead. If the provider switches to manual input, the dashboard replaces the old code and instructions on its next status poll. +If the proxy tries to open a browser and cannot, the login says so above the URL and keeps +going: open the sign-in page from the link or copy it. A device login never opens a browser by +itself; **Copy code & open** copies the code and opens the verification page in one click, and +the dashboard keeps waiting for as long as the provider's code stays valid. In the desktop app +these links open in your default browser. + To stop the proxy from opening a browser at all, tick **Don't open a browser on the proxy machine** beside the login button, or set it permanently: @@ -406,6 +412,11 @@ the dashboard Codex account pool also performs. See ### Kiro request credits +On tool-enabled turns, opencodex holds Kiro's ordinary text until completion is validated. +If Kiro ends with plain text instead of its private final-answer tool, one bounded retry +still runs, and only the resulting final answer is displayed. Progress accompanying a real +tool call remains visible. A normal private final answer needs no completion retry. + When Kiro emits credit metering, request logs preserve the reported spend as `usage.providerCredits`, including in the persisted usage ledger. These are Kiro credits; token counts may still be estimated, and the credit value does not replace USD cost estimates. @@ -589,6 +600,18 @@ The MiniMax and MiniMax (CN) provider cards can also show Coding Plan quota when key has an active plan. The dashboard reads the plan's 5-hour window and, when present, weekly window; these are display observations and do not change model routing. +`MiniMax-M3.1-Flash-Preview` (1M context) is listed on both MiniMax presets. MiniMax serves it +only to Token Plan subscription keys and MiniMax Code for now, so a pay-as-you-go API key gets an +error for it. Thinking is always on: the effort picker offers `low` through `max` and defaults to +`max`, and there is no way to turn thinking off. MiniMax has not published a per-token price +for the preview; usage is drawn from your +[Token Plan quota](https://platform.minimax.io/docs/guides/pricing-token-plan), so OpenCodex +shows no estimated cost for it. MiniMax's `/models` endpoint does not list +the preview yet, so OpenCodex keeps it in the catalog from the preset. An install whose saved +MiniMax model list is still the previous default receives it on the next start; a list you edited +is left as it is; register the preview by hand with +`ocx models add minimax MiniMax-M3.1-Flash-Preview --context-window 1000000`. + **OpenCode Go** requires a stable session identifier for routing. OpenCodex derives its Go session header from Codex thread/session headers, or from a client's `x-opencode-session` header when Codex headers are absent. This applies to direct diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 5845d23865e..b8148e98d33 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -246,6 +246,8 @@ ocx service install # persistent: auto-starts on login and respawns on crash `ocx account refresh openai` と `ocx account list openai --quota --refresh` は使用量のみを取得します。モデル検証はクォータを消費するため、人間のダッシュボードセッションが必要です。回復後に `ocx gui` を開き、**Refresh quotas** をクリックしてください。ヘッドレスホストでもブラウザーからそのダッシュボードにアクセスします。管理者トークンだけでは検証できません。一時停止中でも検証できますが、アカウントの再開や選択は行いません。モデル認証エラーは検証または再認証に成功するまで表示されます。 +**Codex Set → Multi-auth** で **Codex Auth** 見出しの **Codex クレジット** スイッチを有効にすると、メインとプールの各アカウントで最後に取得したクレジットが Week の直下に表示されます。既定では無効で、`showCodexCredits` として保存されます。残高はロケールに合わせた数値で、報告があれば無制限または超過利用上限の警告を表示します。総上限が提供されないため、バーは割合ではなく利用可能な状態を示します。この設定は表示のみを制御し、新しいログインにはそのアカウントの取得結果が必要です。 + バックグラウンド再検証は別機能で既定では無効です。Token Guardian、`openai` の `proactive` 更新ポリシー、`tokenGuardian.codexWarmupEnabled` が必要で、登録検証待ちのアカウントは除外します。 ### アカウントがリクエストを処理しなくなった理由 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 201b96f3698..9eea66614ad 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -358,6 +358,8 @@ ChatGPT 계정을 추가하거나 재인증할 때 OpenCodex는 일반적으로 `ocx account refresh openai`와 `ocx account list openai --quota --refresh`는 사용량만 조회합니다. 모델 검증은 할당량을 사용하므로 사람의 대시보드 세션이 필요합니다. 할당량이 복구되면 `ocx gui`를 열고 **Refresh quotas**를 클릭하세요. 헤드리스 호스트도 브라우저에서 해당 대시보드에 접속해야 하며, 관리자 토큰만으로는 검증할 수 없습니다. 일시 정지된 계정도 검증할 수 있지만 일시 정지를 해제하거나 계정을 선택하지는 않습니다. 모델 인증 실패 표시는 검증 또는 재인증에 성공할 때까지 유지됩니다. +**Codex Set → Multi-auth**에서 **Codex Auth** 제목 줄의 **Codex 크레딧** 스위치를 켜면 메인 계정과 풀 계정의 최근 크레딧 잔액이 Week 바로 아래에 표시됩니다. 기본값은 꺼짐이며 `showCodexCredits`로 저장됩니다. 잔액은 로케일에 맞는 숫자로 표시하고, 응답에 따라 무제한 또는 초과 사용 한도 도달 안내를 표시합니다. 전체 크레딧 한도가 제공되지 않으므로 막대는 백분율이 아닌 사용 가능 상태를 나타냅니다. 스위치는 표시만 제어하며 새 로그인에는 해당 계정의 조회 결과가 필요합니다. + 별도의 백그라운드 재검증은 기본적으로 꺼져 있습니다. Token Guardian, `openai`의 `proactive` 갱신 정책, `tokenGuardian.codexWarmupEnabled`가 필요하며 등록 검증 대기 계정은 제외합니다. ### 계정이 요청을 처리하지 못하게 된 이유 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 7f8c672ca7f..12ddcad4aac 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 @@ -96,17 +96,19 @@ Reserve입니다. 차단 중에는 그 메인 계정의 Reserve를 활성화할 예정된 리셋 시간이 지났다는 이유만으로 풀지는 않습니다. 차단 중에는 1분 주기 점검이 알려진 차단 창의 리셋 시각까지 기다린 뒤 계정의 실제 사용량을 확인합니다. 이후에도 차단 상태이거나 미래 리셋 시각을 모르면 5·10·20·40·60분 간격으로 재시도하며, 더 긴 `Retry-After`가 있으면 -프로필 및 토큰 준비도 그 시각까지 미룹니다. 유효한 최신 수치만 차단을 해제합니다. +프로필 및 토큰 준비도 그 시각까지 미룹니다. 유효한 최신 수치나 창이 없음을 명시한 응답만 차단을 해제합니다. Pool 모드에서 사용량 조회의 `--refresh`는 캐시 유효기간을 무시하지만 실패 후 대기 시간은 지킵니다. 조회가 연기되면 새 진단 시도로 기록하지 않습니다. 일시정지, 재인증, 서버의 사용량 제한은 별도로 적용됩니다. -새로운 유효한 WHAM 사용량 응답 한 건에서 1차 창의 기간이 **24시간 이상**으로 명시되고 유효한 사용량 수치가 있으며, -2차·3차 창이 명시적 `null`이거나 그 기간도 24시간 이상으로 명시되고 사용량 수치도 함께 오면 이전 5h 수치를 대체합니다. +새로운 유효한 WHAM 응답에서 1차 창이 **24시간 이상**이고 사용량 수치가 있으면 이전 5h 수치를 대체할 수 있습니다. +1차 창이 명시적 `null`이고 2차 창에 유효한 주간 사용량이 있어도 같습니다. 어느 경우든 2차·3차 창은 +명시적 `null`이거나 기간이 24시간 이상이고 유효한 사용량 수치가 있어야 합니다. 파서의 단기·장기 구분 기준을 따르므로 주간·월간뿐 아니라 하루짜리 창도 해당합니다. 현재 창에는 동일한 98% 기준을 적용합니다. 이 판단은 응답 한 건의 정보에 의존하며 연속 관측을 -요구하지 않습니다. 2차·3차 필드가 생략되었거나, 1차 창의 기간을 모르거나, 응답 헤더만 일부 -도착한 경우에는 이전 차단을 해제하지 않습니다. +요구하지 않습니다. 창 필드가 하나라도 생략되었거나, 창의 기간을 모르거나, 응답 헤더만 일부 +도착한 경우에는 이전 차단을 해제하지 않습니다. 모든 창이 `null`이고 크레딧만 있거나, 보조 월간 수치만 +있는 응답도 차단을 풀지 않습니다. 지연 응답을 반영하기 전에 저장된 인증정보를 다시 확인합니다. 파일을 읽을 수 없거나 같은 계정의 인증 토큰이 교체되었다면 별도 사용량 조회가 없어도 이전 응답은 사용량 캐시나 차단 상태를 갱신하거나 새 토큰을 재인증 대상으로 표시하지 않습니다. 해당 요청자에게 파싱된 조회 결과를 반환할 수는 있지만, 공유 상태나 차단 해제 근거에는 반영하지 않습니다. 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 46a973c1265..49cf4d0f763 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -159,16 +159,18 @@ An unreadable 5h reading cannot hide a weekly block. Unknown usage does not fabr not erase an already measured blocking tuple. A predicted reset time alone does not unlock it. While blocked, the minute sweep waits for the latest known blocking reset, then checks owned usage. If no future reset is known or a check remains blocked, recovery uses a capped 5/10/20/40/60-minute -schedule; a longer `Retry-After` also delays profile and token preparation. Only a fresh valid reading +schedule; a longer `Retry-After` also delays profile and token preparation. Only fresh valid usage or authoritative window-absence evidence can lift the block. In Pool mode, a quota `--refresh` bypasses cache freshness but still honors failed-read pacing; a deferred read makes no new diagnostic attempt. Other pause, reauthentication, and upstream limits remain independent. Protection treats one fresh valid WHAM usage response as a replacement for the old 5h reading when -its primary window explicitly lasts **at least 24 hours** and secondary/tertiary windows are explicitly `null` -or also explicitly last at least 24 hours and report their usage. This follows the parser's short/long boundary, so a +its primary window explicitly lasts **at least 24 hours** and reports valid usage, or its primary window is +explicitly `null` and a measured secondary supplies the weekly usage. In either case, secondary/tertiary +windows must be explicitly `null` or explicitly last at least 24 hours and report valid usage. This follows the parser's short/long boundary, so a one-day window qualifies as well as weekly/monthly windows. The current window still uses the same 98% threshold. This relies on the single reported snapshot; repeated observations are not required. -Omitted secondary/tertiary fields, an unknown primary duration, or partial response headers cannot clear a previous block. +Any omitted window field, an unknown duration, or partial response headers cannot clear a previous block. +All-null responses carrying only credits and supplementary monthly-only readings also cannot establish recovery. The proxy checks the stored credential again before applying a delayed response. An unreadable file or replaced bearer cannot update the usage cache, release the lock, or quarantine the new credential, even for the same account with no second quota read. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index e4250e11654..7a15ef01c81 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -719,6 +719,16 @@ is excluded: the gateway answers `service_tier: "default"` when sent `priority`, unclassified and its caller tier is not forwarded. Unlisted models stay unclassified on both transports. +On the OAuth gateway, Grok 4.7's Fast works differently. The gateway also lists +`grok-4.7-build-fast`, which is the same model on faster serving hardware, and it measured about 1.6× +faster than `grok-4.7`. Priority processing on `grok-4.7` measured no faster and consumed about 6× the +subscription usage per output token. The Models list therefore shows one Grok 4.7 row. Selecting its +Fast row (`xai/grok-4.7--fast`), sending `service_tier: "priority"`, or turning on Fast mode sends the +request as `grok-4.7-build-fast` without a service tier. Request logs keep the model as `grok-4.7` and +record `grok-4.7-build-fast` as the wire model. API-key mode is unchanged: build-fast is not on xAI's +public API, so Grok 4.7 Fast there still means priority processing. An explicit +`xai/grok-4.7-build-fast` selection from an earlier configuration keeps working. + xAI charges Priority Processing at 2× the standard token price for input, output, cached, and reasoning tokens; cache discounts are applied before the multiplier. Cost estimates use that premium only when xAI's response confirms `service_tier: "priority"`. A missing or unparsed response tier is diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 8bbc6894c18..e97fab4cdca 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -17,7 +17,7 @@ runs helper features around provider requests. | `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. | | `dropCodexSafetyBuffering?` | `boolean` | `false` | Remove optional client-facing hints from canonical Codex Responses passthrough: the two `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` response headers, `response.metadata` events whose metadata type is `safety_buffering`, and top-level `safety_buffering` fields. Other headers, response data, policy refusals and failures are preserved. This does not disable provider safety enforcement or upstream buffering. Native `codex.response.metadata.headers` WebSocket metadata and `/responses/compact` are outside this filter. | | `stallTimeoutSec?` | `number` | `300` (public) / disabled (local) | Seconds without meaningful upstream progress (Responses and native Chat) before the stream is cut. Unset, a **local** upstream (loopback, private, or a `.local`/`.lan` name) defaults to disabled and a public upstream to 300 s; a positive value applies to both (minimum 1 s); `0` disables the silence watchdog everywhere. Disabled leaves a silent-but-healthy local model connected (keep-alives still flow). Canonical ChatGPT Responses folded from SSE into non-streaming JSON retain a separate 15-minute whole-turn ceiling even when the silence watchdog is disabled. Pending `/v1/responses/compact` body reads share this budget but default to 300 s even for a local upstream — the route buffers the complete body while holding an active-turn lease — and an explicit value, including `0`, still wins. | -| `oauthOpenBrowser?` | `boolean` | `true` | Whether a login may open a browser on the machine running the proxy. Absent and `true` both open, so an existing install is unchanged; only an explicit `false` declines. Decline when you need the authorization link in a different browser profile, or when the dashboard is not on the proxy's machine — the login still starts and the URL is still returned and displayed. `POST /api/oauth/login` and `POST /api/codex-auth/login` accept a per-request `openBrowser` boolean that overrides this, and the dashboard exposes the same choice beside the login button. Device-code flows never open a browser either way. | +| `oauthOpenBrowser?` | `boolean` | `true` | Whether a login may open a browser on the machine running the proxy. Absent and `true` both open, so an existing install is unchanged; only an explicit `false` declines. Decline when you need the authorization link in a different browser profile, or when the dashboard is not on the proxy's machine — the login still starts and the URL is still returned and displayed. `POST /api/oauth/login` and `POST /api/codex-auth/login` accept a per-request `openBrowser` boolean that overrides this, and the dashboard exposes the same choice beside the login button. Both routes report the outcome as `browserLaunch`: `started`, `failed` (nothing could be opened; the dashboard says so), or `skipped`. Device-code flows never open a browser either way. | | `connectTimeoutMs?` | `number` | `200000` | Per-attempt DNS/TCP/TLS/final-header deadline; it ends before body generation. | | `shutdownTimeoutMs?` | `number` | `5000` | Graceful drain deadline before active turns are aborted. | | `websockets?` | `boolean` | `false` | Advertise and admit the client-facing Responses WebSocket path. False keeps clients on HTTP/SSE; it does not disable an eligible canonical ChatGPT upstream WS optimization. Complete-input requests may reuse an upstream connection within the same selected credential, account, thread and turn; changed handshake policy or missing identity keeps requests on separate connections. This does not trim HTTP input or create previous-response IDs. | @@ -424,6 +424,40 @@ never lost; the next append tries again. Trimming also refreshes what the dashbo There is no dashboard control for this yet; set it in `config.json` or with `ocx config set usageLedgerMaxBytes `. +## Catalog auto-refresh (`catalogAutoRefresh`) + +Enabled by default when OpenCodex manages your local Codex client: the section or `enabled` +may be absent. The proxy refreshes its model catalog every 60 minutes, with one initial refresh +about three minutes after startup. When the Codex integration is turned off, or the instance +runs as a hub or beside another live proxy, background refresh runs only with an explicit +`"enabled": true`, and it never reads Codex sources. + +```json +{ + "catalogAutoRefresh": { "enabled": true, "intervalMinutes": 60 } +} +``` + +| Field | Default | Meaning | +| --- | --- | --- | +| `enabled` | `true` | Set to `false` to disable automatic refresh. | +| `intervalMinutes` | `60` | Refresh cadence; positive values below 15 are clamped to 15. `0` disables refresh. | + +Each refresh observes the selected Codex runtime's bundled catalog, then warms authenticated +Codex model rosters before converging the served list. Source failures use existing evidence +and retry on a later tick. Cadence and enablement edits take effect on a subsequent tick. + +A native OpenAI model that your ChatGPT account's Codex roster lists, but that this OpenCodex +release does not know yet, is added with the metadata upstream publishes for it (name, +reasoning levels, context window). OpenCodex remembers such models in +`discovered-native-models.json` in its home directory and forgets one that has not been seen +for 14 days. A later release that ships the model takes over its row. + +Running Codex sessions retain an in-memory model list. When the served set changes while +Codex app-servers are running, the proxy logs a restart hint and records `reloadRequired` +in its auto-refresh status. Run `ocx sync --restart-codex` when ready to restart those sessions. +Automatic refresh never restarts them. + ## Quota-reset notifications (`quotaResetNotify`) Off by default. When the section is absent, no detection runs, no timer starts, and no state diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 0be6555da10..745e2f51a90 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -377,6 +377,8 @@ v1/base/v2 при делегировании и fallback — в `ocx account refresh openai` и `ocx account list openai --quota --refresh` только читают квоты. Проверка модели расходует квоту и требует пользовательского сеанса панели: после восстановления откройте `ocx gui` и нажмите **Refresh quotas**. Для сервера без графического интерфейса откройте его панель в браузере; одного токена администратора недостаточно. Проверка приостановленного аккаунта не возобновляет и не выбирает его. Ошибки авторизации остаются видимыми до успешной проверки или повторного входа. +В **Codex Set → Multi-auth** включите переключатель **Кредиты Codex** в заголовке **Codex Auth**, чтобы видеть последние полученные данные основного аккаунта и аккаунтов пула сразу под Week. По умолчанию показ выключен; настройка сохраняется как `showCodexCredits`. Баланс форматируется по локали; при наличии соответствующих данных отображается безлимитный режим или предупреждение о достижении лимита перерасхода. Общий лимит кредитов не предоставляется, поэтому полоса показывает доступность, а не процент. Переключатель управляет только отображением; новый вход ожидает данные своего аккаунта. + Фоновая проверка — отдельная функция, выключенная по умолчанию. Она требует Token Guardian, политики `proactive` провайдера `openai` и `tokenGuardian.codexWarmupEnabled` и пропускает аккаунты, ожидающие проверки регистрации. ### Почему аккаунт перестал обслуживать запросы diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index ad8eb6be39d..6775e5566c4 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -432,6 +432,8 @@ Yeni OAuth belirteciyle yapılan kota sorgusu 5 saatlik, haftalık veya aylık k `ocx account refresh openai` ve `ocx account list openai --quota --refresh` yalnızca kullanımı okur. Model doğrulaması kota tüketir ve insanın pano oturumunu gerektirir: kota yenilendikten sonra `ocx gui` açıp **Refresh quotas** düğmesine tıklayın. Grafik arayüzü olmayan bir sunucunun panosuna da tarayıcınızdan erişin; yalnızca yönetici belirteci doğrulama yetkisi vermez. Duraklatılmış hesap doğrulanabilir, ancak devam ettirilmez veya seçilmez. Model yetkilendirme hataları başarılı doğrulama veya yeniden girişe kadar görünür kalır. +**Codex Set → Multi-auth** bölümünde **Codex Auth** başlığındaki **Codex kredileri** anahtarını açarak ana hesap ve havuz hesaplarının son gözlenen kredilerini Week satırının hemen altında görebilirsiniz. Varsayılan olarak kapalıdır ve `showCodexCredits` olarak kaydedilir. Bakiye yerel sayı biçiminde gösterilir; bildirildiğinde sınırsız kullanım veya aşım sınırı uyarısı görünür. Toplam kredi sınırı verilmediği için çubuk yüzdeyi değil kullanılabilirliği gösterir. Anahtar yalnızca görünümü değiştirir; yeni giriş kendi gözlemini bekler. + Arka plan doğrulaması ayrı ve varsayılan olarak kapalıdır. Token Guardian, `openai` için `proactive` yenileme ilkesi ve `tokenGuardian.codexWarmupEnabled` gerektirir; kayıt doğrulaması bekleyen hesapları atlar. ### Bir hesabın istek karşılamayı bırakma nedeni diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index a1ee583f0b3..9b19850626b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -325,6 +325,8 @@ fallback 行为,参见 [Sub-agent Surface](/guides/sub-agent-surface/)。 `ocx account refresh openai` 和 `ocx account list openai --quota --refresh` 仅查询用量。模型验证会消耗配额,因此需要用户的仪表板会话:配额恢复后,打开 `ocx gui` 并点击 **Refresh quotas**。无界面主机也需要通过浏览器访问其仪表板;仅凭管理员令牌无法授权验证。暂停的账号可以完成验证,但不会因此恢复或被选中。模型授权错误会一直显示,直到验证或重新登录成功。 +在 **Codex Set → Multi-auth** 中,打开 **Codex Auth** 标题栏中的 **Codex 额度** 开关,即可在 Week 下方显示主账号和各池账号最近查询到的积分。默认关闭,并保存为 `showCodexCredits`。余额按地区格式显示;上游报告时会显示无限额或超额使用上限警告。由于没有积分总上限,条形表示可用状态而非百分比。此开关仅控制显示,新登录需等待其自身的查询结果。 + 后台重新验证是独立功能,默认关闭。它要求 Token Guardian、`openai` 的 `proactive` 刷新策略及 `tokenGuardian.codexWarmupEnabled`,并跳过等待注册验证的账号。 ### 账号停止处理请求的原因 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index 3996ae69462..700f01bc3ce 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -326,6 +326,8 @@ ocx service install # 常駐:登入時自動啟動,崩潰後自動重新 `ocx account refresh openai` 和 `ocx account list openai --quota --refresh` 僅查詢用量。模型驗證會消耗配額,因此需要使用者的儀表板工作階段:配額恢復後,開啟 `ocx gui` 並點選 **Refresh quotas**。無介面主機也需要透過瀏覽器存取其儀表板;僅憑管理員權杖無法授權驗證。暫停的帳號可以完成驗證,但不會因此恢復或被選取。模型授權錯誤會持續顯示,直到驗證或重新登入成功。 +在 **Codex Set → Multi-auth** 中,開啟 **Codex Auth** 標題列中的 **Codex 額度** 開關,即可在 Week 下方顯示主帳號和各池帳號最近查詢到的點數。預設關閉,並儲存為 `showCodexCredits`。餘額按地區格式顯示;上游回報時會顯示無限額或超額使用上限警告。由於沒有點數總上限,長條表示可用狀態而非百分比。此開關僅控制顯示,新登入需等待其自身的查詢結果。 + 背景重新驗證是獨立功能,預設關閉。它需要 Token Guardian、`openai` 的 `proactive` 更新政策及 `tokenGuardian.codexWarmupEnabled`,並略過等待註冊驗證的帳號。 ### 帳號停止處理請求的原因 diff --git a/gui/src/components/AddCodexAccountModal.tsx b/gui/src/components/AddCodexAccountModal.tsx index 12b20bd487a..bc50c5864b9 100644 --- a/gui/src/components/AddCodexAccountModal.tsx +++ b/gui/src/components/AddCodexAccountModal.tsx @@ -73,6 +73,7 @@ export default function AddCodexAccountModal({ authUrl={ui.authUrl} deviceCode={ui.deviceCode} instructions={ui.instructions} + browserLaunch={ui.browserLaunch} manualCode={ui.manualCode} manualCodeBusy={manualCodeBusy} manualCodeWaiting={manualCodeWaiting} diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index d25354de43d..8e05222c45a 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -21,6 +21,7 @@ import { baseUrlForChoice, matchChoiceId, resolvedBaseUrlForChoice } from "../ba import { AddProviderOAuthPane } from "./add-provider-oauth-pane"; import { AddProviderFormPane } from "./add-provider-form-pane"; import { useAddProviderOAuth } from "./use-add-provider-oauth"; +import type { BrowserLaunch } from "../oauth-browser-launch"; import { addProviderModalReducer, createInitialAddProviderState, @@ -108,7 +109,7 @@ export default function AddProviderModal({ const usageRank = Object.fromEntries((usagePoll.data?.providers ?? []).map(row => [row.provider, row.requests])); const { preset, form, saving, error, oauthBusy, oauthMsg, oauthMsgTone, oauthUrl, oauthUrlProvider, - oauthDeviceCode, oauthInstructions, + oauthDeviceCode, oauthInstructions, oauthBrowserLaunch, manualCode, manualCodeBusy, manualCodeMsg, manualCodeOk, endpointChoice, oauthTosPending, } = state; @@ -227,8 +228,8 @@ export default function AddProviderModal({ setOauthBusy: (busy: boolean) => dispatch({ type: "set-oauth-busy", busy }), setOauthMsg: (msg: string) => dispatch({ type: "set-oauth-msg", msg }), setOauthMsgTone: (tone: "ok" | "warn") => dispatch({ type: "set-oauth-tone", tone }), - setOauthUrl: (url: string, providerId: string, deviceCode?: string, instructions?: string) => - dispatch({ type: "set-oauth-url", url, providerId, deviceCode, instructions }), + setOauthUrl: (url: string, providerId: string, deviceCode?: string, instructions?: string, browserLaunch?: BrowserLaunch) => + dispatch({ type: "set-oauth-url", url, providerId, deviceCode, instructions, browserLaunch }), setManualCode: (code: string) => dispatch({ type: "set-manual-code", code }), setManualCodeMsg: (msg: string) => dispatch({ type: "set-manual-code-msg", msg }), setManualCodeOk: (ok: boolean) => dispatch({ type: "set-manual-code-msg", msg: manualCodeMsg, ok }), @@ -310,6 +311,7 @@ export default function AddProviderModal({ oauthUrl={oauthUrlProvider === preset.oauthProvider ? oauthUrl : ""} oauthDeviceCode={oauthUrlProvider === preset.oauthProvider ? oauthDeviceCode : ""} oauthInstructions={oauthUrlProvider === preset.oauthProvider ? oauthInstructions : ""} + oauthBrowserLaunch={oauthUrlProvider === preset.oauthProvider ? oauthBrowserLaunch : undefined} manualCode={manualCode} manualCodeBusy={manualCodeBusy} manualCodeMsg={manualCodeMsg} diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 73f7b4f356c..541b81c3f39 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -6,6 +6,7 @@ import { confirmAction, requestTextValue } from "../action-dialogs"; import { credentialAliasRejection, CREDENTIAL_ALIAS_MAX_LENGTH } from "../credential-alias"; import AddCodexAccountModal from "./AddCodexAccountModal"; import { useCodexAccountPool, type CodexAccountPoolController } from "../hooks/useCodexAccountPool"; +import { useCodexCreditsVisibility } from "../hooks/useCodexCreditsVisibility"; import { useMainDeviceReauth } from "./use-main-device-reauth"; import NativeMainProfiles from "./NativeMainProfiles"; import type { ReactNode } from "react"; @@ -152,6 +153,22 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current); }, []); + const credits = useCodexCreditsVisibility(apiBase, load, showActionFeedback, t, { + revision: quotaReadRevision, + onRead: (read, signal) => { + const mutationRevision = quotaAutoRefreshMutationRevisionRef.current; + read.then(payload => { + if (signal.aborted || quotaAutoRefreshMutationRevisionRef.current !== mutationRevision) return; + setQuotaState({ apiBase, revision: quotaReadRevision, settings: readQuotaActivationSettings(payload), error: false }); + setQuotaBusyScope(null); + }).catch(() => { + if (signal.aborted || quotaAutoRefreshMutationRevisionRef.current !== mutationRevision) return; + setQuotaState({ apiBase, revision: quotaReadRevision, settings: null, error: true }); + setQuotaBusyScope(null); + }); + }, + }); + const copyDoctor = useCallback((accountId: string) => { doctorCopy.copy(DOCTOR_CMD, accountId); }, [doctorCopy]); @@ -373,35 +390,12 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban }; useEffect(() => { - // AbortController rather than a `cancelled` flag: the in-flight request is actually torn - // down on unmount, and the state update lands in a .then() the linter can see is guarded. + // The credits hook shares the parsed settings GET above; this scope still + // owns teardown for the quota activation writes and their reconciliation. const abort = new AbortController(); - const read = createBoundedFetch(15_000); quotaScopeRef.current = abort; - const mutationRevision = quotaAutoRefreshMutationRevisionRef.current; - fetch(`${apiBase}/api/settings`, { signal: read.signal }) - .then(response => { if (!response.ok) throw new Error("read"); return response.json(); }) - .then((payload: { - codexQuotaAutoRefresh?: QuotaAutoRefreshSettings; - } | null) => { - if (abort.signal.aborted) return; - if (!payload) throw new Error("read"); - if (quotaAutoRefreshMutationRevisionRef.current === mutationRevision) { - setQuotaState({ apiBase, revision: quotaReadRevision, settings: readQuotaActivationSettings(payload), error: false }); - setQuotaBusyScope(null); - } - }) - .catch(() => { - if (!abort.signal.aborted && quotaAutoRefreshMutationRevisionRef.current === mutationRevision) { - setQuotaState({ apiBase, revision: quotaReadRevision, settings: null, error: true }); - setQuotaBusyScope(null); - } - }) - .finally(() => read.clear()); return () => { abort.abort(); - read.controller.abort(); - read.clear(); quotaMutationRef.current?.controller.abort(); quotaMutationRef.current?.clear(); quotaMutationRef.current = null; @@ -468,6 +462,9 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban { void credits.toggle(); }} refreshingQuota={refreshingQuota} actionFeedback={actionFeedback} actionFeedbackTone={actionFeedbackTone} @@ -509,6 +506,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban 0)); + const range = (values?: [number, number]) => values + ? values.map(n => format.format(n)).join("–") : "—"; + const title = credits.approxLocalMessages || credits.approxCloudMessages + ? t("codexAuth.creditsApprox", { + local: range(credits.approxLocalMessages), cloud: range(credits.approxCloudMessages), + }) : undefined; + return ( +
+ {t("codexAuth.credits")} + {t("codexAuth.creditsRemaining")} + + + + ); +} diff --git a/gui/src/components/QuotaBars.tsx b/gui/src/components/QuotaBars.tsx index 7a9e848eb38..ec18b78b65a 100644 --- a/gui/src/components/QuotaBars.tsx +++ b/gui/src/components/QuotaBars.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties } from "react"; +import { Fragment, type CSSProperties, type ReactNode } from "react"; import type { Locale, TFn } from "../i18n/shared"; import { useI18n } from "../i18n/shared"; import { IconAlert } from "../icons"; @@ -239,6 +239,7 @@ export default function QuotaBars({ incompleteWindowKeys, incompleteCustomWindowLabels, observedAt, + afterWeekly, }: { quota: AccountQuota | null; plan?: string | null; @@ -264,6 +265,8 @@ export default function QuotaBars({ * look live. */ observedAt?: number; + /** Compact-only slot after Week, or after the last row when Week is absent. */ + afterWeekly?: ReactNode; }) { const { locale } = useI18n(); const rows = buildQuotaRows(quota, plan, t); @@ -276,7 +279,9 @@ export default function QuotaBars({

); if (rows.length === 0) { - if (!pending) return null; + if (!pending) return layout === "compact" && afterWeekly ? ( +
{afterWeekly}
+ ) : null; if (layout === "stacked") { return (
@@ -339,17 +344,20 @@ export default function QuotaBars({
{observedLine} {rows.map(row => ( - + + + {row.windowKey === "weekly" && afterWeekly} + ))} + {!rows.some(row => row.windowKey === "weekly") && afterWeekly}
); } diff --git a/gui/src/components/add-codex-account-reducer.ts b/gui/src/components/add-codex-account-reducer.ts index ac48af83c52..1a982183c63 100644 --- a/gui/src/components/add-codex-account-reducer.ts +++ b/gui/src/components/add-codex-account-reducer.ts @@ -1,3 +1,5 @@ +import type { BrowserLaunch } from "../oauth-browser-launch"; + export type AddCodexAccountStep = "pick" | "oauth-waiting"; export type ManualCodeState = "idle" | "submitting" | "waiting"; export type StatusTone = "ok" | "warn"; @@ -11,6 +13,8 @@ export interface AddCodexAccountUiState { deviceCode: string; /** Provider-supplied prose that accompanies the code. */ instructions: string; + /** What the proxy reported about opening the browser for this login. */ + browserLaunch?: BrowserLaunch; manualCode: string; manualCodeState: ManualCodeState; statusNotice: string; @@ -37,7 +41,7 @@ export type AddCodexAccountUiAction = | { type: "set-id"; id: string } | { type: "set-error"; error: string } | { type: "set-auth-url"; authUrl: string } - | { type: "set-login-hint"; authUrl: string; deviceCode?: string; instructions?: string } + | { type: "set-login-hint"; authUrl: string; deviceCode?: string; instructions?: string; browserLaunch?: BrowserLaunch } | { type: "set-manual-code"; manualCode: string } | { type: "set-manual-code-state"; manualCodeState: ManualCodeState } | { type: "set-status-notice"; statusNotice: string; statusTone?: StatusTone } @@ -62,6 +66,7 @@ export function addCodexAccountUiReducer(state: AddCodexAccountUiState, action: authUrl: action.authUrl, deviceCode: action.deviceCode ?? "", instructions: action.instructions ?? "", + browserLaunch: action.browserLaunch, }; case "set-manual-code": return { ...state, manualCode: action.manualCode }; @@ -74,7 +79,9 @@ export function addCodexAccountUiReducer(state: AddCodexAccountUiState, action: case "clear-manual-code": return { ...state, manualCode: "", manualCodeState: "idle", statusNotice: "", statusTone: "ok" }; case "reset-oauth-start": - return { ...state, error: "", statusNotice: "", statusTone: "ok", flowId: null }; + // The launch outcome belongs to the login being replaced: a restart (for instance into the + // device flow after a failed launch) must not keep warning about a browser it never tried. + return { ...state, error: "", statusNotice: "", statusTone: "ok", flowId: null, browserLaunch: undefined }; case "oauth-code-submitted": return { ...state, error: "", manualCode: "", manualCodeState: "waiting", statusTone: "ok", statusNotice: "" }; default: diff --git a/gui/src/components/add-codex-account-waiting-step.tsx b/gui/src/components/add-codex-account-waiting-step.tsx index 1cdc64da88a..46a98aebd99 100644 --- a/gui/src/components/add-codex-account-waiting-step.tsx +++ b/gui/src/components/add-codex-account-waiting-step.tsx @@ -1,5 +1,6 @@ import { useT } from "../i18n/shared"; import { LoginHint } from "./login-url-block"; +import type { BrowserLaunch } from "../oauth-browser-launch"; import type { StatusTone } from "./add-codex-account-reducer"; export function AddCodexAccountWaitingStep({ @@ -7,6 +8,7 @@ export function AddCodexAccountWaitingStep({ authUrl, deviceCode, instructions, + browserLaunch, manualCode, manualCodeBusy, manualCodeWaiting, @@ -24,6 +26,7 @@ export function AddCodexAccountWaitingStep({ /** Short human code when the login is a device flow; empty otherwise. */ deviceCode?: string; instructions?: string; + browserLaunch?: BrowserLaunch; manualCode: string; manualCodeBusy: boolean; manualCodeWaiting: boolean; @@ -44,7 +47,7 @@ export function AddCodexAccountWaitingStep({

{reauthAccountId ? t("codexAuth.reauthenticate") : t("codexAuth.oauthLogin")}

{t("codexAuth.oauthWaiting")}

"copied" | "unavailable" | null; }) { const t = useT(); + const { locale } = useI18n(); const isNext = (account: CodexAccountEntry) => !account.paused && activeId === account.id; const idCopy = useCopyFeedback(); // Which cards have their ⋯ disclosure open; the priority select renders inside it unless @@ -82,6 +88,7 @@ export function CodexAccountPoolCards({ return ( <> {pool.map(a => { + const showCredits = creditsVisible === true && a.credits !== undefined; const healthStatus = a.health?.status; const planExcluded = a.selectionExcludedReason === "plan_excluded"; const showReauth = Boolean(a.needsReauth) || oauthHealthShowsReauth(healthStatus); @@ -224,7 +231,8 @@ export function CodexAccountPoolCards({ plan={a.plan} threshold={a.autoSwitchThresholdOverride ?? threshold} t={t} - pending={a.quota == null} + pending={a.quota == null && (loading || !showCredits)} + afterWeekly={showCredits && !loading ? : undefined} /> }
diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 487c2f910f9..9ec23fe5a79 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -3,11 +3,14 @@ import { IconLock, IconPause, IconPlay, IconPlus, IconRefresh, IconTicket } from import AccountPriorityControl, { AccountPriorityBadge } from "./AccountPriorityControl"; import AccountAutoSwitchControl from "./AccountAutoSwitchControl"; import QuotaBars from "./QuotaBars"; +import CodexCreditsRow from "./CodexCreditsRow"; +import { useI18n } from "../i18n/shared"; import { CodexPauseToggleLabel, CodexTicketBadge } from "./codex-account-pool-helpers"; import type { CodexAccountEntry, CodexAccountLoadState } from "./codex-account-pool-types"; import type { CodexAccountModeState } from "../codex-multi-state"; import type { TFn } from "../i18n/shared"; import type { MainDeviceReauthState } from "./use-main-device-reauth"; +import { LoginHint } from "./login-url-block"; import type { NoticeTone } from "../ui"; import { navigateHash } from "../hash-routing"; import { @@ -42,9 +45,13 @@ export function CodexAccountPoolMainCard({ doctorCopyOutcomeFor, onManageMainHardLock, mainReauth, + creditsVisible, + loading = false, }: { t: TFn; main: CodexAccountEntry | undefined; + creditsVisible?: boolean; + loading?: boolean; isMainActive: boolean; accountModeState: CodexAccountModeState | null; threshold: number; @@ -76,6 +83,8 @@ export function CodexAccountPoolMainCard({ cancel: () => Promise; } | undefined; }) { + const { locale } = useI18n(); + const showCredits = creditsVisible === true && main?.credits !== undefined; const mainFallbackLabel = t("codexAuth.codexApp"); const mainId = main?.id ?? "__main__"; const mainSwitchEntry: CodexAccountEntry = { @@ -228,12 +237,10 @@ export function CodexAccountPoolMainCard({ )} {mainReauth && (mainReauth.state.phase === "pending" || mainReauth.state.phase === "committing") && ( - {mainReauth.state.verificationUrl && ( - {t("codexAuth.mainReauthOpen")}: {mainReauth.state.verificationUrl} - )} - {mainReauth.state.deviceCode && ( - {t("codexAuth.mainReauthCode")}: {mainReauth.state.deviceCode} - )} + {/* The shared renderer every other login surface uses: a copyable code, a + selectable and copyable URL, and one click to copy the code and open the + page. Plain text here left the user retyping both by hand. */} + {t("codexAuth.mainReauthPending")} {mainReauth.state.cancelFailed && ( {t("codexAuth.mainReauthFailed")} @@ -257,7 +264,8 @@ export function CodexAccountPoolMainCard({ plan={main?.plan} threshold={mainSwitchEntry.autoSwitchThresholdOverride ?? threshold} t={t} - pending={main != null && main.quota == null} + pending={main != null && main.quota == null && (loading || !showCredits)} + afterWeekly={showCredits && !loading ? : undefined} /> }
@@ -274,9 +282,16 @@ export function CodexAccountPoolPageHead({ actionFeedbackTone, onRefresh, onPauseExhausted, + creditsVisible, + creditsBusy, + onToggleCredits, }: { t: TFn; embedded: boolean; + /** Undefined until settings loads. */ + creditsVisible?: boolean; + creditsBusy?: boolean; + onToggleCredits?: () => void; refreshingQuota: boolean; pausingExhausted: boolean; pauseBusy?: boolean; @@ -299,6 +314,22 @@ export function CodexAccountPoolPageHead({ > {actionFeedback ?? ""} + {creditsVisible !== undefined && onToggleCredits && ( + + {t("codexAuth.creditsToggle")} + + + )} {/* The standalone pause/refresh row sits next to the account cards. Embedded surfaces keep those actions beside feedback because there is no page title. */} {embedded && ( diff --git a/gui/src/components/login-url-block.tsx b/gui/src/components/login-url-block.tsx index a56227954d0..533967a2def 100644 --- a/gui/src/components/login-url-block.tsx +++ b/gui/src/components/login-url-block.tsx @@ -1,15 +1,28 @@ import { IconExternal, IconLink } from "../icons"; import { useT } from "../i18n/shared"; import { useCopyFeedback } from "./use-copy-feedback"; +import type { BrowserLaunch } from "../oauth-browser-launch"; + +/** Whether a login URL is something a browser can be asked to open. */ +function isOpenableUrl(url: string): boolean { + try { + const protocol = new URL(url).protocol; + return protocol === "https:" || protocol === "http:"; + } catch { + return false; + } +} /** - * Recovery affordance for an OAuth waiting state: the proxy already tried to - * open the browser server-side, so this block only matters once that failed. - * It exposes the authorization URL as selectable text, copies it, and offers a - * manual open — the single owner for all three login surfaces (workspace panel, - * add-provider modal, Codex account modal). + * The authorization URL of a login in progress: selectable text, a copy button, + * and a manual open — the single owner for every login surface (workspace panel, + * add-provider modal, Codex account modal, native main-account reauth). + * + * `openLabel` exists because "Didn't open?" is only true when something tried to + * open a browser. A device grant and a login whose operator declined the + * automatic launch never did, so for them the link is simply the way in. */ -export function LoginUrlBlock({ url }: { url: string }) { +export function LoginUrlBlock({ url, openLabel }: { url: string; openLabel?: string }) { const t = useT(); const { outcomeFor, copy } = useCopyFeedback(); @@ -26,14 +39,7 @@ export function LoginUrlBlock({ url }: { url: string }) { // inherently trustworthy: only offer navigation for schemes a browser can // safely open. Unsafe or malformed values stay visible and copyable but are // never rendered as a clickable link. - const canOpen = (() => { - try { - const protocol = new URL(url).protocol; - return protocol === "https:" || protocol === "http:"; - } catch { - return false; - } - })(); + const canOpen = isOpenableUrl(url); return (
@@ -45,7 +51,7 @@ export function LoginUrlBlock({ url }: { url: string }) { {canOpen && ( - )}
@@ -58,6 +64,13 @@ export type LoginHintData = { url?: string; deviceCode?: string; instructions?: string; + /** + * What the proxy reported about opening the browser itself. Absent from older + * servers and from surfaces with no server launch; only `"failed"` changes + * what is shown, because `"started"` proves a launcher ran, not that a page + * rendered. + */ + browserLaunch?: BrowserLaunch; }; export type LoginHintPaste = { @@ -105,20 +118,44 @@ export function LoginHint({ hint, paste }: { hint: LoginHintData; paste?: LoginH : deviceOutcome === "unavailable" ? t("prov.linkCopyUnavailable") : t("prov.copyCode"); + // Nothing tried to open a browser for a device grant or a declined launch, so + // the link is labelled as the way in rather than as a recovery. + const openLabel = deviceCode || hint.browserLaunch === "skipped" ? t("prov.openSignInPage") : undefined; + // Copy the code and open the page in one click, the way device logins usually + // go: the user lands on a page asking for a code that is already on their + // clipboard. The copy is started before the open so it still runs inside the + // click's user activation; the code stays on screen either way. Inside the + // desktop app the new window is handed to the default browser by the shell. + const copyAndOpen = deviceCode && isOpenableUrl(url) + ? () => { + deviceCopy.copy(deviceCode, deviceCode); + window.open(url, "_blank", "noopener,noreferrer"); + } + : null; return (
+ {hint.browserLaunch === "failed" && url && ( +
+ {t("prov.browserLaunchFailed")} +
+ )} {deviceCode && (
{t("prov.deviceCode")} {deviceCode} - + {copyAndOpen && ( + + )}
)} - + {hint.instructions &&
{hint.instructions}
} {paste && !deviceCode && (
diff --git a/gui/src/components/provider-catalog/CatalogAccountRow.tsx b/gui/src/components/provider-catalog/CatalogAccountRow.tsx index e5f824225d6..b259acf6ed3 100644 --- a/gui/src/components/provider-catalog/CatalogAccountRow.tsx +++ b/gui/src/components/provider-catalog/CatalogAccountRow.tsx @@ -110,7 +110,7 @@ export default function CatalogAccountRow({
{showHint && loginHint && ( void; setOauthMsg: (v: string) => void; setOauthMsgTone: (v: "ok" | "warn") => void; - setOauthUrl: (url: string, providerId: string, deviceCode?: string, instructions?: string) => void; + setOauthUrl: (url: string, providerId: string, deviceCode?: string, instructions?: string, browserLaunch?: BrowserLaunch) => void; setManualCode: (v: string) => void; setManualCodeMsg: (v: string) => void; setManualCodeOk: (v: boolean) => void; @@ -115,12 +117,14 @@ export function useAddProviderOAuth({ // A device flow may return a user code with no URL, and `instructions` may // carry the only human-readable step. Keep all three: the hint renderer // decides what to show, rather than this hook deciding what to discard. - const data = await res.json() as { url?: string; instructions?: string; deviceCode?: string; error?: string }; + const data = await res.json() as { url?: string; instructions?: string; deviceCode?: string; error?: string; browserLaunch?: unknown }; if (!aliveRef.current || !isCurrent()) return; - setOauthUrl(data.url ?? "", providerId, data.deviceCode, data.instructions); + setOauthUrl(data.url ?? "", providerId, data.deviceCode, data.instructions, parseBrowserLaunch(data.browserLaunch)); if (data.url || data.deviceCode) setOauthMsg(t("modal.waitingLogin")); else setOauthMsg(data.instructions || t("modal.loggingIn")); - for (let i = 0; i < 100; i++) { + // A device grant outlives the browser budget; see oauth-login-budget.ts. + let deviceFlow = Boolean(data.deviceCode); + for (let i = 0; i < loginPollAttempts(deviceFlow, OAUTH_LOGIN_POLL_INTERVAL_MS, 100); i++) { await new Promise(r => setTimeout(r, OAUTH_LOGIN_POLL_INTERVAL_MS)); if (!aliveRef.current || !isCurrent()) return; const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${providerId}`).catch(() => null); @@ -138,11 +142,13 @@ export function useAddProviderOAuth({ onAdded(providerId); return; } - if (s?.hint) { - setOauthUrl(s.hint.url ?? "", providerId, s.hint.deviceCode, s.hint.instructions); - setOauthMsg(s.hint.url || s.hint.deviceCode + const hint = s?.hint; + if (hint) { + if (hint.deviceCode) deviceFlow = true; + setOauthUrl(hint.url ?? "", providerId, hint.deviceCode, hint.instructions); + setOauthMsg(hint.url || hint.deviceCode ? t("modal.waitingLogin") - : (s.hint.instructions || t("modal.loggingIn"))); + : (hint.instructions || t("modal.loggingIn"))); } } await cancelServerLogin(providerId); diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index 615a6288569..9765212944f 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -31,6 +31,15 @@ export interface MainAccountHardLockStatus { resetAt?: number; } +export interface CodexCredits { + hasCredits?: boolean; + unlimited?: boolean; + overageLimitReached?: boolean; + balance?: string; + approxLocalMessages?: [number, number]; + approxCloudMessages?: [number, number]; +} + export interface CodexAccountEntry { id: string; email: string; @@ -48,6 +57,8 @@ export interface CodexAccountEntry { autoSwitchThresholdOverride: number | null; hasCredential: boolean; quota: AccountQuota | null; + /** Display-only observation; never used for account selection. */ + credits?: CodexCredits; quotaAutoRefresh: { fiveHourAvailable: boolean; weeklyAvailable: boolean; diff --git a/gui/src/hooks/useCodexCreditsVisibility.ts b/gui/src/hooks/useCodexCreditsVisibility.ts new file mode 100644 index 00000000000..2aea90a645f --- /dev/null +++ b/gui/src/hooks/useCodexCreditsVisibility.ts @@ -0,0 +1,115 @@ +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { createBoundedFetch } from "../bounded-fetch"; +import type { TFn } from "../i18n/shared"; +import type { NoticeTone } from "../ui"; + +/** Display preference only. A saved preference survives a failed account reload. */ +export function useCodexCreditsVisibility( + apiBase: string, + reloadAccounts: (refreshQuota?: boolean) => Promise, + showActionFeedback: (text: string, tone?: NoticeTone) => void, + t: TFn, + settingsRead?: { + revision: number; + /** Share the parsed initial GET with the page's other settings consumer. */ + onRead: (read: Promise, signal: AbortSignal) => void; + }, +) { + const [state, setState] = useState<{ apiBase: string; visible?: boolean; busy: boolean }>({ apiBase, busy: false }); + const scopeRef = useRef(null); + const mutationRevisionRef = useRef(0); + const observeSettingsRead = useEffectEvent((read: Promise, signal: AbortSignal) => { + settingsRead?.onRead(read, signal); + }); + const readRevision = settingsRead?.revision ?? 0; + const mutationRef = useRef(null); + // Reset at the prop boundary, including A → B → A before any new settings read. + if (state.apiBase !== apiBase) setState({ apiBase, busy: false }); + const visible = state.apiBase === apiBase ? state.visible : undefined; + const busy = state.apiBase === apiBase && state.busy; + + useEffect(() => { + const scope = new AbortController(); + scopeRef.current = scope; + return () => { + scope.abort(); + mutationRef.current?.abort(); + mutationRef.current = null; + }; + }, [apiBase]); + + useEffect(() => { + const abort = new AbortController(); + const bounded = createBoundedFetch(15_000); + const startedMutationRevision = mutationRevisionRef.current; + const read = fetch(`${apiBase}/api/settings`, { signal: bounded.signal }) + .then(response => { if (!response.ok) throw new Error("read"); return response.json() as Promise; }); + observeSettingsRead(read, abort.signal); + read.then(payload => { + if (abort.signal.aborted || mutationRef.current || mutationRevisionRef.current !== startedMutationRevision) return; + if (!payload || typeof payload !== "object" || !("showCodexCredits" in payload) + || typeof payload.showCodexCredits !== "boolean") return; + setState({ apiBase, visible: payload.showCodexCredits, busy: false }); + }) + // A failed read leaves the switch unrendered instead of guessing its state. + .catch(() => {}) + .finally(() => bounded.clear()); + return () => { + abort.abort(); + bounded.controller.abort(); + bounded.clear(); + }; + }, [apiBase, readRevision]); + + const toggle = async () => { + const scope = scopeRef.current; + if (visible === undefined || busy || mutationRef.current || !scope || scope.signal.aborted) return; + const mutation = new AbortController(); + mutationRef.current = mutation; + // Bounded like the read: a relay that accepts the PUT and never answers must not leave the + // switch disabled with an unreconciled optimistic value. + const write = createBoundedFetch(15_000); + const abortWrite = () => write.controller.abort(); + mutation.signal.addEventListener("abort", abortWrite, { once: true }); + const current = () => scopeRef.current === scope && !scope.signal.aborted; + const requested = !visible; + mutationRevisionRef.current += 1; + setState({ apiBase, visible: requested, busy: true }); + try { + let confirmed: boolean; + try { + const response = await fetch(`${apiBase}/api/settings`, { + method: "PUT", headers: { "content-type": "application/json" }, signal: write.signal, + body: JSON.stringify({ showCodexCredits: requested }), + }); + if (!response.ok) throw new Error("save"); + const payload = await response.json() as { showCodexCredits?: unknown } | null; + if (typeof payload?.showCodexCredits !== "boolean") throw new Error("shape"); + confirmed = payload.showCodexCredits; + } catch { + if (current()) { + setState({ apiBase, visible, busy: false }); + showActionFeedback(t("codexAuth.creditsToggleFailed"), "err"); + } + return; + } + if (!current()) return; + setState({ apiBase, visible: confirmed, busy: true }); + showActionFeedback(t(confirmed ? "codexAuth.creditsShown" : "codexAuth.creditsHidden"), "ok"); + try { + const ok = await reloadAccounts(true); + if (current() && !ok) showActionFeedback(t("codexAuth.quotaRefreshFailed"), "err"); + } catch { + if (current()) showActionFeedback(t("codexAuth.quotaRefreshFailed"), "err"); + } + } finally { + write.clear(); + mutation.signal.removeEventListener("abort", abortWrite); + if (current()) { + mutationRef.current = null; + setState(previous => ({ ...previous, busy: false })); + } + } + }; + return { visible, busy, toggle }; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6ddb8ba1d59..204aa1cf580 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -258,6 +258,9 @@ export const de: Record = { "startup.preference": "Start bei Bedarf", "startup.enabled": "Aktiviert", "startup.disabled": "Deaktiviert", + "startup.protection.desktop": "Desktop-App", + "startup.desktopHint": "Startet bei der Anmeldung und überwacht den integrierten Proxy. Zuständigkeit und laufende Prozesse werden geprüft.", + "startup.desktopRecovery": "OpenCodex verwaltet diesen Proxy. Öffne die Desktop-App erneut und prüfe „Beim Anmelden starten“. Dienst- und Launcher-Änderungen bleiben währenddessen gesperrt.", "startup.protection.service": "Hintergrunddienst", "startup.protection.shim": "Launcher-Shim", "startup.protection.none": "Nicht installiert", @@ -527,6 +530,9 @@ export const de: Record = { "prov.linkCopyUnavailable": "Zwischenablage nicht verfügbar", "prov.deviceCode": "Gerätecode", "prov.copyCode": "Code kopieren", + "prov.copyCodeAndOpen": "Code kopieren & öffnen", + "prov.openSignInPage": "Anmeldeseite öffnen", + "prov.browserLaunchFailed": "Der Browser hat sich nicht automatisch geöffnet. Öffne unten die Anmeldeseite oder kopiere den Link.", "prov.codeCopied": "Code kopiert", "prov.editAlias": "Alias bearbeiten", "prov.aliasPrompt": "Anzeigename (leer lassen zum Entfernen)", @@ -1688,6 +1694,16 @@ export const de: Record = { "codexAuth.openaiPresetUnavailable": "OpenAI-Anbieter-Preset ist nicht verfügbar.", "codexAuth.openProviders": "Anbieter öffnen", "codexAuth.add": "Hinzufügen", + "codexAuth.credits": "Credits", + "codexAuth.creditsRemaining": "verbleibend", + "codexAuth.creditsUnlimited": "Unbegrenzt", + "codexAuth.creditsOverage": "Zusatzlimit erreicht", + "codexAuth.creditsApprox": "Ungefähre Nachrichten: lokal {local} · Cloud {cloud}", + "codexAuth.creditsToggle": "Codex-Credits", + "codexAuth.creditsToggleHint": "Verbleibende Codex-Credits auf Kontokarten anzeigen. Standardmäßig ausgeblendet.", + "codexAuth.creditsShown": "Codex-Credits werden angezeigt", + "codexAuth.creditsHidden": "Codex-Credits ausgeblendet", + "codexAuth.creditsToggleFailed": "Codex-Credits-Einstellung konnte nicht geändert werden", "codexAuth.refreshQuota": "Kontingente aktualisieren", "codexAuth.ultraFastTitle": "Ultra-Fast-Diensttarif", "codexAuth.mainHardLockTitle": "Hauptkonto bei 98 % sperren", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index cd0bf7c4953..eb0b4655184 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -267,6 +267,9 @@ export const en = { "startup.preference": "On-demand startup", "startup.enabled": "Enabled", "startup.disabled": "Disabled", + "startup.protection.desktop": "Desktop app", + "startup.desktopHint": "Starts at login; the desktop app supervises its bundled proxy. Ownership and live processes are verified.", + "startup.desktopRecovery": "OpenCodex owns this proxy. Reopen the desktop app and check Start at Login. Service and launcher changes stay disabled while the desktop app owns it.", "startup.protection.service": "Background service", "startup.protection.shim": "Launcher shim", "startup.protection.none": "Not installed", @@ -550,6 +553,9 @@ export const en = { "prov.linkCopyUnavailable": "Clipboard unavailable", "prov.deviceCode": "Device code", "prov.copyCode": "Copy code", + "prov.copyCodeAndOpen": "Copy code & open", + "prov.openSignInPage": "Open sign-in page", + "prov.browserLaunchFailed": "The browser didn't open automatically. Open the sign-in page below or copy the link.", "prov.codeCopied": "Code copied", "prov.editAlias": "Edit alias", "prov.aliasPrompt": "Display name (leave empty to clear)", @@ -2291,6 +2297,16 @@ export const en = { "codexAuth.openaiPresetUnavailable": "OpenAI provider preset is unavailable.", "codexAuth.openProviders": "Open Providers", "codexAuth.add": "Add", + "codexAuth.credits": "Credits", + "codexAuth.creditsRemaining": "remaining", + "codexAuth.creditsUnlimited": "Unlimited", + "codexAuth.creditsOverage": "Overage limit reached", + "codexAuth.creditsApprox": "Approx. messages: local {local} · cloud {cloud}", + "codexAuth.creditsToggle": "Codex credits", + "codexAuth.creditsToggleHint": "Show remaining Codex credits on account cards. Hidden by default.", + "codexAuth.creditsShown": "Codex credits shown", + "codexAuth.creditsHidden": "Codex credits hidden", + "codexAuth.creditsToggleFailed": "Could not change the Codex credits setting", "codexAuth.refreshQuota": "Refresh quotas", "codexAuth.ultraFastTitle": "Ultra Fast service tier", "codexAuth.mainHardLockTitle": "Block main account at 98%", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b146c51151c..c6cdb4e76e6 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -260,6 +260,9 @@ export const fr: Record = { "startup.preference": "Démarrage à la demande", "startup.enabled": "Activé", "startup.disabled": "Désactivé", + "startup.protection.desktop": "Application de bureau", + "startup.desktopHint": "Démarre à la connexion et supervise son proxy intégré. La prise en charge et les processus actifs sont vérifiés.", + "startup.desktopRecovery": "OpenCodex gère ce proxy. Rouvrez l’application de bureau et vérifiez le démarrage à la connexion. Les modifications du service et du lanceur restent désactivées tant que l’application le gère.", "startup.protection.service": "Service en arrière-plan", "startup.protection.shim": "Mécanisme de lancement", "startup.protection.none": "Non installé", @@ -537,6 +540,9 @@ export const fr: Record = { "prov.linkCopyUnavailable": "Presse-papiers indisponible", "prov.deviceCode": "Code de l’appareil", "prov.copyCode": "Copier le code", + "prov.copyCodeAndOpen": "Copier le code et ouvrir", + "prov.openSignInPage": "Ouvrir la page de connexion", + "prov.browserLaunchFailed": "Le navigateur ne s’est pas ouvert automatiquement. Ouvrez la page de connexion ci-dessous ou copiez le lien.", "prov.codeCopied": "Code copié", "prov.editAlias": "Modifier l’alias", "prov.aliasPrompt": "Nom d’affichage (laissez vide pour l’effacer)", @@ -2218,6 +2224,16 @@ export const fr: Record = { "codexAuth.openaiPresetUnavailable": "Le préréglage du fournisseur OpenAI est indisponible.", "codexAuth.openProviders": "Ouvrir Fournisseurs", "codexAuth.add": "Ajouter", + "codexAuth.credits": "Crédits", + "codexAuth.creditsRemaining": "restants", + "codexAuth.creditsUnlimited": "Illimité", + "codexAuth.creditsOverage": "Limite de dépassement atteinte", + "codexAuth.creditsApprox": "Messages estimés : locaux {local} · cloud {cloud}", + "codexAuth.creditsToggle": "Crédits Codex", + "codexAuth.creditsToggleHint": "Afficher les crédits Codex restants sur les cartes de compte. Masqués par défaut.", + "codexAuth.creditsShown": "Crédits Codex affichés", + "codexAuth.creditsHidden": "Crédits Codex masqués", + "codexAuth.creditsToggleFailed": "Impossible de modifier le réglage des crédits Codex", "codexAuth.refreshQuota": "Actualiser les quotas", "codexAuth.ultraFastTitle": "Niveau de service Ultra Fast", "codexAuth.mainHardLockTitle": "Bloquer le compte principal à 98 %", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 479576d4484..36d2bfcbe27 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -265,6 +265,9 @@ export const ja: Record = { "startup.preference": "オンデマンド起動", "startup.enabled": "有効", "startup.disabled": "無効", + "startup.protection.desktop": "デスクトップアプリ", + "startup.desktopHint": "ログイン時に起動し、内蔵プロキシを監視します。所有権の記録と実行中のプロセスを確認します。", + "startup.desktopRecovery": "OpenCodex がこのプロキシを管理しています。デスクトップアプリを開き直し、ログイン時の起動を確認してください。アプリが管理している間はサービスとランチャーを変更できません。", "startup.protection.service": "バックグラウンドサービス", "startup.protection.shim": "Launcher shim", "startup.protection.none": "未インストール", @@ -538,6 +541,9 @@ export const ja: Record = { "prov.linkCopyUnavailable": "クリップボードを使用できません", "prov.deviceCode": "デバイスコード", "prov.copyCode": "コードをコピー", + "prov.copyCodeAndOpen": "コードをコピーして開く", + "prov.openSignInPage": "サインインページを開く", + "prov.browserLaunchFailed": "ブラウザーが自動で開きませんでした。下のサインインページを開くか、リンクをコピーしてください。", "prov.codeCopied": "コードをコピーしました", "prov.pasteRedirect": "リダイレクト URL またはコードを貼り付け", "prov.pasteRedirectHint": "ブラウザに localhost エラーが表示された場合、アドレスバーから URL 全体をコピーしてここに貼り付けてください(または認可コードを貼り付け)。", @@ -2121,6 +2127,16 @@ export const ja: Record = { "codexAuth.openaiPresetUnavailable": "OpenAI プロバイダーのプリセットを利用できません。", "codexAuth.openProviders": "プロバイダーを開く", "codexAuth.add": "追加", + "codexAuth.credits": "クレジット", + "codexAuth.creditsRemaining": "残り", + "codexAuth.creditsUnlimited": "無制限", + "codexAuth.creditsOverage": "超過利用上限に到達", + "codexAuth.creditsApprox": "推定メッセージ数: ローカル {local} · クラウド {cloud}", + "codexAuth.creditsToggle": "Codex クレジット", + "codexAuth.creditsToggleHint": "アカウントカードに残りの Codex クレジットを表示します。既定は非表示です。", + "codexAuth.creditsShown": "Codex クレジットを表示しました", + "codexAuth.creditsHidden": "Codex クレジットを非表示にしました", + "codexAuth.creditsToggleFailed": "Codex クレジットの設定を変更できませんでした", "codexAuth.refreshQuota": "クォータを更新", "codexAuth.ultraFastTitle": "Ultra Fast サービスティア", "codexAuth.mainHardLockTitle": "メインアカウントを98%で停止", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 55ad082da30..73fad35e2d9 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -260,6 +260,9 @@ export const ko: Record = { "startup.preference": "필요 시 자동 시작", "startup.enabled": "켜짐", "startup.disabled": "꺼짐", + "startup.protection.desktop": "데스크톱 앱", + "startup.desktopHint": "로그인 시 시작하여 내장 프록시를 관리합니다. 소유 기록과 실행 중인 프로세스를 확인합니다.", + "startup.desktopRecovery": "OpenCodex 데스크톱 앱이 이 프록시를 관리합니다. 앱을 다시 열고 로그인 시 시작 설정을 확인하세요. 앱이 관리하는 동안 서비스와 런처는 변경할 수 없습니다.", "startup.protection.service": "백그라운드 서비스", "startup.protection.shim": "Launcher shim", "startup.protection.none": "설치되지 않음", @@ -536,6 +539,9 @@ export const ko: Record = { "prov.linkCopyUnavailable": "클립보드를 사용할 수 없음", "prov.deviceCode": "기기 인증 코드", "prov.copyCode": "코드 복사", + "prov.copyCodeAndOpen": "코드 복사 후 열기", + "prov.openSignInPage": "로그인 페이지 열기", + "prov.browserLaunchFailed": "브라우저가 자동으로 열리지 않았습니다. 아래 로그인 페이지를 열거나 링크를 복사하세요.", "prov.codeCopied": "코드 복사됨", "prov.editAlias": "별칭 편집", "prov.aliasPrompt": "표시 이름 (비우면 삭제)", @@ -1724,6 +1730,16 @@ export const ko: Record = { "codexAuth.openaiPresetUnavailable": "OpenAI 공급자 프리셋을 사용할 수 없습니다.", "codexAuth.openProviders": "프로바이더 열기", "codexAuth.add": "추가", + "codexAuth.credits": "크레딧", + "codexAuth.creditsRemaining": "남음", + "codexAuth.creditsUnlimited": "무제한", + "codexAuth.creditsOverage": "초과 사용 한도 도달", + "codexAuth.creditsApprox": "예상 메시지: 로컬 {local} · 클라우드 {cloud}", + "codexAuth.creditsToggle": "Codex 크레딧", + "codexAuth.creditsToggleHint": "계정 카드에 남은 Codex 크레딧을 표시합니다. 기본값은 숨김입니다.", + "codexAuth.creditsShown": "Codex 크레딧을 표시합니다", + "codexAuth.creditsHidden": "Codex 크레딧을 숨겼습니다", + "codexAuth.creditsToggleFailed": "Codex 크레딧 설정을 바꾸지 못했습니다", "codexAuth.refreshQuota": "할당량 새로고침", "codexAuth.ultraFastTitle": "Ultra Fast 서비스 티어", "codexAuth.mainHardLockTitle": "메인 계정 98% 차단", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 545641e5a71..6a592b041a7 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -265,6 +265,9 @@ export const ru: Record = { "startup.preference": "Запуск по требованию", "startup.enabled": "Включён", "startup.disabled": "Выключен", + "startup.protection.desktop": "Настольное приложение", + "startup.desktopHint": "Запускается при входе и следит за встроенным прокси. Проверяются владелец и работающие процессы.", + "startup.desktopRecovery": "OpenCodex управляет этим прокси. Откройте настольное приложение заново и проверьте запуск при входе. Изменения службы и запускающего скрипта недоступны, пока прокси принадлежит приложению.", "startup.protection.service": "Фоновая служба", "startup.protection.shim": "Launcher shim", "startup.protection.none": "Не установлен", @@ -538,6 +541,9 @@ export const ru: Record = { "prov.linkCopyUnavailable": "Буфер обмена недоступен", "prov.deviceCode": "Код устройства", "prov.copyCode": "Копировать код", + "prov.copyCodeAndOpen": "Скопировать код и открыть", + "prov.openSignInPage": "Открыть страницу входа", + "prov.browserLaunchFailed": "Браузер не открылся автоматически. Откройте страницу входа ниже или скопируйте ссылку.", "prov.codeCopied": "Код скопирован", "prov.editAlias": "Изменить псевдоним", "prov.aliasPrompt": "Отображаемое имя (оставьте пустым для удаления)", @@ -2212,6 +2218,16 @@ export const ru: Record = { "codexAuth.openaiPresetUnavailable": "Пресет провайдера OpenAI недоступен.", "codexAuth.openProviders": "Открыть провайдеров", "codexAuth.add": "Добавить", + "codexAuth.credits": "Кредиты", + "codexAuth.creditsRemaining": "осталось", + "codexAuth.creditsUnlimited": "Без ограничений", + "codexAuth.creditsOverage": "Лимит превышения достигнут", + "codexAuth.creditsApprox": "Примерное число сообщений: локально {local} · в облаке {cloud}", + "codexAuth.creditsToggle": "Кредиты Codex", + "codexAuth.creditsToggleHint": "Показывать оставшиеся кредиты Codex на карточках аккаунтов. По умолчанию скрыты.", + "codexAuth.creditsShown": "Кредиты Codex показаны", + "codexAuth.creditsHidden": "Кредиты Codex скрыты", + "codexAuth.creditsToggleFailed": "Не удалось изменить настройку кредитов Codex", "codexAuth.refreshQuota": "Обновить квоты", "codexAuth.ultraFastTitle": "Уровень обслуживания Ultra Fast", "codexAuth.mainHardLockTitle": "Блокировать основной аккаунт при 98%", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 6de30bf8766..f555706cce0 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -266,6 +266,9 @@ export const tr: Record = { "startup.preference": "İsteğe bağlı başlatma", "startup.enabled": "Etkin", "startup.disabled": "Devre dışı", + "startup.protection.desktop": "Masaüstü uygulaması", + "startup.desktopHint": "Oturum açıldığında başlar ve yerleşik proxy’yi izler. Sahiplik ve çalışan işlemler doğrulanır.", + "startup.desktopRecovery": "Bu proxy OpenCodex tarafından yönetiliyor. Masaüstü uygulamasını yeniden açın ve oturum açılışında başlatma ayarını kontrol edin. Uygulama proxy’yi yönettiği sürece servis ve başlatıcı değişiklikleri devre dışıdır.", "startup.protection.service": "Arka plan servisi", "startup.protection.shim": "Başlatıcı shim", "startup.protection.none": "Yüklü değil", @@ -541,6 +544,9 @@ export const tr: Record = { "prov.linkCopyUnavailable": "Pano kullanılamıyor", "prov.deviceCode": "Cihaz kodu", "prov.copyCode": "Kodu kopyala", + "prov.copyCodeAndOpen": "Kodu kopyala ve aç", + "prov.openSignInPage": "Oturum açma sayfasını aç", + "prov.browserLaunchFailed": "Tarayıcı otomatik olarak açılmadı. Aşağıdaki oturum açma sayfasını açın veya bağlantıyı kopyalayın.", "prov.codeCopied": "Kod kopyalandı", "prov.editAlias": "Takma adı düzenle", "prov.aliasPrompt": "Görüntülenen ad (temizlemek için boş bırakın)", @@ -2242,6 +2248,16 @@ export const tr: Record = { "codexAuth.openaiPresetUnavailable": "Ayar kullanılamıyor.", "codexAuth.openProviders": "Sağlayıcıları Aç", "codexAuth.add": "Ekle", + "codexAuth.credits": "Kredi", + "codexAuth.creditsRemaining": "kalan", + "codexAuth.creditsUnlimited": "Sınırsız", + "codexAuth.creditsOverage": "Aşım sınırına ulaşıldı", + "codexAuth.creditsApprox": "Tahmini mesajlar: yerel {local} · bulut {cloud}", + "codexAuth.creditsToggle": "Codex kredileri", + "codexAuth.creditsToggleHint": "Hesap kartlarında kalan Codex kredilerini gösterir. Varsayılan olarak gizlidir.", + "codexAuth.creditsShown": "Codex kredileri gösteriliyor", + "codexAuth.creditsHidden": "Codex kredileri gizlendi", + "codexAuth.creditsToggleFailed": "Codex kredileri ayarı değiştirilemedi", "codexAuth.refreshQuota": "Kotaları yenile", "codexAuth.ultraFastTitle": "Ultra Fast hizmet katmanı", "codexAuth.mainHardLockTitle": "Ana hesabı %98’de durdur", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 23a799cd258..53909f52acf 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -260,6 +260,9 @@ export const vi: Record = { "startup.preference": "Khởi động theo yêu cầu", "startup.enabled": "Đã bật", "startup.disabled": "Đã tắt", + "startup.protection.desktop": "Ứng dụng máy tính", + "startup.desktopHint": "Khởi chạy khi đăng nhập và giám sát proxy tích hợp. Xác minh quyền quản lý và các tiến trình đang chạy.", + "startup.desktopRecovery": "OpenCodex quản lý proxy này. Mở lại ứng dụng máy tính và kiểm tra khởi động khi đăng nhập. Không thể thay đổi dịch vụ hay trình khởi chạy khi ứng dụng đang quản lý proxy.", "startup.protection.service": "Service nền", "startup.protection.shim": "Trình bao bọc khởi chạy", "startup.protection.none": "Chưa cài đặt", @@ -537,6 +540,9 @@ export const vi: Record = { "prov.linkCopyUnavailable": "Clipboard không khả dụng", "prov.deviceCode": "Mã thiết bị", "prov.copyCode": "Sao chép mã", + "prov.copyCodeAndOpen": "Sao chép mã và mở", + "prov.openSignInPage": "Mở trang đăng nhập", + "prov.browserLaunchFailed": "Trình duyệt không tự mở. Hãy mở trang đăng nhập bên dưới hoặc sao chép liên kết.", "prov.codeCopied": "Đã sao chép mã", "prov.editAlias": "Chỉnh sửa bí danh", "prov.aliasPrompt": "Tên hiển thị (để trống để xoá)", @@ -2246,6 +2252,16 @@ export const vi: Record = { "codexAuth.openaiPresetUnavailable": "Preset của provider OpenAI không khả dụng.", "codexAuth.openProviders": "Mở Providers", "codexAuth.add": "Thêm", + "codexAuth.credits": "Tín dụng", + "codexAuth.creditsRemaining": "còn lại", + "codexAuth.creditsUnlimited": "Không giới hạn", + "codexAuth.creditsOverage": "Đã đạt giới hạn vượt mức", + "codexAuth.creditsApprox": "Số tin nhắn ước tính: cục bộ {local} · đám mây {cloud}", + "codexAuth.creditsToggle": "Tín dụng Codex", + "codexAuth.creditsToggleHint": "Hiển thị tín dụng Codex còn lại trên thẻ tài khoản. Mặc định ẩn.", + "codexAuth.creditsShown": "Đã hiển thị tín dụng Codex", + "codexAuth.creditsHidden": "Đã ẩn tín dụng Codex", + "codexAuth.creditsToggleFailed": "Không thể thay đổi cài đặt tín dụng Codex", "codexAuth.refreshQuota": "Làm mới quota", "codexAuth.ultraFastTitle": "Dịch vụ cấp độ Ultra Fast", "codexAuth.mainHardLockTitle": "Khóa tài khoản chính ở mức 98%", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index b8b8786e243..c30d4386ae8 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -152,6 +152,9 @@ export const zhTW: Record = { "startup.preference": "按需啟動", "startup.enabled": "已啟用", "startup.disabled": "已停用", + "startup.protection.desktop": "桌面應用程式", + "startup.desktopHint": "登入時啟動,由桌面應用程式守護內建代理;已核對接管記錄和實際程序。", + "startup.desktopRecovery": "OpenCodex 桌面應用程式正在接管此代理。請重新開啟應用程式並檢查「登入時啟動」。桌面應用程式仍持有接管記錄時,服務和啟動指令碼的變更保持停用。", "startup.protection.service": "背景服務", "startup.protection.shim": "Launcher shim", "startup.protection.none": "未安裝", @@ -411,6 +414,9 @@ export const zhTW: Record = { "prov.linkCopyUnavailable": "剪貼簿不可用", "prov.deviceCode": "裝置驗證碼", "prov.copyCode": "複製驗證碼", + "prov.copyCodeAndOpen": "複製驗證碼並開啟", + "prov.openSignInPage": "開啟登入頁面", + "prov.browserLaunchFailed": "瀏覽器未能自動開啟。請開啟下方的登入頁面,或複製連結。", "prov.codeCopied": "驗證碼已複製", "prov.editAlias": "編輯別名", "prov.aliasPrompt": "顯示名稱(留空以清除)", @@ -1665,6 +1671,16 @@ export const zhTW: Record = { "codexAuth.openaiPresetUnavailable": "OpenAI 供應商預設不可用。", "codexAuth.openProviders": "開啟供應商", "codexAuth.add": "新增", + "codexAuth.credits": "額度", + "codexAuth.creditsRemaining": "剩餘", + "codexAuth.creditsUnlimited": "無限制", + "codexAuth.creditsOverage": "已達超額使用上限", + "codexAuth.creditsApprox": "預估訊息數:本機 {local} · 雲端 {cloud}", + "codexAuth.creditsToggle": "Codex 額度", + "codexAuth.creditsToggleHint": "在帳號卡片上顯示剩餘 Codex 額度。預設隱藏。", + "codexAuth.creditsShown": "已顯示 Codex 額度", + "codexAuth.creditsHidden": "已隱藏 Codex 額度", + "codexAuth.creditsToggleFailed": "無法變更 Codex 額度設定", "codexAuth.refreshQuota": "重新整理額度", "codexAuth.ultraFastTitle": "Ultra Fast 服務層級", "codexAuth.mainHardLockTitle": "主帳戶用量達 98% 時阻擋請求", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c93b513d65a..36577edd129 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -260,6 +260,9 @@ export const zh: Record = { "startup.preference": "按需启动", "startup.enabled": "已启用", "startup.disabled": "已禁用", + "startup.protection.desktop": "桌面应用", + "startup.desktopHint": "登录时启动,由桌面应用守护内置代理;已核对接管记录和实际进程。", + "startup.desktopRecovery": "OpenCodex 桌面应用正在接管此代理。请重新打开应用并检查“登录时启动”。桌面应用仍持有接管记录时,服务和启动脚本的更改保持禁用。", "startup.protection.service": "后台服务", "startup.protection.shim": "Launcher shim", "startup.protection.none": "未安装", @@ -533,6 +536,9 @@ export const zh: Record = { "prov.linkCopyUnavailable": "剪贴板不可用", "prov.deviceCode": "设备验证码", "prov.copyCode": "复制验证码", + "prov.copyCodeAndOpen": "复制验证码并打开", + "prov.openSignInPage": "打开登录页面", + "prov.browserLaunchFailed": "浏览器未能自动打开。请打开下方的登录页面,或复制链接。", "prov.codeCopied": "验证码已复制", "prov.editAlias": "编辑别名", "prov.aliasPrompt": "显示名称(留空以清除)", @@ -1705,6 +1711,16 @@ export const zh: Record = { "codexAuth.openaiPresetUnavailable": "OpenAI 提供方预设不可用。", "codexAuth.openProviders": "打开提供商", "codexAuth.add": "添加", + "codexAuth.credits": "额度", + "codexAuth.creditsRemaining": "剩余", + "codexAuth.creditsUnlimited": "无限制", + "codexAuth.creditsOverage": "已达到超额使用上限", + "codexAuth.creditsApprox": "预计消息数:本地 {local} · 云端 {cloud}", + "codexAuth.creditsToggle": "Codex 额度", + "codexAuth.creditsToggleHint": "在账户卡片上显示剩余 Codex 额度。默认隐藏。", + "codexAuth.creditsShown": "已显示 Codex 额度", + "codexAuth.creditsHidden": "已隐藏 Codex 额度", + "codexAuth.creditsToggleFailed": "无法更改 Codex 额度设置", "codexAuth.refreshQuota": "刷新额度", "codexAuth.ultraFastTitle": "Ultra Fast 服务层级", "codexAuth.mainHardLockTitle": "主账户用量达 98% 时阻止请求", diff --git a/gui/src/oauth-browser-launch.ts b/gui/src/oauth-browser-launch.ts new file mode 100644 index 00000000000..7ebe5d54977 --- /dev/null +++ b/gui/src/oauth-browser-launch.ts @@ -0,0 +1,13 @@ +/** + * What the proxy reported about opening a browser for a login it started. + * + * `"started"` proves a launcher ran, not that a page rendered; `"failed"` means nothing could be + * opened on the proxy's machine; `"skipped"` means nothing was tried (a device grant, or an + * operator who declined the automatic launch). Older servers omit the field entirely. + */ +export type BrowserLaunch = "started" | "failed" | "skipped"; + +/** Narrow an untrusted response field; anything unexpected reads as unknown. */ +export function parseBrowserLaunch(value: unknown): BrowserLaunch | undefined { + return value === "started" || value === "failed" || value === "skipped" ? value : undefined; +} diff --git a/gui/src/oauth-login-budget.ts b/gui/src/oauth-login-budget.ts new file mode 100644 index 00000000000..c7d025976da --- /dev/null +++ b/gui/src/oauth-login-budget.ts @@ -0,0 +1,18 @@ +/** + * How long a dashboard login keeps polling before it gives up and cancels. + * + * A browser login finishes in the same sitting, so its surfaces keep their own short budgets. A + * device grant is different: the user leaves to type a code somewhere else, and the grant stays + * valid for as long as the provider says — 15 minutes for Copilot, Kimi and Nous, up to 30 for + * Meta Muse. A dashboard that stopped at five minutes cancelled logins that were still valid. + * + * This is a backstop only. The provider's own expiry ends a device login first, through the + * status error the poll already handles; the budget just has to outlast the longest grant. + */ +export const DEVICE_LOGIN_POLL_BUDGET_MS = 31 * 60_000; + +/** Poll attempts for a login, stretched to the device budget once the flow shows a device code. */ +export function loginPollAttempts(isDeviceFlow: boolean, intervalMs: number, browserAttempts: number): number { + if (!isDeviceFlow) return browserAttempts; + return Math.max(browserAttempts, Math.ceil(DEVICE_LOGIN_POLL_BUDGET_MS / intervalMs)); +} diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index f45544e0376..2a833d4aa70 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -26,6 +26,7 @@ import { navigateHash } from "../hash-routing"; import { JEV_AUTO_CREATE_HASH } from "../app-routing"; import { useProviderSettingsDeepLink } from "./providers-deep-link"; import { subscribeKiroDeviceFinal } from "../kiro-device-login-finalizer"; +import type { BrowserLaunch } from "../oauth-browser-launch"; /** The page's real refresh tickets: only the captured report epoch and account read can settle them. */ // oxlint-disable-next-line react/only-export-components -- keep the page-owned coordinator and its direct race tests in the authorized owner. @@ -225,7 +226,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { const [oauthProviders, setOauthProviders] = useState([]); const [oauthStatus, setOauthStatus] = useState>({}); const [busy, setBusy] = useState(null); - const [loginInfo, setLoginInfo] = useState<{ provider: string; url?: string; instructions?: string; deviceCode?: string } | null>(null); + const [loginInfo, setLoginInfo] = useState<{ provider: string; url?: string; instructions?: string; deviceCode?: string; browserLaunch?: BrowserLaunch } | null>(null); const [workspaceSelected, setWorkspaceSelected] = useState(null); const [addIntent, setAddIntent] = useState(null); const [removeConfirmName, setRemoveConfirmName] = useState(null); diff --git a/gui/src/pages/providers-page-modals.tsx b/gui/src/pages/providers-page-modals.tsx index ba5964675e3..f87a5a87c3c 100644 --- a/gui/src/pages/providers-page-modals.tsx +++ b/gui/src/pages/providers-page-modals.tsx @@ -5,6 +5,7 @@ import OAuthTosWarningModal from "../components/OAuthTosWarningModal"; import { RemoveConfirmDialog, UnsavedLeaveDialog } from "../components/provider-workspace/ProviderDialogs"; import type { AddProviderIntent } from "../components/provider-workspace/ProviderWorkspaceShell"; import type { AccountLoginRow, AccountLoginStatus } from "../components/provider-catalog/ProviderCatalog"; +import type { CatalogLoginHint } from "../components/provider-catalog/login-hint-visibility"; import type { ProvidersConfig } from "./providers-shared"; import { oauthLabel } from "./providers-shared"; import type { CodexAccountMutationCompletion } from "../codex-account-mutation"; @@ -50,7 +51,7 @@ export function ProvidersPageModals({ busy: string | null; addModalAccountRows: AccountLoginRow[]; accountLoginStatus: Record; - accountLoginHint?: { provider: string; url?: string; instructions?: string; deviceCode?: string } | null; + accountLoginHint?: CatalogLoginHint | null; removeConfirmName: string | null; removeDefaultProvider: string | null; codexLoginOpen: boolean; diff --git a/gui/src/pages/startup-sections.tsx b/gui/src/pages/startup-sections.tsx index b808680f8bb..cfffeaf7c8f 100644 --- a/gui/src/pages/startup-sections.tsx +++ b/gui/src/pages/startup-sections.tsx @@ -88,7 +88,7 @@ export function StartupDetailsSection({ // Repair only rewrites stale assets — conflict/disabled need uninstall/reinstall, not repair. const serviceNeedsRepair = data.serviceSupported && data.serviceInstalled && data.serviceStale && !data.serviceConflict; const shimNeedsRepair = data.shimInstalled && !data.shimHealthy; - const actionsDisabled = installBusy !== null || failed || loading; + const actionsDisabled = installBusy !== null || failed || loading || data.desktop?.owned === true; return (
@@ -96,6 +96,14 @@ export function StartupDetailsSection({

{t("startup.details")}

{data.platform}
+ {data.desktop && ( +
+
{t("startup.protection.desktop")}{t(!failed && data.desktop.viable ? "startup.desktopHint" : "startup.desktopRecovery")}
+
+ +
+
+ )}
{t("startup.service")}{t("startup.serviceHint")}
@@ -248,9 +256,10 @@ export function StartupRecoverySection({ commands are the fallback. Open by default only while protection is missing. */}
- {t("startup.recoveryHint")} + {t(data.desktop?.owned ? "startup.protection.desktop" : "startup.recoveryHint")} + {data.desktop?.owned &&

{t("startup.desktopRecovery")}

}
- {data.serviceSupported && ( + {data.serviceSupported && !data.desktop?.owned && (
{t("startup.command.service")} @@ -261,7 +270,7 @@ export function StartupRecoverySection({
)} -
+ {!data.desktop?.owned &&
{t("startup.command.shim")} {data.commands.installShim} @@ -269,7 +278,7 @@ export function StartupRecoverySection({ -
+
}
{t("startup.command.native")} @@ -280,9 +289,9 @@ export function StartupRecoverySection({
- {data.status === "at-risk" && ( + {data.status === "at-risk" && data.recommendedCommand && !data.desktop?.owned && (
- {t("startup.recommended", { cmd: data.recommendedCommand ?? data.commands.installService })} + {t("startup.recommended", { cmd: data.recommendedCommand })}
)}
diff --git a/gui/src/pages/startup-shared.ts b/gui/src/pages/startup-shared.ts index b62d7b058ed..3e120092da2 100644 --- a/gui/src/pages/startup-shared.ts +++ b/gui/src/pages/startup-shared.ts @@ -1,10 +1,11 @@ import type { TKey } from "../i18n/shared"; export type StartupStatus = "native" | "protected" | "at-risk"; -export type StartupProtection = "service" | "shim" | "none"; +export type StartupProtection = "service" | "desktop" | "shim" | "none"; export type StartupInstallAction = "install-service" | "install-shim"; export interface StartupHealthData { + desktop?: { owned: boolean; loginEnabled: boolean; running: boolean; viable: boolean }; status: StartupStatus; routingKind: "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; routingInjected: boolean; @@ -64,6 +65,7 @@ export const SUMMARY_KEYS: Record = { }; export const PROTECTION_KEYS: Record = { + desktop: "startup.protection.desktop", service: "startup.protection.service", shim: "startup.protection.shim", none: "startup.protection.none", diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts index 3df82bfed93..ac66bd33b85 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -3,6 +3,8 @@ import type { TFn } from "../i18n/shared"; import { readJsonIfOk } from "../fetch-json"; import { openBrowserRequestField } from "../oauth-open-browser-pref"; import { afterOAuthCancellation, cancelOAuthLogin } from "../oauth-cancellation-barrier"; +import { parseBrowserLaunch, type BrowserLaunch } from "../oauth-browser-launch"; +import { loginPollAttempts } from "../oauth-login-budget"; import type { OAuthAccount, OAuthStatus } from "./providers-shared"; import { oauthLabel } from "./providers-shared"; @@ -33,7 +35,7 @@ export function useProvidersOAuth({ setAccountSets: React.Dispatch>>; setBusy: React.Dispatch>; setStatus: React.Dispatch>; - setLoginInfo: React.Dispatch>; + setLoginInfo: React.Dispatch>; setOauthStatus: React.Dispatch>>; notify: (msg: string, ok: boolean) => void; fetchConfig: () => Promise; @@ -145,14 +147,18 @@ export function useProvidersOAuth({ notify(data.error || t("prov.loginFailStart", { provider: oauthLabel(provider) }), false); return; } - const data = await res.json() as { url?: string; instructions?: string; deviceCode?: string }; + const data = await res.json() as { url?: string; instructions?: string; deviceCode?: string; browserLaunch?: unknown }; if (!aliveRef.current || oauthLoginGenerationRef.current!.get(provider) !== generation) return; + const browserLaunch = parseBrowserLaunch(data.browserLaunch); if (data.url || data.instructions || data.deviceCode) { - setLoginInfo({ provider, url: data.url, instructions: data.instructions, deviceCode: data.deviceCode }); + setLoginInfo({ provider, url: data.url, instructions: data.instructions, deviceCode: data.deviceCode, browserLaunch }); } const baselineCount = accountSets[provider]?.accounts.length ?? 0; let finished = false; - for (let i = 0; i < 150 && aliveRef.current && oauthLoginGenerationRef.current!.get(provider) === generation; i++) { + // A device code can arrive with the POST or with a later status hint; either one stretches + // the budget to the grant's lifetime (see oauth-login-budget.ts). + let deviceFlow = Boolean(data.deviceCode); + for (let i = 0; i < loginPollAttempts(deviceFlow, 2000, 150) && aliveRef.current && oauthLoginGenerationRef.current!.get(provider) === generation; i++) { await new Promise(r => setTimeout(r, 2000)); if (oauthLoginGenerationRef.current!.get(provider) !== generation || !aliveRef.current) return; const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${provider}`).catch(() => null); @@ -223,7 +229,11 @@ export function useProvidersOAuth({ } // A later provider step replaces the initial POST hint (including an // absent device code); generation checks above keep old polls out. - if (s.hint) setLoginInfo({ provider, url: s.hint.url, instructions: s.hint.instructions, deviceCode: s.hint.deviceCode }); + // The launch outcome belongs to the POST, so a status hint keeps it. + if (s.hint) { + if (s.hint.deviceCode) deviceFlow = true; + setLoginInfo({ provider, url: s.hint.url, instructions: s.hint.instructions, deviceCode: s.hint.deviceCode, browserLaunch }); + } } if (!finished && oauthLoginGenerationRef.current!.get(provider) === generation && aliveRef.current) { await cancelServerLogin(provider); diff --git a/gui/src/startup-health-ui.ts b/gui/src/startup-health-ui.ts index 5fcf5fa6172..fcc28849fb7 100644 --- a/gui/src/startup-health-ui.ts +++ b/gui/src/startup-health-ui.ts @@ -1,12 +1,14 @@ import type { TKey } from "./i18n/shared"; export interface StartupRiskDetail { + desktop?: { owned: boolean }; routingKind: "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown"; shimCoverage: "full" | "cli-only" | "none"; } export function startupRiskDetailKey(health: StartupRiskDetail): TKey { if (health.routingKind === "custom-local") return "startup.riskDetailCustomLocal"; + if (health.desktop?.owned) return "startup.desktopRecovery"; if (health.shimCoverage === "cli-only") return "startup.riskDetailWindowsShim"; return "startup.riskDetail"; } diff --git a/gui/src/styles/codex-credits.css b/gui/src/styles/codex-credits.css new file mode 100644 index 00000000000..60531d126d8 --- /dev/null +++ b/gui/src/styles/codex-credits.css @@ -0,0 +1,29 @@ +/* Credits lives inside the existing compact slot, so no second slot padding. */ +.quota-row--codex-credits .quota-val { + white-space: nowrap; +} +/* Each .quota-row is its own grid, so max-content columns size per row and the Credits bar + would start where its own (empty) reset columns end, not where Week's bar starts. When the + slot carries a Credits row, hoist the row template onto the slot and let every row adopt it + through subgrid, so all bars share one start and one end. Scoped with :has() so slots + without Credits keep their existing per-row layout. */ +.quota-compact:has(> .quota-row--codex-credits) { + grid-template-columns: minmax(34px, max-content) max-content minmax(34px, max-content) minmax(38px, max-content) minmax(58px, 1fr) minmax(30px, max-content); + column-gap: 8px; +} +.quota-compact:has(> .quota-row--codex-credits) > * { + grid-column: 1 / -1; +} +.quota-compact:has(> .quota-row--codex-credits) > .quota-row:not(.quota-row--credits) { + grid-template-columns: subgrid; +} +.codex-auth-credits-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-2); + white-space: nowrap; +} +.codex-auth-credits-toggle__label { + font-size: var(--text-label); + color: var(--muted); +} diff --git a/gui/src/styles/login-url-block.css b/gui/src/styles/login-url-block.css index b5b2915416f..67b32e9279e 100644 --- a/gui/src/styles/login-url-block.css +++ b/gui/src/styles/login-url-block.css @@ -10,6 +10,8 @@ /* LoginHint — the composed login-in-progress surface (device code + URL + provider prose + paste fallback), shared by the same three surfaces. */ .login-hint { display: flex; flex-direction: column; gap: 8px; } +/* The hint's own gap spaces it; `.notice-warn`'s standalone bottom margin would double it. */ +.login-hint-launch-failed { margin-bottom: 0; } .login-hint-device { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); } .login-hint-device-code { font-size: 20px; font-weight: 800; letter-spacing: .14em; color: var(--text); user-select: all; } .login-hint-paste { display: flex; flex-direction: column; gap: 6px; } diff --git a/gui/tests/codex-credits-row.test.tsx b/gui/tests/codex-credits-row.test.tsx new file mode 100644 index 00000000000..a257e73c531 --- /dev/null +++ b/gui/tests/codex-credits-row.test.tsx @@ -0,0 +1,97 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import CodexCreditsRow from "../src/components/CodexCreditsRow"; +import QuotaBars from "../src/components/QuotaBars"; +import { CodexAccountPoolMainCard } from "../src/components/codex-account-pool-main-card"; +import { CodexAccountPoolCards } from "../src/components/codex-account-pool-cards"; +import type { CodexCredits, CodexAccountEntry } from "../src/hooks/useCodexAccountPool"; +import { en } from "../src/i18n/en"; +import { I18nContext, interpolate, type TFn } from "../src/i18n/shared"; +const t: TFn = (key, vars) => interpolate(en[key], vars); +function render(node: ReactNode) { + return renderToStaticMarkup( {} }}>{node}); +} +function row(credits?: CodexCredits, locale: "en" | "de" = "en") { + return render(); +} +test.each([ + [{ balance: "62500" }, "62,500", "1"], + [{ balance: "62498.725" }, "62,498.73", "1"], + [{ balance: "0" }, "0", "0"], + [{ balance: "0.001" }, "0", "1"], + [{ unlimited: true }, "Unlimited", "1"], + [{ unlimited: true, balance: "10" }, "Unlimited", "1"], + [{ overageLimitReached: true }, "Overage limit reached", "0"], + [{ overageLimitReached: true, unlimited: true, balance: "62500" }, "62,500 · Overage limit reached", "0"], +] satisfies [CodexCredits, string, string][])("credits status %j", (credits, value, scale) => { + const html = row(credits); + expect(html).toContain(`class="quota-val">${value}`); + expect(html).toContain(`--bar-scale:${scale}`); + expect(html).toContain('class="quota-reset-label">remaining'); + expect(html).not.toContain("%"); + expect(html).not.toContain("progressbar"); + expect(html).not.toContain("codex-account-quota-slot"); +}); +test.each([undefined, {}, { hasCredits: true }, { unlimited: false }, { balance: "invalid" }])("unusable credits render nothing: %j", credits => { + expect(row(credits)).toBe(""); +}); +test("balance uses the current locale and two fractional digits", () => { + expect(row({ balance: "62498.725" }, "de")).toContain("62.498,73"); +}); +test("message ranges live in the tooltip, including one-sided observations", () => { + expect(row({ balance: "5", approxLocalMessages: [1000, 2000], approxCloudMessages: [50, 100] })) + .toContain('title="Approx. messages: local 1,000–2,000 · cloud 50–100"'); + expect(row({ unlimited: true, approxLocalMessages: [1, 2] })).toContain("local 1–2 · cloud —"); + expect(row({ balance: "5" })).not.toContain("title="); +}); +const creditsNode = ; +const quota = { fiveHourPercent: 10, weeklyPercent: 20, monthlyPercent: 30, customWindows: [{ label: "Custom", percent: 40 }], updatedAt: 1 }; +function quotaHtml(overrides: Partial[0]> = {}) { + return render(); +} +test("compact order is Week → Credits → Monthly/custom in a single slot", () => { + const html = quotaHtml(); + const labels = [...html.matchAll(/class="quota-label"[^>]*>(.*?)<\/span>/g)].map(match => match[1]); + expect(labels).toEqual([en["codexAuth.fiveHour"], en["codexAuth.weekly"], "Credits", en["codexAuth.monthly"], "Custom"]); + expect(html.match(/codex-account-quota-slot/g)?.length).toBe(1); +}); +test("no Week appends credits after the last quota row", () => { + const html = quotaHtml({ quota: { monthlyPercent: 30, updatedAt: 1 } }); + expect(html.indexOf(en["codexAuth.monthly"])).toBeLessThan(html.indexOf("Credits")); +}); +test("no quota renders credits alone when ready", () => { + const html = quotaHtml({ quota: null }); + expect(html).toContain("62,500"); + expect(html).not.toContain("skeleton"); +}); +test("stacked layout ignores the slot, including empty quota", () => { + expect(quotaHtml({ layout: "stacked" })).not.toContain("Credits"); + expect(quotaHtml({ layout: "stacked", quota: null })).toBe(""); +}); +const account: CodexAccountEntry = { + id: "fixture", email: "fixture@example.test", isMain: true, paused: false, priority: 0, + autoSwitchThresholdOverride: null, hasCredential: true, quota: null, credits: { balance: "62500" }, + quotaAutoRefresh: { fiveHourAvailable: false, weeklyAvailable: false, fiveHourEnabled: false, weeklyEnabled: false }, +}; +const common = { + accountModeState: null, threshold: 80, switchActionLabel: "switch", onSwitch: () => {}, onTogglePause: () => {}, + pauseUpdatingId: null, pauseBusy: false, onPriorityChange: () => {}, priorityUpdatingId: null, + onAutoSwitchThresholdChange: async () => true, autoSwitchDisabled: false, switchingId: null, onOpenReset: () => {}, +}; +test.each(["main", "pool"])("%s card gates the DTO on visibility and handles credits-only accounts", kind => { + const card = (visible?: boolean, loading = false, credits = account.credits) => kind === "main" + ? + : {}} onEditAlias={() => {}} onRemove={() => {}} creditsVisible={visible} loading={loading} />; + expect(render(card(true))).toContain("62,500"); + expect(render(card(true))).not.toContain("quota-compact--pending"); + expect(render(card(false))).not.toContain("quota-row--codex-credits"); + expect(render(card())).not.toContain("quota-row--codex-credits"); + expect(render(card(true, true))).toContain("quota-compact--pending"); + const absent = { ...account, credits: undefined }; + const html = kind === "main" + ? render() + : render( {}} onEditAlias={() => {}} onRemove={() => {}} creditsVisible />); + expect(html).not.toContain("quota-row--codex-credits"); + expect(html).toContain("quota-compact--pending"); +}); diff --git a/gui/tests/codex-credits-visibility.test.tsx b/gui/tests/codex-credits-visibility.test.tsx new file mode 100644 index 00000000000..110987573e4 --- /dev/null +++ b/gui/tests/codex-credits-visibility.test.tsx @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { Window } from "happy-dom"; +import { useCodexCreditsVisibility } from "../src/hooks/useCodexCreditsVisibility"; +import { CodexAccountPoolPageHead } from "../src/components/codex-account-pool-main-card"; +import CodexAccountPool from "../src/components/CodexAccountPool"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { en } from "../src/i18n/en"; +import { I18nContext, interpolate, type TFn } from "../src/i18n/shared"; + +const t: TFn = (key, vars) => interpolate(en[key], vars); +const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record; +let win: Window; +let host: HTMLElement; +let root: Root; +let unmounted: boolean; +let current: ReturnType; +let feedback: Array<[string, string | undefined]>; +let reloads: Array; +let reload: (refresh?: boolean) => Promise; +let requests: Array<{ url: string; init?: RequestInit }>; +let respond: (url: string, init?: RequestInit) => Promise; +function response(payload: unknown, status = 200) { return Response.json(payload, { status }); } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return { promise, resolve }; +} +function Harness({ apiBase, revision = 0 }: { apiBase: string; revision?: number }) { + current = useCodexCreditsVisibility(apiBase, reload, (message, tone) => { feedback.push([message, tone]); }, t, { revision, onRead: () => {} }); + return {}} onPauseExhausted={() => {}} creditsVisible={current.visible} creditsBusy={current.busy} + onToggleCredits={() => { void current.toggle(); }} />; +} +async function paint(node: ReactNode) { + await act(async () => { + root.render( {} }}>{node}); + }); +} +async function flush() { await act(async () => { await new Promise(r => setTimeout(r, 0)); }); } +function button() { return host.querySelector(".codex-auth-credits-toggle button"); } +function writes() { return requests.filter(r => r.init?.method === "PUT"); } + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])); + win = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, localStorage: { configurable: true, value: win.localStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + requests = []; feedback = []; reloads = []; unmounted = false; + reload = async refresh => { reloads.push(refresh); return true; }; + respond = async (_url, init) => response({ showCodexCredits: init?.method === "PUT" ? true : false }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: string, init?: RequestInit) => { + requests.push({ url: String(input), init }); return respond(String(input), init); + } }); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); + root = createRoot(host); +}); +afterEach(async () => { + if (!unmounted) await act(async () => { root.unmount(); }); + clearClientResourceStoresForTests(); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + await win.happyDOM.close(); +}); + +test("settings stays undefined and switch absent until GET resolves; cleanup aborts the read", async () => { + const read = deferred(); respond = () => read.promise; + await paint(); + expect(current.visible).toBeUndefined(); expect(button()).toBeNull(); + const signal = requests[0]!.init!.signal!; + await act(async () => { root.unmount(); }); unmounted = true; + expect(signal.aborted).toBe(true); + read.resolve(response({ showCodexCredits: true })); await flush(); + expect(current.visible).toBeUndefined(); +}); + +test.each([response({}, 503), response({}), response({ showCodexCredits: "yes" })])("failed or malformed settings read never guesses a position", async res => { + respond = async () => res; + await paint(); + expect(current.visible).toBeUndefined(); expect(button()).toBeNull(); +}); + +test("labelled toggle is optimistic, serializes writes, reconciles server state, and reloads", async () => { + const write = deferred(); + respond = async (_url, init) => init?.method === "PUT" ? write.promise : response({ showCodexCredits: false }); + await paint(); + expect(button()?.getAttribute("aria-label")).toBe(en["codexAuth.creditsToggle"]); + expect(button()?.getAttribute("title")).toBe(en["codexAuth.creditsToggleHint"]); + let pending!: Promise; + await act(async () => { pending = current.toggle(); void current.toggle(); }); + expect(button()?.getAttribute("aria-pressed")).toBe("true"); expect(button()?.disabled).toBe(true); + expect(writes()).toHaveLength(1); + expect(JSON.parse(writes()[0]!.init!.body as string)).toEqual({ showCodexCredits: true }); + await act(async () => { write.resolve(response({ showCodexCredits: false })); await pending; }); + expect(current.visible).toBe(false); expect(current.busy).toBe(false); + expect(reloads).toEqual([true]); expect(feedback).toEqual([[en["codexAuth.creditsHidden"], "ok"]]); +}); + +test.each([response({}, 500), response({}), response({ showCodexCredits: "true" })])("rejected or malformed PUT reverts with error feedback", async res => { + respond = async (_url, init) => init?.method === "PUT" ? res : response({ showCodexCredits: false }); + await paint(); + await act(async () => { await current.toggle(); }); + expect(current.visible).toBe(false); expect(current.busy).toBe(false); + expect(reloads).toHaveLength(0); expect(feedback).toEqual([[en["codexAuth.creditsToggleFailed"], "err"]]); +}); + +test("network failure reverts and allows retry", async () => { + respond = async (_url, init) => { if (init?.method === "PUT") throw new Error("offline"); return response({ showCodexCredits: false }); }; + await paint(); + await act(async () => { await current.toggle(); }); + expect(current.visible).toBe(false); expect(current.busy).toBe(false); + respond = async () => response({ showCodexCredits: true }); + await act(async () => { await current.toggle(); }); + expect(current.visible).toBe(true); expect(reloads).toEqual([true]); +}); + +test.each([false, "throw"])("successful disable survives account reload failure: %s", async outcome => { + respond = async (_url, init) => response({ showCodexCredits: init?.method !== "PUT" }); + reload = async () => { if (outcome === "throw") throw new Error("reload"); return false; }; + await paint(); + await act(async () => { await current.toggle(); }); + expect(current.visible).toBe(false); expect(current.busy).toBe(false); + expect(feedback).toContainEqual([en["codexAuth.creditsHidden"], "ok"]); + expect(feedback).toContainEqual([en["codexAuth.quotaRefreshFailed"], "err"]); +}); + +test("proxy change aborts pending PUT and ignores its stale response", async () => { + const write = deferred(); + respond = async (_url, init) => init?.method === "PUT" ? write.promise : response({ showCodexCredits: false }); + await paint(); + let pending!: Promise; + await act(async () => { pending = current.toggle(); }); + const signal = writes()[0]!.init!.signal!; + await paint(); + expect(signal.aborted).toBe(true); + await act(async () => { write.resolve(response({ showCodexCredits: true })); await pending; }); + expect(current.visible).toBe(false); expect(reloads).toHaveLength(0); expect(feedback).toHaveLength(0); +}); + +test("returning to a prior proxy still waits for a fresh GET; old reads cannot revive state", async () => { + const read = deferred(); + const stale = deferred(); + await paint(); + respond = url => url.startsWith("/a/") ? read.promise : stale.promise; + await paint(); + await paint(); + expect(current.visible).toBeUndefined(); expect(button()).toBeNull(); + stale.resolve(response({ showCodexCredits: false })); + read.resolve(response({ showCodexCredits: true })); await flush(); + expect(current.visible).toBe(true); +}); + +test("CodexAccountPool hides main and pool rows after disable even when account refresh fails", async () => { + let accountsOk = true; + let setting = true; + const fixture = { email: "fixture@example.test", paused: false, priority: 0, hasCredential: true, quota: null, credits: { balance: "62500" } }; + respond = async (url, init) => { + if (url.endsWith("/api/settings")) { + if (init?.method === "PUT") { setting = JSON.parse(init.body as string).showCodexCredits; accountsOk = false; } + return response({ showCodexCredits: setting, codexQuotaAutoRefresh: {} }); + } + if (url.includes("/api/codex-auth/accounts")) return accountsOk + ? response({ accounts: [{ ...fixture, id: "__main__", isMain: true }, { ...fixture, id: "fixture", isMain: false }] }) + : response({}, 503); + if (url.includes("/api/codex-auth/active")) return response({ activeCodexAccountId: null, autoSwitchThreshold: 80, accountPoolStrategy: "quota-first" }); + return response({ accounts: [], profiles: [] }); + }; + await paint(); await flush(); + expect(host.querySelectorAll(".quota-row--codex-credits")).toHaveLength(2); + await act(async () => { button()!.click(); }); await flush(); + expect(button()?.getAttribute("aria-pressed")).toBe("false"); + expect(host.querySelectorAll(".quota-row--codex-credits")).toHaveLength(0); + expect(host.textContent).toContain(en["codexAuth.quotaRefreshFailed"]); +}); + + +test("a quota-settings retry cannot cancel a credits write or let its GET overwrite the mutation", async () => { + const write = deferred(); + const read = deferred(); + respond = async (_url, init) => init?.method === "PUT" ? write.promise : response({ showCodexCredits: false }); + await paint(); + let pending!: Promise; + await act(async () => { pending = current.toggle(); }); + const signal = writes()[0]!.init!.signal!; + respond = async (_url, init) => init?.method === "PUT" ? write.promise : read.promise; + await paint(); + expect(signal.aborted).toBe(false); + read.resolve(response({ showCodexCredits: false })); await flush(); + expect(current.visible).toBe(true); expect(current.busy).toBe(true); + await act(async () => { write.resolve(response({ showCodexCredits: true })); await pending; }); + expect(current.visible).toBe(true); expect(current.busy).toBe(false); +}); diff --git a/gui/tests/login-hint-browser-launch.test.tsx b/gui/tests/login-hint-browser-launch.test.tsx new file mode 100644 index 00000000000..e2a6055d433 --- /dev/null +++ b/gui/tests/login-hint-browser-launch.test.tsx @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { LoginHint, type LoginHintData } from "../src/components/login-url-block"; +import { addProviderModalReducer, createInitialAddProviderState } from "../src/components/add-provider-modal-reducer"; +import { addCodexAccountUiReducer, initialAddCodexAccountUiState } from "../src/components/add-codex-account-reducer"; +import { DEVICE_LOGIN_POLL_BUDGET_MS, loginPollAttempts } from "../src/oauth-login-budget"; +import { parseBrowserLaunch } from "../src/oauth-browser-launch"; + +/** + * How a login in progress tells the user what happened to the browser, and how a device login + * gets them to the verification page. The standard shape (VS Code, GitHub CLI, Codex CLI): say + * so when the browser did not open, keep the URL copyable either way, and let one click copy the + * device code and open the page that asks for it. + */ + +const URL_A = "https://auth.example.test/device"; +const CODE = "WDJB-MJHT"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let clipboardWrites: string[] = []; +let opened: Array<{ url: string; target: string; features: string }> = []; + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + clipboardWrites = []; + Object.defineProperty(win.navigator, "clipboard", { + configurable: true, + value: { writeText: async (text: string) => { clipboardWrites.push(text); } }, + }); + opened = []; + Object.defineProperty(win, "open", { + configurable: true, + value: (url: string, target: string, features: string) => { opened.push({ url, target, features }); return null; }, + }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + await win.happyDOM?.close?.(); +}); + +async function render(hint: LoginHintData) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root ??= createRoot(host); + root.render( + + + , + ); + }); +} + +function openLink(): HTMLAnchorElement | null { + return host.querySelector(".login-url-block-open") as HTMLAnchorElement | null; +} + +test("says so when the proxy could not open a browser, and keeps the way in", async () => { + await render({ url: URL_A, browserLaunch: "failed" }); + + expect(host.querySelector(".login-hint-launch-failed")?.textContent).toContain("didn't open automatically"); + expect(openLink()?.getAttribute("href")).toBe(URL_A); + expect(host.textContent).toContain(URL_A); +}); + +test("a started or unknown launch shows no failure notice and keeps the recovery wording", async () => { + for (const browserLaunch of ["started", undefined] as const) { + await render({ url: URL_A, browserLaunch }); + expect(host.querySelector(".login-hint-launch-failed")).toBeNull(); + expect(openLink()?.textContent).toContain("Didn't open?"); + } +}); + +test("a declined launch labels the link as the way in, not as a recovery", async () => { + await render({ url: URL_A, browserLaunch: "skipped" }); + + expect(host.querySelector(".login-hint-launch-failed")).toBeNull(); + expect(openLink()?.textContent).toContain("Open sign-in page"); +}); + +test("one click copies the device code and opens the verification page", async () => { + await render({ url: URL_A, deviceCode: CODE, browserLaunch: "skipped" }); + + expect(openLink()?.textContent).toContain("Open sign-in page"); + const button = host.querySelector(".login-hint-copy-open") as HTMLButtonElement | null; + expect(button?.textContent).toBe("Copy code & open"); + await act(async () => { + button!.dispatchEvent(new win.MouseEvent("click", { bubbles: true }) as unknown as Event); + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(clipboardWrites).toEqual([CODE]); + expect(opened).toEqual([{ url: URL_A, target: "_blank", features: "noopener,noreferrer" }]); + // The code stays on screen: the clipboard is a shortcut, not the only copy. + expect(host.textContent).toContain(CODE); +}); + +test("a device URL a browser cannot open never gets the open shortcut", async () => { + await render({ url: "javascript:alert(1)", deviceCode: CODE }); + + expect(host.querySelector(".login-hint-copy-open")).toBeNull(); + expect(openLink()).toBeNull(); + expect(host.textContent).toContain(CODE); +}); + +test("only the three known outcomes are read off a response", () => { + expect(parseBrowserLaunch("failed")).toBe("failed"); + expect(parseBrowserLaunch("started")).toBe("started"); + expect(parseBrowserLaunch("skipped")).toBe("skipped"); + for (const value of [undefined, null, "", "FAILED", 1, {}]) expect(parseBrowserLaunch(value)).toBeUndefined(); +}); + +test("the add-provider modal keeps the launch outcome across a status hint for the same login", () => { + const preset = { id: "kimi", label: "Kimi", adapter: "openai-chat", baseUrl: "", auth: "oauth", oauthProvider: "kimi" }; + let state = { ...createInitialAddProviderState(false, "Custom"), preset } as ReturnType; + state = addProviderModalReducer(state, { type: "set-oauth-url", url: URL_A, providerId: "kimi", browserLaunch: "failed" }); + expect(state.oauthBrowserLaunch).toBe("failed"); + // The status poll re-sends the hint without the outcome. + state = addProviderModalReducer(state, { type: "set-oauth-url", url: URL_A, providerId: "kimi" }); + expect(state.oauthBrowserLaunch).toBe("failed"); + // Clearing the URL ends the login, and its outcome with it. + state = addProviderModalReducer(state, { type: "set-oauth-url", url: "", providerId: "kimi" }); + expect(state.oauthBrowserLaunch).toBeUndefined(); + // Leaving the preset clears it with the rest of the login state. + state = addProviderModalReducer(state, { type: "set-oauth-url", url: URL_A, providerId: "kimi", browserLaunch: "failed" }); + state = addProviderModalReducer(state, { type: "back" }); + expect(state.oauthBrowserLaunch).toBeUndefined(); +}); + +test("a device login polls for the grant's lifetime, a browser login keeps its own budget", () => { + expect(loginPollAttempts(false, 2000, 150)).toBe(150); + expect(loginPollAttempts(false, 2000, 100)).toBe(100); + expect(loginPollAttempts(true, 2000, 150) * 2000).toBeGreaterThanOrEqual(DEVICE_LOGIN_POLL_BUDGET_MS); + // Longer than the longest provider grant (Meta Muse, 30 minutes). + expect(DEVICE_LOGIN_POLL_BUDGET_MS).toBeGreaterThan(30 * 60_000); +}); + +test("restarting a Codex login drops the previous login's launch warning", () => { + let state = initialAddCodexAccountUiState(); + state = addCodexAccountUiReducer(state, { type: "set-login-hint", authUrl: URL_A, browserLaunch: "failed" }); + expect(state.browserLaunch).toBe("failed"); + // Switching to the device flow restarts the login before its own response arrives. + state = addCodexAccountUiReducer(state, { type: "reset-oauth-start" }); + expect(state.browserLaunch).toBeUndefined(); +}); diff --git a/gui/tests/startup-minimal.test.tsx b/gui/tests/startup-minimal.test.tsx index d96d2dd6450..946ad417086 100644 --- a/gui/tests/startup-minimal.test.tsx +++ b/gui/tests/startup-minimal.test.tsx @@ -36,8 +36,14 @@ function response(body: unknown): Response { } let status: "protected" | "at-risk" = "protected"; +let desktop = false; +let desktopViable = true; +let brokenStarters = false; beforeEach(() => { + desktop = false; + desktopViable = true; + brokenStarters = false; clearClientResourceStoresForTests(); previousGlobals = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previousGlobals; testWindow = new Window({ url: "http://localhost/#startup" }); @@ -54,7 +60,11 @@ beforeEach(() => { configurable: true, value: async (url: string) => { const path = new URL(String(url), "http://localhost/").pathname; - if (path === "/api/startup-health") return response(health(status)); + if (path === "/api/startup-health") return response(desktop ? { + ...health(desktopViable ? "protected" : "at-risk"), protection: desktopViable ? "desktop" : "none", serviceInstalled: false, serviceViable: false, + shimInstalled: brokenStarters, shimHealthy: false, serviceInstalled: brokenStarters, serviceStale: brokenStarters, + desktop: { owned: true, loginEnabled: desktopViable, running: desktopViable, viable: desktopViable }, + } : health(status)); if (path === "/api/settings") return response({ codexAutoStart: true, codexRuntime: { version: "x" } }); return response({}); }, @@ -99,3 +109,53 @@ test("at-risk: recovery details open by default", async () => { const details = container.querySelector("details.startup-recovery-details")!; expect(details.open).toBe(true); }); + +test("desktop protection is named separately and cannot install a competing service", async () => { + desktop = true; + await mount(); + expect(container.querySelector(".startup-state-line")?.textContent).toContain("Desktop app"); + expect(container.querySelector(".startup-details")?.textContent).toContain("desktop app supervises"); + const install = container.querySelector('button[aria-label="Background service - Install"]'); + expect(install).not.toBeNull(); + expect(install!.disabled).toBe(true); +}); + +test("non-viable desktop ownership blocks competing starters and gives desktop recovery", async () => { + desktop = true; + desktopViable = false; + await mount(); + const install = container.querySelector('button[aria-label="Background service - Install"]'); + expect(install).not.toBeNull(); + expect(install!.disabled).toBe(true); + const shim = container.querySelector('button[aria-label="Codex launcher shim - Install"]'); + expect(shim!.disabled).toBe(true); + const recovery = container.querySelector(".startup-recovery-details")!; + expect(recovery.textContent).toContain("Start at Login"); + expect(recovery.textContent).not.toContain("ocx service install"); + expect(recovery.textContent).not.toContain("ocx shim install"); + expect(recovery.textContent).not.toContain("background service is recommended"); + expect(recovery.textContent).toContain("ocx restore"); + expect(container.querySelector(".startup-hero")!.textContent).toContain("Start at Login"); +}); + +test("desktop ownership also blocks repair of stale service and shim assets", async () => { + desktop = true; + desktopViable = false; + brokenStarters = true; + await mount(); + for (const label of ["Background service - Repair", "Codex launcher shim - Repair"]) { + const button = container.querySelector(`button[aria-label="${label}"]`); + expect(button).not.toBeNull(); + expect(button!.disabled).toBe(true); + } +}); + +test("a CLI-owned unprotected install still offers service and shim actions", async () => { + status = "at-risk"; + await mount(); + for (const label of ["Background service - Install", "Codex launcher shim - Install"]) { + const button = container.querySelector(`button[aria-label="${label}"]`); + expect(button).not.toBeNull(); + expect(button!.disabled).toBe(false); + } +}); diff --git a/package.json b/package.json index 9bf34d94c31..1808a150251 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.73.0", + "version": "2.74.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", diff --git a/scripts/model-metadata.source.json b/scripts/model-metadata.source.json index 53ad2fc6556..6605104afdf 100644 --- a/scripts/model-metadata.source.json +++ b/scripts/model-metadata.source.json @@ -41882,6 +41882,20 @@ "minLevel": "minimal", "maxLevel": "xhigh" } + }, + "MiniMax-M3.1-Flash-Preview": { + "id": "MiniMax-M3.1-Flash-Preview", + "name": "MiniMax-M3.1-Flash-Preview", + "api": "anthropic-messages", + "provider": "minimax", + "baseUrl": "https://api.minimax.io/anthropic", + "reasoning": true, + "input": [ + "text", + "image", + "video" + ], + "contextWindow": 1000000 } }, "minimax-cn": { @@ -42102,6 +42116,20 @@ "minLevel": "minimal", "maxLevel": "xhigh" } + }, + "MiniMax-M3.1-Flash-Preview": { + "id": "MiniMax-M3.1-Flash-Preview", + "name": "MiniMax-M3.1-Flash-Preview", + "api": "anthropic-messages", + "provider": "minimax-cn", + "baseUrl": "https://api.minimaxi.com/anthropic", + "reasoning": true, + "input": [ + "text", + "image", + "video" + ], + "contextWindow": 1000000 } }, "minimax-code": { diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3fc00931e08..a8ffeb8454e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,6 +2,13 @@ "version": 1, "root": "tests", "explicit": { + "codex-credits.test.ts": "codex-integration", + "codex-credits-settings.test.ts": "codex-integration", + "codex-credits-probes.test.ts": "codex-integration", + "service-desktop-startup-health.test.ts": "service", + "service-desktop-startup.test.ts": "service", + "startup-health-packaged-probe.test.ts": "server", + "discovered-native-models.test.ts": "codex-integration", "cursor-local-installer.test.ts": "providers/cursor", "codex-quota-query-backoff.test.ts": "codex-integration", "pnpm-command-isolation.test.ts": "update", "provider-antigravity-quota-retry.test.ts": "providers", "project-config-warning-snapshot.test.ts": "codex-integration", "codex-quota-auto-refresh-generation.test.ts": "codex-integration", "codex-account-clear-paused.test.ts": "codex-integration", @@ -57,6 +64,8 @@ "command-code-tool-text-prose-split.test.ts": "providers", "cli-effort-slug.test.ts": "cli", "grok-47-build-fast-metadata.test.ts": "providers/xai", + "grok-47-fast-model.test.ts": "providers/xai", + "grok-47-fast-model-wire.test.ts": "providers/xai", "abort-idle-deadline.test.ts": "lib", "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", @@ -581,6 +590,7 @@ "codex-shim-destroyed-probe.test.ts": "codex-integration", "codex-shim-ensure-failure.test.ts": "codex-integration", "codex-shim-readiness.test.ts": "codex-integration", + "codex-shim-standalone.test.ts": "codex-integration", "codex-shim.test.ts": "codex-integration", "codex-signin-lockout.test.ts": "codex-integration", "codex-spark-visibility.test.ts": "codex-integration", @@ -963,6 +973,7 @@ "kiro-review-regressions.test.ts": "providers/kiro", "kiro-metering-events.test.ts": "providers/kiro", "kiro-metering-usage.test.ts": "providers/kiro", + "kiro-single-final.test.ts": "providers/kiro", "kiro-stream.test.ts": "providers/kiro", "kiro-transport-parity.test.ts": "providers/kiro", "kiro-usage-quota.test.ts": "providers/kiro", @@ -1055,6 +1066,7 @@ "main-device-reauth-ui.test.ts": "gui", "main-device-reauth.test.ts": "codex-integration", "main-quota-evidence-validation.test.ts": "codex-integration", + "main-account-hard-lock-retirement.test.ts": "codex-integration", "main-quota-provenance.test.ts": "codex-integration", "main-quota-window-observation.test.ts": "codex-integration", "management-anthropic-reset-grants.test.ts": "server", @@ -1104,6 +1116,7 @@ "model-pinned-effort.test.ts": "codex-integration", "model-presets.test.ts": "providers", "model-rename-migration.test.ts": "providers", + "model-roster-seed-repair.test.ts": "providers", "model-selection-guidance.test.ts": "cli", "model-settings-management-api.test.ts": "server", "model-visibility-management-api.test.ts": "codex-integration", "models-feedback-callback.test.ts": "gui", diff --git a/scripts/test.ts b/scripts/test.ts index 1b06865dc72..58efd2663a0 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -374,6 +374,10 @@ export const SERIAL_FULL_SUITE_FILES = [ // Its management API import stalled the long-lived macOS isolate pool before // any case ran; the complete file finishes in under a second in a fresh process. "routing/subagent-roster-retention.test.ts", + // Linux run 36610213506 stalled this file after its WebSocket admission case + // in a multi-file process; all 11 cases completed in the attribution process. + // Keep its real listener lifecycle in a fresh process on every platform. + "codex-integration/active-registry-admission.test.ts", "update/update-stop-first.test.ts", // Relays a 50 MiB WebSocket frame end to end against a 15s deadline, so its result is a // measurement of the whole process, not of the relay. On a healthy 3-CPU macOS runner the diff --git a/src/adapters/kiro/stream.ts b/src/adapters/kiro/stream.ts index f10cc6fce99..dbd39e83764 100644 --- a/src/adapters/kiro/stream.ts +++ b/src/adapters/kiro/stream.ts @@ -38,6 +38,8 @@ interface KiroAttemptParseResult { } interface KiroAttemptResult extends KiroAttemptParseResult { + drainDeferred(supersededByCompletion?: boolean): AsyncGenerator; + releaseCollectors(): void; releaseRetained(): void; } @@ -45,6 +47,7 @@ interface KiroAttemptRetention { trackReplacement(previousBytes: number, nextBytes: number): void; retainEvent(event: AdapterEvent, bytes: number): void; releaseEvent(event: AdapterEvent): void; + releaseCollectors(): void; releaseAll(): void; } @@ -66,6 +69,11 @@ function createKiroAttemptRetention(budget: TranslatorBudget): KiroAttemptRetent retainedBytes = Math.max(0, retainedBytes - bytes); budget.releaseRetained(bytes, { kind: "retained_collectors" }); }, + releaseCollectors() { + const pendingBytes = [...eventBytes.values()].reduce((sum, bytes) => sum + bytes, 0); + budget.releaseRetained(retainedBytes - pendingBytes, { kind: "retained_collectors" }); + retainedBytes = pendingBytes; + }, releaseAll() { if (retainedBytes > 0) budget.releaseRetained(retainedBytes, { kind: "retained_collectors" }); retainedBytes = 0; @@ -228,13 +236,20 @@ async function* parseKiroAttempt( nameMap: Map | undefined, conversationId: string | undefined, contextInputEstimate?: number, - /** True when an earlier attempt already flushed visible content to the client (#520). */ + /** True when an earlier attempt has output that must survive a failed retry. */ priorEmittedOutput = false, + priorAttempt?: KiroAttemptResult, ): AsyncGenerator { - // `required` mode holds staged commentary until a real tool call or terminal metadata identifies - // the attempt boundary. Anything the inner parser leaves behind is flushed before the terminal. + // Hold commentary through completion validation; tools and failures still release progress. const deferred: AdapterEvent[] = []; const retention = createKiroAttemptRetention(budget); + const drainDeferred = async function* (supersededByCompletion = false): AsyncGenerator { + for (const event of deferred.splice(0)) { + try { + if (!supersededByCompletion || event.type !== "text_delta") yield event; + } finally { retention.releaseEvent(event); } + } + }; // Shared box: the inner parser stages its calibration observation here on the completion path, // and this wrapper decides whether the attempt was terminal enough to commit it. A box rather // than a return field because the completion path has a dozen terminal returns and threading a @@ -254,6 +269,7 @@ async function* parseKiroAttempt( attemptCalibration, contextInputEstimate, priorEmittedOutput, + priorAttempt, ); let handedOff = false; try { @@ -267,11 +283,14 @@ async function* parseKiroAttempt( if (staged && !result.needsFallback) { recordKiroCalibration(staged.conversationId, staged.estimated, staged.charged); } - for (const event of deferred.splice(0)) { - try { yield event; } finally { retention.releaseEvent(event); } - } + if (priorAttempt) yield* priorAttempt.drainDeferred(); + if (!result.needsFallback) yield* drainDeferred(); handedOff = true; - return { ...result, releaseRetained: () => retention.releaseAll() }; + return { + ...result, drainDeferred, + releaseCollectors: () => retention.releaseCollectors(), + releaseRetained: () => retention.releaseAll(), + }; } finally { if (!handedOff) retention.releaseAll(); } @@ -291,6 +310,7 @@ async function* parseKiroAttemptEvents( attemptCalibration: { value?: { conversationId: string; estimated: number; charged: number } }, contextInputEstimate?: number, priorEmittedOutput = false, + priorAttempt?: KiroAttemptResult, ): AsyncGenerator { const emptyResult = (): KiroAttemptParseResult => ({ assistantText: "", sawReasoning: false }); // Every early return below is a failure path that stages nothing; only the completion path @@ -343,10 +363,8 @@ async function* parseKiroAttemptEvents( // (#2819 follow-up). Consume the collection instead — drop the redundant text, keep every // non-text event, and release retention either way. // - // This is deliberately the ONLY suppression site. The outer drain in `parseKiroAttempt` is also - // the leftover flush for early terminal returns (stream, protocol, and provider failures), so - // teaching it to discard text would hide the only commentary a failed turn ever produced. - // Splicing here leaves that drain empty on the completion path and untouched everywhere else. + // The preceding attempt uses the same rule when bounded validation succeeds. Failures retain + // the ordinary leftover flush so a failed turn's only progress is still delivered. const consumeSupersededByCompletion = async function* ( events: AdapterEvent[], ): AsyncGenerator { @@ -421,7 +439,7 @@ async function* parseKiroAttemptEvents( message, usage(), providerState(), - // First-attempt progress was already flushed before this bounded fallback (#520). + // Failed validation releases first-attempt progress before the terminal. !priorEmittedOutput, ); } @@ -469,7 +487,7 @@ async function* parseKiroAttemptEvents( // In `required` mode Kiro's stop reason only arrives on the terminal metadata event, so staged // commentary is held until either a real tool call proves the turn continues (flush as - // commentary) or the stream ends (relabel as the final answer when END_TURN says so). A heartbeat + // commentary) or bounded validation settles the held text. A heartbeat // stands in for each held event so the bridge's stall watchdog stays armed. const defer = (event: AdapterEvent): AdapterEvent[] => { if (sawRealTool) return [...deferred.splice(0), event]; @@ -708,6 +726,7 @@ async function* parseKiroAttemptEvents( if (ev.stop === true) { const flushed = flushOpen(); if (flushed.terminal) return { assistantText, sawReasoning, terminal: flushed.terminal }; + if (priorAttempt && flushed.events.length) yield* priorAttempt.drainDeferred(); for (const event of flushed.events) { yield* emitRetained(stage(event)); } @@ -744,6 +763,7 @@ async function* parseKiroAttemptEvents( } const flushed = flushOpen(); if (flushed.terminal) return { assistantText, sawReasoning, terminal: flushed.terminal }; + if (priorAttempt && flushed.events.length) yield* priorAttempt.drainDeferred(); for (const event of flushed.events) { yield* emitRetained(stage(event)); } @@ -802,14 +822,14 @@ async function* parseKiroAttemptEvents( assistantChars: assistantText.length, }); - if (mode === "required") { - // A valid completion answer makes this inference's staged prose redundant; anything else - // still flushes exactly as before (bounded fallback, explicit stops, real tool calls). - if (completionAnswer !== undefined) yield* consumeSupersededByCompletion(deferred); - else yield* emitRetained(deferred.splice(0)); + if (mode === "required" && completionAnswer !== undefined) { + yield* consumeSupersededByCompletion(deferred); } if (mode === "text_fallback") { + if (priorAttempt) { + yield* priorAttempt.drainDeferred(completionAnswer !== undefined || (sawText && !sawRealTool)); + } if (completionAnswer !== undefined) { yield* consumeSupersededByCompletion(fallbackEvents); yield { type: "text_delta", text: completionAnswer, phase: "final_answer" }; @@ -853,7 +873,7 @@ async function* parseKiroAttemptEvents( : "Kiro produced no final answer on its bounded completion retry", finalUsage, finalProviderState, - // First-attempt progress was already flushed before this bounded fallback (#520). + // Failed validation releases first-attempt progress before the terminal. !priorEmittedOutput, ), }; @@ -1047,6 +1067,7 @@ export async function* parseKiroStream( return; } if (!fallbackFactory) { + yield* firstResult.drainDeferred(); yield retryableKiroIncomplete( "uncompleted_kiro_response", "Kiro produced progress without an explicit final answer and no bounded retry transport was available", @@ -1057,9 +1078,8 @@ export async function* parseKiroStream( } yield { type: "heartbeat" }; - // First attempt already flushed deferred progress before this point. Gate fallback - // setup/HTTP failures the same way as the second-stream catch so a replay cannot - // duplicate visible commentary (#520). + // Failed validation releases held progress. Keep those failures non-retryable so a later + // replay cannot duplicate it; successful validation instead discards the superseded text. const priorEmittedOutput = Boolean(firstResult.assistantText.trim()) || firstResult.sawReasoning; let firstAssistantText = firstResult.assistantText; const firstHadAssistantText = firstAssistantText.length > 0; @@ -1072,6 +1092,7 @@ export async function* parseKiroStream( budget, ); } catch (err) { + yield* firstResult.drainDeferred(); firstAssistantText = ""; firstResult.assistantText = ""; firstResult.releaseRetained(); @@ -1096,14 +1117,14 @@ export async function* parseKiroStream( }; return; } - // The factory has finished using the live first-attempt alias and has retained its own retry - // serialization through the fetch boundary. The discarded parser collectors can now release - // before the second attempt begins on the same turn budget. + // The factory has retained its retry serialization. First-attempt progress remains charged + // until the second attempt decides whether it is superseded or must be released. firstAssistantText = ""; firstResult.assistantText = ""; - firstResult.releaseRetained(); + firstResult.releaseCollectors(); fallback.releaseRequestBody?.(); if (!fallback.response.ok) { + yield* firstResult.drainDeferred(); const payload = await readDisplaySafeErrorPayloadText(fallback.response, fallback.abortSignal); const failure = classifyKiroHttpError(fallback.response.status, fallback.response.headers, payload); yield { @@ -1128,9 +1149,9 @@ export async function* parseKiroStream( fallback.nameMap, fallback.conversationId, fallback.contextInputEstimate, - // First attempt already flushed deferred progress to the client before this fallback. - // A zero-output transport failure here must stay non-retryable to avoid duplicating that text. + // Failed validation will release the held first-attempt progress. priorEmittedOutput, + firstResult, ); try { if (!secondResult.terminal) { diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index b40702f7c7d..56ef351845e 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -14,7 +14,7 @@ import { openRouterProviderPayload, resolveOpenRouterRouting } from "../provider import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../providers/vercel-gateway-routing"; import { fastPolicyForModel } from "../providers/service-tier"; import { applyGithubCopilotContextTier } from "../providers/github-copilot-context"; -import { createAdapterTierMetadata, decideTier, type AdapterTierMetadata } from "../providers/fastwire"; +import { createAdapterTierMetadata, decideTier, emittedFastWire, type AdapterTierMetadata } from "../providers/fastwire"; import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, @@ -105,7 +105,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider, toolNames.registry()); const body: Record = { - model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId, + model: parsed._wireModelOverride ?? (provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId), messages, stream: parsed.stream, }; @@ -231,12 +231,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (parsed.stream) body.stream_options = { include_usage: true }; const bodyJson = JSON.stringify(applyGithubCopilotContextTier(body, provider, parsed.modelId, incoming?.providerName)); - const actualServiceTier = typeof body.service_tier === "string" ? body.service_tier : null; const tierLog = createAdapterTierMetadata( parsed.options.tierObservation, parsed.options.tierDecision, - actualServiceTier === null ? null : "service-tier", - actualServiceTier, + ...emittedFastWire(parsed, body), ); if (isDebugEnabled()) { let host = "upstream"; diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 9ef085c8cf2..5aa3474942a 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -33,6 +33,7 @@ import { } from "../xai-tool-schema"; import { createAdapterTierMetadata, + emittedFastWire, } from "../../providers/fastwire"; import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripNoneReasoningSummary, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; import { scrubOcxCompactionItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripRejectedSamplingParams } from "./request-strips"; @@ -531,14 +532,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const hint = routingHeaders.get(CODEX_ROUTING_HINT_HEADER); if (hint !== null) headers[CODEX_ROUTING_HINT_HEADER] = hint; } - const actualServiceTier = isPlainObject(finalBody) && typeof finalBody.service_tier === "string" - ? finalBody.service_tier - : null; const tierLog = createAdapterTierMetadata( parsed.options?.tierObservation, parsed.options?.tierDecision, - actualServiceTier === null ? null : "service-tier", - actualServiceTier, + ...emittedFastWire(parsed, finalBody), ); // The Responses adapter is passthrough: it forwards `parsed._rawBody` rather than // rebuilding the body from `parsed.modelId`, and the router writes the routed id into diff --git a/src/codex/auth-api/account-list.ts b/src/codex/auth-api/account-list.ts index ee73d680bea..7930d5fe00e 100644 --- a/src/codex/auth-api/account-list.ts +++ b/src/codex/auth-api/account-list.ts @@ -1,5 +1,8 @@ +import { codexCreditsDtoField, hasCodexCreditsObservation, pruneCodexCredits } from "../credits"; +import type { CodexCredits } from "../credits"; +import { getMainChatgptAccountId } from "../auth-collision"; import { codexAccountLogLabel } from "../account-label"; -import { getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store"; +import { poolQuotaHistoryIdentity, getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store"; import { getAccountQuota, isCodexQuotaExhausted, setAccountQuotaFromParsed, withoutRetiredCodexQuota } from "../quota"; import type { StoredAccountQuota } from "../quota"; import { ConfigMutationLockError, mutatePersistedConfig } from "../../config"; @@ -137,6 +140,7 @@ export function poolAccountDto( priority, autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(config, account.id), quota: quota ? { ...quota } : null, + ...codexCreditsDtoField(config, account.id, poolQuotaHistoryIdentity(account.id) ?? null), needsReauth: needsReauth || health.status === "reauth_required", ...(reauthReason !== undefined ? { reauthReason } : {}), ...(isCodexAccountPlanExcluded(config, account.id) ? { @@ -162,6 +166,7 @@ export interface CodexAuthAccountDto { /** Null inherits the global usage-switch threshold; 0 disables it for this account. */ autoSwitchThresholdOverride: number | null; quota: (StoredAccountQuota | (Omit & { updatedAt: number })) | null; + credits?: CodexCredits; needsReauth?: boolean; /** * Which of the independent causes behind `needsReauth` fired. Present only when the account @@ -254,6 +259,7 @@ export async function listCodexAuthAccountsSnapshot( ): Promise { const runtimeConfig = getRuntimeConfig(config); const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); + pruneCodexCredits([MAIN_CODEX_ACCOUNT_ID, ...poolAccounts.map(account => account.id)]); // One redaction decision for the whole snapshot, read once from the operator's config (#3859). const maskEmails = emailMaskingEnabled(runtimeConfig); const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1, undefined, false, @@ -265,7 +271,12 @@ export async function listCodexAuthAccountsSnapshot( quotaResult = { quota: null, needsReauth: true }; } else { try { - quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan, getValidCodexToken, options.validatePending === true); + // Credits are process-local while pool quota is hydrated from disk, so right after a + // restart the cache would hide credits for up to POOL_CACHE_TTL. Bypass it once per + // identity when the switch is on and nothing has been observed yet. + const creditsIdentity = runtimeConfig.showCodexCredits === true ? poolQuotaHistoryIdentity(account.id) ?? null : null; + const creditsUnobserved = creditsIdentity !== null && !hasCodexCreditsObservation(account.id, creditsIdentity); + quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh || creditsUnobserved, account.plan, getValidCodexToken, options.validatePending === true); } catch (error) { if (!(error instanceof PoolQuotaProbeBusyError)) throw error; quotaResult = { @@ -369,6 +380,7 @@ export async function listCodexAuthAccountsSnapshot( quota: mainInfo.quota ? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan) : null, + ...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getMainChatgptAccountId()), ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth), }; return { diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index 6e404df63aa..a8e6d69dbd6 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -1,3 +1,4 @@ +import { parseCodexCredits, rememberCodexCredits } from "../credits"; import { fetchCodexUsage } from "../quota-query-backoff"; import type { CodexUsageOwner } from "../quota-query-backoff"; import { loadConfig } from "../../config"; @@ -351,6 +352,7 @@ export async function fetchMainAccountInfoWhileOwned( // Tag the count with the identity it was read from, so a later response that omits the // summary can restore the badge without ever crossing an account boundary. rememberMainResetCredits(requestAccountId, freshResetCredits); + if (requestAccountId !== null) rememberCodexCredits(MAIN_CODEX_ACCOUNT_ID, requestAccountId, parseCodexCredits(data.credits)); setMainAccountInfoCache(result); // Only an explicit refresh may retract a reauth quarantine. A 200 from // /wham/usage proves the token authenticates to the usage endpoint; it does not diff --git a/src/codex/auth-api/pool-quota-probe.ts b/src/codex/auth-api/pool-quota-probe.ts index 1598a8138c7..b95c4afb8e6 100644 --- a/src/codex/auth-api/pool-quota-probe.ts +++ b/src/codex/auth-api/pool-quota-probe.ts @@ -1,3 +1,4 @@ +import { parseCodexCredits, rememberCodexCredits } from "../credits"; import { fetchCodexUsage } from "../quota-query-backoff"; import type { CodexUsageOwner } from "../quota-query-backoff"; import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, forceRefreshCodexPoolToken, markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, isTerminalCodexPoolRefreshFailure, CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, TokenRefreshError } from "../account-store"; @@ -353,6 +354,9 @@ export async function commitPoolQuotaResponse( if (ctx.mayPublish?.() === false) { return { quota: getAccountQuota(accountId), needsReauth: false, credentialGeneration: generation }; } + if (ctx.poolWriter && isCodexAccountGenerationLive(accountId, generation)) { + rememberCodexCredits(accountId, ctx.poolWriter.historyIdentity, parseCodexCredits(data.credits)); + } const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined; const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan }); const freshResetCredits = quota?.resetCredits; diff --git a/src/codex/autostart-health.ts b/src/codex/autostart-health.ts index 83b330ca3db..fe558ad8a45 100644 --- a/src/codex/autostart-health.ts +++ b/src/codex/autostart-health.ts @@ -4,12 +4,14 @@ import type { OcxConfig } from "../types"; import { getCodexRoutingKind, type CodexRoutingKind } from "./inject"; import { collectRoutingAdoption, type RoutingAdoptionEvidence } from "./routing-adoption"; import { diagnoseCodexShim, type CodexShimDiagnostic } from "./shim"; +import { diagnoseMacDesktopStartup, type DesktopStartupDiagnostic } from "../service/desktop-startup"; -export type StartupProtection = "service" | "shim" | "none"; +export type StartupProtection = "service" | "desktop" | "shim" | "none"; export type StartupHealthStatus = "native" | "protected" | "at-risk"; export type ShimCoverage = "full" | "cli-only" | "none"; export interface StartupHealthInputs { + desktop?: DesktopStartupDiagnostic; routingKind: CodexRoutingKind; autostartEnabled: boolean; serviceInstalled: boolean; @@ -27,6 +29,7 @@ export interface StartupHealthInputs { } export interface StartupHealth { + desktop?: DesktopStartupDiagnostic; status: StartupHealthStatus; routingKind: CodexRoutingKind; routingInjected: boolean; @@ -77,18 +80,22 @@ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth // We can only credit an opencodex service/shim for routing that opencodex owns. // An arbitrary localhost gateway has an independent lifecycle that OCX cannot repair. const ownsLocalRouting = inputs.routingKind === "opencodex-local"; + const desktopEffective = inputs.platform === "darwin" && !inputs.diagnosticStale + && inputs.desktop?.owned === true && inputs.desktop.loginEnabled + && inputs.desktop.running && inputs.desktop.viable; const protection: StartupProtection = ownsLocalRouting && inputs.serviceViable ? "service" + : ownsLocalRouting && desktopEffective ? "desktop" : ownsLocalRouting && shimEffective ? "shim" : "none"; - const rebootSafe = !localRoutingDependency || (ownsLocalRouting && inputs.serviceViable); + const rebootSafe = !localRoutingDependency || (ownsLocalRouting && (inputs.serviceViable || desktopEffective)); const status: StartupHealthStatus = !localRoutingDependency ? "native" : rebootSafe ? "protected" : "at-risk"; - const recommendedCommand = status !== "at-risk" + const recommendedCommand = status !== "at-risk" || (ownsLocalRouting && inputs.desktop?.owned) ? null : inputs.routingKind === "custom-local" || inputs.routingKind === "unknown" ? COMMANDS.restoreNative @@ -115,6 +122,7 @@ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth } export interface StartupHealthDiagnostics { + desktop?: DesktopStartupDiagnostic; routingKind?: CodexRoutingKind; service?: ServiceDiagnostic; shim?: CodexShimDiagnostic; @@ -129,9 +137,11 @@ export function collectStartupHealth( const shim = diagnostics.shim ?? diagnoseCodexShim(); const service = diagnostics.service ?? diagnoseService(); const routingKind = diagnostics.routingKind ?? getCodexRoutingKind(); + const desktop = diagnostics.desktop ?? diagnoseMacDesktopStartup(); const routingAdoption = diagnostics.routingAdoption ?? (routingKind === "opencodex-local" ? collectRoutingAdoption({ routingKind }) : undefined); return deriveStartupHealth({ + ...(desktop ? { desktop } : {}), routingKind, autostartEnabled: codexAutoStartEnabled(config), serviceInstalled: service.installed, @@ -187,9 +197,11 @@ function classifyStartupHealthSummary(health: StartupHealth): string { ? "custom remote Codex routing (no local restart dependency)" : "native Codex routing (no opencodex restart dependency)"; if (health.protection === "service") return "protected by background service"; + if (health.protection === "desktop") return "protected by desktop app at login and its proxy supervisor"; const command = health.recommendedCommand ?? health.commands.restoreNative; if (health.routingKind === "unknown") return `AT RISK after restart (Codex routing could not be verified; run '${command}')`; if (health.routingKind === "custom-local") return `AT RISK after restart (custom local gateway lifecycle is not managed by opencodex; run '${command}')`; + if (health.desktop?.owned) return "AT RISK after restart (desktop startup could not be verified; reopen OpenCodex and check Start at Login)"; if (health.shimCoverage === "cli-only") return `AT RISK for Codex Desktop after restart (launcher shim covers CLI scripts only; run '${command}')`; if (health.serviceConflict) return `AT RISK after restart (background service managers conflict; run '${command}')`; if (health.serviceStale) return `AT RISK after restart (background service files are stale; run '${command}')`; diff --git a/src/codex/catalog-auto-refresh-sources.ts b/src/codex/catalog-auto-refresh-sources.ts new file mode 100644 index 00000000000..d23a92c5d8c --- /dev/null +++ b/src/codex/catalog-auto-refresh-sources.ts @@ -0,0 +1,71 @@ +import type { OcxConfig } from "../types"; + +const SOURCE_WAIT_MS = 15_000; +const PROCESS_WAIT_MS = 1_000; + +/** + * A source failure or slow roster must not prevent publication from existing evidence. The signal + * aborts when the wait bound passes or the step ends, so a late source cannot publish afterwards. + */ +async function bestEffortSource(step: (active: () => boolean, signal: AbortSignal) => Promise): Promise { + let timer: ReturnType | undefined; + let active = true; + const controller = new AbortController(); + try { + await Promise.race([ + Promise.resolve().then(() => step(() => active, controller.signal)), + new Promise(resolve => { + timer = setTimeout(resolve, SOURCE_WAIT_MS); + timer.unref?.(); + }), + ]); + } catch { + // Convergence can still use the last confirmed source snapshots. + } finally { + active = false; + controller.abort(); + clearTimeout(timer); + } +} + +/** Settle source observations before admission captures them; gather itself never probes. */ +export async function refreshCatalogAutoRefreshSources( + config: OcxConfig, + current: () => boolean, +): Promise { + // Runtime selection also supplies the roster's trusted client version after an upgrade. + await bestEffortSource(async active => { + const { loadBundledCodexCatalog } = await import("./catalog/bundled"); + if (active() && current()) loadBundledCodexCatalog(); + // The synchronous loader bounds its selected-runtime subprocess probes itself. + }); + if (!current()) return; + await bestEffortSource(async active => { + const { ensureCodexEntitlementFreshness } = await import("./model-entitlements"); + if (active() && current()) await ensureCodexEntitlementFreshness(config, { waitMs: SOURCE_WAIT_MS }); + }); + if (!current()) return; + // The entitlement roster above is asked under the installed client version, which upstream's + // rollout gate can hide a new model from (GPT-6.1 Sol was invisible to 0.158 on release day). + // Discovery asks as a newer client so an unpinned native reaches the converge below. + await bestEffortSource(async (active, signal) => { + const { discoverCodexNativeRoster } = await import("./model-entitlements"); + // A stopped scheduler generation aborts too, so a stale tick cannot record discoveries. + if (active() && current()) await discoverCodexNativeRoster(config, { signal, isCurrent: () => active() && current() }); + }); +} + +/** Observation only: running sessions own static copies and require an operator restart. */ +export async function catalogAutoRefreshReloadRequired(changed = true): Promise { + try { + const { listCodexAppServerProcesses, collectCodexAppServerCatalogStateWithin } = await import("./app-server-processes"); + // A cold Windows start-time walk can miss a short deadline. The changed-set hint + // needs only the bounded process listing, so it does not depend on that walk. + if (changed) return listCodexAppServerProcesses().length > 0; + const status = await collectCodexAppServerCatalogStateWithin(PROCESS_WAIT_MS); + return status.state === "stale" || status.state === "unknown"; + } catch { + // Failure to observe a restart cannot clear a previously recorded requirement. + return !changed; + } +} diff --git a/src/codex/catalog-auto-refresh.ts b/src/codex/catalog-auto-refresh.ts index 8aa1a6d28f5..b3362054df3 100644 --- a/src/codex/catalog-auto-refresh.ts +++ b/src/codex/catalog-auto-refresh.ts @@ -1,5 +1,5 @@ /** - * Opt-in periodic catalog refresh so newly released models appear without a + * Default-on periodic catalog refresh so newly released models appear without a * manual `ocx sync` (issue #3630). * * This is load-bearing, not a convenience. The served model set is otherwise @@ -24,6 +24,7 @@ */ const DEFAULT_INTERVAL_MS = 60 * 60_000; const MIN_INTERVAL_MS = 15 * 60_000; +const INITIAL_DELAY_MS = 3 * 60_000; /** * Commit-lock wait only. Gather already has per-provider timeouts, and automatic * callers fail fast and defer (ConvergeRequest.mode) rather than holding the @@ -37,6 +38,7 @@ const MAX_JOURNAL_BYTES = 1024 * 1024; const MAX_CATALOG_BYTES = 64 * 1024 * 1024; let timer: ReturnType | null = null; +let initialTimer: ReturnType | null = null; let detachShutdownHook: (() => void) | null = null; /** The bounded cadence the live timer was created with, so a tick can notice config drift. */ let liveIntervalMs: number | null = null; @@ -170,8 +172,10 @@ function boundedInterval(value: number): number { /** Re-arm the timer when the operator changed the cadence since it was created. */ function restartIfCadenceChanged(configured: number): void { if (timer === null || boundedInterval(configured) === liveIntervalMs) return; - stopCatalogAutoRefresh(); - startCatalogAutoRefresh(configured); + clearInterval(timer); + liveIntervalMs = boundedInterval(configured); + timer = setInterval(() => void tick(), liveIntervalMs); + timer.unref?.(); } async function tick(): Promise { @@ -199,6 +203,13 @@ async function tick(): Promise { // and the unref'd timer is left running so flipping the minutes back on is // picked up without a process restart. if (configured === 0) return; + // The default-on case belongs to installs whose local Codex client this proxy manages. With + // the integration off (or on a hub or sibling), an absent section keeps the old opt-in + // meaning: no background converge may write the native Codex home unasked. An explicit + // `enabled: true` still refreshes, as it did before the default flipped. + const { shouldSyncCodexOnStart } = await import("./desired-state"); + const codexManaged = shouldSyncCodexOnStart(config); + if (!codexManaged && config.catalogAutoRefresh?.enabled !== true) return; // A stop or restart landed while the config resolved: this tick no longer owns the timer, // so it must neither count as a refresh nor adopt a cadence for a generation that is gone. if (entryGeneration !== generation) return; @@ -218,6 +229,12 @@ async function tick(): Promise { } else if (heal === "not-healed") { console.info("[catalog-auto-refresh] injected Codex config keys were rewritten externally; not re-injected this tick"); } + const { refreshCatalogAutoRefreshSources, catalogAutoRefreshReloadRequired } = + await import("./catalog-auto-refresh-sources"); + if (entryGeneration !== generation) return; + // Codex sources read Codex credentials and probe its binary; only a managed client needs them. + if (codexManaged) await refreshCatalogAutoRefreshSources(config, () => entryGeneration === generation); + if (entryGeneration !== generation) return; const [{ createManagementConvergeCodex }, { createCatalogConvergeRequest }] = await Promise.all([ import("./management-convergence"), import("./catalog-admission"), @@ -231,11 +248,16 @@ async function tick(): Promise { // createManagementConvergeCodex always projects catalog-only. Any other kind is a // funnel contract break, not something this scheduler should re-classify. if (outcome.kind !== "catalog-only") return; - const { recordCatalogAutoRefreshOutcome } = await import("./catalog-refresh-status"); - recordCatalogAutoRefreshOutcome(outcome.catalogRefresh, outcome.changed); + const { recordCatalogAutoRefreshOutcome, lastCatalogAutoRefreshOutcome } = await import("./catalog-refresh-status"); + const reloadRequired = (outcome.changed || lastCatalogAutoRefreshOutcome()?.reloadRequired === true) + ? await catalogAutoRefreshReloadRequired(outcome.changed) : false; + if (entryGeneration !== generation) return; + recordCatalogAutoRefreshOutcome(outcome.catalogRefresh, outcome.changed, reloadRequired); if (outcome.changed) { // Privacy scan: no provider names, model ids, paths, or account identifiers. - console.info("[catalog-auto-refresh] served model set changed"); + console.info(reloadRequired + ? "[catalog-auto-refresh] served model set changed; running Codex sessions keep the old list until restarted (ocx sync --restart-codex)" + : "[catalog-auto-refresh] served model set changed"); } } catch { // A failed refresh is not an error worth surfacing: the next tick tries again. @@ -253,8 +275,16 @@ export function startCatalogAutoRefresh(intervalMs = DEFAULT_INTERVAL_MS): void timer = setInterval(() => void tick(), bounded); // Never keep the process alive for a catalog refresh. timer.unref?.(); + const startedGeneration = generation; + initialTimer = setTimeout(() => { + if (startedGeneration !== generation) return; + initialTimer = null; + return tick(); + }, INITIAL_DELAY_MS); + initialTimer.unref?.(); void import("../lib/optional-shutdown-hooks") .then(hooks => { + if (startedGeneration !== generation) return; detachShutdownHook = hooks.registerOptionalShutdownHook( "catalog-auto-refresh", stopCatalogAutoRefresh, @@ -266,6 +296,10 @@ export function startCatalogAutoRefresh(intervalMs = DEFAULT_INTERVAL_MS): void } export function stopCatalogAutoRefresh(): void { + if (initialTimer) { + clearTimeout(initialTimer); + initialTimer = null; + } if (timer) { clearInterval(timer); timer = null; @@ -290,7 +324,9 @@ export function isCatalogAutoRefreshRunning(): boolean { * microtasks before this settles. */ export async function syncCatalogAutoRefreshCadence(): Promise { + const entryGeneration = generation; const { loadConfig, resolveCatalogAutoRefreshIntervalMs } = await import("../config"); + if (entryGeneration !== generation) return; const configured = resolveCatalogAutoRefreshIntervalMs(loadConfig()); // 0 is dormant: tick() already returns before converging, and the timer stays unref'd. if (configured === 0) return; diff --git a/src/codex/catalog-refresh-status.ts b/src/codex/catalog-refresh-status.ts index 719318698ba..fe9b4edeb3e 100644 --- a/src/codex/catalog-refresh-status.ts +++ b/src/codex/catalog-refresh-status.ts @@ -108,6 +108,7 @@ export interface CatalogAutoRefreshOutcome { readonly at: number; readonly disposition: CatalogDisposition; readonly changed: boolean; + readonly reloadRequired: boolean; /** * A refresh that has failed repeatedly is the signal an operator needs, and the * boolean disposition alone cannot express it: skipped and failed look the same @@ -158,6 +159,7 @@ function freezeCatalogAutoRefreshOutcome( at: outcome.at, disposition: freezeCatalogDisposition(outcome.disposition), changed: outcome.changed, + reloadRequired: outcome.reloadRequired, consecutiveFailures: outcome.consecutiveFailures, }); } @@ -171,6 +173,7 @@ function freezeCatalogAutoRefreshOutcome( export function recordCatalogAutoRefreshOutcome( disposition: CatalogDisposition, changed: boolean, + reloadRequired = false, ): CatalogAutoRefreshOutcome | null { const normalized = normalizeCatalogDisposition(disposition); if (normalized === null) return null; @@ -181,6 +184,7 @@ export function recordCatalogAutoRefreshOutcome( at: Date.now(), disposition: normalized, changed: changed === true, + reloadRequired: reloadRequired === true, consecutiveFailures, }); lastAutoRefreshOutcome = outcome; diff --git a/src/codex/catalog/discovered-natives.ts b/src/codex/catalog/discovered-natives.ts new file mode 100644 index 00000000000..67a3b2b096b --- /dev/null +++ b/src/codex/catalog/discovered-natives.ts @@ -0,0 +1,166 @@ +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteFile } from "../../config/atomic-write"; +import { getConfigDir } from "../../config/paths"; +import { assertNotRealHomeUnderTest } from "../../lib/test-home-guard"; +import { isEligibleConfiguredNativeOpenAiModel, setDiscoveredNativeOpenAiModels } from "./native-models"; + +export const DISCOVERED_NATIVE_MAX_ROWS = 32; +/** + * A real row carries its full base instructions twice (`base_instructions` and the + * `model_messages` template): GPT-6.1 Sol's live row was 87,183 bytes on 2026-09-30, so a + * 64 KiB bound silently rejected exactly the model this store exists to discover. + */ +export const DISCOVERED_NATIVE_MAX_ROW_BYTES = 256 * 1024; +export const DISCOVERED_NATIVE_MAX_FILE_BYTES = DISCOVERED_NATIVE_MAX_ROWS * (DISCOVERED_NATIVE_MAX_ROW_BYTES + 1024); +export const DISCOVERED_NATIVE_RETENTION_MS = 14 * 24 * 60 * 60 * 1000; +/** + * An unchanged row renews its last-seen time on disk at most this often. The entitlement path + * records on every successful roster fetch, including request-time ones, so writing each renewal + * would put a synchronous file replace (and on Windows an ACL pass) on ordinary traffic. + */ +export const DISCOVERED_NATIVE_RENEW_INTERVAL_MS = 60 * 60 * 1000; +const FILE_NAME = "discovered-native-models.json"; + +export interface DiscoveredNativeModel { + slug: string; + row: Record; + firstSeenAt: number; + lastSeenAt: number; + clientVersion: string; +} + +let loadedPath: string | undefined; +let models: DiscoveredNativeModel[] = []; +let fingerprint = "[]"; +let generation = 0; + +/** Validate untrusted roster rows without filesystem effects; keep upstream capability metadata. */ +export function validateDiscoveredNativeRows(rows: unknown): Record[] { + if (!Array.isArray(rows)) return []; + const accepted = new Map>(); + for (const candidate of rows) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; + const row = candidate as Record; + if (typeof row.slug !== "string" || row.slug.length > 128 + || !isEligibleConfiguredNativeOpenAiModel(row.slug) + || row.supported_in_api !== true || row.visibility === "hide" + || typeof row.display_name !== "string" || !row.display_name.trim() + || !Array.isArray(row.supported_reasoning_levels) + || row.supported_reasoning_levels.length > 16 + || !row.supported_reasoning_levels.every(level => level && typeof level === "object" + && !Array.isArray(level) && typeof level.effort === "string" && level.effort.length > 0 + && level.effort.length <= 32 && typeof level.description === "string")) continue; + if ([row.context_window, row.max_context_window].some(value => value !== undefined && value !== null + && (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0))) continue; + try { + const serialized = JSON.stringify(row); + if (Buffer.byteLength(serialized, "utf8") > DISCOVERED_NATIVE_MAX_ROW_BYTES) continue; + if (accepted.size >= DISCOVERED_NATIVE_MAX_ROWS && !accepted.has(row.slug)) continue; + accepted.set(row.slug, JSON.parse(serialized) as Record); + } catch { /* Cyclic/non-JSON rows cannot be persisted or sent to Codex. */ } + } + return [...accepted.values()]; +} + +function boundedModels(entries: DiscoveredNativeModel[], now: number): DiscoveredNativeModel[] { + return entries.filter(entry => entry.lastSeenAt >= now - DISCOVERED_NATIVE_RETENTION_MS) + .sort((a, b) => b.lastSeenAt - a.lastSeenAt || a.slug.localeCompare(b.slug)) + .slice(0, DISCOVERED_NATIVE_MAX_ROWS); +} + +function readModels(path: string, now: number): DiscoveredNativeModel[] { + return readModelsWithCount(path, now).models; +} + +/** `storedCount` is the raw entry count, so a caller can tell pruning from an unchanged store. */ +function readModelsWithCount(path: string, now: number): { models: DiscoveredNativeModel[]; storedCount: number } { + const empty = { models: [], storedCount: 0 }; + try { + if (statSync(path).size > DISCOVERED_NATIVE_MAX_FILE_BYTES) return empty; + const bytes = readFileSync(path, "utf8"); + if (Buffer.byteLength(bytes, "utf8") > DISCOVERED_NATIVE_MAX_FILE_BYTES) return empty; + const data = JSON.parse(bytes) as { version?: unknown; models?: unknown }; + if (data.version !== 1 || !Array.isArray(data.models) || data.models.length > DISCOVERED_NATIVE_MAX_ROWS) return empty; + const valid = new Map(); + for (const entry of data.models) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; + const row = validateDiscoveredNativeRows([entry.row])[0]; + if (!row || entry.slug !== row.slug + || !Number.isSafeInteger(entry.firstSeenAt) || entry.firstSeenAt < 0 + || !Number.isSafeInteger(entry.lastSeenAt) || entry.lastSeenAt < entry.firstSeenAt + || entry.lastSeenAt > now + || typeof entry.clientVersion !== "string" || entry.clientVersion.length > 64) continue; + valid.set(entry.slug, { slug: entry.slug, row, firstSeenAt: entry.firstSeenAt, + lastSeenAt: entry.lastSeenAt, clientVersion: entry.clientVersion }); + } + return { models: boundedModels([...valid.values()], now), storedCount: data.models.length }; + } catch { return empty; } +} + +function publish(entries: DiscoveredNativeModel[]): void { + models = entries; + // Seen-at timestamps extend retention but do not invalidate an otherwise identical catalog. + const next = JSON.stringify(entries.map(({ slug, row }) => ({ slug, row })).sort((a, b) => a.slug.localeCompare(b.slug))); + if (next === fingerprint) return; + fingerprint = next; + generation += 1; + setDiscoveredNativeOpenAiModels(entries.map(entry => entry.row)); +} + +/** Load on config activation; switching OpenCodex homes never carries the previous home's rows. */ +export function loadDiscoveredNativeModels(now = Date.now()): void { + const path = join(getConfigDir(), FILE_NAME); + loadedPath = path; + publish(readModels(path, now)); +} + +/** Monotonic process-local catalog evidence generation, independent of account identity. */ +export function discoveredNativeModelsGeneration(): number { + return generation; +} + +/** A confirmed nonempty roster owns discovery. Optional persistence must never fail entitlement. */ +export function recordDiscoveredNativeModels(rows: unknown, clientVersion: string, now = Date.now()): void { + try { + if (!Number.isSafeInteger(now) || now < 0 || clientVersion.length > 64) return; + const dir = getConfigDir(); + const path = join(dir, FILE_NAME); + // Re-read the store rather than trusting this process's snapshot: another OpenCodex process + // sharing the home may have recorded a model since, and merging onto a stale copy would drop + // it at the replace below. The write is atomic, not locked, so only a same-instant race remains. + const { models: onDisk, storedCount } = readModelsWithCount(path, now); + const merged = new Map(loadedPath === path ? models.map(entry => [entry.slug, entry]) : []); + for (const entry of onDisk) { + const known = merged.get(entry.slug); + if (!known || entry.lastSeenAt >= known.lastSeenAt) merged.set(entry.slug, entry); + } + loadedPath = path; + for (const row of validateDiscoveredNativeRows(rows)) { + const slug = row.slug as string; + const previous = merged.get(slug); + if (previous && previous.lastSeenAt > now) continue; + if (previous && JSON.stringify(previous.row) === JSON.stringify(row) + && now - previous.lastSeenAt < DISCOVERED_NATIVE_RENEW_INTERVAL_MS) continue; + merged.set(slug, { slug, row, firstSeenAt: previous?.firstSeenAt ?? now, lastSeenAt: now, clientVersion }); + } + const next = boundedModels([...merged.values()], now); + const serialized = JSON.stringify({ version: 1, models: next }); + if (Buffer.byteLength(serialized, "utf8") > DISCOVERED_NATIVE_MAX_FILE_BYTES) return; + publish(next); + // Nothing new to say: skip the write (and, for empty rosters, never create the file). + if (storedCount === onDisk.length && serialized === JSON.stringify({ version: 1, models: onDisk })) return; + if (next.length === 0 && !existsSync(path)) return; + assertNotRealHomeUnderTest(dir); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + atomicWriteFile(path, serialized + "\n"); + } catch { /* Discovery is optional; current in-memory metadata survives a disk write failure. */ } +} + +export function resetDiscoveredNativeModelsForTests(): void { + loadedPath = undefined; + models = []; + fingerprint = "[]"; + generation += 1; + setDiscoveredNativeOpenAiModels([]); +} diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index ab75b7e52bc..da14be4cb6c 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -55,6 +55,8 @@ import { SUPPORTED_NATIVE_OPENAI_SLUGS, RETIRED_NATIVE_OPENAI_MODELS, configuredNativeOpenAiModels, + discoveredNativeOpenAiModels, + discoveredNativeOpenAiRow, hasNativeOpenAiCapabilityMetadata, isConfiguredNativeOpenAiModel, isNativeOpenAiCapabilityAliasModel, @@ -219,7 +221,9 @@ const PINNED_UPSTREAM_MODELS: Map = new Map( ); function pinnedNativeCapabilityEntry(slug: string): RawEntry | undefined { - return PINNED_UPSTREAM_MODELS.get(nativeOpenAiCapabilitySourceSlug(slug)); + const discovered = discoveredNativeOpenAiRow(slug); + return discovered ? discoveredNativeCapabilityEntry(discovered) + : PINNED_UPSTREAM_MODELS.get(nativeOpenAiCapabilitySourceSlug(slug)); } /** @@ -419,7 +423,7 @@ export function nativeReasoningEfforts(slug: string): string[] { const levels = Array.isArray(upstream?.supported_reasoning_levels) ? upstream!.supported_reasoning_levels as Array<{ effort?: string }> : []; - if (levels.length > 0) { + if (levels.length > 0 || discoveredNativeOpenAiRow(slug)) { // Preserve the exact pinned per-model ladder. In particular, GPT-5.6 Sol and Terra // include ultra while Luna intentionally ends at max. return levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []); @@ -565,6 +569,8 @@ export function applyNativeVisibility( } function upstreamNativeEntryForSlug(slug: string): RawEntry | undefined { + const discovered = discoveredNativeOpenAiRow(slug); + if (discovered) return discoveredNativeCapabilityEntry(discovered); const sourceSlug = nativeOpenAiCapabilitySourceSlug(slug); // A self-described native returns its OWN pinned row; the alias-cloning branch below stays // reserved for slugs that genuinely borrow another model's identity. The allowlist is explicit @@ -605,6 +611,14 @@ function upstreamNativeEntryForSlug(slug: string): RawEntry | undefined { return alias; } +/** Account-specific grants and prompts are projected from entitlement evidence, never a shared row. */ +function discoveredNativeCapabilityEntry(row: RawEntry): RawEntry { + const entry = withDerivedBaseInstructions(structuredClone(row)); + delete entry.available_access_programs; + delete entry.availability_nux; + return entry; +} + /** * Backfill `base_instructions` from `model_messages.instructions_template` when upstream ships * only the latter. @@ -633,7 +647,7 @@ export const UPSTREAM_NATIVE_ENTRIES: Map = new Map( }), ); -// Configured natives join the three per-slug tables in place: other modules hold these exact +// Configured and discovered natives join the per-slug tables in place: modules hold these exact // objects, so a replacement would go unseen. Built-in ids are never configured, so a removal // cannot delete a built-in row. subscribeConfiguredNativeOpenAiModels((current, removed) => { @@ -647,7 +661,11 @@ subscribeConfiguredNativeOpenAiModels((current, removed) => { if (pinned) PINNED_NATIVE_CAPABILITY_ENTRIES.set(slug, pinned); const upstream = upstreamNativeEntryForSlug(slug); if (upstream) UPSTREAM_NATIVE_ENTRIES.set(slug, upstream); - NATIVE_OPENAI_CONTEXT_OVERRIDES[slug] = { ...NATIVE_GPT6_CONTEXT }; + const discovered = discoveredNativeOpenAiRow(slug); + const contextWindow = positiveInt(discovered?.context_window) ?? NATIVE_GPT6_CONTEXT.contextWindow; + const maxContextWindow = Math.max(contextWindow, + positiveInt(discovered?.max_context_window) ?? NATIVE_GPT6_CONTEXT.maxContextWindow); + NATIVE_OPENAI_CONTEXT_OVERRIDES[slug] = { contextWindow, maxContextWindow, maxInputTokens: maxContextWindow }; } }); @@ -692,6 +710,7 @@ const SELF_AUTHORED_NATIVE_ROWS: ReadonlySet = new Set([NATIVE_GPT6_ASTR export function shouldUpgradeToUpstreamEntry(entry: RawEntry): boolean { if (typeof entry.slug !== "string" || !UPSTREAM_NATIVE_ENTRIES.has(entry.slug)) return false; + if (discoveredNativeOpenAiRow(entry.slug)) return true; if (entry.display_name === entry.slug) return true; // A row this project authored from a guess is not evidence of upstream truth, however genuine // its display name looks. Replace it once, from the pin. @@ -703,7 +722,7 @@ export function nativeOpenAiSlugs(): string[] { const live = catalogNativeSlugs(); const availableGated = cachedAvailableAccountGatedNativeModels(); const candidates = live.length > 0 - ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS, ...configuredNativeOpenAiModels()]) + ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS, ...configuredNativeOpenAiModels(), ...discoveredNativeOpenAiModels()]) : NATIVE_OPENAI_MODELS; return candidates.filter(slug => ( !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableGated.has(slug) @@ -913,5 +932,5 @@ function catalogNativeSlugs(): string[] { export function listCatalogNativeSlugs(): string[] { // Ensure documented additions (e.g. gpt-6-astra) appear even when the bundled catalog // predates the slug — mirrors nativeOpenAiSlugs() which already merges them for /v1/models. - return unique([...catalogNativeSlugs(), ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS, ...configuredNativeOpenAiModels()]); + return unique([...catalogNativeSlugs(), ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS, ...configuredNativeOpenAiModels(), ...discoveredNativeOpenAiModels()]); } diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts index bd0e3692fc2..310a5bff64a 100644 --- a/src/codex/catalog/model-hints.ts +++ b/src/codex/catalog/model-hints.ts @@ -437,6 +437,14 @@ export const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly> = Objec * keeps rows this runtime does not expose, which is exactly why presence in the pin cannot be * the predicate: `gpt-5.4` is still pinned (hidden, with an upgrade to Terra) after its retirement. */ -export const SELF_DESCRIBED_NATIVE_OPENAI_MODELS: ReadonlySet = new Set([ +const selfDescribedNativeModels = new Set([ NATIVE_GPT6_ASTRA_MODEL, // Rows come from roster-pinned-models.json via pinnedNativeModelRows(), not the codex-rs pin. NATIVE_GPT6_SOL_MODEL, @@ -137,6 +137,8 @@ export const SELF_DESCRIBED_NATIVE_OPENAI_MODELS: ReadonlySet = new Set( NATIVE_GPT61_SOL_MODEL, ]); +export const SELF_DESCRIBED_NATIVE_OPENAI_MODELS: ReadonlySet = selfDescribedNativeModels; + /** * Native ids whose capability metadata is inherited from another pinned native row. * @@ -155,7 +157,7 @@ export const NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS = Object.freeze( ); export function isNativeOpenAiCapabilityAliasModel(slug: string): boolean { - return Object.hasOwn(NATIVE_OPENAI_CAPABILITY_SOURCES, slug) || configuredNativeSlugs.has(slug); + return Object.hasOwn(NATIVE_OPENAI_CAPABILITY_SOURCES, slug) || (configuredNativeSlugs.has(slug) && !discoveredNativeRows.has(slug)); } /** @@ -175,7 +177,7 @@ export function hasNativeOpenAiCapabilityMetadata(slug: string): boolean { export function nativeOpenAiCapabilitySourceSlug(slug: string): string { return NATIVE_OPENAI_CAPABILITY_SOURCES[slug] - ?? (configuredNativeSlugs.has(slug) ? CONFIGURED_NATIVE_OPENAI_TEMPLATE_MODEL : slug); + ?? (configuredNativeSlugs.has(slug) && !discoveredNativeRows.has(slug) ? CONFIGURED_NATIVE_OPENAI_TEMPLATE_MODEL : slug); } /** @@ -196,7 +198,7 @@ export const NATIVE_OPENAI_ALIAS_PRESENTATION: Readonly `GPT-6-Nova`, the same casing upstream uses for its own GPT-6 rows. */ @@ -240,8 +242,8 @@ const BUILT_IN_NATIVE_OPENAI_MODELS: readonly string[] = Object.freeze([ ]); /** - * The built-in list plus every configured native, appended in config order. The array and the Set - * below are shared by reference across the catalog, `/v1/models` and the dashboard, so + * The built-in list plus configured and discovered natives in registration order. The array and + * Set below are shared by reference across the catalog, `/v1/models` and the dashboard, so * registration edits them in place rather than replacing them. */ export const NATIVE_OPENAI_MODELS: string[] = [...BUILT_IN_NATIVE_OPENAI_MODELS]; @@ -260,6 +262,7 @@ export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS); * persist/reconcile path, so any process that loads config — `ocx ensure` included — sees it. */ const configuredNativeSlugs = new Set(); +const discoveredNativeRows = new Map>(); type ConfiguredNativeListener = (current: readonly string[], removed: readonly string[]) => void; const configuredNativeListeners: ConfiguredNativeListener[] = []; @@ -286,25 +289,49 @@ export function setConfiguredNativeOpenAiModels(ids: readonly string[]): void { const next = [...new Set(ids.filter(isEligibleConfiguredNativeOpenAiModel))]; const previous = [...configuredNativeSlugs]; if (next.length === previous.length && next.every((id, index) => id === previous[index])) return; - const removed = previous.filter(id => !next.includes(id)); - for (const id of previous) { - configuredNativeSlugs.delete(id); - SUPPORTED_NATIVE_OPENAI_SLUGS.delete(id); - const index = NATIVE_OPENAI_MODELS.indexOf(id); - if (index >= 0) NATIVE_OPENAI_MODELS.splice(index, 1); - } - for (const id of next) { - configuredNativeSlugs.add(id); - SUPPORTED_NATIVE_OPENAI_SLUGS.add(id); - NATIVE_OPENAI_MODELS.push(id); + configuredNativeSlugs.clear(); + for (const id of next) configuredNativeSlugs.add(id); + refreshDynamicNativeOpenAiModels(); +} + +export function discoveredNativeOpenAiModels(): readonly string[] { + return [...discoveredNativeRows.keys()]; +} + +export function discoveredNativeOpenAiRow(slug: string): Record | undefined { + const row = discoveredNativeRows.get(slug); + return row ? structuredClone(row) : undefined; +} + +/** Validated roster rows stay self-described; a later built-in registration wins automatically. */ +export function setDiscoveredNativeOpenAiModels(rows: readonly Record[]): void { + for (const slug of discoveredNativeRows.keys()) selfDescribedNativeModels.delete(slug); + discoveredNativeRows.clear(); + for (const row of rows) { + if (typeof row.slug !== "string" || !isEligibleConfiguredNativeOpenAiModel(row.slug)) continue; + discoveredNativeRows.set(row.slug, structuredClone(row)); + selfDescribedNativeModels.add(row.slug); } + refreshDynamicNativeOpenAiModels(); +} + +function dynamicNativeOpenAiModels(): string[] { + return [...new Set([...configuredNativeSlugs, ...discoveredNativeRows.keys()])]; +} + +function refreshDynamicNativeOpenAiModels(): void { + const next = dynamicNativeOpenAiModels(); + const removed = NATIVE_OPENAI_MODELS.filter(id => !BUILT_IN_NATIVE_OPENAI_MODELS.includes(id) && !next.includes(id)); + NATIVE_OPENAI_MODELS.splice(0, NATIVE_OPENAI_MODELS.length, ...BUILT_IN_NATIVE_OPENAI_MODELS, ...next); + SUPPORTED_NATIVE_OPENAI_SLUGS.clear(); + for (const id of NATIVE_OPENAI_MODELS) SUPPORTED_NATIVE_OPENAI_SLUGS.add(id); for (const listener of configuredNativeListeners) listener(next, removed); } -/** Keep derived tables in step; the listener runs immediately with the current set. */ +/** Keep derived tables in step with the configured/discovered union, including metadata updates. */ export function subscribeConfiguredNativeOpenAiModels(listener: ConfiguredNativeListener): void { configuredNativeListeners.push(listener); - listener(configuredNativeOpenAiModels(), []); + listener(dynamicNativeOpenAiModels(), []); } export function resetConfiguredNativeOpenAiModelsForTests(): void { diff --git a/src/codex/credits.ts b/src/codex/credits.ts new file mode 100644 index 00000000000..6ff3b174b7a --- /dev/null +++ b/src/codex/credits.ts @@ -0,0 +1,86 @@ +import type { OcxConfig } from "../types"; + +/** Display-only WHAM observation, never persisted or used for routing. */ +export interface CodexCredits { + hasCredits?: boolean; + unlimited?: boolean; + overageLimitReached?: boolean; + balance?: string; + approxLocalMessages?: [number, number]; + approxCloudMessages?: [number, number]; +} + +/** + * An entry without `credits` means "this identity was observed and reported nothing to show". + * Keeping that apart from "never observed" lets the account listing bypass the persisted quota + * cache exactly once per identity after a restart, instead of on every dashboard poll. + */ +const observations = new Map(); + +function messageRange(raw: unknown): [number, number] | undefined { + return Array.isArray(raw) && raw.length === 2 + && raw.every(value => typeof value === "number" && Number.isFinite(value) && value >= 0) + ? [raw[0], raw[1]] : undefined; +} + +/** Undefined keeps the prior observation; null or unusable input clears it. */ +export function parseCodexCredits(raw: unknown): CodexCredits | null | undefined { + if (raw === undefined) return undefined; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null; + const value = raw as Record; + const credits: CodexCredits = {}; + if (typeof value.has_credits === "boolean") credits.hasCredits = value.has_credits; + if (typeof value.unlimited === "boolean") credits.unlimited = value.unlimited; + if (typeof value.overage_limit_reached === "boolean") credits.overageLimitReached = value.overage_limit_reached; + if (typeof value.balance === "string" && /^\d+(\.\d+)?$/.test(value.balance)) credits.balance = value.balance; + else if (typeof value.balance === "number" && Number.isFinite(value.balance) && value.balance >= 0) { + // String(1e-7) is "1e-7"; the DTO promises a plain decimal string, which the GUI validates. + credits.balance = value.balance.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 20 }); + } + const local = messageRange(value.approx_local_messages); + const cloud = messageRange(value.approx_cloud_messages); + if (local) credits.approxLocalMessages = local; + if (cloud) credits.approxCloudMessages = cloud; + return Object.keys(credits).length > 0 ? credits : null; +} + +export function rememberCodexCredits(accountId: string, identity: string, parsed: CodexCredits | null | undefined): void { + const previous = observations.get(accountId); + if (parsed === undefined) { + // Omission keeps a same-identity observation; otherwise it still records that this identity + // answered, so the listing stops forcing fresh reads for it. + if (previous?.identity !== identity) observations.set(accountId, { identity }); + return; + } + observations.set(accountId, parsed === null ? { identity } : { identity, credits: structuredClone(parsed) }); +} + +export function codexCreditsFor(accountId: string, identity: string | null): CodexCredits | undefined { + const observation = observations.get(accountId); + if (!observation) return undefined; + if (identity !== observation.identity) { + observations.delete(accountId); + return undefined; + } + return observation.credits ? structuredClone(observation.credits) : undefined; +} + +/** Whether this identity has answered at least one usage read since the process started. */ +export function hasCodexCreditsObservation(accountId: string, identity: string | null): boolean { + const observation = observations.get(accountId); + return observation !== undefined && identity !== null && observation.identity === identity; +} + +export function codexCreditsDtoField(config: Pick, accountId: string, identity: string | null): { credits?: CodexCredits } { + const credits = codexCreditsFor(accountId, identity); + return config.showCodexCredits === true && credits ? { credits } : {}; +} + +export function pruneCodexCredits(liveAccountIds: Iterable): void { + const live = new Set(liveAccountIds); + for (const id of observations.keys()) if (!live.has(id)) observations.delete(id); +} + +export function resetCodexCreditsForTests(): void { + observations.clear(); +} diff --git a/src/codex/main-account-hard-lock.ts b/src/codex/main-account-hard-lock.ts index 700cffb2111..9216ddffffc 100644 --- a/src/codex/main-account-hard-lock.ts +++ b/src/codex/main-account-hard-lock.ts @@ -66,9 +66,9 @@ export function getMainAccountHardLockStatus( const windows = governingWindows(quota); const blocking = windows.filter(w => validPercent(w.percent) && w.percent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT); if (blocking.length > 0) { - // The lock holds until every blocking window reads lower, so the earliest possible unlock is + // The lock holds until every blocking window reads lower or is authoritatively absent, so the earliest possible unlock is // the latest blocking reset. One blocking window without a future reset makes it unknowable. - // A predicted reset is not evidence of recovery either way: only a fresh lower reading releases. + // A predicted reset is not evidence of recovery; fresh lower usage or validated WHAM absence releases. const resets = blocking.map(w => resetTimestamp(w.resetAt)); const resetAt = resets.every(r => r !== undefined && r > now) ? Math.max(...(resets as number[])) : undefined; return { enabled: true, state: "blocked", ...(resetAt !== undefined ? { resetAt } : {}) }; diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index d9d7b5d3622..fd0c99af41e 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -20,6 +20,7 @@ import { } from "./catalog/native-models"; import { loadPersistedCodexRuntime } from "./runtime"; import { codexRuntimeStateEpoch } from "./runtime"; +import { recordDiscoveredNativeModels, validateDiscoveredNativeRows } from "./catalog/discovered-natives"; import { pinnedNativeModelRows } from "./catalog/pinned-models"; import { codexCredentialMutationEpoch } from "./credential-mutation-epoch"; import { @@ -650,6 +651,7 @@ function boundedAvailabilityMessage(message: string): string { /** Keep valid roster slugs while dropping malformed program and availability metadata. */ function parseAccountModels(text: string): { + discoveredRows: Record[]; models: ReadonlySet; accessProgramsByModel: ReadonlyMap; availabilityNuxByModel: ReadonlyMap; @@ -681,7 +683,8 @@ function parseAccountModels(text: string): { } return [row.slug]; }); - return { models: new Set(models), accessProgramsByModel, availabilityNuxByModel }; + return { models: new Set(models), accessProgramsByModel, availabilityNuxByModel, + discoveredRows: validateDiscoveredNativeRows(payload.models) }; } catch { return null; } @@ -757,6 +760,7 @@ async function fetchAccountModels( if (!usable) { return unconfirmedAccountModels(credential, clientVersion, now, { kind: "parsed-empty" }); } + recordDiscoveredNativeModels(parsed.discoveredRows, clientVersion, now); const hasUnknownGatedAbsence = [...ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS] // Reachable only through tier 1, an inbound client_version below the floor. Every other // resolution is now structurally >= every recorded minimum, because the floor is the max @@ -789,6 +793,123 @@ async function fetchAccountModels( } } +/** + * Client version the discovery-only roster request claims. + * + * Upstream filters `/models` by client version AND by a rollout gate the row's own + * `minimal_client_version` does not describe: on 2026-09-30 `gpt-6.1-sol` carried + * `minimal_client_version: "0.153.0"` yet was served only to `client_version >= 0.159.0`, while the + * installed Codex was 0.158.0-alpha. Asking under the installed version (what the entitlement path + * must do, #2886) therefore could not see the model at all. Discovery asks as a client newer than + * any release so it sees the whole roster; this proxy already serves pinned rows to older + * clients, so the version only widens what we learn, never what an account may call. + */ +export const CODEX_ROSTER_DISCOVERY_CLIENT_VERSION = "99.0.0"; + +export type CodexNativeRosterDiscoveryOutcome = "recorded" | "not-modified" | "unavailable"; + +export interface CodexNativeRosterDiscoveryOptions extends Pick< + CodexModelEntitlementResolveOptions, + "credentials" | "fetcher" | "signal" | "now" | "nativeMainRefreshDependencies" +> { + /** Checked right before publication; false means the caller's scheduler generation is gone. */ + readonly isCurrent?: () => boolean; +} + +/** + * Last ETag per credential identity, so an unchanged roster costs a 304 when upstream honours it. + * A 304 carries no rows and so cannot renew a discovery's last-seen time; a model visible only + * under the discovery version would then expire after its retention while still being served. + * The ETag is therefore used only within a day of the full fetch that earned it. + */ +const discoveryEtags = new Map(); +const DISCOVERY_ETAG_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +/** + * Background discovery of native rows this build does not pin, independent of entitlement. + * + * It never writes the entitlement cache: a roster fetched under the discovery version answers a + * different question than "may this client call this model", and letting it satisfy entitlement + * reads would reintroduce the cross-version leak #2548/#2886 closed. The first account that + * answers is enough to learn a row; whether a given account may call it stays with the + * entitlement path and, for ungated rows, with the upstream status the request receives. + */ +export async function discoverCodexNativeRoster( + config: Pick, + options: CodexNativeRosterDiscoveryOptions = {}, +): Promise { + const fetcher = options.fetcher ?? fetch; + const run = async (excluded: ReadonlySet, releaseMainLease?: () => void) => { + const credentials: CodexModelEntitlementCredentialSnapshot[] = [...(options.credentials ?? [])]; + for (const accountId of options.credentials ? [] : normalizedCandidateAccountIds(config)) { + if (excluded.has(accountId)) continue; + const credential = await accountCredentialSnapshot(accountId, { + nativeMainRefreshDependencies: options.nativeMainRefreshDependencies, + signal: options.signal, + }).catch(() => null); + if (credential) credentials.push(credential); + } + releaseMainLease?.(); + for (const credential of credentials) { + const outcome = await fetchDiscoveryRoster(credential, fetcher, options).catch(() => "unavailable" as const); + if (outcome !== "unavailable") return outcome; + } + return "unavailable" as const; + }; + try { + return await withNativeMainCredentialAdmission(run); + } catch { + return "unavailable"; + } +} + +async function fetchDiscoveryRoster( + credential: CodexModelEntitlementCredentialSnapshot, + fetcher: typeof fetch, + options: Pick, +): Promise { + const controller = new AbortController(); + const abort = () => controller.abort(options.signal?.reason); + options.signal?.addEventListener("abort", abort, { once: true }); + const timer = setTimeout(() => controller.abort(new DOMException("Codex model discovery timed out", "TimeoutError")), MODEL_ROSTER_TIMEOUT_MS); + try { + const headers = new Headers({ Authorization: `Bearer ${credential.accessToken}`, Accept: "application/json" }); + if (credential.chatgptAccountId) headers.set("ChatGPT-Account-Id", credential.chatgptAccountId); + const now = options.now ?? Date.now(); + const cached = discoveryEtags.get(credential.credentialIdentity); + if (cached && now - cached.fetchedAt < DISCOVERY_ETAG_MAX_AGE_MS) headers.set("If-None-Match", cached.etag); + const response = await fetcher(codexModelsUrl(CODEX_ROSTER_DISCOVERY_CLIENT_VERSION), { + headers, + redirect: "error", + signal: controller.signal, + }); + if (response.status === 304) return "not-modified"; + if (!response.ok) return "unavailable"; + const body = await readBoundedResponseBody(response, { + signal: controller.signal, + maxBytes: MODEL_ROSTER_MAX_BYTES, + fatalUtf8: true, + }); + if (!body.displaySafe || body.truncated) return "unavailable"; + const parsed = parseAccountModels(body.text); + if (parsed === null || parsed.models.size === 0) return "unavailable"; + if (controller.signal.aborted || options.isCurrent?.() === false) return "unavailable"; + recordDiscoveredNativeModels(parsed.discoveredRows, CODEX_ROSTER_DISCOVERY_CLIENT_VERSION, now); + const nextEtag = response.headers.get("etag"); + if (nextEtag && nextEtag.length <= 256) discoveryEtags.set(credential.credentialIdentity, { etag: nextEtag, fetchedAt: now }); + else discoveryEtags.delete(credential.credentialIdentity); + if (discoveryEtags.size > 64) discoveryEtags.delete(discoveryEtags.keys().next().value!); + return "recorded"; + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", abort); + } +} + +export function resetCodexNativeRosterDiscoveryForTests(): void { + discoveryEtags.clear(); +} + function directCallerCredential(headers: Headers): CodexModelEntitlementCredentialSnapshot | null { const match = /^Bearer\s+(\S+)$/i.exec(headers.get("authorization")?.trim() ?? ""); if (!match) return null; diff --git a/src/codex/quota-types.ts b/src/codex/quota-types.ts index c5e8946d80c..f6e6a411412 100644 --- a/src/codex/quota-types.ts +++ b/src/codex/quota-types.ts @@ -118,6 +118,7 @@ export type WhamUsageResponse = { secondary_window?: WhamUsageWindow | null; tertiary_window?: WhamUsageWindow | null; }; + credits?: unknown; 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 57444410a85..7720c33c678 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -827,8 +827,8 @@ function filterMainPolicyMonthlyQuota( /** * Parse ordinary main-policy usage, rejecting messages with invalid numeric window percentages. - * Mark a valid primary of at least 24h as replacement evidence only when both other windows - * are explicitly null or at least 24h. A null result supplies no usable policy observation. + * A measured long primary, or explicitly absent primary with measured weekly secondary, + * proves replacement only with complete long/null topology. Null supplies no usable evidence. */ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQuotaObservation | null { const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; @@ -840,7 +840,8 @@ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQu // carries a valid usage reading: a long window without used_percent leaves that // window's usage unknown, and unknown usage must never release a block. // Headers never supply this proof, and reset time alone still cannot release a block. - if (quota && normalizeUsagePercent(primary?.used_percent) !== undefined && isExplicitLongWindow(primary) + if (quota && (isMeasuredLongWindow(primary) + || (primary === null && isMeasuredLongWindow(secondary) && quota.weeklyPercent !== undefined)) && (secondary === null || isMeasuredLongWindow(secondary)) && (tertiary === null || isMeasuredLongWindow(tertiary))) { return { ...quota, shortWindowAbsent: true }; diff --git a/src/codex/shim-probe.ts b/src/codex/shim-probe.ts index c99a573b7ee..03f5cc95821 100644 --- a/src/codex/shim-probe.ts +++ b/src/codex/shim-probe.ts @@ -146,9 +146,12 @@ function finishAfterStderr(status) { } try { + // BUN_BE_BUN selects the supervisor's interpreter mode, not the saved launcher. + const launcherEnv = { ...process.env }; + delete launcherEnv.BUN_BE_BUN; launcher = spawn(launcherShellPath, [wrapperPath, "--version"], { detached: true, - env: process.env, + env: launcherEnv, stdio: ["ignore", "ignore", "pipe", "pipe"], }); if (!launcher.pid) throw new Error("Codex shim probe launcher has no pid"); @@ -252,6 +255,7 @@ function probeUnixShimInstall(wrapperPath: string): UnixShimProbeResult { const stderrPath = join(probeDir, "stderr"); const env: NodeJS.ProcessEnv = { ...process.env, + BUN_BE_BUN: "1", OCX_SHIM_BYPASS: "1", OCX_SHIM_PROBE: "1", OCX_SHIM_PROBE_REENTRY_PATH: reentryPath, diff --git a/src/codex/shim-templates.ts b/src/codex/shim-templates.ts index 0f5af00d022..82d67869e25 100644 --- a/src/codex/shim-templates.ts +++ b/src/codex/shim-templates.ts @@ -1,6 +1,7 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV } from "../lib/bun-runtime"; import type { BunRuntimeSource } from "../lib/bun-runtime"; import { serviceApiTokenFilePath } from "../lib/service-secrets"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; import { windowsEnvIndirectBatchValue } from "../lib/win-paths"; const SHIM_MARKER = "opencodex codex autostart shim"; @@ -83,6 +84,8 @@ function shQuote(value: string): string { export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string, bunRuntimeSource: BunRuntimeSource, tokenFile = serviceApiTokenFilePath()): string { const internalCommands = CODEX_INTERNAL_COMMANDS.join("|"); const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.join("|"); + const cliArgs = selfLaunchArgv([], { sourceEntrypoint: cliPath, isStandaloneExecutable: bunRuntimeSource === "standalone" }) + .map(arg => `${shQuote(arg)} `).join(""); return `#!/usr/bin/env sh # ${SHIM_MARKER} # ${UNIX_SHIM_REVISION_MARKER} @@ -154,7 +157,7 @@ case "$ocx_subcommand" in ;; *) if [ -z "$OCX_SHIM_BYPASS" ]; then - if ! ${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} ${BUN_RUNTIME_PATH_ENV}=${shQuote(bunPath)} ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1; then + if ! ${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} ${BUN_RUNTIME_PATH_ENV}=${shQuote(bunPath)} ${shQuote(bunPath)} ${cliArgs}ensure >/dev/null 2>&1; then printf '%s\\n' ${shQuote(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC)} >&2 fi fi @@ -184,12 +187,13 @@ function windowsBatchSet(name: string, value: string): string { export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cliPath: string, bunRuntimeSource: BunRuntimeSource): string { const internalCommandChecks = CODEX_INTERNAL_COMMANDS.map(command => `if /I "%~1"=="${command}" goto run_codex`).join("\r\n"); const valueOptionChecks = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => `if /I "%~1"=="${option}" goto skip_option_value`).join("\r\n"); + const cliArgs = selfLaunchArgv([], { sourceEntrypoint: cliPath, isStandaloneExecutable: bunRuntimeSource === "standalone" }); return `@echo off\r rem ${SHIM_MARKER}\r setlocal\r ${windowsBatchSet("OCX_REAL_CODEX", realCodexPath)}\r ${windowsBatchSet("OCX_BUN", bunPath)}\r -${windowsBatchSet("OCX_CLI", cliPath)}\r +${cliArgs.length ? windowsBatchSet("OCX_CLI", cliArgs[0]!) : ""}\r ${windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath())}\r if "%OPENCODEX_API_AUTH_TOKEN%"=="" if exist "%OCX_API_TOKEN_FILE%" set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"\r if not "%OCX_SHIM_BYPASS%"=="" goto run_codex\r @@ -216,7 +220,7 @@ goto scan_codex_args\r setlocal\r ${windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource)}\r ${windowsBatchSet(BUN_RUNTIME_PATH_ENV, bunPath)}\r -"%OCX_BUN%" "%OCX_CLI%" ensure >nul 2>nul\r +"%OCX_BUN%" ${cliArgs.length ? '"%OCX_CLI%" ' : ""}ensure >nul 2>nul\r if errorlevel 1 echo ${CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC} 1>&2\r endlocal\r :run_codex\r @@ -232,6 +236,8 @@ export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: const internalCommands = CODEX_INTERNAL_COMMANDS.map(command => psString(command)).join(", "); const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => psString(option)).join(", "); const tokenFile = serviceApiTokenFilePath(); + const cliArgs = selfLaunchArgv([], { sourceEntrypoint: cliPath, isStandaloneExecutable: bunRuntimeSource === "standalone" }) + .map(arg => `${psString(arg)} `).join(""); return `#!/usr/bin/env pwsh # ${SHIM_MARKER} $hadApiAuthToken = Test-Path Env:\\OPENCODEX_API_AUTH_TOKEN @@ -263,7 +269,7 @@ if (-not $skipEnsure) { $ocxEnsureFailed = $false # Caught, not propagated: a throwing ensure used to escape this wrapper and Codex never # launched at all, which is a lockout produced by the autostart helper itself (#5261). - try { & ${psString(bunPath)} ${psString(cliPath)} ensure *> $null; if ($LASTEXITCODE -ne 0) { $ocxEnsureFailed = $true } } + try { & ${psString(bunPath)} ${cliArgs}ensure *> $null; if ($LASTEXITCODE -ne 0) { $ocxEnsureFailed = $true } } catch { $ocxEnsureFailed = $true } finally { if ($null -eq $priorRuntimeSource) { Remove-Item Env:\\${BUN_RUNTIME_SOURCE_ENV} -ErrorAction SilentlyContinue } diff --git a/src/config/derived-registries.ts b/src/config/derived-registries.ts index 2cac50232e1..9e43002c554 100644 --- a/src/config/derived-registries.ts +++ b/src/config/derived-registries.ts @@ -1,3 +1,4 @@ +import { loadDiscoveredNativeModels } from "../codex/catalog/discovered-natives"; import { setConfiguredNativeOpenAiModels } from "../codex/catalog/native-models"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers-destination"; import type { OcxConfig, OcxProviderConfig } from "../types"; @@ -20,10 +21,11 @@ export function configuredNativeOpenAiModelIds(config: Pick): } /** - * Default cadence for the opt-in catalog auto-refresh (issue #3630): one converge pass + * Default cadence for catalog auto-refresh (issue #3630): one converge pass * per hour. Each pass spends a live /models call against every enabled provider, and * provider catalogs are themselves cached upstream for minutes, so an hour is fresh * enough for newly released models to appear without an `ocx sync`. @@ -33,15 +33,17 @@ export const CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS: number = 60 * 60_000; export const CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS: number = 15 * 60_000; /** - * Opt-in master switch, read with the house `=== true` idiom so an absent key and a - * malformed one both mean off. Pure on purpose: the scheduler calls this from a + * Enabled by default; explicit false or a zero cadence disables background work. + * Malformed enabled values still mean off. Pure on purpose: the scheduler calls this from a * dynamically imported context, so it takes an explicit config slice and reads nothing * global. */ export function isCatalogAutoRefreshEnabled( config: Pick, ): boolean { - return config.catalogAutoRefresh?.enabled === true; + const section = config.catalogAutoRefresh; + return (section?.enabled === undefined || section.enabled === true) + && section?.intervalMinutes !== 0; } /** diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 346dff55e5a..6febc06293f 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -196,6 +196,7 @@ export const configSchema = z.object({ // A malformed hand edit must not silently stop opening the browser: fall back // to undefined, which resolves to the historical auto-open behavior. oauthOpenBrowser: z.boolean().optional().catch(undefined), + showCodexCredits: z.boolean().optional().catch(false), openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(), // Invalid hand edits must not discard an otherwise usable config. googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined), diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 16c8e0a23c7..bce878456d4 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -1058,6 +1058,7 @@ export const quotaResetNotifySchema = z.object({ /** * Catalog auto-refresh section (issue #3630). + * Missing section or enabled flag uses the hourly default-on scheduler. * * `.strict()` like its neighbour: a typo in an optional feature section should surface as a * rejected write rather than a silently ignored key that leaves the operator believing they diff --git a/src/generated/model-metadata.ts b/src/generated/model-metadata.ts index 1ba5b5d0f80..f62bb32b65a 100644 --- a/src/generated/model-metadata.ts +++ b/src/generated/model-metadata.ts @@ -47,7 +47,7 @@ const DATA: Record = { "cerebras": [["gemma-4-31b",131072,40960,"text,image",1,null,0.99,1.49,0,0],["gpt-oss-120b",131072,40960,"text",1,null,0.35,0.75,0,0],["llama3.1-8b",32000,8000,"text",0,null,0.1,0.1,0,0],["qwen-3-235b-a22b-instruct-2507",131000,32000,"text",0,null,0.6,1.2,0,0],["qwen-3-coder-480b",131072,32768,"text",0,null,0,0,0,0],["zai-glm-4.6",131072,32768,"text",0,null,0,0,0,0],["zai-glm-4.7",131072,40960,"text",1,null,2.25,2.75,2.25,0]], "deepseek": [["deepseek-v4-flash",1048576,384000,"text",1,null,0.14,0.28,0.0028,0],["deepseek-v4-pro",1048576,384000,"text",1,null,0.435,0.87,0.003625,0]], "google": [["deep-research-max-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["deep-research-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["gemini-1.5-flash",1000000,8192,"text,image",0,null,0.075,0.3,0.01875,0],["gemini-1.5-flash-8b",1000000,8192,"text,image",0,null,0.0375,0.15,0.01,0],["gemini-1.5-pro",1000000,8192,"text,image",0,null,1.25,5,0.3125,0],["gemini-2.0-flash",1048576,8192,"text,image",0,null,0.1,0.4,0.025,0],["gemini-2.0-flash-lite",1048576,8192,"text,image",0,null,0.075,0.3,0,0],["gemini-2.5-computer-use-preview-10-2025",131072,65536,"text,image",1,null,1.25,10,0,0],["gemini-2.5-flash",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-2.5-flash-lite",1048576,65536,"text,image",1,null,0.1,0.4,0.01,0],["gemini-2.5-flash-lite-preview-06-17",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-lite-preview-09-2025",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-preview-04-17",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-05-20",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-09-2025",1048576,65536,"text,image",1,null,0.3,2.5,0.075,0],["gemini-2.5-pro",1048576,65536,"text,image",1,null,1.25,10,0.125,0],["gemini-2.5-pro-preview-05-06",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-2.5-pro-preview-06-05",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-3-flash-preview",1048576,65536,"text,image",1,null,0.5,3,0.05,0],["gemini-3-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-flash-lite",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-lite-image",65536,65536,"text,image",1,null,0.25,30,0,0],["gemini-3.1-flash-lite-preview",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-live-preview",131072,65536,"text,image",1,null,0.75,4.5,0,0],["gemini-3.1-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-pro-preview-customtools",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.5-flash",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-3.5-flash-lite",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-3.6-flash",1048576,65536,"text,image",1,null,1.5,7.5,0.15,0],["gemini-3.7-flash",1048576,65536,"text,image",1],["gemini-3.8-flash",1048576,65536,"text,image",1],["gemini-flash-latest",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-flash-lite-latest",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-live-2.5-flash",128000,8000,"text,image",1,null,0.5,2,0,0],["gemini-live-2.5-flash-preview-native-audio",131072,65536,"text",1,null,0.5,2,0,0],["gemini-robotics-er-1.6-preview",131072,65536,"text,image",1,null,1,5,0,0],["gemma-3-27b-it",131072,8192,"text,image",0,null,0,0,0,0],["gemma-4-26b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-26b-a4b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-26b-it",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-E2B-it",131072,8192,"text,image",1,null,0,0,0,0],["gemma-4-E4B-it",131072,8192,"text,image",1,null,0,0,0,0]], - "minimax": [["MiniMax-M2",196608,128000,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.1",204800,131072,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.5",204800,131072,"text",1,null,0.3,1.2,0.03,0.375],["MiniMax-M2.5-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["MiniMax-M2.5-lightning",204800,32000,"text",1,null,0.3,2.4,0,0],["MiniMax-M2.7",204800,131072,"text",1,null,0.3,1.2,0.06,0.375],["MiniMax-M2.7-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["minimax-m3",512000,128000,"text,image",1,null,0.6,2.4,0.12,0],["MiniMax-M3",1000000,128000,"text,image,video",1,null,0.3,1.2,0.06,0]], + "minimax": [["MiniMax-M2",196608,128000,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.1",204800,131072,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.5",204800,131072,"text",1,null,0.3,1.2,0.03,0.375],["MiniMax-M2.5-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["MiniMax-M2.5-lightning",204800,32000,"text",1,null,0.3,2.4,0,0],["MiniMax-M2.7",204800,131072,"text",1,null,0.3,1.2,0.06,0.375],["MiniMax-M2.7-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["minimax-m3",512000,128000,"text,image",1,null,0.6,2.4,0.12,0],["MiniMax-M3",1000000,128000,"text,image,video",1,null,0.3,1.2,0.06,0],["MiniMax-M3.1-Flash-Preview",1000000,null,"text,image,video",1]], "mistral": [["codestral-latest",256000,4096,"text",0,null,0.3,0.9,0,0],["devstral-2512",262144,262144,"text",0,null,0.4,2,0,0],["devstral-latest",262144,262144,"text",0,null,0.4,2,0,0],["devstral-medium-2507",128000,128000,"text",0,null,0.4,2,0,0],["devstral-medium-latest",262144,262144,"text",0,null,0.4,2,0,0],["devstral-small-2505",128000,128000,"text",0,null,0.1,0.3,0,0],["devstral-small-2507",128000,128000,"text",0,null,0.1,0.3,0,0],["labs-devstral-small-2512",256000,256000,"text,image",0,null,0,0,0,0],["magistral-medium-latest",128000,16384,"text",1,null,2,5,0,0],["magistral-small",128000,128000,"text",1,null,0.5,1.5,0,0],["ministral-3b-latest",128000,128000,"text",0,null,0.04,0.04,0,0],["ministral-8b-latest",128000,128000,"text",0,null,0.1,0.1,0,0],["mistral-large-2411",131072,16384,"text",0,null,2,6,0,0],["mistral-large-2512",262144,262144,"text,image",0,null,0.5,1.5,0,0],["mistral-large-latest",262144,262144,"text,image",0,null,0.5,1.5,0,0],["mistral-medium-2505",131072,131072,"text,image",0,null,0.4,2,0,0],["mistral-medium-2508",262144,262144,"text,image",0,null,0.4,2,0,0],["mistral-medium-2604",262144,262144,"text,image",1,null,1.5,7.5,0,0],["mistral-medium-latest",262144,262144,"text,image",1,null,1.5,7.5,0,0],["mistral-nemo",128000,128000,"text",0,null,0.15,0.15,0,0],["mistral-small-2506",128000,16384,"text,image",0,null,0.1,0.3,0,0],["mistral-small-2603",256000,256000,"text,image",1,null,0.15,0.6,0,0],["mistral-small-latest",256000,256000,"text,image",1,null,0.15,0.6,0,0],["open-mistral-7b",8000,8000,"text",0,null,0.25,0.25,0,0],["open-mistral-nemo",128000,128000,"text",0,null,0.15,0.15,0,0],["open-mixtral-8x22b",64000,64000,"text",0,null,2,6,0,0],["open-mixtral-8x7b",32000,32000,"text",0,null,0.7,0.7,0,0],["pixtral-12b",128000,128000,"text,image",0,null,0.15,0.15,0,0],["pixtral-large-latest",128000,128000,"text,image",0,null,2,6,0,0]], "moonshot": [["kimi-k2.5",262144,65536,"text,image",1,null,0,0,0,0]], "openai": [["codex-mini-latest",200000,100000,"text",1,null,1.5,6,0.375,0],["gpt-4",8192,8192,"text",0,null,30,60,0,0],["gpt-4-turbo",128000,4096,"text,image",0,null,10,30,0,0],["gpt-4.1",1047576,32768,"text,image",0,null,2,8,0.5,0],["gpt-4.1-mini",1047576,32768,"text,image",0,null,0.4,1.6,0.1,0],["gpt-4.1-nano",1047576,32768,"text,image",0,null,0.1,0.4,0.025,0],["gpt-4o",128000,16384,"text,image",0,null,2.5,10,1.25,0],["gpt-4o-2024-05-13",128000,4096,"text,image",0,null,5,15,0,0],["gpt-4o-2024-08-06",128000,16384,"text,image",0,null,2.5,10,1.25,0],["gpt-4o-2024-11-20",128000,16384,"text,image",0,null,2.5,10,1.25,0],["gpt-4o-mini",128000,16384,"text,image",0,null,0.15,0.6,0.075,0],["gpt-5",400000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5-chat-latest",128000,16384,"text,image",0,null,1.25,10,0.125,0],["gpt-5-codex",272000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5-mini",400000,128000,"text,image",1,null,0.25,2,0.025,0],["gpt-5-nano",400000,128000,"text,image",1,null,0.05,0.4,0.005,0],["gpt-5-pro",400000,272000,"text,image",1,null,15,120,0,0],["gpt-5.1",400000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5.1-chat-latest",128000,16384,"text,image",1,null,1.25,10,0.125,0],["gpt-5.1-codex",272000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5.1-codex-max",272000,128000,"text,image",1,null,1.25,10,0.125,0],["gpt-5.1-codex-mini",272000,128000,"text,image",1,null,0.25,2,0.025,0],["gpt-5.2",400000,128000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.2-chat-latest",128000,16384,"text,image",1,null,1.75,14,0.175,0],["gpt-5.2-codex",272000,128000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.2-pro",400000,128000,"text,image",1,null,21,168,0,0],["gpt-5.3-chat-latest",128000,16384,"text,image",0,null,1.75,14,0.175,0],["gpt-5.3-codex",272000,128000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.3-codex-spark",128000,32000,"text,image",1,null,1.75,14,0.175,0],["gpt-5.4",1050000,128000,"text,image",1,null,2.5,15,0.25,0],["gpt-5.4-mini",400000,128000,"text,image",1,null,0.75,4.5,0.075,0],["gpt-5.4-nano",400000,128000,"text,image",1,null,0.2,1.25,0.02,0],["gpt-5.4-pro",1050000,128000,"text,image",1,null,30,180,0,0],["gpt-5.5",1050000,128000,"text,image",1,null,5,30,0.5,0],["gpt-5.5-pro",1050000,128000,"text,image",1,null,30,180,0,0],["gpt-5.6",373000,128000,"text,image",1,null,5,30,0.5,6.25],["gpt-5.6-luna",373000,128000,"text,image",1,null,0.2,1.2,0.02,0.25],["gpt-5.6-sol",373000,128000,"text,image",1,null,5,30,0.5,6.25],["gpt-5.6-terra",373000,128000,"text,image",1,null,2,12,0.2,2.5],["gpt-6-luna",373000,128000,"text,image",1,null,0.1,0.5,0.01,0.125],["gpt-6-sol",373000,128000,"text,image",1,null,2,10,0.2,2.5],["gpt-6.1-sol",373000,128000,"text,image",1,null,2,10,0.1,2.5],["gpt-realtime-2.1",128000,32000,"text,image",1,null,4,24,0.4,0],["o1",200000,100000,"text,image",1,null,15,60,7.5,0],["o1-pro",200000,100000,"text,image",1,null,150,600,0,0],["o3",200000,100000,"text,image",1,null,2,8,0.5,0],["o3-deep-research",200000,100000,"text,image",1,null,10,40,2.5,0],["o3-mini",200000,100000,"text",1,null,1.1,4.4,0.55,0],["o3-pro",200000,100000,"text,image",1,null,20,80,0,0],["o4-mini",200000,100000,"text,image",1,null,1.1,4.4,0.275,0],["o4-mini-deep-research",200000,100000,"text,image",1,null,2,8,0.5,0]], diff --git a/src/lib/test-home-guard.ts b/src/lib/test-home-guard.ts index 63537742d01..c7c0e28f60b 100644 --- a/src/lib/test-home-guard.ts +++ b/src/lib/test-home-guard.ts @@ -22,6 +22,7 @@ import { homedir } from "node:os"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; const GUARD_ENV = "OCX_TEST_HOME_GUARD"; /** @@ -185,16 +186,57 @@ function isInside(parent: string, child: string): boolean { return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); } +let checkoutRootSpellings: readonly string[] | undefined; + +/** + * Both spellings of the checkout that loaded this module (`src/lib` -> repository root). + * + * Lazy so a production process, which never removes anything through this guard, does no work + * for it at module load. In a compiled binary the module URL sits under a virtual root, so the + * result names no real checkout and the exemption below can never apply. + */ +function checkoutRoots(): readonly string[] { + if (checkoutRootSpellings === undefined) { + let lexical: string | null = null; + try { + lexical = resolve(fileURLToPath(new URL("../..", import.meta.url))); + } catch { + // A non-file module URL names no checkout: no exemption. + } + checkoutRootSpellings = lexical === null ? [] : [...new Set([canonicalize(lexical), lexical])]; + } + return checkoutRootSpellings; +} + +/** + * Whether `candidate` is repository content of a checkout that itself lives inside `protectedPath`. + * + * A Codex-app worktree is a checkout under `~/.codex/worktrees/`, and forty suites keep their + * fixture directory beside the test file. Refusing every path inside `~/.codex` refused those + * suites' own cleanup, 863 failures in one run, although nothing there is Codex state. The lift + * is deliberately narrow: it applies to one tree, only when a checkout spelling is strictly + * inside that tree and the candidate is strictly inside that checkout. The checkout root itself, + * its ancestors and every sibling stay refused, and a checkout that merely CONTAINS a protected + * tree (one at `$HOME`, or the virtual root of a compiled build) never qualifies. + */ +function isOwnCheckoutContent(protectedPath: string, candidate: string): boolean { + return checkoutRoots().some(root => isInside(protectedPath, root) && isInside(root, candidate)); +} + /** * Why removing `target` is refused, or `null` when it is not a protected location. * * Three relations are refused, not one. Equality alone would still permit * `rmSync(getConfigPath())` against a live `config.json`, and it would permit * `rmSync(homedir())`, which takes the protected tree with it. So a target is refused when it - * IS a protected tree, when it sits INSIDE one, or when it is an ANCESTOR of one. + * IS a protected tree, when it sits INSIDE one, or when it is an ANCESTOR of one. The one lift is + * content of the running checkout when that checkout lives inside the tree + * ({@link isOwnCheckoutContent}); it never applies to equality or ancestry. * * Canonicalization is what makes a symlink useless as a bypass: a temp path that merely points - * at the real home resolves to the real home before any comparison happens. + * at the real home resolves to the real home before any comparison happens. Each spelling is + * judged on its own and any refusal wins, so a link inside the checkout that resolves into the + * protected tree is refused through its canonical form. */ export function protectedRemovalReason(target: string): string | null { // Both spellings are judged, not just the canonical one. Canonicalization is what defeats a @@ -209,7 +251,9 @@ export function protectedRemovalReason(target: string): string | null { for (const tree of PROTECTED_TREES) { for (const protectedPath of [tree.path, tree.lexical]) { if (candidate === protectedPath) return `${tree.label} (${protectedPath})`; - if (isInside(protectedPath, candidate)) return `a path inside ${tree.label} (${protectedPath})`; + if (isInside(protectedPath, candidate) && !isOwnCheckoutContent(protectedPath, candidate)) { + return `a path inside ${tree.label} (${protectedPath})`; + } if (isInside(candidate, protectedPath)) return `an ancestor of ${tree.label} (${protectedPath})`; } } diff --git a/src/providers/fastwire.ts b/src/providers/fastwire.ts index d7c10488f04..61bea92fe9c 100644 --- a/src/providers/fastwire.ts +++ b/src/providers/fastwire.ts @@ -18,6 +18,8 @@ const FAST_WIRE_ADAPTERS: Readonly> // Cursor expresses Fast as a variant dimension of the picked model, resolved in the // request builder, so the adapter set is exactly the cursor adapter. "cursor-variant": new Set(["cursor"]), + // Internal xAI OAuth lane switch (xai-fast-model.ts): the OpenAI-family adapters serialize the id. + "model-variant": SERVICE_TIER_ADAPTERS, }; const DEFAULT_SERVICE_TIER_FAST_WIRE: FastWire = Object.freeze({ @@ -437,6 +439,26 @@ export function createAdapterTierMetadata( }; } +/** + * The Fast wire an OpenAI-family adapter actually serialized, as `createAdapterTierMetadata`'s last two + * arguments. A model-variant lane counts only when the observation declares it and the body really carries + * that id; otherwise the historical service_tier reading applies unchanged. + */ +export function emittedFastWire( + parsed: { _wireModelOverride?: string; options?: { tierObservation?: TierObservationContext } }, + body: unknown, +): [FastWire["kind"] | null, string | null] { + const record = body && typeof body === "object" && !Array.isArray(body) ? body as Record : undefined; + const variant = parsed._wireModelOverride; + if (variant !== undefined + && parsed.options?.tierObservation?.fastWire?.kind === "model-variant" + && record?.model === variant) { + return ["model-variant", variant]; + } + const tier = typeof record?.service_tier === "string" ? record.service_tier : null; + return [tier === null ? null : "service-tier", tier]; +} + /** Pure tier state machine. B1 normalizes canonical Fast on classified inherit routes. */ export function decideTier( policy: ResolvedFastPolicy, diff --git a/src/providers/model-rename-startup.ts b/src/providers/model-rename-startup.ts index 0f15b695cd6..f51f06780ca 100644 --- a/src/providers/model-rename-startup.ts +++ b/src/providers/model-rename-startup.ts @@ -1,13 +1,14 @@ import { mutatePersistedConfig } from "../config"; import { projectModelRenames } from "./model-rename-migration"; import { projectStaleContextWindows } from "./stale-context-window-migration"; +import { projectStaleModelRosters } from "./stale-model-roster-migration"; import { projectStaleVisionClassifications } from "./stale-vision-classification-migration"; import { projectDevinCliAuthMode } from "./devin-cli-authmode-migration"; import type { OcxConfig } from "../types"; /** - * The startup projection: registry model renames, then the context-window and - * vision-classification repairs. All three fix a saved row the registry can no longer reach on its own — + * The startup projection: registry model renames, then the context-window, vision-classification + * and model-roster repairs. Each fixes a saved row the registry can no longer reach on its own — * `enrichProviderFromRegistry` backfills a missing field and never rewrites a * present one — so they share this pass rather than adding a second boot step * with its own persistence, adopt, and failure handling. @@ -16,11 +17,12 @@ export function projectStartupConfigRepairs(config: OcxConfig): ReturnType = { }; // 260710 MiniMax models and context windows: Tier-2 evidence in // devlog/_plan/260710_provider_hardening/002_research_cn.md. +// 260930 MiniMax-M3.1-Flash-Preview: Token Plan / MiniMax Code only, 1M context, thinking +// always on (effort none or thinking disabled answers 400 code 2013), omitted effort = max. +// It returns thinking as reasoning_content and ignores reasoning_split. The live /v1/models +// roster does not list it yet. Evidence: devlog/_plan/260930_minimax_m31_flash_preview/. +export const MINIMAX_M31_FLASH_PREVIEW = "MiniMax-M3.1-Flash-Preview"; export const MINIMAX_MODELS = [ + MINIMAX_M31_FLASH_PREVIEW, "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1", "MiniMax-M2.1-highspeed", "MiniMax-M2", ]; +/** The eight-id roster every MiniMax preset seeded from 2026-07-10 until the preview landed. */ +export const MINIMAX_MODELS_BEFORE_M31 = MINIMAX_MODELS.filter(id => id !== MINIMAX_M31_FLASH_PREVIEW); +/** Models that honour reasoning_split and answer with structured reasoning_details. */ +export const MINIMAX_REASONING_SPLIT_MODELS = MINIMAX_MODELS_BEFORE_M31; export const MINIMAX_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( - MINIMAX_MODELS.map(id => [id, id === "MiniMax-M3" ? 1_000_000 : 204_800]), + MINIMAX_MODELS.map(id => [id, id === "MiniMax-M3" || id === MINIMAX_M31_FLASH_PREVIEW ? 1_000_000 : 204_800]), ); export const MINIMAX_M3_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +/** Identity efforts on the wire; no map, so none omits the field instead of disabling thinking. */ +export const MINIMAX_M31_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +export const MINIMAX_M31_DEFAULT_REASONING_EFFORT = "max"; export const MINIMAX_M3_REASONING_EFFORT_MAP: Record = { none: "disabled", minimal: "disabled", diff --git a/src/providers/stale-model-roster-migration.ts b/src/providers/stale-model-roster-migration.ts new file mode 100644 index 00000000000..bdb6a07cbde --- /dev/null +++ b/src/providers/stale-model-roster-migration.ts @@ -0,0 +1,101 @@ +/** + * Add registry models to a saved roster that is still the previous registry seed. + * + * `enrichProviderFromRegistry` copies the registry's `models` only when a row has none, so + * every config saved while an older roster was current keeps that roster forever. That is + * the right posture for a hand-edited list and the wrong one for an untouched seed: a model + * the vendor added never reaches the install, and when the vendor's live `/models` roster + * does not list it either, nothing else can surface it. + * + * This rewrites one thing: a saved `models` list that is still byte-for-byte the roster + * this file names as `from`, on a provider that still carries the registry adapter, and + * only while the registry currently seeds `to`. A list the user changed does not match and + * is left alone. For each added id it also fills a per-model context window and default + * effort, but only inside a container the row already has: enrichment fills those records + * all-or-nothing, so creating one here would hide the rest of the registry seed. + * Same shape and restraint as `stale-context-window-migration`. + */ +import { PROVIDER_REGISTRY } from "./registry"; +import { + MINIMAX_M31_DEFAULT_REASONING_EFFORT, + MINIMAX_M31_FLASH_PREVIEW, + MINIMAX_MODELS, + MINIMAX_MODELS_BEFORE_M31, + MINIMAX_MODEL_CONTEXT_WINDOWS, +} from "./registry/model-seeds"; +import type { OcxConfig } from "../types"; + +export interface StaleModelRoster { + /** Registry provider id whose saved rows may carry the old roster. */ + provider: string; + /** The exact saved roster this migration may replace, and nothing else. */ + from: readonly string[]; + to: readonly string[]; + /** Per-model seed values for the added ids, written only into containers that exist. */ + contextWindows?: Readonly>; + defaultReasoningEfforts?: Readonly>; +} + +export interface StaleModelRosterProjection { + config: OcxConfig; + changed: boolean; + warnings: string[]; +} + +/** + * MiniMax-M3.1-Flash-Preview (2026-09-30). MiniMax's /v1/models roster does not list the + * preview, so live discovery cannot add it; the catalog keeps it only when it is configured. + */ +export const STALE_MODEL_ROSTERS: readonly StaleModelRoster[] = ["minimax", "minimax-cn"].map(provider => ({ + provider, + from: MINIMAX_MODELS_BEFORE_M31, + to: MINIMAX_MODELS, + contextWindows: { [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_MODEL_CONTEXT_WINDOWS[MINIMAX_M31_FLASH_PREVIEW]! }, + defaultReasoningEfforts: { [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_DEFAULT_REASONING_EFFORT }, +})); + +function sameList(value: unknown, expected: readonly string[]): boolean { + return Array.isArray(value) + && value.length === expected.length + && value.every((item, index) => item === expected[index]); +} + +function registrySeeds(provider: string, adapter: unknown, roster: readonly string[]): boolean { + const entry = PROVIDER_REGISTRY.find(row => row.id === provider); + return entry !== undefined && entry.adapter === adapter && sameList(entry.models, roster); +} + +function fillExisting(container: Record | undefined, values: Readonly> | undefined, ids: readonly string[]): void { + if (!container || !values) return; + for (const id of ids) { + const value = values[id]; + if (value !== undefined && !Object.prototype.hasOwnProperty.call(container, id)) container[id] = value; + } +} + +/** Pure projection. The caller decides whether to persist. */ +export function projectStaleModelRosters( + config: OcxConfig, + entries: readonly StaleModelRoster[] = STALE_MODEL_ROSTERS, +): StaleModelRosterProjection { + const warnings: string[] = []; + let changed = false; + + for (const entry of entries) { + const prov = config.providers?.[entry.provider]; + if (!prov) continue; + if (!registrySeeds(entry.provider, prov.adapter, entry.to)) continue; + if (!sameList(prov.models, entry.from)) continue; + const added = entry.to.filter(id => !entry.from.includes(id)); + prov.models = [...entry.to]; + fillExisting(prov.modelContextWindows, entry.contextWindows, added); + fillExisting(prov.modelDefaultReasoningEfforts, entry.defaultReasoningEfforts, added); + changed = true; + warnings.push( + `added ${added.join(", ")} to the saved "${entry.provider}" model list, which was still ` + + "the previous registry seed.", + ); + } + + return { config, changed, warnings }; +} diff --git a/src/providers/xai-fast-model.ts b/src/providers/xai-fast-model.ts new file mode 100644 index 00000000000..609cc38263e --- /dev/null +++ b/src/providers/xai-fast-model.ts @@ -0,0 +1,85 @@ +import type { FastWire, OcxParsedRequest, OcxProviderConfig } from "../types"; + +/** + * Grok OAuth Fast is a serving lane, not a tier. + * + * The Grok OAuth gateway lists `grok-4.7` and `grok-4.7-build-fast` as two models, but they are one model + * on two lanes (same effort ladder, image input, 500k limit and advertised defaults). Measured live on + * 2026-09-30: build-fast is 1.5-1.7x faster end to end, while `service_tier: "priority"` on grok-4.7 bought + * no measurable speed and cost ~5.9x the ticks per output token (build-fast costs ~2x). + * Evidence: devlog/_plan/260930_grok47_build_unify/010_probe-evidence.md. + * + * So on the OAuth lane only, a Fast grok-4.7 request is SERIALIZED as the build-fast id and sends no tier. + * The logical id stays grok-4.7 everywhere else (parsed.modelId, route, policy, usage attempt), so effort, + * sampling strips and operator overrides keep resolving against grok-4.7. Key auth never reaches this + * (build-fast is not on the public API) and keeps priority processing. + */ +export const XAI_OAUTH_FAST_MODELS: Readonly> = Object.freeze({ + "grok-4.7": "grok-4.7-build-fast", +}); + +/** Serving-lane ids that must not be published as rows of their own. */ +export const XAI_OAUTH_FAST_VARIANT_IDS: ReadonlySet = new Set(Object.values(XAI_OAUTH_FAST_MODELS)); + +export function xaiOauthFastModel( + providerName: string, + provider: Pick, + modelId: string, +): string | undefined { + // Same predicate the transport uses to select the Grok CLI gateway (xai-transport.ts). + if (providerName !== "xai" || provider.authMode !== "oauth") return undefined; + return Object.hasOwn(XAI_OAUTH_FAST_MODELS, modelId) ? XAI_OAUTH_FAST_MODELS[modelId] : undefined; +} + +function modelVariantFastWire(variant: string): FastWire { + return { kind: "model-variant", canonicalToWire: { priority: variant }, foreignCallerTiers: "drop" }; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** + * Settle the serialized model for the final route. Runs after decideTier on every (re)normalization, so it + * also undoes an override a previous route in the same request installed: core-normalize only rewrites + * `raw.model` when the route id differs from parsed.modelId, and a same-id fallback (xai/grok-4.7 on key + * auth) would otherwise inherit the variant. + * + * The caller's Fast intent (options.serviceTier, raw.service_tier) is left in place for such re-runs; the + * `drop` decision is what keeps the tier off this wire (canonical-forward.ts, openai-chat.ts). + */ +export function applyXaiOauthFastModel( + parsed: OcxParsedRequest, + route: { providerName: string; provider: Pick; modelId: string }, + logCtx?: { wireModel?: string }, +): void { + const raw = isRecord(parsed._rawBody) ? parsed._rawBody : undefined; + const previous = parsed._wireModelOverride; + // The lane switch replaces the registry's service-tier Fast only. The xai registry entry declares no + // FastWire, so a provider-level `fastWire` is always the operator's own (service-tier.ts reads it + // first): that decision carries a wire value they verified, and it is sent unchanged. + const variant = parsed.options.tierDecision?.kind === "set" && route.provider.fastWire === undefined + ? xaiOauthFastModel(route.providerName, route.provider, route.modelId) + : undefined; + if (!variant) { + if (previous === undefined) return; + delete parsed._wireModelOverride; + if (raw && raw.model === previous) raw.model = route.modelId; + if (logCtx?.wireModel === previous) delete logCtx.wireModel; + return; + } + const observation = parsed.options.tierObservation; + if (observation) { + // The lane switch is the Fast wire. A service_tier echo says nothing about it, so it can neither + // confirm nor deny Fast here (and never unlocks priority pricing, which needs confirmation). + parsed.options.tierObservation = { + ...observation, + fastWire: modelVariantFastWire(variant), + responseTierAuthoritative: false, + }; + } + parsed.options.tierDecision = { kind: "drop" }; + parsed._wireModelOverride = variant; + if (raw) raw.model = variant; + if (logCtx) logCtx.wireModel = variant; +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index c05d14038b9..e53662b844e 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1258,6 +1258,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { // The GUI's browser-open toggle reads and writes this; absent means the // historical auto-open behavior. oauthOpenBrowser: config.oauthOpenBrowser !== false, + showCodexCredits: config.showCodexCredits === true, providers, }; } diff --git a/src/server/background-lifecycle.ts b/src/server/background-lifecycle.ts index d12a366ef31..8b524454101 100644 --- a/src/server/background-lifecycle.ts +++ b/src/server/background-lifecycle.ts @@ -79,8 +79,8 @@ function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops { .catch(() => { // The next tick adopts it. }); - // Opt-in: the tick is a no-op unless catalogAutoRefresh.enabled is true, and the - // interval is unref'd, so a default install pays one dormant timer. The scheduler + // Default-on: explicit false or a zero cadence makes ticks dormant. Both startup + // and interval timers are unref'd. The scheduler // module keeps every heavy import inside its tick, so naming it statically here // costs a module record and nothing else. startCatalogAutoRefresh(); diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 2e5b7f2d2e4..2bef4bdb9e0 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -367,6 +367,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise): StartupHealth { const shim = diagnoseCodexShim(); return deriveStartupHealth({ + desktop: desktopStartupOwnership(), routingKind: getCodexRoutingKind(), autostartEnabled: codexAutoStartEnabled(config), serviceInstalled: false, @@ -121,7 +126,7 @@ function runProbe(config: Pick): Promise { - execFile(bun, [cli, "__startup-health"], { + execFile(bun, selfLaunchArgv(["__startup-health"], { sourceEntrypoint: cli }), { encoding: "utf8", env: process.env, timeout: PROBE_TIMEOUT_MS, diff --git a/src/service/desktop-startup.ts b/src/service/desktop-startup.ts new file mode 100644 index 00000000000..551bca9507f --- /dev/null +++ b/src/service/desktop-startup.ts @@ -0,0 +1,93 @@ +import { execFileSync } from "node:child_process"; +import { accessSync, constants, readFileSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { readPid } from "../config/process-state"; +import { resolveServiceOwnership, sameServiceOwnershipSubject, type ServiceOwnershipResolution } from "./state"; + +export interface DesktopStartupDiagnostic { + /** Durable desktop claim; failed supervision does not release ownership. */ + owned: boolean; + loginEnabled: boolean; + running: boolean; + viable: boolean; +} + +/** A login item alone is insufficient: the same app must own and supervise this proxy. */ +export function deriveDesktopStartup(facts: Omit): DesktopStartupDiagnostic { + return { ...facts, viable: facts.owned && facts.loginEnabled && facts.running }; +} + +function run(command: string, args: string[]): string { + return execFileSync(command, args, { encoding: "utf8", timeout: 750, maxBuffer: 128 * 1024, stdio: ["ignore", "pipe", "pipe"] }).trim(); +} + +interface DesktopStartupDeps { + platform?: NodeJS.Platform; + home?: string; + uid?: number; + ownership?: () => ServiceOwnershipResolution; + readPid?: () => number | null; + run?: typeof run; +} + +/** Cheap ownership-only snapshot: no launchd/process probes on the server request path. */ +export function desktopStartupOwnership(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined { + if ((deps.platform ?? process.platform) !== "darwin") return undefined; + const owner = (deps.ownership ?? resolveServiceOwnership)(); + return owner.kind === "owned" && owner.ownership.owner === "desktop" + ? deriveDesktopStartup({ owned: true, loginEnabled: false, running: false }) : undefined; +} + +function processIdentity(pid: number, execute: typeof run): { parent: number; executable: string } | null { + const row = /^(\d+)\s+(.+)$/.exec(execute("/bin/ps", ["-p", String(pid), "-o", "ppid=,comm="])); + return row ? { parent: Number(row[1]), executable: realpathSync(row[2]!) } : null; +} + +/** Read-only macOS desktop ownership, login registration and live parent/child checks. */ +export function diagnoseMacDesktopStartup(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined { + if ((deps.platform ?? process.platform) !== "darwin") return undefined; + const ownership = deps.ownership ?? resolveServiceOwnership; + const owner = ownership(); + if (owner.kind !== "owned" || owner.ownership.owner !== "desktop") return undefined; + const facts = { owned: true, loginEnabled: false, running: false }; + const execute = deps.run ?? run; + const pidReader = deps.readPid ?? readPid; + try { + const home = deps.home ?? homedir(); + const id = readFileSync(join(home, "Library", "Application Support", "com.opencodex.desktop", "install-id"), "utf8").trim(); + if (id !== owner.ownership.installId) return deriveDesktopStartup(facts); + const path = join(home, "Library", "LaunchAgents", "OpenCodex.plist"); + const plist = JSON.parse(execute("/usr/bin/plutil", ["-convert", "json", "-o", "-", path])); + const args = plist.ProgramArguments; + if (plist.Label !== "OpenCodex" || plist.RunAtLoad !== true || !Array.isArray(args) + || args.length !== 2 || args[1] !== "--autostart" || typeof args[0] !== "string" + || !args[0].endsWith("/Contents/MacOS/opencodex-desktop") + || (plist.Program !== undefined && plist.Program !== args[0])) return deriveDesktopStartup(facts); + const app = realpathSync(args[0]); + const proxy = realpathSync(join(dirname(app), "ocx")); + accessSync(app, constants.X_OK); + accessSync(proxy, constants.X_OK); + const domain = `gui/${deps.uid ?? process.getuid!()}`; + const disabled = execute("/bin/launchctl", ["print-disabled", domain]); + const loaded = execute("/bin/launchctl", ["print", `${domain}/OpenCodex`]); + const program = /^\s*program = (.+)$/m.exec(loaded)?.[1]; + const loadedPath = /^\s*path = (.+)$/m.exec(loaded)?.[1]; + facts.loginEnabled = /^\s*disabled services = \{[\s\S]*\}\s*$/.test(disabled) + && !/"OpenCodex"\s*=>\s*(?:disabled|true)/.test(disabled) + && program !== undefined && realpathSync(program) === app + && loadedPath !== undefined && realpathSync(loadedPath) === realpathSync(path); + const pid = pidReader(); + if (pid !== null) { + const child = processIdentity(pid, execute); + const parent = child && child.parent > 1 ? processIdentity(child.parent, execute) : null; + facts.running = child?.executable === proxy && parent?.executable === app && pidReader() === pid; + } + const currentOwner = ownership(); + if (currentOwner.kind === "unknown" || !sameServiceOwnershipSubject(owner, currentOwner)) facts.running = false; + return deriveDesktopStartup(facts); + } catch { + // Unreadable launchd/process evidence never grants protection or releases the claim. + return deriveDesktopStartup({ ...facts, running: false }); + } +} diff --git a/src/types/config.ts b/src/types/config.ts index 0a3c82cd128..1c0f7c5c83c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -527,6 +527,8 @@ export interface OcxConfig { * the guess is wrong. */ oauthOpenBrowser?: boolean; + /** Display Codex credits on account cards; display only, default off. */ + showCodexCredits?: boolean; /** * @deprecated Compatibility-only limit for bounded legacy usage readers. * `GET /api/usage` always aggregates the complete ledger. @@ -853,13 +855,14 @@ export interface OcxConfig { */ quotaResetNotify?: OcxQuotaResetNotifyConfig; /** - * Periodic provider model-catalog refresh (issue #3630). Absent means off: no timer, no - * refresh pass, no outcome record. + * Periodic provider model-catalog refresh (issue #3630). Absent means on at the hourly + * default; `enabled: false` or `intervalMinutes: 0` turns it off. * - * Off by default for the same reason every optional subsystem here is: a refresh spends a - * live /models call against every enabled provider, and this repository's rule is that a - * default install runs no detection code and starts no live timer work. Not in - * `getDefaultConfig()` — absence is the only default state this feature has. + * It started opt-in, and that is why GPT-6.1 Sol never reached an install without a + * release: nobody had turned the section on, so the authenticated Codex roster that + * announces a new model was never re-read. One hourly pass costs a live /models call per + * enabled provider, which is the price of new models appearing on their own. Not in + * `getDefaultConfig()` — absence is the default state. */ catalogAutoRefresh?: OcxCatalogAutoRefreshConfig; /** Active provider context limits; native long windows remain within their supported ceilings. */ @@ -1645,14 +1648,12 @@ export interface OcxQuotaResetNotifyConfig { /** * Periodic model-catalog auto-refresh settings (issue #3630). * - * Every field is optional and the whole section defaults to off. Each tick converges the + * Every field is optional and an absent section runs hourly. Each tick converges the * served catalog the same way `ocx sync` does, which costs a live /models call against - * every enabled provider — so an install that never asked for this must run no refresh - * code and start no timer, matching the optional-subsystem rule the rest of this file - * follows. + * every enabled provider; set `enabled: false` to keep the catalog to explicit syncs. */ export interface OcxCatalogAutoRefreshConfig { - /** Master switch. Default false — no scheduler, no tick, no upstream calls. */ + /** Master switch. Default true; false leaves the timer dormant with no upstream calls. */ enabled?: boolean; /** * Minutes between refresh ticks. Default 60, floor 15, and 0 keeps the timer dormant diff --git a/src/types/provider.ts b/src/types/provider.ts index 08930881b23..e2fda139323 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -197,8 +197,10 @@ export interface FastWire { * `service_tier` request field; `cursor-variant` is a MODEL-VARIANT switch, because * Cursor has no tier field — its fast product is a different model id * (`claude-opus-5-thinking-high-fast`) or a `{id:"fast"}` request parameter for Grok. + * `model-variant` is internal only (config validation rejects it): the xAI OAuth Fast lane switch in + * src/providers/xai-fast-model.ts, whose only wire value is the serialized model id. */ - kind: "service-tier" | "anthropic-speed" | "cursor-variant"; + kind: "service-tier" | "anthropic-speed" | "cursor-variant" | "model-variant"; /** Canonical tier name to upstream wire spelling. */ canonicalToWire: Readonly>; /** Policy for non-canonical caller-provided tier values. */ diff --git a/src/types/request.ts b/src/types/request.ts index 77d3d7d5d8e..731000c4d57 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -50,6 +50,8 @@ export interface OcxParsedRequest { _responseModelId?: string; /** Selected OpenAI API virtual-model id retained after it rewrites the upstream wire model. */ _openAiVirtualSelectedModelId?: string; + /** Serialized-only model id (xAI OAuth Fast lane); policy keeps reading `modelId`. */ + _wireModelOverride?: string; previousResponseId?: string; context: OcxContext; stream: boolean; diff --git a/src/usage/log.ts b/src/usage/log.ts index 65aec2769cd..9524c294ee8 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -645,7 +645,8 @@ function normalizeAttemptTierOutcome(raw: unknown): AttemptTierOutcome | null { && outcome.wireKind !== null && outcome.wireKind !== "service-tier" && outcome.wireKind !== "anthropic-speed" - && outcome.wireKind !== "cursor-variant") return null; + && outcome.wireKind !== "cursor-variant" + && outcome.wireKind !== "model-variant") return null; if ("wireValue" in outcome && outcome.wireValue !== null && typeof outcome.wireValue !== "string") return null; if ("fastDowngradeReason" in outcome && (typeof outcome.fastDowngradeReason !== "string" @@ -665,6 +666,7 @@ function normalizeAttemptTierOutcome(raw: unknown): AttemptTierOutcome | null { || outcome.wireKind === "service-tier" || outcome.wireKind === "anthropic-speed" || outcome.wireKind === "cursor-variant" + || outcome.wireKind === "model-variant" ? { wireKind: outcome.wireKind } : {}), ...(outcome.wireValue === null diff --git a/structure/catalog.md b/structure/catalog.md index d592bd3a249..2ce2a2f73fa 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -102,7 +102,7 @@ explicit observed-state merge policy and restore native priorities from the once backup rather than from a catalog whose priorities may already have been rewritten. A configured custom catalog remains the native metadata/template authority even when a bundled-catalog memo is warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability -evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. +evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. The default-on scheduler in `src/codex/catalog-auto-refresh.ts` settles bundled runtime and authenticated Codex roster observations before admission, using dynamic imports, a 15-second source wait and the loader's bounded synchronous probes. Source failure uses existing evidence, while a stopped generation cannot start the next source or converge. A changed set records `reloadRequired` through `src/codex/catalog-refresh-status.ts` for observed running app-servers and logs one content-free restart hint; a no-op keeps it while processes remain stale or the restart observation is unknown and clears it when they are fresh or gone. Automatic refresh never restarts processes. `tests/codex-integration/catalog-auto-refresh-scheduler.test.ts` covers these boundaries. Custom Astra and Daybreak rows acquire native identity -- Responses Lite, multi-agent, context windows, display names -- only through the canonical `openai` forward destination and explicit @@ -143,19 +143,15 @@ are emitted only as selector-qualified rows whose account provenance matches. Th the bare native or API-key model list. This keeps account-scoped upstream ids such as `gpt-daybreak-blue-latest` callable without treating them as a static release allowlist. -Configured natives are the operator's way to widen that bare list without a release. A bare -`gpt-*` id under `providers.openai.models` on the canonical Codex forward provider joins -`NATIVE_OPENAI_MODELS` / `SUPPORTED_NATIVE_OPENAI_SLUGS` in place (`src/codex/catalog/native-models.ts`), -and `metadata.ts` keeps its pinned-capability, upstream-entry and context tables in step through -a subscription. Each borrows the pinned `gpt-6.1-sol` row under a name generated from its slug, takes -the GPT-6 272,000 / 872,000 context pair (`NATIVE_GPT6_CONTEXT`, also used by the built-in GPT-6 -rows), and is never account-gated. Built-in, retired and reserve ids never register. The filter -lives in `src/config/derived-registries.ts`, whose `refreshConfigDerivedRegistries` runs on every -load, persist and reconcile path, so every process that loads config sees the same set; removing -the id unregisters it and the next canonical write drops the row. Configured natives are not in -`ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS` or `NATIVE_MAIN_DRAIN_SENTINEL_MODELS` (they behave -like `gpt-5.5` there), and a combo `nativeAlias` cannot target one because schema validation runs -before registration. Covered by `tests/codex-integration/configured-native-models.test.ts`. +Configured natives widen the bare list without a release: eligible bare `gpt-*` ids under `providers.openai.models` on the canonical Codex forward provider join the shared registry in place (`src/codex/catalog/native-models.ts`). Each borrows the pinned `gpt-6.1-sol` row under its generated name, takes `NATIVE_GPT6_CONTEXT` (272,000 / 872,000), and is ungated. Built-in, retired and reserve ids never register. `src/config/derived-registries.ts` filters the config and refreshes registration on load, persist and reconcile; removing a configured id unregisters it unless it is also discovered. Configured natives have no entitlement routing preference or main-drain sentinel and cannot be combo `nativeAlias` targets because schema validation precedes registration. Covered by `tests/codex-integration/configured-native-models.test.ts`. + +Authenticated discovery also widens the set: after a successful nonempty roster fetch, `src/codex/model-entitlements.ts` passes full eligible rows to `src/codex/catalog/discovered-natives.ts`. Rows require a visible, API-supported bare `gpt-*` slug, display name and sane reasoning-level array; built-in, retired, reserve, malformed and oversized rows are rejected. Existing discoveries and configured natives remain eligible for refresh, so an already-supported slug does not freeze its metadata or keep borrowing a template. + +The store keeps version-1 `discovered-native-models.json` under the resolved OpenCodex home using the private atomic writer (0600 on POSIX). It merges by slug with first/last observation times and client version, expires entries unseen for 14 days, and bounds rows to 32, each to 256 KiB (a live row carries its instructions twice; GPT-6.1 Sol's was 87 KB), plus a file-size bound. Each record re-reads the file and merges onto it, so another process's discovery survives (the replace is atomic, not locked), and an unchanged row renews its last-seen time on disk at most hourly so request-time roster fetches rarely write. The discovery ETag is sent only within 24 hours of the full fetch that earned it, because a 304 cannot renew retention. Missing/corrupt files read as empty; persistence failures never fail entitlement. Config activation loads the file through derived registries; observations register immediately. A process-local monotonic generation changes for metadata or membership changes, independently of timestamp refreshes. + +The entitlement roster asks under the installed client version, and upstream's rollout gate can hide a new model from it regardless of the row's `minimal_client_version` (GPT-6.1 Sol reported 0.153.0 but was served only from 0.159.0 while the installed Codex was 0.158). So each scheduler tick also runs `discoverCodexNativeRoster` in `src/codex/model-entitlements.ts`, which asks as `CODEX_ROSTER_DISCOVERY_CLIENT_VERSION` with `If-None-Match`, feeds only the discovery store, and never writes or satisfies the entitlement cache. + +The import-free registry unions configured and discovered membership in place; removing either retains the other. Discoveries join the self-described set, use their own label and exact reasoning ladder, and derive context from their row with `NATIVE_GPT6_CONTEXT` for omitted values. Real metadata outranks a configured template; later built-in registration makes old discoveries inert. They are ungated under the flagship owner policy, without granting account entitlement or routing preference. Shared capability projection omits access programs and availability prompts, which remain scoped entitlement evidence. The next catalog merge replaces a discovered row with its latest metadata. Covered by `tests/codex-integration/discovered-native-models.test.ts`. Retirement is a catalog/evidence policy, not a universal request denylist. Manually supplied model ids still follow generic routing. User-selected config and historical usage remain stored. diff --git a/structure/config.md b/structure/config.md index 0535f765a97..21af76a0260 100644 --- a/structure/config.md +++ b/structure/config.md @@ -121,8 +121,8 @@ All config publication continues through the existing required ACL-hardened writ `claudeCode.desktopProfile` follows the same preserve-the-rest rule. JSON `null` (or any non-string) `appliedFingerprint` / `appliedAt` is treated as unset. A profile that is still invalid after that is dropped as a whole — `src/config/salvage.ts` already does this for independent `routingProfiles` / `combos` entries — so one bad Desktop marker cannot replace the operator's providers with `getDefaultConfig()`. A `claudeCode` value that is not an object still fails the document, because there is no safe subtree to keep. `claudeCode.cliFirstParty` is an optional boolean in `src/types/config.ts`. The schema passes it through; the load normalizer (`src/config/load-degrade.ts`) drops a non-boolean hand edit, every reader treats only `true` as on, and `PUT /api/claude-code` accepts only a boolean. Absence means off. It is independent of `claudeCode.desktopMode`; changing CLI first-party pins an absent Desktop mode from the observation that will apply after the flag flips — an opt-out observes with `cliFirstParty` already cleared, so a shared env that predates the marker stays attributed to Desktop instead of being pinned `gateway` and removed from under it, and an owned env cannot be mistaken for CLI-only intent. The flag is written by a standalone `PUT /api/claude-code { cliFirstParty }`, including `ocx claude config set --first-party`; the mutation pins an absent `desktopMode` at the same time. The shared settings proxy status follows the ordered classifier in `src/claude/first-party-settings.ts`: unreadable settings are `unknown`; absent or unrecognized proxy URLs are `none`; a token-bearing opencodex URL beside a foreign CA is `foreign`, while a tokenless loopback URL beside that CA is `local` with unconfirmed ownership. An attributed proxy with no bound listener is `stopped`; a usable applied pair on a bound listener is `disabled` when Claude routing is ineligible and `live` when eligible; remaining mismatches are `broken` regardless of eligibility. Inspection never mints a token. A separate `ocx ensure` may write a config-derived port while this server remains bound elsewhere; status is then `broken` until the server restarts or ensure runs after restart. -The former `showCodexSparkQuota` key is inert passthrough data when loading an old config. -It is absent from the typed settings contract and cannot re-enable Spark quota through the management API. Retirement does not migrate user-selected model ids or erase usage history. +`showCodexCredits` is an optional, display-only boolean in `src/types/config.ts`, default off. The load schema degrades malformed values to false; `src/config/diagnostics.ts` rejects malformed write candidates. `GET /api/settings`, its successful PUT response, and the safe `/api/config` DTO always project a boolean. PUT accepts a partial boolean update, persists it, and restores both the previous value and key presence if saving fails. The switch gates account DTO exposure without changing probes or routing; [credits identity binding](providers/openai-accounts.md#display-only-codex-credits) owns the observation contract. +The former `showCodexSparkQuota` key is inert passthrough data when loading an old config. It is absent from the typed settings contract and cannot re-enable Spark quota through the management API. Retirement does not migrate user-selected model ids or erase usage history. ## Config injection @@ -583,7 +583,7 @@ being treated as a text model by one and an image target by the other. ## Catalog auto-refresh -`catalogAutoRefresh` on `src/types/config.ts` stores an optional `enabled` / `intervalMinutes` section that defaults off: an absent key, an explicit false, and a malformed value all leave the scheduler dormant. `src/config/feature-flags.ts` resolves the cadence; an explicit `intervalMinutes: 0` keeps the unref'd timer idle, and any other value is clamped up to 15 minutes because upstream `/models` caches have not moved below that and a shorter tick only multiplies rate-limit exposure. `src/codex/catalog-auto-refresh.ts` is the module-singleton interval `src/server/background-lifecycle.ts` starts beside the quota reset poller; a tick that is enabled and non-dormant drives the same catalog-only converge funnel management mutations drive. Each tick arms its independently loaded config snapshot as a detached baseline before provider work, so the discovery save rebases every field — the listener binding and sections absent from the snapshot included — against the disk state at save time and concurrent hand edits survive the tick — `disabledModels` merges by member, so an overlapping visibility edit survives alongside the discovery additions. Detached saves capture explicit persisted top-level deletion intent before reconciliation, so a changed discovery snapshot cannot erase a current disk tombstone by temporarily restoring its key. A defined value reintroduced on disk removes stale deletion authority; ordinary live-config conflict precedence remains unchanged. The last-outcome record lives in `src/codex/catalog-refresh-status.ts` (when the tick finished, the normalized `CatalogDisposition`, whether the served model set changed, consecutive failures) and carries no provider or account detail. +`catalogAutoRefresh` on `src/types/config.ts` stores an optional `enabled` / `intervalMinutes` section that defaults on at a 60-minute cadence: an absent section or absent `enabled` enables refresh, while explicit false or `intervalMinutes: 0` disables it. The default applies only where `shouldSyncCodexOnStart` holds (this proxy manages the local Codex client); with the integration off, on a hub or on a sibling, an absent section keeps the old opt-in meaning and only an explicit `enabled: true` converges, without reading Codex sources. A malformed section is dropped by the load schema and therefore uses the default. `src/config/feature-flags.ts` resolves the cadence; an explicit `intervalMinutes: 0` keeps the unref'd timer idle, and any other value is clamped up to 15 minutes because upstream `/models` caches have not moved below that and a shorter tick only multiplies rate-limit exposure. `src/codex/catalog-auto-refresh.ts` owns the unref'd interval and a single unref'd three-minute startup tick that `src/server/background-lifecycle.ts` starts beside the quota reset poller; stopping cancels both timers, and adopting a new cadence re-arms only the interval; a tick that is enabled and non-dormant drives the same catalog-only converge funnel management mutations drive. Each tick arms its independently loaded config snapshot as a detached baseline before provider work, so the discovery save rebases every field — the listener binding and sections absent from the snapshot included — against the disk state at save time and concurrent hand edits survive the tick — `disabledModels` merges by member, so an overlapping visibility edit survives alongside the discovery additions. Detached saves capture explicit persisted top-level deletion intent before reconciliation, so a changed discovery snapshot cannot erase a current disk tombstone by temporarily restoring its key. A defined value reintroduced on disk removes stale deletion authority; ordinary live-config conflict precedence remains unchanged. The last-outcome record lives in `src/codex/catalog-refresh-status.ts` (when the tick finished, the normalized `CatalogDisposition`, whether the served model set changed, consecutive failures, and `reloadRequired` for running Codex sessions) and carries no provider or account detail. ## Aggregate request metrics export diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 145d89639f7..2369f2f25e7 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -417,6 +417,14 @@ the runtime to be gone; destroying the X window or a crash does not count as a d report records readiness time and whole app-process-tree RSS as evidence; those observations are not pass/fail budgets until a reviewed cross-platform baseline exists. +The lane takes about 15 minutes, so a pull request selects it only through the `changes` job's +`desktop` filter: `desktop/**`, the standalone build and its runtime locator +(`scripts/build-standalone.ts`, `scripts/standalone-targets.ts`, `src/lib/standalone.ts`, +`src/lib/bun-runtime.ts`), native keyring staging (`scripts/standalone-keyring.ts`, +`src/lib/keyring-native.ts`), `package.json`, `bun.lock` and `ci.yml` itself. Ordinary `src/**` and +`gui/**` edits do not run it on a pull request; promotion pushes to `main` and `preview` and +`workflow_dispatch` always do, so a packaging regression from such an edit surfaces at promotion. + Extraction is intentional. A GitHub-hosted runner is disposable but its package database is still a shared job resource, and a normal pull request does not need passwordless package installation or GUI elevation to prove that the packaged executable and resources boot together. The separate diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index fb6136f84d3..13da2971e76 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -292,7 +292,7 @@ after the save, and a non-retryable skip (no managed catalog) is a clean save. C | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. `PUT` keeps a stored field the body omits (`cooldownMs`, `waitForCooldownMs`, `defaultEffortMode`, `reasoningEffortMode`, `imageInput`, `cooldownWaitPolicy`, per-target `lastResort`); explicit values replace it and defaults are stored sparse. | | Protocol paths | `src/server/management/protocol-routes.ts` (lazy-loaded) — `GET /api/protocols` returns the contract version, the resolved API surfaces and protocol settings, the policy revision and the feature vocabulary; with `?provider=` (one non-empty name of at most 200 characters without control characters, else 400 `invalid_provider`; an unconfigured name is 404 `unknown_provider`, neither echoing the name) it adds a `provider` block from `src/protocols/provider-summary.ts` ([Protocol Paths](data-planes/protocol-paths.md#provider-wire-summary)); `POST /api/protocols/plan` takes `{ model, inbound, features? }` (model at most 200 characters, at most 24 features, any other key refused with 400) and returns a `ProtocolPlanV1` with `basis: "preview"` from `src/protocols/plan-snapshot.ts`. Both are read-only, never log their input, and send nothing upstream. `PATCH /api/protocols/settings` takes `{ messagesEnabled?, unrepresentable?, rollout? }` (strict: unknown keys and wrong types are 400), validates and applies through `src/server/management/protocol-settings-patch.ts`, persists with `saveConfigPreservingClaudeCode`, restores the live config if the save fails (409 on lock contention, 500 otherwise), and answers with the fresh `GET /api/protocols` body; closing Messages also writes `claudeCode.enabled = false` through `commitClaudeCodeBlock` ([Protocol Paths](data-planes/protocol-paths.md#settings)). The CLI drives all three through `ocx api protocols`, `ocx api explain` and `ocx api policy` ([Protocol Paths](data-planes/protocol-paths.md#cli)); none carries a route-registry exemption. | | Workflow budget | `src/server/management/workflow-budget-routes.ts` — `GET /api/workflow-budget` reads the tracked roots or one root, and `POST /api/workflow-budget/clear` clears exactly one. The clear moves the windowed send ring and the child map and nothing else: `active` belongs to turns still in flight, the spend ledger is a token budget an operator did not ask to forgive, and the lifetime send total survives so a clear cannot launder the record. A refusal event carries `spendScope` and `spendLimit` when a token ceiling fired, so the reason is readable without the config open beside it; no scope id is ever attached, because root ids are client thread headers and identity ids are credentials. Both are `deferred-verb` in the route registry — they are owed CLI verbs, and because the ledger is process memory there is no local projection the CLI could read instead. See [`../devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md`](../devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md). | -| Codex accounts | `src/codex/auth-api/routes.ts` — `GET/POST/DELETE /api/codex-auth/accounts`, `PUT /api/codex-auth/accounts/alias`, `PUT /api/codex-auth/accounts/pause`, `PUT /api/codex-auth/accounts/pause-exhausted`, `POST /api/codex-auth/accounts/clear-cooldown`, `GET/PUT /api/codex-auth/active`, `PUT /api/codex-auth/auto-switch`, `PUT /api/codex-auth/pool-strategy`, `PUT /api/codex-auth/failover`, `GET /api/codex-auth/quota`, `GET /api/codex-auth/reset-credits` with `POST /api/codex-auth/reset-credits/consume`, and the login flow `POST /api/codex-auth/login`, `POST /api/codex-auth/login/code`, `POST /api/codex-auth/login/cancel`, `GET /api/codex-auth/login-status`. Per-account quota activation uses the existing `GET/PUT /api/settings` surface and `src/codex/quota-auto-refresh.ts`, keeping scheduled spending separate from credential/authentication mutation. Account ids are opaque handles and are serialized so the GUI can address an account; emails are masked and tokens are never serialized. New-account config commits add UI-managed selector bindings in the same config save; deletion deliberately retains existing bindings for fail-closed exact routing and re-add stability. Account mutations request catalog convergence only after config durability and expose only the boolean `catalogRefreshPending` completion projection. | +| Codex accounts | `src/codex/auth-api/routes.ts` — `GET/POST/DELETE /api/codex-auth/accounts`, `PUT /api/codex-auth/accounts/alias`, `PUT /api/codex-auth/accounts/pause`, `PUT /api/codex-auth/accounts/pause-exhausted`, `POST /api/codex-auth/accounts/clear-cooldown`, `GET/PUT /api/codex-auth/active`, `PUT /api/codex-auth/auto-switch`, `PUT /api/codex-auth/pool-strategy`, `PUT /api/codex-auth/failover`, `GET /api/codex-auth/quota`, `GET /api/codex-auth/reset-credits` with `POST /api/codex-auth/reset-credits/consume`, and the login flow `POST /api/codex-auth/login`, `POST /api/codex-auth/login/code`, `POST /api/codex-auth/login/cancel`, `GET /api/codex-auth/login-status`. Per-account quota activation uses the existing `GET/PUT /api/settings` surface and `src/codex/quota-auto-refresh.ts`, keeping scheduled spending separate from credential/authentication mutation. Account ids are opaque handles and are serialized so the GUI can address an account; emails are masked and tokens are never serialized. New-account config commits add UI-managed selector bindings in the same config save; deletion deliberately retains existing bindings for fail-closed exact routing and re-add stability. Account mutations request catalog convergence only after config durability and expose only the boolean `catalogRefreshPending` completion projection. `showCodexCredits` is a default-off boolean in settings GET and successful PUT; partial PUT accepts a boolean and rolls back on persistence failure. See [credits exposure](providers/openai-accounts.md#display-only-codex-credits). | | Low-quota history | `src/server/management/low-quota-routes.ts` lazily serves authenticated `GET /api/codex-auth/low-quota-events` from the bounded ledger owned by the server handling the request. The response includes only that server’s account ids and notice/pause-save status; logs omit identifiers. The default notice records `logged`; a notice records `delivered` only after an injected sink succeeds, while a pause-save records `succeeded` after persistence succeeds. A timed-out in-flight save remains `pending` until it settles. Invalid limits return 400. No OS notification is emitted. | | Sidebar | `src/server/management/sidebar-routes.ts` — `GET/POST /api/github/star`, `GET /api/update/badge`, and `POST /api/update/desktop-snapshot`. The snapshot POST accepts the raw admin-token principal or the dedicated `local-desktop-snapshot-capability`; GUI sessions and requests carrying `Origin` cannot publish desktop state. A capability's bounded raw body is verified against its authenticated digest before JSON parsing or storage. Badge state is cosmetic and a failed poll degrades silently. | | Logs | `src/server/management/logs-usage-routes.ts` — `GET /api/logs`, `GET /api/claude/inbound-debug`, and `GET /api/debug/injection-logs` join the debug streams described above. | @@ -318,6 +318,15 @@ callback paste field while a device code is present: the code belongs on the ven page, whose approval is polled by the server. A later manual continuation restores the field. These additive status fields preserve the initial start-response shape and need no migration. +Both login starts (`POST /api/oauth/login`, `POST /api/codex-auth/login`) await the proxy-side +launcher and return `browserLaunch: "started" | "failed" | "skipped"`; `started` proves only that +a launcher ran. The GUI narrows the field in `gui/src/oauth-browser-launch.ts`, keeps it across +later status hints for the same login, and `LoginHint` shows a warning only for `failed`. A device +code gets a single "Copy code & open" action that copies before calling `window.open`, so both run +inside the click's user activation; only http(s) URLs get it. Once a device code appears, the +provider pollers stretch their budget to `gui/src/oauth-login-budget.ts` (longer than the longest +provider grant) instead of cancelling a still-valid grant at the browser-flow budget. + > Decision record: [OAuth login continuations](decisions/ADR-5877-oauth-login-continuations.md) ### Claude Desktop picker management @@ -457,6 +466,8 @@ unvalidated Bun builds is unchanged (`src/lib/bun-stream-caps.ts`). ## Startup safety +Startup safety credits macOS `desktop` protection only after matching the durable app ownership, enabled and loaded login item, and live app-to-bundled-proxy process relationship. This does not claim an independently installed CLI service. Missing or stale evidence remains at risk. The durable desktop claim remains visible when identity, login registration, or supervision fails; service/shim install and repair controls and their copyable commands stay disabled until ownership changes. Recovery guidance asks the user to reopen OpenCodex and check Start at Login. Compiled startup probes use the standalone-aware self-launch argument builder. + **Startup safety** is reachable by route (`/#startup`) and rendered by the app, but it is not a sidebar entry: it is entered from the dashboard's startup-state row, which links there whether the current state needs remediation or merely reports how routing is protected. Its warning state is derived from active diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 1e8deddd60f..23c0562120d 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -419,4 +419,6 @@ src/update/async-check.ts uses the existing owner-bound registry target with a b The desktop badge snapshot in src/update/desktop-badge.ts is process-local display state keyed by a Tauri session id. A 60-second shell heartbeat renews receipt time; entries expire after 180 seconds and the store retains at most 32 sessions. It is separate from the package version cache and from the updater job/ownership transaction. A proxy restart reports unknown until a bound desktop shell republishes; no update installation can be authorized by this snapshot. +MacOS desktop startup diagnostics use `src/service/desktop-startup.ts` to read the ownership record, launchd login registration and exact parent/child executable paths without mutating them. A durable desktop claim survives a failed identity or supervision check; only fresh matching identity, enabled login registration, and live supervision grant protection. Ownership and PID are re-read before crediting the result. The startup-health subprocess uses `selfLaunchArgv` to support both source and compiled entrypoints. + On Linux, a dashboard update worker started from the systemd user service is launched through an executable regular file at a trusted absolute path — `/usr/bin/systemd-run`, `/bin/systemd-run`, `/usr/local/bin/systemd-run` (local installs), or `/run/current-system/sw/bin/systemd-run` (the NixOS layout) — with `--user --scope --quiet --collect` (`src/update/worker-launch.ts`), so it leaves the service cgroup before the updater stops `opencodex-proxy.service`; the default `KillMode=control-group` otherwise kills it with the proxy (#5750). The inherited `PATH` is never searched, and each candidate's resolved target — plus every ancestor directory able to substitute it — must be root-owned and not group/world-writable: a trusted-path symlink into a user-replaceable directory is skipped, as is a group-writable `/usr/local/bin`, rather than exec'd under the service account. Candidates are tried in order and a path whose no-op scope probe fails falls through to the next trusted path; the probe applies only when `INVOCATION_ID` is set, and every other case keeps the plain detached spawn. The management route resolves the launcher with `resolveSystemdRunAsync` before spawning, so first-request probing overlaps other work instead of blocking the event loop for up to twenty seconds. `--scope` moves `systemd-run` itself into the scope and then execs the worker, so the recorded PID is the worker's (`tests/update/update-worker-launch.test.ts`). diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index b8cbdb56e94..8f26f84df8d 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -411,6 +411,11 @@ axis that outranks every source here, so it is where a deliberate text-only over (`ocx provider edit --model --text-only` writes it) and the one declaration a restart cannot take back. +Roster additions share the blind spot when the vendor's `/models` omits the new id (MiniMax-M3.1-Flash-Preview): +`src/providers/stale-model-roster-migration.ts` replaces a saved roster only while it is byte-for-byte the previous +seed, filling the added id's window and default effort only inside records the row already has, in the same startup +pass; `CALLABLE_CONFIGURED_COMPATIBILITY_MODELS` (`src/codex/catalog/model-hints.ts`) keeps it in the live catalog. + The BigModel Coding Plan Responses preset uses the separately documented `https://open.bigmodel.cn/api/v1` transport and a static catalog. Its provider row disables live discovery: a local Codex `models.json` example does not establish an diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index afb64a91b07..47981213ec1 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -122,6 +122,18 @@ raw body. ## Bounded fallback HTTP errors +Tool-enabled turns in `src/adapters/kiro/stream.ts` hold ordinary text through the one +bounded completion retry. A valid private final answer or accepted retry text supersedes +first-attempt prose, so the client receives one final answer. A real tool call releases +held progress as commentary before the tool; failed validation also releases progress +and preserves the non-retryable boundary. Held events stay charged to the translator +budget until emitted, discarded, or cancelled; replay collectors are released after +retry construction. Native `END_TURN` and `STOP_SEQUENCE` alone do not distinguish +progress from an answer and therefore still require validation. Normal private completion +and real tool calls need no completion retry. +Coverage: `tests/providers/kiro/kiro-single-final.test.ts` and +`tests/server/server-kiro-completion-e2e.test.ts`. + `src/adapters/kiro-retry.ts` uses the configured executor for every generation send and may try the existing `q.{region}.amazonaws.com` host once after a canonical-host HTTP 502/503/504 before output, subject to the same send budget. Reset, 429, alternate, and completion-fallback sends wait for a pacing slot; only the first send is pre-paid. Kiro web-search turns are paced as well. A Kiro-local wrapper maps its header deadline to HTTP 504 without changing shared or Google fetch behavior; caller cancellation remains an abort. Final HTTP 5xx text is fixed for clients, and opt-in provider diagnostics carry only closed-set status and classification codes. When a first Kiro stream needs a completion fallback, the fallback response's non-success diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index eddb712daa5..7c719ce7b92 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -277,6 +277,12 @@ Context relay dispatch rechecks the native experimental opt-in after body and cr A disabled gate prevents upstream dispatch even when the request entered while enabled. Final materialized headers pass the proxy-credential exclusion check before owner matching. +## Display-only Codex credits + +The credits module beside `src/codex/quota-types.ts` retains validated WHAM credits only in process memory. Main publication uses the current credential and physical ChatGPT account identity; pool publication uses the captured writer's `quotaHistoryIdentity` and a live credential generation, including credits-only responses. Omitted credits retain the observation; null or unusable credits clear it. Identity mismatch or removal retires it, without a TTL or disk hydration. + +`src/codex/auth-api/account-list.ts` exposes optional `credits` only when `showCodexCredits === true` and the current identity has an observation. Decimal balances remain strings; boolean flags and approximate local/cloud message ranges are allowlisted. Credits never enter persisted quota, routing, reset-credit recovery, or `/api/provider-quotas`, and are never logged. The [config surface](../config.md#config-surface) owns the display switch. + ## Quota history publication identity `src/codex/account-store.ts` assigns each explicit pool credential publication a private random `quotaHistoryIdentity`. Same-account token refresh preserves it, including each alias record's own identity; replacement or deletion retires it. A refresh CAS with a changed upstream account identity rotates the tag and does not propagate that changed identity to old aliases. Credential-only projections omit this metadata. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 861722e63df..95b3e318112 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -304,7 +304,7 @@ This stops partial weekly/Spark or credits-only refreshes from renewing obsolete The separately retained main-policy snapshot preserves omitted blocking short evidence even after its reset clock passes. Credits-only, partial weekly-only, and metadata-only updates cannot remove an existing blocking short usage reading or release its hard lock; a fresh short reading can replace -it. A validated long-primary WHAM snapshot can also retire the short tuple as described below. +it. A validated complete WHAM snapshot can also retire the short tuple as described below. Expired non-blocking short evidence is dropped, so it cannot take priority over a fresh blocking weekly reading. The Codex writer explicitly asks `src/quota/reset-observer.ts` to retain an absent short window @@ -320,14 +320,14 @@ Regression coverage lives in `tests/codex-integration/codex-quota-parser-parity. `MAIN_ACCOUNT_HARD_LOCK_PERCENT` = 98%. The 5h/short window and the weekly window each govern on their own: either one at 98% blocks, and an unknown or invalid reading in one never hides a block in the other (unknown still admits). Monthly governs only a monthly-only account. A block holds -until every blocking window reads lower, so its reported `resetAt` is the latest blocking reset, +until every blocking window reads lower or is authoritatively absent, so `resetAt` is the latest blocking reset, omitted when any blocking window has none. In the policy snapshot a reset-only weekly observation keeps a blocking weekly tuple, mirroring the short-window rule; monthly-primary evidence still replaces it. It blocks newly admitted identity-matched main-account requests. Pool alternatives remain eligible; explicit main selection and stored Direct substitution do not override it. It neither pauses the account nor clears upstream cooldown/reauth state, and management quota refresh remains available. -Only a fresh valid reading below 98%, including 0%, releases a measured block; passing a reset -timestamp alone does not. The minute sweep waits locally until the latest known blocking reset; +Fresh valid usage below 98%, including 0%, or validated WHAM absence retires a measured short block; +passing a reset timestamp alone does not. The minute sweep waits locally until the latest known blocking reset; when no future reset is known or reads remain blocked, main recovery uses the same capped 5/10/20/40/60-minute delay calculation as usage-query failures. Skipped ticks do not extend it; the physical bearer is reconciled before checking the delay, and late results cannot charge @@ -336,7 +336,7 @@ current credential before the recovery worker takes a profile lease or prepares credential has a separate key and may proceed immediately. Nonterminal 401/403 responses do not arm the successful-but-blocked recovery delay; the next sweep may retry, while terminal authentication failure keeps its reauth quarantine. -Only fresh lower usage releases the lock; no inference or reset-credit consumption is added. Failed, +Only fresh lower usage or validated window absence releases the lock; no reset-credit consumption is added. Failed, missing, non-finite or out-of-range readings do not release the block. Policy validation precedes legacy clamping. Supplementary monthly data cannot become the fallback governing window without a monthly-only plan or explicit primary-monthly evidence. Previously unobserved usage is unknown, not @@ -361,16 +361,18 @@ boundary, the admission consequence, and the settings opt-out round trip are cov hard-lock tests registered in `scripts/test-layout/layout.json`, including `tests/config/settings-main-account-hard-lock.test.ts`. -A single fresh valid WHAM response with an explicitly long primary window can replace an obsolete -short-window tuple when secondary and tertiary windows are explicitly null or also explicitly long with a valid usage reading. +A single fresh valid WHAM response with a measured long primary, or an explicitly null primary and +measured secondary that supplies parsed weekly usage, can replace an obsolete short-window tuple. +Secondary and tertiary must each be explicitly null or explicitly long with a valid usage reading. Long means **at least 24 hours**, matching the parser's short/long discriminator; a one-day primary qualifies, not only a seven-day or monthly window. The policy trusts that one reported topology; it does not require repeated observations or independently confirm upstream window completeness. -Omitted secondary/tertiary fields, a long auxiliary window without a usage reading, an unknown primary duration, partial headers, or invalid usage cannot prove that the -short window disappeared. Replacement proof belongs only to that observation and is never persisted; +Any omitted window field, an unreadable long window, an unknown duration, partial headers, or invalid usage +cannot prove that the short window disappeared. All-null credits-only and tertiary-only Go/Free responses remain insufficient. Replacement proof belongs only to that observation and is never persisted; the resulting weekly/monthly window still blocks at 98%. This prevents old short-window exhaustion from surviving indefinitely on a now weekly/monthly account. Coverage lives in `tests/codex-integration/main-quota-evidence-validation.test.ts`, +`tests/codex-integration/main-account-hard-lock-retirement.test.ts`, `tests/codex-integration/main-quota-provenance.test.ts`, and `tests/codex-integration/main-account-hard-lock-recovery.test.ts`. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 1ec2d5b83a1..d90dceb02cf 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -61,11 +61,13 @@ the Responses wire. Claude Code auto-mode always sends `stop_sequences`; forward classifier mark Grok temporarily unavailable. Regression coverage: `tests/providers/xai/xai-no-stop.test.ts`. -`grok-4.7-build-fast` joins these lists, `preserveReasoningContentModels` and the grok-4.7 -context/effort/vision rows, because xAI documents Grok 4.7 Fast as the same model on faster -infrastructure (Cursor and Grok Build only, not the public xAI API); it stays out of the lineup -seed, `modelWireDefaults` and `modelSupportsServiceTier` until a live probe. Regression coverage: -`tests/providers/xai/grok-47-build-fast-metadata.test.ts`. +`grok-4.7-build-fast` joins these lists, `preserveReasoningContentModels`, the grok-4.7 +context/effort/vision rows and grok-4.7's OAuth Responses wire default, because xAI documents Grok +4.7 Fast as the same model on faster infrastructure (Cursor and Grok Build only, not the public xAI +API) and the 2026-09-30 probe confirmed identical capabilities +(`devlog/_plan/260930_grok47_build_unify/010_probe-evidence.md`). It stays out of the lineup seed and +`modelSupportsServiceTier`, and it is not published as a row of its own; see "Grok 4.7 Fast lane" below. +Regression coverage: `tests/providers/xai/grok-47-build-fast-metadata.test.ts`. ### Policy-refusal 403 @@ -238,6 +240,29 @@ Classification reaches saved configs through the fill-only enrich backfill (`src/providers/derive.ts`); an explicit config value always wins, and a config saved while the lane is live keeps it as an explicit value even if the registry default later changes. +#### Grok 4.7 Fast lane (OAuth) + +The Grok OAuth gateway lists `grok-4.7` and `grok-4.7-build-fast` as two models. They are one model on +two serving lanes: the 2026-09-30 probe measured build-fast 1.5-1.7x faster end to end, while +`priority` on grok-4.7 bought no measurable speed and cost ~5.9x the ticks per output token +(build-fast costs ~2x). So the catalog shows one row. `shouldExposeProviderModel` +(`src/codex/catalog/model-visibility.ts`) hides build-fast from discovery, and a Fast grok-4.7 +request on the OAuth lane (`--fast` row, caller `priority`, or `fastMode`) is serialized as +`grok-4.7-build-fast` with no tier (`src/providers/xai-fast-model.ts`, applied at the tail of +`applyFinalRouteRequestNormalization`, so Responses, WebSocket, Chat, Claude, combo children and routed +compaction all take it). The logical id stays grok-4.7 for routing, effort, sampling strips, operator +overrides and the usage attempt. Only the serialized `model` changes (`raw.model` for the passthrough, +`parsed._wireModelOverride` for openai-chat), and `logCtx.wireModel` records it. The attempt's tier +outcome uses the internal `model-variant` Fast wire kind (applied, assumed). Its +`responseTierAuthoritative:false` keeps a `service_tier` echo from confirming or denying it, and from +unlocking priority pricing, so estimates stay at grok-4.7's standard rate. The passthrough relays the +upstream `model` echo (`grok-4.7-build-fast`, as plain turns already relay `grok-4.7-build`), while +translated deliveries answer with `grok-4.7`. Key auth never switches lanes, because build-fast is not +on the public API, so it keeps priority processing. An explicit `xai/grok-4.7-build-fast` request, a +`retainModels` entry or a combo target keeps working and stays visible where the user configured it. +Regression coverage: `tests/providers/xai/grok-47-fast-model.test.ts`, +`tests/providers/xai/grok-47-fast-model-wire.test.ts`. + The upstream tier echo relays to the client on every Chat Completions delivery shape (`src/chat/outbound.ts` projections and `src/server/chat-native-sse.ts` chunks), matching what the Responses lane already relayed for responses-wire upstreams; the responses-lane diff --git a/structure/runtime.md b/structure/runtime.md index 6e8036d85a4..aba5f201412 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -141,7 +141,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/providers/api-key-selection-capture.ts` | Pure request-owned snapshot of the configured key entry, reference, and revision. The router and stateful selection module share this leaf with type-only dependencies; `api-key-selection.ts` retains the compatibility export and owns persisted selection changes and route resolution. | | `src/types.ts` | Shared config, parsed request, adapter, and event types. | | `src/reasoning-effort.ts` | Codex reasoning-level definitions (`low`/`medium`/`high`/`xhigh`), per-model effort mapping, and catalog effort sanitization. | -| `src/codex/shim.ts` | Codex autostart shim: replaces the `codex` binary with a wrapper that auto-starts the proxy on demand. It skips startup for management subcommands even when value-taking global flags precede the subcommand, and transactionally restores complete, stable external launcher replacements without a watcher or PATH rediscovery. | +| `src/codex/shim.ts` | Codex autostart shim: replaces the `codex` binary with a wrapper that auto-starts the proxy on demand. It skips startup for management subcommands even when value-taking global flags precede the subcommand, and transactionally restores complete, stable external launcher replacements without a watcher or PATH rediscovery. `src/codex/shim-templates.ts` uses `src/lib/self-launch-argv.ts` to invoke standalone executables with `ensure` directly in Unix, CMD and PowerShell wrappers, without a virtual source entrypoint. | | `src/service.ts` | OS service manager (macOS launchd, Linux systemd, Windows schtasks and native WinSW): always-on proxy with crash restart. Facade over the `src/service/` leaves — `src/service/launchd.ts`, `src/service/systemd.ts`, `src/service/windows-ops.ts`, `src/service/windows-scheduler.ts`, `src/service/windows-taskxml.ts`, `src/service/state.ts`, `src/service/guards.ts`, `src/service/health.ts`, `src/service/repair.ts`, `src/service/orchestration.ts`, `src/service/diagnostics.ts`, `src/service/cli.ts`. Service definitions filter shell-local multishell PATH entries, including WinSW XML generated during install and repair, and recorded temporary launchers are replaced during repair; changed launchd definitions are reloaded. Codex-home ownership accepts either the recorded path or the same existing physical directory so path aliases remain compatible across upgrades. Elevated Task Scheduler repair stages bounded payloads; the unelevated launcher pins every namespace ancestor and payload with non-reparse handles that deny write/delete sharing on the payload and delete sharing on each ancestor until UAC processing exits. On Windows, `src/service/windows-process-priority.ts` raises the process that binds the proxy to ABOVE_NORMAL just before `startServer` (best-effort; `OCX_DISABLE_PRIORITY_BOOST=1` opts out), because the NORMAL task priority (`4`) still let a saturated host hold `/healthz` past the 750 ms CLI liveness ceiling; spawned children fall back to NORMAL since Windows does not inherit ABOVE_NORMAL. | `src/cli/provider.ts` accepts the Google-only `--google-tool-schema-policy` creation flag and rejects @@ -314,7 +314,7 @@ Failures warn without changing the requested command's exit behavior. The probe diagnostics only for a confirmed candidate and never reads adjacent auth state. Unix install-probe cleanup refusals retain their fail-closed behavior and report a bounded -diagnostic suffix: a fixed probe phase, allowlisted native error/signal, and bounded exit status. +diagnostic suffix: a fixed probe phase, allowlisted native error/signal, and bounded exit status. The probe supervisor re-enters its executable with `BUN_BE_BUN=1` to run the inline script even from compiled ocx; the saved launcher receives an environment without that interpreter flag. Metadata contents, launcher paths and raw child errors never enter that suffix. Diagnostic classification does not grant process ownership or change rollback/termination policy. An explicit `codex-shim install` (`src/cli/dispatch.ts`) exits nonzero when installation is refused or the resulting shim is unhealthy, printing the diagnostic summary; an already-installed healthy shim succeeds. @@ -543,7 +543,7 @@ Codex compaction uses a request-local model override for the configured triggers state, beside the install provenance. The claim carries an `owner` (`cli` or `desktop`), an opaque `installId` naming the owning installation rather than the user or the machine, and a `consentGeneration`. An absent claim means the CLI install that registered the service owns -the runtime, which is what every record written before the field existed says. +the runtime, which is what every record written before the field existed says. `src/service/desktop-startup.ts` separates the durable macOS desktop claim from startup viability. The bounded probe verifies matching install identity, loaded and enabled login registration, and exact app-to-bundled-proxy process paths; PID or ownership changes fail closed. `src/server/startup-health-cache.ts` launches source and compiled probes with `selfLaunchArgv`, revokes stale protection, and preserves desktop recovery guidance. Every write goes through `swapServiceInstallState`. With a custom home, the default-home record is the authority every writer can derive and the active-home record is a compatibility diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 7f03faf0306..e9c5f795e63 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -70,8 +70,8 @@ existing wire and tier policy. The OAuth lane is service-tier classified per mod `devlog/_plan/260923_grok47_parity/010_probe-evidence.md` records 4.7): grok-4.7, grok-4.6, grok-4.5, grok-4.3, grok-4.20-0309-reasoning, grok-4.20-0309-non-reasoning, grok-build-0.1 and grok-composer-2.5-fast accept `service_tier: "priority"` over Grok OAuth and echo it, so those -routes resolve Fast-eligible, publish `--fast` rows, and forward a caller-sent tier on either -wire (`chatServiceTier: true`). grok-4.20-multi-agent-0309 stays unclassified with its +routes resolve Fast-eligible, publish `--fast` rows, and forward a caller-sent tier on either wire (`chatServiceTier: true`) — except OAuth grok-4.7, whose Fast serializes `grok-4.7-build-fast` with no tier ([xAI Grok](../providers/xai-grok.md#oauth-fast-tier-priority-processing)). +grok-4.20-multi-agent-0309 stays unclassified with its caller-tier pin: the gateway accepts the field but answers `service_tier: "default"`, a live downgrade rather than a fast tier. diff --git a/tests/ci-workflows/linux-desktop-packaged-ci.test.ts b/tests/ci-workflows/linux-desktop-packaged-ci.test.ts index 10caad018ec..ad3d52a4ce1 100644 --- a/tests/ci-workflows/linux-desktop-packaged-ci.test.ts +++ b/tests/ci-workflows/linux-desktop-packaged-ci.test.ts @@ -27,9 +27,25 @@ describe("Linux packaged desktop E2E in CI", () => { const filter = changes?.steps?.find(step => step.name === "Detect changed areas"); const filters = String(filter?.with?.filters ?? ""); expect(filters).toContain("desktop:"); - expect(filters).toContain("'desktop/**'"); - expect(filters).toContain("'src/**'"); - expect(filters).toContain("'.github/workflows/ci.yml'"); + // The package E2E filter: only packaging inputs select it on a pull request. Ordinary + // src/** and gui/** edits are left to promotion pushes and workflow_dispatch. + const desktopFilter = filters.split(/\n(?=\s{0,2}\S[^\n]*:\s*$)/m) + .find(block => /^\s*desktop:\s*$/m.test(block.split("\n")[0] ?? "")) ?? ""; + const desktopPaths = [...desktopFilter.matchAll(/- '([^']+)'/g)].map(match => match[1]); + expect(desktopPaths).toEqual([ + "desktop/**", + "src/lib/standalone.ts", + "src/lib/keyring-native.ts", + "src/lib/bun-runtime.ts", + "scripts/build-standalone.ts", + "scripts/standalone-keyring.ts", + "scripts/standalone-targets.ts", + "package.json", + "bun.lock", + ".github/workflows/ci.yml", + ]); + expect(desktopPaths).not.toContain("src/**"); + expect(desktopPaths).not.toContain("gui/**"); const shell = workflow.jobs?.["desktop-shell"]; expect(shell?.if).toContain("needs.changes.outputs.desktop == 'true'"); diff --git a/tests/ci-workflows/test-home-guard.test.ts b/tests/ci-workflows/test-home-guard.test.ts index aa89f907e78..8be98541ed0 100644 --- a/tests/ci-workflows/test-home-guard.test.ts +++ b/tests/ci-workflows/test-home-guard.test.ts @@ -10,7 +10,7 @@ * Incident: devlog/_fin/260730_codex_rs_upstream_v2_live_handoff/070. */ import { describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -692,6 +692,64 @@ const canSymlink = (() => { expect(JSON.parse(probe.stdout.trim())).toEqual({ alias: true, plain: true }); }); + test.skipIf(!canSymlink)("content of a checkout inside the Codex home may be removed; the checkout and its neighbours may not", async () => { + // A Codex-app worktree is a checkout under ~/.codex/worktrees/. The sentinel's .codex points + // at this checkout's parent, which puts the running checkout inside the protected tree the + // same way. Only repository content is lifted; a link inside the checkout that resolves out + // of it is still judged by its canonical form. + const probeId = beginProbe("14-checkout-content"); + const realHome = mkdtempSync(join(tmpdir(), "ocx-sentinel-home-")); + const codexLink = join(realHome, ".codex"); + symlinkSync(dirname(REPO_ROOT), codexLink); + mkdirSync(join(realHome, ".opencodex"), { recursive: true }); + try { + const probe = await runProbe(probeId, ` + import { mkdirSync, symlinkSync, unlinkSync } from "node:fs"; + import { dirname, join } from "node:path"; + import { protectedRemovalReason } from "${REPO_ROOT_URL}src/lib/test-home-guard"; + const root = ${JSON.stringify(REPO_ROOT)}; + const linkDir = join(root, ".tmp"); + mkdirSync(linkDir, { recursive: true }); + const link = join(linkDir, "guard-link-" + process.pid); + symlinkSync(dirname(root), link); + try { + console.log(JSON.stringify({ + fixture: protectedRemovalReason(join(root, "tests", ".tmp-guard-fixture")) === null, + checkout: protectedRemovalReason(root) !== null, + parent: protectedRemovalReason(dirname(root)) !== null, + sibling: protectedRemovalReason(join(dirname(root), "another-worktree")) !== null, + link: protectedRemovalReason(link) !== null, + })); + } finally { + unlinkSync(link); + } + `, { OCX_REAL_HOME: realHome, OCX_TEST_HOME_GUARD: "1" }); + + expect(JSON.parse(probe.stdout.trim())).toEqual({ + fixture: true, checkout: true, parent: true, sibling: true, link: true, + }); + } finally { + unlinkSync(codexLink); + } + }); + + test("a checkout that contains a protected tree gains no exemption", async () => { + // The lift requires the checkout to sit INSIDE the tree. A checkout at the home directory, or + // the virtual root a compiled build reports, contains ~/.codex instead and must stay guarded. + const probeId = beginProbe("15-checkout-contains-tree"); + const probe = await runProbe(probeId, ` + import { join } from "node:path"; + import { protectedRemovalReason } from "${REPO_ROOT_URL}src/lib/test-home-guard"; + const root = ${JSON.stringify(REPO_ROOT)}; + console.log(JSON.stringify({ + codex: protectedRemovalReason(join(root, ".codex", "sessions")) !== null, + opencodex: protectedRemovalReason(join(root, ".opencodex", "config.json")) !== null, + })); + `, { OCX_REAL_HOME: REPO_ROOT, OCX_TEST_HOME_GUARD: "1" }); + + expect(JSON.parse(probe.stdout.trim())).toEqual({ codex: true, opencodex: true }); + }); + test("removeTreeWithRetry refuses a protected tree before it calls through", () => { const attempted: string[] = []; expect(() => removeTreeWithRetry(protectedHomeForTests(), { remove: path => { attempted.push(path); } })) diff --git a/tests/ci-workflows/test-runner.test.ts b/tests/ci-workflows/test-runner.test.ts index 37f208c6b3e..db18e231f3b 100644 --- a/tests/ci-workflows/test-runner.test.ts +++ b/tests/ci-workflows/test-runner.test.ts @@ -575,6 +575,14 @@ describe("bun test argv", () => { } }); + test("server admission fixtures finish in a dedicated process", () => { + const plan = resolveBunTestPlan([]); + expect(plan[0]?.args).toContain("**/active-registry-admission.test.ts"); + expect(plan.find(lane => lane.label === "active-registry-admission.test.ts")?.args).toEqual([ + "--isolate", "--parallel=1", "./tests/codex-integration/active-registry-admission.test.ts", + ]); + }); + test("serial lanes override caller parallelism without changing the main lane", () => { const plan = resolveBunTestPlan(["--parallel=2", "--only-failures"]); expect(plan[0]?.args).toContain("--parallel=2"); diff --git a/tests/claude-integration/claude-picker-recovery.test.ts b/tests/claude-integration/claude-picker-recovery.test.ts index 4172b6acd9e..7534a424556 100644 --- a/tests/claude-integration/claude-picker-recovery.test.ts +++ b/tests/claude-integration/claude-picker-recovery.test.ts @@ -1,7 +1,6 @@ // INV-PICKER-02: the outgoing public picker CA survives process replacement until a confirmed untrust clears it. import { expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -15,21 +14,17 @@ import type { PickerRuntime } from "../../src/claude/intercept/picker-runtime"; import type { OcxConfig } from "../../src/types"; const runtimeUrl = pathToFileURL(join(import.meta.dir, "../../src/claude/intercept/runtime.ts")).href; +const proxyUrl = pathToFileURL(join(import.meta.dir, "../../src/claude/intercept/connect-proxy.ts")).href; const caUrl = pathToFileURL(join(import.meta.dir, "../../src/claude/intercept/picker-ca.ts")).href; -async function freePort(): Promise { - const server = createServer(); - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (!address || typeof address === "string") throw new Error("missing port"); - await new Promise(resolve => server.close(() => resolve())); - return address.port; -} +// Logical configured ports are asserted separately from kernel-owned allocations. +const REQUESTED_PROXY_PORT = 10234; function replacement(root: string, port: number, fail: boolean, fingerprints: string[]) { const source = ` import { readFileSync } from "node:fs"; import { startClaudeIntercept, getClaudePickerRuntime } from ${JSON.stringify(runtimeUrl)}; + import { startConnectProxy } from ${JSON.stringify(proxyUrl)}; import { pickerCaFingerprints } from ${JSON.stringify(caUrl)}; const root = ${JSON.stringify(root)}; const trusted = new Set(${JSON.stringify(fingerprints)}); @@ -45,6 +40,7 @@ function replacement(root: string, port: number, fail: boolean, fingerprints: st if (args[0] === "delete-certificate" && !${fail}) trusted.delete(args[2]); return { code: ${fail} && args[0] === "delete-certificate" ? 1 : 0, stdout: "", stderr: "" }; }; + const requestedPorts = []; let created = false; const handle = await startClaudeIntercept({ config: { port: 10100, providers: {}, defaultProvider: "openai", claudeCode: { intercept: { port: ${port} } } }, @@ -52,8 +48,14 @@ function replacement(root: string, port: number, fail: boolean, fingerprints: st loadPickerRoutes: async () => ({ nativeSlugs: [], routedModels: [] }), createPicker: () => { created = true; return { selectTunnel: () => ({ kind: "blind" }), start: async () => {}, stop: async () => {} }; }, pickerSecurity: security, pickerPlatform: "darwin", + // Bind real proxies on port 0; probing and releasing a port does not reserve its neighbour. + startProxy: async (requestedPort, options) => { + requestedPorts.push(requestedPort); + return startConnectProxy(0, options); + }, }); - const result = { created, active: getClaudePickerRuntime() !== null, attempts, pickerPort: handle?.pickerProxyPort ?? null }; + const result = { created, active: getClaudePickerRuntime() !== null, attempts, requestedPorts, + pickerPort: handle?.pickerProxyPort ?? null, proxyPort: handle?.proxyPort ?? null, listenerPort: handle?.listener.port ?? null }; await handle?.stop(); process.stdout.write(JSON.stringify(result)); `; @@ -64,18 +66,21 @@ function replacement(root: string, port: number, fail: boolean, fingerprints: st stdout: "pipe", stderr: "pipe", }); expect(child.exitCode, child.stderr.toString()).toBe(0); - return JSON.parse(child.stdout.toString()) as { created: boolean; active: boolean; attempts: string[]; pickerPort: number | null }; + return JSON.parse(child.stdout.toString()) as { created: boolean; active: boolean; attempts: string[]; requestedPorts: number[]; + pickerPort: number | null; proxyPort: number | null; listenerPort: number | null }; } test("a replacement process retries the recorded predecessor before rotating and arming", { timeout: 30_000 }, async () => { const root = mkdtempSync(join(tmpdir(), "ocx-picker-recovery-")); + // Keep the logical picker port occupied to prove the child owns separate real allocations. + const occupied = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("occupied") }); try { mkdirSync(join(root, "codex")); const foreign = createCertificateAuthority({ commonName: PICKER_CA_COMMON_NAME, permittedDnsNames: [PICKER_HOST] }); const stateDir = join(root, "claude-picker"); await Bun.write(join(stateDir, "ca.pem"), foreign.certPem); const foreignSha1 = pickerCaFingerprints(foreign.certPem).sha1; - const port = await freePort(); + const port = occupied.port - 1; const first = replacement(root, port, true, [foreignSha1]); expect(first).toMatchObject({ created: false, active: false, attempts: [foreignSha1] }); expect(readPendingPickerCaUntrust(root)?.sha1).toBe(foreignSha1); @@ -83,9 +88,13 @@ test("a replacement process retries the recorded predecessor before rotating and expect(firstProcessSha1).not.toBe(foreignSha1); const second = replacement(root, port, false, [foreignSha1, firstProcessSha1]); expect(second).toMatchObject({ created: true, active: true, attempts: [foreignSha1, firstProcessSha1] }); - expect(second.pickerPort).toBe(port + 1); + expect(second.requestedPorts).toEqual([port, port + 1]); + const boundPorts = [second.listenerPort, second.proxyPort, second.pickerPort]; + expect(boundPorts.every(value => typeof value === "number" && Number.isInteger(value) && value > 0)).toBe(true); + expect(new Set(boundPorts).size).toBe(boundPorts.length); expect(readPendingPickerCaUntrust(root)).toBeNull(); } finally { + occupied.stop(true); rmSync(root, { recursive: true, force: true }); } }); @@ -125,7 +134,7 @@ test("replacement defers a pending certificate still published by a live owner", const ca = ensurePickerCa(root); const pending = { certPem: ca.certPem, ...pickerCaFingerprints(ca.certPem) }; writeFileSync(pickerCaPendingUntrustPath(root), JSON.stringify(pending)); - const port = await freePort(); + const port = REQUESTED_PROXY_PORT; const peer = replacement(root, port, false, [pending.sha1]); expect(peer).toMatchObject({ created: false, active: false, attempts: [] }); expect(readPendingPickerCaUntrust(root)).toEqual(pending); diff --git a/tests/codex-integration/active-registry-admission.test.ts b/tests/codex-integration/active-registry-admission.test.ts index 16b375b9872..17b6f28ed03 100644 --- a/tests/codex-integration/active-registry-admission.test.ts +++ b/tests/codex-integration/active-registry-admission.test.ts @@ -143,7 +143,7 @@ describe("active registry admission", () => { } finally { settle?.(); await server.stop(true); - upstream.stop(true); + await upstream.stop(true); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; removeTreeWithRetry(home); diff --git a/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts b/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts index 38953a32854..5eccb998edb 100644 --- a/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts +++ b/tests/codex-integration/catalog-auto-refresh-scheduler.test.ts @@ -16,6 +16,9 @@ import { } from "../../src/codex/catalog-auto-refresh"; import { lastCatalogAutoRefreshOutcome, resetCatalogAutoRefreshStatusForTests } from "../../src/codex/catalog-refresh-status"; import type { CatalogOnlyOutcome } from "../../src/codex/convergence-types"; +import * as bundled from "../../src/codex/catalog/bundled"; +import * as entitlements from "../../src/codex/model-entitlements"; +import * as appServerProcesses from "../../src/codex/app-server-processes"; import * as managementConvergence from "../../src/codex/management-convergence"; import { DEFAULT_CATALOG_PATH } from "../../src/codex/paths"; import { @@ -44,6 +47,7 @@ let openCodexHome = ""; let isolatedCodexHome: IsolatedCodexHome | null = null; let convergeFactoryCalls = 0; let convergeImpl: (config: OcxConfig) => Promise = async () => COMMITTED_CATALOG_ONLY; +let sourceSpies: Array<{ mockRestore(): void }> = []; let convergeSpy: { mockRestore(): void } | null = null; let releaseHanging: ((outcome: CatalogOnlyOutcome) => void) | null = null; let pendingTick: Promise | null = null; @@ -71,6 +75,14 @@ beforeEach(() => { writeFileSync(DEFAULT_CATALOG_PATH, JSON.stringify({ models: [] }), "utf8"); resetCatalogAutoRefreshForTests(); resetCatalogAutoRefreshStatusForTests(); + sourceSpies = [ + spyOn(bundled, "loadBundledCodexCatalog").mockReturnValue(null), + spyOn(entitlements, "ensureCodexEntitlementFreshness").mockResolvedValue(undefined), + spyOn(entitlements, "discoverCodexNativeRoster").mockResolvedValue("unavailable"), + spyOn(appServerProcesses, "listCodexAppServerProcesses").mockReturnValue([]), + spyOn(appServerProcesses, "collectCodexAppServerCatalogStateWithin") + .mockResolvedValue({ state: "not_running", processes: [], catalogMtimeMs: null }), + ]; convergeFactoryCalls = 0; convergeImpl = async () => COMMITTED_CATALOG_ONLY; releaseHanging = null; @@ -93,6 +105,8 @@ afterEach(async () => { stopCatalogAutoRefresh(); resetCatalogAutoRefreshForTests(); resetCatalogAutoRefreshStatusForTests(); + for (const spy of sourceSpies) spy.mockRestore(); + sourceSpies = []; convergeSpy?.mockRestore(); convergeSpy = null; isolatedCodexHome?.restore(); @@ -153,17 +167,157 @@ describe("catalog auto-refresh scheduler", () => { expect(catalogAutoRefreshTickCountForTests()).toBe(0); }); - test("a tick with catalogAutoRefresh absent or enabled:false performs no converge", async () => { + test("the unref'd startup tick fires once, survives cadence changes, and stop cancels it", async () => { + writeCatalogAutoRefreshConfig({ intervalMinutes: 30 }); + const delayed: Array<{ callback: () => unknown; unrefs: number }> = []; + const original = globalThis.setTimeout; + const set = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => unknown, delay?: number) => { + if (delay !== 3 * 60_000) return original(callback, delay); + const handle = { callback, unrefs: 0, unref() { this.unrefs += 1; return this; } }; + delayed.push(handle); + return handle; + }) as typeof setTimeout); + const clear = spyOn(globalThis, "clearTimeout").mockImplementation(() => {}); + try { + startCatalogAutoRefresh(); + startCatalogAutoRefresh(); + expect(delayed).toHaveLength(1); + expect(delayed[0]!.unrefs).toBe(1); + await delayed[0]!.callback(); + expect(convergeFactoryCalls).toBe(1); + expect(catalogAutoRefreshIntervalForTests()).toBe(30 * 60_000); + expect(delayed).toHaveLength(1); + stopCatalogAutoRefresh(); + startCatalogAutoRefresh(); + stopCatalogAutoRefresh(); + expect(clear).toHaveBeenCalledWith(delayed[1]); + // Even an already queued callback loses publication authority after stop. + await delayed[1]!.callback(); + expect(convergeFactoryCalls).toBe(1); + } finally { + stopCatalogAutoRefresh(); + set.mockRestore(); + clear.mockRestore(); + } + }); + + test.each(["none", "bundled", "roster", "discovery"])("sources settle before converge despite %s failure", async failure => { writeCatalogAutoRefreshConfig(); + const steps: string[] = []; + spyOn(bundled, "loadBundledCodexCatalog").mockImplementation(() => { + steps.push("bundled"); + if (failure === "bundled") throw new Error("private source failure"); + return null; + }); + spyOn(entitlements, "ensureCodexEntitlementFreshness").mockImplementation(async (_config, options) => { + steps.push("roster"); + expect(options?.waitMs).toBe(15_000); + if (failure === "roster") throw new Error("private roster failure"); + }); + spyOn(entitlements, "discoverCodexNativeRoster").mockImplementation(async () => { + steps.push("discovery"); + if (failure === "discovery") throw new Error("private discovery failure"); + return "recorded"; + }); + convergeImpl = async () => { steps.push("converge"); return COMMITTED_CATALOG_ONLY; }; await runCatalogAutoRefreshTickForTests(); - expect(catalogAutoRefreshTickCountForTests()).toBe(0); + expect(steps).toEqual(["bundled", "roster", "discovery", "converge"]); + expect(lastCatalogAutoRefreshOutcome()?.disposition.status).toBe("committed"); + }); + + test("stopping during source refresh prevents roster warm and convergence", async () => { + writeCatalogAutoRefreshConfig(); + spyOn(bundled, "loadBundledCodexCatalog").mockImplementation(() => { + stopCatalogAutoRefresh(); + return null; + }); + await runCatalogAutoRefreshTickForTests(); + expect(entitlements.ensureCodexEntitlementFreshness).not.toHaveBeenCalled(); + expect(entitlements.discoverCodexNativeRoster).not.toHaveBeenCalled(); expect(convergeFactoryCalls).toBe(0); expect(lastCatalogAutoRefreshOutcome()).toBeNull(); + }); + test("a roster wait that exceeds its bound still allows convergence", async () => { + writeCatalogAutoRefreshConfig(); + let entered!: () => void; + const rosterEntered = new Promise(resolve => { entered = resolve; }); + spyOn(entitlements, "ensureCodexEntitlementFreshness").mockImplementation(() => { + entered(); + return new Promise(() => {}); + }); + const deadlines: Array<() => void> = []; + const original = globalThis.setTimeout; + const timeout = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void, delay?: number) => { + if (delay !== 15_000) return original(callback, delay); + deadlines.push(callback); + return { unref() { return this; } }; + }) as typeof setTimeout); + const clear = spyOn(globalThis, "clearTimeout").mockImplementation(() => {}); + try { + const pending = runCatalogAutoRefreshTickForTests(); + await rosterEntered; + expect(deadlines).toHaveLength(2); + deadlines[1]!(); + await pending; + expect(convergeFactoryCalls).toBe(1); + } finally { + timeout.mockRestore(); + clear.mockRestore(); + } + }); + + test("a changed set records reloadRequired and logs one safe restart hint", async () => { + writeCatalogAutoRefreshConfig(); + spyOn(appServerProcesses, "listCodexAppServerProcesses").mockReturnValue([{ pid: 123, commandLine: "private fixture" }]); + spyOn(appServerProcesses, "collectCodexAppServerCatalogStateWithin").mockResolvedValue({ + state: "stale", processes: [{ pid: 123, startedAtMs: 1 }], + catalogMtimeMs: 2, + }); + convergeImpl = async () => ({ + kind: "catalog-only", changed: true, + catalogRefresh: { status: "committed", changed: true, degraded: false, notices: [] }, + }); + const info = spyOn(console, "info").mockImplementation(() => {}); + try { + await runCatalogAutoRefreshTickForTests(); + expect(lastCatalogAutoRefreshOutcome()?.reloadRequired).toBe(true); + expect(info.mock.calls).toEqual([[ + "[catalog-auto-refresh] served model set changed; running Codex sessions keep the old list until restarted (ocx sync --restart-codex)", + ]]); + convergeImpl = async () => COMMITTED_CATALOG_ONLY; + await runCatalogAutoRefreshTickForTests(); + expect(lastCatalogAutoRefreshOutcome()?.reloadRequired).toBe(true); + expect(info).toHaveBeenCalledTimes(1); + spyOn(appServerProcesses, "collectCodexAppServerCatalogStateWithin").mockResolvedValue({ + state: "unknown", processes: [], catalogMtimeMs: null, + }); + await runCatalogAutoRefreshTickForTests(); + expect(lastCatalogAutoRefreshOutcome()?.reloadRequired).toBe(true); + spyOn(appServerProcesses, "collectCodexAppServerCatalogStateWithin").mockResolvedValue({ + state: "fresh", processes: [{ pid: 124, startedAtMs: 3 }], + catalogMtimeMs: 2, + }); + await runCatalogAutoRefreshTickForTests(); + expect(lastCatalogAutoRefreshOutcome()?.reloadRequired).toBe(false); + } finally { info.mockRestore(); } + }); + + test("a tick with catalogAutoRefresh absent performs a converge", async () => { + writeCatalogAutoRefreshConfig(); + await runCatalogAutoRefreshTickForTests(); + expect(catalogAutoRefreshTickCountForTests()).toBe(1); + expect(convergeFactoryCalls).toBe(1); + expect(lastCatalogAutoRefreshOutcome()?.disposition.status).toBe("committed"); + }); + + test("explicit enabled:false performs no converge", async () => { writeCatalogAutoRefreshConfig({ enabled: false, intervalMinutes: 60 }); await runCatalogAutoRefreshTickForTests(); expect(catalogAutoRefreshTickCountForTests()).toBe(0); expect(convergeFactoryCalls).toBe(0); + expect(bundled.loadBundledCodexCatalog).not.toHaveBeenCalled(); + expect(entitlements.ensureCodexEntitlementFreshness).not.toHaveBeenCalled(); expect(lastCatalogAutoRefreshOutcome()).toBeNull(); }); @@ -554,6 +708,8 @@ describe("catalog auto-refresh drift heal", () => { const fs = require("node:fs"); const path = require("node:path"); const configModule = require(${JSON.stringify(configPath)}); + const sources = require(${JSON.stringify(fileURLToPath(new URL("../../src/codex/catalog-auto-refresh-sources.ts", import.meta.url)))}); + spyOn(sources, "refreshCatalogAutoRefreshSources").mockResolvedValue(undefined); const scheduler = require(${JSON.stringify(schedulerPath)}); const drift = require(${JSON.stringify(driftPath)}); const desired = require(${JSON.stringify(desiredPath)}); @@ -606,6 +762,8 @@ describe("catalog auto-refresh drift heal", () => { const fs = require("node:fs"); const path = require("node:path"); const config = require(${JSON.stringify(source("config.ts"))}); + const sources = require(${JSON.stringify(fileURLToPath(new URL("../../src/codex/catalog-auto-refresh-sources.ts", import.meta.url)))}); + spyOn(sources, "refreshCatalogAutoRefreshSources").mockResolvedValue(undefined); const scheduler = require(${JSON.stringify(source("codex/catalog-auto-refresh.ts"))}); const drift = require(${JSON.stringify(source("codex/config-drift-heal.ts"))}); const desired = require(${JSON.stringify(source("codex/desired-state.ts"))}); @@ -669,3 +827,35 @@ describe("catalog auto-refresh drift heal", () => { expect(selectDriftHealCatalogPath(journalPath, defaultPath, path => join(openCodexHome, path))).toBeNull(); }); }); + +describe("catalog auto-refresh without a managed Codex client", () => { + function writeIntegrationOffConfig(catalogAutoRefresh?: unknown): void { + const config = { + ...getDefaultConfig(), + defaultProvider: "xai", + providers: { xai: { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1" } }, + clientIntegrations: { codex: false }, + ...(catalogAutoRefresh === undefined ? {} : { catalogAutoRefresh }), + }; + writeFileSync(getConfigPath(), JSON.stringify(config), "utf8"); + } + + test("an absent section stays dormant: no Codex sources and no converge", async () => { + writeIntegrationOffConfig(); + await runCatalogAutoRefreshTickForTests(); + expect(convergeFactoryCalls).toBe(0); + expect(bundled.loadBundledCodexCatalog).not.toHaveBeenCalled(); + expect(entitlements.ensureCodexEntitlementFreshness).not.toHaveBeenCalled(); + expect(entitlements.discoverCodexNativeRoster).not.toHaveBeenCalled(); + expect(catalogAutoRefreshTickCountForTests()).toBe(0); + }); + + test("an explicit enabled:true still converges but never reads Codex sources", async () => { + writeIntegrationOffConfig({ enabled: true, intervalMinutes: 60 }); + await runCatalogAutoRefreshTickForTests(); + expect(convergeFactoryCalls).toBe(1); + expect(bundled.loadBundledCodexCatalog).not.toHaveBeenCalled(); + expect(entitlements.ensureCodexEntitlementFreshness).not.toHaveBeenCalled(); + expect(entitlements.discoverCodexNativeRoster).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/codex-integration/codex-catalog-refresh-status.test.ts b/tests/codex-integration/codex-catalog-refresh-status.test.ts index 5a728b05b8e..d9a1a910937 100644 --- a/tests/codex-integration/codex-catalog-refresh-status.test.ts +++ b/tests/codex-integration/codex-catalog-refresh-status.test.ts @@ -12,6 +12,15 @@ afterEach(() => { resetCatalogAutoRefreshStatusForTests(); }); +test("reloadRequired is exposed as a frozen boolean without copying caller details", () => { + const committed: CatalogDisposition = { status: "committed", changed: true, degraded: false, notices: [] }; + expect(recordCatalogAutoRefreshOutcome(committed, true)?.reloadRequired).toBe(false); + expect(recordCatalogAutoRefreshOutcome(committed, true, true)?.reloadRequired).toBe(true); + expect(lastCatalogAutoRefreshOutcome()?.reloadRequired).toBe(true); + expect(Object.isFrozen(lastCatalogAutoRefreshOutcome())).toBe(true); + expect(recordCatalogAutoRefreshOutcome(committed, true, "private detail" as never)?.reloadRequired).toBe(false); +}); + describe("catalogRefreshIsPending", () => { test("only committed catalog state is complete", () => { const committed: CatalogDisposition = { diff --git a/tests/codex-integration/codex-catalog-restore.test.ts b/tests/codex-integration/codex-catalog-restore.test.ts index f4931d9d880..1b45b215543 100644 --- a/tests/codex-integration/codex-catalog-restore.test.ts +++ b/tests/codex-integration/codex-catalog-restore.test.ts @@ -1,11 +1,14 @@ // Holds INV-RESTORE-01 from structure/overview.md; keep the id here if this file is split or renamed. import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { existsSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { restoreCodexCatalog } from "../../src/codex/catalog"; +import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; +import { flushWindowsSecretAclReapsBeforeRemoval } from "../../src/lib/windows-secret-acl"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const repoRoot = dirname(fileURLToPath(new URL("../../package.json", import.meta.url))); @@ -29,15 +32,39 @@ function runScript(codexHome: string, opencodexHome: string, script: string): { describe("Codex catalog restore", () => { let codexHome: string; let opencodexHome: string; + let previousCodexHome: string | undefined; + let previousOpencodexHome: string | undefined; + let catalogDatabasePath: string; beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; codexHome = mkdtempSync(join(tmpdir(), "ocx-catalog-home-")); opencodexHome = mkdtempSync(join(tmpdir(), "ocx-catalog-ocx-")); - }); - - afterEach(() => { - if (existsSync(codexHome)) removeTreeWithRetry(codexHome); - if (existsSync(opencodexHome)) removeTreeWithRetry(opencodexHome); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + // Cold Windows namespace discovery has two bounded 30s PowerShell lookups. + // Leave 5s for filesystem work beyond the two lookup envelopes. + catalogDatabasePath = resolveCodexCatalogSerializationDatabasePath( + resolveEffectiveUserIdentity(), realpathSync.native(codexHome), + ); + }, 65_000); + + afterEach(async () => { + try { + await flushWindowsSecretAclReapsBeforeRemoval(codexHome); + await flushWindowsSecretAclReapsBeforeRemoval(opencodexHome); + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + rmSync(`${catalogDatabasePath}${suffix}`, { force: true }); + } + if (existsSync(codexHome)) removeTreeWithRetry(codexHome); + if (existsSync(opencodexHome)) removeTreeWithRetry(opencodexHome); + } finally { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + } }); test("version-1 process journals with injected hashes restore, while matching client ownership is durable", () => { @@ -84,8 +111,8 @@ describe("Codex catalog restore", () => { expect(existsSync(journalPath)).toBe(true); }); - // spawnSync(bun --eval) under `bun test --isolate` on Windows can exceed the - // default 5s case budget when the runner is under load (seen at ~5.4s on GHA). + // Restore is a filesystem contract: reuse this process's Windows identity cache + // instead of cold-starting Bun, PowerShell and ACL setup for every case. test("drops routed entries without overwriting user-added native entries", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); @@ -97,14 +124,8 @@ describe("Codex catalog restore", () => { ], }, null, 2) + "\n"); - const r = runScript(codexHome, opencodexHome, ` - const { restoreCodexCatalog } = require("./src/codex/catalog"); - const result = restoreCodexCatalog(); - console.log(JSON.stringify(result)); - `); - - expect(r.status).toBe(0); - expect(JSON.parse(r.stdout)).toMatchObject({ removed: 1, kept: 2 }); + const result = restoreCodexCatalog(); + expect(result).toMatchObject({ removed: 1, kept: 2 }); const slugs = JSON.parse(readFileSync(catalogPath, "utf8")).models.map((m: { slug: string }) => m.slug); expect(slugs).toEqual(["gpt-5.5", "user-native"]); }, { timeout: 15_000 }); @@ -128,14 +149,8 @@ describe("Codex catalog restore", () => { writeFileSync(catalogPath, JSON.stringify({ models: [ ...bare, { ...native, slug: "gpt-future-native" }, ...qualified, ] })); - const r = runScript(codexHome, opencodexHome, ` - const { restoreCodexCatalog } = require("./src/codex/catalog"); - const first = restoreCodexCatalog(); - const second = restoreCodexCatalog(); - console.log(JSON.stringify({ first, second })); - `); - expect(r.status).toBe(0); - expect(JSON.parse(r.stdout)).toMatchObject({ first: { removed: 6, kept: 2 }, second: { removed: 0, kept: 2 } }); + const result = { first: restoreCodexCatalog(), second: restoreCodexCatalog() }; + expect(result).toMatchObject({ first: { removed: 6, kept: 2 }, second: { removed: 0, kept: 2 } }); const rows = JSON.parse(readFileSync(catalogPath, "utf8")).models; expect(rows).toEqual([native, { ...native, slug: "gpt-future-native" }]); }, { timeout: 15_000 }); @@ -171,16 +186,9 @@ describe("Codex catalog restore", () => { ], }, null, 2) + "\n"); - const r = runScript(codexHome, opencodexHome, ` - const { restoreCodexCatalog } = require("./src/codex/catalog"); - const first = restoreCodexCatalog(); - const second = restoreCodexCatalog(); - console.log(JSON.stringify({ first, second })); - `); - - expect(r.status).toBe(0); + const result = { first: restoreCodexCatalog(), second: restoreCodexCatalog() }; const resolvedCatalogPath = join(realpathSync.native(codexHome), "catalog.json"); - expect(JSON.parse(r.stdout)).toEqual({ + expect(result).toEqual({ first: { removed: 4, kept: 4, path: resolvedCatalogPath }, second: { removed: 0, kept: 4, path: resolvedCatalogPath }, }); @@ -211,13 +219,8 @@ describe("Codex catalog restore", () => { ], }, null, 2) + "\n"); - const r = runScript(codexHome, opencodexHome, ` - const { restoreCodexCatalog } = require("./src/codex/catalog"); - console.log(JSON.stringify(restoreCodexCatalog())); - `); - - expect(r.status).toBe(0); - expect(JSON.parse(r.stdout)).toMatchObject({ removed: 1, kept: 1 }); + const result = restoreCodexCatalog(); + expect(result).toMatchObject({ removed: 1, kept: 1 }); expect(JSON.parse(readFileSync(catalogPath, "utf8")).models).toEqual([ { slug: "gpt-5.5", visibility: "hide" }, ]); @@ -259,13 +262,8 @@ describe("Codex catalog restore", () => { ], }, null, 2) + "\n"); - const r = runScript(codexHome, opencodexHome, ` - const { restoreCodexCatalog } = require("./src/codex/catalog"); - console.log(JSON.stringify(restoreCodexCatalog())); - `); - - expect(r.status).toBe(0); - expect(JSON.parse(r.stdout)).toMatchObject({ removed: 4, kept: 3 }); + const result = restoreCodexCatalog(); + expect(result).toMatchObject({ removed: 4, kept: 3 }); const restored = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array>; expect(restored).toEqual([ { slug: "gpt-5.6-luna", visibility: "hide", priority: 50 }, @@ -293,14 +291,8 @@ describe("Codex catalog restore", () => { ], }, null, 2) + "\n"); - const r = runScript(codexHome, opencodexHome, ` - const { restoreCodexCatalog } = require("./src/codex/catalog"); - const result = restoreCodexCatalog(); - console.log(JSON.stringify(result)); - `); - - expect(r.status).toBe(0); - expect(JSON.parse(r.stdout)).toMatchObject({ removed: 1, kept: 3 }); + const result = restoreCodexCatalog(); + expect(result).toMatchObject({ removed: 1, kept: 3 }); const restored = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array>; expect(restored).toEqual([ { slug: "gpt-5.5", priority: 50 }, @@ -323,14 +315,8 @@ describe("Codex catalog restore", () => { ], }, null, 2) + "\n"); - const r = runScript(codexHome, opencodexHome, ` - const { restoreCodexCatalog } = require("./src/codex/catalog"); - const result = restoreCodexCatalog(); - console.log(JSON.stringify(result)); - `); - - expect(r.status).toBe(0); - expect(JSON.parse(r.stdout)).toMatchObject({ removed: 1, kept: 2 }); + const result = restoreCodexCatalog(); + expect(result).toMatchObject({ removed: 1, kept: 2 }); const restored = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array>; expect(restored.map(m => m.slug)).toEqual(["gpt-5.5", "user-native"]); }, { timeout: 15_000 }); diff --git a/tests/codex-integration/codex-credits-probes.test.ts b/tests/codex-integration/codex-credits-probes.test.ts new file mode 100644 index 00000000000..c262a125ac9 --- /dev/null +++ b/tests/codex-integration/codex-credits-probes.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { capturePoolQuotaWriter, poolQuotaHistoryIdentity, readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { codexCreditsFor, rememberCodexCredits, resetCodexCreditsForTests } from "../../src/codex/credits"; +import { listCodexAuthAccounts, poolAccountDto } from "../../src/codex/auth-api/account-list"; +import { fetchMainAccountInfoSnapshot } from "../../src/codex/auth-api/main-account-probe"; +import { commitPoolQuotaResponse } from "../../src/codex/auth-api/pool-quota-probe"; +import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { clearAccountQuota } from "../../src/codex/quota"; +import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; +import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; +import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import type { CodexAccount, OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let testDir: string; +let previousHome: string | undefined; +let previousCodexHome: string | undefined; +let originalFetch: typeof fetch; +const account: CodexAccount = { id: "credits-pool", email: "credits@example.test", isMain: false }; +const config = (): OcxConfig => ({ port: 10100, defaultProvider: "openai", providers: {}, codexAccounts: [] }); + +function writeMain(bearer = "fixture-main-bearer", identity = "fixture-main-identity") { + writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ + tokens: { access_token: bearer, account_id: identity }, + })); +} +function poolContext() { + const record = readCodexAccountRecord(account.id)!; + const credential = record.credential!; + return { accountId: account.id, existing: null, configuredPlan: undefined, + generation: record.generation, writerGeneration: captureConfigGeneration(), + poolWriter: capturePoolQuotaWriter(account.id, { ...credential, generation: record.generation }), + }; +} +function savePool(identity = "fixture-pool-identity") { + saveCodexAccountCredential(account.id, { + accessToken: "fixture-pool-bearer", refreshToken: "fixture-pool-refresh", + chatgptAccountId: identity, expiresAt: Date.now() + 3_600_000, + }); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + originalFetch = globalThis.fetch; + testDir = mkdtempSync(join(tmpdir(), "ocx-credits-probes-")); + process.env.OPENCODEX_HOME = testDir; + process.env.CODEX_HOME = join(testDir, "codex"); + mkdirSync(process.env.CODEX_HOME, { recursive: true }); + globalThis.fetch = (async () => { throw new Error("unexpected fixture network request"); }) as typeof fetch; + clearMainAccountInfoCache(); + clearAccountQuota(); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + resetCodexCreditsForTests(); + resetQuotaQueryBackoffForTests(); +}); +afterEach(() => { + clearMainAccountInfoCache(); + clearAccountQuota(); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + resetCodexCreditsForTests(); + resetQuotaQueryBackoffForTests(); + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(testDir); +}); + +describe("main credits publication", () => { + test("current credential publishes credits-only usage, omission keeps, null clears", async () => { + writeMain(); + for (const payload of [{ credits: { balance: "125.725" } }, {}, { credits: null }]) { + globalThis.fetch = (async () => Response.json(payload)) as typeof fetch; + await fetchMainAccountInfoSnapshot(true, config()); + expect(codexCreditsFor("__main__", "fixture-main-identity")) + .toEqual("credits" in payload && payload.credits === null ? undefined : { balance: "125.725" }); + } + }); + test("a response from a replaced bearer never publishes credits", async () => { + writeMain(); + globalThis.fetch = (async () => { + writeMain("fixture-replacement-bearer"); + return Response.json({ credits: { balance: "999" } }); + }) as typeof fetch; + await fetchMainAccountInfoSnapshot(true, config()); + expect(codexCreditsFor("__main__", "fixture-main-identity")).toBeUndefined(); + }); + test("main account DTO is gated, identity-bound and pruning removes retired pool observations", async () => { + writeMain(); + globalThis.fetch = (async () => Response.json({ credits: { balance: "24.5" } })) as typeof fetch; + const cfg = config(); + const off = await listCodexAuthAccounts(cfg, true); + expect(off[0].credits).toBeUndefined(); + cfg.showCodexCredits = true; + rememberCodexCredits("removed-pool", "removed-identity", { balance: "11" }); + const on = await listCodexAuthAccounts(cfg); + expect(on[0].credits).toEqual({ balance: "24.5" }); + expect(codexCreditsFor("removed-pool", "removed-identity")).toBeUndefined(); + writeMain("fixture-new-bearer", "fixture-new-identity"); + globalThis.fetch = (async () => Response.json({})) as typeof fetch; + expect((await listCodexAuthAccounts(cfg, true))[0].credits).toBeUndefined(); + }); +}); + +describe("pool credits publication", () => { + test("credits-only response publishes without quota and DTO follows the switch", async () => { + savePool(); + const ctx = poolContext(); + const result = await commitPoolQuotaResponse(Response.json({ credits: { balance: "7.125" } }), ctx); + expect(result.quota).toBeNull(); + expect(ctx.poolWriter).toBeDefined(); + const cfg = config(); + expect(poolAccountDto(cfg, account, result, true, false, 0, false).credits).toBeUndefined(); + cfg.showCodexCredits = true; + expect(poolAccountDto(cfg, account, result, true, false, 0, false).credits).toEqual({ balance: "7.125" }); + await commitPoolQuotaResponse(Response.json({}), ctx); + expect(codexCreditsFor(account.id, poolQuotaHistoryIdentity(account.id)!)).toEqual({ balance: "7.125" }); + await commitPoolQuotaResponse(Response.json({ credits: null }), ctx); + expect(codexCreditsFor(account.id, poolQuotaHistoryIdentity(account.id)!)).toBeUndefined(); + }); + test("same pool id with a replaced identity hides previous credits and rejects a dead generation", async () => { + savePool(); + const old = poolContext(); + await commitPoolQuotaResponse(Response.json({ credits: { balance: "5" } }), old); + savePool("fixture-other-pool-identity"); + expect(poolQuotaHistoryIdentity(account.id)).not.toBe(old.poolWriter!.historyIdentity); + const dto = poolAccountDto({ ...config(), showCodexCredits: true }, account, + { quota: null, needsReauth: false }, true, false, 0, false); + expect(dto.credits).toBeUndefined(); + await commitPoolQuotaResponse(Response.json({ credits: { balance: "9" } }), old); + expect(codexCreditsFor(account.id, old.poolWriter!.historyIdentity)).toBeUndefined(); + }); + test("a superseded request and a request without a captured writer cannot publish or clear credits", async () => { + savePool(); + const ctx = poolContext(); + await commitPoolQuotaResponse(Response.json({ credits: { balance: "5" } }), ctx); + await commitPoolQuotaResponse(Response.json({ credits: null }), { ...ctx, mayPublish: () => false }); + await commitPoolQuotaResponse(Response.json({ credits: { balance: "99" } }), { ...ctx, poolWriter: undefined }); + expect(codexCreditsFor(account.id, ctx.poolWriter!.historyIdentity)).toEqual({ balance: "5" }); + }); + test("with the switch on, the listing bypasses a fresh quota cache once per identity", async () => { + savePool(); + writeMain(); + const resetAt = Math.floor(Date.now() / 1000) + 3_600; + const usage = { rate_limit: { primary_window: { used_percent: 10, limit_window_seconds: 18_000, reset_at: resetAt }, + secondary_window: { used_percent: 20, limit_window_seconds: 604_800, reset_at: resetAt } } }; + // A restart keeps the disk-hydrated quota but loses the process-local credits. + await commitPoolQuotaResponse(Response.json(usage), poolContext()); + resetCodexCreditsForTests(); + let poolReads = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const auth = new Headers(init?.headers).get("authorization") ?? ""; + if (auth.includes("fixture-pool-bearer")) { + poolReads += 1; + return Response.json({ ...usage, credits: { balance: "62498.725" } }); + } + return Response.json({}); + }) as typeof fetch; + const cfg: OcxConfig = { ...config(), codexAccounts: [account] }; + expect((await listCodexAuthAccounts(cfg)).find(row => row.id === account.id)?.credits).toBeUndefined(); + expect(poolReads).toBe(0); + cfg.showCodexCredits = true; + expect((await listCodexAuthAccounts(cfg)).find(row => row.id === account.id)?.credits).toEqual({ balance: "62498.725" }); + expect(poolReads).toBe(1); + await listCodexAuthAccounts(cfg); + expect(poolReads).toBe(1); + }); +}); diff --git a/tests/codex-integration/codex-credits-settings.test.ts b/tests/codex-integration/codex-credits-settings.test.ts new file mode 100644 index 00000000000..868ff51430a --- /dev/null +++ b/tests/codex-integration/codex-credits-settings.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../../src/config"; +import { validateConfigCandidate } from "../../src/config/diagnostics"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { safeConfigDTO } from "../../src/server/auth-cors"; +import { handleManagementAPI, type ManagementApiDeps } from "../../src/server/management-api"; +import { invalidateStartupHealthCache } from "../../src/server/startup-health-cache"; +import type { OcxConfig } from "../../src/types"; +import { startupHealthFixture } from "../helpers/startup-health"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let testDir: string; +let previousHome: string | undefined; +const baseConfig = (): OcxConfig => ({ port: 10100, defaultProvider: "openai", providers: { openai: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1", apiKey: "fixture-key", defaultModel: "gpt-test" } } }); + +function request(config: OcxConfig, body?: unknown, deps: Partial = {}) { + const req = new Request("http://127.0.0.1:10100/api/settings", { + method: body === undefined ? "GET" : "PUT", + headers: { host: "127.0.0.1:10100", "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + return handleManagementAPI(req, new URL(req.url), config, { + getCachedStartupHealth: async () => startupHealthFixture(), ...deps, + }); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-credits-settings-")); + process.env.OPENCODEX_HOME = testDir; + invalidateStartupHealthCache(); +}); +afterEach(() => { + invalidateStartupHealthCache(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(testDir); +}); + +describe("credits settings contract", () => { + test("GET settings and safe config default off", async () => { + const config = baseConfig(); + const response = await request(config); + expect(response!.status).toBe(200); + expect(await response!.json()).toMatchObject({ showCodexCredits: false }); + expect(safeConfigDTO(config)).toMatchObject({ showCodexCredits: false }); + }); + test("PUT true and false persist, respond, and survive config reload", async () => { + const config = baseConfig(); + saveConfig(config); + for (const enabled of [true, false]) { + const put = await request(config, { showCodexCredits: enabled }); + expect(put!.status).toBe(200); + expect(await put!.json()).toMatchObject({ ok: true, showCodexCredits: enabled }); + expect(config.showCodexCredits).toBe(enabled); + expect(loadConfig().showCodexCredits).toBe(enabled); + expect(await (await request(config))!.json()).toMatchObject({ showCodexCredits: enabled }); + expect(safeConfigDTO(config)).toMatchObject({ showCodexCredits: enabled }); + } + }); + test.each(["true", 1, null, {}, []].map(value => [value]))("PUT rejects non-boolean %j before mutation or persistence", async value => { + const config = baseConfig(); + let saves = 0; + const response = await request(config, { showCodexCredits: value }, { saveConfigPreservingClaudeCode: () => { saves++; } }); + expect(response!.status).toBe(400); + expect(await response!.json()).toMatchObject({ error: "showCodexCredits boolean is required" }); + expect(Object.hasOwn(config, "showCodexCredits")).toBe(false); + expect(saves).toBe(0); + }); + test.each([undefined, false, true])("save failure restores value and presence %s", async previous => { + const config = baseConfig(); + if (previous !== undefined) config.showCodexCredits = previous; + await expect(request(config, { showCodexCredits: previous !== true }, { + saveConfigPreservingClaudeCode: () => { throw new Error("fixture disk failure"); }, + })).rejects.toThrow("fixture disk failure"); + expect(config.showCodexCredits).toBe(previous); + expect(Object.hasOwn(config, "showCodexCredits")).toBe(previous !== undefined); + }); + test("disk loading degrades malformed boolean but config candidates reject it", () => { + expect(configSchema.parse({ ...baseConfig(), showCodexCredits: "bad" }).showCodexCredits).toBe(false); + expect(configSchema.parse(baseConfig()).showCodexCredits).toBeUndefined(); + expect(validateConfigCandidate({ ...baseConfig(), showCodexCredits: "bad" })).toEqual({ + ok: false, error: "schema_invalid: showCodexCredits: must be a boolean or omitted", + }); + expect(validateConfigCandidate({ ...baseConfig(), showCodexCredits: true }).ok).toBe(true); + }); +}); diff --git a/tests/codex-integration/codex-credits.test.ts b/tests/codex-integration/codex-credits.test.ts new file mode 100644 index 00000000000..c59a08a1d6e --- /dev/null +++ b/tests/codex-integration/codex-credits.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { codexCreditsDtoField, codexCreditsFor, hasCodexCreditsObservation, parseCodexCredits, pruneCodexCredits, rememberCodexCredits, resetCodexCreditsForTests } from "../../src/codex/credits"; + +beforeEach(resetCodexCreditsForTests); + +describe("Codex credits boundary parser", () => { + test.each(["62500", "62498.725", "0", "000.050", 125.5, 0])("preserves decimal balance %s", balance => { + expect(parseCodexCredits({ balance })).toEqual({ balance: String(balance) }); + }); + test.each([[1e-7, "0.0000001"], [1e21, "1000000000000000000000"], [62498.725, "62498.725"]] as const)( + "normalizes numeric balance %p to a plain decimal string", (balance, expected) => { + const parsed = parseCodexCredits({ balance }); + expect(parsed).toEqual({ balance: expected }); + expect(parsed?.balance).toMatch(/^\d+(\.\d+)?$/); + }); + test.each([-1, Infinity, NaN, "-1", "1e3", " 25", "25 ", "", "garbage", true, null])("drops invalid balance %s", balance => { + expect(parseCodexCredits({ balance })).toBeNull(); + }); + test("absent, null, malformed and empty observations remain distinct", () => { + expect(parseCodexCredits(undefined)).toBeUndefined(); + for (const value of [null, {}, [], "invalid", 0]) expect(parseCodexCredits(value)).toBeNull(); + }); + test("projects only typed display fields and copies ranges", () => { + expect(parseCodexCredits({ has_credits: false, unlimited: true, overage_limit_reached: false, + approx_local_messages: [0, 12.5], approx_cloud_messages: [10, 20], private: "ignored" })).toEqual({ + hasCredits: false, unlimited: true, overageLimitReached: false, + approxLocalMessages: [0, 12.5], approxCloudMessages: [10, 20], + }); + }); + test.each([[1], [1, 2, 3], [-1, 2], [1, Infinity], ["1", 2], [NaN, 2]].map(range => [range]))("drops malformed range %j", range => { + expect(parseCodexCredits({ balance: "3", approx_local_messages: range, approx_cloud_messages: range, + has_credits: "true", unlimited: 1, overage_limit_reached: null })).toEqual({ balance: "3" }); + }); +}); + +describe("identity-bound process-local credits", () => { + test("an answer without credits is an observation, distinct from never observed", () => { + expect(hasCodexCreditsObservation("pool", "identity-a")).toBe(false); + rememberCodexCredits("pool", "identity-a", undefined); + expect(hasCodexCreditsObservation("pool", "identity-a")).toBe(true); + expect(codexCreditsFor("pool", "identity-a")).toBeUndefined(); + expect(hasCodexCreditsObservation("pool", "identity-b")).toBe(false); + expect(hasCodexCreditsObservation("pool", null)).toBe(false); + rememberCodexCredits("pool", "identity-a", { balance: "2" }); + rememberCodexCredits("pool", "identity-b", undefined); + expect(codexCreditsFor("pool", "identity-b")).toBeUndefined(); + expect(hasCodexCreditsObservation("pool", "identity-b")).toBe(true); + }); + test("omission keeps, null clears, and replacement overwrites the observation", () => { + rememberCodexCredits("pool", "identity-a", { balance: "4" }); + rememberCodexCredits("pool", "identity-a", undefined); + expect(codexCreditsFor("pool", "identity-a")).toEqual({ balance: "4" }); + rememberCodexCredits("pool", "identity-a", { balance: "0" }); + expect(codexCreditsFor("pool", "identity-a")).toEqual({ balance: "0" }); + rememberCodexCredits("pool", "identity-a", null); + expect(codexCreditsFor("pool", "identity-a")).toBeUndefined(); + }); + test.each(["identity-b", null])("identity mismatch %s retires the entry permanently", identity => { + rememberCodexCredits("__main__", "identity-a", { balance: "7" }); + expect(codexCreditsFor("__main__", identity)).toBeUndefined(); + expect(codexCreditsFor("__main__", "identity-a")).toBeUndefined(); + }); + test("pruning retains only live account ids", () => { + rememberCodexCredits("live", "identity", { unlimited: true }); + rememberCodexCredits("removed", "identity", { balance: "2" }); + pruneCodexCredits(["live"]); + expect(codexCreditsFor("live", "identity")).toEqual({ unlimited: true }); + expect(codexCreditsFor("removed", "identity")).toBeUndefined(); + }); + test("DTO exposure requires an explicit opt-in and a current observation", () => { + rememberCodexCredits("pool", "identity", { balance: "12.5" }); + expect(codexCreditsDtoField({}, "pool", "identity")).toEqual({}); + expect(codexCreditsDtoField({ showCodexCredits: false }, "pool", "identity")).toEqual({}); + expect(codexCreditsDtoField({ showCodexCredits: true }, "pool", "identity")).toEqual({ credits: { balance: "12.5" } }); + expect(codexCreditsDtoField({ showCodexCredits: true }, "pool", "other")).toEqual({}); + }); + test("readers and callers cannot mutate retained observations", () => { + const credits = { balance: "1" }; + rememberCodexCredits("pool", "identity", credits); + credits.balance = "2"; + const read = codexCreditsFor("pool", "identity")!; + read.balance = "3"; + expect(codexCreditsFor("pool", "identity")).toEqual({ balance: "1" }); + }); +}); diff --git a/tests/codex-integration/codex-log-guard-maintenance-coderabbit.test.ts b/tests/codex-integration/codex-log-guard-maintenance-coderabbit.test.ts index 696927dda05..d7aa36575ac 100644 --- a/tests/codex-integration/codex-log-guard-maintenance-coderabbit.test.ts +++ b/tests/codex-integration/codex-log-guard-maintenance-coderabbit.test.ts @@ -57,7 +57,10 @@ function fixture(): { codexHome: string; databasePath: string } { createLogsSchema(db); db.exec("CREATE TABLE reclaim_fixture (id INTEGER PRIMARY KEY, body BLOB NOT NULL)"); const fill = db.query("INSERT INTO reclaim_fixture (id, body) VALUES (?, zeroblob(8192))"); - for (let i = 0; i < 220; i += 1) fill.run(i + 1); + // Seed once: 220 autocommits add durable I/O without changing the reclamation fixture. + db.transaction(() => { + for (let i = 0; i < 220; i += 1) fill.run(i + 1); + })(); db.exec("DELETE FROM reclaim_fixture WHERE id <= 200"); db.exec("PRAGMA wal_checkpoint(FULL)"); db.close(); diff --git a/tests/codex-integration/codex-shim-standalone.test.ts b/tests/codex-integration/codex-shim-standalone.test.ts new file mode 100644 index 00000000000..e38e86d88eb --- /dev/null +++ b/tests/codex-integration/codex-shim-standalone.test.ts @@ -0,0 +1,113 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim } from "../../src/codex/shim-templates"; +import { prependPath } from "../helpers/codex-shim-install-fixture"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +describe("standalone shim command generation (#6276)", () => { + test("Unix invokes the executable directly without a virtual CLI entrypoint", () => { + const script = buildUnixCodexShim("/bin/codex-real", "/opt/ocx app/ocx", "/$bunfs/root/cli/index.ts", "standalone"); + expect(script).toContain("'/opt/ocx app/ocx' ensure >/dev/null 2>&1"); + expect(script).not.toContain("/$bunfs/"); + }); + + test("CMD invokes the executable directly without a virtual CLI entrypoint", () => { + const script = buildWindowsCodexShim("C:\\codex-real.cmd", "C:\\ocx app\\ocx.exe", "C:\\~BUN\\root\\cli\\index.ts", "standalone"); + expect(script).toContain('"%OCX_BUN%" ensure >nul 2>nul'); + expect(script).not.toContain("~BUN"); + }); + + test("PowerShell invokes the executable directly without a virtual CLI entrypoint", () => { + const script = buildWindowsPowerShellCodexShim("C:\\codex-real.ps1", "C:\\ocx app\\ocx.exe", "C:\\~BUN\\root\\cli\\index.ts", "standalone"); + expect(script).toContain("& 'C:\\ocx app\\ocx.exe' ensure *> $null"); + expect(script).not.toContain("~BUN"); + }); +}); + +describe.skipIf(process.platform === "win32")("compiled Unix shim installation and launch (#6276)", () => { + let root: string; + let executable: string; + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), "ocx-compiled-shim-")); + const entry = join(root, "entry.ts"); + executable = join(root, "ocx app's standalone"); + writeFileSync(entry, ` + import { installCodexShim, setCodexShimProbeObservationMsForTests } from ${JSON.stringify(repoPath("src", "codex", "shim.ts"))}; + import { writeFileSync } from "node:fs"; + setCodexShimProbeObservationMsForTests(20); + if (process.argv[2] === "install") { + console.log(JSON.stringify(installCodexShim())); + } else if (process.argv[2] === "ensure" && process.argv.length === 3) { + writeFileSync(process.env.OCX_TEST_ENSURE_FILE!, JSON.stringify({ + args: process.argv.slice(2), source: process.env.OCX_BUN_RUNTIME_SOURCE, + path: process.env.OCX_BUN_RUNTIME_PATH, beBun: process.env.BUN_BE_BUN ?? null, + })); + } else { + console.error("Unknown command:", process.argv[2]); + process.exit(64); + } + `); + const built = await Bun.build({ entrypoints: [entry], compile: { outfile: executable } }); + expect(built.success, built.logs.map(log => log.message).join("\n")).toBe(true); + if (process.platform === "darwin") { + const signed = spawnSync("codesign", ["--force", "--sign", "-", executable], { encoding: "utf8", timeout: SPAWN_BUDGET_MS }); + expect(signed.status, signed.stderr).toBe(0); + } + }, SPAWN_BUDGET_MS); + + afterAll(() => { if (root) removeTreeWithRetry(root); }); + + function installFixture() { + const dir = mkdtempSync(join(root, "case-")); + const home = join(dir, "home"); + mkdirSync(home); + const wrapper = join(dir, "codex"); + const probeEnvFile = join(dir, "probe-env"); + const ensureFile = join(dir, "ensure.json"); + writeFileSync(wrapper, `#!/bin/sh +if [ "\${OCX_SHIM_PROBE:-}" = "1" ]; then + printf '%s\\n' "\${BUN_BE_BUN:-unset}" > "$OCX_TEST_PROBE_ENV" + [ "\${BUN_BE_BUN:-}" != "1" ] || exit 64 + exit 0 +fi +printf '%s\\n' real-codex "$@" +exit 7 +`, { mode: 0o755 }); + const env: NodeJS.ProcessEnv = { + ...process.env, PATH: prependPath(dir, process.env.PATH), OPENCODEX_HOME: home, + OCX_TEST_PROBE_ENV: probeEnvFile, OCX_TEST_ENSURE_FILE: ensureFile, + }; + for (const key of ["BUN_BE_BUN", "OCX_SHIM_ACTIVE_PID", "OCX_SHIM_ACTIVE_DEPTH", "OCX_SHIM_PROBE_ACTIVE", "OCX_SHIM_PROBE", "OCX_SHIM_BYPASS", "OCX_BUN_RUNTIME_SOURCE", "OCX_BUN_RUNTIME_PATH"]) delete env[key]; + const result = spawnSync(executable, ["install"], { env, encoding: "utf8", timeout: SPAWN_BUDGET_MS }); + expect(result.error).toBeUndefined(); + expect(result.status, `${result.signal ?? ""}: ${result.stderr}`).toBe(0); + const installed = JSON.parse(result.stdout); + expect(installed.installed, installed.message).toBe(true); + return { wrapper, env, ensureFile, probeEnvFile }; + } + + test("installs through the compiled probe and confines BUN_BE_BUN to its supervisor", () => { + const fixture = installFixture(); + expect(readFileSync(fixture.probeEnvFile, "utf8").trim()).toBe("unset"); + expect(fixture.env.BUN_BE_BUN).toBeUndefined(); + }, SPAWN_BUDGET_MS); + + test("an installed wrapper runs compiled ensure then preserves Codex args and exit status", () => { + const fixture = installFixture(); + const result = spawnSync(fixture.wrapper, ["exec", "prompt with spaces"], { env: fixture.env, encoding: "utf8", timeout: SPAWN_BUDGET_MS }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(7); + expect(result.stderr).toBe(""); + expect(result.stdout.trim().split("\n")).toEqual(["real-codex", "exec", "prompt with spaces"]); + expect(JSON.parse(readFileSync(fixture.ensureFile, "utf8"))).toEqual({ + args: ["ensure"], source: "standalone", path: realpathSync(executable), beBun: null, + }); + expect(readFileSync(fixture.wrapper, "utf8")).not.toContain("/$bunfs/"); + }, SPAWN_BUDGET_MS); +}); diff --git a/tests/codex-integration/discovered-native-models.test.ts b/tests/codex-integration/discovered-native-models.test.ts new file mode 100644 index 00000000000..3f2cc4c42fe --- /dev/null +++ b/tests/codex-integration/discovered-native-models.test.ts @@ -0,0 +1,335 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildCatalogEntries } from "../../src/codex/catalog"; +import { + DISCOVERED_NATIVE_MAX_FILE_BYTES, + DISCOVERED_NATIVE_MAX_ROW_BYTES, + DISCOVERED_NATIVE_MAX_ROWS, + DISCOVERED_NATIVE_RENEW_INTERVAL_MS, + DISCOVERED_NATIVE_RETENTION_MS, + discoveredNativeModelsGeneration, + loadDiscoveredNativeModels, + recordDiscoveredNativeModels, + resetDiscoveredNativeModelsForTests, + validateDiscoveredNativeRows, +} from "../../src/codex/catalog/discovered-natives"; +import { + ACCOUNT_GATED_NATIVE_OPENAI_MODELS, + NATIVE_OPENAI_MODELS, + SUPPORTED_NATIVE_OPENAI_SLUGS, + configuredNativeOpenAiModels, + discoveredNativeOpenAiModels, + resetConfiguredNativeOpenAiModelsForTests, + setConfiguredNativeOpenAiModels, +} from "../../src/codex/catalog/native-models"; +import { + nativeOpenAiContextTier, + nativeOpenAiContextWindow, + nativeOpenAiSlugs, + nativeReasoningEfforts, + upstreamNativeEntry, + mergeCatalogEntriesForSync, +} from "../../src/codex/catalog"; +import { refreshConfigDerivedRegistries } from "../../src/config/derived-registries"; +import { + CODEX_ROSTER_DISCOVERY_CLIENT_VERSION, + discoverCodexNativeRoster, + resetCodexModelEntitlementCacheForTests, + resetCodexNativeRosterDiscoveryForTests, + resolveCodexModelEntitlements, +} from "../../src/codex/model-entitlements"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const FUTURE = "gpt-9-test"; +let home: string; +let previousHome: string | undefined; +let path: string; + +function row(slug = FUTURE, extra: Record = {}): Record { + return { + slug, display_name: "Future native's own name", description: "Its own upstream description", + supported_in_api: true, visibility: "list", context_window: 123_000, max_context_window: 456_000, + supported_reasoning_levels: [{ effort: "low", description: "Quick" }, { effort: "high", description: "Deep" }], + default_reasoning_level: "high", model_messages: { instructions_template: "Upstream future instructions" }, + ...extra, + }; +} + +function persisted(): { version: number; models: Array<{ slug: string; row: Record; firstSeenAt: number; lastSeenAt: number; clientVersion: string }> } { + return JSON.parse(readFileSync(path, "utf8")); +} + +async function fetchRoster(models: unknown, now = Date.now(), status = 200) { + resetCodexModelEntitlementCacheForTests(); + return resolveCodexModelEntitlements({}, { + now, clientVersion: "0.160.0", + credentials: [{ accountId: "future-test", accessToken: "fixture-token", credentialIdentity: "fixture-generation" }], + fetcher: (() => Promise.resolve(Response.json({ models }, { status }))) as typeof fetch, + }); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-discovered-native-")); + process.env.OPENCODEX_HOME = home; + path = join(home, "discovered-native-models.json"); + resetDiscoveredNativeModelsForTests(); + resetConfiguredNativeOpenAiModelsForTests(); + resetCodexModelEntitlementCacheForTests(); +}); + +afterEach(() => { + resetDiscoveredNativeModelsForTests(); + resetConfiguredNativeOpenAiModelsForTests(); + resetCodexModelEntitlementCacheForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +describe("authenticated native discovery", () => { + test("a future model returned only by the roster persists and builds with its own capabilities", async () => { + const now = Date.now(); + const snapshot = await fetchRoster([row("gpt-6.1-sol"), row()], now); + expect(snapshot.modelsByAccount.get("future-test")?.has(FUTURE)).toBe(true); + expect(persisted()).toMatchObject({ version: 1, models: [{ slug: FUTURE, row: row(), firstSeenAt: now, lastSeenAt: now, clientVersion: "0.160.0" }] }); + if (process.platform !== "win32") expect(statSync(path).mode & 0o777).toBe(0o600); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(true); + expect(ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(FUTURE)).toBe(false); + expect(nativeOpenAiSlugs()).toContain(FUTURE); + expect(nativeReasoningEfforts(FUTURE)).toEqual(["low", "high"]); + const built = buildCatalogEntries(null, [...NATIVE_OPENAI_MODELS], []); + const future = built.find(entry => entry.slug === FUTURE)!; + expect(future).toMatchObject({ display_name: row().display_name, description: row().description, + supported_reasoning_levels: row().supported_reasoning_levels, default_reasoning_level: "high", + context_window: 123_000, max_context_window: 123_000, base_instructions: "Upstream future instructions" }); + expect(built.filter(entry => entry.slug === FUTURE)).toHaveLength(1); + expect(nativeOpenAiContextTier(FUTURE)).toEqual({ defaultWindow: 123_000, longWindow: 456_000 }); + expect(nativeOpenAiContextWindow(FUTURE, { modelWindows: { [FUTURE]: 1_000_000 } })).toBe(456_000); + }); + + test("built-in, retired, reserve, hidden, unsupported and malformed rows are rejected", () => { + const invalid = [row("gpt-6.1-sol"), row("gpt-5.4"), row("gpt-5.3-codex-spark"), row("gpt-reserve"), + row("openai/gpt-9-test"), row(FUTURE, { visibility: "hide" }), row(FUTURE, { supported_in_api: false }), + row(FUTURE, { display_name: 9 }), row(FUTURE, { supported_reasoning_levels: null }), + row(FUTURE, { supported_reasoning_levels: [null] }), row(FUTURE, { supported_reasoning_levels: ["high"] }), + row(FUTURE, { context_window: -1 }), row(FUTURE, { description: "x".repeat(DISCOVERED_NATIVE_MAX_ROW_BYTES) }), null]; + expect(validateDiscoveredNativeRows(invalid)).toEqual([]); + expect(validateDiscoveredNativeRows({ models: [row()] })).toEqual([]); + recordDiscoveredNativeModels(invalid, "0.160.0"); + expect(discoveredNativeOpenAiModels()).toEqual([]); + expect(NATIVE_OPENAI_MODELS.filter(slug => slug === "gpt-6.1-sol")).toHaveLength(1); + }); + + test("failed or empty roster fetches cannot register models", async () => { + await fetchRoster([row()], Date.now(), 500); + expect(discoveredNativeOpenAiModels()).toEqual([]); + await fetchRoster([]); + expect(discoveredNativeOpenAiModels()).toEqual([]); + }); + + test("config activation reloads persisted rows; corrupt and oversized files are empty", () => { + recordDiscoveredNativeModels([row()], "0.160.0"); + resetDiscoveredNativeModelsForTests(); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(false); + refreshConfigDerivedRegistries({ providers: {} } as OcxConfig); + expect(upstreamNativeEntry(FUTURE)?.display_name).toBe(row().display_name); + writeFileSync(path, "{corrupt"); + loadDiscoveredNativeModels(); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(false); + expect(upstreamNativeEntry(FUTURE)).toBeNull(); + writeFileSync(path, " ".repeat(DISCOVERED_NATIVE_MAX_FILE_BYTES + 1)); + loadDiscoveredNativeModels(); + expect(discoveredNativeOpenAiModels()).toEqual([]); + }); + + test("latest row wins, preserves first seen, updates generation and refreshes an existing catalog row", () => { + const now = Date.now(); + recordDiscoveredNativeModels([row()], "0.160.0", now); + const before = discoveredNativeModelsGeneration(); + const changed = row(FUTURE, { display_name: "Updated upstream name", context_window: 200_000 }); + recordDiscoveredNativeModels([changed], "0.161.0", now + 1); + expect(persisted().models[0]).toMatchObject({ firstSeenAt: now, lastSeenAt: now + 1, clientVersion: "0.161.0", row: changed }); + expect(discoveredNativeModelsGeneration()).toBeGreaterThan(before); + expect(nativeOpenAiContextWindow(FUTURE)).toBe(200_000); + const entries = buildCatalogEntries(null, [FUTURE], []); + expect(entries[0]?.display_name).toBe("Updated upstream name"); + const merged = mergeCatalogEntriesForSync([row()], [], new Map(), [], false); + expect(merged.find(entry => entry.slug === FUTURE)?.display_name).toBe("Updated upstream name"); + const unchanged = discoveredNativeModelsGeneration(); + recordDiscoveredNativeModels([changed], "0.161.0", now + 2); + expect(discoveredNativeModelsGeneration()).toBe(unchanged); + }); + + test("pruning unregisters rows and cleans metadata, including the last persisted row", () => { + const now = Date.now(); + recordDiscoveredNativeModels([row()], "0.160.0", now); + recordDiscoveredNativeModels([], "0.160.0", now + DISCOVERED_NATIVE_RETENTION_MS + 1); + expect(persisted().models).toEqual([]); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(false); + expect(upstreamNativeEntry(FUTURE)).toBeNull(); + expect(nativeOpenAiContextWindow(FUTURE)).toBeUndefined(); + }); + + test("configured and discovered membership is unioned; real capabilities replace the configured template", () => { + setConfiguredNativeOpenAiModels([FUTURE]); + recordDiscoveredNativeModels([row()], "0.160.0"); + expect(NATIVE_OPENAI_MODELS.filter(slug => slug === FUTURE)).toHaveLength(1); + expect(nativeReasoningEfforts(FUTURE)).toEqual(["low", "high"]); + expect(upstreamNativeEntry(FUTURE)?.display_name).toBe(row().display_name); + setConfiguredNativeOpenAiModels([]); + expect(configuredNativeOpenAiModels()).toEqual([]); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(true); + setConfiguredNativeOpenAiModels([FUTURE]); + recordDiscoveredNativeModels([], "0.160.0", Date.now() + DISCOVERED_NATIVE_RETENTION_MS + 1); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(true); + expect(upstreamNativeEntry(FUTURE)?.display_name).toBe("GPT-9-Test"); + }); + + test("bounds discoveries and falls back only for omitted context metadata", () => { + const rows = Array.from({ length: 40 }, (_, i) => row(`gpt-9-test-${i}`, { context_window: undefined, max_context_window: undefined })); + recordDiscoveredNativeModels(rows, "0.160.0"); + expect(persisted().models).toHaveLength(DISCOVERED_NATIVE_MAX_ROWS); + expect(statSync(path).size).toBeLessThanOrEqual(DISCOVERED_NATIVE_MAX_FILE_BYTES); + expect(nativeOpenAiContextTier("gpt-9-test-0")).toEqual({ defaultWindow: 272_000, longWindow: 872_000 }); + }); + + test("an explicitly empty reasoning ladder stays empty rather than borrowing a family default", () => { + recordDiscoveredNativeModels([row(FUTURE, { supported_reasoning_levels: [], default_reasoning_level: null })], "0.160.0"); + expect(nativeReasoningEfforts(FUTURE)).toEqual([]); + expect(buildCatalogEntries(null, [FUTURE], [])[0]?.supported_reasoning_levels).toEqual([]); + }); + + test("expired persisted rows and built-in rows from an older store do not register on load", () => { + const now = Date.now(); + const entry = (slug: string, lastSeenAt: number) => ({ slug, row: row(slug), + firstSeenAt: 0, lastSeenAt, clientVersion: "0.160.0" }); + writeFileSync(path, JSON.stringify({ version: 1, models: [ + entry(FUTURE, now - DISCOVERED_NATIVE_RETENTION_MS - 1), entry("gpt-6.1-sol", now), + ] })); + loadDiscoveredNativeModels(now); + expect(discoveredNativeOpenAiModels()).toEqual([]); + expect(upstreamNativeEntry("gpt-6.1-sol")?.display_name).toBe("GPT-6.1-Sol"); + expect(NATIVE_OPENAI_MODELS.filter(slug => slug === "gpt-6.1-sol")).toHaveLength(1); + }); + + test("a persistence failure cannot turn a successful entitlement into a request failure", async () => { + mkdirSync(path); + const snapshot = await fetchRoster([row()]); + expect(snapshot.modelsByAccount.get("future-test")?.has(FUTURE)).toBe(true); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(true); + expect(upstreamNativeEntry(FUTURE)?.display_name).toBe(row().display_name); + }); + + test("account-specific grants and availability prompts never become shared capability metadata", () => { + recordDiscoveredNativeModels([row(FUTURE, { available_access_programs: { cyber: ["fixture"] }, availability_nux: { message: "pool-only prompt" } })], "0.160.0"); + const entry = upstreamNativeEntry(FUTURE)!; + expect(entry.available_access_programs).toBeUndefined(); + expect(entry.availability_nux).toBeUndefined(); + }); +}); + +describe("discovery-only roster", () => { + const credential = { accountId: "future-test", accessToken: "fixture-token", chatgptAccountId: "", credentialIdentity: "fixture-generation" }; + + test("asks as a newer client, records an unpinned row, and leaves the entitlement cache alone", async () => { + resetCodexNativeRosterDiscoveryForTests(); + const urls: string[] = []; + const fetcher = ((url: string | URL) => { + urls.push(String(url)); + return Promise.resolve(Response.json({ models: [row()] }, { headers: { etag: "W/\"roster-1\"" } })); + }) as typeof fetch; + expect(await discoverCodexNativeRoster({}, { credentials: [credential], fetcher })).toBe("recorded"); + expect(new URL(urls[0]!).searchParams.get("client_version")).toBe(CODEX_ROSTER_DISCOVERY_CLIENT_VERSION); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(true); + expect(persisted().models.map(model => model.slug)).toEqual([FUTURE]); + // A discovery roster must never answer an entitlement question for another client version. + // Resolving under the discovery version must still fetch, proving no entry was cached. + let entitlementFetches = 0; + await resolveCodexModelEntitlements({}, { + clientVersion: CODEX_ROSTER_DISCOVERY_CLIENT_VERSION, + credentials: [credential], + fetcher: (() => { + entitlementFetches += 1; + return Promise.resolve(Response.json({ models: [row()] })); + }) as typeof fetch, + }); + expect(entitlementFetches).toBe(1); + }); + + test("a discovery whose scheduler generation ended does not publish", async () => { + resetCodexNativeRosterDiscoveryForTests(); + const fetcher = (() => Promise.resolve(Response.json({ models: [row()] }))) as typeof fetch; + expect(await discoverCodexNativeRoster({}, { credentials: [credential], fetcher, isCurrent: () => false })).toBe("unavailable"); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(false); + }); + + test("revalidates with the last ETag and treats 304 as unchanged", async () => { + resetCodexNativeRosterDiscoveryForTests(); + const seen: Array = []; + let calls = 0; + const fetcher = ((_url: string | URL, init?: RequestInit) => { + seen.push(new Headers(init?.headers).get("if-none-match")); + calls += 1; + return Promise.resolve(calls === 1 + ? Response.json({ models: [row()] }, { headers: { etag: "\"roster-2\"" } }) + : new Response(null, { status: 304 })); + }) as typeof fetch; + expect(await discoverCodexNativeRoster({}, { credentials: [credential], fetcher })).toBe("recorded"); + expect(await discoverCodexNativeRoster({}, { credentials: [credential], fetcher })).toBe("not-modified"); + expect(seen).toEqual([null, "\"roster-2\""]); + }); + + test("a real-sized row carrying its instructions twice is admitted", () => { + // GPT-6.1 Sol's live row was 87,183 bytes: base_instructions plus the same text as a template. + const instructions = "x".repeat(44_000); + const large = row(FUTURE, { base_instructions: instructions, model_messages: { instructions_template: instructions } }); + expect(Buffer.byteLength(JSON.stringify(large))).toBeGreaterThan(64 * 1024); + expect(validateDiscoveredNativeRows([large]).map(model => model.slug)).toEqual([FUTURE]); + }); + + test("an upstream failure is unavailable and registers nothing", async () => { + resetCodexNativeRosterDiscoveryForTests(); + const fetcher = (() => Promise.resolve(new Response("nope", { status: 503 }))) as typeof fetch; + expect(await discoverCodexNativeRoster({}, { credentials: [credential], fetcher })).toBe("unavailable"); + expect(SUPPORTED_NATIVE_OPENAI_SLUGS.has(FUTURE)).toBe(false); + }); +}); + +describe("discovery store under concurrency and repeated fetches", () => { + test("a model another process recorded survives this process's next write", () => { + const now = Date.now(); + recordDiscoveredNativeModels([row()], "0.160.0", now); + const other = persisted(); + other.models.push({ slug: "gpt-9-other", row: row("gpt-9-other"), firstSeenAt: now, lastSeenAt: now, clientVersion: "0.160.0" }); + writeFileSync(path, JSON.stringify(other)); + recordDiscoveredNativeModels([row("gpt-9-third")], "0.160.0", now + 1); + expect(persisted().models.map(model => model.slug).sort()).toEqual(["gpt-9-other", FUTURE, "gpt-9-third"]); + }); + + test("an unchanged row renews on disk at most hourly", () => { + const now = Date.now() - 3 * DISCOVERED_NATIVE_RENEW_INTERVAL_MS; + recordDiscoveredNativeModels([row()], "0.160.0", now); + recordDiscoveredNativeModels([row()], "0.160.0", now + 60_000); + expect(persisted().models[0]!.lastSeenAt).toBe(now); + recordDiscoveredNativeModels([row()], "0.160.0", now + DISCOVERED_NATIVE_RENEW_INTERVAL_MS); + expect(persisted().models[0]!.lastSeenAt).toBe(now + DISCOVERED_NATIVE_RENEW_INTERVAL_MS); + }); + + test("a day-old ETag is not sent, so a full fetch renews what a 304 cannot", async () => { + resetCodexNativeRosterDiscoveryForTests(); + const credential = { accountId: "future-test", accessToken: "fixture-token", chatgptAccountId: "", credentialIdentity: "fixture-generation" }; + const seen: Array = []; + const fetcher = ((_url: string | URL, init?: RequestInit) => { + seen.push(new Headers(init?.headers).get("if-none-match")); + return Promise.resolve(Response.json({ models: [row()] }, { headers: { etag: "\"roster-3\"" } })); + }) as typeof fetch; + const start = Date.now() - 2 * 24 * 60 * 60 * 1000; + await discoverCodexNativeRoster({}, { credentials: [credential], fetcher, now: start }); + await discoverCodexNativeRoster({}, { credentials: [credential], fetcher, now: start + 25 * 60 * 60 * 1000 }); + expect(seen).toEqual([null, null]); + }); +}); diff --git a/tests/codex-integration/main-account-hard-lock-recovery.test.ts b/tests/codex-integration/main-account-hard-lock-recovery.test.ts index 823cc27d22e..17dbb5f6736 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -677,7 +677,7 @@ describe("main hard-lock background recovery", () => { test("owned metadata recovery replaces an obsolete short block with the current weekly window", async () => { const calls = fetchWith(async () => Response.json({ plan_type: "pro", rate_limit: { - primary_window: { used_percent: 35, limit_window_seconds: 604_800 }, secondary_window: null, tertiary_window: null, + primary_window: null, secondary_window: { used_percent: 35, limit_window_seconds: 604_800 }, tertiary_window: null, } })); await runMainAccountHardLockRecovery(config()); expect(calls).toEqual([whamUrl]); diff --git a/tests/codex-integration/main-account-hard-lock-retirement.test.ts b/tests/codex-integration/main-account-hard-lock-retirement.test.ts new file mode 100644 index 00000000000..21db57c6527 --- /dev/null +++ b/tests/codex-integration/main-account-hard-lock-retirement.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; +import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; +import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; +import { + applyAccountQuotaFromUpstreamHeaders, clearAccountQuota, getMainPolicyQuota, + parseMainPolicyUsageQuota, parseUsageQuota, setAccountQuotaFromParsed, type WhamUsageResponse, +} from "../../src/codex/quota"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const account = "retirement-main-fixture"; +const weekly = { used_percent: 0, limit_window_seconds: 604_800 }; +let home: string; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-main-retirement-")); + process.env.OPENCODEX_HOME = home; + clearAccountQuota(); + clearMainAccountInfoCache(); + observeMainQuotaIdentity(account); + const writer = captureMainQuotaWriter(account)!; + const old = Date.now() - 19 * 24 * 60 * 60_000; + writeFileSync(join(home, "codex-quota-cache.json"), JSON.stringify({ version: 1, quotas: {}, + mainPolicyQuota: { identityKey: writer.identityKey, quota: { + shortPercent: 100, shortObservedAt: old, shortResetAt: old / 1000 + 18_000, + shortWindowSeconds: 18_000, weeklyPercent: 0, updatedAt: old, + } }, + })); + expect(getMainAccountHardLockStatus({}).state).toBe("blocked"); +}); + +afterEach(() => { + clearAccountQuota(); + clearMainAccountInfoCache(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +/** Exercise the production display/policy projections with one captured live identity writer. */ +function publish(data: WhamUsageResponse): void { + setAccountQuotaFromParsed(MAIN, parseUsageQuota(data), undefined, + captureMainQuotaWriter(account), parseMainPolicyUsageQuota(data)); +} + +describe("authoritative main 5h window retirement (#6244)", () => { + test.each([0, 35, 97.99, 98, 100])("explicit absent primary retires old 5h while weekly %s still governs", percent => { + publish({ rate_limit: { + primary_window: null, secondary_window: { ...weekly, used_percent: percent }, tertiary_window: null, + } }); + const policy = getMainPolicyQuota(); + expect(policy?.weeklyPercent).toBe(percent); + for (const field of ["shortPercent", "shortObservedAt", "shortResetAt", "shortWindowSeconds"] as const) { + expect(policy?.[field]).toBeUndefined(); + } + expect(policy).not.toHaveProperty("shortWindowAbsent"); + expect(getMainAccountHardLockStatus({}).state).toBe(percent < 98 ? "ready" : "blocked"); + }); + + test.each([ + { rate_limit: { secondary_window: weekly, tertiary_window: null } }, + { rate_limit: { primary_window: null, secondary_window: weekly } }, + { rate_limit: { primary_window: null, secondary_window: {}, tertiary_window: null } }, + { rate_limit: { primary_window: {}, secondary_window: weekly, tertiary_window: null } }, + { rate_limit: { primary_window: null, secondary_window: { used_percent: 0 }, tertiary_window: null } }, + { rate_limit: { primary_window: null, secondary_window: { ...weekly, used_percent: 101 }, tertiary_window: null } }, + { rate_limit: { primary_window: null, secondary_window: { ...weekly, used_percent: -1 }, tertiary_window: null } }, + { rate_limit: { primary_window: null, secondary_window: { ...weekly, used_percent: NaN }, tertiary_window: null } }, + { rate_limit: { primary_window: null, secondary_window: weekly, tertiary_window: { limit_window_seconds: 2_592_000 } } }, + { rate_limit: { primary_window: null, secondary_window: weekly, + tertiary_window: { used_percent: 0, limit_window_seconds: 18_000 } } }, + { rate_limit: { primary_window: { limit_window_seconds: 18_000 }, secondary_window: weekly, tertiary_window: null } }, + { rate_limit: { primary_window: null, secondary_window: null, tertiary_window: null }, + rate_limit_reset_credits: { available_count: 2 } }, + { rate_limit_reset_credits: { available_count: 2 } }, + ])("partial or unreadable observation cannot retire 19-day blocking evidence: %j", data => { + const before = getMainPolicyQuota()!; + expect(parseMainPolicyUsageQuota(data)?.shortWindowAbsent).toBeUndefined(); + publish(data); + expect(getMainPolicyQuota()).toMatchObject({ + shortPercent: 100, shortObservedAt: before.shortObservedAt, + shortResetAt: before.shortResetAt, shortWindowSeconds: 18_000, + }); + expect(getMainAccountHardLockStatus({}).state).toBe("blocked"); + }); + + test.each(["go", "free"])("%s tertiary-only monthly usage cannot prove governing recovery", plan_type => { + const data = { plan_type, rate_limit: { primary_window: null, secondary_window: null, + tertiary_window: { used_percent: 0, limit_window_seconds: 2_592_000 } } }; + expect(parseMainPolicyUsageQuota(data)?.shortWindowAbsent).toBeUndefined(); + publish(data); + expect(getMainAccountHardLockStatus({}).state).toBe("blocked"); + }); + + test("weekly headers and elapsed reset clocks retain the old short block", () => { + const before = getMainPolicyQuota()!; + applyAccountQuotaFromUpstreamHeaders(MAIN, new Headers({ + "x-codex-secondary-used-percent": "0", "x-codex-secondary-window-minutes": "10080", + }), undefined, captureMainQuotaWriter(account)); + expect(getMainPolicyQuota()?.shortObservedAt).toBe(before.shortObservedAt); + expect(getMainAccountHardLockStatus({}, Date.now() + 30 * 24 * 60 * 60_000).state).toBe("blocked"); + }); + + test("a stale identity writer cannot publish otherwise authoritative absence", () => { + const staleWriter = captureMainQuotaWriter(account)!; + clearMainAccountInfoCache(); + observeMainQuotaIdentity(account); + const data = { rate_limit: { primary_window: null, secondary_window: weekly, tertiary_window: null } }; + setAccountQuotaFromParsed(MAIN, parseUsageQuota(data), undefined, staleWriter, parseMainPolicyUsageQuota(data)); + expect(getMainPolicyQuota()?.shortPercent).toBe(100); + expect(getMainAccountHardLockStatus({}).state).toBe("blocked"); + }); + + test("fresh lower short usage releases and the next blocking reading rearms", () => { + publish({ rate_limit: { primary_window: { used_percent: 0, limit_window_seconds: 18_000 } } }); + expect(getMainAccountHardLockStatus({}).state).toBe("ready"); + publish({ rate_limit: { primary_window: { used_percent: 98, limit_window_seconds: 18_000 } } }); + expect(getMainAccountHardLockStatus({}).state).toBe("blocked"); + }); +}); diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index e132854b38f..b73680c2e03 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -313,7 +313,7 @@ test("window replacement persists without carrying its proof into later partial setAccountQuotaFromParsed(MAIN, { shortPercent: 100, shortWindowSeconds: 18_000, shortResetAt: 1 }, undefined, writer); expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); publish({ rate_limit: { - primary_window: { used_percent: 35, limit_window_seconds: 604_800 }, secondary_window: null, tertiary_window: null, + primary_window: null, secondary_window: { used_percent: 35, limit_window_seconds: 604_800 }, tertiary_window: null, } }); // Execute quota's actual debounced serializer through the existing deterministic clock. const persisted = flushPersistence(); diff --git a/tests/codex-integration/reserve-catalog-lifecycle.test.ts b/tests/codex-integration/reserve-catalog-lifecycle.test.ts index 44f5d46ab30..f960a3cfa9b 100644 --- a/tests/codex-integration/reserve-catalog-lifecycle.test.ts +++ b/tests/codex-integration/reserve-catalog-lifecycle.test.ts @@ -13,6 +13,10 @@ const roots: string[] = []; const SOURCE = "opencodex_reserve_source"; const MARKER = "opencodex_reserve_metadata_source"; const SELECTOR = "personal/gpt-reserve"; +// Each sync spawns a fresh Bun child that loads the catalog module graph; a cold Windows runner +// has taken 33s for one (dev CI 36706278700). Budget each test by its sync count. +const CHILD_TIMEOUT_MS = 60_000; +const budget = (syncs: number) => syncs * CHILD_TIMEOUT_MS + 10_000; interface Sandbox { root: string; @@ -150,7 +154,7 @@ function sync(sandbox: Sandbox): RawCatalog { 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, + cwd: repoRoot(), env: sandbox.env, encoding: "utf8", timeout: CHILD_TIMEOUT_MS, }); expect({ status: child.status, error: child.error?.message, stderr: child.stderr }).toMatchObject({ status: 0, error: undefined }); expect(child.stdout).toContain("RESERVE_CATALOG_LIFECYCLE_OK"); @@ -183,7 +187,7 @@ describe("Reserve actual catalog finalization lifecycle", () => { expect(first.models?.some(row => row.slug === "external/model")).toBe(true); const second = sync(sandbox); expect(selected(second)).toEqual(selected(first)); - }, 70_000); + }, budget(2)); test("genuine bare active on-disk metadata wins over a bundled-only build base", () => { const sandbox = makeSandbox([nativeRow(), reserveRow(false, ["medium"])]); @@ -191,7 +195,7 @@ describe("Reserve actual catalog finalization lifecycle", () => { 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); + }, budget(1)); test("historical cached A cannot replace fresh active B on the following sync", () => { const cachedA = { @@ -228,7 +232,7 @@ describe("Reserve actual catalog finalization lifecycle", () => { 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); + }, budget(2)); test("qualified-only source survives omission, cache invalidation and effort recovery without Luna fallback", () => { const sandbox = makeSandbox([nativeRow(), reserveRow(true)]); @@ -266,7 +270,7 @@ describe("Reserve actual catalog finalization lifecycle", () => { 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); + }, budget(5)); test("a retained adaptation is rejected rather than promoted to genuine source", () => { const adapted = { ...reserveRow(false, ["medium"]), [MARKER]: "gpt-5.6-luna" }; @@ -274,5 +278,5 @@ describe("Reserve actual catalog finalization lifecycle", () => { const result = sync(sandbox); expect(selected(result)).toMatchObject({ multi_agent_version: "v1", [MARKER]: "gpt-5.6-luna" }); expect(result[SOURCE]).toBeUndefined(); - }, 40_000); + }, budget(1)); }); diff --git a/tests/config/config-catalog-auto-refresh.test.ts b/tests/config/config-catalog-auto-refresh.test.ts index dc142d2e29d..1d96e4cd29e 100644 --- a/tests/config/config-catalog-auto-refresh.test.ts +++ b/tests/config/config-catalog-auto-refresh.test.ts @@ -75,12 +75,13 @@ test("resolveCatalogAutoRefreshIntervalMs clamps below the floor and honours val .toBe(120 * 60_000); }); -test("isCatalogAutoRefreshEnabled reads true only for an explicit enabled:true", () => { - // The house === true idiom keeps an absent key, an explicit false, and a hand-edited - // truthy string all reading off, so a malformed edit cannot start a live timer. - expect(isCatalogAutoRefreshEnabled({})).toBe(false); - expect(isCatalogAutoRefreshEnabled({ catalogAutoRefresh: {} })).toBe(false); +test("isCatalogAutoRefreshEnabled defaults on while explicit false or zero disables", () => { + // Missing settings enable discovery; malformed enabled values still fail closed. + expect(isCatalogAutoRefreshEnabled({})).toBe(true); + expect(isCatalogAutoRefreshEnabled({ catalogAutoRefresh: {} })).toBe(true); expect(isCatalogAutoRefreshEnabled({ catalogAutoRefresh: { enabled: false } })).toBe(false); + expect(isCatalogAutoRefreshEnabled({ catalogAutoRefresh: { intervalMinutes: 0 } })).toBe(false); + expect(isCatalogAutoRefreshEnabled({ catalogAutoRefresh: { intervalMinutes: 30 } })).toBe(true); expect(isCatalogAutoRefreshEnabled({ catalogAutoRefresh: { enabled: "yes" as never } })).toBe(false); expect(isCatalogAutoRefreshEnabled({ catalogAutoRefresh: { enabled: true } })).toBe(true); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fa709f82902..15d64cefde8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,11 @@ { + "codex-credits.test.ts": "codex-integration", + "codex-credits-settings.test.ts": "codex-integration", + "codex-credits-probes.test.ts": "codex-integration", + "service-desktop-startup-health.test.ts": "service", + "service-desktop-startup.test.ts": "service", + "startup-health-packaged-probe.test.ts": "server", + "discovered-native-models.test.ts": "codex-integration", "cursor-local-installer.test.ts": "providers/cursor", "codex-quota-query-backoff.test.ts": "codex-integration", "link-relay-bound-transport.test.ts": "clients", @@ -592,6 +599,7 @@ "codex-shim-destroyed-probe.test.ts": "codex-integration", "codex-shim-ensure-failure.test.ts": "codex-integration", "codex-shim-readiness.test.ts": "codex-integration", + "codex-shim-standalone.test.ts": "codex-integration", "codex-shim.test.ts": "codex-integration", "codex-signin-lockout.test.ts": "codex-integration", "codex-spark-visibility.test.ts": "codex-integration", @@ -971,6 +979,7 @@ "kiro-review-regressions.test.ts": "providers/kiro", "kiro-metering-events.test.ts": "providers/kiro", "kiro-metering-usage.test.ts": "providers/kiro", + "kiro-single-final.test.ts": "providers/kiro", "kiro-stream.test.ts": "providers/kiro", "kiro-transport-parity.test.ts": "providers/kiro", "kiro-usage-quota.test.ts": "providers/kiro", @@ -1068,6 +1077,7 @@ "main-device-reauth-ui.test.ts": "gui", "main-device-reauth.test.ts": "codex-integration", "main-quota-evidence-validation.test.ts": "codex-integration", + "main-account-hard-lock-retirement.test.ts": "codex-integration", "main-quota-provenance.test.ts": "codex-integration", "main-quota-window-observation.test.ts": "codex-integration", "management-anthropic-reset-grants.test.ts": "server", @@ -1117,6 +1127,7 @@ "model-pinned-effort.test.ts": "codex-integration", "model-presets.test.ts": "providers", "model-rename-migration.test.ts": "providers", + "model-roster-seed-repair.test.ts": "providers", "model-selection-guidance.test.ts": "cli", "model-settings-management-api.test.ts": "server", "model-visibility-management-api.test.ts": "codex-integration", "models-feedback-callback.test.ts": "gui", @@ -1854,6 +1865,8 @@ "command-code-tool-text-prose-split.test.ts": "providers", "cli-effort-slug.test.ts": "cli", "grok-47-build-fast-metadata.test.ts": "providers/xai", + "grok-47-fast-model.test.ts": "providers/xai", + "grok-47-fast-model-wire.test.ts": "providers/xai", "client-link-connect.test.ts": "clients", "client-link-relay.test.ts": "clients", "client-link-runtime.test.ts": "clients", diff --git a/tests/helpers/startup-health-packaged-child.ts b/tests/helpers/startup-health-packaged-child.ts new file mode 100644 index 00000000000..95512c36e16 --- /dev/null +++ b/tests/helpers/startup-health-packaged-child.ts @@ -0,0 +1,11 @@ +import { collectStartupHealth } from "../../src/codex/autostart-health"; +import { getCachedStartupHealth } from "../../src/server/startup-health-cache"; + +if (process.argv[2] === "__startup-health") { + console.log(JSON.stringify(collectStartupHealth({}))); +} else if (process.argv[2] === "cached") { + console.log(JSON.stringify(await getCachedStartupHealth({}))); +} else { + console.error("unexpected packaged entrypoint", process.argv.slice(2)); + process.exitCode = 2; +} diff --git a/tests/oauth/oauth-login-open-browser.test.ts b/tests/oauth/oauth-login-open-browser.test.ts index 676641a305e..0f9d0c061af 100644 --- a/tests/oauth/oauth-login-open-browser.test.ts +++ b/tests/oauth/oauth-login-open-browser.test.ts @@ -25,15 +25,21 @@ function baseConfig(): OcxConfig { return { port: 0, hostname: "127.0.0.1", defaultProvider: "kimi", providers: {} } as OcxConfig; } -async function startLogin(flow: { url: string; instructions?: string; deviceCode?: string }): Promise<{ +async function startLogin( + flow: { url: string; instructions?: string; deviceCode?: string }, + launch: "started" | "failed" = "started", +): Promise<{ opened: string[]; - body: { url?: string; deviceCode?: string; instructions?: string }; + body: { url?: string; deviceCode?: string; instructions?: string; browserLaunch?: string }; }> { const oauth = await import("../../src/oauth"); const openUrlMod = await import("../../src/lib/open-url"); const opened: string[] = []; const startSpy = spyOn(oauth, "startLoginFlow").mockResolvedValue(flow); - const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(async (url: string) => { opened.push(url); return { status: "started" as const }; }); + const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(async (url: string) => { + opened.push(url); + return launch === "started" ? { status: "started" as const } : { status: "failed" as const, reason: "spawn-error" as const }; + }); try { const req = loginRequest("kimi"); const response = await handleOauthAccountRoutes({ @@ -44,7 +50,7 @@ async function startLogin(flow: { url: string; instructions?: string; deviceCode convergeCodexCatalog: async () => ({ status: "failed", reason: "disk" }), syncClaudeAgentDefsBestEffort: async () => {}, }); - return { opened, body: await response!.json() as { url?: string; deviceCode?: string; instructions?: string } }; + return { opened, body: await response!.json() as { url?: string; deviceCode?: string; instructions?: string; browserLaunch?: string } }; } finally { startSpy.mockRestore(); openSpy.mockRestore(); @@ -64,6 +70,7 @@ describe("POST /api/oauth/login browser opening", () => { // The user keeps every way to finish the login by hand. expect(body.url).toBe("https://auth.kimi.com/device?user_code=WDJB-MJHT"); expect(body.deviceCode).toBe("WDJB-MJHT"); + expect(body.browserLaunch).toBe("skipped"); }); test("a browser redirect flow is still opened, exactly as before", async () => { @@ -71,5 +78,15 @@ describe("POST /api/oauth/login browser opening", () => { expect(opened).toEqual(["https://accounts.example.test/authorize?code=1"]); expect(body.deviceCode).toBeUndefined(); + expect(body.browserLaunch).toBe("started"); + }); + + test("a launcher that could not open anything is reported, and the login continues", async () => { + const { opened, body } = await startLogin({ url: "https://accounts.example.test/authorize?code=2" }, "failed"); + + expect(opened).toEqual(["https://accounts.example.test/authorize?code=2"]); + // The login is not failed for it: the URL is still returned for the dashboard to offer. + expect(body.url).toBe("https://accounts.example.test/authorize?code=2"); + expect(body.browserLaunch).toBe("failed"); }); }); diff --git a/tests/providers/kiro/kiro-single-final.test.ts b/tests/providers/kiro/kiro-single-final.test.ts new file mode 100644 index 00000000000..a4bac94faed --- /dev/null +++ b/tests/providers/kiro/kiro-single-final.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createKiroAdapter } from "../../../src/adapters/kiro"; +import { KIRO_COMPLETION_TOOL_NAME } from "../../../src/adapters/kiro-constants"; +import { resetKiroThrottleStateForTests } from "../../../src/adapters/kiro-retry"; +import { encodeMessage } from "../../../src/lib/eventstream-decoder"; +import { createTranslatorBudget, releaseTranslatedEvent } from "../../../src/lib/translator-budget"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; + +const provider: OcxProviderConfig = { + adapter: "kiro", baseUrl: "https://runtime.us-east-1.kiro.dev", apiKey: "ksk_test", +}; +const parsed: OcxParsedRequest = { + modelId: "claude-opus-5.5", stream: true, options: {}, + context: { + messages: [{ role: "user", content: "Inspect the workspace." }], + tools: [{ name: "bash", description: "Run a command", parameters: { type: "object" } }], + }, +}; +const frame = (type: string, payload: object) => encodeMessage( + { ":message-type": "event", ":event-type": type }, + new TextEncoder().encode(JSON.stringify(payload)), +); +const text = (content: string) => frame("assistantResponseEvent", { content }); +function tool(name: string, input: object): Uint8Array[] { + return [ + frame("toolUseEvent", { name, toolUseId: "call-1", input: JSON.stringify(input) }), + frame("toolUseEvent", { name, toolUseId: "call-1", stop: true }), + ]; +} +const completion = (answer: string) => tool(KIRO_COMPLETION_TOOL_NAME, { answer }); +function response(frames: Uint8Array[]): Response { + return new Response(new ReadableStream({ + start(controller) { + for (const value of frames) controller.enqueue(value); + controller.close(); + }, + })); +} +afterEach(resetKiroThrottleStateForTests); + +async function run(first: Uint8Array[], retry: Uint8Array[], buffered = false) { + const adapter = createKiroAdapter(provider); + const budget = createTranslatorBudget(); + const sends: number[] = []; + const events: AdapterEvent[] = []; + let visibleAtRetry: AdapterEvent[] = []; + let physicalRequests = 0; + try { + const request = await adapter.buildRequest(structuredClone(parsed)); + const upstream = await adapter.fetchResponse!(request, { + executor: (async () => { + if (++physicalRequests === 1) return response(first); + visibleAtRetry = events.filter(event => event.type === "text_delta"); + return response(retry); + }) as typeof fetch, + onPhysicalSend: send => { sends.push(send.ordinal); }, + }); + if (buffered) events.push(...await adapter.parseResponse!(upstream, budget)); + else for await (const event of adapter.parseStream(upstream, budget)) events.push(event); + if (buffered) for (const event of events) releaseTranslatedEvent(event, budget); + expect(budget.snapshot().currentBytes).toBe(0); + return { events, sends, physicalRequests, visibleAtRetry }; + } finally { + budget.dispose(); + } +} + +describe("Kiro single final answer (#6270)", () => { + for (const buffered of [false, true]) { + test.each(["END_TURN", "STOP_SEQUENCE", undefined])( + `plain text ending is held through validation (stop=%s, buffered=${buffered})`, + async stopReason => { + const answer = "The workspace is ready."; + const { events, sends, physicalRequests, visibleAtRetry } = await run( + [text("The workspace "), text("is ready."), + ...(stopReason ? [frame("metadataEvent", { stopReason })] : [])], + [text(answer), ...completion(answer)], + buffered, + ); + expect(events.filter(event => event.type === "text_delta")).toEqual([ + { type: "text_delta", text: answer, phase: "final_answer" }, + ]); + expect(events.at(-1)).toMatchObject({ type: "done", endTurn: true }); + expect(physicalRequests).toBe(2); + expect(sends).toEqual([1, 2]); + expect(visibleAtRetry).toEqual([]); + }, + ); + } + + test("a real tool ending releases genuine progress without a completion retry", async () => { + const { events, sends, physicalRequests } = await run( + [text("Checking the workspace."), ...tool("bash", { command: "pwd" })], [], + ); + expect(events.filter(event => event.type !== "heartbeat").map(event => event.type)) + .toEqual(["text_delta", "tool_call_start", "tool_call_delta", "tool_call_end", "done"]); + expect(events.find(event => event.type === "text_delta")).toEqual({ + type: "text_delta", text: "Checking the workspace.", phase: "commentary", + }); + expect(events.at(-1)).toMatchObject({ type: "done", endTurn: false }); + expect(physicalRequests).toBe(1); + expect(sends).toEqual([1]); + }); + + test("normal private final_answer supersedes prose without a completion retry", async () => { + const answer = "The workspace is ready."; + const { events, sends, physicalRequests } = await run([text(answer), ...completion(answer)], []); + expect(events.filter(event => event.type === "text_delta")).toEqual([ + { type: "text_delta", text: answer, phase: "final_answer" }, + ]); + expect(events.at(-1)).toMatchObject({ type: "done", endTurn: true }); + expect(physicalRequests).toBe(1); + expect(sends).toEqual([1]); + }); + + test("a retry tool call releases first-attempt progress before the tool", async () => { + const { events } = await run([text("Checking the workspace.")], tool("bash", { command: "pwd" })); + expect(events.filter(event => event.type !== "heartbeat").map(event => event.type)) + .toEqual(["text_delta", "tool_call_start", "tool_call_delta", "tool_call_end", "done"]); + expect(events.at(-1)).toMatchObject({ type: "done", endTurn: false }); + }); + + test("a complete retry tool releases held progress while its stream is still open", async () => { + let releaseEOF!: () => void; + const eof = new Promise(resolve => { releaseEOF = resolve; }); + let reachedOpenStream!: () => void; + const openStream = new Promise(resolve => { reachedOpenStream = resolve; }); + const frames = tool("bash", { command: "pwd" }); + const retry = new Response(new ReadableStream({ + async pull(controller) { + const next = frames.shift(); + if (next) { controller.enqueue(next); return; } + reachedOpenStream(); + await eof; + controller.close(); + }, + }, { highWaterMark: 0 })); + const adapter = createKiroAdapter(provider); + const budget = createTranslatorBudget(); + const events: AdapterEvent[] = []; + let physicalRequests = 0; + try { + const request = await adapter.buildRequest(structuredClone(parsed)); + const first = await adapter.fetchResponse!(request, { + executor: (async () => ++physicalRequests === 1 + ? response([text("Checking the workspace.")]) : retry) as typeof fetch, + }); + const draining = (async () => { + for await (const event of adapter.parseStream(first, budget)) { + events.push(event); + } + })(); + try { + await openStream; + expect(events.filter(event => event.type === "text_delta")).toEqual([ + { type: "text_delta", text: "Checking the workspace.", phase: "commentary" }, + ]); + } finally { releaseEOF(); await draining; } + expect(events.at(-1)).toMatchObject({ type: "done", endTurn: false }); + expect(physicalRequests).toBe(2); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { budget.dispose(); } + }); + + test("an accepted plain-text retry also replaces first-attempt text", async () => { + const { events } = await run([text("The workspace is ready.")], [text("The workspace is ready.")]); + expect(events.filter(event => event.type === "text_delta")).toEqual([ + { type: "text_delta", text: "The workspace is ready.", phase: "final_answer" }, + ]); + expect(events.at(-1)).toMatchObject({ type: "done", endTurn: true }); + }); + + test("an empty retry preserves held progress and stays non-retryable", async () => { + const { events, physicalRequests } = await run([text("Checking the workspace.")], []); + expect(events.filter(event => event.type === "text_delta")).toEqual([ + { type: "text_delta", text: "Checking the workspace.", phase: "commentary" }, + ]); + expect(events.at(-1)).toMatchObject({ type: "incomplete", retryable: false, endTurn: false }); + expect(physicalRequests).toBe(2); + }); +}); diff --git a/tests/providers/kiro/kiro-stream.test.ts b/tests/providers/kiro/kiro-stream.test.ts index 5ad687ef6f7..c09fac50430 100644 --- a/tests/providers/kiro/kiro-stream.test.ts +++ b/tests/providers/kiro/kiro-stream.test.ts @@ -359,7 +359,6 @@ describe("kiro adapter — parseStream", () => { (tool: { toolSpecification: { name: string } }) => tool.toolSpecification.name, )).toEqual(["bash", KIRO_COMPLETION_TOOL_NAME]); expect(events.filter(event => event.type === "text_delta")).toEqual([ - { type: "text_delta", text: "I am checking.", phase: "commentary" }, { type: "text_delta", text: "Final from fallback.", phase: "final_answer" }, ]); expect(events.at(-1)).toMatchObject({ @@ -617,8 +616,6 @@ describe("kiro adapter — parseStream", () => { expect(fetches).toBe(1); expect(events.filter(event => event.type === "text_delta")).toEqual([ - { type: "text_delta", text: "The file has ", phase: "commentary" }, - { type: "text_delta", text: "three lines.", phase: "commentary" }, { type: "text_delta", text: "The file has three lines.", phase: "final_answer" }, ]); expect(events.at(-1)).toMatchObject({ type: "done", endTurn: true }); @@ -689,7 +686,6 @@ describe("kiro adapter — parseStream", () => { )))); expect(events.filter(event => event.type === "text_delta")).toEqual([ - { type: "text_delta", text: "Done.", phase: "commentary" }, { type: "text_delta", text: "Done.", phase: "final_answer" }, ]); expect(fetches).toBe(1); diff --git a/tests/providers/minimax-reasoning-split.test.ts b/tests/providers/minimax-reasoning-split.test.ts index 478ccbc9341..c2decc342b1 100644 --- a/tests/providers/minimax-reasoning-split.test.ts +++ b/tests/providers/minimax-reasoning-split.test.ts @@ -375,3 +375,85 @@ describe("MiniMax split reasoning", () => { expect(events.some(e => e.type === "reasoning_raw_delta")).toBe(false); }); }); + +describe("MiniMax-M3.1-Flash-Preview reasoning wire", () => { + // Probed 2026-09-30: thinking cannot be turned off (effort none or thinking disabled + // answers 400 code 2013), effort low..max is accepted as-is, reasoning_split is ignored + // and thinking always returns as reasoning_content. + const PREVIEW = "MiniMax-M3.1-Flash-Preview"; + + test("Codex efforts go out as identity reasoning_effort and never disable thinking", () => { + const route = minimaxRoute(PREVIEW); + for (const effort of ["low", "medium", "high", "xhigh", "max"] as const) { + const sent = body(route.provider, route.modelId, effort); + expect(sent).toMatchObject({ model: PREVIEW, reasoning_effort: effort }); + expect(sent).not.toHaveProperty("thinking"); + expect(sent).not.toHaveProperty("reasoning_split"); + } + expect(body(route.provider, route.modelId, "minimal")).toMatchObject({ reasoning_effort: "low" }); + expect(body(route.provider, route.modelId, "ultra" as ReasoningEffort)).toMatchObject({ reasoning_effort: "max" }); + const none = body(route.provider, route.modelId, "none" as ReasoningEffort); + expect(none).not.toHaveProperty("reasoning_effort"); + expect(none).not.toHaveProperty("thinking"); + }); + + test("the preview advertises low..max with max as the default", () => { + const route = minimaxRoute(PREVIEW); + expect(route.provider.modelReasoningEfforts?.[PREVIEW]).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(route.provider.modelDefaultReasoningEfforts?.[PREVIEW]).toBe("max"); + expect(route.provider.thinkingToggleModels ?? []).not.toContain(PREVIEW); + expect(route.provider.reasoningSplitModels ?? []).not.toContain(PREVIEW); + expect(route.provider.reasoningDetailsModels ?? []).not.toContain(PREVIEW); + }); + + test("prior thinking replays as reasoning_content", () => { + const route = minimaxRoute(PREVIEW); + const request = createOpenAIChatAdapter(route.provider).buildRequest({ + modelId: route.modelId, + context: { + messages: [ + { role: "user", content: "first", timestamp: 0 }, + { + role: "assistant", + timestamp: 1, + content: [ + { type: "thinking", thinking: "prior reasoning" }, + { type: "text", text: "prior answer" }, + ], + }, + { role: "user", content: "continue", timestamp: 2 }, + ], + }, + stream: false, + options: {}, + }); + const sent = JSON.parse(request.body as string) as { messages: Array> }; + expect(sent.messages[1]?.reasoning_content).toBe("prior reasoning"); + expect(sent.messages[1]?.reasoning_details).toBeUndefined(); + }); + + test("streamed reasoning_content deltas surface as reasoning", async () => { + const route = minimaxRoute(PREVIEW); + const chunks = [ + { choices: [{ index: 0, delta: { role: "assistant", reasoning_content: "The user" } }] }, + { choices: [{ index: 0, delta: { reasoning_content: " asks" } }] }, + { choices: [{ index: 0, delta: { content: "391" } }] }, + { choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }, + ]; + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + for (const chunk of chunks) controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + const events: Array<{ type: string; text?: string }> = []; + for await (const event of adapterFor(route.provider, route.modelId).parseStream(new Response(stream), createTranslatorBudget())) { + events.push(event); + } + const reasoning = events.filter(e => e.type === "reasoning_raw_delta").map(e => e.text).join(""); + expect(reasoning).toBe("The user asks"); + expect(events.filter(e => e.type === "text_delta").map(e => e.text).join("")).toBe("391"); + }); +}); diff --git a/tests/providers/model-roster-seed-repair.test.ts b/tests/providers/model-roster-seed-repair.test.ts new file mode 100644 index 00000000000..996e8ded9d6 --- /dev/null +++ b/tests/providers/model-roster-seed-repair.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { mergeConfiguredModelsIntoLiveCatalog } from "../../src/codex/catalog/provider-fetch"; +import { projectStartupConfigRepairs } from "../../src/providers/model-rename-startup"; +import { projectStaleModelRosters, STALE_MODEL_ROSTERS } from "../../src/providers/stale-model-roster-migration"; +import { MINIMAX_MODELS, MINIMAX_MODELS_BEFORE_M31 } from "../../src/providers/registry/model-seeds"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +const PREVIEW = "MiniMax-M3.1-Flash-Preview"; + +function minimaxConfig(provider: string, row: Partial): OcxConfig { + return { + providers: { + [provider]: { adapter: "openai-chat", baseUrl: "https://api.minimax.io/v1", ...row }, + }, + } as unknown as OcxConfig; +} + +describe("stale model roster migration", () => { + test("adds the preview to an untouched saved MiniMax seed on both presets", () => { + // A config saved since 2026-07-10 carries exactly this eight-id copy, and enrichment + // never rewrites a present list. MiniMax's /v1/models omits the preview, so without + // this refresh an existing install could never see it. + for (const provider of ["minimax", "minimax-cn"]) { + const config = minimaxConfig(provider, { + models: [...MINIMAX_MODELS_BEFORE_M31], + modelContextWindows: { "MiniMax-M3": 1_000_000 }, + modelDefaultReasoningEfforts: { "MiniMax-M3": "medium" }, + }); + const projection = projectStaleModelRosters(config); + const row = projection.config.providers![provider]!; + expect(projection.changed).toBe(true); + expect(row.models).toEqual(MINIMAX_MODELS); + expect(row.models![0]).toBe(PREVIEW); + expect(row.modelContextWindows).toEqual({ "MiniMax-M3": 1_000_000, [PREVIEW]: 1_000_000 }); + expect(row.modelDefaultReasoningEfforts).toEqual({ "MiniMax-M3": "medium", [PREVIEW]: "max" }); + expect(projection.warnings.join(" ")).toContain(`added ${PREVIEW} to the saved "${provider}" model list`); + } + }); + + test("never creates a per-model container the row does not have", () => { + // Enrichment fills these records all-or-nothing. Creating one here with a single key + // would stop enrichment from seeding MiniMax-M3's own entry. + const projection = projectStaleModelRosters(minimaxConfig("minimax", { models: [...MINIMAX_MODELS_BEFORE_M31] })); + const row = projection.config.providers!.minimax!; + expect(projection.changed).toBe(true); + expect(row.modelContextWindows).toBeUndefined(); + expect(row.modelDefaultReasoningEfforts).toBeUndefined(); + }); + + test("keeps a value the user already saved for the added id", () => { + const projection = projectStaleModelRosters(minimaxConfig("minimax", { + models: [...MINIMAX_MODELS_BEFORE_M31], + modelDefaultReasoningEfforts: { [PREVIEW]: "low" }, + })); + expect(projection.config.providers!.minimax!.modelDefaultReasoningEfforts).toEqual({ [PREVIEW]: "low" }); + }); + + test("leaves a hand-edited roster alone", () => { + const trimmed = MINIMAX_MODELS_BEFORE_M31.filter(id => id !== "MiniMax-M2"); + const reordered = [...MINIMAX_MODELS_BEFORE_M31].reverse(); + for (const models of [trimmed, reordered, ["MiniMax-M3"]]) { + const projection = projectStaleModelRosters(minimaxConfig("minimax", { models: [...models] })); + expect(projection.changed).toBe(false); + expect(projection.config.providers!.minimax!.models).toEqual(models); + } + }); + + test("skips a row that no longer carries the registry adapter", () => { + const projection = projectStaleModelRosters(minimaxConfig("minimax", { + adapter: "anthropic", + models: [...MINIMAX_MODELS_BEFORE_M31], + } as Partial)); + expect(projection.changed).toBe(false); + }); + + test("is idempotent, including through the shared startup repair pass", () => { + const first = projectStartupConfigRepairs(minimaxConfig("minimax", { models: [...MINIMAX_MODELS_BEFORE_M31] })); + expect(first.changed).toBe(true); + const second = projectStartupConfigRepairs(structuredClone(first.config)); + expect(second.changed).toBe(false); + expect(second.config.providers!.minimax!.models).toEqual(MINIMAX_MODELS); + }); + + test("every entry targets the roster the registry seeds today", () => { + for (const entry of STALE_MODEL_ROSTERS) { + const registry = PROVIDER_REGISTRY.find(row => row.id === entry.provider); + expect(registry?.models).toEqual([...entry.to]); + expect(entry.to.length).toBeGreaterThan(entry.from.length); + } + }); +}); + +describe("MiniMax preview catalog retention", () => { + test("a live /models roster that omits the preview does not drop the configured row", () => { + // Probed 2026-09-30: GET /v1/models lists M3 and the M2.x family only, while chat + // completions serve the preview to Token Plan keys. + for (const name of ["minimax", "minimax-cn"]) { + const provider = { adapter: "openai-chat", baseUrl: "https://api.minimax.io/v1" } as OcxProviderConfig; + const live = MINIMAX_MODELS_BEFORE_M31.map(id => ({ id, provider: name })); + const configured = MINIMAX_MODELS.map(id => ({ id, provider: name })); + const { models, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ name, provider, models: live, configured }); + expect(models.map(model => model.id)).toContain(PREVIEW); + expect(droppedConfiguredIds).toEqual([]); + } + }); +}); diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index e0f1dff3241..789bdcc6257 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.test.ts @@ -342,10 +342,13 @@ describe("fetchProviderAccountQuotas", () => { let releaseUsage!: () => void; const usageGate = new Promise(resolve => { releaseUsage = resolve; }); let usageCalls = 0; + let usageStarted!: () => void; + const usageEntered = new Promise(resolve => { usageStarted = resolve; }); globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { const auth = new Headers(init?.headers).get("authorization") ?? ""; if (auth.endsWith("token-first")) { usageCalls += 1; + usageStarted(); await usageGate; return new Response(usageBody(70, 15), { status: 200 }); } @@ -365,8 +368,10 @@ describe("fetchProviderAccountQuotas", () => { }; const reportPromise = fetchProviderQuotaReports(config, true); // Switch active mid-flight before Anthropic responds. - await setActiveAccount("anthropic", second!.id); - releaseUsage(); + try { + await usageEntered; + await setActiveAccount("anthropic", second!.id); + } finally { releaseUsage(); } const switchedReport = await reportPromise; expect(switchedReport.reports).toEqual([]); expect(getCachedProviderAccountQuota("anthropic", first!.id)?.fiveHourPercent).toBe(70); diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 42769999df2..bf73f225c55 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -491,12 +491,14 @@ describe("provider registry parity", () => { }); const minimaxModels = [ + "MiniMax-M3.1-Flash-Preview", "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1", "MiniMax-M2.1-highspeed", "MiniMax-M2", ]; + const splitModels = minimaxModels.slice(1); for (const providerId of ["minimax", "minimax-cn"]) { const entry = PROVIDER_REGISTRY.find(provider => provider.id === providerId); expect(entry?.adapter).toBe("openai-chat"); @@ -504,14 +506,18 @@ describe("provider registry parity", () => { expect(entry?.defaultModel).toBe("MiniMax-M3"); expect(entry?.models).toEqual(minimaxModels); expect(entry?.modelContextWindows?.["MiniMax-M3"]).toBe(1_000_000); + expect(entry?.modelContextWindows?.["MiniMax-M3.1-Flash-Preview"]).toBe(1_000_000); expect(entry?.modelReasoningEfforts?.["MiniMax-M3"]).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(entry?.modelReasoningEfforts?.["MiniMax-M3.1-Flash-Preview"]).toEqual(["low", "medium", "high", "xhigh", "max"]); expect(entry?.modelDefaultReasoningEfforts?.["MiniMax-M3"]).toBe("medium"); + expect(entry?.modelDefaultReasoningEfforts?.["MiniMax-M3.1-Flash-Preview"]).toBe("max"); expect(entry?.modelReasoningEffortMap?.["MiniMax-M3"]).toMatchObject({ low: "disabled", medium: "adaptive", high: "adaptive" }); + expect(entry?.modelReasoningEffortMap?.["MiniMax-M3.1-Flash-Preview"]).toBeUndefined(); expect(entry?.preserveReasoningContentModels).toEqual(minimaxModels); - expect(entry?.reasoningSplitModels).toEqual(minimaxModels); - expect(entry?.reasoningDetailsModels).toEqual(minimaxModels); + expect(entry?.reasoningSplitModels).toEqual(splitModels); + expect(entry?.reasoningDetailsModels).toEqual(splitModels); expect(entry?.thinkingToggleModels).toEqual(["MiniMax-M3"]); - for (const modelId of minimaxModels.slice(1)) { + for (const modelId of minimaxModels.slice(2)) { expect(entry?.modelContextWindows?.[modelId]).toBe(204_800); } } diff --git a/tests/providers/xai/grok-47-build-fast-metadata.test.ts b/tests/providers/xai/grok-47-build-fast-metadata.test.ts index f24f3749024..38cd290f985 100644 --- a/tests/providers/xai/grok-47-build-fast-metadata.test.ts +++ b/tests/providers/xai/grok-47-build-fast-metadata.test.ts @@ -5,8 +5,9 @@ import type { ProviderRegistryEntry } from "../../../src/providers/registry/type // xAI documents Grok 4.7 Fast as "the same model served on faster infrastructure", listed for // Cursor and Grok Build and not available on the public xAI API -// (docs.x.ai/developers/grok-4-7, fetched 2026-09-24). The discovered OAuth id inherits -// grok-4.7's documented facts; its wire pin and service tier stay unclaimed until probed. +// (docs.x.ai/developers/grok-4-7). The discovered OAuth id inherits grok-4.7's documented facts and, +// since the 2026-09-30 probe (devlog/_plan/260930_grok47_build_unify/010_probe-evidence.md), its OAuth +// Responses wire. Its service tier stays unclaimed: priority multiplied its cost for no measured gain. const BASE = "grok-4.7"; const BUILD_FAST = "grok-4.7-build-fast"; @@ -44,15 +45,16 @@ describe("xai grok-4.7-build-fast metadata", () => { }); } - test("claims no lineup slot, wire pin or service tier", () => { + test("claims no lineup slot or service tier, and shares grok-4.7's OAuth wire", () => { const entry = xai(); // Live discovery owns the lineup, so the seed lists stay free of a Cursor/Grok-Build-only id. expect(XAI_MODELS).toContain(BASE); expect(XAI_MODELS).not.toContain(BUILD_FAST); expect(entry.models ?? []).not.toContain(BUILD_FAST); - // Non-vacuous negatives: both claims exist for grok-4.7, and only there. + // Probed: build-fast answers on OAuth Responses exactly like grok-4.7. expect(entry.modelWireDefaults?.[BASE]).toBeDefined(); - expect(entry.modelWireDefaults?.[BUILD_FAST]).toBeUndefined(); + expect(entry.modelWireDefaults?.[BUILD_FAST]).toEqual(entry.modelWireDefaults?.[BASE]); + // Non-vacuous negative: the tier claim exists for grok-4.7, and only there. expect(entry.modelSupportsServiceTier?.[BASE]).toBe(true); expect(entry.modelSupportsServiceTier?.[BUILD_FAST]).toBeUndefined(); }); diff --git a/tests/providers/xai/grok-47-fast-model-wire.test.ts b/tests/providers/xai/grok-47-fast-model-wire.test.ts new file mode 100644 index 00000000000..3527bd06aaa --- /dev/null +++ b/tests/providers/xai/grok-47-fast-model-wire.test.ts @@ -0,0 +1,405 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../../src/config"; +import { XAI_OAUTH_DISCOVERY_URL } from "../../../src/oauth/xai"; +import { saveCredential } from "../../../src/oauth/store"; +import { XAI_GROK_CLI_BASE_URL } from "../../../src/providers/xai-transport"; +import { startServer } from "../../../src/server"; +import { + clearRequestLogsForTests, + getRequestLogEntries, + observeRequestLogsForTests, + type RequestLogEntry, +} from "../../../src/server/request-log"; +import type { OcxConfig, OcxProviderConfig } from "../../../src/types"; +import { readUsageEntries } from "../../../src/usage/log"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../../helpers/remove-tree"; + +const LOGICAL_MODEL = "grok-4.7"; +const FAST_MODEL = "grok-4.7-build-fast"; +const TOKEN_ENDPOINT = "https://auth.x.ai/oauth/token"; +const BACKUP_BASE_URL = "https://grok47-backup.test/v1"; +type Body = Record; +type Server = ReturnType; +interface CapturedSend { url: string; body: Body; authorization: string | null } + +let originalFetch: typeof fetch; +let previousHome: string | undefined; +let testDir: string; +let codexHome: IsolatedCodexHome; +let server: Server | undefined; + +beforeEach(() => { + originalFetch = globalThis.fetch; + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-grok47-wire-")); + process.env.OPENCODEX_HOME = testDir; + codexHome = installIsolatedCodexHome("ocx-grok47-wire-codex-"); + clearRequestLogsForTests(); +}); + +afterEach(async () => { + try { + await server?.stop(true); + } finally { + server = undefined; + globalThis.fetch = originalFetch; + clearRequestLogsForTests(); + codexHome.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(testDir); + } +}); + +function xaiConfig( + authMode: "oauth" | "key" = "oauth", + extra: Partial = {}, + providerExtra: Partial = {}, +): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + codexAutoStart: false, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode, + refreshPolicy: "disabled", + ...(authMode === "key" ? { apiKey: "fake-xai-wire-key" } : {}), + models: [LOGICAL_MODEL], + ...providerExtra, + }, + }, + ...extra, + } as OcxConfig; +} + +function upstreamReply(body: Body, chat: boolean, sequence: number): Response { + const model = body.model; + const id = `resp-grok47-wire-${sequence}`; + const output = [{ + id: `msg-grok47-wire-${sequence}`, type: "message", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "wire fixture reply", annotations: [] }], + }]; + if (chat) { + const choice = { index: 0, message: { role: "assistant", content: "wire fixture reply" }, finish_reason: "stop" }; + const usage = { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }; + if (!body.stream) return Response.json({ id, object: "chat.completion", model, choices: [choice], usage }); + const chunk = { id, object: "chat.completion.chunk", model, choices: [{ + index: 0, delta: { role: "assistant", content: "wire fixture reply" }, finish_reason: "stop", + }], usage }; + return new Response(`data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + } + // Echo a tier despite the absent outbound field: it must not confirm a model serving lane. + const response = { + id, object: "response", status: "completed", model, output, service_tier: "priority", + usage: { input_tokens: 3, output_tokens: 2, total_tokens: 5 }, + }; + if (!body.stream) return Response.json(response); + const events = [ + { type: "response.created", response: { ...response, status: "in_progress", output: [] } }, + { type: "response.output_item.added", output_index: 0, item: { ...output[0], content: [] } }, + { type: "response.content_part.added", item_id: output[0]!.id, output_index: 0, content_index: 0, + part: { type: "output_text", text: "", annotations: [] } }, + { type: "response.output_text.delta", item_id: output[0]!.id, output_index: 0, content_index: 0, + delta: "wire fixture reply" }, + { type: "response.output_item.done", output_index: 0, item: output[0] }, + { 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" }, + }); +} + +async function launch(config = xaiConfig(), statuses: number[] = [], failingEndpoint?: string) { + if (config.providers.xai?.authMode === "oauth") { + await saveCredential("xai", { + access: "fake-xai-old-access", refresh: "fake-xai-refresh", expires: Date.now() + 3_600_000, + accountId: "fake-xai-wire-account", source: "oauth", + }); + } + saveConfig(config); + const sends: CapturedSend[] = []; + const counts = { refresh: 0 }; + globalThis.fetch = (async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = request.url; + if (url === XAI_OAUTH_DISCOVERY_URL) { + return Response.json({ authorization_endpoint: "https://auth.x.ai/oauth/authorize", token_endpoint: TOKEN_ENDPOINT }); + } + if (url === TOKEN_ENDPOINT) { + counts.refresh++; + return Response.json({ access_token: "fake-xai-new-access", refresh_token: "fake-xai-new-refresh", expires_in: 3600 }); + } + const endpoints = [ + `${XAI_GROK_CLI_BASE_URL}/responses`, `${XAI_GROK_CLI_BASE_URL}/chat/completions`, + "https://api.x.ai/v1/responses", "https://api.x.ai/v1/chat/completions", + `${BACKUP_BASE_URL}/responses`, + ]; + if (!endpoints.includes(url)) throw new Error(`Unexpected outbound request: ${url}`); + const body = await request.json() as Body; + sends.push({ url, body, authorization: request.headers.get("authorization") }); + const status = url === failingEndpoint ? 500 : statuses.shift() ?? 200; + if (status !== 200) return Response.json({ error: { message: "fixture rejected request" } }, { status }); + return upstreamReply(body, url.endsWith("/chat/completions"), sends.length); + }) as typeof fetch; + server = startServer(0); + return { server, sends, counts }; +} + +async function post(proxy: Server, body: Body, path = "/v1/responses"): Promise { + // Use the original fetch only for this known loopback server, bypassing the upstream stub. + const response = await originalFetch(new URL(path, proxy.url), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify(body), signal: AbortSignal.timeout(5_000), + }); + const json = await response.json() as Body; + expect(response.status).toBe(200); + return json; +} + +function responsesBody(extra: Body = {}): Body { + return { model: "xai/grok-4.7--fast", input: "hello", stream: false, ...extra }; +} + +function assertFastSend(send: CapturedSend): void { + expect(send.body.model).toBe(FAST_MODEL); + expect(Object.hasOwn(send.body, "service_tier")).toBe(false); +} + +function assertFastReceipts(receiptModel = LOGICAL_MODEL): void { + const log = getRequestLogEntries().at(-1); + const usage = readUsageEntries().at(-1); + for (const receipt of [log, usage]) { + expect(receipt?.model).toBe(receiptModel); + expect(receipt?.wireModel).toBe(FAST_MODEL); + expect(receipt?.attempts).toHaveLength(1); + expect(receipt?.attempts?.[0]?.model).toBe(LOGICAL_MODEL); + expect(receipt?.attempts?.[0]?.tierOutcome).toMatchObject({ + wireKind: "model-variant", wireValue: FAST_MODEL, fastOutcome: "applied", + confirmation: "assumed", responseTierAuthoritative: false, + }); + } +} + +describe("Grok 4.7 Fast serialized upstream model", () => { + test.each([ + { label: "--fast selector", config: xaiConfig(), body: responsesBody() }, + { label: "caller priority tier", config: xaiConfig(), body: responsesBody({ model: "xai/grok-4.7", service_tier: "priority" }) }, + { label: "global fastMode", config: xaiConfig("oauth", { fastMode: true }), body: responsesBody({ model: "xai/grok-4.7" }) }, + ])("OAuth Responses $label sends build-fast without a tier and preserves logical receipts", async ({ config, body }) => { + const fixture = await launch(config); + const json = await post(fixture.server, body); + expect(fixture.sends).toHaveLength(1); + expect(fixture.sends[0]!.url).toBe(`${XAI_GROK_CLI_BASE_URL}/responses`); + assertFastSend(fixture.sends[0]!); + expect(json.model).toBe(FAST_MODEL); + assertFastReceipts(); + }); + + test("plain OAuth Responses keeps grok-4.7 without a tier", async () => { + const fixture = await launch(); + const json = await post(fixture.server, responsesBody({ model: "xai/grok-4.7" })); + expect(fixture.sends).toHaveLength(1); + expect(fixture.sends[0]!.body.model).toBe(LOGICAL_MODEL); + expect(Object.hasOwn(fixture.sends[0]!.body, "service_tier")).toBe(false); + expect(json.model).toBe(LOGICAL_MODEL); + }); + + test("key-auth --fast keeps grok-4.7 and priority on api.x.ai", async () => { + const fixture = await launch(xaiConfig("key")); + await post(fixture.server, responsesBody()); + expect(fixture.sends).toHaveLength(1); + expect(fixture.sends[0]).toMatchObject({ + url: "https://api.x.ai/v1/chat/completions", authorization: "Bearer fake-xai-wire-key", + body: { model: LOGICAL_MODEL, service_tier: "priority" }, + }); + }); + + test("fastMode false suppresses --fast without changing the OAuth model", async () => { + const fixture = await launch(xaiConfig("oauth", { fastMode: false })); + await post(fixture.server, responsesBody()); + expect(fixture.sends).toHaveLength(1); + expect(fixture.sends[0]!.body.model).toBe(LOGICAL_MODEL); + expect(Object.hasOwn(fixture.sends[0]!.body, "service_tier")).toBe(false); + }); + + test.each([ + { label: "Chat Completions", path: "/v1/chat/completions", body: { + model: "xai/grok-4.7--fast", messages: [{ role: "user", content: "hello" }], stream: false, + } }, + { label: "Claude Messages", path: "/v1/messages", body: { + model: "xai/grok-4.7--fast", messages: [{ role: "user", content: "hello" }], max_tokens: 128, stream: false, + } }, + ])("$label ingress sends build-fast and never shows it to the client", async ({ path, body }) => { + const fixture = await launch(); + const json = await post(fixture.server, body, path); + expect(fixture.sends).toHaveLength(1); + assertFastSend(fixture.sends[0]!); + // Translated deliveries echo the client's own selector (chat-completions.ts, claude-messages.ts); + // the serving-lane id stays internal. + expect(json.model).toBe((body as Body).model); + expect(JSON.stringify(json)).not.toContain(FAST_MODEL); + assertFastReceipts(); + }); + + test("OAuth reactive 401 replay sends build-fast without a tier on both sends", async () => { + const fixture = await launch(xaiConfig(), [401, 200]); + await post(fixture.server, responsesBody()); + expect(fixture.sends).toHaveLength(2); + fixture.sends.forEach(assertFastSend); + expect(fixture.sends.map(send => send.authorization)).toEqual([ + "Bearer fake-xai-old-access", "Bearer fake-xai-new-access", + ]); + expect(fixture.counts.refresh).toBe(1); + assertFastReceipts(); + expect(readUsageEntries().at(-1)?.attempts?.[0]?.sendCount).toBe(2); + }); + + test("WebSocket response.create sends build-fast without a tier and preserves logical receipts", async () => { + const fixture = await launch(xaiConfig("oauth", { websockets: true })); + const url = new URL("/v1/responses", fixture.server.url); + url.protocol = "ws:"; + const socket = new WebSocket(url); + let unsubscribe = () => {}; + let timer: ReturnType | undefined; + try { + const completed = await new Promise((resolve, reject) => { + let terminal: Body | undefined; + let finalized: RequestLogEntry | undefined; + const settle = () => { if (terminal && finalized) resolve(terminal); }; + unsubscribe = observeRequestLogsForTests(entry => { + if (entry.provider !== "xai" || entry.model !== LOGICAL_MODEL) return; + finalized = entry; + settle(); + }); + timer = setTimeout(() => reject(new Error("Grok Fast WebSocket turn or receipt timed out")), 4_000); + socket.addEventListener("open", () => { + socket.send(JSON.stringify({ type: "response.create", ...responsesBody({ stream: true }) })); + }, { once: true }); + socket.addEventListener("message", event => { + try { + const payload = JSON.parse(String(event.data)) as Body; + if (payload.type === "error" || payload.type === "response.failed") { + reject(new Error(`Grok Fast WebSocket failed: ${JSON.stringify(payload)}`)); + } else if (payload.type === "response.completed") { + terminal = payload.response as Body; + settle(); + } + } catch (error) { reject(error); } + }); + socket.addEventListener("error", () => reject(new Error("Grok Fast WebSocket connection failed")), { once: true }); + socket.addEventListener("close", () => { + if (!terminal) reject(new Error("Grok Fast WebSocket closed before completion")); + }, { once: true }); + }); + expect(completed.status).toBe("completed"); + expect(fixture.sends).toHaveLength(1); + expect(fixture.sends[0]!.url).toBe(`${XAI_GROK_CLI_BASE_URL}/responses`); + assertFastSend(fixture.sends[0]!); + assertFastReceipts(); + expect(getRequestLogEntries().at(-1)).toMatchObject({ status: 200, terminalStatus: "completed" }); + } finally { + clearTimeout(timer); + unsubscribe(); + socket.close(); + } + }); + + test.each([ + { label: "global fastMode", fastMode: true, tier: {} }, + { label: "caller priority", fastMode: undefined, tier: { service_tier: "priority" } }, + ])("combo OAuth child with $label sends build-fast without a tier", async ({ label, fastMode, tier }) => { + const comboId = label === "caller priority" ? "fast-child-caller" : "fast-child-global"; + const config = xaiConfig("oauth", { + fastMode, + combos: { [comboId]: { strategy: "failover", targets: [{ provider: "xai", model: LOGICAL_MODEL }] } }, + }); + const fixture = await launch(config); + await post(fixture.server, responsesBody({ model: `combo/${comboId}`, ...tier })); + expect(fixture.sends).toHaveLength(1); + expect(fixture.sends[0]!.url).toBe(`${XAI_GROK_CLI_BASE_URL}/responses`); + assertFastSend(fixture.sends[0]!); + assertFastReceipts(`combo/${comboId}`); + }); + + test("combo OAuth 500 fallback keeps the backup model and its priority tier without a build-fast leak", async () => { + // Combo targets select provider entries, not auth modes; one xai entry cannot mix OAuth and key auth. + const backupModel = "backup-model"; + const config = xaiConfig("oauth", { + combos: { "fast-failover": { strategy: "failover", targets: [ + { provider: "xai", model: LOGICAL_MODEL }, { provider: "backup", model: backupModel }, + ] } }, + }); + config.providers.backup = { + adapter: "openai-responses", baseUrl: BACKUP_BASE_URL, authMode: "key", + apiKey: "fake-backup-wire-key", models: [backupModel], supportsServiceTier: true, + }; + const fixture = await launch(config, [], `${XAI_GROK_CLI_BASE_URL}/responses`); + const json = await post(fixture.server, responsesBody({ + model: "combo/fast-failover", service_tier: "priority", + })); + const xaiSends = fixture.sends.slice(0, -1); + expect(xaiSends.length).toBeGreaterThan(0); + for (const send of xaiSends) { + expect(send.url).toBe(`${XAI_GROK_CLI_BASE_URL}/responses`); + assertFastSend(send); + } + expect(fixture.sends.at(-1)).toMatchObject({ + url: `${BACKUP_BASE_URL}/responses`, authorization: "Bearer fake-backup-wire-key", + body: { model: backupModel, service_tier: "priority" }, + }); + expect(JSON.stringify(fixture.sends.at(-1)!.body)).not.toContain(FAST_MODEL); + expect(json.model).toBe(backupModel); + for (const receipt of [getRequestLogEntries().at(-1), readUsageEntries().at(-1)]) { + expect(receipt).toMatchObject({ + model: "combo/fast-failover", resolvedModel: backupModel, + attempts: [ + { provider: "xai", model: LOGICAL_MODEL, status: 500 }, + { provider: "backup", model: backupModel, status: 200 }, + ], + }); + expect(receipt?.wireModel).not.toBe(FAST_MODEL); + expect(receipt?.attempts?.[0]?.sendCount).toBe(xaiSends.length); + expect(receipt?.attempts?.[1]?.sendCount).toBe(1); + expect(receipt?.attempts?.[0]?.tierOutcome).toMatchObject({ wireKind: "model-variant", wireValue: FAST_MODEL }); + expect(receipt?.attempts?.[1]?.tierOutcome).toMatchObject({ wireKind: "service-tier", wireValue: "priority" }); + } + }); + + test.each(["low", "high", "xhigh"])("Fast sends the same reasoning as the plain request (%s)", async effort => { + const fixture = await launch(); + await post(fixture.server, responsesBody({ model: "xai/grok-4.7", reasoning: { effort } })); + await post(fixture.server, responsesBody({ reasoning: { effort } })); + expect(fixture.sends).toHaveLength(2); + expect(fixture.sends[0]!.body.model).toBe(LOGICAL_MODEL); + assertFastSend(fixture.sends[1]!); + expect(fixture.sends[1]!.body.reasoning).toEqual(fixture.sends[0]!.body.reasoning); + }); + + test.each([ + { label: "global Fast", config: xaiConfig("oauth", { fastMode: true }), tier: {} }, + { label: "caller priority", config: xaiConfig(), tier: { service_tier: "priority" } }, + ])("routed compaction with $label sends build-fast without a tier", async ({ config, tier }) => { + const fixture = await launch(config); + const json = await post(fixture.server, responsesBody({ + model: "xai/grok-4.7", ...tier, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Retain task progress." }] }, + { type: "compaction_trigger" }], + })); + expect(fixture.sends).toHaveLength(1); + assertFastSend(fixture.sends[0]!); + expect(JSON.stringify(fixture.sends[0]!.body)).not.toContain("compaction_trigger"); + expect(JSON.stringify(fixture.sends[0]!.body)).toContain("CONTEXT CHECKPOINT COMPACTION"); + expect(json.output).toEqual(expect.arrayContaining([expect.objectContaining({ type: "compaction" })])); + assertFastReceipts(); + }); +}); diff --git a/tests/providers/xai/grok-47-fast-model.test.ts b/tests/providers/xai/grok-47-fast-model.test.ts new file mode 100644 index 00000000000..d7296400df9 --- /dev/null +++ b/tests/providers/xai/grok-47-fast-model.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { createResponsesPassthroughAdapter } from "../../../src/adapters/openai-responses"; +import { parseRequest } from "../../../src/responses/parser"; +import { XAI_GROK_CLI_BASE_URL } from "../../../src/providers/xai-transport"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import { shouldExposeProviderModel } from "../../../src/codex/catalog/model-visibility"; +import { providerConfigSeed } from "../../../src/providers/derive"; +import { + createAdapterTierMetadata, + decideTier, + emittedFastWire, + fastWireDeclarationError, + tierObservationContext, +} from "../../../src/providers/fastwire"; +import { getProviderRegistryEntry } from "../../../src/providers/registry"; +import { fastPolicyForModel } from "../../../src/providers/service-tier"; +import { + applyXaiOauthFastModel, + XAI_OAUTH_FAST_MODELS, + XAI_OAUTH_FAST_VARIANT_IDS, + xaiOauthFastModel, +} from "../../../src/providers/xai-fast-model"; +import type { OcxParsedRequest, OcxProviderConfig, TierDecision, TierObservationContext } from "../../../src/types"; +import { estimateAttemptCost } from "../../../src/usage/cost"; +import { normalizePersistedUsageRow, type PersistedUsageEntry } from "../../../src/usage/log"; + +const BASE = "grok-4.7"; +const VARIANT = "grok-4.7-build-fast"; +const SET: TierDecision = { kind: "set", value: "priority" }; +const VARIANT_WIRE = { + kind: "model-variant" as const, + canonicalToWire: { priority: VARIANT }, + foreignCallerTiers: "drop" as const, +}; +type Route = Parameters[1]; + +function oauthRoute(modelId = BASE): Route { + return { providerName: "xai", provider: { authMode: "oauth" }, modelId }; +} + +function observation(): TierObservationContext { + return { + capability: true, + eligibility: "eligible", + fastWire: { + kind: "service-tier", + canonicalToWire: { priority: "priority" }, + foreignCallerTiers: "verbatim", + }, + demandDecision: "inherit", + callerTier: "priority", + responseTierAuthoritative: true, + }; +} + +function parsedFor( + decision: TierDecision | undefined = SET, + modelId = BASE, +): OcxParsedRequest & { _rawBody: Record } { + return { + modelId, + stream: true, + context: { systemPrompt: [], messages: [{ role: "user", content: "hello" }] }, + options: { + serviceTier: "priority", + ...(decision ? { tierDecision: decision } : {}), + tierObservation: observation(), + }, + _rawBody: { model: modelId, service_tier: "priority", input: "hello" }, + }; +} + +function decisionChain(fastMode?: boolean, callerTier?: string) { + const entry = getProviderRegistryEntry("xai"); + if (!entry) throw new Error("xai registry entry missing"); + const provider = { ...providerConfigSeed(entry), authMode: "oauth" as const }; + const route = { providerName: "xai", provider, modelId: BASE }; + const policy = fastPolicyForModel(provider, BASE, "xai", "responses"); + const decision = decideTier(policy, fastMode, callerTier); + const obs = tierObservationContext(policy, fastMode, callerTier, true); + const parsed = parsedFor(decision); + parsed.options.serviceTier = callerTier; + parsed.options.tierObservation = obs; + if (callerTier === undefined) delete parsed._rawBody.service_tier; + else parsed._rawBody.service_tier = callerTier; + const logCtx: { wireModel?: string } = {}; + applyXaiOauthFastModel(parsed, route, logCtx); + return { parsed, route, policy, decision, obs, logCtx }; +} + +function fastMetadata(fastMode?: boolean, callerTier?: string) { + const chain = decisionChain(fastMode, callerTier); + const tracker = createAdapterTierMetadata( + chain.parsed.options.tierObservation, + chain.parsed.options.tierDecision, + ...emittedFastWire(chain.parsed, { model: VARIANT }), + ); + if (!tracker) throw new Error("Fast observation metadata missing"); + return { ...chain, tracker }; +} + +describe("xAI OAuth Grok 4.7 serialized Fast model", () => { + test("OAuth set swaps only the serialized model and preserves caller intent", () => { + const parsed = parsedFor(); + const route = oauthRoute(); + const originalRoute = structuredClone(route); + const originalObservation = structuredClone(parsed.options.tierObservation); + const logCtx: { wireModel?: string } = {}; + + applyXaiOauthFastModel(parsed, route, logCtx); + + expect(parsed._rawBody).toEqual({ model: VARIANT, service_tier: "priority", input: "hello" }); + expect(parsed.modelId).toBe(BASE); + expect(route).toEqual(originalRoute); + expect(parsed._wireModelOverride).toBe(VARIANT); + expect(parsed.options.serviceTier).toBe("priority"); + expect(parsed.options.tierDecision).toEqual({ kind: "drop" }); + expect(parsed.options.tierObservation).toEqual({ + ...originalObservation, + fastWire: VARIANT_WIRE, + responseTierAuthoritative: false, + }); + expect(logCtx.wireModel).toBe(VARIANT); + }); + + const unchangedCases: { label: string; decision?: TierDecision; route: Route }[] = [ + { label: "no decision", route: oauthRoute() }, + { label: "forward-caller decision", decision: { kind: "forward-caller" }, route: oauthRoute() }, + { label: "drop decision", decision: { kind: "drop" }, route: oauthRoute() }, + { label: "key auth", decision: SET, route: { ...oauthRoute(), provider: { authMode: "key" } } }, + { label: "implicit key auth", decision: SET, route: { ...oauthRoute(), provider: {} } }, + { label: "another provider", decision: SET, route: { ...oauthRoute(), providerName: "cursor" } }, + { label: "Grok 4.6", decision: SET, route: oauthRoute("grok-4.6") }, + { label: "explicit build-fast route", decision: SET, route: oauthRoute(VARIANT) }, + { + label: "operator-declared FastWire", + decision: { kind: "set", value: "custom-priority" }, + route: { + ...oauthRoute(), + provider: { + authMode: "oauth", + fastWire: { kind: "service-tier", canonicalToWire: { priority: "custom-priority" }, foreignCallerTiers: "verbatim" }, + }, + }, + }, + ]; + for (const { label, decision, route } of unchangedCases) { + test(`${label} leaves request and log unchanged`, () => { + const parsed = parsedFor(decision, route.modelId); + // parsedFor's default is a set decision; the absent-decision case must really be absent. + if (decision === undefined) delete parsed.options.tierDecision; + const before = structuredClone(parsed); + const routeBefore = structuredClone(route); + const logCtx = { wireModel: "another-normalizer-model" }; + applyXaiOauthFastModel(parsed, route, logCtx); + expect(parsed).toEqual(before); + expect(route).toEqual(routeBefore); + expect(logCtx).toEqual({ wireModel: "another-normalizer-model" }); + }); + } + + test("same-id key-auth re-normalization restores the model and removes its log annotation", () => { + const parsed = parsedFor(); + const logCtx: { wireModel?: string } = {}; + applyXaiOauthFastModel(parsed, oauthRoute(), logCtx); + expect(parsed._rawBody.model).toBe(VARIANT); + expect(logCtx.wireModel).toBe(VARIANT); + // A fresh final-route decision can still request priority on the key-auth fallback. + parsed.options.tierDecision = SET; + applyXaiOauthFastModel(parsed, { ...oauthRoute(), provider: { authMode: "key" } }, logCtx); + expect(parsed._rawBody.model).toBe(BASE); + expect(parsed.modelId).toBe(BASE); + expect(Object.hasOwn(parsed, "_wireModelOverride")).toBe(false); + expect(Object.hasOwn(logCtx, "wireModel")).toBe(false); + expect(parsed.options.tierDecision).toEqual(SET); + expect(parsed._rawBody.service_tier).toBe("priority"); + }); + + test("re-normalization preserves a wireModel installed by another normalizer", () => { + const parsed = parsedFor(); + const logCtx: { wireModel?: string } = {}; + applyXaiOauthFastModel(parsed, oauthRoute(), logCtx); + logCtx.wireModel = "another-normalizer-model"; + applyXaiOauthFastModel(parsed, { ...oauthRoute(), provider: { authMode: "key" } }, logCtx); + expect(parsed._rawBody.model).toBe(BASE); + expect(parsed._wireModelOverride).toBeUndefined(); + expect(logCtx.wireModel).toBe("another-normalizer-model"); + }); +}); + +describe("Grok 4.7 Fast policy and adapter observation contract", () => { + test.each([ + { label: "caller priority", fastMode: undefined, callerTier: "priority" }, + { label: "global fastMode", fastMode: true, callerTier: undefined }, + ])("$label reaches an applied, assumed model variant through the real decision chain", ({ fastMode, callerTier }) => { + const { parsed, route, policy, decision, logCtx, tracker } = fastMetadata(fastMode, callerTier); + expect(policy.capability).toBe(true); + expect(policy.eligibility).toBe("eligible"); + expect(decision).toEqual({ kind: "set", value: "priority" }); + expect(parsed.options.tierDecision).toEqual({ kind: "drop" }); + expect(parsed._rawBody.model).toBe(VARIANT); + expect(parsed.modelId).toBe(BASE); + expect(route.modelId).toBe(BASE); + expect(logCtx.wireModel).toBe(VARIANT); + expect(emittedFastWire(parsed, { model: VARIANT })).toEqual(["model-variant", VARIANT]); + expect(tracker.outcome).toEqual({ + canonical: "priority", + wireKind: "model-variant", + wireValue: VARIANT, + fastOutcome: "applied", + confirmation: "assumed", + responseTierAuthoritative: false, + }); + const beforeEcho = structuredClone(tracker.outcome); + for (const echo of ["priority", "default"]) { + tracker.observeResponseServiceTier(echo); + expect(tracker.outcome).toEqual({ ...beforeEcho, responseServiceTier: echo }); + } + }); + + test("fastMode false drops caller priority without swapping the model", () => { + const { parsed, decision, logCtx } = decisionChain(false, "priority"); + expect(decision).toEqual({ kind: "drop" }); + expect(parsed.options.tierDecision).toEqual({ kind: "drop" }); + expect(parsed._rawBody.model).toBe(BASE); + expect(parsed._wireModelOverride).toBeUndefined(); + expect(logCtx.wireModel).toBeUndefined(); + expect(emittedFastWire(parsed, { model: BASE })).toEqual([null, null]); + }); + + test.each([ + { label: "priority tier", body: { model: BASE, service_tier: "priority" }, expected: ["service-tier", "priority"] }, + { label: "foreign tier", body: { model: BASE, service_tier: "flex" }, expected: ["service-tier", "flex"] }, + { label: "absent tier", body: { model: BASE }, expected: [null, null] }, + ])("without an override emittedFastWire reads $label", ({ body, expected }) => { + expect(emittedFastWire(parsedFor(), body)).toEqual(expected); + }); + + test("an override with a different serialized model falls back to the actual tier", () => { + const { parsed } = decisionChain(undefined, "priority"); + expect(parsed._wireModelOverride).toBe(VARIANT); + expect(emittedFastWire(parsed, { model: BASE, service_tier: "flex" })).toEqual(["service-tier", "flex"]); + expect(emittedFastWire(parsed, { model: BASE })).toEqual([null, null]); + }); + + test("an override alone cannot claim a model-variant wire without its observation", () => { + const parsed = parsedFor(); + parsed._wireModelOverride = VARIANT; + expect(emittedFastWire(parsed, { model: VARIANT, service_tier: "priority" })) + .toEqual(["service-tier", "priority"]); + }); +}); + +describe("Grok 4.7 Fast usage contracts", () => { + test("JSON log round trip preserves model-variant outcomes on the row and its attempt", () => { + const { tracker, logCtx } = fastMetadata(undefined, "priority"); + tracker.observeResponseServiceTier("priority"); + const outcome = structuredClone(tracker.outcome); + const entry: PersistedUsageEntry = { + requestId: "test-grok47-fast", + timestamp: 1_800_000_000_000, + provider: "xai", + model: BASE, + wireModel: logCtx.wireModel, + status: 200, + durationMs: 10, + usageStatus: "reported", + tierOutcome: outcome, + attempts: [{ + ordinal: 1, + provider: "xai", + model: BASE, + adapter: "openai-responses", + credentialSource: "grok-oauth", + status: 200, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + usage: { inputTokens: 1_000, outputTokens: 100 }, + tierOutcome: outcome, + }], + }; + const normalized = normalizePersistedUsageRow(JSON.parse(JSON.stringify(entry))); + expect(normalized).toBeDefined(); + expect(normalized?.model).toBe(BASE); + expect(normalized?.wireModel).toBe(VARIANT); + expect(normalized?.tierOutcome).toEqual(outcome); + expect(normalized?.attempts).toHaveLength(1); + expect(normalized?.attempts?.[0]?.model).toBe(BASE); + expect(normalized?.attempts?.[0]?.tierOutcome).toEqual(outcome); + }); + + test("non-authoritative model-variant priority echo keeps base pricing while confirmed priority costs more", () => { + const { tracker, obs, decision } = fastMetadata(undefined, "priority"); + tracker.observeResponseServiceTier("priority"); + expect(tracker.outcome).toMatchObject({ + canonical: "priority", + wireKind: "model-variant", + fastOutcome: "applied", + confirmation: "assumed", + responseTierAuthoritative: false, + responseServiceTier: "priority", + }); + const confirmed = createAdapterTierMetadata(obs, decision, "service-tier", "priority"); + if (!confirmed) throw new Error("service-tier control metadata missing"); + confirmed.observeResponseServiceTier("priority"); + expect(confirmed.outcome.confirmation).toBe("confirmed"); + const attempt = { + ordinal: 1, + provider: "xai", + model: BASE, + usageStatus: "reported" as const, + usage: { inputTokens: 10_000, outputTokens: 1_000 }, + }; + // Disable user overlays explicitly so local operator pricing cannot alter the oracle. + const base = estimateAttemptCost(attempt, undefined, undefined, []); + const variant = estimateAttemptCost({ ...attempt, tierOutcome: tracker.outcome }, undefined, undefined, []); + const priority = estimateAttemptCost({ ...attempt, tierOutcome: confirmed.outcome }, undefined, undefined, []); + expect(base).not.toBeNull(); + expect(variant).not.toBeNull(); + expect(priority).not.toBeNull(); + if (!base || !variant || !priority) throw new Error("xAI Grok 4.7 price missing"); + expect(base.cost.total).toBeGreaterThan(0); + expect(variant.cost).toEqual(base.cost); + expect(variant.priorityMultiplier).toBeUndefined(); + expect(priority.cost.total).toBeGreaterThan(base.cost.total); + expect(priority.priorityMultiplier).toBeGreaterThan(1); + }); +}); + +describe("Grok 4.7 Fast visibility and config boundaries", () => { + test("the hidden variant set equals the OAuth model map values", () => { + expect(XAI_OAUTH_FAST_MODELS).toEqual({ [BASE]: VARIANT }); + expect([...XAI_OAUTH_FAST_VARIANT_IDS].sort()).toEqual(Object.values(XAI_OAUTH_FAST_MODELS).sort()); + expect(xaiOauthFastModel("xai", { authMode: "oauth" }, BASE)).toBe(VARIANT); + expect(xaiOauthFastModel("xai", { authMode: "key" }, BASE)).toBeUndefined(); + expect(xaiOauthFastModel("xai", {}, BASE)).toBeUndefined(); + expect(xaiOauthFastModel("cursor", { authMode: "oauth" }, BASE)).toBeUndefined(); + expect(xaiOauthFastModel("xai", { authMode: "oauth" }, VARIANT)).toBeUndefined(); + expect(xaiOauthFastModel("xai", { authMode: "oauth" }, "grok-4.6")).toBeUndefined(); + }); + + test.each([ + { provider: "xai", model: VARIANT, visible: false }, + { provider: "xai", model: BASE, visible: true }, + { provider: "cursor", model: VARIANT, visible: true }, + { provider: "opencode-go", model: VARIANT, visible: true }, + { provider: "opencode-go", model: BASE, visible: true }, + ])("$provider/$model visibility is $visible", ({ provider, model, visible }) => { + expect(shouldExposeProviderModel(provider, model)).toBe(visible); + }); + + test("user config rejects the internal-only model-variant FastWire kind", () => { + const declaration = { canonicalToWire: { priority: "x" }, foreignCallerTiers: "drop" }; + expect(fastWireDeclarationError({ fastWire: { ...declaration, kind: "service-tier" } })).toBeNull(); + expect(fastWireDeclarationError({ fastWire: { ...declaration, kind: "model-variant" } })) + .toBe("fastWire.kind must be service-tier, anthropic-speed, or cursor-variant"); + }); +}); + + +describe("adapters key policy on the logical id while serializing the lane id", () => { + const oauthXai = (extra: Partial): OcxProviderConfig => ({ + adapter: "openai-responses", baseUrl: XAI_GROK_CLI_BASE_URL, authMode: "oauth", ...extra, + } as OcxProviderConfig); + const fastParsed = (effort: string): OcxParsedRequest => { + const parsed = parseRequest({ model: BASE, input: [{ role: "user", content: "OK" }], reasoning: { effort } }); + parsed.options.tierDecision = { kind: "drop" }; + parsed._wireModelOverride = VARIANT; + (parsed._rawBody as Record).model = VARIANT; + return parsed; + }; + const passthroughBody = (provider: OcxProviderConfig, parsed: OcxParsedRequest) => + JSON.parse(withTestTranslatorBudget(createResponsesPassthroughAdapter(provider)).buildRequest(parsed).body as string); + const chatBody = async (provider: OcxProviderConfig, parsed: OcxParsedRequest) => + JSON.parse((await createOpenAIChatAdapter({ ...provider, adapter: "openai-chat", apiKey: "fake-oauth-access" }).buildRequest(parsed)).body as string); + + test("passthrough: a ladder declared only for grok-4.7 governs the Fast request", () => { + const body = passthroughBody(oauthXai({ modelReasoningEfforts: { [BASE]: [] } }), fastParsed("high")); + expect(body.model).toBe(VARIANT); + expect(body).not.toHaveProperty("service_tier"); + expect(body.reasoning?.effort).toBeUndefined(); + }); + + test("passthrough: a ladder declared only for the lane id is not consulted", () => { + const body = passthroughBody(oauthXai({ modelReasoningEfforts: { [VARIANT]: [] } }), fastParsed("high")); + expect(body.model).toBe(VARIANT); + expect(body.reasoning?.effort).toBe("high"); + }); + + test("openai-chat: an effort map keyed to grok-4.7 applies and the model line carries the lane id", async () => { + const provider = oauthXai({ + modelReasoningEfforts: { [BASE]: ["low", "high"] }, + modelReasoningEffortMap: { [BASE]: { high: "low" } }, + }); + const body = await chatBody(provider, fastParsed("high")); + expect(body.model).toBe(VARIANT); + expect(body).not.toHaveProperty("service_tier"); + expect(body.reasoning_effort).toBe("low"); + }); + + test("openai-chat: an effort map keyed only to the lane id is ignored", async () => { + const provider = oauthXai({ + modelReasoningEfforts: { [BASE]: ["low", "high"] }, + modelReasoningEffortMap: { [VARIANT]: { high: "low" } }, + }); + const body = await chatBody(provider, fastParsed("high")); + expect(body.model).toBe(VARIANT); + expect(body.reasoning_effort).toBe("high"); + }); +}); diff --git a/tests/server/server-kiro-completion-e2e.test.ts b/tests/server/server-kiro-completion-e2e.test.ts index 4d35b2100d3..12f0566ddeb 100644 --- a/tests/server/server-kiro-completion-e2e.test.ts +++ b/tests/server/server-kiro-completion-e2e.test.ts @@ -131,7 +131,7 @@ function anthropicEvents(sse: string): Array<{ name: string; data: Record { - test("/v1/responses keeps progress nonterminal and lets only the bounded fallback complete", async () => { + test("/v1/responses releases only the final answer after bounded validation", async () => { const upstream = scriptedKiroUpstream([ [textFrame("Checking the workspace."), eventFrame("meteringEvent", { unit: "credit", usage: 0.04582331509121062 })], [...completionFrames("The workspace is ready."), eventFrame("meteringEvent", { unit: "credit", amount: 0.01 })], @@ -155,14 +155,13 @@ describe("Kiro completion through public server endpoints", () => { const events = responseEvents(wire); const text = events.filter(event => event.name === "response.output_text.delta"); expect(text.map(event => [event.data.delta, event.data.phase])).toEqual([ - ["Checking the workspace.", undefined], ["The workspace is ready.", undefined], ]); const completed = events.filter(event => event.name === "response.completed"); expect(completed).toHaveLength(1); expect(events.at(-1)?.name).toBe("response.completed"); const messages = completed[0].data.response.output.filter((item: { type: string }) => item.type === "message"); - expect(messages.map((item: { phase?: string }) => item.phase)).toEqual(["commentary", "final_answer"]); + expect(messages.map((item: { phase?: string }) => item.phase)).toEqual(["final_answer"]); expect(wire).not.toContain(KIRO_COMPLETION_TOOL_NAME); const expectedCredits = 0.04582331509121062 + 0.01; @@ -212,7 +211,7 @@ describe("Kiro completion through public server endpoints", () => { const deltas = events .filter(event => event.name === "content_block_delta" && event.data.delta?.type === "text_delta") .map(event => event.data.delta.text); - expect(deltas).toEqual(["I am checking the Claude task.", "The Claude task is complete."]); + expect(deltas).toEqual(["The Claude task is complete."]); expect(events.filter(event => event.name === "message_delta")).toHaveLength(1); expect(events.find(event => event.name === "message_delta")?.data.delta.stop_reason).toBe("end_turn"); expect(events.at(-1)?.name).toBe("message_stop"); diff --git a/tests/server/startup-health-packaged-probe.test.ts b/tests/server/startup-health-packaged-probe.test.ts new file mode 100644 index 00000000000..c3df5f4883f --- /dev/null +++ b/tests/server/startup-health-packaged-probe.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { helperPath, repoRoot } from "../helpers/repo-root"; + +// Exercise the default cache reader's real subprocess from a compiled executable. +// A unit test of selfLaunchArgv alone misses a caller that still passes $bunfs source. +test("packaged startup probe is fresh before and after replacing its bundled executable", () => { + const scratch = mkdtempSync(join(tmpdir(), "ocx-packaged-startup-")); + try { + const name = process.platform === "win32" ? "ocx.exe" : "ocx"; + const built = join(scratch, `build-${name}`); + const bundled = join(scratch, "OpenCodex.app", "Contents", "MacOS", name); + mkdirSync(join(bundled, ".."), { recursive: true }); + const build = Bun.spawnSync([process.execPath, "build", "--compile", helperPath("startup-health-packaged-child.ts"), "--outfile", built], { + cwd: repoRoot(), stdout: "pipe", stderr: "pipe", timeout: 60_000, + }); + expect(build.exitCode, build.stderr.toString()).toBe(0); + if (process.platform === "darwin") { + // Match build-standalone: Bun's linker signature may omit its embedded payload. + const sign = Bun.spawnSync(["/usr/bin/codesign", "--force", "--sign", "-", built], { stdout: "pipe", stderr: "pipe" }); + expect(sign.exitCode, sign.stderr.toString()).toBe(0); + } + for (let install = 0; install < 2; install++) { + copyFileSync(built, bundled); + const home = join(scratch, `home-${install}`); + mkdirSync(join(home, ".codex"), { recursive: true }); + const result = Bun.spawnSync([bundled, "cached"], { + cwd: scratch, env: { ...process.env, HOME: home, USERPROFILE: home, + OPENCODEX_HOME: join(home, ".opencodex"), CODEX_HOME: join(home, ".codex") }, + stdout: "pipe", stderr: "pipe", timeout: 25_000, + }); + expect(result.exitCode, JSON.stringify({ signal: result.signalCode, error: result.error?.message, stderr: result.stderr.toString() })).toBe(0); + const health = JSON.parse(result.stdout.toString().trim().split(/\r?\n/).at(-1)!); + expect(health).toMatchObject({ status: "native", diagnosticStale: false, rebootSafe: true }); + } + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +}, 120_000); diff --git a/tests/service/autostart-health.test.ts b/tests/service/autostart-health.test.ts index 5c96349515b..0f093bc9a0a 100644 --- a/tests/service/autostart-health.test.ts +++ b/tests/service/autostart-health.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { deriveDesktopStartup } from "../../src/service/desktop-startup"; import { collectStartupHealth, deriveStartupHealth, formatStartupRoutingDetail, injectedRoutingRestartWarningLines, startupHealthSummary } from "../../src/codex/autostart-health"; import { unusedProxyWarningLines } from "../../src/cli/status"; import { classifyCodexRouting, hasInjectedCodexRouting } from "../../src/codex/inject"; @@ -676,3 +677,28 @@ describe("routing adoption (#4550)", () => { })).toEqual({ status: "enumerated", processes: [{ pid: 4242, commandLine: "codex chat" }] }); }); }); + +describe("macOS desktop startup protection", () => { + const facts = { owned: true, loginEnabled: true, running: true }; + test("credits verified desktop supervision without claiming a CLI service", () => { + const desktop = deriveDesktopStartup(facts); + const health = deriveStartupHealth({ ...base, platform: "darwin", desktop }); + expect(health).toMatchObject({ protection: "desktop", rebootSafe: true, status: "protected", serviceInstalled: false }); + expect(startupHealthSummary(health)).toContain("desktop app"); + }); + test("missing ownership, login registration or running supervisor never grants protection", () => { + for (const key of ["owned", "loginEnabled", "running"] as const) { + const desktop = deriveDesktopStartup({ ...facts, [key]: false }); + expect(deriveStartupHealth({ ...base, platform: "darwin", desktop }).rebootSafe).toBe(false); + } + }); + test("desktop evidence cannot protect another OS or an unrelated gateway", () => { + const desktop = deriveDesktopStartup(facts); + expect(deriveStartupHealth({ ...base, desktop }).status).toBe("at-risk"); + expect(deriveStartupHealth({ ...base, platform: "darwin", routingKind: "custom-local", desktop }).status).toBe("at-risk"); + }); + test("a failed follow-up probe revokes the desktop protection claim", () => { + const health = deriveStartupHealth({ ...base, platform: "darwin", desktop: deriveDesktopStartup(facts) }); + expect(markStartupHealthDiagnosticStale(health)).toMatchObject({ protection: "none", rebootSafe: false, diagnosticStale: true }); + }); +}); diff --git a/tests/service/service-desktop-startup-health.test.ts b/tests/service/service-desktop-startup-health.test.ts new file mode 100644 index 00000000000..a5f892dd0c3 --- /dev/null +++ b/tests/service/service-desktop-startup-health.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test"; +import { deriveStartupHealth, startupHealthSummary } from "../../src/codex/autostart-health"; +import { deriveDesktopStartup } from "../../src/service/desktop-startup"; +import { markStartupHealthDiagnosticStale } from "../../src/server/startup-health-cache"; + +const base = { + routingKind: "opencodex-local" as const, platform: "darwin" as const, + autostartEnabled: true, serviceInstalled: false, serviceViable: false, + serviceEnabled: false, serviceRunning: false, serviceStale: false, + serviceConflict: false, serviceSupported: true, shimInstalled: false, shimHealthy: false, +}; + +test("desktop ownership keeps failed and stale diagnostics from recommending a competing service", () => { + const desktop = deriveDesktopStartup({ owned: true, loginEnabled: true, running: false }); + const health = deriveStartupHealth({ ...base, desktop }); + expect(health).toMatchObject({ status: "at-risk", rebootSafe: false, protection: "none", recommendedCommand: null }); + expect(startupHealthSummary(health)).toContain("Start at Login"); + expect(startupHealthSummary(health)).not.toContain("ocx service"); + const stale = markStartupHealthDiagnosticStale(deriveStartupHealth({ + ...base, desktop: deriveDesktopStartup({ owned: true, loginEnabled: true, running: true }), + })); + expect(stale).toMatchObject({ status: "at-risk", protection: "none", rebootSafe: false, diagnosticStale: true, recommendedCommand: null }); +}); + +test("stale or inconsistent desktop evidence cannot grant protection", () => { + for (const override of [{ diagnosticStale: true }, { desktop: { owned: false, loginEnabled: true, running: true, viable: true } }]) { + const health = deriveStartupHealth({ ...base, desktop: deriveDesktopStartup({ owned: true, loginEnabled: true, running: true }), ...override }); + expect(health).toMatchObject({ status: "at-risk", rebootSafe: false, protection: "none" }); + } +}); diff --git a/tests/service/service-desktop-startup.test.ts b/tests/service/service-desktop-startup.test.ts new file mode 100644 index 00000000000..6be82b4001c --- /dev/null +++ b/tests/service/service-desktop-startup.test.ts @@ -0,0 +1,101 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { diagnoseMacDesktopStartup, desktopStartupOwnership } from "../../src/service/desktop-startup"; +import type { ServiceOwnershipResolution } from "../../src/service/state"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +function fixture() { + const home = mkdtempSync(join(tmpdir(), "ocx-desktop-startup-")); + roots.push(home); + const app = join(home, "OpenCodex.app", "Contents", "MacOS", "opencodex-desktop").replaceAll("\\", "/"); + const proxy = join(home, "OpenCodex.app", "Contents", "MacOS", "ocx").replaceAll("\\", "/"); + const idPath = join(home, "Library", "Application Support", "com.opencodex.desktop", "install-id"); + const plistPath = join(home, "Library", "LaunchAgents", "OpenCodex.plist"); + for (const path of [app, proxy, idPath, plistPath]) { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, path === idPath ? "installation-a" : "fixture"); + chmodSync(path, 0o700); + } + const owner: ServiceOwnershipResolution = { kind: "owned", revision: 1, + ownership: { owner: "desktop", installId: "installation-a", consentGeneration: 1 } }; + const state = { + owner, plist: { Label: "OpenCodex", RunAtLoad: true, ProgramArguments: [app, "--autostart"] }, + disabled: 'disabled services = { "OpenCodex" => enabled }', + loaded: `program = ${app}\npath = ${plistPath}`, + child: `200 ${proxy}`, parent: `1 ${app}`, pid: 100 as number | null, + fail: "", ownerReads: 0, pidReads: 0, changedPid: false, changedOwner: false, + }; + const deps = { + platform: "darwin" as const, home, uid: 501, + ownership: (): ServiceOwnershipResolution => { + state.ownerReads++; + return state.changedOwner && state.ownerReads > 1 ? { kind: "none", revision: 2 } : state.owner; + }, + readPid: () => { state.pidReads++; return state.changedPid && state.pidReads > 1 ? 101 : state.pid; }, + run: (command: string, args: string[]) => { + if (command === state.fail) throw new Error("unavailable evidence"); + if (command === "/usr/bin/plutil") return JSON.stringify(state.plist); + if (command === "/bin/launchctl") return args[0] === "print-disabled" ? state.disabled : state.loaded; + if (command === "/bin/ps") return args[1] === "100" ? state.child : state.parent; + throw new Error(`unexpected command: ${command}`); + }, + }; + return { state, deps, idPath, proxy, app, plistPath }; +} + +test("matching install, loaded login item and exact supervisor paths grant desktop protection", () => { + const { deps } = fixture(); + expect(diagnoseMacDesktopStartup(deps)).toEqual({ owned: true, loginEnabled: true, running: true, viable: true }); +}); + +test("failed identity and login/process evidence retain the durable desktop claim", () => { + const mutations = [ + (f: ReturnType) => writeFileSync(f.idPath, "different-install"), + (f: ReturnType) => rmSync(f.idPath), + (f: ReturnType) => { f.state.plist.RunAtLoad = false; }, + (f: ReturnType) => { f.state.plist.ProgramArguments[1] = "--wrong"; }, + (f: ReturnType) => { f.state.disabled = 'disabled services = { "OpenCodex" => disabled }'; }, + (f: ReturnType) => { f.state.disabled = 'disabled services = { "OpenCodex" => true }'; }, + (f: ReturnType) => { f.state.disabled = "unreadable output"; }, + (f: ReturnType) => { f.state.loaded = `program = ${f.proxy}\npath = ${f.plistPath}`; }, + (f: ReturnType) => { f.state.loaded = `program = ${f.app}\npath = ${f.idPath}`; }, + (f: ReturnType) => { f.state.child = `200 ${f.app}`; }, + (f: ReturnType) => { f.state.child = `1 ${f.proxy}`; }, + (f: ReturnType) => { f.state.parent = `1 ${f.proxy}`; }, + (f: ReturnType) => { f.state.pid = null; }, + (f: ReturnType) => { f.state.changedPid = true; }, + (f: ReturnType) => { f.state.changedOwner = true; }, + ...["/usr/bin/plutil", "/bin/launchctl", "/bin/ps"].map(command => + (f: ReturnType) => { f.state.fail = command; }), + ]; + for (const mutate of mutations) { + const f = fixture(); mutate(f); + expect(diagnoseMacDesktopStartup(f.deps)).toMatchObject({ owned: true, viable: false }); + } +}); + +test("other platforms and absent, CLI or unknown ownership cannot grant desktop protection", () => { + const f = fixture(); + expect(diagnoseMacDesktopStartup({ ...f.deps, platform: "linux" })).toBeUndefined(); + expect(f.state.ownerReads).toBe(0); + for (const owner of [ + { kind: "none", revision: 0 }, { kind: "unknown", reason: "unreadable" }, + { kind: "owned", revision: 1, ownership: { owner: "cli", installId: "installation-a", consentGeneration: 1 } }, + ] as ServiceOwnershipResolution[]) { + f.state.owner = owner; + expect(diagnoseMacDesktopStartup(f.deps)).toBeUndefined(); + expect(f.state.pidReads).toBe(0); + } +}); + +test("ownership-only fallback does not run external probes", () => { + const f = fixture(); + expect(desktopStartupOwnership({ ...f.deps, run: () => { throw new Error("must not probe"); } })).toEqual({ + owned: true, loginEnabled: false, running: false, viable: false, + }); + expect(f.state.pidReads).toBe(0); +}); diff --git a/tests/service/shutdown-launcher.test.ts b/tests/service/shutdown-launcher.test.ts index 9f7dca0b8c2..b6045bf18a0 100644 --- a/tests/service/shutdown-launcher.test.ts +++ b/tests/service/shutdown-launcher.test.ts @@ -108,6 +108,11 @@ describe.skipIf(!runnable)("ocx launcher graceful shutdown", () => { // no-ops when no config.toml exists) — this lets us prove the config is RESTORED. const codexConfig = join(home, "config.toml"); writeFileSync(codexConfig, 'model = "gpt-5.1"\n'); + // Pin the configured port to this test's own port. `ocx start` probes the CONFIGURED + // port for a live owner even with no state files, and a fresh home defaults to 10100. + // On a developer machine running ocx there, the child found that proxy, took the sibling + // path and by design never injected Codex config, so this test could not pass locally. + writeFileSync(join(home, "config.json"), JSON.stringify({ port })); // stdout/stderr are CAPTURED, not discarded. // @@ -126,6 +131,10 @@ describe.skipIf(!runnable)("ocx launcher graceful shutdown", () => { USERPROFILE: identity.userProfile, OPENCODEX_HOME: home, CODEX_HOME: home, + // Inherited real state must not make the child a sibling of the host's proxy either + // (same pinning as tests/cli/sibling-home-client-sync.test.ts). + GROK_HOME: join(home, "grok"), + OCX_OWNER_REGISTRY_DIR: join(identity.homeDir, ".opencodex", "ocx-homes"), ...identity.serviceManagerEnv, }, });