From 685321e2976f8491b5e798d039fc604f82f4e9de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:30:56 +0900 Subject: [PATCH 01/48] chore(release): open dev at 2.65.0 before releasing 2.64.0 (#5666) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index eb3bbca497c..f05b9c2d323 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2541,7 +2541,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.64.0" +version = "2.65.0" dependencies = [ "dbus", "reqwest 0.12.24", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index fa68e43c6e8..83013957af7 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.64.0" +version = "2.65.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 543eaece9d9..e536e7367ea 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.64.0", + "version": "2.65.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index 23bb5a90ad8..bb40cda5b7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.64.0", + "version": "2.65.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", From 7dd1db22ac80b4fb3ce89fef28d1275feaec1891 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 19:21:15 +0900 Subject: [PATCH 02/48] =?UTF-8?q?fix(tests):=20bundle=20lane=20A=20?= =?UTF-8?q?=E2=80=94=20test=20hygiene,=20desktop=20restart=20guard=20and?= =?UTF-8?q?=20README=20inventory=20counts=20(#5672)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(devlog): triage the lane A tests-hygiene bundle * fix(tests): capture the real resolver before mocking adapter-resolve Carries #5482. Co-authored-by: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> * fix(tests): dispose test translator budgets in every file that creates them Carries #5607. Co-authored-by: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> * fix(tests): restore the sandbox home after every test file Carries #5570 (both PR commits, including the CodeRabbit ordering fix). Co-authored-by: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> * fix(tests): put the real modules back after the image tests mock them Carries #5605. Folded review fix: each file restores only the module snapshots it actually captured, so a beforeAll that failed partway cannot install an empty module, and z-handler-activation restores its overrides in a finally block so a failed directory removal cannot leave them installed for later files in the process. Co-authored-by: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> * fix(desktop): never restart the real desktop app from the test runner Carries #5630. restartCodexDesktopApp returns the skipped reason test_environment when the test preload armed OCX_TEST_HOME_GUARD and no execFile was injected, and the CLI reports that skip. Folded review fix: structure/runtime.md documents the guarded outcome next to the CLI restart scope it owns. Co-authored-by: terin <100397903+sh940701@users.noreply.github.com> * docs(readme): derive the memory inventory counts instead of restating them Carries #5340, rebuilt on dev after #5615 and #5638 so their README and locale prose stays intact. Folded review fixes: dev now registers 14 retained stores, and native_control_replay is pinned (evictOldest returns 0), so every page says 14 and names the one store the budget never evicts; the guard's header drops the numbers that had gone stale; readme/i18n-manifest.json carries the hash of the final README.md. Co-authored-by: codingbo <9621077+codingbooo@users.noreply.github.com> * docs(devlog): record the lane A delivery --------- Co-authored-by: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> Co-authored-by: terin <100397903+sh940701@users.noreply.github.com> Co-authored-by: codingbo <9621077+codingbooo@users.noreply.github.com> --- README.md | 9 +- .../000_triage.md | 20 ++ .../010_build.md | 12 ++ .../020_delivery.md | 11 ++ readme/README.fr.md | 9 +- readme/README.ja.md | 9 +- readme/README.ko.md | 8 +- readme/README.ru.md | 9 +- readme/README.tr.md | 9 +- readme/README.zh-CN.md | 9 +- readme/README.zh-TW.md | 9 +- readme/i18n-manifest.json | 14 +- scripts/test-layout/layout.json | 1 + src/cli/restart-scope.ts | 10 +- src/codex/desktop-app-restart.ts | 8 + structure/runtime.md | 2 +- tests/adapters/abort-race.test.ts | 7 +- tests/adapters/key-failover.test.ts | 4 +- .../docs-readme-memory-inventory.test.ts | 178 ++++++++++++++++++ ...laude-code-thought-signature-scope.test.ts | 7 +- .../clients/desktop-app-restart-posix.test.ts | 32 +++- tests/clients/desktop-app-restart.test.ts | 59 +++++- ...count-store-refresh-classification.test.ts | 4 +- .../codex-account-store.test.ts | 4 +- ...ex-entitlement-identity-read-fence.test.ts | 4 +- .../codex-transition-state-adoption.test.ts | 4 +- .../issue-914-transport-attribution.test.ts | 94 +++++---- tests/fixtures/test-layout-expected.json | 1 + tests/helpers/translator-budget.ts | 24 ++- tests/images/download-cap-default.test.ts | 11 +- tests/images/loop-reasoning-replay.test.ts | 9 + tests/images/loop.test.ts | 14 +- tests/images/z-fulfill.test.ts | 14 +- tests/images/z-handler-activation.test.ts | 38 +++- ...-automation-coderabbit-regressions.test.ts | 4 +- ...ation-final-coderabbit-regressions.test.ts | 4 +- .../lab-automation-management-http.test.ts | 4 +- .../lab-automation-review-regressions.test.ts | 4 +- tests/lab/lab-automation.test.ts | 4 +- tests/lab/lab-evidence-ledger.test.ts | 4 +- tests/lab/lab-evidence-sanitization.test.ts | 4 +- tests/lab/lab-fabric-task.test.ts | 4 +- tests/lab/lab-live-probe.test.ts | 3 +- tests/lab/lab-live-review-regressions.test.ts | 4 +- tests/lab/lab-live-sandbox.test.ts | 3 +- tests/lab/lab-post-merge-hardening.test.ts | 4 +- tests/lab/lab-public-surfaces.test.ts | 4 +- tests/lab/lab-read-surfaces.test.ts | 4 +- .../responses-snapshot-repair-server.test.ts | 3 + .../routing-policy-surface-parity.test.ts | 7 +- tests/server/config.test.ts | 4 +- ...ement-provider-pinsless-validation.test.ts | 8 +- ...management-provider-proto-override.test.ts | 8 +- tests/service/process-state.test.ts | 4 +- tests/service/service-secrets.test.ts | 4 +- 55 files changed, 607 insertions(+), 142 deletions(-) create mode 100644 devlog/_plan/260923_bundle_a_tests_hygiene/000_triage.md create mode 100644 devlog/_plan/260923_bundle_a_tests_hygiene/010_build.md create mode 100644 devlog/_plan/260923_bundle_a_tests_hygiene/020_delivery.md create mode 100644 tests/ci-workflows/docs-readme-memory-inventory.test.ts diff --git a/README.md b/README.md index 3e994459a57..b215b5d0514 100644 --- a/README.md +++ b/README.md @@ -306,14 +306,15 @@ see the [installation docs](https://opencodex.me/getting-started/installation/).
Memory ownership details -OpenCodex tracks 36 categories of process-retained state. Each has a documented bound: +OpenCodex tracks process-retained state in the categories below. Each has a documented bound: -- **12 retained stores** (request log, debug rings, image cache, model cache, vision +- **14 retained stores** (request log, debug rings, image cache, model cache, vision descriptions, cursor blobs, responses continuation, etc.) are byte-accounted and - evicted by the app-owned memory budget (default 256 MiB). + evicted by the app-owned memory budget (default 256 MiB), except the native control replay + store, which is pinned and never evicted. - **4 observed buffers** (translator accumulators, image/OAuth/Grok tails) are monitored for in-flight byte pressure without eviction. -- **24 state-store registrations** handle expiry sweeps (60 s interval) and +- **28 state-store registrations** handle expiry sweeps (60 s interval) and config-generation reconciliation so stale provider/account keys are removed. - **Path and fingerprint memos** (workspace metadata, hardened identities, installation salts, mode-hint capabilities) use insertion-order LRU caps (8–128 entries). diff --git a/devlog/_plan/260923_bundle_a_tests_hygiene/000_triage.md b/devlog/_plan/260923_bundle_a_tests_hygiene/000_triage.md new file mode 100644 index 00000000000..aba50e41518 --- /dev/null +++ b/devlog/_plan/260923_bundle_a_tests_hygiene/000_triage.md @@ -0,0 +1,20 @@ +# Lane A — tests hygiene triage + +Bundle lane A of the 260923 PR-consolidation round. One branch (codex/260923-bundle-a-tests-hygiene) from origin/dev 685321e297, one commit per carried PR, one PR to dev. Each candidate got a read-only gpt-6-sol soundness review against current dev. + +| PR | Author | Verdict | Carry notes | +|---|---|---|---| +| #5607 | FredAmartey | CARRY | Translator budgets disposed per test via onTestFinished; module-level afterEach only fires for the first importing file in a shared process. | +| #5605 | FredAmartey | CARRY-WITH-FIXES | Restore real modules after image-test mock.module overrides. Fix: capture each real module before its first override and restore only captured snapshots (a partial beforeAll must not install an empty module); z-handler-activation restores even if directory cleanup throws. | +| #5570 | FredAmartey | CARRY | Every test file that pins OPENCODEX_HOME restores the inherited value; commit 2 already folded the CodeRabbit ordering finding. | +| #5482 | FredAmartey | CARRY | Capture resolveAdapter before mock.module rewrites the live binding (three files). | +| #5630 | sh940701 | CARRY-WITH-FIXES | Guard the real desktop restart adapter when OCX_TEST_HOME_GUARD=1 and no execFile is injected. Fix: document the armed-test skip and CLI outcome in structure/runtime.md. | +| #5340 | codingbooo | CARRY-WITH-FIXES | README memory inventory counts derived from registries with a per-locale guard test. Fix: rebuild on dev after #5615 (keep its prose), retained stores are now 14 (native_control_replay is pinned, evictOldest returns 0, so the "all evicted" wording changes), recompute readme/i18n-manifest.json hash from the final README, register the new test in layout.json and test-layout-expected.json. | + +## Issue #5439 + +Part 1 (batched runner cannot run on macOS: GNU timeout, mapfile) is already fixed on dev by #5456 (portable process-group timeout fallback, no mapfile). Part 2 (failure counts depend on batch size) is caused by the cross-file leaks that #5570, #5605 and #5607 fix; tests/server/config.test.ts already restores its cwd. The single-owner spend ledger errors are the intended owned-spend-home contract. The PR references the issue with the evidence; closing is the coordinator's call. + +## Verification plan + +Focused files per carry, including non-isolated same-process pairs that reproduce the leak (the reviewers' named orderings), then bun run typecheck, bun run structure:check, bun run privacy:scan, tests/test-layout*.test.ts and the file-size ratchet test. No full local suite. diff --git a/devlog/_plan/260923_bundle_a_tests_hygiene/010_build.md b/devlog/_plan/260923_bundle_a_tests_hygiene/010_build.md new file mode 100644 index 00000000000..442c4ba771a --- /dev/null +++ b/devlog/_plan/260923_bundle_a_tests_hygiene/010_build.md @@ -0,0 +1,12 @@ +# Lane A — build order + +Commits on codex/260923-bundle-a-tests-hygiene, in order, each with the original author's Co-authored-by trailer: + +1. #5482 capture resolveAdapter before mocking — Fred Amartey <43480311+FredAmartey@users.noreply.github.com>. Applied as-is. +2. #5607 per-test translator budget disposal — Fred Amartey. Applied as-is. +3. #5570 restore inherited OPENCODEX_HOME in every test file — Fred Amartey. Both PR commits squashed into one carry. +4. #5605 restore real modules after image mocks — Fred Amartey. Fold: snapshot each module before its first override, restore only captured snapshots, and restore in z-handler-activation before the throwable directory cleanup. +5. #5630 guard the real desktop restart adapter in armed test processes — terin <100397903+sh940701@users.noreply.github.com>. Fold: structure/runtime.md documents the skipped outcome. +6. #5340 derived README memory inventory counts — codingbo <9621077+codingbooo@users.noreply.github.com>. Rebuilt on dev after #5615: 14 retained stores, pinned-store eviction wording, recomputed readme/i18n-manifest.json hash, new test registered in layout.json and test-layout-expected.json. + +Focused proof per commit: the PR's named files plus a non-isolated same-process ordering that reproduced the leak on dev (run before and after where cheap). diff --git a/devlog/_plan/260923_bundle_a_tests_hygiene/020_delivery.md b/devlog/_plan/260923_bundle_a_tests_hygiene/020_delivery.md new file mode 100644 index 00000000000..277e294f0ef --- /dev/null +++ b/devlog/_plan/260923_bundle_a_tests_hygiene/020_delivery.md @@ -0,0 +1,11 @@ +# Lane A — delivery + +1. gpt-6-sol adversarial review of origin/dev..codex/260923-bundle-a-tests-hygiene; fold or rebut every finding. +2. Push the branch (no-verify), open one PR to dev from the repository template with Supersedes #5482 #5607 #5570 #5605 #5630 #5340, Refs #5439 with the findings, credit list, verification commands and the environment-only issue-914 note. +3. Watch exact-head CI; a run cancelled by the 2.64 release coordinator is re-dispatched after the release, never read as a failure. +4. Final report to the coordinator. +## Delivery record + +- PR #5672 to dev from codex/260923-bundle-a-tests-hygiene; supersedes #5482, #5607, #5570, #5605, #5630 and #5340; refs #5439. +- Adversarial review: P2 dashboard finding withdrawn (the gap predates this branch for every desktop skip reason, and test_environment only occurs under OCX_TEST_HOME_GUARD=1), P3 EOF nits fixed. +- Exact-head CI is read from the PR head only; a run cancelled by the 2.64 release coordinator is re-dispatched after the release. diff --git a/readme/README.fr.md b/readme/README.fr.md index 6e760f0655b..44a45659640 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -311,14 +311,15 @@ consultez la [documentation d'installation](https://opencodex.me/fr/getting-star
Détails de la gestion de la mémoire -OpenCodex suit 36 catégories d'état conservé par le processus. Chacune possède une limite documentée : +OpenCodex suit l'état conservé par le processus dans les catégories ci-dessous. Chacune possède une limite documentée : -- **12 stockages conservés** (journal des requêtes, tampons circulaires de débogage, cache d'images, cache de +- **14 stockages conservés** (journal des requêtes, tampons circulaires de débogage, cache d'images, cache de modèles, descriptions visuelles, blobs de curseurs, continuation des réponses, etc.) sont comptabilisés en octets et - évincés selon le budget mémoire géré par l'application (256 Mio par défaut). + évincés selon le budget mémoire géré par l'application (256 Mio par défaut), sauf le stockage de + rejeu des contrôles natifs, épinglé et jamais évincé. - **4 tampons observés** (accumulateurs de traduction, segments finaux d'images/OAuth/Grok) sont surveillés pour détecter la pression des octets en cours de traitement, sans éviction. -- **24 enregistrements de stockages d'état** gèrent les balayages d'expiration (intervalle de 60 s) et la +- **28 enregistrements de stockages d'état** gèrent les balayages d'expiration (intervalle de 60 s) et la réconciliation des générations de configuration afin de supprimer les clés obsolètes des fournisseurs et des comptes. - **Les mémos de chemins et d'empreintes** (métadonnées de l'espace de travail, identités renforcées, sels d'installation, capacités indiquées par le mode) utilisent des limites LRU selon l'ordre d'insertion (8 à 128 entrées). diff --git a/readme/README.ja.md b/readme/README.ja.md index 5383ac67f24..5817cab5df2 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -307,14 +307,15 @@ CLI インストールには [Node](https://nodejs.org) 18 以上が必要です
メモリ所有権の詳細 -OpenCodex はプロセスが保持する状態を 36 種類に分けて追跡し、それぞれに文書化された上限があります: +OpenCodex はプロセスが保持する状態を以下のカテゴリで追跡し、それぞれに文書化された上限があります: -- **保持ストア 12 個**(リクエストログ、デバッグリング、画像キャッシュ、モデルキャッシュ、ビジョンの +- **保持ストア 14 個**(リクエストログ、デバッグリング、画像キャッシュ、モデルキャッシュ、ビジョンの 説明、カーソル blob、responses の継続など)はバイト単位で集計され、アプリが持つメモリ予算 - (既定 256 MiB)によって退避されます。 + (既定 256 MiB)によって退避されます。ただしネイティブ制御のリプレイ用ストアは固定され、 + 退避されません。 - **観測バッファ 4 個**(トランスレーターのアキュムレーター、画像・OAuth・Grok の tail)は処理中の バイト圧力を監視するだけで、退避はしません。 -- **state-store の登録 24 個**が期限切れの掃除(60 秒間隔)と config 世代の reconciliation を担い、 +- **state-store の登録 28 個**が期限切れの掃除(60 秒間隔)と config 世代の reconciliation を担い、 古いプロバイダー/アカウントのキーを取り除きます。 - **パスとフィンガープリントのメモ**(ワークスペースのメタデータ、hardened identity、インストール salt、mode-hint の capability)は挿入順の LRU 上限(8〜128 件)を使います。 diff --git a/readme/README.ko.md b/readme/README.ko.md index 3b52191e77c..bb775214799 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -297,14 +297,14 @@ CLI 설치에는 [Node](https://nodejs.org) 18 이상이 필요하고, 데스크
메모리 소유권 상세 -OpenCodex는 프로세스가 붙잡고 있는 상태 36종을 추적합니다. 각각에 문서화된 한도가 있습니다: +OpenCodex는 프로세스가 붙잡고 있는 상태를 아래 항목에서 추적합니다. 각각에 문서화된 한도가 있습니다: -- **유지 저장소 12개**(요청 로그, debug ring, image cache, model cache, vision 설명, cursor blob, +- **유지 저장소 14개**(요청 로그, debug ring, image cache, model cache, vision 설명, cursor blob, responses continuation 등)는 바이트 단위로 집계되며, 앱이 소유한 메모리 예산(기본 256 MiB)이 - eviction합니다. + eviction합니다. 단, native control replay 저장소는 고정되어 eviction되지 않습니다. - **관측 버퍼 4개**(translator accumulator, image/OAuth/Grok tail)는 진행 중 바이트 압력을 감시만 하고 eviction하지 않습니다. -- **state-store 등록 24개**는 만료 sweep(60초 간격)과 config-generation reconciliation을 돌려, +- **state-store 등록 28개**는 만료 sweep(60초 간격)과 config-generation reconciliation을 돌려, 낡은 프로바이더/계정 키를 지웁니다. - **경로·fingerprint 메모**(워크스페이스 메타데이터, hardened identity, 설치 salt, mode-hint capability)는 삽입 순서 LRU cap(8–128개)을 씁니다. diff --git a/readme/README.ru.md b/readme/README.ru.md index 294e05b192a..95edd73b330 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -317,15 +317,16 @@ ocx init # интерактивная настройка: пишет ~/.ope
Подробности владения памятью -OpenCodex отслеживает 36 категорий состояния, удерживаемого процессом. У каждой есть +OpenCodex отслеживает состояние, удерживаемое процессом, в категориях ниже. У каждой есть документированная граница: -- **12 удерживаемых хранилищ** (журнал запросов, отладочные кольца, кэш изображений, кэш +- **14 удерживаемых хранилищ** (журнал запросов, отладочные кольца, кэш изображений, кэш моделей, vision-описания, cursor-блобы, продолжение responses и т. д.) учитываются - в байтах и вытесняются бюджетом памяти приложения (по умолчанию 256 MiB). + в байтах и вытесняются бюджетом памяти приложения (по умолчанию 256 MiB), кроме + хранилища native control replay: оно закреплено и не вытесняется. - **4 наблюдаемых буфера** (аккумуляторы транслятора, хвосты image/OAuth/Grok) мониторятся по байтовому давлению in-flight без вытеснения. -- **24 регистрации state-store** выполняют sweeps истечения (интервал 60 с) и сверку +- **28 регистраций state-store** выполняют sweeps истечения (интервал 60 с) и сверку поколений конфигурации, чтобы удалять устаревшие ключи провайдеров и аккаунтов. - **Мемо пути и отпечатков** (метаданные рабочей области, усиленные идентификаторы, соли установки, возможности mode-hint) используют LRU-потолки в порядке вставки diff --git a/readme/README.tr.md b/readme/README.tr.md index e73663c46e5..7d802bb2f61 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -312,14 +312,15 @@ betiklerini engellediyse [kurulum belgelerine](https://opencodex.me/tr/getting-s
Bellek sahipliği ayrıntıları -OpenCodex, süreçte tutulan durumu 36 kategoride izler. Her birinin belgelenmiş bir sınırı vardır: +OpenCodex, süreçte tutulan durumu aşağıdaki kategorilerde izler. Her birinin belgelenmiş bir sınırı vardır: -- **12 tutulan depo** (istek günlüğü, hata ayıklama halkaları, görsel önbelleği, model önbelleği, görü +- **14 tutulan depo** (istek günlüğü, hata ayıklama halkaları, görsel önbelleği, model önbelleği, görü açıklamaları, imleç blob'ları, responses devamlılığı vb.) bayt olarak hesaplanır ve uygulamanın sahip - olduğu bellek bütçesiyle (varsayılan 256 MiB) tahliye edilir. + olduğu bellek bütçesiyle (varsayılan 256 MiB) tahliye edilir; yalnızca native control replay deposu + sabitlenmiştir ve hiç tahliye edilmez. - **4 gözlenen arabellek** (çevirici biriktiricileri, görsel/OAuth/Grok kuyrukları) tahliye edilmeden, yalnızca uçuştaki bayt baskısı için izlenir. -- **24 state-store kaydı**, süre dolumu taramalarını (60 sn aralık) ve yapılandırma kuşağı uzlaştırmasını +- **28 state-store kaydı**, süre dolumu taramalarını (60 sn aralık) ve yapılandırma kuşağı uzlaştırmasını yürüterek eski sağlayıcı/hesap anahtarlarını kaldırır. - **Yol ve parmak izi notları** (çalışma alanı meta verileri, sağlamlaştırılmış kimlikler, kurulum tuzları, mod ipucu yetenekleri) ekleme sıralı LRU sınırları kullanır (8–128 girdi). diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 66a5ac1a4d3..ebe804682c7 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -296,14 +296,15 @@ Bun,Windows 也不需要 WSL。如果 npm 拦截了捆绑运行时的安装脚
内存所有权详情 -OpenCodex 跟踪 36 类进程保留状态。每一类都有文档化的边界: +OpenCodex 在下列类别中跟踪进程保留状态。每一类都有文档化的边界: -- **12 个保留存储**(请求日志、调试环、图片缓存、模型缓存、视觉 +- **14 个保留存储**(请求日志、调试环、图片缓存、模型缓存、视觉 描述、光标 blob、responses 续写等)按字节记账,并由应用自有的内存预算 - (默认 256 MiB)逐出。 + (默认 256 MiB)逐出;其中 native control replay 存储是固定的, + 不会被逐出。 - **4 个观测缓冲区**(翻译累加器、图片/OAuth/Grok 尾部)会监测飞行中的字节压力, 但不做逐出。 -- **24 个状态存储注册** 负责过期扫描(60 秒间隔)和配置世代对账,从而移除过期的 +- **28 个状态存储注册** 负责过期扫描(60 秒间隔)和配置世代对账,从而移除过期的 提供商/账户键。 - **路径与指纹备忘**(工作区元数据、加固身份、安装盐、模式提示能力)使用按插入顺序的 LRU 上限(8–128 条)。 diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 11901c3ffd3..abc4f6a03e5 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -293,14 +293,15 @@ Bun,Windows 也不需要 WSL。若 npm 攔截了打包執行環境的安裝腳
記憶體所有權細節 -OpenCodex 追蹤 36 類行程保留狀態。每一類都有文件化的上限: +OpenCodex 在下列類別中追蹤行程保留狀態。每一類都有文件化的上限: -- **12 個保留儲存**(請求日誌、除錯環形緩衝、圖片快取、模型快取、視覺 +- **14 個保留儲存**(請求日誌、除錯環形緩衝、圖片快取、模型快取、視覺 描述、cursor blob、responses 延續等)以位元組計帳,並由 - 應用程式自己的記憶體預算淘汰(預設 256 MiB)。 + 應用程式自己的記憶體預算淘汰(預設 256 MiB);其中 native control replay + 儲存為固定,不會被淘汰。 - **4 個觀測緩衝區**(翻譯累加器、image/OAuth/Grok 尾端)會 監控進行中的位元組壓力,但不淘汰。 -- **24 個狀態儲存註冊**負責到期清掃(間隔 60 秒)與 +- **28 個狀態儲存註冊**負責到期清掃(間隔 60 秒)與 設定世代調和,以移除過期的供應商/帳號鍵。 - **路徑與指紋 memo**(工作區中繼資料、強化身分、安裝 salt、mode-hint 能力)使用插入順序 LRU 上限(8–128 筆)。 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index 2eeb1c5380f..a2805b162aa 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "7a0520543cabc7c07d1f2ee9b55098c853bb1aeaab45d6c41627d3652efbd530" + "sourceSha256": "84b94b81fd4db947b7ad86fd77ce3404f1370e70ef32e23ee42b4bf7a7378813" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "7a0520543cabc7c07d1f2ee9b55098c853bb1aeaab45d6c41627d3652efbd530" + "sourceSha256": "84b94b81fd4db947b7ad86fd77ce3404f1370e70ef32e23ee42b4bf7a7378813" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "7a0520543cabc7c07d1f2ee9b55098c853bb1aeaab45d6c41627d3652efbd530" + "sourceSha256": "84b94b81fd4db947b7ad86fd77ce3404f1370e70ef32e23ee42b4bf7a7378813" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "7a0520543cabc7c07d1f2ee9b55098c853bb1aeaab45d6c41627d3652efbd530" + "sourceSha256": "84b94b81fd4db947b7ad86fd77ce3404f1370e70ef32e23ee42b4bf7a7378813" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "7a0520543cabc7c07d1f2ee9b55098c853bb1aeaab45d6c41627d3652efbd530" + "sourceSha256": "84b94b81fd4db947b7ad86fd77ce3404f1370e70ef32e23ee42b4bf7a7378813" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "7a0520543cabc7c07d1f2ee9b55098c853bb1aeaab45d6c41627d3652efbd530" + "sourceSha256": "84b94b81fd4db947b7ad86fd77ce3404f1370e70ef32e23ee42b4bf7a7378813" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "7a0520543cabc7c07d1f2ee9b55098c853bb1aeaab45d6c41627d3652efbd530" + "sourceSha256": "84b94b81fd4db947b7ad86fd77ce3404f1370e70ef32e23ee42b4bf7a7378813" } } } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 518fa8c1b76..15778d23b10 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -783,6 +783,7 @@ "docs-provider-billing-claims.test.ts": "ci-workflows", "docs-provider-discovery-limits.test.ts": "ci-workflows", "docs-provider-preset-counts.test.ts": "ci-workflows", + "docs-readme-memory-inventory.test.ts": "ci-workflows", "docs-readme-translation-parity.test.ts": "ci-workflows", "docs-remote-hub-claims.test.ts": "ci-workflows", "doctor-codex-envkey-readiness.test.ts": "service", diff --git a/src/cli/restart-scope.ts b/src/cli/restart-scope.ts index 4f8660b2d95..2dd14c94214 100644 --- a/src/cli/restart-scope.ts +++ b/src/cli/restart-scope.ts @@ -6,7 +6,7 @@ */ import { afterCatalogWriteHandleAppServers } from "../codex/app-server-processes"; import type { AfterCatalogWriteAppServerResult } from "../codex/app-server-processes"; -import type { DesktopAppRestartResult } from "../codex/desktop-app-restart"; +import type { DesktopAppRestartIo, DesktopAppRestartResult } from "../codex/desktop-app-restart"; /** * Which restart a command was asked for. @@ -102,10 +102,12 @@ export async function handleRestartScopeAfterWrite( */ export async function handleDesktopAppRestart( log: Pick, + io: DesktopAppRestartIo = {}, ): Promise { const { restartCodexDesktopApp } = await import("../codex/desktop-app-restart"); const { startDesktopRestartHandoff } = await import("../codex/desktop-app/handoff"); const result = restartCodexDesktopApp({ + ...io, // The CLI is the one caller whose exit is exactly the signal the helper waits for, // so it is the one caller allowed to hand off. The management service is not (it // runs in a proxy that never exits) and the helper itself is not (recursion). @@ -123,6 +125,12 @@ export async function handleDesktopAppRestart( + "nothing was stopped.", ); return result; + case "test_environment": + log.error( + "Skipped the Codex desktop app restart: this is an armed opencodex test process " + + "(OCX_TEST_HOME_GUARD=1), so the real app was not touched.", + ); + return result; case "restart_in_flight": log.error( "Another Codex desktop-app restart is already running; this one did nothing. " diff --git a/src/codex/desktop-app-restart.ts b/src/codex/desktop-app-restart.ts index 17bf83ed7de..6de8220ad0b 100644 --- a/src/codex/desktop-app-restart.ts +++ b/src/codex/desktop-app-restart.ts @@ -40,6 +40,7 @@ import { rootShells, type DesktopAppAdapter, type DesktopExec, type DesktopProce import { darwinDesktopAppAdapter, darwinDefaultExec } from "./desktop-app/darwin"; import { linuxDesktopAppAdapter, linuxDefaultExec } from "./desktop-app/linux"; import { windowsDesktopAppAdapter, windowsDefaultExec } from "./desktop-app/windows"; +import { isTestHomeGuardArmed } from "../lib/test-home-guard"; export type { DesktopAppExecOptions } from "./desktop-app/types"; @@ -81,6 +82,7 @@ export interface DesktopAppRestartIo { export type DesktopAppRestartReason = | "unsupported_platform" + | "test_environment" | "package_discovery_failed" | "process_probe_failed" | "no_targets" @@ -213,6 +215,12 @@ export function restartCodexDesktopApp(io: DesktopAppRestartIo = {}): DesktopApp const adapter = io.adapter ?? selected?.adapter; const exec = io.execFile ?? selected?.exec; if (!adapter || !exec) return skipped("unsupported_platform"); + // In an armed test process a call without an injected exec would reach the real OS (an injected + // adapter still execs through the platform default): on a developer Mac, `performCodexRestart` + // tests quit the user's ChatGPT (Codex) app and relaunched it through `/usr/bin/open` with the + // runner's sandbox HOME, logged out. Armed means the test preload's flag, not NODE_ENV, for the + // reason test-home-guard gives: a real `NODE_ENV=test ocx ...` must still restart the app. + if (!io.execFile && isTestHomeGuardArmed()) return skipped("test_environment"); // Step 0. Two restarts at once are destructive rather than merely wasteful: the // first quits and relaunches, the second sees the freshly started shell as a target diff --git a/structure/runtime.md b/structure/runtime.md index 284be4c939b..36edbaf86d5 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -65,7 +65,7 @@ Catalog-derived reasoning-level diagnostics are escaped only at the human-output ## CLI Codex restart scope -`ocx system codex-restart` requests a full Codex desktop-app restart and app-server restarts through the management endpoint. `src/cli/capabilities.ts` names that scope in its summary and `--yes` description; `src/cli/system-command.ts` explains the desktop interruption when confirmation is missing and sends no restart request. Human output says the restart was requested, while `--json` preserves the complete server result, including skipped or refused desktop outcomes. +`ocx system codex-restart` requests a full Codex desktop-app restart and app-server restarts through the management endpoint. `src/cli/capabilities.ts` names that scope in its summary and `--yes` description; `src/cli/system-command.ts` explains the desktop interruption when confirmation is missing and sends no restart request. Human output says the restart was requested, while `--json` preserves the complete server result, including skipped or refused desktop outcomes. An armed test process never reaches the real desktop app. When the test preload's `OCX_TEST_HOME_GUARD=1` is set and the caller injected no `execFile`, `restartCodexDesktopApp` in `src/codex/desktop-app-restart.ts` returns the skipped reason `test_environment` before discovery or signalling, and `handleDesktopAppRestart` in `src/cli/restart-scope.ts` reports that skip. The flag, not `NODE_ENV`, decides, so a real `NODE_ENV=test ocx ...` still restarts the app; adapter tests that inject `execFile` still exercise the full path. `tests/clients/desktop-app-restart.test.ts` covers the skip. After a CLI catalog/cache write, advisory restart guidance compares each running Codex app-server's start time with the written catalog mtime. It reports only processes proven stale; a fresh or diff --git a/tests/adapters/abort-race.test.ts b/tests/adapters/abort-race.test.ts index 7843e1c0871..adfcdf659ce 100644 --- a/tests/adapters/abort-race.test.ts +++ b/tests/adapters/abort-race.test.ts @@ -4,12 +4,17 @@ import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "../../src/types import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; const actualResolver = await import("../../src/server/adapter-resolve"); +// Capture the real function before the override. `mock.module` rewrites the namespace's live +// binding in place, so a lookup through `actualResolver` inside the wrapper would reach +// whichever override is current, including this one, once another file in the same process +// has mocked this module too. +const actualResolveAdapter = actualResolver.resolveAdapter; let adapterFactory: ((provider: OcxProviderConfig) => ProviderAdapter) | undefined; mock.module("../../src/server/adapter-resolve", () => ({ ...actualResolver, resolveAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") { - return adapterFactory?.(provider) ?? actualResolver.resolveAdapter(provider, cacheRetention); + return adapterFactory?.(provider) ?? actualResolveAdapter(provider, cacheRetention); }, })); diff --git a/tests/adapters/key-failover.test.ts b/tests/adapters/key-failover.test.ts index 2aeec365b22..e6ccbff9fdf 100644 --- a/tests/adapters/key-failover.test.ts +++ b/tests/adapters/key-failover.test.ts @@ -36,6 +36,7 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/t import { removeTreeWithRetry } from "../helpers/remove-tree"; let home: string; +const previousHome = process.env.OPENCODEX_HOME; function makeConfig(provider: Partial): OcxConfig { const config = { @@ -68,7 +69,8 @@ beforeEach(() => { }); afterEach(() => { - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; removeTreeWithRetry(home); clearKeyCooldowns(); }); diff --git a/tests/ci-workflows/docs-readme-memory-inventory.test.ts b/tests/ci-workflows/docs-readme-memory-inventory.test.ts new file mode 100644 index 00000000000..cd9e8e48391 --- /dev/null +++ b/tests/ci-workflows/docs-readme-memory-inventory.test.ts @@ -0,0 +1,178 @@ +/** + * The README memory inventory restates three counts that live in source, and nothing compared + * them. + * + * The block was written once (`6c14e3433`, 2026-08-13) and never revisited. `usage_snapshot` + * became the thirteenth retained store the very next day, three more state-store registrations + * landed after that, and `native_control_replay` became the fourteenth retained store while this + * guard was still in review, so all eight README files kept advertising 12 and 24 the whole + * time. Review does not catch this: an English diff of one number looks complete on its own, + * and the seven translations were copied from a source that was already stale. + * + * AGENTS.md calls this class out directly: derive a count from the thing it describes rather + * than restating it. The three numbers below are derived from the rosters the runtime actually + * registers, so the next store that lands fails this test in all eight pages at once instead of + * silently disagreeing with the proxy for months. + * + * Each page is anchored by a locale-specific label rather than by the digits, so a reworded + * sentence fails loudly and asks to be re-anchored. That is the intended behavior: a sentence + * nobody can locate is a sentence nobody is checking. + * + * The opening sentence states no total, and this test holds it that way. The three rosters below + * it can be summed, but the page also lists stores belonging to no roster, and its last bullet + * describes a ledger that keeps no process-level RAM index at all, so a single total over + * "categories of process-retained state" has no source to be derived from. A count that cannot + * be derived is not stated here. + * + * The numbers moved while this guard was written, so the diff that adds it also corrects the + * documents; the test would otherwise land red. + * + * One locale needed a word changed and not only a digit: Russian agrees its numeral with the + * noun, and `регистрации` was the right genitive for the 24 the page used to claim while + * `регистраций` is the right one for 28. The anchor here follows the corrected wording, so a + * page that reverts to the stale number has to revert the inflection too, and this fails. + * + * Every Russian anchor carries an inflection its numeral governs, not only the state-store one: + * `удерживаемых хранилищ`, `наблюдаемых буфера` and `регистраций` each change form with the + * count. A future count that moves one of them stops this check matching and fails it, asking + * for a re-anchor. That is the designed outcome. The alternative — a pattern loose enough to + * match any noun form — would accept a sentence nobody re-read, which is the failure this guard + * exists to prevent. + */ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +import { + APP_OWNED_OBSERVED_BUFFER_REGISTRATIONS, + APP_OWNED_RETAINED_STORE_REGISTRATIONS, +} from "../../src/lib/app-owned-memory-stores"; +import { STATE_STORE_REGISTRATIONS } from "../../src/lib/state-store-registrations"; +import { repoPath } from "../helpers/repo-root"; + +const RETAINED = APP_OWNED_RETAINED_STORE_REGISTRATIONS.length; +const OBSERVED = APP_OWNED_OBSERVED_BUFFER_REGISTRATIONS.length; +const STATE_STORES = STATE_STORE_REGISTRATIONS.length; + +/** + * One README, the three counts it states, and the sentence that must carry no count. + * + * Each claim captures the number it states instead of merely searching for the derived value: + * `toContain("4")` would pass on the `60 s interval` further down the same bullet, which is the + * one place in this block where a second number sits close enough to be mistaken for the claim. + */ +const PAGES = [ + { + locale: "en", + path: "README.md", + retained: /(\d+) retained stores/, + observed: /(\d+) observed buffers/, + stateStores: /(\d+) state-store registrations/, + totalSentence: "process-retained state", + }, + { + locale: "fr", + path: "readme/README.fr.md", + retained: /(\d+) stockages conservés/, + observed: /(\d+) tampons observés/, + stateStores: /(\d+) enregistrements de stockages/, + totalSentence: "état conservé par le processus", + }, + { + locale: "ja", + path: "readme/README.ja.md", + retained: /保持ストア (\d+) 個/, + observed: /観測バッファ (\d+) 個/, + stateStores: /state-store の登録 (\d+) 個/, + totalSentence: "プロセスが保持する状態を", + }, + { + locale: "ko", + path: "readme/README.ko.md", + retained: /유지 저장소 (\d+)개/, + observed: /관측 버퍼 (\d+)개/, + stateStores: /state-store 등록 (\d+)개/, + totalSentence: "프로세스가 붙잡고 있는 상태를", + }, + { + locale: "ru", + path: "readme/README.ru.md", + retained: /(\d+) удерживаемых хранилищ/, + observed: /(\d+) наблюдаемых буфера/, + stateStores: /(\d+) регистраций state-store/, + totalSentence: "удерживаемое процессом", + }, + { + locale: "tr", + path: "readme/README.tr.md", + retained: /(\d+) tutulan depo/, + observed: /(\d+) gözlenen arabellek/, + stateStores: /(\d+) state-store kaydı/, + totalSentence: "süreçte tutulan durumu", + }, + { + locale: "zh-CN", + path: "readme/README.zh-CN.md", + retained: /(\d+) 个保留存储/, + observed: /(\d+) 个观测缓冲区/, + stateStores: /(\d+) 个状态存储注册/, + totalSentence: "进程保留状态", + }, + { + locale: "zh-TW", + path: "readme/README.zh-TW.md", + retained: /(\d+) 個保留儲存/, + observed: /(\d+) 個觀測緩衝區/, + stateStores: /(\d+) 個狀態儲存註冊/, + totalSentence: "行程保留狀態", + }, +] as const; + +/** + * The number one page states for one claim. + * + * Exactly one occurrence is required. A page that states the count twice has two places to + * update and this check only sees one of them, so the second is a silent drift waiting to + * happen; a page that states it zero times has been reworded past the anchor. + */ +function statedOnce(path: string, claim: RegExp): number { + const hits = [...readFileSync(repoPath(path), "utf8").matchAll(new RegExp(claim, "g"))]; + expect(hits.length, `${path} states this count ${hits.length} times; re-anchor this check`).toBe(1); + return Number(hits[0]![1]); +} + +/** The single line carrying `anchor`, which is the line a claim is required to sit on. */ +function anchoredLine(path: string, anchor: string): string { + const lines = readFileSync(repoPath(path), "utf8") + .split("\n") + .filter(line => line.includes(anchor)); + expect(lines.length, `${path} has no line containing "${anchor}"; re-anchor this check`).toBe(1); + return lines[0]!; +} + +describe("documented memory inventory counts match the registered rosters", () => { + test("the rosters are the only source of the numbers under test", () => { + // A derived count that collapsed to zero would make every assertion below vacuous. + expect(RETAINED).toBeGreaterThan(0); + expect(OBSERVED).toBeGreaterThan(0); + expect(STATE_STORES).toBeGreaterThan(0); + }); + + for (const page of PAGES) { + test(`${page.locale} README states ${RETAINED} retained stores`, () => { + expect(statedOnce(page.path, page.retained)).toBe(RETAINED); + }); + + test(`${page.locale} README states ${OBSERVED} observed buffers`, () => { + expect(statedOnce(page.path, page.observed)).toBe(OBSERVED); + }); + + test(`${page.locale} README states ${STATE_STORES} state-store registrations`, () => { + expect(statedOnce(page.path, page.stateStores)).toBe(STATE_STORES); + }); + + test(`${page.locale} README states no total it cannot derive`, () => { + const line = anchoredLine(page.path, page.totalSentence); + expect(line, `${page.path} states a total; derive it from a roster or drop it`).not.toMatch(/[0-9]/); + }); + } +}); diff --git a/tests/claude-integration/claude-code-thought-signature-scope.test.ts b/tests/claude-integration/claude-code-thought-signature-scope.test.ts index 814ce7ceb4a..6722dbf955e 100644 --- a/tests/claude-integration/claude-code-thought-signature-scope.test.ts +++ b/tests/claude-integration/claude-code-thought-signature-scope.test.ts @@ -14,13 +14,18 @@ import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } fro import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; const actualResolver = await import("../../src/server/adapter-resolve"); +// Capture the real function before the override. `mock.module` rewrites the namespace's live +// binding in place, so a lookup through `actualResolver` inside the wrapper would reach +// whichever override is current, including this one, once another file in the same process +// has mocked this module too. +const actualResolveAdapter = actualResolver.resolveAdapter; let adapterFactory: ((provider: OcxProviderConfig) => ProviderAdapter) | undefined; mock.module("../../src/server/adapter-resolve", () => ({ ...actualResolver, resolveAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") { - return adapterFactory?.(provider) ?? actualResolver.resolveAdapter(provider, cacheRetention); + return adapterFactory?.(provider) ?? actualResolveAdapter(provider, cacheRetention); }, })); diff --git a/tests/clients/desktop-app-restart-posix.test.ts b/tests/clients/desktop-app-restart-posix.test.ts index 585c86dcb78..660d2775f5a 100644 --- a/tests/clients/desktop-app-restart-posix.test.ts +++ b/tests/clients/desktop-app-restart-posix.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { restartCodexDesktopApp, type DesktopAppRestartIo } from "../../src/codex/desktop-app-restart"; +import { setDarwinKillForTests } from "../../src/codex/desktop-app/darwin"; import { isUnderRoot } from "../../src/codex/desktop-app/types"; import { acquireDesktopRestartLock, @@ -52,6 +53,15 @@ function psRows(rows: Array<[number, number, string]>): string { return rows.map(([pid, ppid, exe]) => `${pid} ${ppid} ${WHEN} ${process.getuid?.() ?? 0} ${exe}`).join("\n"); } +/** + * Every signal the darwin adapter sends. The adapter signals through `process.kill`, which the + * exec seam cannot intercept, so without this recorder the synthetic pids below (15901 …) were + * signalled for real on whatever machine ran the suite. + */ +const kills: Array<[number, string]> = []; +beforeEach(() => { setDarwinKillForTests((pid, signal) => { kills.push([pid, signal]); }); }); +afterEach(() => { setDarwinKillForTests(null); }); + function darwinIo(options: { calls: Call[]; rows?: Array<[number, number, string]>; @@ -279,6 +289,26 @@ describe.skipIf(process.platform === "win32")("a stop is only ever claimed when }); }); +describe.skipIf(process.platform === "win32")("darwin signals stay inside the suite", () => { + test("a forced stop signals the recorder, never a real pid", () => { + kills.length = 0; + restartCodexDesktopApp({ + platform: "darwin", + lock: isolatedLock(), + ancestryPids: () => [99_999], + isAlive: () => true, + sleep: () => {}, + now: (() => { let t = 0; return () => (t += 500); })(), + execFile: (file) => { + if (file === "/bin/ps") return psRows([[15901, 1, SHELL]]); + if (file === "/usr/libexec/PlistBuddy") return "com.openai.codex"; + return ""; + }, + }); + expect(kills.some(([pid]) => pid === 15901)).toBe(true); + }); +}); + describe("the restart singleton lock", () => { const alive = new Set([1001, 1002, 2001]); const io = (lockPath: string, pid: number) => ({ diff --git a/tests/clients/desktop-app-restart.test.ts b/tests/clients/desktop-app-restart.test.ts index fd7ae597dcd..1fd3de3c26e 100644 --- a/tests/clients/desktop-app-restart.test.ts +++ b/tests/clients/desktop-app-restart.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { restartCodexDesktopApp, type DesktopAppRestartIo } from "../../src/codex/desktop-app-restart"; import { windowsDesktopAppAdapter } from "../../src/codex/desktop-app/windows"; +import { handleDesktopAppRestart } from "../../src/cli/restart-scope"; import { setTrustedWindowsElevationExecutablesForTests } from "../../src/lib/windows-elevation"; /** @@ -140,6 +141,35 @@ function scriptedIo(options: { const DISCOVERY = [AUMID.replace("!App", ""), INSTALL, AUMID].join("\n"); +// The guard follows the test preload's OCX_TEST_HOME_GUARD, as the home guard does, rather than +// NODE_ENV: Bun's test runner keeps an inherited NODE_ENV, and a real `NODE_ENV=test ocx ...` +// must still restart the app. +describe("the test-runner guard follows the test preload, not NODE_ENV", () => { + const originalNodeEnv = process.env.NODE_ENV; + const originalGuard = process.env.OCX_TEST_HOME_GUARD; + afterEach(() => { + if (originalNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = originalNodeEnv; + if (originalGuard === undefined) delete process.env.OCX_TEST_HOME_GUARD; + else process.env.OCX_TEST_HOME_GUARD = originalGuard; + }); + + test("an armed test process is guarded even when NODE_ENV was inherited as something else", () => { + process.env.NODE_ENV = "development"; + const result = restartCodexDesktopApp({ lock: isolatedLock(), platform: "win32" }); + expect(result.reason).toBe("test_environment"); + }); + + test("a process the test preload did not arm restarts as usual even with NODE_ENV=test", () => { + process.env.NODE_ENV = "test"; + process.env.OCX_TEST_HOME_GUARD = "0"; + // discover() answers without exec, so this never reaches the OS. + const adapter = { ...windowsDesktopAppAdapter, discover: () => null }; + const result = restartCodexDesktopApp({ lock: isolatedLock(), platform: "win32", adapter }); + expect(result.reason).toBe("package_discovery_failed"); + }); +}); + describe("Codex desktop app restart (#2292)", () => { // macOS and Linux are no longer no-ops: they have real adapters. What survives from the // original assertion is that a platform with NO adapter still refuses without execing @@ -158,6 +188,33 @@ describe("Codex desktop app restart (#2292)", () => { expect(calls).toEqual([]); }); + // A test that reaches the restart without injecting an adapter or exec used to drive the real + // OS adapter: on a developer Mac `performCodexRestart` tests quit the user's ChatGPT (Codex) + // app and relaunched it through `/usr/bin/open` with the runner's sandbox HOME, logged out. + // win32 keeps the pre-fix run harmless off Windows (no PowerShell to discover anything with). + test("under the test runner, the real OS adapter is never used without an injected one", () => { + const result = restartCodexDesktopApp({ lock: isolatedLock(), platform: "win32" }); + expect(result).toEqual({ + attempted: false, stopped: [], surviving: [], relaunch: "skipped", reason: "test_environment", + }); + }); + + // An injected adapter still execs through the platform default when no exec is injected, so + // only an injected exec proves the caller is simulating the OS. + test("an injected adapter without an injected exec still never reaches the OS", () => { + const result = restartCodexDesktopApp({ lock: isolatedLock(), platform: "win32", adapter: windowsDesktopAppAdapter }); + expect(result.reason).toBe("test_environment"); + }); + + // win32 keeps this call away from the OS on macOS and Linux even if the guard regressed. + test("the CLI says why nothing was restarted under the test runner", async () => { + const out: string[] = []; + const log = { log: (...a: unknown[]) => { out.push(a.join(" ")); }, error: (...a: unknown[]) => { out.push(a.join(" ")); } }; + const result = await handleDesktopAppRestart(log, { platform: "win32", lock: isolatedLock() }); + expect(result.reason).toBe("test_environment"); + expect(out.join("\n")).toContain("OCX_TEST_HOME_GUARD"); + }); + test("fails closed when the package cannot be identified, killing nothing", () => { const calls: Call[] = []; const result = withTrustedExes(() => restartCodexDesktopApp(scriptedIo({ discovery: "MISS", calls }))); diff --git a/tests/codex-integration/codex-account-store-refresh-classification.test.ts b/tests/codex-integration/codex-account-store-refresh-classification.test.ts index 934d9ca5e78..9365a267686 100644 --- a/tests/codex-integration/codex-account-store-refresh-classification.test.ts +++ b/tests/codex-integration/codex-account-store-refresh-classification.test.ts @@ -22,6 +22,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; */ let TEST_DIR = ""; +const previousHome = process.env.OPENCODEX_HOME; const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; @@ -39,7 +40,8 @@ describe("codex refresh-failure classification", () => { await flushConfigDirHardeningForTests(); setIcaclsRunnerForTests(null); setAsyncIcaclsRunnerForTests(null); - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; if (TEST_DIR) removeTreeWithRetry(TEST_DIR); TEST_DIR = ""; }); diff --git a/tests/codex-integration/codex-account-store.test.ts b/tests/codex-integration/codex-account-store.test.ts index 2fcb9538b4b..c9756a904f7 100644 --- a/tests/codex-integration/codex-account-store.test.ts +++ b/tests/codex-integration/codex-account-store.test.ts @@ -15,6 +15,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; * 49 errors in run 33590540220 were before/after hooks failing on the same path. */ let TEST_DIR = ""; +const previousHome = process.env.OPENCODEX_HOME; let ACCOUNTS_PATH = ""; const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; @@ -33,7 +34,8 @@ async function removeScratchHome(): Promise { await flushConfigDirHardeningForTests(); setIcaclsRunnerForTests(null); setAsyncIcaclsRunnerForTests(null); - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; if (TEST_DIR) removeTreeWithRetry(TEST_DIR); TEST_DIR = ""; } diff --git a/tests/codex-integration/codex-entitlement-identity-read-fence.test.ts b/tests/codex-integration/codex-entitlement-identity-read-fence.test.ts index d5daad4d20a..e02b7facead 100644 --- a/tests/codex-integration/codex-entitlement-identity-read-fence.test.ts +++ b/tests/codex-integration/codex-entitlement-identity-read-fence.test.ts @@ -33,6 +33,7 @@ const VERSION_B = "0.147.0"; const NOW = 1_800_000_000_000; let TEST_DIR = ""; +const previousHome = process.env.OPENCODEX_HOME; const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; @@ -83,7 +84,8 @@ describe("the denial pass resolves credential identity once, not once per cache await flushConfigDirHardeningForTests(); setIcaclsRunnerForTests(null); setAsyncIcaclsRunnerForTests(null); - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; if (TEST_DIR) removeTreeWithRetry(TEST_DIR); TEST_DIR = ""; resetCodexModelEntitlementCacheForTests(); diff --git a/tests/codex-integration/codex-transition-state-adoption.test.ts b/tests/codex-integration/codex-transition-state-adoption.test.ts index b025f12a7d2..36632c26d7a 100644 --- a/tests/codex-integration/codex-transition-state-adoption.test.ts +++ b/tests/codex-integration/codex-transition-state-adoption.test.ts @@ -16,6 +16,7 @@ const CHILD = helperPath("codex-adoption-crash-child.ts"); let root = ""; let codexHome = ""; let opencodexHome = ""; +const previousHome = process.env.OPENCODEX_HOME; let coordinatorPath = ""; beforeEach(() => { @@ -32,7 +33,8 @@ beforeEach(() => { afterEach(() => { delete process.env.CODEX_HOME; - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; rmSync(coordinatorPath, { force: true }); removeTreeWithRetry(root); }); diff --git a/tests/codex-integration/issue-914-transport-attribution.test.ts b/tests/codex-integration/issue-914-transport-attribution.test.ts index a3fdc090d0d..0669a7940f6 100644 --- a/tests/codex-integration/issue-914-transport-attribution.test.ts +++ b/tests/codex-integration/issue-914-transport-attribution.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { classifyCodexUpstreamOutcome, clearCodexUpstreamHealth, @@ -75,71 +75,61 @@ function restoreEnv(): void { removeTreeWithRetry(TEST_DIR); } +afterEach(restoreEnv); + function coded(message: string, code: string): Error { return Object.assign(new Error(message), { code }); } describe("issue #914 — pre-connection failures never touch account health", () => { test("three concurrent neutral failures leave streak, affinity, and active account untouched", () => { - try { - const config = makeTwoAccountConfig({ upstreamFailoverThreshold: 3 }); - expect(getConfigPath().startsWith(TEST_DIR)).toBe(true); - // Pin the thread to account A the way a real continue would. - resolveCodexAccountForThread(config, "thread-914", { now: Date.now() }); - expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); - - const hostKey = upstreamHostHealthKey("openai", "chatgpt.com"); - for (let i = 0; i < 3; i++) { - recordCodexUpstreamOutcome(config, "a", "connect_neutral", { - threadId: "thread-914", - hostKey, - lastFailureCode: "ECONNREFUSED", - }); - } - - // No account evidence at the failover threshold: no streak, no soft-avoid, - // no affinity loss, no rotation. The host ledger carries the failure instead. - expect(getCodexUpstreamHealth("a")).toBeNull(); - expect(isCodexAccountSoftAvoided("a")).toBe(false); - expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); - expect(getUpstreamHostHealth(hostKey)).toMatchObject({ consecutiveFailures: 3, lastFailureCode: "ECONNREFUSED" }); - } finally { - restoreEnv(); + const config = makeTwoAccountConfig({ upstreamFailoverThreshold: 3 }); + expect(getConfigPath().startsWith(TEST_DIR)).toBe(true); + // Pin the thread to account A the way a real continue would. + resolveCodexAccountForThread(config, "thread-914", { now: Date.now() }); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + + const hostKey = upstreamHostHealthKey("openai", "chatgpt.com"); + for (let i = 0; i < 3; i++) { + recordCodexUpstreamOutcome(config, "a", "connect_neutral", { + threadId: "thread-914", + hostKey, + lastFailureCode: "ECONNREFUSED", + }); } + + // No account evidence at the failover threshold: no streak, no soft-avoid, + // no affinity loss, no rotation. The host ledger carries the failure instead. + expect(getCodexUpstreamHealth("a")).toBeNull(); + expect(isCodexAccountSoftAvoided("a")).toBe(false); + expect(getEffectiveActiveCodexAccountId(config)).toBe("a"); + expect(getUpstreamHostHealth(hostKey)).toMatchObject({ consecutiveFailures: 3, lastFailureCode: "ECONNREFUSED" }); }); test("a relayed 3xx is the neutral class: no account and no host evidence", () => { - try { - const config = makeTwoAccountConfig(); - for (const status of [301, 302, 307, 308]) { - expect(classifyCodexUpstreamOutcome(status)).toBe("neutral"); - recordCodexUpstreamOutcome(config, "a", status); - } - expect(getCodexUpstreamHealth("a")).toBeNull(); - expect(isCodexAccountSoftAvoided("a")).toBe(false); - expect(getUpstreamHostHealth(upstreamHostHealthKey("openai", "chatgpt.com"))).toBeNull(); - } finally { - restoreEnv(); + const config = makeTwoAccountConfig(); + for (const status of [301, 302, 307, 308]) { + expect(classifyCodexUpstreamOutcome(status)).toBe("neutral"); + recordCodexUpstreamOutcome(config, "a", status); } + expect(getCodexUpstreamHealth("a")).toBeNull(); + expect(isCodexAccountSoftAvoided("a")).toBe(false); + expect(getUpstreamHostHealth(upstreamHostHealthKey("openai", "chatgpt.com"))).toBeNull(); }); test("mixed evidence: 503 then a reachability rejection stays account-attributed", async () => { - try { - const config = makeTwoAccountConfig({ upstreamFailoverThreshold: 1 }); - let calls = 0; - const rejection = coded("refused", "ECONNREFUSED"); - const outcome = classifyTransportFailureKind(await fetchWithTransientRetry(async () => { - calls++; - if (calls === 1) return new Response("gw", { status: 503 }); - throw rejection; - }, { slowAttemptMs: 60_000 }).catch(err => err)); - expect(calls).toBe(2); - expect(outcome).toBe("connect_error"); - recordCodexUpstreamOutcome(config, "a", outcome, { threadId: "t-mixed" }); - expect(getCodexUpstreamHealth("a")).toMatchObject({ consecutiveFailures: 1 }); - } finally { - restoreEnv(); - } + const config = makeTwoAccountConfig({ upstreamFailoverThreshold: 1 }); + let calls = 0; + const rejection = coded("refused", "ECONNREFUSED"); + const outcome = classifyTransportFailureKind(await fetchWithTransientRetry(async () => { + calls++; + if (calls === 1) return new Response("gw", { status: 503 }); + throw rejection; + }, { slowAttemptMs: 60_000 }).catch(err => err)); + expect(calls).toBe(2); + expect(outcome).toBe("connect_error"); + recordCodexUpstreamOutcome(config, "a", outcome, { threadId: "t-mixed" }); + expect(getCodexUpstreamHealth("a")).toMatchObject({ consecutiveFailures: 1 }); }); test("mixed evidence: a reset then a reachability rejection stays account-attributed", async () => { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 53d53517396..d855da0f260 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -615,6 +615,7 @@ "docs-provider-billing-claims.test.ts": "ci-workflows", "docs-provider-discovery-limits.test.ts": "ci-workflows", "docs-provider-preset-counts.test.ts": "ci-workflows", + "docs-readme-memory-inventory.test.ts": "ci-workflows", "docs-readme-translation-parity.test.ts": "ci-workflows", "docs-remote-hub-claims.test.ts": "ci-workflows", "doctor-codex-envkey-readiness.test.ts": "service", diff --git a/tests/helpers/translator-budget.ts b/tests/helpers/translator-budget.ts index 7245729752e..0c4e5f565dd 100644 --- a/tests/helpers/translator-budget.ts +++ b/tests/helpers/translator-budget.ts @@ -1,4 +1,4 @@ -import { afterEach } from "bun:test"; +import { afterEach, onTestFinished } from "bun:test"; import type { IncomingMeta, ProviderAdapter } from "../../src/adapters/base"; import { createTranslatorBudget, @@ -8,17 +8,29 @@ import { const liveTestBudgets = new Set(); +function disposeTestTranslatorBudgets(): void { + for (const budget of liveTestBudgets) budget.dispose(); + liveTestBudgets.clear(); + resetTranslatorAggregateForTests(); +} + export function createTestTranslatorBudget(options?: Parameters[0]): TranslatorBudget { const budget = createTranslatorBudget(options); liveTestBudgets.add(budget); + // The `afterEach` below only runs for the first test file that imports this module: a + // shared Bun process evaluates it once, so every later importer keeps its budgets and the + // aggregate for the rest of the run. `onTestFinished` belongs to the running test, whatever + // file it is in. Outside a test (module scope, `beforeAll`) it throws, and the `afterEach` + // stays the only cleanup. + try { + onTestFinished(disposeTestTranslatorBudgets); + } catch { + // Not inside a running test. + } return budget; } -afterEach(() => { - for (const budget of liveTestBudgets) budget.dispose(); - liveTestBudgets.clear(); - resetTranslatorAggregateForTests(); -}); +afterEach(disposeTestTranslatorBudgets); type TestAdapter = Omit & { buildRequest( diff --git a/tests/images/download-cap-default.test.ts b/tests/images/download-cap-default.test.ts index 77fce8fd986..40ba400389f 100644 --- a/tests/images/download-cap-default.test.ts +++ b/tests/images/download-cap-default.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, mock, test } from "bun:test"; +import { afterAll, describe, expect, mock, test } from "bun:test"; // The default downloader inside connectPublicHttps used to forward `maxBytes: undefined` // to pinnedHttpGet, whose cap is optional — so a caller that omitted a limit removed the @@ -6,6 +6,15 @@ import { describe, expect, mock, test } from "bun:test"; // happen to pass an explicit limit today, which is why the existing suites (they all // inject `pinnedDownload` and bypass the default path) could not see it. +// `mock.module` outlives this file: Bun keeps both overrides below for every file that +// runs after this one in the same process. Keep the real modules and put them back. +const realDns = { ...(await import("node:dns/promises")) }; +const realPinnedHttp = { ...(await import("../../src/lib/pinned-http")) }; +afterAll(() => { + mock.module("node:dns/promises", () => realDns); + mock.module("../../src/lib/pinned-http", () => realPinnedHttp); +}); + const lookupMock = mock(async (): Promise<{ address: string; family: number }[]> => [ { address: "93.184.216.34", family: 4 }, ]); diff --git a/tests/images/loop-reasoning-replay.test.ts b/tests/images/loop-reasoning-replay.test.ts index b854b8060e0..a21c8bd2c81 100644 --- a/tests/images/loop-reasoning-replay.test.ts +++ b/tests/images/loop-reasoning-replay.test.ts @@ -18,6 +18,11 @@ import { createTestTranslatorBudget } from "../helpers/translator-budget"; const REASONING = "I need to inspect files before answering."; const PREV_HOME = process.env.OPENCODEX_HOME; +// `mock.restore()` does not undo `mock.module`: Bun keeps both overrides below for every +// file that runs after this one in the same process. Keep the real modules to put back, +// and restore only the ones captured: a setup that failed partway must not install an empty module. +let realProgressStream: Record | undefined; +let realFulfill: Record | undefined; let runWithImageBridgeProduction: typeof import("../../src/images/loop")["runWithImageBridge"]; let fulfillResult: import("../../src/images/types").ImageCallResult = { ok: true, model: "grok-imagine-image-quality", prompt: "a cat", @@ -27,6 +32,8 @@ let fulfillResult: import("../../src/images/types").ImageCallResult = { beforeAll(async () => { process.env.OPENCODEX_HOME = join(tmpdir(), "ocx-test-" + randomUUID()); mock.restore(); + realProgressStream = { ...(await import("../../src/web-search/progress-stream")) }; + realFulfill = { ...(await import("../../src/images/fulfill")) }; mock.module("../../src/web-search/progress-stream", () => ({ parseStreamWithProgress: async function* (_resp: Response, parse: (r: Response) => AsyncGenerator, _opts: unknown) { for await (const e of parse(_resp)) yield e; @@ -44,6 +51,8 @@ afterAll(() => { if (PREV_HOME === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = PREV_HOME; mock.restore(); + if (realProgressStream) { const real = realProgressStream; mock.module("../../src/web-search/progress-stream", () => real); } + if (realFulfill) { const real = realFulfill; mock.module("../../src/images/fulfill", () => real); } }); let streamQueue: AdapterEvent[][] = []; diff --git a/tests/images/loop.test.ts b/tests/images/loop.test.ts index cfbadfae3d7..84dd5f13257 100644 --- a/tests/images/loop.test.ts +++ b/tests/images/loop.test.ts @@ -15,6 +15,11 @@ let useRealProgressStream = false; let fulfillCallCount = 0; const PREV_HOME = process.env.OPENCODEX_HOME; +// `mock.restore()` does not undo `mock.module`: Bun keeps both overrides below for every +// file that runs after this one in the same process. Keep the real modules to put back, +// and restore only the ones captured: a setup that failed partway must not install an empty module. +let realProgressStream: Record | undefined; +let realFulfill: Record | undefined; let runWithImageBridgeProduction: typeof import("../../src/images/loop")["runWithImageBridge"]; let clampImageMaxRounds: typeof import("../../src/images/loop")["clampImageMaxRounds"]; let DEFAULT_MAX_ROUNDS: typeof import("../../src/images/loop")["DEFAULT_MAX_ROUNDS"]; @@ -28,6 +33,8 @@ let fulfillResult: ImageCallResult = { beforeAll(async () => { process.env.OPENCODEX_HOME = join(tmpdir(), "ocx-test-" + randomUUID()); mock.restore(); + realProgressStream = { ...(await import("../../src/web-search/progress-stream")) }; + realFulfill = { ...(await import("../../src/images/fulfill")) }; mock.module("../../src/web-search/progress-stream", () => ({ parseStreamWithProgress: async function* (_resp: Response, parse: ProviderAdapter["parseStream"], opts: ParseStreamWithProgressOptions) { if (useRealProgressStream) yield* realParseStreamWithProgress(_resp, parse, opts); @@ -58,7 +65,12 @@ function runWithImageBridge( }, }); } -afterAll(() => { if (PREV_HOME === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = PREV_HOME; mock.restore(); }); +afterAll(() => { + if (PREV_HOME === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = PREV_HOME; + mock.restore(); + if (realProgressStream) { const real = realProgressStream; mock.module("../../src/web-search/progress-stream", () => real); } + if (realFulfill) { const real = realFulfill; mock.module("../../src/images/fulfill", () => real); } +}); // --- Mock adapter: yields canned events per iteration from a queue --- let streamQueue: AdapterEvent[][] = []; diff --git a/tests/images/z-fulfill.test.ts b/tests/images/z-fulfill.test.ts index 646ce1bf7a8..8aabe9edd3a 100644 --- a/tests/images/z-fulfill.test.ts +++ b/tests/images/z-fulfill.test.ts @@ -10,11 +10,18 @@ const PREV_HOME = process.env.OPENCODEX_HOME; let fulfillImageCall: typeof import("../../src/images/fulfill")["fulfillImageCall"]; let imageFulfillmentTailSnapshot: typeof import("../../src/images/fulfill")["imageFulfillmentTailSnapshot"]; let testHome = ""; +// `mock.restore()` does not undo `mock.module`: Bun keeps both overrides below for every +// file that runs after this one in the same process. Keep the real modules to put back, +// and restore only the ones captured: a setup that failed partway must not install an empty module. +let realXaiClient: Record | undefined; +let realArtifacts: Record | undefined; beforeAll(async () => { testHome = join(tmpdir(), "ocx-test-" + randomUUID()); process.env.OPENCODEX_HOME = testHome; mock.restore(); + realXaiClient = { ...(await import("../../src/images/xai-client")) }; + realArtifacts = { ...(await import("../../src/images/artifacts")) }; mock.module("../../src/images/xai-client", () => ({ callXaiImages: async (req: XaiImageRequest, _auth: unknown, _signal?: AbortSignal, timeoutMs?: number) => { xaiCalls.push(req); @@ -37,7 +44,12 @@ beforeAll(async () => { })); ({ fulfillImageCall, imageFulfillmentTailSnapshot } = await import(`../../src/images/fulfill?fulfill=${Date.now()}`)); }); -afterAll(() => { if (PREV_HOME === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = PREV_HOME; mock.restore(); }); +afterAll(() => { + if (PREV_HOME === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = PREV_HOME; + mock.restore(); + if (realXaiClient) { const real = realXaiClient; mock.module("../../src/images/xai-client", () => real); } + if (realArtifacts) { const real = realArtifacts; mock.module("../../src/images/artifacts", () => real); } +}); // --- Mutable mock state (reset() restores defaults before each test) --- let xaiResult: { images: Array<{ b64_json?: string; url?: string }> } = { images: [{ b64_json: "dGVzdA==" }] }; diff --git a/tests/images/z-handler-activation.test.ts b/tests/images/z-handler-activation.test.ts index a2227dc8b1f..7543c2d37f8 100644 --- a/tests/images/z-handler-activation.test.ts +++ b/tests/images/z-handler-activation.test.ts @@ -45,6 +45,12 @@ let releaseSpendHome: (() => void) | undefined; // Retained so teardown can remove it. Nothing created this directory before the lease did: // taking ownership mkdirs the state directory, so the suite now owns its removal too. let ownedHome = ""; +// `mock.restore()` does not undo `mock.module`: Bun keeps the three overrides below for every +// file that runs after this one in the same process. Keep the real modules to put back, and +// restore only the ones captured: a setup that failed partway must not install an empty module. +let realAdapterResolve: Record | undefined; +let realImageLoop: Record | undefined; +let realWebSearch: Record | undefined; beforeAll(async () => { ownedHome = join(tmpdir(), "ocx-test-" + randomUUID()); @@ -52,9 +58,9 @@ beforeAll(async () => { // Take the writer lease after this suite installs its home so direct handler dispatch can open the spend journal. releaseSpendHome = acquireOwnedSpendHome(); - const actualResolver = await import("../../src/server/adapter-resolve"); + realAdapterResolve = { ...(await import("../../src/server/adapter-resolve")) }; mock.module("../../src/server/adapter-resolve", () => ({ - ...actualResolver, + ...realAdapterResolve, resolveAdapter(provider: OcxProviderConfig) { const base = { name: "test", @@ -79,9 +85,9 @@ beforeAll(async () => { }, })); - const actualLoop = await import("../../src/images/loop"); + realImageLoop = { ...(await import("../../src/images/loop")) }; mock.module("../../src/images/loop", () => ({ - ...actualLoop, + ...realImageLoop, runWithImageBridge: async (args: { parsed: { options: { toolChoice?: unknown } }; plan: { toolNames: Set }; @@ -95,6 +101,7 @@ beforeAll(async () => { }, })); + realWebSearch = { ...(await import("../../src/web-search/index")) }; mock.module("../../src/web-search/index", () => ({ buildWebSearchTool: () => ({ name: "web_search", parameters: { type: "object", properties: {} } }), WEB_SEARCH_TOOL_NAME: "web_search", @@ -124,12 +131,23 @@ afterAll(() => { // Release, then remove, then restore. An open lease inside a directory being deleted fails // the removal on Windows and leaves an unlinked live database on POSIX, and the removal has // to happen while OPENCODEX_HOME still names the directory being removed. - releaseSpendHome?.(); - releaseSpendHome = undefined; - if (ownedHome) removeTreeWithRetry(ownedHome); - if (PREV_HOME === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = PREV_HOME; - mock.restore(); + // The module restore sits in `finally` so a failed removal cannot leave the overrides + // installed for every later file in the process. + try { + releaseSpendHome?.(); + releaseSpendHome = undefined; + if (ownedHome) removeTreeWithRetry(ownedHome); + } finally { + if (PREV_HOME === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = PREV_HOME; + mock.restore(); + const adapterResolve = realAdapterResolve; + const imageLoop = realImageLoop; + const webSearch = realWebSearch; + if (adapterResolve) mock.module("../../src/server/adapter-resolve", () => adapterResolve); + if (imageLoop) mock.module("../../src/images/loop", () => imageLoop); + if (webSearch) mock.module("../../src/web-search/index", () => webSearch); + } }); /** Routed (non-OpenAI) keyed provider + an xAI provider with an API key so the real planImageBridge returns a plan. */ diff --git a/tests/lab/lab-automation-coderabbit-regressions.test.ts b/tests/lab/lab-automation-coderabbit-regressions.test.ts index 3f65ad4ab15..19a540a8d52 100644 --- a/tests/lab/lab-automation-coderabbit-regressions.test.ts +++ b/tests/lab/lab-automation-coderabbit-regressions.test.ts @@ -33,6 +33,7 @@ import { createProductionLabRouteExecutor } from "../../src/lib/lab-live-route-p import { removeTreeWithRetry } from "../helpers/remove-tree"; const HOMES: string[] = []; +const previousHome = process.env.OPENCODEX_HOME; function tempHome(): string { const dir = join(tmpdir(), `ocx-lab-coderabbit-${process.pid}-${Math.random().toString(16).slice(2)}`); @@ -92,8 +93,9 @@ afterEach(() => { requestLabAutomationShutdown(); stopLabAutomationScheduler(); resetLabAutomationSchedulerStateForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; for (const dir of HOMES.splice(0)) removeTreeWithRetry(dir); - delete process.env.OPENCODEX_HOME; }); describe("CL-08 CodeRabbit regressions", () => { diff --git a/tests/lab/lab-automation-final-coderabbit-regressions.test.ts b/tests/lab/lab-automation-final-coderabbit-regressions.test.ts index b563359bfac..d6a0836b84f 100644 --- a/tests/lab/lab-automation-final-coderabbit-regressions.test.ts +++ b/tests/lab/lab-automation-final-coderabbit-regressions.test.ts @@ -38,6 +38,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; const HOMES: string[] = []; +const previousHome = process.env.OPENCODEX_HOME; const COMPAT_VERSION = "9".repeat(64); function tempHome(): string { @@ -165,7 +166,8 @@ afterEach(() => { stopLabAutomationScheduler(); resetLabAutomationSchedulerStateForTests(); resetCompatibilityVersionCacheForTests(); - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; for (const dir of HOMES.splice(0)) removeTreeWithRetry(dir); }); diff --git a/tests/lab/lab-automation-management-http.test.ts b/tests/lab/lab-automation-management-http.test.ts index ea2f08b2574..cb3d0a1be70 100644 --- a/tests/lab/lab-automation-management-http.test.ts +++ b/tests/lab/lab-automation-management-http.test.ts @@ -18,6 +18,7 @@ import type { LabAutomationRunRecordV1 } from "../../src/lab/automation/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const HOMES: string[] = []; +const previousHome = process.env.OPENCODEX_HOME; function tempHome(): string { const dir = join(tmpdir(), `ocx-lab-http-${process.pid}-${Math.random().toString(16).slice(2)}`); @@ -57,8 +58,9 @@ afterEach(() => { requestLabAutomationShutdown(); stopLabAutomationScheduler(); resetLabAutomationSchedulerStateForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; for (const dir of HOMES.splice(0)) removeTreeWithRetry(dir); - delete process.env.OPENCODEX_HOME; }); describe("CL-08 automation management HTTP", () => { diff --git a/tests/lab/lab-automation-review-regressions.test.ts b/tests/lab/lab-automation-review-regressions.test.ts index 7042835927c..ac3a7cb52aa 100644 --- a/tests/lab/lab-automation-review-regressions.test.ts +++ b/tests/lab/lab-automation-review-regressions.test.ts @@ -41,6 +41,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; const COMPAT_VERSION = "e".repeat(64); const HOMES: string[] = []; +const previousHome = process.env.OPENCODEX_HOME; function tempHome(): string { const dir = join(tmpdir(), `ocx-lab-cl08-review-${process.pid}-${Math.random().toString(16).slice(2)}`); @@ -152,7 +153,8 @@ afterEach(() => { resetLabAutomationSchedulerStateForTests(); setLabAutomationDispatchDeps({}); resetCompatibilityVersionCacheForTests(); - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; for (const dir of HOMES.splice(0)) { try { removeTreeWithRetry(dir); } catch { /* ignore */ } } diff --git a/tests/lab/lab-automation.test.ts b/tests/lab/lab-automation.test.ts index 080ed834a37..9898fa45f59 100644 --- a/tests/lab/lab-automation.test.ts +++ b/tests/lab/lab-automation.test.ts @@ -44,6 +44,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; const COMPAT_VERSION = "f".repeat(64); const HOMES: string[] = []; +const previousHome = process.env.OPENCODEX_HOME; function tempHome(): string { const dir = join(tmpdir(), `ocx-lab-cl08-${process.pid}-${Math.random().toString(16).slice(2)}`); @@ -65,7 +66,8 @@ afterEach(() => { /* ignore */ } } - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; }); function withHome(fn: (home: string) => T): T { diff --git a/tests/lab/lab-evidence-ledger.test.ts b/tests/lab/lab-evidence-ledger.test.ts index c7ecdeff839..1cfe44b8f06 100644 --- a/tests/lab/lab-evidence-ledger.test.ts +++ b/tests/lab/lab-evidence-ledger.test.ts @@ -45,6 +45,7 @@ import type { ClaimSnapshotEvent, ObservationEvent, ProtocolSubjectV1 } from ".. import { removeTreeWithRetry } from "../helpers/remove-tree"; const HOMES: string[] = []; +const previousHome = process.env.OPENCODEX_HOME; function tempHome(): string { const dir = join(tmpdir(), `ocx-lab-${process.pid}-${Math.random().toString(16).slice(2)}`); @@ -61,7 +62,8 @@ afterEach(() => { /* ignore */ } } - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; }); function withHome(fn: (home: string) => T): T { diff --git a/tests/lab/lab-evidence-sanitization.test.ts b/tests/lab/lab-evidence-sanitization.test.ts index 672f6f1f328..25f342fbf1d 100644 --- a/tests/lab/lab-evidence-sanitization.test.ts +++ b/tests/lab/lab-evidence-sanitization.test.ts @@ -28,6 +28,7 @@ import type { NormalizedObservation } from "../../src/lab/conformance/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const HOMES: string[] = []; +const previousHome = process.env.OPENCODEX_HOME; function tempHome(): string { const dir = join(tmpdir(), `ocx-lab-sanitize-${process.pid}-${Math.random().toString(16).slice(2)}`); mkdirSync(dir, { recursive: true, mode: 0o700 }); @@ -38,7 +39,8 @@ afterEach(() => { for (const dir of HOMES.splice(0)) { try { removeTreeWithRetry(dir); } catch { /* ignore */ } } - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; }); function behavior(adapter: string, upstreamProtocol: string): LabBehaviorValues { diff --git a/tests/lab/lab-fabric-task.test.ts b/tests/lab/lab-fabric-task.test.ts index 5bc000ff3a2..a06087eeceb 100644 --- a/tests/lab/lab-fabric-task.test.ts +++ b/tests/lab/lab-fabric-task.test.ts @@ -112,6 +112,7 @@ const FAST_FABRIC_ISOLATION = Object.freeze({ }); const HOMES: string[] = []; +const previousHome = process.env.OPENCODEX_HOME; function tempHome(): string { const dir = join(tmpdir(), `ocx-cl07-${process.pid}-${Math.random().toString(16).slice(2)}`); @@ -314,7 +315,8 @@ afterEach(() => { /* ignore */ } } - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; }); function routeSubject(overrides: Partial = {}): RouteSubjectV1 { diff --git a/tests/lab/lab-live-probe.test.ts b/tests/lab/lab-live-probe.test.ts index 4aec1615222..bb12aded84f 100644 --- a/tests/lab/lab-live-probe.test.ts +++ b/tests/lab/lab-live-probe.test.ts @@ -20,8 +20,9 @@ import type { NormalizedObservation } from "../../src/lab/conformance/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const HOMES: string[] = []; +const previousHome = process.env.OPENCODEX_HOME; function tempHome(): string { const dir = join(tmpdir(), `ocx-lab-probe-${process.pid}-${Math.random().toString(16).slice(2)}`); mkdirSync(dir, { recursive: true, mode: 0o700 }); HOMES.push(dir); return dir; } -afterEach(() => { for (const dir of HOMES.splice(0)) { try { removeTreeWithRetry(dir); } catch { /* ignore */ } } delete process.env.OPENCODEX_HOME; clearMcpStub(); }); +afterEach(() => { for (const dir of HOMES.splice(0)) { try { removeTreeWithRetry(dir); } catch { /* ignore */ } } if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; clearMcpStub(); }); function behavior(adapter: string, upstreamProtocol: string): LabBehaviorValues { return { diff --git a/tests/lab/lab-live-review-regressions.test.ts b/tests/lab/lab-live-review-regressions.test.ts index 5215e8bd754..5b58ab24522 100644 --- a/tests/lab/lab-live-review-regressions.test.ts +++ b/tests/lab/lab-live-review-regressions.test.ts @@ -23,6 +23,7 @@ import type { import { removeTreeWithRetry } from "../helpers/remove-tree"; const HOMES: string[] = []; +const previousHome = process.env.OPENCODEX_HOME; function tempHome(): string { const dir = join(tmpdir(), `ocx-lab-live-review-${process.pid}-${Math.random().toString(16).slice(2)}`); mkdirSync(dir, { recursive: true, mode: 0o700 }); @@ -34,7 +35,8 @@ afterEach(() => { for (const dir of HOMES.splice(0)) { try { removeTreeWithRetry(dir); } catch { /* ignore */ } } - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; }); function behavior(overrides: Partial = {}): LabBehaviorValues { diff --git a/tests/lab/lab-live-sandbox.test.ts b/tests/lab/lab-live-sandbox.test.ts index c4efc0185a0..d8eb61eb3ad 100644 --- a/tests/lab/lab-live-sandbox.test.ts +++ b/tests/lab/lab-live-sandbox.test.ts @@ -16,8 +16,9 @@ import { REQUIRED_LAB_SANDBOX_BOUNDARIES, type LabBehaviorValues, type LabRouteC import { removeTreeWithRetry } from "../helpers/remove-tree"; const HOMES: string[] = []; +const previousHome = process.env.OPENCODEX_HOME; function tempHome(): string { const dir = join(tmpdir(), `ocx-lab-live-${process.pid}-${Math.random().toString(16).slice(2)}`); mkdirSync(dir, { recursive: true, mode: 0o700 }); HOMES.push(dir); return dir; } -afterEach(() => { for (const dir of HOMES.splice(0)) { try { removeTreeWithRetry(dir); } catch { /* ignore */ } } delete process.env.OPENCODEX_HOME; }); +afterEach(() => { for (const dir of HOMES.splice(0)) { try { removeTreeWithRetry(dir); } catch { /* ignore */ } } if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; }); function behavior(adapter: string, upstreamProtocol: string): LabBehaviorValues { return { "wire.adapter": { source: "lab_forced", value: adapter }, "wire.upstreamProtocol": { source: "lab_forced", value: upstreamProtocol }, "auth.mode": { source: "provider_config", value: "api_key" }, "auth.transport": { source: "provider_config", value: "authorization_bearer" }, "mcp.nativeLocalExec": { source: "lab_forced", value: false }, "runtime.bunVersion": { source: "lab_forced", value: Bun.version }, "runtime.platform": { source: "lab_forced", value: process.platform }, "runtime.arch": { source: "lab_forced", value: process.arch }, "runtime.streamMode": { source: "lab_forced", value: "auto" }, "runtime.fastMode": { source: "lab_forced", value: false }, "runtime.effortCap": { source: "lab_forced", value: null }, "headers.nonCredentialBehaviorDigest": { source: "provider_config", value: "0".repeat(64) } }; diff --git a/tests/lab/lab-post-merge-hardening.test.ts b/tests/lab/lab-post-merge-hardening.test.ts index 0e8660a448a..4642cdba8a3 100644 --- a/tests/lab/lab-post-merge-hardening.test.ts +++ b/tests/lab/lab-post-merge-hardening.test.ts @@ -26,6 +26,7 @@ import type { ObservationEvent, ProtocolSubjectV1 } from "../../src/lab/events/t import { removeTreeWithRetry } from "../helpers/remove-tree"; const HOMES: string[] = []; +const previousHome = process.env.OPENCODEX_HOME; function tempHome(): string { const dir = join(tmpdir(), `ocx-lab-hardening-${process.pid}-${Math.random().toString(16).slice(2)}`); @@ -42,7 +43,8 @@ afterEach(() => { /* ignore */ } } - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; }); function createHashHex(value: string): string { diff --git a/tests/lab/lab-public-surfaces.test.ts b/tests/lab/lab-public-surfaces.test.ts index bf798e2992a..f2ab6bc44bb 100644 --- a/tests/lab/lab-public-surfaces.test.ts +++ b/tests/lab/lab-public-surfaces.test.ts @@ -21,10 +21,12 @@ import { ManagementRequest } from "../helpers/management-auth"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const HOMES: string[] = []; +const previousHome = process.env.OPENCODEX_HOME; afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; for (const home of HOMES.splice(0)) removeTreeWithRetry(home); - delete process.env.OPENCODEX_HOME; }); function tempHome(): string { diff --git a/tests/lab/lab-read-surfaces.test.ts b/tests/lab/lab-read-surfaces.test.ts index 078c3d2042f..5495f1bc2fe 100644 --- a/tests/lab/lab-read-surfaces.test.ts +++ b/tests/lab/lab-read-surfaces.test.ts @@ -46,6 +46,7 @@ import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const HOMES: string[] = []; +const previousHome = process.env.OPENCODEX_HOME; function tempHome(): string { const dir = join(tmpdir(), `ocx-lab-cl04-${process.pid}-${Math.random().toString(16).slice(2)}`); @@ -62,7 +63,8 @@ afterEach(() => { /* ignore */ } } - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; }); function withHome(fn: (home: string) => T): T { diff --git a/tests/responses/responses-snapshot-repair-server.test.ts b/tests/responses/responses-snapshot-repair-server.test.ts index 3b4f1230b0f..002407b1557 100644 --- a/tests/responses/responses-snapshot-repair-server.test.ts +++ b/tests/responses/responses-snapshot-repair-server.test.ts @@ -23,6 +23,7 @@ setDefaultTimeout(30_000); const originalFetch = globalThis.fetch; let TEST_DIR = ""; +const previousHome = process.env.OPENCODEX_HOME; let isolated: IsolatedCodexHome; const SPARSE_EVENTS = [ @@ -123,6 +124,8 @@ beforeEach(() => { }); afterEach(async () => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; releaseSpendHome?.(); releaseSpendHome = undefined; globalThis.fetch = originalFetch; diff --git a/tests/routing/routing-policy-surface-parity.test.ts b/tests/routing/routing-policy-surface-parity.test.ts index 5bdd93161a7..afcc0795243 100644 --- a/tests/routing/routing-policy-surface-parity.test.ts +++ b/tests/routing/routing-policy-surface-parity.test.ts @@ -114,12 +114,17 @@ describe("routing policy request evidence parity (translator-level coverage)", ( // ---- Handler-level parity tests (via dev handler entry points) ---- const actualResolver = await import("../../src/server/adapter-resolve"); +// Capture the real function before the override. `mock.module` rewrites the namespace's live +// binding in place, so a lookup through `actualResolver` inside the wrapper would reach +// whichever override is current, including this one, once another file in the same process +// has mocked this module too. +const actualResolveAdapter = actualResolver.resolveAdapter; let adapterFactory: ((provider: OcxProviderConfig) => ProviderAdapter) | undefined; mock.module("../../src/server/adapter-resolve", () => ({ ...actualResolver, resolveAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") { - return adapterFactory?.(provider) ?? actualResolver.resolveAdapter(provider, cacheRetention); + return adapterFactory?.(provider) ?? actualResolveAdapter(provider, cacheRetention); }, })); diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index df49231080f..66fe5dd5ad0 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -53,6 +53,7 @@ import { runRetiredCodexModelMigration, RETIRED_MODEL_MIGRATION_CUTOFF } from ". import { providerManagementConfigError } from "../../src/server/auth-cors"; import { removeTreeWithRetry } from "../helpers/remove-tree"; let testDir = ""; +const previousHome = process.env.OPENCODEX_HOME; /** * Windows without Developer Mode or admin cannot create a file symlink (EPERM). @@ -79,7 +80,7 @@ beforeEach(() => { }); afterEach(() => { - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (testDir && existsSync(testDir)) removeTreeWithRetry(testDir); testDir = ""; }); @@ -2547,7 +2548,6 @@ describe("opencodex config defaults", () => { } }); - test("diagnostics keep the operator's config instead of reporting defaults", () => { // The salvage in loadConfig was not enough on its own. readConfigDiagnostics returned // getDefaultConfig(), and a config command writing that result back would have persisted diff --git a/tests/server/management-provider-pinsless-validation.test.ts b/tests/server/management-provider-pinsless-validation.test.ts index 66249e7d00e..b621f3d704a 100644 --- a/tests/server/management-provider-pinsless-validation.test.ts +++ b/tests/server/management-provider-pinsless-validation.test.ts @@ -6,7 +6,7 @@ * ever moves downward, so a case added after it was set fails the ratchet for every * later pull request. The case is unchanged apart from its own temp directory. */ -import { describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; import { managementFetch as fetch } from "../helpers/management-auth"; import { config } from "../helpers/management-relative-send-paths"; import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; @@ -21,6 +21,7 @@ import type { OcxConfig } from "../../src/types"; setDefaultTimeout(60_000); const TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-management-provider-pinsless-")); +const previousHome = process.env.OPENCODEX_HOME; const canonicalDirect = { adapter: "openai-responses", @@ -35,6 +36,11 @@ function poolProviders(): OcxConfig["providers"] { }; } +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; +}); + describe("provider management validation", () => { test("provider POST validates a pins-less candidate before live adoption", async () => { if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); diff --git a/tests/server/management-provider-proto-override.test.ts b/tests/server/management-provider-proto-override.test.ts index a25b6bda85d..2706061d314 100644 --- a/tests/server/management-provider-proto-override.test.ts +++ b/tests/server/management-provider-proto-override.test.ts @@ -6,7 +6,7 @@ * ever moves downward, so a test added to it after the cap was set fails the ratchet * for every later pull request rather than only its own. The case is unchanged. */ -import { describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; import { managementFetch as fetch } from "../helpers/management-auth"; import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -20,6 +20,7 @@ import type { OcxConfig } from "../../src/types"; setDefaultTimeout(60_000); const TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-management-provider-proto-")); +const previousHome = process.env.OPENCODEX_HOME; const canonicalDirect = { adapter: "openai-responses", @@ -28,6 +29,11 @@ const canonicalDirect = { codexAccountMode: "direct", } as const; +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; +}); + describe("provider management validation", () => { // A "__proto__" model id is a legitimate override key once the GUI can draft it. // The merge target must be a null-prototype map: on an ordinary object the diff --git a/tests/service/process-state.test.ts b/tests/service/process-state.test.ts index 248317fd24a..ffaf8484181 100644 --- a/tests/service/process-state.test.ts +++ b/tests/service/process-state.test.ts @@ -26,6 +26,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; let testDir = ""; +const previousHome = process.env.OPENCODEX_HOME; beforeEach(() => { testDir = mkdtempSync(join(tmpdir(), "ocx-process-state-")); @@ -38,7 +39,8 @@ afterEach(() => { setProcessCommandLinePlatformForTests(null); setTrustedWindowsSystemDirectoryResolverForTests(null); setOcxStartProcessCacheForTests([]); - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; if (testDir && existsSync(testDir)) removeTreeWithRetry(testDir); testDir = ""; }); diff --git a/tests/service/service-secrets.test.ts b/tests/service/service-secrets.test.ts index acb04a051c4..3653e8b3676 100644 --- a/tests/service/service-secrets.test.ts +++ b/tests/service/service-secrets.test.ts @@ -34,6 +34,7 @@ import { import { removeTreeWithRetry } from "../helpers/remove-tree"; let home = ""; +const previousHome = process.env.OPENCODEX_HOME; beforeEach(() => { home = mkdtempSync(join(tmpdir(), "ocx-service-secret-")); @@ -41,7 +42,8 @@ beforeEach(() => { }); afterEach(() => { - delete process.env.OPENCODEX_HOME; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; if (home) removeTreeWithRetry(home); }); From 8ffd32357ce4936ca34b00c65ca4a7eaddef3f89 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 19:22:23 +0900 Subject: [PATCH 03/48] =?UTF-8?q?fix(providers):=20bundle=20lane=20F1=20?= =?UTF-8?q?=E2=80=94=20Cursor=20fast=20continuation,=20Meta=20Muse=20web?= =?UTF-8?q?=5Fsearch,=20artifact=20connect=20deadline,=20Alibaba=20Respons?= =?UTF-8?q?es=20pins,=20Windows=20kiro.exe=20(#5673)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(devlog): plan bundle lane F1 (provider registry) * fix(cursor): continue composer-2.5-fast tool turns as userMessageAction composer-2.5-fast stayed on resumeAction after the 2026-08-20 capture because it answered on that path then. A 2026-09-21 proxy log shows the fast build completing a tool-result turn with no text and no tool call, the same empty stop that moved composer-2.5 to the external continuation path. Route the fast id through cursorNeedsExternalToolContinuation too. Tests that pinned fast to resumeAction now use composer-1 as the native counterexample, the live-transport screenshot case expects the Composer continuation for both 2.5 builds, and the clipped-invocation restoration case covers fast. cursor-blob.test.ts stays at its line cap. Carries #5362. Co-authored-by: Play <99410048+001005HS@users.noreply.github.com> * fix(adapters): strip the refused web_search fields on direct Meta for every Muse id Direct Meta Muse / Meta Model Responses refuses search_content_types and indexed_web_access on a plain web_search tool as a gateway schema rule, before inference, for every Muse model it serves. Dev only stripped them for the Contributor ids, so the non-Contributor default muse-spark-1.3 (both direct-Meta presets) still sent them and 400ed every Codex turn that attached web_search. The direct Meta destination is now the whole predicate, including a missing model id; the two OpenCode Zen destinations keep the Contributor-id gate because they serve nothing else. Preview tools keep their accepted shape. The contract moves to structure/transports/responses-wire-shapes.md, replacing the stale "unrelated models" wording. Carries #5314. Co-authored-by: Ivan Fokeev <2017148+ifokeev@users.noreply.github.com> * fix(images): add a connect deadline to provider artifact downloads Provider-returned image and video URLs are downloaded through connectPublicHttps and the pinned-IP transport. That path bounded the idle phases and the first byte but did not arm a separate TCP/TLS connect deadline, so a peer that never completed the handshake held the download for the full first-byte window. connectPublicHttps now forwards a 10 s DOWNLOAD_CONNECT_TIMEOUT_MS and pinnedHttpsGet accepts a per-call connectTimeoutMs with the same default; a stalled connect fails with connect_timeout. The idle timer and the 50 MiB cap are unchanged. The production-path test lives in a new sibling file registered in both layout manifests; the transport inventory records the deadline. Carries #5349. Co-authored-by: ahmedfrawelo <247386484+ahmedfrawelo@users.noreply.github.com> * feat(registry): pin live-verified Alibaba Token Plan models to the Responses wire Alibaba Token Plan (Beijing) documents a native Responses API on the same compatible-mode base and an official Codex guide on wire_api = "responses" (#5097). qwen3.8-flash, qwen3.7-plus and glm-5.3 were live-verified end to end on that gateway, so the registry now defaults them to openai-responses for Responses inbound only. Chat and Anthropic inbound keep the provider-wide Chat wire and its measured prefix-cache behaviour, and modelAdapters still wins in both directions. The entry sets preserveResponsesReasoningContent beside the pins: the Responses serializer reads that flag rather than the Chat-side preserveReasoningContentModels list, and the gateway accepted replayed plaintext reasoning content live. qwen3.7-plus sends its effort as a reasoning.effort string on this wire instead of the Chat-side numeric thinking_budget. The intl sibling stays unpinned. Tests cover resolver defaults per inbound, the upstream URL through handleResponses for all three pinned models (glm-5.3 now asserts the Responses default, not only the Anthropic path), the qwen3.7-plus effort payload, overrides, and the reasoning replay flag. The provider reference row and structure/transports/responses-wire-shapes.md describe the pins. Carries #5188. Closes #5097. Co-authored-by: mdwsk88 <11055210+mdwsk88@users.noreply.github.com> * fix(oauth): fall back to kiro.exe inside the dedicated Windows Kiro-Cli folders Some Windows installs keep the CLI as kiro.exe in %LOCALAPPDATA%\Kiro-Cli or Program Files\Kiro-Cli, so forced and add-account Kiro login could not find it. After every canonical kiro-cli candidate misses, the resolver now accepts kiro.exe inside those two folders only, which are already trusted for kiro-cli.exe, and only when the base is a fully qualified drive path. A short name is never resolved from PATH or from the shared POSIX bin directories (~/.local/bin, /usr/local/bin, /opt/homebrew/bin): an unrelated kiro there, such as the Kiro IDE launcher, must not receive credential-flow arguments. Negative tests cover a short name on PATH, relative and drive-relative bases, and the POSIX directories. The provider guide and structure/providers/kiro.md state the order. Partial carry of #5000: its Unix short-name fallback is left out. Co-authored-by: 정우철 <86232509+oocheol@users.noreply.github.com> * fix(bundle-f1): fold the adversarial review nits - src/images/artifacts.ts: state the connect-deadline rationale correctly; a 60 s first-byte timer already runs before the connection exists, and the new deadline bounds TCP/TLS setup on its own. - fr, tr and zh-tw provider guides: add the Windows kiro.exe fallback and the never-a-short-name-from-PATH rule next to the existing Kiro-Cli paragraph. - devlog lane plan: drop trailing blank lines and add the delivery doc. --------- Co-authored-by: Play <99410048+001005HS@users.noreply.github.com> Co-authored-by: Ivan Fokeev <2017148+ifokeev@users.noreply.github.com> Co-authored-by: ahmedfrawelo <247386484+ahmedfrawelo@users.noreply.github.com> Co-authored-by: mdwsk88 <11055210+mdwsk88@users.noreply.github.com> Co-authored-by: 정우철 <86232509+oocheol@users.noreply.github.com> --- .../000_overview.md | 7 + .../010_carry_plan.md | 14 + .../020_delivery.md | 6 + .../src/content/docs/fr/guides/providers.md | 3 + .../src/content/docs/guides/providers.md | 3 + .../docs/reference/configuration/providers.md | 2 +- .../src/content/docs/tr/guides/providers.md | 3 + .../content/docs/zh-tw/guides/providers.md | 3 + scripts/test-layout/layout.json | 2 + src/adapters/cursor/discovery.ts | 15 +- src/adapters/openai-responses/web-search.ts | 26 +- src/images/artifacts.ts | 14 + src/oauth/kiro-credentials.ts | 15 +- src/providers/registry/entries-extended.ts | 37 ++- structure/providers/cursor.md | 4 +- structure/providers/kiro.md | 12 + structure/transports/inventory.md | 2 +- structure/transports/responses-wire-shapes.md | 21 +- tests/fixtures/test-layout-expected.json | 2 + .../download-connect-deadline-default.test.ts | 42 +++ tests/images/pinned-https-get.test.ts | 85 ++++++ ...alibaba-token-plan-responses-optin.test.ts | 49 ++-- .../alibaba-token-plan-wire-defaults.test.ts | 252 ++++++++++++++++++ tests/providers/cursor/cursor-blob.test.ts | 4 +- .../providers/cursor/cursor-discovery.test.ts | 4 +- .../cursor/cursor-live-transport.test.ts | 2 +- .../cursor/cursor-tool-continuation.test.ts | 2 +- .../cursor-tool-result-invocation.test.ts | 33 +-- .../kiro-windows-cli-executable-path.test.ts | 62 +++++ .../muse-spark-web-search-compat.test.ts | 31 +++ 30 files changed, 690 insertions(+), 67 deletions(-) create mode 100644 devlog/_plan/260923_bundle_f1_provider_registry/000_overview.md create mode 100644 devlog/_plan/260923_bundle_f1_provider_registry/010_carry_plan.md create mode 100644 devlog/_plan/260923_bundle_f1_provider_registry/020_delivery.md create mode 100644 tests/images/download-connect-deadline-default.test.ts create mode 100644 tests/providers/alibaba-token-plan-wire-defaults.test.ts diff --git a/devlog/_plan/260923_bundle_f1_provider_registry/000_overview.md b/devlog/_plan/260923_bundle_f1_provider_registry/000_overview.md new file mode 100644 index 00000000000..71a994d5109 --- /dev/null +++ b/devlog/_plan/260923_bundle_f1_provider_registry/000_overview.md @@ -0,0 +1,7 @@ +# 260923 bundle lane F1 — provider registry + +Lane of the 260923 PR lane bundle round (coordinator plan: devlog/_plan/260923_pr_lane_bundle/ in the coordinator worktree). One branch, codex/260923-bundle-f1-provider-registry, cut from origin/dev 685321e297, one PR to dev. Every carry was reviewed by a gpt-6-sol reviewer against current dev before it was rebuilt. + +Docs: + +- 010_carry_plan.md — per-PR verdicts, carry order, required fixes, exclusions. diff --git a/devlog/_plan/260923_bundle_f1_provider_registry/010_carry_plan.md b/devlog/_plan/260923_bundle_f1_provider_registry/010_carry_plan.md new file mode 100644 index 00000000000..1c91b4c0288 --- /dev/null +++ b/devlog/_plan/260923_bundle_f1_provider_registry/010_carry_plan.md @@ -0,0 +1,14 @@ +# Carry plan + +| Order | Item | Verdict | Commit contents | +|---|---|---|---| +| 1 | #5362 cursor composer-2.5-fast external tool continuation | carry with fixes | Route composer-2.5-fast through cursorNeedsExternalToolContinuation; rewrite the contradicting assertions in cursor-blob (at its line cap, so replace, never add), cursor-live-transport and cursor-tool-continuation tests; keep a native resumeAction counterexample (composer-1); cover the clipped-invocation case for fast; update structure/providers/cursor.md. | +| 2 | #5314 Meta Muse web_search strip for every model id | carry with fixes | Dev already strips search_content_types and indexed_web_access for Contributor ids; extend it to every id on the direct Meta host, including a missing id and the muse-spark-1.3 default; document in structure/transports/responses-wire-shapes.md and correct the "unrelated models" wording; tests in muse-spark-web-search-compat.test.ts (openai-responses-passthrough.test.ts is at its cap). | +| 3 | #5349 connect deadline for provider artifact downloads | carry with fixes | 10 s connect deadline on the connectPublicHttps production path and a per-call option on pinnedHttpsGet; correct the rationale (a 60 s first-byte timer already exists, the new deadline bounds TCP/TLS setup specifically); document in the owning transport contract; register the new test file in both layout manifests. | +| 4 | #5188 Alibaba Token Plan Responses wire defaults (closes #5097) | carry with fixes | Default Responses pins for qwen3.8-flash, qwen3.7-plus and glm-5.3 on the Beijing preset for Responses inbound, keeping Chat/Anthropic routing and explicit modelAdapters overrides; fix the glm-5.3 test to assert the Responses default and assert the qwen3.7-plus reasoning.effort payload; rebuild the docs row and layout hunks; update responses-wire-shapes.md. | +| 5 | #5000 kiro short-name executable fallback | PARTIAL | Security review failed the Unix part: ~/.local/bin, /usr/local/bin and /opt/homebrew/bin are shared directories where an unrelated kiro binary (for example the Kiro IDE launcher) could be run for credential commands. Carry only the Windows fallback to kiro.exe inside the dedicated Kiro-Cli install folders that dev already trusts for kiro-cli.exe, skip it when the base path is not absolute, and keep canonical-name-first order. Not superseded; the original PR stays open. | +| — | #5147 CodeBuddy roster discovery (#5146) | excluded | codebuddy --help returns the home-logged-in account's roster regardless of the key passed, while the PR caches it under the configured key's hash, so it can advertise another account's models for a key. Needs a way to prove roster and request key belong to the same account first (owner design decision). #5146 stays open; its tool-bridge half is already on dev. | + +Residual for the owner: #5362 changes fast routing for every client; the maintainer review asked for one direct Cursor fast tool turn to confirm it still answers. This lane does not spend live Cursor calls. + +Verification per commit: focused test files for the touched area and their consumers, then bun run typecheck, bun run structure:check, bun run privacy:scan, layout guards (tests/test-layout.test.ts, tests/test-layout-tooling.test.ts) and the file-size ratchet test. No full local suite (reserved for the owner after all lanes land). diff --git a/devlog/_plan/260923_bundle_f1_provider_registry/020_delivery.md b/devlog/_plan/260923_bundle_f1_provider_registry/020_delivery.md new file mode 100644 index 00000000000..b404ae54a3b --- /dev/null +++ b/devlog/_plan/260923_bundle_f1_provider_registry/020_delivery.md @@ -0,0 +1,6 @@ +# Delivery + +1. Adversarial gpt-6-sol review of the whole branch and a security re-review of the rebuilt #5000 commit. Fold every accepted finding as a follow-up commit; record rebuttals in the PR. +2. Push codex/260923-bundle-f1-provider-registry and open one PR to dev with the repository template: Summary, Verification (exact focused commands and counts, full suite not run by owner instruction), Checklist, Closes #5097, Supersedes lines for the fully carried PRs (#5362, #5314, #5349, #5188), #5000 listed as partial (not superseded), #5147 and #5146 listed as excluded with the reason, and every Co-authored-by credit. +3. Watch exact-head CI. A run cancelled by the 2.64 release coordinator is re-dispatched after the release; real failures are fixed on the branch. +4. Final report to the coordinator. diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 3a86ecbfb52..6888d4b327f 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -270,6 +270,9 @@ la source et la ligne du jeton : Sous Windows, l'importation recherche `%LOCALAPPDATA%\Kiro-Cli\data.sqlite3`. La connexion forcée ou par **Ajouter un compte** nécessite également le binaire local de la CLI : opencodex consulte d'abord `PATH`, puis se rabat sur `%LOCALAPPDATA%\Kiro-Cli\kiro-cli.exe` et `C:\Program Files\Kiro-Cli\kiro-cli.exe`. +Si aucun de ces dossiers ne contient `kiro-cli.exe`, un `kiro.exe` placé dans ces deux mêmes dossiers +`Kiro-Cli` est utilisé. opencodex n'exécute jamais un `kiro` ou `kiro.exe` trouvé dans le `PATH` +ou dans les répertoires bin partagés de macOS/Linux : installez ou liez la CLI sous le nom `kiro-cli`. Après une importation réussie, opencodex conserve les informations d'identification importées dans `~/.opencodex/auth.json`. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 9fe60bb9268..c89793a4788 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -420,6 +420,9 @@ read-only. Two environment variables make the source and token row selection exp On Windows, import looks for `%LOCALAPPDATA%\Kiro-Cli\data.sqlite3`. Forced/add-account login also needs the local CLI binary: opencodex first uses `PATH`, then falls back to `%LOCALAPPDATA%\Kiro-Cli\kiro-cli.exe` and `C:\Program Files\Kiro-Cli\kiro-cli.exe`. +If neither folder has `kiro-cli.exe`, a `kiro.exe` inside those same two `Kiro-Cli` folders is used. +opencodex never runs a short `kiro` or `kiro.exe` found on `PATH` or in shared macOS/Linux bin +directories, so install or link the CLI as `kiro-cli` there. After a successful import, opencodex persists the imported credential to `~/.opencodex/auth.json`. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 3f296a66d87..e10b168374b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -243,7 +243,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `modelSuppressSyntheticMax?` | `Record` | Catalog-only per-model switch. `true` prevents OpenCodex from adding a missing synthetic `max` rung while retaining a provider-declared `max` and continuing to add `ultra` to reasoning-capable ladders. A preserved row that already contains `max` keeps it until healthy discovery rebuilds that row, because the catalog does not persist whether an older `max` was synthetic. If a configured default names a suppressed missing `max`, the catalog falls back to the highest real rung below it. Codex uses the same ladder membership for the picker and explicit `spawn_agent` effort validation, so an explicit `max` spawn can fail client-side before proxy clamping; `ultra` remains the supported harness path. | | `modelSupportsReasoningSummaries?` | `Record` | Set a model to `false` to stop advertising summaries and strip summary-delivery fields. | | `modelReasoningSummaryDelivery?` | `Record` | Per-model Responses delivery enum; rewrites an existing delivery field. | -| `modelAdapters?` | `Record` | Per-model `openai-chat` or `openai-responses` wire override for mixed-wire gateways. Explicit entries beat registry defaults. The OpenCode Go preset selects Responses for `gpt-5.6-luna` while leaving sibling models on their documented wires; DeepSeek can select native Responses for `deepseek-v4-flash`; Alibaba Token Plan (Beijing) serves `qwen3.8-flash`, `qwen3.7-plus`, and `glm-5.3` over its native Responses API on the same base, verified end to end on that gateway, so they can be opted in here while the wire default stays Chat; and GitHub Copilot declares Responses-only defaults for the following models (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) because those models reject `/chat/completions` for agent traffic. Models without a built-in default (for example `gpt-5.4-nano`) can be opted in here. Single-wire upstream pins and canonical ChatGPT forward reject overrides. | +| `modelAdapters?` | `Record` | Per-model `openai-chat` or `openai-responses` wire override for mixed-wire gateways. Explicit entries beat registry defaults. The OpenCode Go preset selects Responses for `gpt-5.6-luna` while leaving sibling models on their documented wires; DeepSeek can select native Responses for `deepseek-v4-flash`; Alibaba Token Plan (Beijing) pins `qwen3.8-flash`, `qwen3.7-plus`, and `glm-5.3` to its native Responses API for Responses inbound only (live-verified on that gateway, including plaintext reasoning replay; Chat and Anthropic inbound stay on Chat, and the rest of the family can be opted in here); and GitHub Copilot declares Responses-only defaults for the following models (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) because those models reject `/chat/completions` for agent traffic. Models without a built-in default (for example `gpt-5.4-nano`) can be opted in here. Single-wire upstream pins and canonical ChatGPT forward reject overrides. | | xAI Chat Completions (dashboard / CLI) | switch | Grok 4.5/4.6 OAuth Responses requests default to Responses. Existing Chat overrides are migrated once on upgrade; later Chat choices are preserved. Turn on to select Chat for both models, off to select Responses. CLI: `ocx provider edit xai --xai-chat on` or `--xai-chat off` (running proxy required). Mixed means only one model currently uses Chat. Other overrides and tier policy stay unchanged. API-key and translated Chat/Anthropic defaults are unchanged. Grok 4.7 defaults to Responses on OAuth through its registry wire default and can use Chat through an explicit `modelAdapters["grok-4.7"] = "openai-chat"` override. | | `xaiResponsesXSearch?` | `boolean` | Disabled by default. On an xAI Responses destination, append the provider-hosted `x_search` declaration only when a live `web_search` tool survives final request normalization. Existing declarations are not duplicated, caller `tool_choice`/`allowed_tools` selectors are never widened, and this is separate from the web-search sidecar's `search.xSearch` options. | | `modelPreferHostedTools?` | `Record` | Exact-model opt-in for non-forward Responses gateways that reserve a hosted-tool namespace. Currently accepts only `["image_generation"]`; a matching model must use the `openai-responses` wire and support that hosted tool. It removes colliding client `image_gen` declarations and rewrites their selectors to preserve caller tool choice. For OpenAI API virtual `-pro` models, the selected public ID is matched first and the resolved base wire-model ID is a fallback. `modelAdapters` resolves the public ID first, then the base ID; the second resolution determines the final wire. Other models retain normal alias behavior. | diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 3a84713f26f..073b57dc688 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -299,6 +299,9 @@ Zorunlu/hesap ekleme girişi yerel CLI ikili dosyasına da ihtiyaç duyar: opencodex önce `PATH`'i kullanır, ardından `%LOCALAPPDATA%\Kiro-Cli\kiro-cli.exe` ve `C:\Program Files\Kiro-Cli\kiro-cli.exe`'ye geri döner. +Bu klasörlerin hiçbirinde `kiro-cli.exe` yoksa, aynı iki `Kiro-Cli` klasöründeki `kiro.exe` +kullanılır. opencodex, `PATH` üzerinde veya paylaşılan macOS/Linux bin dizinlerinde bulunan kısa +`kiro` ya da `kiro.exe` dosyasını asla çalıştırmaz; CLI'yi orada `kiro-cli` adıyla kurun veya bağlayın. Başarılı bir içe aktarmadan sonra opencodex içe aktarılan kimlik bilgisini `~/.opencodex/auth.json` dosyasına kalıcı hale getirir. Bu değişkenleri ve diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index bbfcdcf6bf2..1ec5b07d1ef 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -229,6 +229,9 @@ PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1' | iex`;接著以 `ki Windows 匯入會尋找 `%LOCALAPPDATA%\Kiro-Cli\data.sqlite3`。forced/add-account login 也需要本機 CLI binary:opencodex 先使用 `PATH`,再 fallback 到 `%LOCALAPPDATA%\Kiro-Cli\kiro-cli.exe` 與 `C:\Program Files\Kiro-Cli\kiro-cli.exe`。 +若兩個資料夾都沒有 `kiro-cli.exe`,會改用同樣這兩個 `Kiro-Cli` 資料夾內的 `kiro.exe`。 +opencodex 絕不執行在 `PATH` 或 macOS/Linux 共用 bin 目錄中找到的 `kiro` 或 `kiro.exe`, +請在那裡以 `kiro-cli` 名稱安裝或連結 CLI。 成功匯入後,opencodex 會把 credential 寫入 `~/.opencodex/auth.json`。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 15778d23b10..bb62fef0ca9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -195,6 +195,7 @@ "alibaba-region-migration.test.ts": "providers", "alibaba-region-startup.test.ts": "providers", "alibaba-token-plan-responses-optin.test.ts": "providers", + "alibaba-token-plan-wire-defaults.test.ts": "providers", "always-on-429-failover.test.ts": "routing", "ambiguous-resend-composition.test.ts": "lib", "ambiguous-resend-gate.test.ts": "lib", @@ -792,6 +793,7 @@ "doctor-service-memory-contract.test.ts": "service", "doctor.test.ts": "codex-integration", "download-cap-default.test.ts": "images", + "download-connect-deadline-default.test.ts": "images", "dsh-path-contract.test.ts": "ci-workflows", "dsh-rc6-compat-script.test.ts": "ci-workflows", "dsh-writer-lock.test.ts": "ci-workflows", diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index fe8b52e5a49..50c412c6f3a 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -270,15 +270,20 @@ export function isCursorExternalWireModel(modelId: string): boolean { * Observed on live Cursor Connect traffic (2026-08-20): `composer-2.5` (the * standard, non-fast build) resumes a tool-result turn with server-side native * tool calls (read/grep/exec) instead of answering, or completes with zero text - * (empty `content` + `stop`). `composer-2.5-fast` answers correctly on the same - * resumeAction path, so only the affected id is listed here. Sending the same - * continuation as an explicit user message (external path) makes the model - * answer reliably. + * (empty `content` + `stop`). Sending the same continuation as an explicit user + * message (external path) makes the model answer reliably. + * + * `composer-2.5-fast` was left on resumeAction after that same capture because + * it answered on that path then. A later live proxy log (2026-09-21) shows + * `cursor/composer-2.5-fast completed with no output text and no tool call` on + * the Chat Completions → Responses bridge used by OpenAI-compatible clients + * (GJC executor). That is the same empty-stop shape, so the fast id uses the + * external continuation path too. */ export function cursorNeedsExternalToolContinuation(modelId: string): boolean { if (isCursorExternalWireModel(modelId)) return true; const wire = cursorCodexToWireModelId(modelId).trim().toLowerCase(); - return wire === "composer-2.5"; + return wire === "composer-2.5" || wire === "composer-2.5-fast"; } function stripCursorEffortSuffix(wireModelId: string): string { diff --git a/src/adapters/openai-responses/web-search.ts b/src/adapters/openai-responses/web-search.ts index 6af07160e72..e808fe7b3e0 100644 --- a/src/adapters/openai-responses/web-search.ts +++ b/src/adapters/openai-responses/web-search.ts @@ -68,6 +68,10 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { * same-shaped successor to 1.2 on the same Zen wire, and an equality check would * have let a Codex-emitted `web_search` body reach the * gateway and come back 400 for every request the moment 1.3 was selected. + * + * This list gates the two Zen destinations only. Zen serves nothing but the Contributor + * tiers there, so the id is a proxy for "this gateway"; the direct Meta host below serves + * a non-Contributor default and is gated by destination instead. */ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ "muse-spark-1.3-contributor", @@ -76,10 +80,21 @@ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ "muse-spark-1.2-contributor-free", ]); +/** + * Direct Meta Muse / Meta Model Responses. Its refusal is a gateway schema rule applied + * before inference, so it holds for every Muse model Meta serves — including the default + * `muse-spark-1.3`, which no Contributor-shaped list contains. Gating that host on model + * membership sent `search_content_types` through on every Codex `web_search` turn and 400ed + * the whole request. Same reading as the 64-char tool-name rewrite in + * `src/responses/muse-tool-name-alias.ts`, which is host-scoped and deliberately not + * model-gated for this reason. + */ +const MUSE_SPARK_STRICT_ANY_MODEL_DESTINATION = "https://api.meta.ai/v1/responses"; + const MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS = new Set([ "https://opencode.ai/zen/v1/responses", "https://opencode.ai/zen/go/v1/responses", - "https://api.meta.ai/v1/responses", + MUSE_SPARK_STRICT_ANY_MODEL_DESTINATION, ]); const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [ @@ -94,7 +109,8 @@ const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [ * malformed, credentialed, or parameterized destinations keep their original body * instead of assuming this gateway contract. Keep the rejected names together so a * newly identified field is a one-line compatibility update rather than another - * bespoke rewrite. + * bespoke rewrite. The destination is the whole predicate on direct Meta; the Zen + * wires additionally require a known Contributor id. */ export function stripMuseSparkUnsupportedWebSearchFields( body: unknown, @@ -102,8 +118,6 @@ export function stripMuseSparkUnsupportedWebSearchFields( responseUrl: string, ): unknown { if (!isPlainObject(body)) return body; - if (typeof modelId !== "string") return body; - if (!MUSE_SPARK_WEB_SEARCH_STRICT_MODELS.has(modelId.trim().toLowerCase())) return body; let destination: string; try { const url = new URL(responseUrl); @@ -113,6 +127,10 @@ export function stripMuseSparkUnsupportedWebSearchFields( return body; } if (!MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS.has(destination)) return body; + if ( + destination !== MUSE_SPARK_STRICT_ANY_MODEL_DESTINATION + && (typeof modelId !== "string" || !MUSE_SPARK_WEB_SEARCH_STRICT_MODELS.has(modelId.trim().toLowerCase())) + ) return body; const rewriteTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => { let changed = false; diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts index cb0c77f9df0..8a40d1f7374 100644 --- a/src/images/artifacts.ts +++ b/src/images/artifacts.ts @@ -14,6 +14,13 @@ const MAX_DECODED_BYTES_PER_RESPONSE = 100 * 1024 * 1024; export const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024; // 50 MiB /** Idle timeout for pinned HTTPS connect/headers/body when no AbortSignal is provided. */ export const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000; +/** + * Connect deadline for pinned HTTPS downloads: a TCP/TLS setup that never + * completes fails with connect_timeout after 10 s instead of holding the + * download until the 60 s first-byte timer fires. Callers can still override + * per call. + */ +export const DOWNLOAD_CONNECT_TIMEOUT_MS = 10_000; /** * Upper bound on the raw base64 string length before it is decoded. Base64 @@ -271,6 +278,7 @@ export function pinnedHttpsGet( options?: { maxBytes?: number; idleTimeoutMs?: number; + connectTimeoutMs?: number; rejectUnauthorized?: boolean; }, ): Promise { @@ -280,9 +288,11 @@ export function pinnedHttpsGet( } const maxBytes = options?.maxBytes ?? MAX_DOWNLOAD_BYTES; const idleTimeoutMs = options?.idleTimeoutMs ?? DOWNLOAD_IDLE_TIMEOUT_MS; + const connectTimeoutMs = options?.connectTimeoutMs ?? DOWNLOAD_CONNECT_TIMEOUT_MS; return pinnedHttpGet(url, pinned, signal, { maxBytes, idleTimeoutMs, + connectTimeoutMs, rejectUnauthorized: options?.rejectUnauthorized, context: "image download", }).then(response => { @@ -325,6 +335,10 @@ async function connectPublicHttps( // cap entirely instead of inheriting a default. Keep the 50 MiB ceiling when a // caller omits a limit, and honour an explicit tighter one. maxBytes: options.maxBytes ?? MAX_DOWNLOAD_BYTES, + // Bound the TCP/TLS setup phase on its own: without this, a peer that never + // completes the handshake holds the download until the 60 s first-byte timer + // fires. Covers image and video downloads (both go through here). + connectTimeoutMs: DOWNLOAD_CONNECT_TIMEOUT_MS, context: `${options.context} download`, })); return download(url, pinned, options.signal); diff --git a/src/oauth/kiro-credentials.ts b/src/oauth/kiro-credentials.ts index 3019262c7e0..9336c3dcaf7 100644 --- a/src/oauth/kiro-credentials.ts +++ b/src/oauth/kiro-credentials.ts @@ -179,6 +179,12 @@ export function resolveKiroCliNativeSessionEntries( * Windows: official MSI installs to `C:\Program Files\Kiro-Cli\kiro-cli.exe`, while some local * installs keep the binary next to `%LOCALAPPDATA%\Kiro-Cli\data.sqlite3`. * macOS/Linux: prefer PATH, then the usual user-local bin directories. + * + * The canonical `kiro-cli` name is exhausted everywhere first. Only then, and only on Windows, does + * the short `kiro.exe` name count, and only inside the two dedicated `Kiro-Cli` install folders + * already trusted for `kiro-cli.exe`, resolved from an absolute base. A short name is never looked + * up on PATH or in shared POSIX bin directories (`~/.local/bin`, `/usr/local/bin`, `/opt/homebrew/bin`): + * an unrelated `kiro` there, such as the Kiro IDE launcher, must not be run for credential commands. */ export function resolveKiroCliExecutable( inputs: KiroCliNativeInputs & { @@ -212,6 +218,8 @@ export function resolveKiroCliExecutable( : pathEntries.map(entry => posix.join(entry, "kiro-cli")); const installCandidates: string[] = []; + // Windows only: kiro.exe inside the dedicated Kiro-Cli folders, tried after every canonical name. + const shortInstallCandidates: string[] = []; if (inputs.platform === "win32") { const localBase = inputs.env.LOCALAPPDATA?.trim() || (inputs.env.USERPROFILE?.trim() ? win32.join(inputs.env.USERPROFILE.trim(), "AppData", "Local") : "") @@ -221,6 +229,11 @@ export function resolveKiroCliExecutable( win32.join(localBase, "Kiro-Cli", "kiro-cli.exe"), win32.join(programFiles, "Kiro-Cli", "kiro-cli.exe"), ); + // A relative or drive-relative base would make the short-name lookup depend on the process + // working directory or current drive, so only a fully qualified drive path qualifies. + for (const base of [localBase, programFiles]) { + if (/^[A-Za-z]:[\\/]/.test(base)) shortInstallCandidates.push(win32.join(base, "Kiro-Cli", "kiro.exe")); + } } else if (inputs.platform === "darwin") { installCandidates.push( posix.join(inputs.home, ".local", "bin", "kiro-cli"), @@ -234,7 +247,7 @@ export function resolveKiroCliExecutable( ); } - for (const candidate of [...pathCandidates, ...installCandidates]) { + for (const candidate of [...pathCandidates, ...installCandidates, ...shortInstallCandidates]) { if (exists(candidate) && isFile(candidate)) return candidate; } return inputs.platform === "win32" ? "kiro-cli.exe" : "kiro-cli"; diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index a121bf103e1..d96f6605043 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -824,15 +824,30 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ // glm-5.3 carry live end-to-end evidence there (custom tools, reasoning replay, streaming, // multi-turn continuation). // - // That is deliberately NOT expressed as a modelWireDefaults pin. Pinning would move every - // existing Codex user of those models onto a different upstream with no config change, and - // one delta is unresolved: preserveReasoningContentModels below is read by the CHAT adapter, - // while the Responses serializer reads preserveResponsesReasoningContent, which this entry - // does not set. On the Responses wire those models would replay with blanked reasoning - // content -- less state than they carry today. Z.AI and DeepSeek set both flags together for - // exactly this reason. Until that flag is justified against this gateway, Responses stays a - // documented per-model modelAdapters opt-in; - // tests/providers/alibaba-token-plan-responses-optin.test.ts holds both halves. + // That evidence is now expressed as a modelWireDefaults pin scoped to Responses inbound + // only: Codex clients ride the native wire with zero translation hops, while chat and + // anthropic inbound keep the provider-wide chat wire and its measured prefix-cache + // behavior. The pin was held back until the one open delta was closed with its own live + // evidence: the Responses serializer replays reasoning content through the separate + // preserveResponsesReasoningContent flag, which the Chat-side preserveReasoningContentModels + // list does not cover. Measured 260922 on this gateway (#5188): a two-turn replay that + // round-trips a reasoning item WITH its plaintext content array is accepted (HTTP 200) and + // the model continues from it, so the flag is set beside the pins — the same pairing Z.AI + // and DeepSeek use. qwen3.7-plus is the one pinned model in thinkingBudgetModels, and its + // full low/medium/high/xhigh/max effort ladder is accepted as reasoning.effort strings on + // this wire (measured same day), so the Responses path does not need the numeric + // thinking_budget translation the Chat wire applies. The rest of the family stays a + // documented per-model modelAdapters opt-in; modelAdapters always wins over the pin in + // both directions. + // tests/providers/alibaba-token-plan-responses-optin.test.ts holds the opt-in half and the + // flag guard; tests/providers/alibaba-token-plan-wire-defaults.test.ts holds the pins. + // The intl sibling stays unpinned until the same four-axis verification runs against its + // gateway (its /responses route is registered, #5097). + modelWireDefaults: { + "qwen3.8-flash": { wire: "openai-responses", inbound: ["responses"] }, + "qwen3.7-plus": { wire: "openai-responses", inbound: ["responses"] }, + "glm-5.3": { wire: "openai-responses", inbound: ["responses"] }, + }, note: "Token Plan Personal Edition · China (Beijing)", modelInputModalities: ALIBABA_TOKEN_PLAN_INPUT_MODALITIES, modelContextWindows: ALIBABA_TOKEN_PLAN_CONTEXT_WINDOWS, @@ -862,6 +877,10 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ directReasoningEffortModels: QWEN38_FAMILY, thinkingBudgetModels: ALIBABA_TOKEN_PLAN_QWEN_MODELS.filter(id => !QWEN38_FAMILY.includes(id)), preserveReasoningContentModels: ALIBABA_TOKEN_PLAN_PRESERVE_REASONING, + // Responses replay uses this provider-level flag, not the Chat-path model list above; + // measured live on this gateway (see the pin comment). The model list still covers a + // caller who opts back into Chat. + preserveResponsesReasoningContent: true, noVisionModels: ALIBABA_TOKEN_PLAN_NO_VISION, // The gateway accepts prompt_cache_key on every Token Plan chat model (probed 260902). promptCacheKey: true, diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 5fd24793dc1..fc07c05dd5f 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -159,8 +159,8 @@ Token estimation includes retained external root blobs, including checkpoint-car Missing or invalid UTF-8 blobs are skipped with bounded provider diagnostics; estimating does not alter blob-retention metrics. Root-echo eligibility is `cursorNeedsExternalToolContinuation`, which includes native -`composer-2.5`, not only external wire models, so the restoration reaches every replay that carries -an invocation line. Coverage lives in +`composer-2.5` and `composer-2.5-fast`, not only external wire models, so the restoration reaches +every replay that carries an invocation line. Coverage lives in `tests/providers/cursor/cursor-tool-result-invocation.test.ts`. ## Cursor executable tool schema ownership diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index ff76fc1892f..b87b0274abd 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -8,6 +8,18 @@ is scoped to canonical ChatGPT Responses forwarding; other source-area behavior The shared hosted-tool policy has no Codex Spark-specific branch. Kiro continues to use its provider capabilities below; see [Responses compatibility](../transports/responses.md#responses-httpsse). +## Kiro CLI executable resolution + +Forced and add-account login spawn the local CLI, so `resolveKiroCliExecutable` in +`src/oauth/kiro-credentials.ts` decides which file runs with credential-flow arguments. The +canonical `kiro-cli` name is tried on `PATH` and then in the platform install locations. Only +after every canonical candidate misses, and only on Windows, does the short `kiro.exe` name count, +and only inside the two dedicated `Kiro-Cli` folders (`%LOCALAPPDATA%` and `Program Files`) when +their base is a fully qualified drive path. A short name is never resolved from `PATH` or from the +shared POSIX bin directories (`~/.local/bin`, `/usr/local/bin`, `/opt/homebrew/bin`), where an +unrelated `kiro` such as the Kiro IDE launcher can live. Coverage: +`tests/providers/kiro/kiro-windows-cli-executable-path.test.ts`. + ## Kiro client parallel-tool hint Kiro's wire remains serialized even when an OpenAI Responses client sends diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 80e73d3c96a..e1620291c11 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -38,7 +38,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Claude Messages | `src/server/claude-messages.ts` | Routed translation, a native Anthropic passthrough branch, and `count_tokens`. | | Chat Completions inbound | `src/server/chat-completions.ts`, `src/server/chat-native.ts`, `src/chat/`, `src/adapters/openai-chat.ts` | Inbound translation onto the same routing pipeline. The content mapper preserves image URLs and supported detail, including screenshot-bearing tool results; target adapters own image placement on their wire. Image-free tool results stay strings. The native handler owns pin/cap normalization; both adapter builders share explicit gateway-object and tool-bearing effort-omission policy, while the native builder preserves unknown or undeclared raw behavior and removes effort for explicit empty declarations or no-reasoning models. On the response side, the upstream `service_tier` echo relays on every delivery shape (`src/chat/outbound.ts` projections, `src/server/chat-native-sse.ts` chunks); an upstream without the field gets no injected key. | | Hosted search relay | `src/server/search.ts` | Verbatim ChatGPT relay, or an explicitly configured web-search sidecar backend when no forward provider exists; distinct from the web-search sidecar loop below. | -| Image/video generation loop | `src/images/loop.ts`, `src/images/plan.ts`, `src/images/fulfill.ts`, `src/images/xai-client.ts`, `src/images/xai-video-client.ts`, `src/images/artifacts.ts` | A provider-returned image URL is downloaded into a local artifact once, then served locally; warnings stay URL-free because provider CDN URLs may embed credentials. | +| Image/video generation loop | `src/images/loop.ts`, `src/images/plan.ts`, `src/images/fulfill.ts`, `src/images/xai-client.ts`, `src/images/xai-video-client.ts`, `src/images/artifacts.ts` | A provider-returned image URL is downloaded into a local artifact once, then served locally; warnings stay URL-free because provider CDN URLs may embed credentials. Artifact downloads go through the pinned-IP transport with a 10 s connect deadline (`DOWNLOAD_CONNECT_TIMEOUT_MS`) that bounds TCP/TLS setup on its own, in addition to the 60 s idle timer, and `pinnedHttpsGet` accepts a per-call `connectTimeoutMs`. | | GitHub Copilot | `src/providers/xai-transport.ts` (`resolveProviderTransport`), `src/providers/github-copilot-transport.ts` | `resolveProviderTransport` selects the Copilot transport when the routed provider name is `github-copilot`; the Copilot module then resolves its headers and base URL, and the registry seeds the provider row and model fallback. | | API-key pools | `src/providers/api-key-selection.ts`, `src/providers/key-failover.ts` | A configured `apiKeyPoolStrategy` plus a cooling committed key rotates before the first send (`selectProactiveApiKeyTransport`); a 429 still rotates after the send and records a cooldown. `provider.apiKey` keeps mirroring the active entry so routing stays single-key. The pick is inert without a strategy or while the committed key is healthy. | | OAuth account failover | `src/oauth/generic-account-failover.ts`, `src/oauth/anthropic-routing.ts` | Reactive pre-output 429 recovery is presence-driven with 2+ eligible accounts. Pool and `oauthAccountFailover` flags govern proactive routing, not the reactive retry: a disabled Anthropic pool recovers through quota ordering rather than its dormant strategy, a per-provider `enabled` beats the global default in either direction, and a non-positive fill-first threshold disables proactive usage-based rotation. | diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 2b7db0e29a4..9e647c27ab8 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -15,6 +15,16 @@ different custom destination does not inherit its upstream assumptions. Object-f also narrow the decision by inbound protocol and authentication mode; an auth-scoped default must not leak from a subscription transport into an API-key or forwarded-credential route. +Alibaba Token Plan (Beijing) keeps `openai-chat` provider-wide but defaults `qwen3.8-flash`, +`qwen3.7-plus` and `glm-5.3` to `openai-responses` for Responses inbound only; Chat and Anthropic +inbound stay on Chat and its measured prefix-cache behavior. The entry sets +`preserveResponsesReasoningContent` beside the pins, because the Responses serializer reads that +flag rather than the Chat-side `preserveReasoningContentModels` list, and this gateway accepted +replayed plaintext reasoning content live. `qwen3.7-plus` sends effort as a `reasoning.effort` +string on this wire instead of the numeric `thinking_budget` the Chat wire applies. The intl sibling +stays unpinned. `tests/providers/alibaba-token-plan-wire-defaults.test.ts` covers the pins and the +replay flag. + xAI keeps `openai-chat` as its provider-wide compatibility wire, but Grok 4.5/4.6/4.7 subscription Responses requests default to native `openai-responses`. Existing namespace, hosted-search and reasoning-replay normalization remains in force. The reserved `xai` OAuth transport is name-pinned @@ -167,9 +177,14 @@ OpenCode Go's exact `union-alpha` model id is hard-pinned to the Anthropic wire surface; sibling models retain their existing Chat or Responses selection. This wire choice and the session namespace do not assert upstream availability after the Messages endpoint accepts the session header. -Muse Spark's Responses sanitizer also drops the provider-rejected `search_content_types` and -`indexed_web_access` fields from plain `web_search` tools while preserving preview tools and -unrelated models. +`src/adapters/openai-responses/web-search.ts` also drops the provider-rejected +`search_content_types` and `indexed_web_access` fields from plain `web_search` tools while +preserving preview tools. The two OpenCode Zen destinations gate that on a Contributor Muse id +because they serve nothing else; on the direct Meta destination (`https://api.meta.ai/v1/responses`) +the destination is the whole predicate, because Meta's refusal is a gateway schema rule for every +Muse model it serves, its default `muse-spark-1.3` is not a Contributor id, and a missing model id +still strips. Because the predicate is the host, a custom provider pointed at that exact URL gets the +same strip. Direct Meta Muse / Meta Model Responses (`https://api.meta.ai/v1`) also rejects function tool names longer than 64 characters or containing characters outside `[a-zA-Z0-9_-]`. After namespace diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d855da0f260..4b88544f944 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -27,6 +27,7 @@ "alibaba-region-migration.test.ts": "providers", "alibaba-region-startup.test.ts": "providers", "alibaba-token-plan-responses-optin.test.ts": "providers", + "alibaba-token-plan-wire-defaults.test.ts": "providers", "always-on-429-failover.test.ts": "routing", "ambiguous-resend-composition.test.ts": "lib", "ambiguous-resend-gate.test.ts": "lib", @@ -624,6 +625,7 @@ "doctor-service-memory-contract.test.ts": "service", "doctor.test.ts": "codex-integration", "download-cap-default.test.ts": "images", + "download-connect-deadline-default.test.ts": "images", "dsh-path-contract.test.ts": "ci-workflows", "dsh-rc6-compat-script.test.ts": "ci-workflows", "dsh-writer-lock.test.ts": "ci-workflows", diff --git a/tests/images/download-connect-deadline-default.test.ts b/tests/images/download-connect-deadline-default.test.ts new file mode 100644 index 00000000000..959e787d3e3 --- /dev/null +++ b/tests/images/download-connect-deadline-default.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, mock, test } from "bun:test"; + +// The default downloader inside connectPublicHttps is the production path for +// provider-returned image/video URLs (downloadImageToArtifact and +// downloadVideoToArtifact both go through it), while pinnedHttpsGet has no +// production callers. A connect deadline wired only into pinnedHttpsGet would +// therefore never arm in production — this suite pins the default path. + +const lookupMock = mock(async (): Promise<{ address: string; family: number }[]> => [ + { address: "93.184.216.34", family: 4 }, +]); +mock.module("node:dns/promises", () => ({ lookup: lookupMock })); + +const MIN_PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const seenConnectTimeoutMs: Array = []; + +mock.module("../../src/lib/pinned-http", () => ({ + pinnedHttpGet: async ( + _url: string, + _pinned: unknown, + _signal?: AbortSignal, + options?: { connectTimeoutMs?: number }, + ) => { + seenConnectTimeoutMs.push(options?.connectTimeoutMs); + return new Response(MIN_PNG, { status: 200 }); + }, + PinnedHttpError: class extends Error {}, +})); + +const { fetchPublicHttpsImage, DOWNLOAD_CONNECT_TIMEOUT_MS } = await import( + `../../src/images/artifacts?connect-deadline=${Date.now()}` +); + +describe("default image downloader connect deadline", () => { + test("the production path schedules the 10s connect deadline", async () => { + expect(DOWNLOAD_CONNECT_TIMEOUT_MS).toBe(10_000); + seenConnectTimeoutMs.length = 0; + const resp = await fetchPublicHttpsImage("https://public-host/image.png"); + expect(resp.status).toBe(200); + expect(seenConnectTimeoutMs).toEqual([DOWNLOAD_CONNECT_TIMEOUT_MS]); + }); +}); diff --git a/tests/images/pinned-https-get.test.ts b/tests/images/pinned-https-get.test.ts index e3d1f8017f8..ea356aed1cf 100644 --- a/tests/images/pinned-https-get.test.ts +++ b/tests/images/pinned-https-get.test.ts @@ -318,4 +318,89 @@ describe("pinnedHttpsGet transport", () => { expect(resIdleMs).toBe(12_345); await resp.arrayBuffer(); }); + + test("schedules the 10s connect deadline by default and rejects a stalled connect", async () => { + // A socket stuck in `connecting`: TCP/TLS setup never completes, so the + // idle timers (which only start once the connection exists) never arm. + const requestMock = mock((_options: unknown, _onResponse?: Function) => { + const req = new EventEmitter() as EventEmitter & { setTimeout: Function; end: Function; destroy: Function }; + req.setTimeout = mock(() => {}); + req.destroy = mock(() => {}); + req.end = mock(() => { + const socket = new EventEmitter() as EventEmitter & { connecting: boolean }; + socket.connecting = true; // never emits secureConnect + req.emit("socket", socket); + }); + return req; + }); + mock.module("node:https", () => ({ default: { request: requestMock }, request: requestMock })); + + // Observe scheduled deadlines without waiting for them: record the delay, + // arm nothing, and fire the connect deadline manually. + const realSetTimeout = globalThis.setTimeout; + const scheduled: { delay: number; fire: (...args: unknown[]) => void }[] = []; + globalThis.setTimeout = (( + cb: (...args: unknown[]) => void, + delay?: number, + ) => { + scheduled.push({ delay: delay ?? 0, fire: cb }); + return 0 as unknown as ReturnType; + }) as unknown as typeof setTimeout; + try { + const { pinnedHttpsGet, DOWNLOAD_CONNECT_TIMEOUT_MS } = await import("../../src/images/artifacts"); + expect(DOWNLOAD_CONNECT_TIMEOUT_MS).toBe(10_000); + const pending = pinnedHttpsGet( + "https://cdn.example/stalled.png", + { address: "93.184.216.34", family: 4 }, + ); + await new Promise(resolve => realSetTimeout(resolve, 0)); + const connectDeadline = scheduled.find(t => t.delay === DOWNLOAD_CONNECT_TIMEOUT_MS); + expect(connectDeadline).toBeDefined(); + connectDeadline!.fire(); + await expect(pending).rejects.toThrow(/connect timed out/); + } finally { + globalThis.setTimeout = realSetTimeout; + } + }); + + test("forwards an explicit connectTimeoutMs override", async () => { + const requestMock = mock((_options: unknown, _onResponse?: Function) => { + const req = new EventEmitter() as EventEmitter & { setTimeout: Function; end: Function; destroy: Function }; + req.setTimeout = mock(() => {}); + req.destroy = mock(() => {}); + req.end = mock(() => { + const socket = new EventEmitter() as EventEmitter & { connecting: boolean }; + socket.connecting = true; // never emits secureConnect + req.emit("socket", socket); + }); + return req; + }); + mock.module("node:https", () => ({ default: { request: requestMock }, request: requestMock })); + + const realSetTimeout = globalThis.setTimeout; + const scheduled: { delay: number; fire: (...args: unknown[]) => void }[] = []; + globalThis.setTimeout = (( + cb: (...args: unknown[]) => void, + delay?: number, + ) => { + scheduled.push({ delay: delay ?? 0, fire: cb }); + return 0 as unknown as ReturnType; + }) as unknown as typeof setTimeout; + try { + const { pinnedHttpsGet } = await import("../../src/images/artifacts"); + const pending = pinnedHttpsGet( + "https://cdn.example/stalled.png", + { address: "93.184.216.34", family: 4 }, + undefined, + { connectTimeoutMs: 250 }, + ); + await new Promise(resolve => realSetTimeout(resolve, 0)); + const override = scheduled.find(t => t.delay === 250); + expect(override).toBeDefined(); + override!.fire(); + await expect(pending).rejects.toThrow(/connect timed out/); + } finally { + globalThis.setTimeout = realSetTimeout; + } + }); }); diff --git a/tests/providers/alibaba-token-plan-responses-optin.test.ts b/tests/providers/alibaba-token-plan-responses-optin.test.ts index 67daf0c7df4..4c7c936f59c 100644 --- a/tests/providers/alibaba-token-plan-responses-optin.test.ts +++ b/tests/providers/alibaba-token-plan-responses-optin.test.ts @@ -4,17 +4,17 @@ * official Codex integration guide on wire_api = "responses" (#5097). Three models carry live * end-to-end evidence on that gateway: qwen3.8-flash, qwen3.7-plus and glm-5.3. * - * That evidence buys a documented OPT-IN, not a default. The registry deliberately declares no - * modelWireDefaults for this entry, because flipping the wire would change the upstream for - * every existing Codex user of those models with no config change, and one delta is still - * unresolved: the entry preserves plaintext reasoning content on the Chat wire through - * preserveReasoningContentModels, but the Responses serializer reads the separate - * preserveResponsesReasoningContent flag, which this entry does not set. On the Responses wire - * those models would therefore replay with blanked reasoning content, which is strictly less - * state than they carry today. Z.AI and DeepSeek set both flags for exactly this reason. + * That evidence now backs the registry modelWireDefaults pin in + * alibaba-token-plan-wire-defaults.test.ts together with the entry-level + * preserveResponsesReasoningContent flag: the flag that was the open delta when the opt-in + * landed (#5198) is measured live on this gateway (#5188), because the entry preserves + * plaintext reasoning content on the Chat wire through preserveReasoningContentModels, and + * the Responses serializer reads the separate flag. Z.AI and DeepSeek set both flags for + * exactly this reason. * - * These cases lock the opt-in so it cannot silently regress, and lock the precondition so a - * later default flip cannot land without the Responses-side preservation beside it. + * These cases lock the documented per-model modelAdapters opt-in for the REST of the family + * (and the opt-out against the pins) so neither can silently regress, and keep the guard that + * a Responses wire default must carry the Responses-side preservation beside it. */ import { afterEach, describe, expect, test } from "bun:test"; import { providerConfigSeed } from "../../src/providers/derive"; @@ -32,18 +32,22 @@ function tokenPlanProvider(): OcxProviderConfig { return { ...providerConfigSeed(getProviderRegistryEntry("alibaba-token-plan")!), apiKey: "sk-test" }; } -describe("the Token Plan wire default is unchanged", () => { +describe("chat and anthropic inbound keep the provider chat wire", () => { for (const model of LIVE_VERIFIED) { - test(`${model} stays on the provider chat wire for every inbound`, () => { - for (const inbound of INBOUNDS) { + for (const inbound of ["chat", "anthropic"] as const) { + test(`${model} stays on the provider chat wire for ${inbound} inbound`, () => { expect(resolveWireProtocolOverride("alibaba-token-plan", model, tokenPlanProvider(), inbound).adapter) .toBe("openai-chat"); - } - }); + }); + } } - test("the entry declares no model wire defaults", () => { - expect(getProviderRegistryEntry("alibaba-token-plan")!.modelWireDefaults).toBeUndefined(); + test("the pins are exactly the live-verified models, scoped to responses inbound", () => { + const entry = getProviderRegistryEntry("alibaba-token-plan")!; + expect(Object.keys(entry.modelWireDefaults ?? {}).sort()).toEqual([...LIVE_VERIFIED].sort()); + for (const declared of Object.values(entry.modelWireDefaults ?? {})) { + expect(typeof declared === "string" ? undefined : declared.inbound).toEqual(["responses"]); + } }); }); @@ -111,11 +115,18 @@ describe("the opt-in survives the handleResponses replay", () => { expect(url).not.toContain("chat/completions"); }); - test("qwen3.8-flash reaches /chat/completions without the opt-in", async () => { - const url = await drive("qwen3.8-flash"); + test("an unpinned family member reaches /chat/completions without the opt-in", async () => { + const url = await drive("qwen3.8-max"); expect(url).toContain("token-plan.cn-beijing.maas.aliyuncs.com"); expect(url).toContain("chat/completions"); }); + + test("an unpinned family member reaches /responses once opted in", async () => { + const url = await drive("qwen3.8-max", { "qwen3.8-max": "openai-responses" }); + expect(url).toContain("token-plan.cn-beijing.maas.aliyuncs.com"); + expect(url).toContain("/responses"); + expect(url).not.toContain("chat/completions"); + }); }); describe("a Responses default flip must carry Responses-side reasoning preservation", () => { diff --git a/tests/providers/alibaba-token-plan-wire-defaults.test.ts b/tests/providers/alibaba-token-plan-wire-defaults.test.ts new file mode 100644 index 00000000000..90a746937dc --- /dev/null +++ b/tests/providers/alibaba-token-plan-wire-defaults.test.ts @@ -0,0 +1,252 @@ +/** + * Alibaba Token Plan (Beijing) serves the same models over both the OpenAI Responses + * wire and Chat Completions, and Alibaba documents an official Responses API plus a + * Codex integration guide on the same base (#5097). The registry pins the + * live-verified models to native Responses for Responses inbound only; chat and + * anthropic inbound keep the provider-wide chat wire, mirroring the DeepSeek + * deepseek-v4-flash precedent. The end-to-end cases assert the captured upstream URL + * because a resolver-only test would pass even if the handleResponses replay flipped + * the wire back. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; +import { handleResponses } from "../../src/server/responses/core"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +const RESPONSES_INBOUND_DEFAULT = ["qwen3.8-flash", "qwen3.7-plus", "glm-5.3"] as const; +const CHAT_SERVED = ["qwen3.8-max", "qwen3.7-max", "qwen3.6-flash", "deepseek-v4-pro", "glm-5.2"] as const; +const INBOUNDS = ["responses", "chat", "anthropic"] as const; + +function tokenPlanProvider(): OcxProviderConfig { + return { ...providerConfigSeed(getProviderRegistryEntry("alibaba-token-plan")!), apiKey: "sk-test" }; +} + +describe("pinned Token Plan models ride Responses only on Responses inbound", () => { + for (const model of RESPONSES_INBOUND_DEFAULT) { + test(`${model} resolves to openai-responses for responses inbound`, () => { + expect(resolveWireProtocolOverride("alibaba-token-plan", model, tokenPlanProvider(), "responses").adapter) + .toBe("openai-responses"); + }); + + for (const inbound of ["chat", "anthropic"] as const) { + test(`${model} stays on the provider chat wire for ${inbound} inbound`, () => { + expect(resolveWireProtocolOverride("alibaba-token-plan", model, tokenPlanProvider(), inbound).adapter) + .toBe("openai-chat"); + }); + } + } +}); + +describe("unpinned Token Plan models keep the provider chat wire", () => { + for (const model of CHAT_SERVED) { + test(`${model} stays on openai-chat for every inbound`, () => { + for (const inbound of INBOUNDS) { + expect(resolveWireProtocolOverride("alibaba-token-plan", model, tokenPlanProvider(), inbound).adapter) + .toBe("openai-chat"); + } + }); + } +}); + +describe("explicit modelAdapters beat the Token Plan defaults in both directions", () => { + test("opt-out: qwen3.8-flash pinned back to chat for responses inbound", () => { + const provider = { ...tokenPlanProvider(), modelAdapters: { "qwen3.8-flash": "openai-chat" } }; + expect(resolveWireProtocolOverride("alibaba-token-plan", "qwen3.8-flash", provider, "responses").adapter) + .toBe("openai-chat"); + }); + + test("opt-in: an unpinned model mapped to Responses", () => { + const provider = { ...tokenPlanProvider(), modelAdapters: { "deepseek-v4.1-flash": "openai-responses" } }; + expect(resolveWireProtocolOverride("alibaba-token-plan", "deepseek-v4.1-flash", provider, "responses").adapter) + .toBe("openai-responses"); + }); +}); + +describe("the Token Plan default is isolated to the registry provider", () => { + test("qwen3.8-flash on a custom provider is untouched", () => { + const other: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://example.com/v1", apiKey: "sk-test" }; + for (const inbound of INBOUNDS) { + expect(resolveWireProtocolOverride("some-custom", "qwen3.8-flash", other, inbound).adapter) + .toBe("openai-chat"); + } + }); + + test("resolution preserves credentials and the base URL through the copy", () => { + const resolved = resolveWireProtocolOverride("alibaba-token-plan", "qwen3.8-flash", tokenPlanProvider(), "responses"); + expect(resolved.adapter).toBe("openai-responses"); + expect(resolved.apiKey).toBe("sk-test"); + expect(resolved.baseUrl).toBe("https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"); + }); +}); + +describe("the Token Plan wire default survives the handleResponses replay", () => { + const originalFetch = globalThis.fetch; + let releaseSpendHome: (() => void) | undefined; + + afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + }); + + type Captured = { url: string; body: Record }; + + function captureUpstream(): Captured[] { + const seen: Captured[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + seen.push({ url: String(input), body: JSON.parse(String(init?.body ?? "{}")) as Record }); + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + return seen; + } + + async function driveCapture( + model: string, + inboundWire: "responses" | "chat" | "anthropic", + extra: Record = {}, + ): Promise { + const seen = captureUpstream(); + const config = { providers: { "alibaba-token-plan": tokenPlanProvider() } } as unknown as OcxConfig; + releaseSpendHome ??= acquireOwnedSpendHome(); + await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `alibaba-token-plan/${model}`, input: "ping", stream: true, ...extra }), + }), + config, + { model: "", provider: "" }, + { inboundWire }, + ); + return seen[0]; + } + + async function drive(model: string, inboundWire: "responses" | "chat" | "anthropic"): Promise { + return (await driveCapture(model, inboundWire))?.url ?? ""; + } + + test("qwen3.8-flash reaches the Responses upstream, never /chat/completions", async () => { + const url = await drive("qwen3.8-flash", "responses"); + expect(url).toContain("token-plan.cn-beijing.maas.aliyuncs.com"); + expect(url).toContain("/responses"); + expect(url).not.toContain("chat/completions"); + }); + + // The review on #5188 asked for this model by name: it is the only pinned model that + // loses thinkingBudgetModels' numeric thinking_budget translation when it moves to the + // Responses wire, so its upstream URL has to be pinned by evidence, not by symmetry + // with the other two. + test("qwen3.7-plus reaches the Responses upstream, never /chat/completions", async () => { + const url = await drive("qwen3.7-plus", "responses"); + expect(url).toContain("token-plan.cn-beijing.maas.aliyuncs.com"); + expect(url).toContain("/responses"); + expect(url).not.toContain("chat/completions"); + }); + + // Moving qwen3.7-plus off Chat drops the numeric thinking_budget translation. The live + // evidence on #5188 is that this gateway accepts the whole effort ladder as + // reasoning.effort strings on the Responses wire, so the outgoing body must carry the + // caller's effort as that string and no Chat-side budget field. + test("qwen3.7-plus sends the caller's effort as reasoning.effort on the Responses wire", async () => { + const captured = await driveCapture("qwen3.7-plus", "responses", { reasoning: { effort: "high" } }); + expect(captured?.url).toContain("/responses"); + const reasoning = captured?.body.reasoning as Record | undefined; + expect(reasoning?.effort).toBe("high"); + expect(Object.hasOwn(captured?.body ?? {}, "thinking_budget")).toBe(false); + }); + + test("glm-5.3 reaches the Responses upstream on a responses inbound", async () => { + const url = await drive("glm-5.3", "responses"); + expect(url).toContain("token-plan.cn-beijing.maas.aliyuncs.com"); + expect(url).toContain("/responses"); + expect(url).not.toContain("chat/completions"); + }); + + test("qwen3.8-flash keeps the chat upstream on a chat inbound replay", async () => { + const url = await drive("qwen3.8-flash", "chat"); + expect(url).toContain("token-plan.cn-beijing.maas.aliyuncs.com"); + expect(url).toContain("chat/completions"); + }); + + test("glm-5.3 keeps the chat upstream on an anthropic inbound replay", async () => { + const url = await drive("glm-5.3", "anthropic"); + expect(url).toContain("chat/completions"); + }); + + test("qwen3.8-max (unpinned) keeps the chat upstream on a responses inbound", async () => { + const url = await drive("qwen3.8-max", "responses"); + expect(url).toContain("chat/completions"); + }); +}); + +describe("pinned replay keeps plaintext reasoning content on the Responses wire", () => { + const originalFetch = globalThis.fetch; + let releaseSpendHome: (() => void) | undefined; + + afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + }); + + // The pin moved these models onto the Responses serializer, whose + // sanitizeReasoningInputContent blanks replayed reasoning content unless + // preserveResponsesReasoningContent is set — the flag the Chat-side + // preserveReasoningContentModels list does not cover. DeepSeek and Z.AI set the flag beside + // their pins; this entry keeps the pairing, or the pinned models would replay with strictly + // less state than they carry on Chat (the #5097 opt-in record never exercised plaintext replay). + async function driveReplay(provider: OcxProviderConfig): Promise<{ url: string; reasoningContent: unknown[] | undefined }> { + const seen: { url: string; body: Record }[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + seen.push({ url: String(input), body: JSON.parse(String(init?.body ?? "{}")) as Record }); + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + releaseSpendHome ??= acquireOwnedSpendHome(); + await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "alibaba-token-plan/qwen3.8-flash", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "ping" }] }, + { type: "reasoning", id: "rs_1", summary: [], content: [{ type: "reasoning_text", text: "keep me" }] }, + ], + stream: true, + }), + }), + { providers: { "alibaba-token-plan": provider } } as unknown as OcxConfig, + { model: "", provider: "" }, + { inboundWire: "responses" }, + ); + const first = seen[0]; + if (!first) return { url: "", reasoningContent: undefined }; + const reasoning = (first.body.input as unknown[] | undefined)?.find( + (item): item is Record => + typeof item === "object" && item !== null && (item as Record).type === "reasoning", + ); + return { url: first.url, reasoningContent: reasoning?.content as unknown[] | undefined }; + } + + test("the registry flag preserves replayed reasoning content on the pinned wire", async () => { + const { url, reasoningContent } = await driveReplay(tokenPlanProvider()); + expect(url).toContain("/responses"); + expect(reasoningContent).toEqual([{ type: "reasoning_text", text: "keep me" }]); + }); + + test("without the flag the same replay is blanked — the flag is what preserves it", async () => { + const provider = { ...tokenPlanProvider(), preserveResponsesReasoningContent: false }; + const { url, reasoningContent } = await driveReplay(provider); + expect(url).toContain("/responses"); + expect(reasoningContent).toEqual([]); + }); +}); diff --git a/tests/providers/cursor/cursor-blob.test.ts b/tests/providers/cursor/cursor-blob.test.ts index 53051d37330..1f3c57ed479 100644 --- a/tests/providers/cursor/cursor-blob.test.ts +++ b/tests/providers/cursor/cursor-blob.test.ts @@ -941,7 +941,7 @@ describe("Cursor blob handshake", () => { test("keeps ResumeAction for native-model tool-result continuations", () => { const bytes = encodeCursorRunRequest({ - modelId: "composer-2.5-fast", + modelId: "composer-1", conversationId: "c1", system: ["You are helpful."], messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: read_file\nis_error: false\noutput:\ncontents" }], @@ -949,7 +949,7 @@ describe("Cursor blob handshake", () => { { role: "user", content: "read a file", timestamp: 1 }, { role: "assistant", - model: "cursor/composer-2.5-fast", + model: "cursor/composer-1", timestamp: 2, content: [{ type: "toolCall", id: "call_1", name: "read_file", arguments: { path: "a.txt" } }], }, diff --git a/tests/providers/cursor/cursor-discovery.test.ts b/tests/providers/cursor/cursor-discovery.test.ts index 9a1a46bb08f..620facd4b07 100644 --- a/tests/providers/cursor/cursor-discovery.test.ts +++ b/tests/providers/cursor/cursor-discovery.test.ts @@ -274,8 +274,8 @@ describe("Cursor discovery metadata", () => { test("routes composer-2.5 tool continuations through the external userMessageAction path", () => { expect(cursorNeedsExternalToolContinuation("composer-2.5")).toBe(true); expect(cursorNeedsExternalToolContinuation("cursor/composer-2.5")).toBe(true); - expect(cursorNeedsExternalToolContinuation("composer-2.5-fast")).toBe(false); - expect(cursorNeedsExternalToolContinuation("cursor/composer-2.5-fast")).toBe(false); + expect(cursorNeedsExternalToolContinuation("composer-2.5-fast")).toBe(true); + expect(cursorNeedsExternalToolContinuation("cursor/composer-2.5-fast")).toBe(true); expect(cursorNeedsExternalToolContinuation("auto")).toBe(false); expect(cursorNeedsExternalToolContinuation("gpt-5.6-sol")).toBe(true); }); diff --git a/tests/providers/cursor/cursor-live-transport.test.ts b/tests/providers/cursor/cursor-live-transport.test.ts index f823440378e..5c290e0275b 100644 --- a/tests/providers/cursor/cursor-live-transport.test.ts +++ b/tests/providers/cursor/cursor-live-transport.test.ts @@ -610,7 +610,7 @@ describe("Cursor live transport context estimate wiring (#373)", () => { const capture = await captureOpen({ ...request, modelId }); expect(capture.encoded).toBeInstanceOf(Uint8Array); const action = capture.run?.action?.action; - if (modelId === "composer-2.5") { + if (modelId !== "auto") { if (action?.case !== "userMessageAction") throw new Error("expected Composer continuation"); expect(action.value.userMessage?.text).toBe(CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT); expect(action.value.userMessage?.selectedContext?.selectedImages).toEqual([]); diff --git a/tests/providers/cursor/cursor-tool-continuation.test.ts b/tests/providers/cursor/cursor-tool-continuation.test.ts index f5f4a86b76e..296b0b3157a 100644 --- a/tests/providers/cursor/cursor-tool-continuation.test.ts +++ b/tests/providers/cursor/cursor-tool-continuation.test.ts @@ -104,7 +104,7 @@ describe("363-B: tool result reaches the model via rootPromptMessagesJson", () = test("native resume models do not few-shot [Tool Result] as assistant chat", () => { const bytes = encodeCursorRunRequest({ - modelId: "composer-2.5-fast", + modelId: "composer-1", conversationId: "c1", system: ["You are helpful."], messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: mcp__fs__read_file\nis_error: false\noutput:\nFILE CONTENTS HERE" }], diff --git a/tests/providers/cursor/cursor-tool-result-invocation.test.ts b/tests/providers/cursor/cursor-tool-result-invocation.test.ts index ba73cf69891..3f5f76f27fa 100644 --- a/tests/providers/cursor/cursor-tool-result-invocation.test.ts +++ b/tests/providers/cursor/cursor-tool-result-invocation.test.ts @@ -154,12 +154,13 @@ describe("cursor replayed tool results name their invocation", () => { expect(step).toContain("invoked: exec_command with"); }); - // composer-2.5 (non-fast) is a NATIVE wire model that still routes through the external - // tool-continuation path (discovery.ts cursorNeedsExternalToolContinuation), so it echoes results - // into root as text and needs the invocation named too. Gating on `externalModel` would have - // skipped exactly this model (audit 001 F2). - test("composer-2.5 root replay names the invocation too", () => { - const root = resultRoot(encode(history(), "composer-2.5")); + // composer-2.5 and composer-2.5-fast are NATIVE wire models that still route through the + // external tool-continuation path (discovery.ts cursorNeedsExternalToolContinuation), so they + // echo results into root as text and need the invocation named too. Gating on `externalModel` + // would have skipped exactly these models (audit 001 F2). Fast used to stay on resumeAction; + // empty Chat Completions completions on 2026-09-21 put it on the same path as non-fast. + test.each(["composer-2.5", "composer-2.5-fast"])("%s root replay names the invocation too", modelId => { + const root = resultRoot(encode(history(), modelId)); expect(root).toBeDefined(); expect(root).toContain("invoked: exec_command with"); }); @@ -171,8 +172,8 @@ describe("cursor replayed tool results name their invocation", () => { expect(root).not.toContain("invoked:"); }); - test("native composer replay keeps results off the root prompt entirely", () => { - const bytes = encode(history(), "composer-2.5-fast"); + test("native composer-1 replay keeps results off the root prompt entirely", () => { + const bytes = encode(history(), "composer-1"); expect(rootTexts(bytes).some(text => text.startsWith("[Tool Result]"))).toBe(false); expect(rootTexts(bytes).some(text => text.includes("invoked:"))).toBe(false); }); @@ -405,8 +406,8 @@ describe("cursor checkpoint continuation names the invocation from covered histo expect(root).not.toContain("invoked:"); }); - test("native composer keeps checkpoint results off the root prompt", () => { - const roots = rootTexts(encodeCheckpoint(history(), "composer-2.5-fast", 2)); + test("native composer-1 keeps checkpoint results off the root prompt", () => { + const roots = rootTexts(encodeCheckpoint(history(), "composer-1", 2)); expect(roots.some(text => text.startsWith("[Tool Result]"))).toBe(false); expect(roots.some(text => text.includes("invoked:"))).toBe(false); }); @@ -641,13 +642,13 @@ describe("cursor spare envelope budget restores clipped invocation arguments", ( expect(invokedLine(root)).toBe("invoked: write_file with " + JSON.stringify(args)); }); - // composer-2.5 is a NATIVE wire model (isCursorExternalWireModel is false) that still routes - // through the external tool-continuation path, so it echoes results into roots and accumulates - // the same clipped lines. This is the case the echoToolResultInRoot gate exists for: a gate - // written as externalModel would leave the one native model with clipped lines capped. - test("native composer-2.5 root replay is restored too", () => { + // composer-2.5 and composer-2.5-fast are NATIVE wire models (isCursorExternalWireModel is false) + // that still route through the external tool-continuation path, so they echo results into roots + // and accumulate the same clipped lines. This is the case the echoToolResultInRoot gate exists + // for: a gate written as externalModel would leave these native models with clipped lines capped. + test.each(["composer-2.5", "composer-2.5-fast"])("native %s root replay is restored too", modelId => { const args = { contents: "A".repeat(4600) }; - const root = resultRoot(encode(writeFileHistory(args), "composer-2.5")); + const root = resultRoot(encode(writeFileHistory(args), modelId)); expect(root).toBeDefined(); const line = invokedLine(root); expect(line).toBeDefined(); diff --git a/tests/providers/kiro/kiro-windows-cli-executable-path.test.ts b/tests/providers/kiro/kiro-windows-cli-executable-path.test.ts index 73a4d28bd77..3519e727423 100644 --- a/tests/providers/kiro/kiro-windows-cli-executable-path.test.ts +++ b/tests/providers/kiro/kiro-windows-cli-executable-path.test.ts @@ -106,4 +106,66 @@ describe("kiro-cli executable resolution", () => { exists, })).toBe("C:\\Tools\\kiro-cli.exe"); }); + test("win32 falls back to kiro.exe inside the dedicated Kiro-Cli folders after every canonical name", () => { + const local = "C:\\Users\\u\\AppData\\Local\\Kiro-Cli\\kiro.exe"; + const programFiles = "C:\\Program Files\\Kiro-Cli\\kiro.exe"; + const base = { + env: { PATH: "C:\\Windows\\System32", LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", ProgramFiles: "C:\\Program Files" }, + platform: "win32" as const, + home: WIN_HOME, + pathEntries: ["C:\\Windows\\System32"], + }; + expect(resolveKiroCliExecutable({ ...base, exists: path => path === local })).toBe(local); + expect(resolveKiroCliExecutable({ ...base, exists: path => path === programFiles })).toBe(programFiles); + // Canonical kiro-cli.exe in either folder beats the short name in the other. + expect(resolveKiroCliExecutable({ + ...base, + exists: path => path === local || path === "C:\\Program Files\\Kiro-Cli\\kiro-cli.exe", + })).toBe("C:\\Program Files\\Kiro-Cli\\kiro-cli.exe"); + // Canonical kiro-cli.exe on PATH beats both. + expect(resolveKiroCliExecutable({ + ...base, + pathEntries: ["C:\\Tools"], + exists: path => path === local || path === "C:\\Tools\\kiro-cli.exe", + })).toBe("C:\\Tools\\kiro-cli.exe"); + }); + + test("win32 never runs a short kiro.exe found on PATH", () => { + expect(resolveKiroCliExecutable({ + env: { PATH: "C:\\Tools", LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local" }, + platform: "win32", + home: WIN_HOME, + pathEntries: ["C:\\Tools"], + exists: path => path === "C:\\Tools\\kiro.exe" || path === "C:\\Tools\\kiro", + })).toBe("kiro-cli.exe"); + }); + + test("win32 skips the short name when the install base is relative or drive-relative", () => { + for (const LOCALAPPDATA of ["AppData\\Local", "\\Users\\u\\AppData\\Local"]) { + const shortPath = LOCALAPPDATA + "\\Kiro-Cli\\kiro.exe"; + expect(resolveKiroCliExecutable({ + env: { PATH: "C:\\Windows\\System32", LOCALAPPDATA, ProgramFiles: "Program Files" }, + platform: "win32", + home: WIN_HOME, + pathEntries: ["C:\\Windows\\System32"], + exists: path => path === shortPath || path === "Program Files\\Kiro-Cli\\kiro.exe", + })).toBe("kiro-cli.exe"); + } + }); + + test("posix never falls back to a short kiro in shared bin directories", () => { + for (const [platform, dirs] of [ + ["linux", ["/home/u/.local/bin", "/usr/local/bin"]], + ["darwin", ["/home/u/.local/bin", "/usr/local/bin", "/opt/homebrew/bin"]], + ] as const) { + const shortNames = new Set(dirs.map(dir => dir + "/kiro")); + expect(resolveKiroCliExecutable({ + env: { PATH: "/usr/bin:/usr/local/bin" }, + platform, + home: "/home/u", + pathEntries: ["/usr/bin", "/usr/local/bin"], + exists: path => shortNames.has(path), + })).toBe("kiro-cli"); + } + }); }); diff --git a/tests/providers/muse-spark-web-search-compat.test.ts b/tests/providers/muse-spark-web-search-compat.test.ts index 5254c7163d0..f0e9f6c9b18 100644 --- a/tests/providers/muse-spark-web-search-compat.test.ts +++ b/tests/providers/muse-spark-web-search-compat.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; +import { stripMuseSparkUnsupportedWebSearchFields } from "../../src/adapters/openai-responses/web-search"; import { getProviderRegistryEntry } from "../../src/providers/registry"; import type { OcxProviderConfig } from "../../src/types"; import { withTestTranslatorBudget } from "../helpers/translator-budget"; @@ -266,4 +267,34 @@ describe("#2617/#3378 Muse Spark web_search compatibility", () => { expect(Object.hasOwn(tool, "search_content_types")).toBe(false); expect(Object.hasOwn(tool, "indexed_web_access")).toBe(false); }); + + /** + * Both direct-Meta providers ship a non-Contributor `defaultModel`, and Meta's refusal is a + * gateway schema rule that holds for every Muse model it serves. Keying the strip on the + * Contributor-only id list left `muse-spark-1.3` 400ing on every Codex turn whose client + * attaches its default web_search tool — the same equality-shaped hole the 1.3 id hit on the + * Zen wire, one tier over. + */ + test("direct Meta strips the fields on the non-Contributor tiers too", () => { + for (const modelId of ["muse-spark-1.3", "muse-spark-1.2"]) { + const body = buildForProvider(META_PROVIDER, modelId, { + tools: [webSearchTool(), { ...webSearchTool(), type: "web_search_preview" }], + }); + const [plain, preview] = toolsOf(body); + expect(Object.hasOwn(plain!, "search_content_types")).toBe(false); + expect(Object.hasOwn(plain!, "indexed_web_access")).toBe(false); + // The destination is the predicate here, not the id: it must not swallow the preview shape. + expect(preview!.search_content_types).toEqual(["text", "image"]); + expect(preview!.indexed_web_access).toBe(true); + } + }); + + test("direct Meta remains destination-scoped when the model id is unavailable", () => { + const body = { tools: [webSearchTool()] }; + const rewritten = stripMuseSparkUnsupportedWebSearchFields(body, undefined, "https://api.meta.ai/v1/responses") as { + tools: Array>; + }; + expect(Object.hasOwn(rewritten.tools[0]!, "search_content_types")).toBe(false); + expect(Object.hasOwn(rewritten.tools[0]!, "indexed_web_access")).toBe(false); + }); }); From a1dba2ccbd85ee4867cab59c47b5884ae5581816 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 19:24:45 +0900 Subject: [PATCH 04/48] fix(openai-chat): Pi developer role and duplicated serialized tool calls (lane F2) (#5674) * fix(clients): tell Pi and omo to send system instead of developer (#5664) Pi sends its system prompt as `developer` for reasoning models. The native Chat Completions route forwards caller roles verbatim unless a destination has recorded foldDeveloperRoleToSystem, so upstreams that reject the role (DashScope compatible-mode answers "developer is not one of [...]") failed every request. Users could not work around it: every export rewrites the whole provider block and drops a hand-set compat key. The Pi export now writes compat.supportsDeveloperRole: false next to the session-affinity key, on both the export and the managed-contribution path. omo keeps its byte-identical-to-Pi contract because senpi documents the same key. Prime and Aside keep their compat-free block. * fix(openai-chat): stop duplicated serialized tool calls reaching visible text (#5548) Some Chat gateways send one model-produced call twice: as a bare ... block in content and as a structured tool_calls entry, sometimes with the block body prefixed onto the JSON arguments too. Codex then showed the raw call syntax as the answer and could receive malformed arguments. The adapter now holds a possible bare block (outside Markdown fences, quotes and inline code) and removes it only when its function name and freeform body match a structured call in the same response; the argument prefix is repaired only for that exact duplicated shape. Mismatched markup stays byte-exact, held text is drained on every terminal path, and the held bytes use the translator budget. Carried from #5548 and rebuilt on dev's inline splitter: reconciliation sees only answer text; reasoning that arrives behind a held block is queued in place (with a heartbeat) so order is kept and the duplicate is not exposed early; buffered responses replay through the same buffer so both paths share one rule set. The adapter glue lives in serialized-tool-call-content.ts to stay under the file-size ratchet. #5548's unrelated codex/home.ts import-cycle, WSL test, and service test edits are not carried. Co-authored-by: Vadevious <56196048+Vadevious@users.noreply.github.com> --------- Co-authored-by: Vadevious <56196048+Vadevious@users.noreply.github.com> --- docs-site/src/content/docs/fr/guides/pi.md | 5 +- docs-site/src/content/docs/guides/pi.md | 5 +- docs-site/src/content/docs/ja/guides/pi.md | 5 +- docs-site/src/content/docs/ko/guides/pi.md | 5 +- docs-site/src/content/docs/ru/guides/pi.md | 5 +- docs-site/src/content/docs/tr/guides/pi.md | 5 +- docs-site/src/content/docs/zh-cn/guides/pi.md | 5 +- docs-site/src/content/docs/zh-tw/guides/pi.md | 5 +- scripts/test-layout/layout.json | 4 + src/adapters/openai-chat.ts | 48 ++- .../serialized-tool-call-content.ts | 354 ++++++++++++++++++ src/clients/config-export.ts | 49 ++- .../ADR-5548-serialized-tool-call-content.md | 12 + structure/providers-and-adapters.md | 2 +- structure/providers/chat-compat.md | 23 ++ ...at-sanitization-review-regressions.test.ts | 338 +++++++++++++++++ ...-chat-serialized-tool-call-content.test.ts | 63 ++++ ...ai-chat-serialized-tool-call-think.test.ts | 199 ++++++++++ tests/clients/omo-client.test.ts | 5 +- tests/clients/prime-client.test.ts | 2 +- tests/config/client-config-export.test.ts | 14 +- tests/fixtures/test-layout-expected.json | 4 + .../responses-chat-tool-call-content.test.ts | 88 +++++ 23 files changed, 1213 insertions(+), 32 deletions(-) create mode 100644 src/adapters/openai-chat/serialized-tool-call-content.ts create mode 100644 structure/decisions/ADR-5548-serialized-tool-call-content.md create mode 100644 tests/adapters/openai/openai-chat-sanitization-review-regressions.test.ts create mode 100644 tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts create mode 100644 tests/adapters/openai/openai-chat-serialized-tool-call-think.test.ts create mode 100644 tests/responses/responses-chat-tool-call-content.test.ts diff --git a/docs-site/src/content/docs/fr/guides/pi.md b/docs-site/src/content/docs/fr/guides/pi.md index 030d91679c3..14acc0db356 100644 --- a/docs-site/src/content/docs/fr/guides/pi.md +++ b/docs-site/src/content/docs/fr/guides/pi.md @@ -28,7 +28,8 @@ d’exportation de la variable d’environnement et le nombre de modèles dotés "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -46,6 +47,8 @@ d’exportation de la variable d’environnement et le nombre de modèles dotés Les fournisseurs Pi générés activent `compat.sendSessionAffinityHeaders`. Conservez ce réglage lors de la fusion ou de la modification manuelle du fournisseur : Pi transmet un identifiant de session stable, dont OpenCodex dérive l’affinité pour la destination canonique OpenCode Go. Pi peut omettre cet identifiant lorsque `cacheRetention` vaut `none`. +Les fournisseurs Pi générés définissent aussi `compat.supportsDeveloperRole` à `false` : Pi envoie alors son prompt système avec le rôle `system` au lieu de `developer`. OpenCodex transmet les rôles Chat Completions tels quels, et plusieurs amonts compatibles OpenAI refusent `developer` avec une erreur 400 ; tous acceptent `system`. + Les identifiants de modèle sont les sélecteurs canoniques du proxy : les modèles routés apparaissent donc sous la forme `provider/model` (`anthropic/claude-opus-5`) et les slugs natifs OpenAI restent sans préfixe (`gpt-5.6-sol`). Le `name` suffixe — `(anthropic)`, `(native)`, `(routed)` — permet de distinguer, dans le sélecteur de Pi, deux modèles de même nom diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md index f8a00f7b3fc..b5a92212c88 100644 --- a/docs-site/src/content/docs/guides/pi.md +++ b/docs-site/src/content/docs/guides/pi.md @@ -28,7 +28,8 @@ export line, and how many models carry authoritative context limits. "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -46,6 +47,8 @@ export line, and how many models carry authoritative context limits. Generated Pi providers enable `compat.sendSessionAffinityHeaders`. Keep this flag when merging or manually editing the provider: Pi supplies a stable session identity and OpenCodex derives canonical OpenCode Go affinity from it. Pi may omit the identity when `cacheRetention` is `none`. +Generated Pi providers also set `compat.supportsDeveloperRole` to `false`, so Pi sends its system prompt as `system` instead of `developer`. OpenCodex forwards Chat Completions roles as sent, and several OpenAI-compatible upstreams reject `developer` with a 400; every upstream accepts `system`. + Model ids are the proxy's canonical selectors, so routed models appear as `provider/model` (`anthropic/claude-opus-5`) and native OpenAI slugs stay unprefixed (`gpt-5.6-sol`). The `name` suffix — `(anthropic)`, `(native)`, `(routed)` — is what makes two same-named models from diff --git a/docs-site/src/content/docs/ja/guides/pi.md b/docs-site/src/content/docs/ja/guides/pi.md index 67e55107e08..37980f9745a 100644 --- a/docs-site/src/content/docs/ja/guides/pi.md +++ b/docs-site/src/content/docs/ja/guides/pi.md @@ -24,7 +24,8 @@ ocx export --client pi "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -42,6 +43,8 @@ ocx export --client pi 生成される Pi プロバイダーでは `compat.sendSessionAffinityHeaders` が有効です。設定をマージしたり手動で編集したりする際も、このフラグを保持してください。Pi が送る安定したセッション識別子から、OpenCodex が正規の OpenCode Go 接続先用の affinity を生成します。`cacheRetention` が `none` の場合、Pi は識別子を送信しないことがあります。 +生成される Pi プロバイダーでは `compat.supportsDeveloperRole` も `false` に設定され、Pi はシステムプロンプトを `developer` ではなく `system` ロールで送ります。OpenCodex は Chat Completions のロールを受け取ったまま転送しますが、OpenAI 互換のアップストリームの中には `developer` を 400 で拒否するものがあります。`system` はすべてのアップストリームが受け付けます。 + モデル ID はプロキシの正規セレクターであるため、ルーティングされたモデルは `provider/model` (`anthropic/claude-opus-5`) として表示され、ネイティブ OpenAI スラグはプレフィックスなし (`gpt-5.6-sol`) のままになります。 `name` サフィックス (`(anthropic)`、`(native)`、`(routed)`) により、異なるアップストリームの 2 つの同じ名前のモデルが Pi のピッカーで区別できるようになります。 ## どこへ行くのか diff --git a/docs-site/src/content/docs/ko/guides/pi.md b/docs-site/src/content/docs/ko/guides/pi.md index a2c7be8d5fc..3de309f7701 100644 --- a/docs-site/src/content/docs/ko/guides/pi.md +++ b/docs-site/src/content/docs/ko/guides/pi.md @@ -28,7 +28,8 @@ ocx export --client pi "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -46,6 +47,8 @@ ocx export --client pi 생성된 Pi provider에는 `compat.sendSessionAffinityHeaders`가 활성화됩니다. provider를 병합하거나 직접 수정할 때 이 설정을 유지하세요. Pi가 안정적인 세션 식별자를 보내면 OpenCodex가 이를 바탕으로 정규 OpenCode Go 대상의 affinity를 계산합니다. `cacheRetention`이 `none`이면 Pi가 식별자를 보내지 않을 수 있습니다. +생성된 Pi provider는 `compat.supportsDeveloperRole`도 `false`로 설정합니다. 그래서 Pi는 시스템 프롬프트를 `developer`가 아닌 `system` 역할로 보냅니다. OpenCodex는 Chat Completions 역할을 받은 그대로 전달하는데, OpenAI 호환 업스트림 중 일부는 `developer`를 400으로 거부합니다. `system`은 모든 업스트림이 받습니다. + 모델 id는 프록시의 정규 선택자이므로, 라우팅된 모델은 `provider/model` (`anthropic/claude-opus-5`) 형태로 나타나고, 네이티브 OpenAI slug는 접두사 없이 (`gpt-5.6-sol`) 유지됩니다. `name` 접미사인 `(anthropic)`, `(native)`, `(routed)`는 diff --git a/docs-site/src/content/docs/ru/guides/pi.md b/docs-site/src/content/docs/ru/guides/pi.md index f6cb3b141da..c93286ea752 100644 --- a/docs-site/src/content/docs/ru/guides/pi.md +++ b/docs-site/src/content/docs/ru/guides/pi.md @@ -28,7 +28,8 @@ ocx export --client pi "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -46,6 +47,8 @@ ocx export --client pi В создаваемой конфигурации Pi включён `compat.sendSessionAffinityHeaders`. Сохраняйте этот флаг при объединении или ручном редактировании провайдера: Pi передаёт стабильный идентификатор сессии, из которого OpenCodex формирует affinity для канонического OpenCode Go. При `cacheRetention: none` Pi может не передавать идентификатор. +Создаваемая конфигурация Pi также задаёт `compat.supportsDeveloperRole` равным `false`, поэтому Pi отправляет системный промпт с ролью `system`, а не `developer`. OpenCodex передаёт роли Chat Completions без изменений, а часть OpenAI-совместимых провайдеров отклоняет `developer` с ошибкой 400; роль `system` принимают все. + Id моделей — это канонические селекторы прокси, поэтому маршрутизируемые модели появляются как `provider/model` (`anthropic/claude-opus-5`), а нативные slug OpenAI остаются без префикса (`gpt-5.6-sol`). Суффикс в `name` — `(anthropic)`, `(native)`, `(routed)` — как раз и позволяет diff --git a/docs-site/src/content/docs/tr/guides/pi.md b/docs-site/src/content/docs/tr/guides/pi.md index dc1ba48e10c..e04cd9326e4 100644 --- a/docs-site/src/content/docs/tr/guides/pi.md +++ b/docs-site/src/content/docs/tr/guides/pi.md @@ -32,7 +32,8 @@ export line, and how many models carry authoritative context limits. "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -50,6 +51,8 @@ export line, and how many models carry authoritative context limits. Oluşturulan Pi sağlayıcılarında `compat.sendSessionAffinityHeaders` etkinleştirilir. Sağlayıcıyı birleştirirken veya elle düzenlerken bu ayarı koruyun: Pi sabit bir oturum kimliği gönderir ve OpenCodex bu kimlikten kanonik OpenCode Go hedefi için oturum yakınlığı üretir. `cacheRetention` değeri `none` olduğunda Pi kimliği göndermeyebilir. +Oluşturulan Pi sağlayıcıları ayrıca `compat.supportsDeveloperRole` değerini `false` yapar; böylece Pi sistem istemini `developer` yerine `system` rolüyle gönderir. OpenCodex Chat Completions rollerini olduğu gibi iletir ve OpenAI uyumlu bazı sağlayıcılar `developer` rolünü 400 hatasıyla reddeder; `system` rolünü hepsi kabul eder. + Model ids are the proxy's canonical selectors, so routed models appear as `provider/model` (`anthropic/claude-opus-5`) and native OpenAI slugs stay unprefixed diff --git a/docs-site/src/content/docs/zh-cn/guides/pi.md b/docs-site/src/content/docs/zh-cn/guides/pi.md index f83ba873fee..8358cc82673 100644 --- a/docs-site/src/content/docs/zh-cn/guides/pi.md +++ b/docs-site/src/content/docs/zh-cn/guides/pi.md @@ -24,7 +24,8 @@ ocx export --client pi "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -42,6 +43,8 @@ ocx export --client pi 生成的 Pi 提供方配置启用了 `compat.sendSessionAffinityHeaders`。合并或手动编辑提供方时请保留该设置:Pi 提供稳定的会话标识,OpenCodex 据此为规范的 OpenCode Go 目标生成会话亲和标识。`cacheRetention` 为 `none` 时,Pi 可能不发送会话标识。 +生成的 Pi 提供方配置还会把 `compat.supportsDeveloperRole` 设为 `false`,使 Pi 以 `system` 而不是 `developer` 角色发送系统提示词。OpenCodex 按原样转发 Chat Completions 角色,而部分 OpenAI 兼容上游会以 400 拒绝 `developer`;所有上游都接受 `system`。 + 模型 id 是代理的规范选择器,因此已路由模型会显示为 `provider/model`(`anthropic/claude-opus-5`),而原生 OpenAI slug 会保持不带前缀(`gpt-5.6-sol`)。`name` 后缀 - `(anthropic)`、`(native)`、`(routed)` - 负责让两个同名但来自不同上游的模型在 Pi 的选择器中可区分。 ## 放置位置 diff --git a/docs-site/src/content/docs/zh-tw/guides/pi.md b/docs-site/src/content/docs/zh-tw/guides/pi.md index 9d64b8ef5fd..09e057ae95d 100644 --- a/docs-site/src/content/docs/zh-tw/guides/pi.md +++ b/docs-site/src/content/docs/zh-tw/guides/pi.md @@ -24,7 +24,8 @@ ocx export --client pi "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -42,6 +43,8 @@ ocx export --client pi 產生的 Pi 供應商設定會啟用 `compat.sendSessionAffinityHeaders`。合併或手動編輯供應商時請保留此設定:Pi 提供穩定的工作階段識別碼,OpenCodex 據此為標準 OpenCode Go 目標產生工作階段親和識別碼。當 `cacheRetention` 為 `none` 時,Pi 可能不傳送識別碼。 +產生的 Pi 供應商設定也會把 `compat.supportsDeveloperRole` 設為 `false`,讓 Pi 以 `system` 而非 `developer` 角色傳送系統提示詞。OpenCodex 會照原樣轉送 Chat Completions 角色,而部分 OpenAI 相容上游會以 400 拒絕 `developer`;所有上游都接受 `system`。 + 模型 id 是代理的規範選擇器,因此路由模型顯示為 `provider/model`(`anthropic/claude-opus-5`),而原生 OpenAI slug 保持無前綴(`gpt-5.6-sol`)。`name` 後綴 — `(anthropic)`、`(native)`、`(routed)` — 正是讓來自不同上游的兩個同名模型在 Pi 的 picker 中可區分的關鍵。 ## 放置位置 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index bb62fef0ca9..3dd1302600a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1162,7 +1162,11 @@ "openai-chat-parallel-stream.test.ts": "adapters/openai", "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-reasoning-wire-policy.test.ts": "adapters/openai", + "openai-chat-sanitization-review-regressions.test.ts": "adapters/openai", + "openai-chat-serialized-tool-call-content.test.ts": "adapters/openai", + "openai-chat-serialized-tool-call-think.test.ts": "adapters/openai", "openai-chat-system-order.test.ts": "adapters/openai", + "responses-chat-tool-call-content.test.ts": "responses", "openai-chat-tool-result-images.test.ts": "adapters/openai", "openai-chat-url.test.ts": "adapters/openai", "openai-chat-video-part.test.ts": "adapters/openai", diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index dbc1ce76954..5fb1775a940 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -45,6 +45,7 @@ import { messagesToChatFormat } from "./openai-chat/messages"; import { withOpenAIChatToolNames } from "./openai-chat/tool-name-registry"; import { openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire"; import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema"; +import { reconcileSerializedToolCallEvents, reconcileStructuredToolCall, SerializedToolCallContentBuffer } from "./openai-chat/serialized-tool-call-content"; export { stripBracketedModelSuffix } from "./openai-chat/wire"; export { buildOpenAIChatPassthroughRequest } from "./openai-chat/passthrough"; @@ -299,6 +300,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd sawArgumentsString: boolean; } const pendingToolCalls: PendingToolCall[] = []; + const toolCallContent = new SerializedToolCallContentBuffer(budget); + const heldText = (): AdapterEvent[] => toolCallContent.drain([]); let toolCallSeq = 0; const closeToolCalls = (): PendingToolCall[] => { const calls = [...pendingToolCalls]; @@ -310,7 +313,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd pendingToolCalls.length > 0 && pendingToolCalls.every(call => { if (call.name.trim().length === 0 || !call.sawArgumentsString || call.args.length === 0) return false; try { - const parsed = JSON.parse(call.args) as unknown; + const parsed = JSON.parse(reconcileStructuredToolCall(call.name, toolNames.restore(call.name), call.args, toolCallContent.current()).argumentsText) as unknown; return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed); } catch { return false; @@ -320,7 +323,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // stops the turn instead of emitting an unusable call. `closeToolCalls()` runs first, // so budget reservations are released for every pending call even on the early return. const flushToolCalls = function* (): Generator { - for (const call of closeToolCalls()) { + const calls = closeToolCalls(); + for (const call of calls) { // Ingest already proved `name` is a string; the typeof guard keeps this branch // total so a future ingest change cannot turn a malformed name into a throw. if (typeof call.name !== "string" || call.name.trim().length === 0) { @@ -328,9 +332,14 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd hadId: call.id.length > 0, argsBytes: call.argsBytes, }); - yield unnamedToolCallEvent(pendingUsage); - return "terminate"; + return yield* terminateWithError(unnamedToolCallEvent(pendingUsage)); } + } + // Held serialized markup is released only now, reconciled against the calls it may duplicate. + const references = calls.map(call => reconcileStructuredToolCall(call.name, toolNames.restore(call.name), call.args, toolCallContent.current())); + calls.forEach((call, index) => { call.args = references[index]!.argumentsText; }); + yield* toolCallContent.drain(references); + for (const call of calls) { if (!call.id) call.id = `call_${++toolCallSeq}`; yield { type: "tool_call_start", id: call.id, name: toolNames.restore(call.name) }; if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args }; @@ -342,6 +351,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd event: Extract, ): Generator { closeToolCalls(); + yield* heldText(); // Pending tools are not dispatched, so held text stays visible. yield event; return "terminate"; }; @@ -360,7 +370,13 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // blocks, which would otherwise render as the answer. Passthrough unless opted in. const inlineThink = createInlineThinkContentSplitter(provider.inlineThinkTagModels, lastRequestedModelId, budget); const emitContent = function* (events: AdapterEvent[]): Generator { - for (const event of events) { if (event.type === "text_delta") sawUserFacingOutput = true; yield event; } + for (const event of events) { + // Any other event keeps its place behind held text instead of overtaking it. + if (event.type !== "text_delta") { yield* toolCallContent.hold(event); continue; } + sawUserFacingOutput = true; + const text = toolCallContent.ingest(event.text); + yield text.length > 0 ? { type: "text_delta", text } : { type: "heartbeat" }; + } }; const handleDataLine = function* (line: string): Generator { @@ -381,8 +397,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd parsed = JSON.parse(payload); } catch { tierMetadata?.markResponseUnparseable(); - yield { type: "error", message: "malformed upstream SSE data frame" }; - return "terminate"; + return yield* terminateWithError({ type: "error", message: "malformed upstream SSE data frame" }); } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return "continue"; const chunk = parsed as Record; @@ -423,11 +438,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (detailSegments.length > 0) { for (const segment of detailSegments) { const reasoningDelta = reasoningDetailTracker.ingest(segment); - if (reasoningDelta !== null) yield { type: "reasoning_raw_delta", text: reasoningDelta }; + if (reasoningDelta !== null) yield* toolCallContent.hold({ type: "reasoning_raw_delta", text: reasoningDelta }); } } else { const reasoningText = reasoningTextFrom(delta); - if (reasoningText !== undefined) yield { type: "reasoning_raw_delta", text: reasoningText }; + if (reasoningText !== undefined) yield* toolCallContent.hold({ type: "reasoning_raw_delta", text: reasoningText }); } if (typeof delta.content === "string" && delta.content.length > 0) { yield* emitContent(inlineThink.feed(delta.content)); @@ -630,7 +645,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd hadUsage: pendingUsage !== undefined, pendingToolCalls: pendingToolCalls.length, }); - yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" }; + yield* terminateWithError({ type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" }); return; } if (!sawFinish && !sawUserFacingOutput) { @@ -638,13 +653,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd finishReason: finishReason ?? null, hadUsage: pendingUsage !== undefined, }); - yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" }; + yield* terminateWithError({ type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" }); return; } if ((yield* flushToolCalls()) === "terminate") return; const stopReason = stopReasonFor(finishReason); yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) }; } catch (error) { + closeToolCalls(); + yield* heldText(); if (isTranslatorBudgetExceededError(error) || (error instanceof Error && (error.cause as { code?: unknown } | undefined)?.code === "translation_buffer_limit")) { yield { @@ -662,6 +679,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd budget.releaseRetained(bufferBytes, { kind: "live_transient" }); reasoningDetailTracker.release(); inlineThink.dispose(); + toolCallContent.dispose(); closeToolCalls(); reader.releaseLock(); } @@ -748,7 +766,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (segments.length > 0) reasoningText = segments.map(s => s.text).join(""); } if (reasoningText !== undefined) events.push({ type: "reasoning_raw_delta", text: reasoningText }); + const contentStart = events.length; if (typeof msg.content === "string") events.push(...splitInlineThinkContent(provider.inlineThinkTagModels, lastRequestedModelId, budget, msg.content)); + const contentEnd = events.length; + const answerText = events.slice(contentStart).map(event => (event.type === "text_delta" ? event.text : "")).join(""); + const references: ReturnType[] = []; const rawToolCalls = msg.tool_calls; if (rawToolCalls !== undefined && rawToolCalls !== null) { if (!Array.isArray(rawToolCalls)) { @@ -771,11 +793,13 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd logInvalidToolCalls("response", rawToolCalls); return [invalidToolCallsEvent(rawToolCalls, "response", usage)]; } + references.push(reconcileStructuredToolCall(name, toolNames.restore(name), args, answerText)); events.push({ type: "tool_call_start", id, name: toolNames.restore(name) }); - events.push({ type: "tool_call_delta", arguments: args }); + events.push({ type: "tool_call_delta", arguments: references.at(-1)!.argumentsText }); events.push({ type: "tool_call_end" }); } } + reconcileSerializedToolCallEvents(events, contentStart, contentEnd, references, budget); const stopReason = stopReasonFor(choice.finish_reason); events.push({ type: "done", diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts new file mode 100644 index 00000000000..d874849a458 --- /dev/null +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -0,0 +1,354 @@ +import type { TranslatorBudget } from "../../lib/translator-budget"; +import type { AdapterEvent } from "../../types"; + +const OPEN_TAG = ""; +const FUNCTION_TAG = "; + argumentsText: string; +} + +/** Finds complete bare blocks outside literal Markdown; ambiguous outer blocks stop the scan. */ +function callsIn(text: string, context: TextContext = { fence: null, lineStart: true }): SerializedToolCall[] { + const pattern = /\s*\r\n]+)>([\s\S]*?)(?:<\/parameter>)?\s*<\/function>\s*<\/tool_call>/y; + const calls: SerializedToolCall[] = []; + let offset = 0; + while (offset < text.length) { + const split = splitAtPossibleSerializedToolCall(text.slice(offset), context, true); + offset += split.emit.length; + if (!split.hasOpenTag) break; + pattern.lastIndex = offset; + const match = pattern.exec(text); + if (!match) break; // An incomplete/ambiguous outer block cannot authorize an inner call. + calls.push({ + name: match[1]!.trim(), + body: match[2]!, + start: match.index, + end: match.index + match[0].length, + }); + offset = pattern.lastIndex; + context = { fence: null, lineStart: false }; + } + return calls; +} + +/** Splits safe visible text from a possible control block while carrying Markdown context across chunks. */ +export function splitAtPossibleSerializedToolCall( + text: string, + initialContext: TextContext = { fence: null, lineStart: true }, + final = false, +): { + emit: string; + defer: string; + hasOpenTag: boolean; + context: TextContext; +} { + const context = { ...initialContext }; + const split = (at: number, hasOpenTag = false) => ({ + emit: text.slice(0, at), defer: text.slice(at), hasOpenTag, context, + }); + for (let index = 0; index < text.length; index++) { + if (context.lineStart) { + const rest = text.slice(index); + const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)/.exec(rest); + if (!context.inlineTicks && fence && (!context.fence || (fence[1]![0] === context.fence[0] + && fence[1]!.length >= context.fence.length && /^[ \t\r]*$/.test(fence[2]!)))) { + if (!final && !rest.includes("\n")) return split(index); + context.fence = context.fence ? null : fence[1]!; + index += fence[0].length - 1; + context.lineStart = false; + continue; + } + if (!final && /^ {0,3}(`*|~*)$/.test(rest)) return split(index); + // Only bare control markup qualifies. Prose, quotes, indented examples and + // fenced code stay user-visible even when their body matches a real call. + if (!context.fence && !context.inlineTicks) { + if (rest.startsWith(OPEN_TAG)) { + const header = rest.slice(OPEN_TAG.length).trimStart(); + if (/^\r\n]+>/.test(header)) return split(index, true); + if (!final && (FUNCTION_TAG.startsWith(header) + || (header.startsWith(FUNCTION_TAG) && !/[>\r\n]/.test(header.slice(FUNCTION_TAG.length))))) { + return split(index); + } + } else if (!final && OPEN_TAG.startsWith(rest)) return split(index); + } + } + if (!context.fence && text[index] === "`") { + let end = index + 1; + while (text[end] === "`") end++; + if (!final && end === text.length) return split(index); + const ticks = end - index; + if (!context.inlineTicks) context.inlineTicks = ticks; + else if (context.inlineTicks === ticks) context.inlineTicks = undefined; + index = end - 1; + } + context.lineStart = text[index] === "\n"; + } + return split(text.length); +} + +/** + * The line, fence and inline-code state after `text`. Serialized blocks are neutralised first so + * the scan runs through the whole text instead of stopping at the first opening tag; a block ends + * mid-line, which is exactly what the neutral spelling reports too. + */ +function contextAfter(text: string, context: TextContext): TextContext { + if (text.length === 0) return context; + return splitAtPossibleSerializedToolCall(text.replaceAll(OPEN_TAG, ""), context, true).context; +} + +/** + * Holds possible duplicate text within the shared translator budget until the dispatch outcome is + * known. While a block candidate is open, any other event (reasoning) is queued at its position in + * the held text rather than overtaking it or forcing the block out early, and `drain` restores the + * original order. + */ +export class SerializedToolCallContentBuffer { + private text = ""; + private bytes = 0; + private hasOpenTag = false; + private context: TextContext = { fence: null, lineStart: true }; + private queued: { offset: number; event: AdapterEvent }[] = []; + + constructor(private readonly budget: TranslatorBudget) {} + + /** Reserves the replacement before releasing the old text, preserving it if the budget rejects growth. */ + private replace(next: string, hasOpenTag: boolean): void { + const nextBytes = Buffer.byteLength(next); + const reservation = this.budget.reserveTransient(nextBytes, { kind: "live_transient" }); + try { + reservation.commitRetained(); + this.budget.releaseRetained(this.bytes, { kind: "live_transient" }); + this.text = next; + this.bytes = nextBytes; + this.hasOpenTag = hasOpenTag; + } catch (error) { + reservation.release(); + throw error; + } + } + + /** Charges only the appended bytes, so holding an open block never needs twice its retained size. */ + private append(delta: string): void { + const deltaBytes = Buffer.byteLength(delta); + this.budget.reserveTransient(deltaBytes, { kind: "live_transient" }).commitRetained(); + this.text += delta; + this.bytes += deltaBytes; + } + + /** Returns immediately safe text and retains only the suffix that still needs reconciliation. */ + ingest(delta: string): string { + if (this.hasOpenTag) { + this.append(delta); + return ""; + } + const split = splitAtPossibleSerializedToolCall(this.text + delta, this.context); + this.replace(split.defer, split.hasOpenTag); + this.context = split.context; + return split.emit; + } + + /** Exposes held text as evidence for narrowly repairing duplicated argument prefixes. */ + current(): string { + return this.text; + } + + /** + * Passes a non-text event through, in order. With an open block candidate held, the event is + * queued behind the held text and a heartbeat stands in for it; with only a partial prefix held (no complete opening tag yet), + * that prefix cannot be a whole duplicate and is released ahead of the event. + */ + hold(event: AdapterEvent): AdapterEvent[] { + if (!this.hasOpenTag) return [...this.drain([]), event]; + const eventBytes = Buffer.byteLength(JSON.stringify(event)); + this.budget.reserveTransient(eventBytes, { kind: "live_transient" }).commitRetained(); + this.bytes += eventBytes; + this.queued.push({ offset: this.text.length, event }); + // The consumer still sees activity, so a stall watchdog never mistakes a held turn for a dead one. + return [{ type: "heartbeat" }]; + } + + /** + * Drains held text and queued events in their original order, suppressing only blocks that + * duplicate a dispatched call; pass an empty list on failure to preserve everything. + */ + drain(structuredCalls: readonly StructuredToolCallReference[]): AdapterEvent[] { + const removed = duplicatedSerializedToolCallRanges(this.text, structuredCalls, this.context); + const kept = (from: number, to: number): string => { + let piece = ""; + let cursor = from; + for (const range of removed) { + if (range.end <= cursor || range.start >= to) continue; + piece += this.text.slice(cursor, Math.max(cursor, range.start)); + cursor = Math.min(to, range.end); + } + return piece + this.text.slice(cursor, to); + }; + const out: AdapterEvent[] = []; + let cursor = 0; + for (const boundary of [...this.queued, { offset: this.text.length, event: undefined }]) { + const text = kept(cursor, boundary.offset); + if (text.length > 0) out.push({ type: "text_delta", text }); + if (boundary.event) out.push(boundary.event); + cursor = boundary.offset; + } + // Later text continues after what was drained, so its line and fence state carry forward. + this.context = contextAfter(this.text, this.context); + this.queued = []; + this.replace("", false); + return out; + } + + /** Text-only drain for callers that never queued an event. */ + flush(structuredCalls: readonly StructuredToolCallReference[]): string { + return this.drain(structuredCalls) + .map(event => (event.type === "text_delta" ? event.text : "")) + .join(""); + } + + /** Releases retained bytes when the stream ends or its consumer cancels iteration. */ + dispose(): void { + this.budget.releaseRetained(this.bytes, { kind: "live_transient" }); + this.text = ""; + this.bytes = 0; + this.hasOpenTag = false; + this.queued = []; + } +} + +/** Reads only a string input from a JSON object; other argument shapes cannot prove duplication. */ +function inputFromArguments(argumentsText: string): string | undefined { + try { + const parsed = JSON.parse(argumentsText) as unknown; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + const input = (parsed as Record).input; + return typeof input === "string" ? input : undefined; + } catch { + return undefined; + } +} + +/** The `[start, end)` ranges of blocks whose function identity and freeform input match a dispatched call. */ +function duplicatedSerializedToolCallRanges( + text: string, + structuredCalls: readonly StructuredToolCallReference[], + context?: TextContext, +): { start: number; end: number }[] { + if (structuredCalls.length === 0) return []; + return callsIn(text, context).filter(call => { + const body = call.body.trimEnd(); + return structuredCalls.some(structured => + structured.names.has(call.name) && inputFromArguments(structured.argumentsText)?.trimEnd() === body); + }); +} + +/** Removes eligible blocks only when both the function identity and freeform input match a dispatched call. */ +export function stripDuplicatedSerializedToolCalls( + text: string, + structuredCalls: readonly StructuredToolCallReference[], + context?: TextContext, +): string { + let result = ""; + let cursor = 0; + for (const range of duplicatedSerializedToolCallRanges(text, structuredCalls, context)) { + result += text.slice(cursor, range.start); + cursor = range.end; + } + return result + text.slice(cursor); +} + +/** Removes a malformed argument prefix only when a bare block and the JSON suffix prove identical input. */ +export function repairArgumentsDuplicatedBesideSerializedCall( + argumentsText: string, + functionNames: ReadonlySet, + serializedText: string, +): string { + try { + JSON.parse(argumentsText); + return argumentsText; + } catch { + // Continue only for the exact duplication shape emitted by some Chat gateways. + } + + const bodies = callsIn(serializedText) + .filter(call => functionNames.has(call.name)) + .map(call => call.body.trimEnd()); + if (bodies.length === 0) return argumentsText; + + for (const body of bodies) { + if (!argumentsText.startsWith(body)) continue; + let start = body.length; + while (start < argumentsText.length && /\s/.test(argumentsText[start]!)) start += 1; + const candidate = argumentsText.slice(start); + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch { + continue; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) continue; + const input = (parsed as Record).input; + if (typeof input !== "string") continue; + if (body !== input.trimEnd()) continue; + return candidate; + } + return argumentsText; +} + +/** + * One structured call as the reconciler sees it: both the wire name and its restored client name + * identify it, and its arguments are repaired against the visible text the same response carried. + */ +export function reconcileStructuredToolCall( + wireName: string, + restoredName: string, + argumentsText: string, + serializedText: string, +): StructuredToolCallReference { + const names = new Set([wireName, restoredName]); + return { names, argumentsText: repairArgumentsDuplicatedBesideSerializedCall(argumentsText, names, serializedText) }; +} + +/** + * Buffered-response counterpart of the streaming path, applied in place to the content events in + * `events[start, end)`. It replays them through the same buffer the stream uses, so both paths + * share one rule set: text carries its line and fence context across events (the inline-think + * splitter may cut one answer into several), any other event keeps its place relative to held + * text, and only text still held at the end is matched against the structured calls. + */ +export function reconcileSerializedToolCallEvents( + events: AdapterEvent[], + start: number, + end: number, + structuredCalls: readonly StructuredToolCallReference[], + budget: TranslatorBudget, +): void { + if (structuredCalls.length === 0) return; + const buffer = new SerializedToolCallContentBuffer(budget); + const reconciled: AdapterEvent[] = []; + try { + for (const event of events.slice(start, end)) { + if (event.type !== "text_delta") { reconciled.push(...buffer.hold(event).filter(held => held.type !== "heartbeat")); continue; } + const text = buffer.ingest(event.text); + if (text.length > 0) reconciled.push({ type: "text_delta", text }); + } + reconciled.push(...buffer.drain(structuredCalls)); + } finally { + buffer.dispose(); + } + events.splice(start, end - start, ...reconciled); +} diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 6c9da37fadf..f6ae1d1c179 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -812,10 +812,34 @@ export interface PiProviderBlock { baseUrl: string; api: string; apiKey: string; - compat?: { sendSessionAffinityHeaders: boolean }; + compat?: PiProviderCompat; models: PiModelEntry[]; } +/** + * The subset of Pi's per-provider `compat` block this export writes. Both keys are part of + * Pi's own model-config schema; an unknown key there would empty the whole config, so nothing + * outside this set is ever emitted. + */ +export interface PiProviderCompat { + sendSessionAffinityHeaders?: boolean; + supportsDeveloperRole?: boolean; +} + +interface PiExportOptions { + sendSessionAffinityHeaders?: boolean; + /** + * Tell Pi to send its system prompt as `system` rather than `developer` (#5664). + * + * Pi sends `developer` for reasoning models by default. On `/v1/chat/completions` the proxy + * forwards the caller's roles verbatim unless a destination has recorded + * `foldDeveloperRoleToSystem`, and many OpenAI-compatible upstreams reject `developer` with a + * 400. `system` is accepted by every destination behind this one provider block, so the export + * states it rather than leaving each user to hand-edit a block the next export rewrites. + */ + foldDeveloperRole?: boolean; +} + export interface PiGeneratedConfig { providers: Record; } @@ -935,7 +959,7 @@ export interface GajaeGeneratedConfig { * model. The rest of this contract (omitting `cost`) is still ours rather than * a claim about Pi's acceptance. */ -function buildPiClientConfig(ctx: ExportContext, sendSessionAffinityHeaders = false): PiGeneratedConfig { +function buildPiClientConfig(ctx: ExportContext, options: PiExportOptions = {}): PiGeneratedConfig { const models: PiModelEntry[] = []; for (const model of normalizeExportModels(ctx.models)) { // Text is the one modality every routed model supports; anything richer must come @@ -972,19 +996,30 @@ function buildPiClientConfig(ctx: ExportContext, sendSessionAffinityHeaders = fa } models.push(entry); } + const compat: PiProviderCompat = { + ...(options.sendSessionAffinityHeaders ? { sendSessionAffinityHeaders: true } : {}), + ...(options.foldDeveloperRole ? { supportsDeveloperRole: false } : {}), + }; return { providers: { [OPENCODE_PROVIDER_ID]: { baseUrl: ctx.baseUrl, api: PI_API_DIALECT, apiKey: LOOPBACK_API_KEY_PLACEHOLDER, - ...(sendSessionAffinityHeaders ? { compat: { sendSessionAffinityHeaders: true } } : {}), + ...(Object.keys(compat).length > 0 ? { compat } : {}), models, }, }, }; } +/** + * Pi's export options, shared by `ocx export --client pi` and the managed contribution so the two + * never drift apart at the first refresh. omo uses the same options: senpi documents both keys in + * its models.json `compat` block (docs/models.md, docs/custom-provider.md). + */ +const PI_EXPORT_OPTIONS: PiExportOptions = { sendSessionAffinityHeaders: true, foldDeveloperRole: true }; + /** Do not let provider-controlled catalog text become an environment lookup. */ function containsEnvInterpolation(value: string): boolean { return value.includes("${"); @@ -1162,7 +1197,7 @@ function buildOpencodeContribution(ctx: ExportContext): ManagedContribution { } function buildPiContribution(ctx: ExportContext): ManagedContribution { - const doc = buildPiClientConfig(ctx, true); + const doc = buildPiClientConfig(ctx, PI_EXPORT_OPTIONS); return singleFragment("pi", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } @@ -1254,7 +1289,7 @@ function buildAsideContribution(ctx: ExportContext): ManagedContribution { * the two would drift apart at the first refresh. */ function buildOmoContribution(ctx: ExportContext): ManagedContribution { - const doc = buildPiClientConfig(ctx, true); + const doc = buildPiClientConfig(ctx, PI_EXPORT_OPTIONS); return singleFragment("omo", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } @@ -1296,7 +1331,7 @@ export const EXPORT_CLIENTS: Record = { destination: env => piConfigPath(env), apiKeyEnv: "", exportHint: "Pi reads a non-secret placeholder from models.json; loopback needs no key.", - build: ctx => buildPiClientConfig(ctx, true), + build: ctx => buildPiClientConfig(ctx, PI_EXPORT_OPTIONS), format: "json", summarize: summarizePi, buildContribution: buildPiContribution, @@ -1479,7 +1514,7 @@ export const EXPORT_CLIENTS: Record = { destination: env => omoConfigPath(env), apiKeyEnv: "", exportHint: "omo reads a non-secret placeholder from models.json; loopback needs no key.", - build: ctx => buildPiClientConfig(ctx, true), + build: ctx => buildPiClientConfig(ctx, PI_EXPORT_OPTIONS), format: "json", summarize: summarizePi, buildContribution: buildOmoContribution, diff --git a/structure/decisions/ADR-5548-serialized-tool-call-content.md b/structure/decisions/ADR-5548-serialized-tool-call-content.md new file mode 100644 index 00000000000..b0677e485fe --- /dev/null +++ b/structure/decisions/ADR-5548-serialized-tool-call-content.md @@ -0,0 +1,12 @@ +# ADR-5548 — decision recorded under "Serialized tool-call content" + +- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#serialized-tool-call-content) + +## Decision record + +- Intent: Prevent a gateway's duplicated serialized tool-call markup from becoming visible assistant text or malformed executable input. +- Prior constraint: Chat content is user-visible and must otherwise stream without delay; structured tool-call arguments are upstream-owned bytes. +- Alternatives considered: Drop all tool-call-looking content, add a provider-specific switch, or reconcile serialized blocks with structured calls at the Chat adapter boundary. +- Choice: Hold only a possible complete markup block and suppress or repair it only when the function name and duplicated body agree with a structured call in the same response. +- Why: Agreement between both representations is deterministic and avoids changing ordinary commentary, mismatched markup, or unrelated providers' valid text. +- Consequences: Matching calls no longer appear twice; same-name/different-body examples remain visible; the small held region is translator-budgeted and emits heartbeats while held; terminal failures retain held text without dispatching tools; malformed concatenated arguments are repaired only for the exact duplicated wrapper shape. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a548a7eb842..4b37c35334d 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -46,7 +46,7 @@ only canonical Fable, Opus, or Sonnet labels after removing terminal controls; u | `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | -| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | +| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 2b50d3f084e..f595d2fff5c 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -317,6 +317,29 @@ lookalike hosts, and custom proxy paths fail validation. A model override replac merges the provider-wide default, keeping precedence deterministic. With no preference configured, the request body is byte-for-byte unchanged in this area and OpenRouter retains its default routing. +## Serialized tool-call content + +Some Chat gateways expose one model-produced call twice: as a complete +`…` content block and as a structured `tool_calls` +entry. `src/adapters/openai-chat/serialized-tool-call-content.ts` recognizes bare blocks at the +start of a line outside Markdown fences; inline, quoted and indented examples remain unchanged. +It holds a possible serialized block, resumes ordinary text delivery when the header cannot match, +and removes the block only when its function name and +freeform body match a structured call's parsed `input` in the same response. If the gateway also prefixes the structured call's JSON +arguments with the same freeform body, the adapter keeps the JSON suffix only when the block body, +prefix, and wrapper's `input` value all agree. Mismatched markup and arguments remain byte-exact. +Silent held-content frames emit adapter heartbeats. Terminal errors and transport read failures +drain all held text, including matching serialized blocks, because pending tools are not dispatched. +The held bytes use the shared translator budget. For a model opted into inline `` splitting, +reconciliation sees only the answer text the splitter emits. A reasoning event that arrives while +a block candidate is held waits behind it and is released in its original position, so event order +never changes and a duplicate is not exposed early; line and fence context carry across the +answer text on both sides of a think section. Streaming and buffered responses use the same +matching and repair rules; regression coverage enters through `/v1/responses` in +`tests/responses/responses-chat-tool-call-content.test.ts`. + +> Decision record: [ADR-5548](../decisions/ADR-5548-serialized-tool-call-content.md) + ## Kimi Coding Plan prompt-cache affinity The canonical `kimi` OAuth and `kimi-code` API-key presets opt into forwarding the internal diff --git a/tests/adapters/openai/openai-chat-sanitization-review-regressions.test.ts b/tests/adapters/openai/openai-chat-sanitization-review-regressions.test.ts new file mode 100644 index 00000000000..e0d5e772ec7 --- /dev/null +++ b/tests/adapters/openai/openai-chat-sanitization-review-regressions.test.ts @@ -0,0 +1,338 @@ +import { expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import type { AdapterEvent, OcxParsedRequest, OcxTool } from "../../../src/types"; +import { namespacedToolName } from "../../../src/types/tools"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +const provider = { + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "fixture-key", +} as const; + +const frame = (delta: Record, finishReason?: string) => ({ + choices: [{ delta, ...(finishReason ? { finish_reason: finishReason } : {}) }], +}); +const sse = (value: unknown): string => `data: ${JSON.stringify(value)}\n\n`; +const textOf = (events: AdapterEvent[]): string => events + .filter((event): event is Extract => event.type === "text_delta") + .map(event => event.text).join(""); +const argsOf = (events: AdapterEvent[]): string => events + .filter((event): event is Extract => event.type === "tool_call_delta") + .map(event => event.arguments).join(""); +const tool = (input: string) => ({ + index: 0, + id: "call_exec", + function: { name: "exec", arguments: input }, +}); +const block = (name: string, body: string): string => + `${body}\n`; + +async function buffered(content: string, argumentsText: string): Promise { + return createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { content, tool_calls: [tool(argumentsText)] }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); +} + +async function streamed(frames: unknown[], done = true): Promise { + const response = new Response(frames.map(sse).join("") + (done ? "data: [DONE]\n\n" : "")); + const result: AdapterEvent[] = []; + for await (const event of createOpenAIChatAdapter(provider).parseStream!(response, createTestTranslatorBudget())) { + result.push(event); + } + return result; +} + +test("review control: reported duplicated input is repaired in buffered and streaming modes", async () => { + const script = "text('ok');"; + const argumentsText = JSON.stringify({ input: script }); + const content = "Running it.\n" + block("exec", script); + const bufferedEvents = await buffered(content, script + argumentsText); + const streamingEvents = await streamed([ + frame({ content }), + frame({ tool_calls: [tool(script + argumentsText)] }), + frame({}, "tool_calls"), + ]); + for (const events of [bufferedEvents, streamingEvents]) { + expect(textOf(events)).toBe("Running it.\n"); + expect(argsOf(events)).toBe(argumentsText); + expect(events.some(event => event.type === "error")).toBe(false); + } +}); + +test("review P1: held content frames must still yield adapter activity", async () => { + const script = "text('ok');"; + const chunks = ["Running it.\n", "", "", script, ""]; + let controller!: ReadableStreamDefaultController; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ start(value) { controller = value; } }); + const events: AdapterEvent[] = []; + const progressByChunk: boolean[] = []; + const pump = (async () => { + for await (const event of createOpenAIChatAdapter(provider).parseStream!(new Response(stream), createTestTranslatorBudget())) { + events.push(event); + } + })(); + + try { + for (const content of chunks) { + const before = events.length; + controller.enqueue(encoder.encode(sse(frame({ content })))); + await new Promise(resolve => setTimeout(resolve, 0)); + progressByChunk.push(events.slice(before).some(event => + event.type === "text_delta" || event.type === "heartbeat")); + } + controller.enqueue(encoder.encode( + sse(frame({ tool_calls: [tool(JSON.stringify({ input: script }))] })) + + sse(frame({}, "tool_calls")) + + "data: [DONE]\n\n", + )); + } finally { + controller.close(); + await pump; + } + expect(textOf(events)).toBe("Running it.\n"); + expect(progressByChunk).toEqual(chunks.map(() => true)); +}); + +test("review P2: a same-name different-body fenced example is not a duplicate", async () => { + const example = "Example only:\n```xml\ntext('example');\n```\nActual call follows."; + const argumentsText = JSON.stringify({ input: "text('actual');" }); + const bufferedEvents = await buffered(example, argumentsText); + const streamingEvents = await streamed([ + frame({ content: example }), + frame({ tool_calls: [tool(argumentsText)] }), + frame({}, "tool_calls"), + ]); + for (const events of [bufferedEvents, streamingEvents]) { + expect(textOf(events)).toBe(example); + expect(argsOf(events)).toBe(argumentsText); + } +}); + +test("review P2: previously received nonduplicate text survives a terminal upstream error", async () => { + const chunks = ["Explanation: ", "", " is an XML-like marker. This is ordinary text."]; + const events = await streamed([ + ...chunks.map(content => frame({ content })), + { error: { message: "fixture error" } }, + ], false); + expect(events.some(event => event.type === "error")).toBe(true); + expect(events.some(event => event.type === "tool_call_start")).toBe(false); + expect(events.some(event => event.type === "done")).toBe(false); + expect(textOf(events)).toBe(chunks.join("")); +}); + +test("review control: unmatched marker content survives normal completion", async () => { + const content = "Explanation: is just a literal marker."; + const events = await streamed([frame({ content }), frame({}, "stop")]); + expect(textOf(events)).toBe(content); + expect(events.some(event => event.type === "done")).toBe(true); +}); + +test("only the matching block is removed when several blocks use the same name", async () => { + const example = block("exec", "text('example');"); + const actual = block("exec", "text('actual');"); + const content = `Example:\n${example}\nActual:\n${actual}`; + const argumentsText = JSON.stringify({ input: "text('actual');" }); + const expected = `Example:\n${example}\nActual:\n`; + for (const events of [ + await buffered(content, argumentsText), + await streamed([frame({ content }), frame({ tool_calls: [tool(argumentsText)] }), frame({}, "tool_calls")]), + ]) { + expect(textOf(events)).toBe(expected); + expect(argsOf(events)).toBe(argumentsText); + } +}); + +test("held ordinary text survives malformed SSE and pending-call truncation", async () => { + const prefix = "Explanation: is ordinary text."; + const malformedEvents: AdapterEvent[] = []; + const malformed = new Response(sse(frame({ content: prefix })) + "data: {bad json\n\n"); + for await (const event of createOpenAIChatAdapter(provider).parseStream!(malformed, createTestTranslatorBudget())) { + malformedEvents.push(event); + } + expect(textOf(malformedEvents)).toBe(prefix); + expect(malformedEvents.some(event => event.type === "error")).toBe(true); + + const truncatedEvents = await streamed([ + frame({ content: prefix }), + frame({ tool_calls: [tool('{"input":"unfinished"')] }), + ], false); + expect(textOf(truncatedEvents)).toBe(prefix); + expect(truncatedEvents.some(event => event.type === "error")).toBe(true); + expect(truncatedEvents.some(event => event.type === "tool_call_start")).toBe(false); +}); + +test.each([ + ["upstream error", sse({ error: { message: "fixture error" } })], + ["error finish reason", sse({ choices: [{ finish_reason: "error", error: { message: "fixture error" } }] })], + ["malformed SSE", "data: {bad json\n\n"], + ["invalid choices", sse({ choices: {} })], + ["invalid tool calls", sse(frame({ tool_calls: {} }))], + ["truncated stream", ""], +])("terminal %s retains serialized text without dispatching its pending tool", async (_, terminal) => { + const script = "text('ok');"; + const argumentsText = JSON.stringify({ input: script }); + const content = "Running it.\n" + block("exec", script); + const response = new Response(sse(frame({ content })) + + sse(frame({ tool_calls: [tool(argumentsText)] })) + terminal); + const events: AdapterEvent[] = []; + for await (const event of createOpenAIChatAdapter(provider).parseStream!(response, createTestTranslatorBudget())) events.push(event); + expect(textOf(events)).toBe(content); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(event => event.type === "error")).toBe(true); + expect(events.some(event => event.type.startsWith("tool_call_") || event.type === "done")).toBe(false); +}); + +test("an unnamed pending call cannot hide text for a later undispatched call", async () => { + const script = "text('ok');"; + const content = block("exec", script); + const events = await streamed([ + frame({ content }), + frame({ tool_calls: [ + { index: 0, id: "unnamed", function: { arguments: "{}" } }, + { ...tool(JSON.stringify({ input: script })), index: 1 }, + ] }), + ]); + expect(textOf(events)).toBe(content); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(event => event.type.startsWith("tool_call_") || event.type === "done")).toBe(false); +}); + +test("tolerant EOF evaluates narrowly repaired arguments before rejecting the call", async () => { + const script = "text('ok');"; + const argumentsText = JSON.stringify({ input: script }); + const tolerantProvider = { ...provider, openaiChatEofTolerance: true }; + const response = new Response( + sse(frame({ content: block("exec", script) })) + + sse(frame({ tool_calls: [tool(script + argumentsText)] })), + ); + const events: AdapterEvent[] = []; + for await (const event of createOpenAIChatAdapter(tolerantProvider).parseStream!(response, createTestTranslatorBudget())) { + events.push(event); + } + expect(textOf(events)).toBe(""); + expect(argsOf(events)).toBe(argumentsText); + expect(events.some(event => event.type === "done")).toBe(true); + expect(events.some(event => event.type === "error")).toBe(false); +}); + +test("wire aliases use the restored identity for suppression and argument repair", async () => { + const namespace = "mcp__codex_apps__codex_document_control"; + const name = "execute_document_command"; + const originalName = namespacedToolName(namespace, name); + const declared: OcxTool = { namespace, name, description: "fixture", parameters: { type: "object" } }; + const parsed: OcxParsedRequest = { + modelId: "test-model", + stream: false, + options: {}, + context: { tools: [declared], messages: [{ role: "user", content: "run", timestamp: 0 }] }, + }; + const adapter = createOpenAIChatAdapter(provider); + const request = adapter.buildRequest(parsed, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }); + if (request instanceof Promise) throw new Error("unexpected async request"); + const alias = (JSON.parse(request.body) as { tools: Array<{ function: { name: string } }> }).tools[0]!.function.name; + expect(alias).not.toBe(originalName); + + const script = "text('ok');"; + const argumentsText = JSON.stringify({ input: script }); + const events = await adapter.parseResponse!(Response.json({ + choices: [{ + message: { content: block(originalName, script), tool_calls: [{ ...tool(script + argumentsText), function: { name: alias, arguments: script + argumentsText } }] }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + expect(textOf(events)).toBe(""); + expect(argsOf(events)).toBe(argumentsText); +}); + +test("ambiguous raw closing delimiters are preserved instead of partially suppressed", async () => { + const script = "text('');"; + const content = block("exec", script); + const argumentsText = script + JSON.stringify({ input: script }); + const events = await buffered(content, argumentsText); + expect(textOf(events)).toBe(content); + expect(argsOf(events)).toBe(argumentsText); +}); + +test("quoted and fenced copies remain visible beside an identical actual call, at every split", async () => { + const script = "text('ok');"; + const actual = block("exec", script); + const argumentsText = JSON.stringify({ input: script }); + for (const example of [ + `Example: \`${actual}\`\n`, + `Example: \`\`\n${actual}\n\`\`\n`, + `> ${actual}\n`, + `\`\`\`xml\n${actual}\n\`\`\`\n`, + ` ~~~~xml\n${actual}\n~~~\n${actual}\n~~~~\n`, + ]) { + const content = example + actual; + expect(textOf(await buffered(content, argumentsText))).toBe(example); + for (let split = 0; split <= content.length; split++) { + const events = await streamed([ + frame({ content: content.slice(0, split) }), + frame({ content: content.slice(split) }), + frame({ tool_calls: [tool(argumentsText)] }), + frame({}, "tool_calls"), + ]); + expect(textOf(events)).toBe(example); + expect(argsOf(events)).toBe(argumentsText); + } + } +}); + +test("a quoted example cannot authorize argument repair", async () => { + const script = "text('ok');"; + const content = `\`\`\`xml\n${block("exec", script)}\n\`\`\``; + const argumentsText = script + JSON.stringify({ input: script }); + for (const events of [ + await buffered(content, argumentsText), + await streamed([frame({ content }), frame({ tool_calls: [tool(argumentsText)] }), frame({}, "tool_calls")]), + ]) { + expect(textOf(events)).toBe(content); + expect(argsOf(events)).toBe(argumentsText); + } +}); + +test("literal markers resume text delivery before the terminal frame", async () => { + const chunks = ["", " is an XML-like marker.", " More ordinary text."]; + const response = new Response(chunks.map(content => sse(frame({ content }))).join("") + sse(frame({}, "stop"))); + const iterator = createOpenAIChatAdapter(provider).parseStream!(response, createTestTranslatorBudget()); + try { + expect((await iterator.next()).value).toEqual({ type: "heartbeat" }); + expect((await iterator.next()).value).toEqual({ type: "text_delta", text: chunks[0]! + chunks[1]! }); + expect((await iterator.next()).value).toEqual({ type: "text_delta", text: chunks[2]! }); + } finally { + await iterator.return(); + } +}); + +test.each([false, true])("transport read failures retain held text with a pending matching call: %s", async (pendingCall) => { + const script = "text('ok');"; + const content = block("exec", script); + const failure = new Error("fixture read failure"); + let sent = false; + const response = new Response(new ReadableStream({ + pull(controller) { + if (sent) controller.error(failure); + else { + sent = true; + controller.enqueue(new TextEncoder().encode(sse(frame({ content })) + + (pendingCall ? sse(frame({ tool_calls: [tool(JSON.stringify({ input: script }))] })) : ""))); + } + }, + })); + const events: AdapterEvent[] = []; + let caught: unknown; + try { + for await (const event of createOpenAIChatAdapter(provider).parseStream!(response, createTestTranslatorBudget())) events.push(event); + } catch (error) { + caught = error; + } + expect(caught).toBe(failure); + expect(textOf(events)).toBe(content); + expect(events.some(event => event.type === "tool_call_start" || event.type === "done")).toBe(false); +}); diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts new file mode 100644 index 00000000000..e1e64287dfb --- /dev/null +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { SerializedToolCallContentBuffer } from "../../../src/adapters/openai-chat/serialized-tool-call-content"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +const provider = { adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", apiKey: "key" } as const; + +test("buffered Chat responses reconcile matching serialized and structured tool calls", async () => { + const script = "text('ok');"; + const content = `Running it.\n${script}\n`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content, + tool_calls: [{ + id: "call_exec", + function: { name: "exec", arguments: script + JSON.stringify({ input: script }) }, + }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([ + { type: "text_delta", text: "Running it.\n" }, + ]); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", + arguments: JSON.stringify({ input: script }), + }); +}); + +test("buffered Chat responses preserve serialized markup for a different function", async () => { + const content = "literal example"; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content, + tool_calls: [{ id: "call_exec", function: { name: "exec", arguments: "{}" } }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.find(event => event.type === "text_delta")).toEqual({ type: "text_delta", text: content }); +}); + +test("an open serialized block charges only its appended bytes", () => { + const open = ""; + const body = "x".repeat(open.length); + // Rebuilding the whole buffer would reserve the new total beside the retained + // text, so this exact budget only admits the append when it charges the delta. + const budget = createTestTranslatorBudget({ maxTurnBytes: open.length + body.length }); + const buffer = new SerializedToolCallContentBuffer(budget); + + expect(buffer.ingest(open)).toBe(""); + expect(buffer.ingest(body)).toBe(""); + expect(buffer.current()).toBe(open + body); + expect(budget.snapshot()).toMatchObject({ currentBytes: open.length + body.length, overflows: 0 }); + + expect(buffer.flush([])).toBe(open + body); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, overflows: 0 }); +}); diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-think.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-think.test.ts new file mode 100644 index 00000000000..4a65bc199eb --- /dev/null +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-think.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { SerializedToolCallContentBuffer } from "../../../src/adapters/openai-chat/serialized-tool-call-content"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "../../helpers/translator-budget"; + +/** + * Serialized tool-call reconciliation (#5548) composed with inline splitting: the + * reconciler only ever sees answer text, so reasoning is never held or stripped, and a block + * whose input differs from the structured call stays visible byte for byte. + */ +const MODEL = "mimo-v2.6-flash"; +const SCRIPT = "text('ok');"; +const block = (body: string): string => `${body}\n`; +const call = (input: string) => ({ index: 0, id: "call_exec", function: { name: "exec", arguments: JSON.stringify({ input }) } }); + +function adapterFor(inlineThink: boolean) { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://gateway.example.test/v1", + apiKey: "key", + ...(inlineThink ? { inlineThinkTagModels: [MODEL] } : {}), + }; + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + const parsed: OcxParsedRequest = { + modelId: MODEL, + stream: true, + options: {}, + context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] }, + }; + adapter.buildRequest(parsed); + return adapter; +} + +async function streamed(inlineThink: boolean, content: string[], input: string): Promise { + const frames = [ + ...content.map(text => ({ choices: [{ delta: { content: text } }] })), + { choices: [{ delta: { tool_calls: [call(input)] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapterFor(inlineThink).parseStream(new Response(body))) { + if (event.type !== "heartbeat") events.push(event); + } + return events; +} + +async function buffered(inlineThink: boolean, content: string, input: string): Promise { + return adapterFor(inlineThink).parseResponse!(Response.json({ + choices: [{ message: { content, tool_calls: [call(input)] }, finish_reason: "tool_calls" }], + })); +} + +const joined = (events: AdapterEvent[], type: "text_delta" | "reasoning_raw_delta"): string => events + .filter((event): event is Extract => event.type === type) + .map(event => event.text) + .join(""); +const argsOf = (events: AdapterEvent[]): string => events + .filter((event): event is Extract => event.type === "tool_call_delta") + .map(event => event.arguments) + .join(""); +const toolStarts = (events: AdapterEvent[]): number => events.filter(event => event.type === "tool_call_start").length; + +describe("serialized tool-call reconciliation behind inline splitting", () => { + test("a duplicated block after a think block is removed while reasoning is kept, streamed and buffered", async () => { + const content = "plan the callRunning it.\n" + block(SCRIPT); + const runs = [ + await streamed(true, ["plan the ", "callRunning it.\n", block(SCRIPT)], SCRIPT), + await buffered(true, content, SCRIPT), + ]; + for (const events of runs) { + expect(joined(events, "reasoning_raw_delta")).toBe("plan the call"); + expect(joined(events, "text_delta")).toBe("Running it.\n"); + expect(argsOf(events)).toBe(JSON.stringify({ input: SCRIPT })); + expect(toolStarts(events)).toBe(1); + expect(events.some(event => event.type === "error")).toBe(false); + const reasoningAt = events.findIndex(event => event.type === "reasoning_raw_delta"); + const textAt = events.findIndex(event => event.type === "text_delta"); + expect(reasoningAt).toBeGreaterThanOrEqual(0); + expect(reasoningAt).toBeLessThan(textAt); + } + }); + + test("a block whose input differs from the structured call stays visible byte for byte", async () => { + const shown = "Example:\n" + block("text('example');"); + const runs = [ + await streamed(true, ["x", shown], SCRIPT), + await buffered(true, "x" + shown, SCRIPT), + await streamed(false, [shown], SCRIPT), + await buffered(false, shown, SCRIPT), + ]; + for (const events of runs) { + expect(joined(events, "text_delta")).toBe(shown); + expect(argsOf(events)).toBe(JSON.stringify({ input: SCRIPT })); + expect(toolStarts(events)).toBe(1); + } + }); + + test("without the inline-think opt-in a literal think tag stays answer text and the duplicate is still removed", async () => { + const lead = "not parsed hereRunning it.\n"; + for (const events of [await streamed(false, [lead, block(SCRIPT)], SCRIPT), await buffered(false, lead + block(SCRIPT), SCRIPT)]) { + expect(joined(events, "reasoning_raw_delta")).toBe(""); + expect(joined(events, "text_delta")).toBe(lead); + expect(toolStarts(events)).toBe(1); + } + }); +}); + + +describe("review folds: order and line context across interleaved think sections", () => { + test("a block that follows visible prose on the same line is kept even when a think section sits between them", async () => { + const content = "r1prefix r2" + block(SCRIPT); + const runs = [ + await buffered(true, content, SCRIPT), + await streamed(true, ["r1prefix ", "r2", block(SCRIPT)], SCRIPT), + ]; + for (const events of runs) { + expect(joined(events, "reasoning_raw_delta")).toBe("r1r2"); + expect(joined(events, "text_delta")).toBe("prefix " + block(SCRIPT)); + expect(toolStarts(events)).toBe(1); + } + }); + + test("held text is released before later reasoning, so events keep their original order", async () => { + const shown = "\n" + block("text('example');"); + const runs = [ + await streamed(true, ["first", shown, "second", "answer"], SCRIPT), + await buffered(true, "first" + shown + "secondanswer", SCRIPT), + ]; + for (const events of runs) { + const order = events + .filter(event => event.type === "text_delta" || event.type === "reasoning_raw_delta") + .map(event => (event.type === "text_delta" ? "T:" : "R:") + (event as { text: string }).text); + const firstShown = order.findIndex(item => item.startsWith("T:") && item.includes("")); + const secondReasoning = order.findIndex(item => item.startsWith("R:") && item.includes("second")); + expect(order[0]).toBe("R:first"); + expect(firstShown).toBeGreaterThan(0); + expect(firstShown).toBeLessThan(secondReasoning); + expect(joined(events, "text_delta")).toBe(shown + "answer"); + } + }); +}); + + +describe("review folds, round two", () => { + test("a duplicate between two think sections stays suppressed when its structured call arrives later", async () => { + const runs = [ + await streamed(true, ["r1", block(SCRIPT), "r2"], SCRIPT), + await buffered(true, "r1" + block(SCRIPT) + "r2", SCRIPT), + ]; + for (const events of runs) { + expect(joined(events, "text_delta")).not.toContain(""); + const reasoning = events.filter(event => event.type === "reasoning_raw_delta").map(event => (event as { text: string }).text); + expect(reasoning.join("")).toBe("r1r2"); + expect(toolStarts(events)).toBe(1); + expect(argsOf(events)).toBe(JSON.stringify({ input: SCRIPT })); + } + }); + + test("after a drain, a second block on the same visible line is not treated as line-start markup", () => { + const buffer = new SerializedToolCallContentBuffer(createTestTranslatorBudget()); + try { + const first = block("text('a');"); + expect(buffer.ingest(first)).toBe(""); + expect(buffer.flush([])).toBe(first); + const second = block(SCRIPT); + // Mid-line after the first block, so it is ordinary text and cannot be held or removed. + expect(buffer.ingest(second)).toBe(second); + const names = new Set(["exec"]); + expect(buffer.flush([{ names, argumentsText: JSON.stringify({ input: SCRIPT }) }])).toBe(""); + } finally { + buffer.dispose(); + } + }); +}); + + +describe("review folds, round three", () => { + test("every reasoning frame that arrives behind a held block still yields adapter activity", async () => { + const frames = [ + { choices: [{ delta: { content: "" + SCRIPT } }] }, + { choices: [{ delta: { reasoning_content: "one" } }] }, + { choices: [{ delta: { reasoning_content: "two" } }] }, + { choices: [{ delta: { content: "\n" } }] }, + { choices: [{ delta: { tool_calls: [call(SCRIPT)] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => "data: " + JSON.stringify(frame) + "\n\n").join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapterFor(false).parseStream(new Response(body))) events.push(event); + const heartbeats = events.filter(event => event.type === "heartbeat").length; + // One for the held content frame, one per queued reasoning frame, one for the closing content frame. + expect(heartbeats).toBeGreaterThanOrEqual(4); + expect(joined(events, "reasoning_raw_delta")).toBe("onetwo"); + expect(joined(events, "text_delta")).toBe(""); + expect(toolStarts(events)).toBe(1); + }); +}); diff --git a/tests/clients/omo-client.test.ts b/tests/clients/omo-client.test.ts index aac1cffbcb7..bab9af682c1 100644 --- a/tests/clients/omo-client.test.ts +++ b/tests/clients/omo-client.test.ts @@ -43,14 +43,15 @@ describe("omo client config", () => { * Prime and Aside reuse Pi's builder with the session-affinity flag left at * its default, because nobody has verified that their engines read it. omo's * engine WAS verified: senpi's compiled validator accepts `compat` with - * `sendSessionAffinityHeaders`, so omo opts in and the generated provider is + * `sendSessionAffinityHeaders`, and senpi documents `supportsDeveloperRole` + * in the same block (#5664), so omo opts in and the generated provider is * byte-identical to Pi's. */ test("is Pi's document including the session-affinity opt-in", () => { const omo = buildClientConfig("omo", context()) as PiGeneratedConfig; const pi = buildClientConfig("pi", context()) as PiGeneratedConfig; expect(omo).toEqual(pi); - expect(omo.providers[OPENCODE_PROVIDER_ID]!.compat).toEqual({ sendSessionAffinityHeaders: true }); + expect(omo.providers[OPENCODE_PROVIDER_ID]!.compat).toEqual({ sendSessionAffinityHeaders: true, supportsDeveloperRole: false }); }); /** diff --git a/tests/clients/prime-client.test.ts b/tests/clients/prime-client.test.ts index 6c7c0a2f76e..eaae451d172 100644 --- a/tests/clients/prime-client.test.ts +++ b/tests/clients/prime-client.test.ts @@ -40,7 +40,7 @@ describe("Prime Agent client config", () => { test("shares Pi's model contract without opting Prime into session headers", () => { const prime = buildClientConfig("prime", context()) as PiGeneratedConfig; const pi = buildClientConfig("pi", context()) as PiGeneratedConfig; - expect(pi.providers[OPENCODE_PROVIDER_ID]!.compat).toEqual({ sendSessionAffinityHeaders: true }); + expect(pi.providers[OPENCODE_PROVIDER_ID]!.compat).toEqual({ sendSessionAffinityHeaders: true, supportsDeveloperRole: false }); delete pi.providers[OPENCODE_PROVIDER_ID]!.compat; expect(prime).toEqual(pi); expect(buildClientContribution("prime", context()).fragments[0]!.value) diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 1884dc941c3..332f6b19d08 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -320,6 +320,17 @@ describe("Pi serializer (accept criterion 2)", () => { expect(buildClientContribution("pi", ctx()).fragments[0]!.value).toEqual(provider); }); + test("tells Pi to send system instead of developer, on export and contribution alike (#5664)", () => { + // Pi sends `developer` for reasoning models; the native Chat route forwards roles verbatim, + // and upstreams such as DashScope compatible-mode reject that role with a 400. + expect(piConfig().providers.opencodex!.compat).toEqual({ + sendSessionAffinityHeaders: true, + supportsDeveloperRole: false, + }); + expect(buildClientContribution("pi", ctx()).fragments[0]!.value) + .toHaveProperty("compat.supportsDeveloperRole", false); + }); + test("cost is omitted on every entry — zeros would assert routed models are free", () => { for (const model of piConfig().providers.opencodex!.models) { expect(model).not.toHaveProperty("cost"); @@ -941,7 +952,8 @@ describe("EXPORT_CLIENTS registry", () => { "api": "openai-completions", "apiKey": "opencodex-loopback", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 4b88544f944..d4065d6db0c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -994,7 +994,11 @@ "openai-chat-parallel-stream.test.ts": "adapters/openai", "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-reasoning-wire-policy.test.ts": "adapters/openai", + "openai-chat-sanitization-review-regressions.test.ts": "adapters/openai", + "openai-chat-serialized-tool-call-content.test.ts": "adapters/openai", + "openai-chat-serialized-tool-call-think.test.ts": "adapters/openai", "openai-chat-system-order.test.ts": "adapters/openai", + "responses-chat-tool-call-content.test.ts": "responses", "openai-chat-tool-result-images.test.ts": "adapters/openai", "openai-chat-url.test.ts": "adapters/openai", "openai-chat-video-part.test.ts": "adapters/openai", diff --git a/tests/responses/responses-chat-tool-call-content.test.ts b/tests/responses/responses-chat-tool-call-content.test.ts new file mode 100644 index 00000000000..c9b876a184c --- /dev/null +++ b/tests/responses/responses-chat-tool-call-content.test.ts @@ -0,0 +1,88 @@ +import { afterEach, expect, test } from "bun:test"; +import { handleResponses } from "../../src/server/responses"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +let releaseSpendHome: (() => void) | undefined; + +afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; +}); + +test("/v1/responses suppresses OpenAI Chat tool-call markup duplicated by a structured call", async () => { + const savedFetch = globalThis.fetch; + const script = "const result = await tools.exec_command({cmd: \"pwd\"});\ntext(result.output);"; + const leaked = `${script}\n`; + const commentary = "I'll run it now.\n"; + const content = commentary + leaked; + const split = commentary.length + 5; + const frames = [ + { choices: [{ delta: { content: content.slice(0, split) } }] }, + { choices: [{ delta: { content: content.slice(split) } }] }, + { + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id: "call_exec", + function: { name: "exec", arguments: script + JSON.stringify({ input: script }) }, + }], + }, + }], + }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ].map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + + globalThis.fetch = (async () => new Response(frames, { + headers: { "content-type": "text/event-stream" }, + })) as typeof fetch; + + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + try { + releaseSpendHome = acquireOwnedSpendHome(); + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/xiaomi-mimo-v2.6-pro", + stream: true, + input: "run pwd", + tools: [{ + type: "custom", + name: "exec", + description: "Run JavaScript", + format: { type: "grammar", syntax: "lark" }, + }], + }), + }), config, { model: "", provider: "" }); + const body = await response.text(); + const payloads = body.split("\n") + .filter(line => line.startsWith("data: {") && line !== "data: [DONE]") + .map(line => JSON.parse(line.slice("data: ".length)) as Record); + const inputDone = payloads.find(payload => payload.type === "response.custom_tool_call_input.done"); + const outputText = payloads + .filter(payload => payload.type === "response.output_text.delta") + .map(payload => payload.delta) + .join(""); + + expect(response.status).toBe(200); + expect(body).toContain('"type":"custom_tool_call"'); + expect(inputDone).toMatchObject({ input: script }); + expect(outputText).toBe(commentary); + expect(body).not.toContain(""); + } finally { + globalThis.fetch = savedFetch; + } +}); From 052e1d71d716048eb871a86f176e50c3e9783986 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 19:41:35 +0900 Subject: [PATCH 05/48] devlog: record the 2.64 release round (#5677) Record the verified preview and stable releases, the promoted candidate, exact CI run evidence, the dependency and routing fixes, and the remaining medium desktop dependency alerts. --- devlog/_fin/260923_release_2_64/000_plan.md | 68 ++++++++++ .../010_wp2_prerelease_items.md | 86 +++++++++++++ .../011_wp2_privacy_gate_complement.md | 85 +++++++++++++ .../012_wp2_request_owned_main_cursor.md | 97 +++++++++++++++ .../020_wp3_dev_candidate.md | 41 +++++++ .../260923_release_2_64/030_wp4_release.md | 116 ++++++++++++++++++ devlog/_fin/260923_release_2_64/050_done.md | 67 ++++++++++ 7 files changed, 560 insertions(+) create mode 100644 devlog/_fin/260923_release_2_64/000_plan.md create mode 100644 devlog/_fin/260923_release_2_64/010_wp2_prerelease_items.md create mode 100644 devlog/_fin/260923_release_2_64/011_wp2_privacy_gate_complement.md create mode 100644 devlog/_fin/260923_release_2_64/012_wp2_request_owned_main_cursor.md create mode 100644 devlog/_fin/260923_release_2_64/020_wp3_dev_candidate.md create mode 100644 devlog/_fin/260923_release_2_64/030_wp4_release.md create mode 100644 devlog/_fin/260923_release_2_64/050_done.md diff --git a/devlog/_fin/260923_release_2_64/000_plan.md b/devlog/_fin/260923_release_2_64/000_plan.md new file mode 100644 index 00000000000..579ec410cb8 --- /dev/null +++ b/devlog/_fin/260923_release_2_64/000_plan.md @@ -0,0 +1,68 @@ +# 260923 release 2.64 — plan + +## Objective + +Ship the verified `dev` tree as preview `2.64.0-preview.20260923` and stable `2.64.0` +after closing the two items that kept the previous readiness answer at "not yet": + +1. The critical Dependabot alert on `desktop/src-tauri` (GHSA-c9pr-q8gx-3mgp, + `tauri-plugin-shell` below 2.2.1). +2. Missing security-review records for the CI, release and account-routing changes + merged by the parallel batch (#5471, #5456, #5653, #5469, #5024, #5654, #5655). + +The owner authorized PR creation, admin squash merges to `dev`, promotion merges to +`main` and `preview`, and release dispatch for this round. + +## Starting state (2026-09-23 07:50Z) + +| Ref | Commit | Version | Evidence | +|---|---|---|---| +| `dev` | `fa81e5a2a7` | 2.64.0 in all four version sources | lane=all run 35828289232, every job success, privacy gate skipped by design | +| `main` | `96b1406cb6` | 2.63.0 | npm `latest`, release v2.63.0 | +| `preview` | `5bec58cdda` | 2.63.0-preview.20260923 | npm `preview` | +| Dependabot #5525 | `d6dea8f246` (base `main`) | tauri-plugin-shell =2.2.1 | applies to `dev` cleanly, merge tree `11d41e0b70` | + +Open Dependabot alerts on `desktop/src-tauri/Cargo.lock`: critical `tauri-plugin-shell`, +medium `serde_with`, `time`, `glib`. Only the critical one is in scope. `glib` 0.20 +needs a GTK binding upgrade that the pinned Tauri line does not take; `serde_with` and +`time` are transitive and are recorded as residuals for a later dependency round. + +## Constraints + +- No local test, typecheck, build, install, cargo or ocx run. Hosted CI at the exact + head is the only execution evidence. Helper scripts that read state (version-source + check, merge-tree, gh reads) or rewrite the four version sources + (`release-version-sources.ts sync`) are allowed; neither executes the product. +- Skipped, cancelled, missing or older-head results are not success. A Windows job that + fails once on a known runner stall is rerun once; a repeat is a defect. +- No timeout increase, platform skip, weakened assertion, or ratchet cap raise. +- Security analysis stays in scratch space outside this repository's tracked tree. This + unit records only that each review happened and how findings were dispositioned. +- Pushes use `--no-verify`; merges use `--admin` with `--match-head-commit`. + +## Work-phase map (dependency order) + +| Phase | Doc | Consumes | Produces | +|---|---|---|---| +| wp1 | this unit | current state | locked roadmap | +| wp2 | [010](010_wp2_prerelease_items.md), [011](011_wp2_privacy_gate_complement.md), [012](012_wp2_request_owned_main_cursor.md) | wp1 | `tauri-plugin-shell` 2.2.1 on `dev`; review records; fixes for the two confirmed findings | +| wp3 | [020](020_wp3_dev_candidate.md) | wp2's final `dev` SHA | fixed candidate SHA with a fully green lane=all run | +| wp4 | [030](030_wp4_release.md) | wp3's candidate | dev pre-move, promotions, both releases, channel verification | + +Each phase closes with something checkable from GitHub alone: a merged PR with its +exact-head run, a dev run ID, release run IDs and registry state. + +## Verifiers + +| Script (scratch) | Reads | Proves | +|---|---|---| +| `check-wp1.sh` | this unit | numbered docs, no private review detail, no absolute user paths | +| `check-wp2.sh` | PRs, `origin/dev`, Dependabot API | the three wp2 PRs merged at their verified heads with green exact-head runs, `Cargo.toml` pins `=2.2.1` on `dev`, review and second-review reports present | +| `check-wp3.sh` | the candidate run | every job `success` except the privacy gate skip, run head equals candidate | +| `check-wp4.sh` | npm registry, GitHub releases, `latest.json` | channel versions, release assets, updater signatures | + +## Terminal outcomes + +DONE when wp4's verification passes. BLOCKED on a confirmed security blocker that +cannot be fixed inside this round or on a repeated CI defect. UNSAFE if a release gate +would have to be bypassed. NEEDS_HUMAN on a policy decision this plan does not cover. diff --git a/devlog/_fin/260923_release_2_64/010_wp2_prerelease_items.md b/devlog/_fin/260923_release_2_64/010_wp2_prerelease_items.md new file mode 100644 index 00000000000..d5881da0d30 --- /dev/null +++ b/devlog/_fin/260923_release_2_64/010_wp2_prerelease_items.md @@ -0,0 +1,86 @@ +# 010 — wp2: pre-release items + +## A. tauri-plugin-shell 2.2.1 on dev + +Dependabot opened #5525 against `main`, the default branch. `dev` is the integration +branch, so the same commit is carried to `dev` and reaches `main` through promotion. + +Branch `codex/260923-tauri-plugin-shell-2.2.1` from `origin/dev` in a scratch worktree: + +```bash +git fetch origin pull/5525/head:refs/remotes/origin/pr-5525 +git switch -c codex/260923-tauri-plugin-shell-2.2.1 origin/dev +git cherry-pick -x d6dea8f246944677c8ce80264c66095b562e3deb +``` + +Resulting diff (exactly two files, four lines): + +```diff +--- a/desktop/src-tauri/Cargo.toml ++++ b/desktop/src-tauri/Cargo.toml +-tauri-plugin-shell = "=2.2.0" ++tauri-plugin-shell = "=2.2.1" +--- a/desktop/src-tauri/Cargo.lock ++++ b/desktop/src-tauri/Cargo.lock + name = "tauri-plugin-shell" +-version = "2.2.0" ++version = "2.2.1" + source = "registry+https://github.com/rust-lang/crates.io-index" +-checksum = "bb2c50a63e60fb8925956cc5b7569f4b750ac197a4d39f13b8dd46ea8e2bad79" ++checksum = "69d5eb3368b959937ad2aeaf6ef9a8f5d11e01ffe03629d3530707bbcb27ff5d" +``` + +The lock hunk was produced by the dependency tool, not by hand, and the dependency list +of the package is unchanged, so no other lock entry moves. + +PR to `dev`, filled from the repository template, with a `Co-authored-by` trailer for +the Dependabot author because the description names the carried PR. Push with +`--no-verify`, then dispatch the full lane on the PR branch: + +```bash +gh workflow run ci.yml --ref codex/260923-tauri-plugin-shell-2.2.1 -f lane=all +``` + +A pull-request event alone would also run `desktop shell` (`desktop/**` matches both the +`ci` and `native` filters in `.github/workflows/ci.yml`), but the dispatched lane=all run +also builds the macOS bundle and the widget, which link the same crate graph. + +Acceptance: + +- `desktop shell` (`cargo fmt --check`, `cargo clippy -D warnings`, `cargo test`), + `platform-macos` and `widget` jobs succeed at the exact PR head in the lane=all run, + and the `ci` aggregate succeeds. +- Merge with `gh pr merge --admin --squash --match-head-commit ` after a clean + `git merge-tree` against the current `origin/dev`. +- After merge, `git show origin/dev:desktop/src-tauri/Cargo.toml` pins `=2.2.1`. +- #5525 is closed with a note once `main` carries the bump (wp4), because Dependabot + targets `main` and would otherwise stay open. + +## B. Security reviews of the unreviewed batch + +`MAINTAINERS.md` asks for explicit security review of changes to GitHub Actions workflows, +release automation and credential handling. Seven merged PRs had no review record: + +| Review | PRs | Surface | +|---|---|---| +| S1 | #5471 | PR quality gate script run by a `pull_request_target` workflow | +| S2 | #5456, #5653 | Bun batch runner; `ci.yml` and `release.yml`, release preflight | +| S3 | #5469 | privacy-scan gating in `ci.yml` | +| S4 | #5024, #5654, #5655 | request-owned account routing; remote workspace helper protocol | + +Each review is read-only against the merged code on `dev` and is written to scratch +space, not to this unit. Disposition rules: + +- A blocker or major finding counts only after a second, independent reviewer reproduces + it from source (file and line, concrete trigger). A finding the second reviewer cannot + reproduce is rebutted with the reason recorded in scratch. The second reviewer also + states whether the batch introduced it and whether it reaches a release artifact. +- A confirmed blocker or major gets a focused fix PR to `dev`, designed at diff level in + its own numbered doc (011, 012, ...), reviewed the same way and merged at a green exact + head before the candidate is fixed in wp3. If a fix cannot be made inside this round, + the round stops as BLOCKED rather than releasing. Once a fix has shipped, its doc is the + public record; until then the doc describes only the change and its tests. +- Minor and informational findings are recorded for follow-up and do not gate the release. + +This unit's D summary states only which reviews ran and whether any finding gated the +release; details of an unfixed weakness never enter the tracked tree. diff --git a/devlog/_fin/260923_release_2_64/011_wp2_privacy_gate_complement.md b/devlog/_fin/260923_release_2_64/011_wp2_privacy_gate_complement.md new file mode 100644 index 00000000000..1c8dd782d2e --- /dev/null +++ b/devlog/_fin/260923_release_2_64/011_wp2_privacy_gate_complement.md @@ -0,0 +1,85 @@ +# 011 — wp2: privacy scan on every pull request that `gates` skips + +## Problem + +`privacy:scan` runs in two jobs of `.github/workflows/ci.yml`. `gates` runs it on every event +it runs for, and `gates` is skipped on a pull request whose paths miss the `ci` filter. +`privacy-gate` covers that gap only when the `privacy` filter matches, and that filter lists +`devlog/**` and `ci.yml` alone. A pull request touching only paths outside both filters +(for example `docs-site/**`, `structure/**`, `native/**`, `.github/actions/**`, +`.github/release.yml`, `.github/CODEOWNERS`, the pull request template, or root markdown +other than `README.md`) therefore runs no scan, and the `ci` aggregate still concludes +success. + +The gap predates #5469, which closed it for `devlog/**` only. It does not reach an npm or +GitHub release: `release.yml` requires a push-event `ci.yml` success on the exact release +SHA, where `gates` scans the whole tree. It does reach GitHub Pages, because +`deploy-docs.yml` publishes `docs-site` on a `main` push without waiting for that scan. + +## Change + +Make `privacy-gate` the exact complement of `gates` on pull requests, so the scan's coverage +stops depending on an enumerated path list. The `privacy` filter then selects nothing and is +removed with its plumbing. + +`.github/workflows/ci.yml`: + +```diff +- privacy: +- - 'devlog/**' +- - '.github/workflows/ci.yml' +``` + +(with the comment block above it, which describes the removed filter), the `privacy` output of +`changes`, the `PRIVACY_SCOPE` validation in the `scope` step, and the `CHANGES_PRIVACY` env +of the aggregate. + +```diff + privacy-gate: + name: privacy gate + needs: changes +- if: github.event_name == 'pull_request' && needs.changes.outputs.ci != 'true' && needs.changes.outputs.privacy == 'true' ++ if: github.event_name == 'pull_request' && needs.changes.outputs.ci != 'true' +``` + +```diff +- privacy=not-requested +- if [ "$scoped" = not-requested ] && [ "$CHANGES_PRIVACY" = "true" ]; then +- privacy=requested +- fi ++ privacy=not-requested ++ if [ "$scoped" = not-requested ]; then ++ privacy=requested ++ fi +``` + +The aggregate already sets `scoped=not-requested` exactly when the event is `pull_request` +and `CHANGES_CI` is not `true`, which is the job's new condition, so both sides keep deriving +the same expectation. Comments above `privacy-gate` and the aggregate derivation are reworded +to state the complement rule. + +Tests (`tests/ci-workflows/`): + +- `ci-privacy-gate.test.ts`: the event × `ci` matrix expects exactly one scanner for every + combination: `gates` off pull requests or when `ci` is true, `privacy-gate` otherwise. + The executed-aggregate cases cover a docs-only and a no-filter pull request requiring + `privacy-gate` success, `skipped`/`failure`/`cancelled` failing by name, and a second + scan still rejected when `ci` is true. The filter and malformed-output cases for the removed + `privacy` output are replaced by an assertion that no job or step reads it. +- `ci-review-lanes.test.ts` and any other test that executes the aggregate: a pull request + with `CHANGES_CI=false` now requires `privacy-gate`; fixtures are updated to include it, + never by loosening the aggregate. + +No file-size cap is raised; if a test file would exceed its cap, the new cases move to a +sibling registered in `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`. `structure/ops/cross-platform-ci.md` is updated +where it describes the privacy gate. + +## Acceptance + +- The PR changes `ci.yml`, so its own pull-request run sets `ci` true and scans in `gates`, + not in `privacy gate`. The complement is proven by the executed tests above, which run + the checked-in `if:` expressions and aggregate shell. +- `gates`, `structure gate` (the PR edits `structure/ops/`), and the `ci-privacy-gate` and + `ci-review-lanes` tests pass at the exact PR head; the `ci` aggregate succeeds. +- An independent reviewer confirms that no event loses a scan it had before. diff --git a/devlog/_fin/260923_release_2_64/012_wp2_request_owned_main_cursor.md b/devlog/_fin/260923_release_2_64/012_wp2_request_owned_main_cursor.md new file mode 100644 index 00000000000..1a7d29d36f6 --- /dev/null +++ b/devlog/_fin/260923_release_2_64/012_wp2_request_owned_main_cursor.md @@ -0,0 +1,97 @@ +# 012 — wp2: request-owned main stays out of shared active state + +## Problem + +Unreleased on `dev` since #5024: a request that carries its own main credential makes the +stored `main` account an ordinary pool candidate for that request +(`CodexAccountUsabilityOptions.requestOwnedMainCredential`). #5654 stopped three writes in +`resolveCodexAccountForThreadDetailed` from recording such a pick as the shared active account, +through a local `sharesActiveSelection` closure in `src/codex/routing.ts`. The same resolve still +reaches other writers of shared active state with the request's `selectionOptions`: + +| Site | Writer | Path | +|---|---|---| +| `src/codex/routing/selection.ts` `pickUnboundStrategyAccount`, round-robin and fill-first/reset-first branches | `rememberActiveCodexAccount` | new unbound session under a non-quota strategy | +| `src/codex/routing/selection.ts` `applyQuotaAutoSwitch` | shared active write inside the helper (persisted) | default `quota` strategy crossing the switch threshold | +| `src/codex/routing/selection.ts` `applyFailureFailover` | shared active write inside the helper | failover streak on the active account | +| `src/codex/routing.ts` priority preemption | `rememberActiveCodexAccount(preempted)` | a higher tier becomes selectable | +| `src/codex/routing.ts` bound-thread quota re-evaluation | `promoteActiveCodexAccount(cooler)` | bound thread moves to a cooler account | +| `src/codex/routing.ts` expired transient hold | `promoteActiveCodexAccount(expiredDetour)` | bound thread adopts its detour | + +When any of them picks `main` for a request that owns the main credential, later requests that +do not carry that credential read `main` as the effective (or persisted) active account. +No credential moves between callers: only the account id is recorded. + +## Change + +One rule, one helper, applied at every shared-state write reachable from a request-owned +selection. + +`src/codex/routing/selection.ts` exports: + +```ts +/** + * A main that is live only through this request's own credential serves this request alone. + * Recording it as the shared active account would route later requests through a credential + * they do not carry (see CodexAccountUsabilityOptions.requestOwnedMainCredential). + */ +export function sharesActiveSelection( + accountId: string, + selectionOptions?: CodexAccountUsabilityOptions, +): boolean { + return !(accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.requestOwnedMainCredential === true); +} +``` + +and guards with it: + +- `pickUnboundStrategyAccount`: both `if (commitSharedActive)` blocks become + `if (commitSharedActive && sharesActiveSelection(picked, selectionOptions))`. +- `applyQuotaAutoSwitch` and `applyFailureFailover`: every write of shared active state is + skipped when `sharesActiveSelection(target, selectionOptions)` is false. The returned account + is unchanged, so the request is still served by its own credential. + +`src/codex/routing.ts` (1618 lines against a 1626 cap; the change must not grow it past the cap): + +- Delete the local `sharesActiveSelection` closure and its comment; import the helper from + `./routing/selection`; the three existing call sites pass `selectionOptions`. +- Add the same condition to the existing `if` guarding `promoteActiveCodexAccount(cooler)`, + `promoteActiveCodexAccount(expiredDetour)` and `rememberActiveCodexAccount(preempted)`, + editing the condition in place. + +Post-response failover (`recordCodexUpstreamOutcome` quota-refusal branches and the account +exclusion path) promotes `meta.promoteAccountId` or `pickAlternateCodexAccount(...)` without +request selection options. The implementation traces where `meta.promoteAccountId` is set; if a +request-owned retry can place `main` there, the same rule is applied by carrying the request's +ownership into that metadata, and if it cannot, the PR states the reason with file and line. + +Thread affinity, round-robin ring bookkeeping and the account that serves the request are +unchanged. + +## Regression tests + +`tests/codex-integration/codex-pool-rotation.test.ts` (no file-size cap), next to the existing +`getEffectiveActiveCodexAccountId` assertions, each resolving through +`resolveCodexAccountForThreadDetailed` with +`{ requestOwnedMainCredential: true, isMainAccountTokenLive: () => true }` on a pool whose +operator-selected active account is a stored account: + +- default `quota` strategy with the active account over its switch threshold and `main` the + cooler candidate: the request resolves to `main`, while `config.activeCodexAccountId` and + `getEffectiveActiveCodexAccountId(config)` still name the operator's account; +- round-robin and fill-first new sessions whose next pick is `main`: same assertions; +- control: each scenario without `requestOwnedMainCredential` (stored main live) does move + the active account to `main`, proving the new cases are not passing because nothing moves. + +Preemption and the bound-thread paths get a case each when the fixture can reach them with a +request-owned selection; otherwise the PR names why they are unreachable for such a request. + +## Acceptance + +- The PR's pull-request run executes this file (`src/**` and `tests/**` match the `ci` filter) + and every requested job succeeds at the exact head; `file-size ratchet` passes with no cap + change. +- An independent reviewer enumerates every writer of `runtimeActiveCodexAccountId` and + `config.activeCodexAccountId` (`git grep -n -E 'rememberActiveCodexAccount|promoteActiveCodexAccount|setActiveCodexAccount|activeCodexAccountId =' -- src`) + and confirms each is guarded or unreachable from a request-owned main selection, and that + the new tests fail without the change. diff --git a/devlog/_fin/260923_release_2_64/020_wp3_dev_candidate.md b/devlog/_fin/260923_release_2_64/020_wp3_dev_candidate.md new file mode 100644 index 00000000000..994ea993d56 --- /dev/null +++ b/devlog/_fin/260923_release_2_64/020_wp3_dev_candidate.md @@ -0,0 +1,41 @@ +# 020 — wp3: dev candidate + +The candidate is the `dev` SHA after wp2's last merge. It is fixed before the version +pre-move, so the pre-move PR never changes the tree that ships. + +Dispatch the full lane on `dev` and bind it to the exact SHA: + +```bash +git fetch origin dev +CAND=$(git rev-parse origin/dev) +gh workflow run ci.yml --ref dev -f lane=all +gh run list --workflow ci.yml --branch dev --event workflow_dispatch --limit 3 \ + --json databaseId,headSha,status,conclusion +``` + +Take the run whose `headSha` equals `CAND`. If `dev` moves before the dispatch resolves, +the candidate is the run's head, and every later step uses that SHA. + +Once the candidate is bound, dispatch the dev pre-move of [030](030_wp4_release.md) §1 so its +pull request runs its own checks in parallel with the candidate run. The pre-move never changes +the candidate: the candidate is a fixed SHA, and the pre-move PR is merged only after both its +own exact-head checks and this run have finished green. + +Acceptance: every job of that run has conclusion `success`, except `privacy gate`, +which is skipped by design on `workflow_dispatch`, and the `ci` aggregate is `success`. +A job counts at its latest attempt only. + +Failure handling: + +- A Windows job failing once with a known runner-stall signature (`spawnSync ETIMEDOUT`, + a 480 s batch timeout where each file passes alone, `EPERM` on temp cleanup) is rerun + once with `gh run rerun --job ` after the run completes. +- The same case failing twice is a defect: a focused fix PR to `dev`, reviewed, merged at + a green exact head, then a new lane=all dispatch on the new candidate. +- Any non-Windows failure is a defect from the first occurrence. + +Runners: the owner's standing instruction for release rounds is that release-path runs get +the runners and other runs are cancelled by hand, one at a time, never by script. While this +run and the release runs are active, other queued or in-progress runs are cancelled +individually after reading each run's workflow, branch and event; runs on `main`, `preview`, +the candidate run, this round's own PR runs and `Release` runs are never cancelled. diff --git a/devlog/_fin/260923_release_2_64/030_wp4_release.md b/devlog/_fin/260923_release_2_64/030_wp4_release.md new file mode 100644 index 00000000000..ff81f5385e6 --- /dev/null +++ b/devlog/_fin/260923_release_2_64/030_wp4_release.md @@ -0,0 +1,116 @@ +# 030 — wp4: release + +Order is fixed by `scripts/version-line.ts` `assertReleasable`: a candidate must strictly +outrank every existing tag, so the preview of core 2.64.0 is published before the stable +2.64.0. This is a gate, not a convention: once `v2.64.0` exists, `2.64.0-preview.20260923` +no longer outranks the tag set and its publish job refuses. Both channels ship the wp3 +candidate tree. + +The candidate is the `dev` SHA verified in wp3, taken before the pre-move below. Its four +version sources already read 2.64.0, so the `main` promotion tree is byte-identical to the +verified tree and needs no metadata commit (precedent: candidate `a077087b74` was taken +before the 2.63.0 pre-move #5601). + +## 1. Dev pre-move + +`release.yml` refuses to publish unless `origin/dev` outranks the release version +(`version-line.ts assert-ahead`). Move `dev` to 2.65.0 first: + +```bash +gh workflow run dev-version-bump.yml --ref main -f intended-version=2.64.0 -f mode=pre-move +``` + +The workflow opens a PR changing only the four version sources to 2.65.0. It is dispatched +as soon as wp3 binds the candidate, so its checks run alongside the candidate run. Confirm +the diff is exactly `package.json`, `desktop/src-tauri/tauri.conf.json`, +`desktop/src-tauri/Cargo.toml` and the `opencodex-desktop` entry of +`desktop/src-tauri/Cargo.lock`, wait until every requested check at its exact head has +succeeded, then admin squash merge it with `--match-head-commit`. + +## 2. Promotion PRs + +Both promotions start at the candidate and merge the branch tip with the `ours` strategy, +so the promoted tree is exactly the candidate (precedent #5603 and #5602). + +```bash +git switch -c codex/260923-release-preview-2.64.0 "$CAND" +git merge -s ours --no-edit origin/preview -m "release: promote the verified 2.64.0 preview tree to preview" +# the only writer of the four version sources (scripts/release-version-sources.ts): +# package.json "version" +# desktop/src-tauri/tauri.conf.json "version" +# desktop/src-tauri/Cargo.toml [package] version +# desktop/src-tauri/Cargo.lock [[package]] opencodex-desktop version +bun scripts/release-version-sources.ts sync 2.64.0-preview.20260923 +git commit -am "release: prepare 2.64.0-preview.20260923 version metadata" +bun scripts/release-version-sources.ts check 2.64.0-preview.20260923 + +git switch -c codex/260923-release-main-2.64.0 "$CAND" +git merge -s ours --no-edit origin/main -m "release: promote the verified 2.64.0 tree to main" +bun scripts/release-version-sources.ts check 2.64.0 +``` + +Checks before opening: `git diff --stat $CAND codex/260923-release-main-2.64.0` is empty, +and the preview branch differs from `CAND` only in the four version lines. Push with +`--no-verify`, open PRs to `preview` and `main` from the template, and merge each with +`gh pr merge --merge --admin --match-head-commit ` (a merge commit, never squash, +so the candidate stays an ancestor of both release branches). + +Owner steering for this round: merge these two promotion PRs immediately after confirming +their head and base, while their PR checks are pending. This was done for #5670 and #5671. +Their release-branch push CI and Service lifecycle runs still gate publication below. + +## 3. Release-branch CI + +`release.yml` requires, for the exact release SHA: + +- a successful `ci.yml` run with event `push` on that branch (a PR run does not qualify); +- a successful Service lifecycle run, because `package.json` and `desktop/**` changed + since the previous tag. + +Read each run's jobs at the merge SHA. Failures follow wp3's rerun and defect rules. + +## 4. Dispatch + +```bash +gh workflow run release.yml --ref preview -f version=2.64.0-preview.20260923 -f tag=preview \ + -f expected-sha= -f dry-run=false +# after the preview release run succeeds: +gh workflow run release.yml --ref main -f version=2.64.0 -f tag=latest \ + -f expected-sha=
-f dry-run=false +``` + +The release preflight fails fast on a version-source mismatch, an existing tag or release, +an npm version already present, or a tag-ordering violation. A job that fails after npm +acknowledged publication is completed by re-dispatching with the same version and +expected SHA plus `resume-after-npm-publish=true`; the version is never republished. +Other failed jobs are rerun individually. + +Push-event CI on `main` and `preview` does not run the Windows shards; the wp3 lane=all +run is the Windows evidence for this tree, which is why both promotions carry the +candidate tree unchanged apart from the preview version line. + +## 5. Verification + +```bash +curl -s https://registry.npmjs.org/@bitkyc08%2fopencodex # dist-tags.latest / .preview +gh release view v2.64.0 --json assets,isPrerelease,targetCommitish +gh release view v2.64.0-preview.20260923 --json assets,isPrerelease,targetCommitish +curl -sL https://github.com/lidge-jun/opencodex/releases/latest/download/latest.json +``` + +Acceptance: `latest` = 2.64.0 and `preview` = 2.64.0-preview.20260923 on npm; both GitHub +releases exist with the same asset count as v2.63.0 (25); `latest.json` reports 2.64.0 +with a signature for every platform entry. Registry propagation lag is waited out, not +worked around. + +A green release run can still end with registry verification `pending` (the post-publish +smoke retries six times and then reports pending rather than failing). The release-outcomes +rows of each run and a direct registry read, not the run conclusion alone, decide the +channel state. + +## 6. Close-out + +- Close #5525 with a note that `main` now carries `tauri-plugin-shell` 2.2.1 through the + 2.64.0 promotion. +- Record residual medium alerts (`serde_with`, `time`, `glib`) for a dependency round. +- D summary in `050_done.md`. diff --git a/devlog/_fin/260923_release_2_64/050_done.md b/devlog/_fin/260923_release_2_64/050_done.md new file mode 100644 index 00000000000..8ca96ac2aa7 --- /dev/null +++ b/devlog/_fin/260923_release_2_64/050_done.md @@ -0,0 +1,67 @@ +# 050 — 2.64.0 release outcome + +## Result + +The 2.64.0 release round is complete. The verified `dev` candidate +`a1131f521b644c09f43c924a615ea48dfca5b607` shipped to both channels. + +| Channel | Version | Promotion merge | Release run | npm dist-tag | +|---|---|---|---|---| +| preview | 2.64.0-preview.20260923 | `836321b33e` (#5670) | [35843685057](https://github.com/lidge-jun/opencodex/actions/runs/35843685057) | `preview` | +| stable | 2.64.0 | `4cb43cb0a8` (#5671) | [35847101363](https://github.com/lidge-jun/opencodex/actions/runs/35847101363) | `latest` | + +Both release runs completed successfully. Both GitHub releases are public, with 25 +assets each. `releases/latest/download/latest.json` serves `2.64.0` and has a +signature for each of its five platform entries. Direct npm registry reads after +propagation reported `latest=2.64.0` and +`preview=2.64.0-preview.20260923`. + +## Pre-release close-out + +- Critical desktop dependency update: #5661 merged as `a1131f521b`. + `tauri-plugin-shell` is pinned to `=2.2.1` in Cargo.toml and Cargo.lock. + The [full-platform branch run](https://github.com/lidge-jun/opencodex/actions/runs/35835959962) + finished with 39 successful jobs; a privacy gate skip was expected for dispatch. + #5525 closed after the update reached `main`. +- Privacy scan complement: #5662 merged as `da662a30ee`. A pull request skipped by + `gates` now runs the dedicated `privacy gate` job. The exact-head PR CI passed. +- Request-owned main selection: #5663 merged as `f2e8045140`. A request's own + main credential no longer changes shared active-account state. The exact-head + PR CI passed after tests were moved to a registered sibling file to satisfy the + file-size ratchet. +- Reviews of #5471, #5456/#5653, #5469 and #5024/#5654/#5655 were performed. + The two confirmed findings were fixed in #5662 and #5663 before the candidate + was selected. Unreleased review details were kept out of this tracked unit. + +## Verification and operations + +- The `dev` candidate passed [lane=all run 35840680817](https://github.com/lidge-jun/opencodex/actions/runs/35840680817): + 39 successful jobs and the dispatch-only privacy gate skipped as designed. +- The dev pre-move #5666 merged as `685321e297`, taking `dev` to 2.65.0 + before either publication. Its PR Cross-platform CI and Service lifecycle passed. +- The preview promotion's push Cross-platform CI + [35843639351](https://github.com/lidge-jun/opencodex/actions/runs/35843639351) + and Service lifecycle [35843639372](https://github.com/lidge-jun/opencodex/actions/runs/35843639372) + passed on `836321b33e`. The Linux `test 1/4` batch timed out once only when + twelve files ran together; the one job passed on its second attempt. All other + requested jobs succeeded. +- The stable promotion's push Cross-platform CI + [35843612570](https://github.com/lidge-jun/opencodex/actions/runs/35843612570) + and Service lifecycle [35843612468](https://github.com/lidge-jun/opencodex/actions/runs/35843612468) + passed on `4cb43cb0a8`. +- The owner directed the two promotion PRs to merge before their PR checks + finished. Their release-branch push checks succeeded before publication, + as enforced by `release.yml`. +- The first preview publish attempt reached its CI gate before the preview push + run passed; only the failed publish job was rerun. The stable packaging run + started before the preview retry, so it was cancelled to preserve the required + preview-before-stable version order, then the stable release was dispatched + again. Publication was not repeated for either version. +- Local tests, typecheck and build: NOT RUN. Hosted CI above is the execution + evidence. + +## Residuals + +Three medium Dependabot alerts remain in the desktop lockfile: +`serde_with`, `time` and `glib`. They are separate dependency work. +No release blocker remains from this round. From aed3bb8f420ff75205b04ab83235c20a78c3ba93 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 19:46:07 +0900 Subject: [PATCH 06/48] =?UTF-8?q?fix(responses):=20bundle=20lane=20E=20?= =?UTF-8?q?=E2=80=94=20combo=20resend=20safety,=20WebSocket=20replacement,?= =?UTF-8?q?=20goal=20helpers,=20Devin=20retry=20delays=20(#5675)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(devlog): plan bundle lane E (responses and combo) * fix(devin): accept generated approximate retry delays Carries #5629. The shared retry-delay parser accepts the generated "retry after ~180s" approximation marker after Retry-After, and the bounded Devin replay re-evaluates the delay on every attempt within the existing cumulative ceilings. Folded review fixes: a repeated approximation marker ("~1 minute ~30 seconds") now rejects the hint instead of silently shortening it to the first component, and the cloud-direct comment no longer claims the marker blocks re-parsing. Supersedes #5629. Co-authored-by: Epinephrine <27862058+luvs01@users.noreply.github.com> * fix(responses): restore code-mode goal helpers Carries #5659. Routed create_goal, get_goal and update_goal calls (bare or with a provider-invented default. prefix) are accepted as nested helpers of a genuinely declared code-mode exec and compiled to the matching tools.(...) call instead of falling through to exec_command. A genuinely declared bare goal tool keeps its identity, and a catalog that declares neither the tool nor exec still fails closed. Folded review fixes: the guard is asserted on the original unrestored wire name, an unlisted helper-like name is proven not admitted, bare-goal precedence is covered through full restoration, the authorization comments in src/types/tools.ts name the goal helpers, and the codex integration guide describes the repair. Closes #5495. Supersedes #5659. Co-authored-by: ingwannu <186453546+Ingwannu@users.noreply.github.com> * fix(responses): stop combo failover once a request has spent its ambiguous replacement Carries #5646. Once a request has spent its retryOnReset replacement, the first send may already have run the turn, so a replacement that answers 200 and then fails with zero output must not be sent again. RequestExecutionBudget now reports ambiguousResendSpent from the one shared grant; combo failover stops when it is spent and settles the answer with the shared settleOperatorReplacement rule (a resendable status becomes the replay refusal, anything else keeps its status with the non-replayable marker). On the direct path the streamed opaque-blob rebuild is skipped once the grant is spent. Carried before #5633 so its WebSocket replacement row is never exposed to the third-send gap. Supersedes #5646. Co-authored-by: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> * fix(responses): let retryOnReset replace a Codex WebSocket send that died unanswered Carries #5633. A Codex WebSocket that opens and then closes or errors under its create frame before any Responses event is the same unknown state as an HTTP connection that resets before its head. For a provider that opted into retryOnReset, the request-resend gate may now spend the request's single replacement on it (one replacement per logical request, self-contained body only). The exchange records the stage it reached; silence keeps its 504 and a drop after a relayed event keeps its errored 200. Providers that have not opted in are unchanged. Carried after #5646, so the WebSocket replacement row inherits the spent-grant stop. Folded review fixes: the duplicate settleOperatorReplacement import the pair merge produced is removed, and responses-failover.md states the 2xx replacement contract once as settled for all three replacement rows. Supersedes #5633. Co-authored-by: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> * fix(combo): fail over undeclared zero-output tools Carries #5489 (net diff; its upstream/dev merge commit is dropped). When a runTurn adapter's first meaningful event in a combo attempt is a tool call the current request did not declare, the existing fail-closed refusal is projected as a pre-commit 502 so the combo can hop to the next target with the same tool catalog. Chat Completions and Anthropic Messages inbound requests keep their existing behaviour. Folded review fix: the non-streaming path now applies the same boundary as the streaming preflight. An undeclared tool call after a replay-unsafe heartbeat (an adapter-side effect already ran) keeps the refusal on that child instead of sending the turn to another target. New streaming and non-streaming cases prove exactly one dispatch; the non-streaming one fails without the gate. The combos guide gains the hop row in every locale and responses-failover.md records the runTurn boundary. Related to #5407 (covers its Responses path only; the reported Claude Code Anthropic Messages path is unchanged). Supersedes #5489. Co-authored-by: Yu Zhang <34849476+AaronZ345@users.noreply.github.com> * fix(combo): keep failures after a replay-unsafe side effect on their child Found by the lane's adversarial review of the carried #5646/#5489 changes. - A runTurn adapter that emits a replay-unsafe heartbeat (it already ran a local side effect, as Cursor does) and then errors or ends empty before any output returned a plain 502, so a combo sent the turn to the next target and could repeat the side effect. Streaming and non-streaming paths now mark that 502 non-replayable, and the combo stops on the child. This predates the carried commits; it sits on the same boundary structure/runtime.md states. - A scope derived from a shape-compatible budget that implements claimAmbiguousResend but not ambiguousResendSpent reported "not spent" after it claimed the grant, which would let a combo hop on a zero-output 200 from the replacement (a third send). Grants claimed through the bridge are now latched per bridged parent and visible to every sibling scope. Both are covered by new tests that fail without the fix. * fix(responses): let a WebSocket replacement that resets use a second grant Review finding on #5675. With retryOnReset.replacements set to 2, a dead Codex WebSocket spends the first grant on its HTTP replacement; if that replacement resets before its head, the WebSocket row settled it as the replay refusal at once, so the configured second replacement was never reachable. The reset is the pre-header row again, so the row now asks the same gate (and the send budget) once more and resends only when a grant remains; with the default of one it still settles as the refusal. The loop is bounded by the request's finite allowance. --------- Co-authored-by: Epinephrine <27862058+luvs01@users.noreply.github.com> Co-authored-by: ingwannu <186453546+Ingwannu@users.noreply.github.com> Co-authored-by: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> Co-authored-by: Yu Zhang <34849476+AaronZ345@users.noreply.github.com> --- .../260923_bundle_lane_e/000_overview.md | 5 + .../260923_bundle_lane_e/010_decisions.md | 20 + .../_plan/260923_bundle_lane_e/020_carry.md | 17 + .../_plan/260923_bundle_lane_e/030_verify.md | 12 + .../src/content/docs/fr/guides/combos.md | 1 + .../fr/reference/configuration/providers.md | 2 +- .../content/docs/guides/codex-integration.md | 6 + docs-site/src/content/docs/guides/combos.md | 1 + .../src/content/docs/ja/guides/combos.md | 1 + .../ja/reference/configuration/providers.md | 2 +- .../src/content/docs/ko/guides/combos.md | 1 + .../ko/reference/configuration/providers.md | 2 +- .../src/content/docs/reference/adapters.md | 10 + .../docs/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/server.md | 12 +- .../src/content/docs/ru/guides/combos.md | 1 + .../ru/reference/configuration/providers.md | 2 +- .../src/content/docs/tr/guides/combos.md | 1 + .../tr/reference/configuration/providers.md | 2 +- .../src/content/docs/zh-cn/guides/combos.md | 1 + .../reference/configuration/providers.md | 2 +- .../src/content/docs/zh-tw/guides/combos.md | 1 + .../reference/configuration/providers.md | 2 +- scripts/test-layout/layout.json | 2 + src/adapters/devin/cloud-direct/chat.ts | 4 +- src/adapters/run-turn-queue.ts | 7 + src/lib/request-execution-budget.ts | 56 +- src/lib/request-resend-gate.ts | 7 +- src/lib/retry-delay.ts | 8 +- src/lib/upstream-retry.ts | 32 +- src/responses/code-mode-helper-compat.ts | 3 + src/server/responses/codex-ws-exchange.ts | 25 +- src/server/responses/codex-ws-wire.ts | 31 +- src/server/responses/core-combo.ts | 14 +- src/server/responses/core-opaque-recovery.ts | 5 +- src/server/responses/passthrough-dispatch.ts | 146 ++++-- src/server/responses/policy-fallback.ts | 3 + src/server/responses/request-send-budget.ts | 4 + src/server/responses/run-turn-execution.ts | 53 +- src/types/tools.ts | 16 +- structure/adapters/registry.md | 6 +- structure/runtime.md | 2 +- structure/transports/responses-failover.md | 56 +- structure/transports/responses-wire-shapes.md | 17 +- tests/adapters/run-turn-queue.test.ts | 31 ++ tests/fixtures/test-layout-expected.json | 2 + .../lib/ambiguous-resend-composition.test.ts | 55 ++ .../devin-stated-reset-retry.test.ts | 38 ++ .../responses-code-mode-goal-helpers.test.ts | 93 ++++ tests/responses/ws-ambiguous-resend.test.ts | 483 ++++++++++++++++++ tests/routing/routing-policy-fallback.test.ts | 23 +- tests/server/replay-refusal-parity.test.ts | 128 +++++ tests/server/retry-after-429.test.ts | 8 + tests/server/retry-delay-hardening.test.ts | 5 + .../server-combo-zero-output-failover.test.ts | 216 +++++++- 55 files changed, 1548 insertions(+), 137 deletions(-) create mode 100644 devlog/_plan/260923_bundle_lane_e/000_overview.md create mode 100644 devlog/_plan/260923_bundle_lane_e/010_decisions.md create mode 100644 devlog/_plan/260923_bundle_lane_e/020_carry.md create mode 100644 devlog/_plan/260923_bundle_lane_e/030_verify.md create mode 100644 tests/responses/responses-code-mode-goal-helpers.test.ts create mode 100644 tests/responses/ws-ambiguous-resend.test.ts diff --git a/devlog/_plan/260923_bundle_lane_e/000_overview.md b/devlog/_plan/260923_bundle_lane_e/000_overview.md new file mode 100644 index 00000000000..b2582aaf0ae --- /dev/null +++ b/devlog/_plan/260923_bundle_lane_e/000_overview.md @@ -0,0 +1,5 @@ +# 260923 bundle lane E — responses/combo + +Lane E of the coordinator round devlog/_plan/260923_pr_lane_bundle (coordinator task 01a0cda3-22de-7680-b771-f5338e65ec57). Task 01a0cdb0-b76c-7511-a70f-3bdee1788dc1, worktree .codex/worktrees/0ba7, branch codex/260923-bundle-e-responses-combo from origin/dev 685321e297. One branch, ordered commits, one PR to dev. + +Docs: 010_decisions.md (per-candidate verdict and carry order), 020_carry.md (commit plan with fixes folded from gpt-6-sol soundness reviews), 030_verify.md (focused tests and gates). diff --git a/devlog/_plan/260923_bundle_lane_e/010_decisions.md b/devlog/_plan/260923_bundle_lane_e/010_decisions.md new file mode 100644 index 00000000000..80f4f4c7eca --- /dev/null +++ b/devlog/_plan/260923_bundle_lane_e/010_decisions.md @@ -0,0 +1,20 @@ +# Decisions + +Soundness reviews by gpt-6-sol reviewers, notes in the lane worktree .tmp/lane-e/review-*.md (scratch, not committed). Every PR merges cleanly into dev, and the cumulative stack simulates cleanly with git merge-tree. Carry order is 5629 -> 5659 -> 5646 -> 5633 -> 5489: #5646 first so the WebSocket row #5633 adds is never exposed to the 2xx third-send gap. + +| Item | Verdict | Decision | +|---|---|---| +| #5629 Devin approximate retry delays | SOUND-WITH-FIXES | Carry; fix the stale "~ prevents re-parsing" comment in src/adapters/devin/cloud-direct/chat.ts and reject a repeated approximation marker (retry after ~1 minute ~30 seconds) with a negative parser case. | +| #5659 code-mode goal helpers | SOUND-WITH-FIXES | Carry; add the original guard-input assertion, an unrelated-name rejection and bare-goal precedence case; update stale authorization comments in src/types/tools.ts. Closes #5495. | +| #5646 stop failover once the replacement is spent | SOUND-WITH-FIXES (pair) | Carry first of the pair. Closes the shared resend-safety gap (CodeRabbit's #5633 2xx finding). | +| #5633 WebSocket retryOnReset replacement | SOUND-WITH-FIXES (pair); UNSOUND alone | Carry after #5646; drop the duplicate settleOperatorReplacement import in passthrough-dispatch.ts; rewrite structure/transports/responses-failover.md so the 2xx gap reads as settled. Partial for #4191 (not in this lane's list; not claimed). | +| #5489 undeclared zero-output tool failover | UNSOUND as submitted, salvageable | Carry with fix: non-streaming classification must not hop after a replayUnsafe heartbeat; add a non-streaming E2E proving no second dispatch; sync structure/runtime.md, structure/transports/responses-failover.md. Covers only the Responses path of #5407, so #5407 stays open. | +| #5221 sub-agent own-model identity | UNSOUND | Exclude. Routed raw-body repair never personalizes the neutral catalog line, native parent -> routed worker stays wrong, fenced identity sentences can be rewritten, new test is unregistered. | +| #5217 | not fixable in bounded effort | Exclude; needs a destination-aware identity design across catalog, parser and passthrough. | +| #5494 DeepSeek combo adapter_eof | NEEDS-REPRO | Exclude. Current dev already hops a zero-output adapter_eof; the final 502 and cooldown 503 follow existing rules; wire capture needed to separate upstream truncation from a relay/adapter terminal loss. | +| #5369 responses-state spill growth | not a defect | Exclude. Reporter's own re-measure stays under the 1 GiB / 1000-entry / 24 h bounds; the remaining unreferenced-file footprint is a design question for snapshot-omitted in-memory owners. | + + +## Closure claims (audit fold) + +The PR says Closes #5495 only. #5407 (Responses path covered, Claude Code/Anthropic path not), #5217 and #4191 are not claimed. Listed issues #5407 and #5217 are reported to the coordinator as unfixed with findings, as the lane packet allows ("fixed ... or excluded with findings"). Supersede claims: #5629, #5659, #5646, #5633 and #5489 are superseded only when their whole net contribution is on the branch; #5489 is carried whole (its issue coverage is what is partial). #5221 is excluded and not superseded. diff --git a/devlog/_plan/260923_bundle_lane_e/020_carry.md b/devlog/_plan/260923_bundle_lane_e/020_carry.md new file mode 100644 index 00000000000..7eed3c46010 --- /dev/null +++ b/devlog/_plan/260923_bundle_lane_e/020_carry.md @@ -0,0 +1,17 @@ +# Carry plan + +Commit order (one commit per item, Co-authored-by trailer for the original author): + +1. #5629 (luvs01) plus review fixes. +2. #5659 (Ingwannu) plus review fixes; Closes #5495. +3. #5646 (FredAmartey). +4. #5633 (FredAmartey) with the import and structure-doc cleanup. +5. #5489 (AaronZ345) net diff (its upstream/dev merge commit dropped) plus the replayUnsafe fix. + +Mechanism: cherry-pick each PR's own commits (squashed per PR) onto the lane branch, then apply the folded review fixes in the same commit. Registries (scripts/test-layout/layout.json, tests/fixtures/test-layout-expected.json) keep every dev entry. + + +## Documentation folded from the audit + +- #5489: one new row in the hop/terminal table of docs-site/src/content/docs/guides/combos.md (an undeclared first tool call before any output and without a replay-unsafe side effect hops; after a replay-unsafe side effect it stays terminal), mirrored in every translated combos.md that carries the table; structure/runtime.md and structure/transports/responses-failover.md updated. +- #5659: one sentence in the English code-mode section of docs-site/src/content/docs/guides/codex-integration.md, next to the existing shell/patch repairs. Locales do not describe these repairs, so they do not contradict it. diff --git a/devlog/_plan/260923_bundle_lane_e/030_verify.md b/devlog/_plan/260923_bundle_lane_e/030_verify.md new file mode 100644 index 00000000000..a1a5aac8d03 --- /dev/null +++ b/devlog/_plan/260923_bundle_lane_e/030_verify.md @@ -0,0 +1,12 @@ +# Verification + +Per item, after its commit: + +- #5629: bun test tests/server/retry-delay-hardening.test.ts tests/server/retry-after-429.test.ts tests/providers/devin-stated-reset-retry.test.ts tests/providers/devin-stated-reset-hardening.test.ts tests/providers/devin-hardening.test.ts tests/codex-integration/combo-authoritative-reset.test.ts (includes the new repeated-marker negative case). +- #5659: bun test tests/responses/responses-code-mode-goal-helpers.test.ts tests/responses/responses-undeclared-tool-guard.test.ts tests/responses/responses-custom-tool-repair.test.ts tests/responses/responses-bare-echo-helper-fence.test.ts tests/responses/responses-default-namespace-emit-normalize.test.ts tests/responses/legacy-shell-compat.test.ts tests/responses/responses-code-mode-shell-compile.test.ts tests/responses/responses-code-mode-patch-compile.test.ts (includes the raw guard-input, unrelated-name and bare-goal precedence cases). +- #5646 + #5633: bun test tests/responses/ws-ambiguous-resend.test.ts tests/responses/ws-failure-stage.test.ts tests/server/replay-refusal-parity.test.ts tests/lib/ambiguous-resend-composition.test.ts tests/routing/routing-policy-fallback.test.ts tests/lib/upstream-retry.test.ts tests/responses/responses-reset-replay.test.ts tests/responses/responses-opaque-blob-recovery.test.ts tests/server/server-combo-failover-e2e.test.ts. +- #5489: bun test tests/adapters/run-turn-queue.test.ts tests/server/server-combo-zero-output-failover.test.ts tests/server/server-combo-failover-e2e.test.ts tests/responses/responses-stream-tool-events.test.ts tests/adapters/bridge.test.ts tests/responses/responses-undeclared-tool-guard.test.ts, including the new non-streaming case: heartbeat(replayUnsafe) then an undeclared tool call returns the refusal with exactly one target dispatch. + +Branch gates: tests/test-layout.test.ts tests/test-layout-tooling.test.ts tests/ci-workflows/file-size-ratchet.test.ts tests/ci-workflows/structure-ssot.test.ts, bun run typecheck, bun run structure:check, bun run privacy:scan, git diff --check. No full local suite (owner runs it after every lane lands). + +Evidence required before the final report: exact-head hosted CI with every required job completed success at the PR head SHA (run ids and per-job conclusions recorded), and a gpt-6-sol adversarial review of the final diff with verdict PASS and findings folded. diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md index 4e9ccda71a8..60f39900394 100644 --- a/docs-site/src/content/docs/fr/guides/combos.md +++ b/docs-site/src/content/docs/fr/guides/combos.md @@ -202,6 +202,7 @@ Les échecs d’un combo se répartissent entre ceux qui entraînent un **bascul | Erreur classée comme erreur d’authentification, d’abonnement, de quota, de limitation de débit, de surcharge ou de serveur en amont | Place la cible en période de refroidissement et bascule, même si le statut seul ne suffit pas. | | Annulation client (499), `origin_rejected`, refus de cyber-politique, débordement de contexte ou autre demande invalide | Arrêtez et renvoyez l'erreur ; une autre cible ne rendrait pas la demande valide. | | Rejet structuré de `user`, valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, ou rejet d'entrée d'image propre à un modèle (`param: input`) | Bascule vers la cible admissible suivante avant le début de la sortie, sans délai de refroidissement ; voir Compatibilité des paramètres facultatifs ci-dessous. | +| Premier appel d'outil d'un tour Responses exécuté par un adaptateur interne (`runTurn`) que la requête courante n'a pas déclaré, avant toute sortie et tout effet de bord non rejouable | Met la cible en refroidissement et bascule avec le même catalogue d'outils. Après une sortie visible ou un effet de bord non rejouable, le refus est définitif. Les requêtes Chat Completions et Anthropic Messages ne changent pas. | | Toute autre erreur non classifiée | Arrêtez et renvoyez l'erreur. | Une cible sautée entre en temps de recharge pendant 60 secondes par défaut. Si la réponse en amont inclut un diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 04bd23891eb..154bef591c0 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -142,7 +142,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `responsesSnapshotRepair?` | `boolean` | Réparation côté client désactivée par défaut pour les instantanés du cycle de vie des réponses clairsemés dans SSE et JSON. Remplit les métadonnées d'état canonique, de sortie et d'outil manquantes tandis que l'inspection brute et la persistance restent inchangées. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Fournisseurs à clé API uniquement (`authMode: "key"`). Nouvelle tentative facultative sur la même cible après un 429 : lorsque `retryOn429` est absent, la fonctionnalité est désactivée ; la présence d'un objet l'active, sauf avec `enabled: false`. Après un 429, le proxy attend selon `Retry-After` reçu en amont ou selon l'intervalle fixe, puis relit la requête à l'identique avec la même clé avant tout basculement de clé. Ce comportement couvre la boucle principale de récupération d'un tour textuel, le protocole de transfert Responses, le pont d'images et de vidéos, le service auxiliaire de recherche Web et les continuations du terminal. Seules les réponses HTTP 429 reçues avant le début de la diffusion peuvent être relues ; les transports `runTurn` personnalisés ne font pas partie de la boucle de nouvelle tentative HTTP. `attempts` compte les relectures avec la même clé après le premier 429, soit `attempts` + 1 envois au total, et constitue un budget commun à toute la requête, partagé entre la boucle principale de récupération, la continuation de la garde du terminal et les nouvelles tentatives du pont. L'épuisement de `attempts` arrête uniquement les relectures supplémentaires avec la même clé : le basculement normal de clé ou la gestion de l'erreur finale s'applique ensuite selon les cibles disponibles. Sur le protocole de transfert authentifié par clé, aucun basculement n'est possible ; le 429 final est donc renvoyé sans modification. Codex ne retente jamais lui-même une requête après un 429 : cette option constitue ainsi la seule protection pour les fournisseurs à clé unique. Valeurs par défaut : `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (chaque attente est plafonnée à `maxIntervalMs`, lui-même plafonné à 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Fournisseurs `openai-chat` et `openai-responses` authentifiés par clé uniquement. Les fournisseurs `authMode: "forward"` (le pool de comptes ChatGPT) ne lisent jamais cette option et conservent l'échelle par défaut. Nouvelle tentative facultative pour les états transitoires reçus en amont avant le début de la diffusion (500, 502, 503, 504, 520, 521, 522) : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Ce comportement couvre la requête Responses initiale, la continuation de la garde du terminal, le point de terminaison natif `/v1/chat/completions` et les réémissions liées à la récupération après un 429 ou à la récupération de compte. `attempts` représente le nombre TOTAL d'envois en amont autorisés pour une requête, premier envoi compris (de 1 à 10, valeur par défaut : 3). Il constitue un budget commun à la requête, partagé avec la récupération après une réinitialisation de connexion ; ainsi, `3` signifie qu'au plus trois requêtes réelles atteignent le fournisseur. Les attentes utilisent une temporisation exponentielle à base fixe de 400 ms, plafonnée à 5 s, et respectent `Retry-After`. Cette option est distincte de `retryOn429`, qui traite la limitation de débit ; les échecs en cours de diffusion ne sont jamais relus. | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Fournisseurs `openai-responses` natifs uniquement, `authMode: "forward"` compris. Remplacement facultatif d'un envoi qui a échoué alors que l'appelant n'avait rien observé : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Couvre les deux étapes ambiguës — une connexion rompue avant tout en-tête de réponse, et un corps SSE rompu après l'en-tête alors qu'il ne portait que des événements de contrôle. Seule une requête autonome est remplacée : `store: false`, `input` complet, ni `previous_response_id`, ni `conversation`, ni `stream_id`, et uniquement des outils exécutés par le client. `replacements` est le nombre d'envois de remplacement qu'UNE requête logique peut effectuer, toutes étapes et tous enfants de combo confondus (de 1 à 2, valeur par défaut : 1). Ce n'est ni un nombre de tentatives par étape ni un budget d'envoi : un remplacement doit toujours tenir dans l'allocation d'envois dont l'étape disposait déjà. Une requête qui a déjà émis une sortie ou un appel d'outil n'est jamais remplacée, quelle que soit cette valeur. L'inférence de remplacement peut tout de même être facturée si l'origine avait déjà démarré la première, d'où la désactivation par défaut. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Fournisseurs `openai-responses` natifs uniquement, `authMode: "forward"` compris. Remplacement facultatif d'un envoi qui a échoué alors que l'appelant n'avait rien observé : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Couvre les deux étapes ambiguës — une connexion rompue avant tout en-tête de réponse, et un corps SSE rompu après l'en-tête alors qu'il ne portait que des événements de contrôle. Une WebSocket canonique ChatGPT en amont qui s'est fermée ou a échoué après l'envoi de sa trame de création, avant tout événement Responses, est couverte de la même façon ; son remplacement est envoyé en HTTP. Seule une requête autonome est remplacée : `store: false`, `input` complet, ni `previous_response_id`, ni `conversation`, ni `stream_id`, et uniquement des outils exécutés par le client. `replacements` est le nombre d'envois de remplacement qu'UNE requête logique peut effectuer, toutes étapes et tous enfants de combo confondus (de 1 à 2, valeur par défaut : 1). Ce n'est ni un nombre de tentatives par étape ni un budget d'envoi : un remplacement doit toujours tenir dans l'allocation d'envois dont l'étape disposait déjà. Une requête qui a déjà émis une sortie ou un appel d'outil n'est jamais remplacée, quelle que soit cette valeur. L'inférence de remplacement peut tout de même être facturée si l'origine avait déjà démarré la première, d'où la désactivation par défaut. | | `autoToolChoiceOnlyModels?` | `string[]` | Modèles dont `tool_choice` accepte uniquement `auto` ou `none` ; les choix forcés sont dévalorisés. | | `preserveReasoningContentModels?` | `string[]` | Modèles nécessitant un assistant préalable `reasoning_content` dans l'historique des discussions. Les enregistrements depuis le tableau de bord conservent la liste enregistrée, y compris `[]`. `PATCH /api/providers?name=` accepte un tableau ou `null` pour l'effacer. Une sauvegarde qui déplace le fournisseur vers un autre adaptateur, une autre URL de base ou un autre mode d'authentification ne la conserve pas (voir la section ci-dessous). | | `reasoningDetailsModels?` | `string[]` | Modèles dont le point de terminaison renvoie la réflexion sous forme de tableau structuré `reasoning_details` (MiniMax série M avec `reasoning_split`) ; les deltas de flux sont des instantanés cumulatifs comparés par préfixe, et la réflexion conservée est rejouée sous forme de tableau `reasoning_details` plutôt que de chaîne `reasoning_content`. | diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 373acce1450..2f7746af571 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -594,6 +594,12 @@ executes and authorizes the command. Valid JavaScript fallback fields, ambiguous and unrelated tool namespaces are not converted. This compatibility repair does not bypass provider rate limits or change the configured retry policy. +The same repair covers the goal helpers. A routed model that calls `create_goal`, `get_goal`, or +`update_goal` (or a `default.`-prefixed spelling of one) as a tool while the catalog declares only +code-mode `exec` has the call converted into the matching `tools.(...)` call inside +`exec`. A catalog that genuinely declares the bare goal tool keeps it, and a catalog that declares +neither the tool nor `exec` still rejects the call as undeclared. + Routed code-mode turns are also told the host's rules for the nested helpers before the first call: `tools.apply_patch` takes one string that opens and closes with the bare patch marker lines, the isolate has no `import`, and long-running commands are polled through `write_stdin`. When a diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index b3bfd8f65bb..c3feb30f58e 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -218,6 +218,7 @@ Combo failures are divided into **hop** failures and **terminal** failures. | Classified authentication, subscription, quota, rate-limit, overload, or upstream-server error | Cool the target and hop, even when the status alone is not sufficient. | | Client cancellation (499), `origin_rejected`, cyber-policy refusal, context overflow, or other invalid request | Stop and return the error; another target would not make the request valid. | | Structured HTTP 400 rejecting optional `user`, an unsupported reasoning effort, or model-scoped image input | Hop before output commitment without cooling; see request-local target compatibility below. | +| First tool call of a Responses turn run by an in-process adapter (`runTurn`) that the current request did not declare, before any output or replay-unsafe side effect | Cool the target and hop with the same tool catalog. After visible output or a replay-unsafe side effect the refusal is final. Chat Completions and Anthropic Messages requests are unchanged. | | Any other unclassified error | Stop and return the error. | When `cooldownMs` is unset, a hopped target uses an upstream fallback: 5 seconds for request-rate diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 2df05bf1dea..1412a18481e 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -126,6 +126,7 @@ ocx combo set balanced \ |機密認証、サブスクリプション、クォータ、レート制限、過負荷、またはアップストリーム サーバー エラー |ステータスだけでは物足りない場合でもターゲットを冷やしてホップさせましょう。 | |クライアントのキャンセル (499)、`origin_rejected`、サイバー ポリシーの拒否、コンテキスト オーバーフロー、またはその他の無効なリクエスト |停止してエラーを返します。別のターゲットではリクエストは有効になりません。 | |`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、またはモデル固有の画像入力拒否(`param: input`)を示す構造化 HTTP 400 |出力開始前に次の適格なターゲットへ進み、クールダウンを記録しません。任意パラメーターの互換性を参照してください。 | +|インプロセスアダプター(`runTurn`)が実行する Responses ターンで、現在のリクエストが宣言していない最初のツール呼び出し(出力やリプレイ不可の副作用より前) |ターゲットをクールダウンし、同じツールカタログで次へ進みます。可視出力やリプレイ不可の副作用の後は拒否が確定します。Chat Completions と Anthropic Messages のリクエストは変わりません。 | |その他の未分類のエラー |停止してエラーを返します。 | `cooldownMs` が未設定の場合、ホップされたターゲットはアップストリームのフォールバックを使用します。アップストリームコード `1302` または `1305` を伴うリクエストレート 429 では 5 秒、それ以外では 60 秒です。設定されている場合、使用可能なアップストリームの `Retry-After` または Codex リセットシグナルが存在しないときは、これらのリクエストレート 429 を含め、`cooldownMs` が適用されます。数値の `Retry-After` 秒数と HTTP-date 値が受け入れられ、すべてのクールダウンは 10 分を上限とします。優先順位は強い順に、明示的な `Retry-After` → Codex リセットヘッダー(`x-codex-primary-reset-at`、`x-codex-secondary-reset-at`、または `x-codex-tertiary-reset-at`)→ コンボの `cooldownMs`(設定時)→ アップストリームのレート制限コード `1302`/`1305` に対する 5 秒のリクエストレート フォールバック → 60 秒のデフォルトです。有効な即時指定 `Retry-After: 0` は、設定されたクールダウンで置き換えられず、即時のアップストリーム指示として維持されます。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index e5f7025a0f8..5d49fe60aa3 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -134,7 +134,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key プロバイダーのみ(`authMode: "key"`)。オプトインの同一ターゲット 429 リトライ: `retryOn429` が無ければ無効で、オブジェクトがあれば `enabled: false` でない限り有効になります。429 時に待機(上流の `Retry-After` または固定間隔)してから、キー フェイルオーバーの前に同一キーで同一リクエストを再送します — メインのテキストターン回復ループ、Responses passthrough、画像/動画ブリッジ、web-search サイドカー、ターミナル継続要求をすべてカバーします。再送の対象はプリストリームの HTTP 429 応答のみで、カスタム `runTurn` トランスポートは HTTP リトライループの対象外です。`attempts` は最初の 429 以降の同一キー再送回数(合計送信数 = `attempts` + 1)で、メインの回復ループ・ターミナルガード継続・ブリッジ再試行で共有されるリクエスト単位の予算です。`attempts` を使い切っても同一キーでの再送が止まるだけで、通常のキー フェイルオーバーまたは最終エラー処理が利用可能なターゲットに応じて続きます — キー認証の passthrough ワイヤにはフェイルオーバーがないため、使い切った 429 はそのまま返ります。Codex 自体は 429 をリトライしないため、単一キーのプロバイダーでは唯一の防御です。デフォルト: `enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(1回の待機は `maxIntervalMs` で上限、その上限は 600000)、`respectRetryAfter: true`。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | キー認証の `openai-chat` および `openai-responses` プロバイダーのみ。`authMode: "forward"` のプロバイダー(ChatGPT アカウントプール)はこのオプションを読まず、既定の再試行段数を維持します。ストリーム開始前に上流から返される一時的なステータス(500、502、503、504、520、521、522)に対するオプトインの再試行です。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。最初の Responses リクエスト、ターミナルガード継続、ネイティブの `/v1/chat/completions`、および 429/アカウント回復時の再取得が対象です。`attempts` は最初の送信を含め、1 回のリクエストで許可される上流への送信総数です(1~10、デフォルトは 3)。接続リセット回復と共有するリクエスト単位の単一予算であるため、`3` を指定した場合、プロバイダーに到達する実リクエストは最大 3 回です。待機には 400 ms を基準とする固定式の指数バックオフを使用し、上限は 5 秒で、`Retry-After` に従います。レート制限を扱う `retryOn429` とは別の機能であり、ストリーム開始後の失敗は再送されません。 | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | ネイティブ `openai-responses` プロバイダー専用で、`authMode: "forward"` も含みます。呼び出し側が何も観測しないまま失敗した送信を、オプトインで置き換えます。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。レスポンスヘッダーが届く前に接続が切れた場合と、ヘッダー後に SSE 本文が制御イベントだけを運んだまま切れた場合の両方が対象です。置き換えるのは自己完結したリクエストだけで、`store: false`、完全な `input`、`previous_response_id` / `conversation` / `stream_id` がないこと、クライアントが実行するツールのみ、が条件です。`replacements` は、すべてのレッグとすべてのコンボ子リクエストを合わせて 1 つの論理リクエストが行える置き換え送信の回数です(1..2、デフォルトは 1)。レッグ単位の再試行回数でも送信予算でもないため、置き換え送信もそのレッグがすでに持つ送信許容量に収まる必要があります。すでに出力やツール呼び出しを送ったリクエストは、この値に関わらず置き換えません。元の送信がすでに開始されていた場合は置き換えた推論も課金される可能性があるため、既定では無効です。 | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | ネイティブ `openai-responses` プロバイダー専用で、`authMode: "forward"` も含みます。呼び出し側が何も観測しないまま失敗した送信を、オプトインで置き換えます。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。レスポンスヘッダーが届く前に接続が切れた場合と、ヘッダー後に SSE 本文が制御イベントだけを運んだまま切れた場合の両方が対象です。canonical ChatGPT upstream WebSocket で create フレームの送信後、Responses イベントが届く前にソケットが閉じたかエラーになった場合も同じく対象で、その置き換えは HTTP で送信します。置き換えるのは自己完結したリクエストだけで、`store: false`、完全な `input`、`previous_response_id` / `conversation` / `stream_id` がないこと、クライアントが実行するツールのみ、が条件です。`replacements` は、すべてのレッグとすべてのコンボ子リクエストを合わせて 1 つの論理リクエストが行える置き換え送信の回数です(1..2、デフォルトは 1)。レッグ単位の再試行回数でも送信予算でもないため、置き換え送信もそのレッグがすでに持つ送信許容量に収まる必要があります。すでに出力やツール呼び出しを送ったリクエストは、この値に関わらず置き換えません。元の送信がすでに開始されていた場合は置き換えた推論も課金される可能性があるため、既定では無効です。 | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` が `auto` または `none` のみを受け入れるモデル。強制的な選択は格下げされます。 | | `preserveReasoningContentModels?` | `string[]` |チャット履歴に以前のアシスタント `reasoning_content` が必要なモデル。ダッシュボードから保存しても、保存済みのリスト(`[]` を含む)は保持されます。`PATCH /api/providers?name=` は配列、または消去するための `null` を受け付けます。 アダプター、ベース URL、または認証モードを変えて別の宛先に移す保存では保持されません(下の節を参照)。 | | `reasoningDetailsModels?` | `string[]` | thinking を構造化された `reasoning_details` 配列で返すモデル(`reasoning_split` 使用の MiniMax M シリーズ)。ストリーム差分は累積スナップショットとして prefix-diff され、保持された reasoning は `reasoning_content` 文字列ではなく `reasoning_details` 配列としてリプレイされます。 | diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index 238127c1cee..b9d79ff6079 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -132,6 +132,7 @@ ocx combo set balanced \ | 인증, 구독, 쿼터, 속도 제한, 과부하, 또는 상위 서버 오류로 분류됨 | 상태 코드만으로는 충분하지 않더라도 대상을 쿨다운으로 보내고 넘어갑니다. | | 클라이언트 취소(499), `origin_rejected`, cyber-policy refusal, context overflow, 또는 기타 invalid request | 멈추고 오류를 반환합니다. 다른 대상을 써도 요청이 유효해지지 않기 때문입니다. | | `user`를 명시적으로 거부하거나, `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값 또는 모델별 이미지 입력 거부(`param: input`)를 나타내는 구조화된 HTTP 400 | 출력 시작 전에 쿨다운 기록 없이 다음 적격 대상으로 넘어갑니다. 선택적 매개변수 호환성을 참조하세요. | +| 인프로세스 어댑터(`runTurn`)가 실행하는 Responses 턴에서 현재 요청이 선언하지 않은 첫 도구 호출(출력이나 재전송 불가 부작용 이전) | 대상을 쿨다운하고 같은 도구 카탈로그로 다음 대상으로 넘어갑니다. 출력이 보였거나 재전송 불가 부작용이 생긴 뒤에는 거부가 그대로 확정됩니다. Chat Completions와 Anthropic Messages 요청은 바뀌지 않습니다. | | 그 밖의 분류되지 않은 오류 | 멈추고 오류를 반환합니다. | `cooldownMs`가 설정되지 않으면 홉된 대상은 업스트림 폴백을 사용합니다. 업스트림 코드 `1302` 또는 `1305`인 요청 속도 제한 429는 5초, 그 외에는 60초입니다. 설정하면 사용 가능한 업스트림 `Retry-After` 또는 Codex 재설정 신호가 없을 때, 해당 요청 속도 제한 429를 포함해 `cooldownMs`가 적용됩니다. 숫자로 된 `Retry-After` 초와 HTTP-date 값을 허용하며, 모든 쿨다운은 최대 10분으로 제한됩니다. 우선순위는 강한 순서대로 명시적 `Retry-After` → Codex 재설정 헤더(`x-codex-primary-reset-at`, `x-codex-secondary-reset-at`, 또는 `x-codex-tertiary-reset-at`) → 콤보의 `cooldownMs`(설정된 경우) → 업스트림 속도 제한 코드 `1302`/`1305`의 5초 요청 속도 제한 폴백 → 60초 기본값입니다. 유효한 즉시 지시인 `Retry-After: 0`은 설정된 쿨다운으로 대체하지 않고 업스트림의 즉시 지시로 유지합니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 0662c2f6ebf..05969efb075 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -134,7 +134,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key 프로바이더 전용(`authMode: "key"`). 동일 대상 429 재시도: `retryOn429`가 없으면 기능이 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 429 시 대기(업스트림 `Retry-After` 또는 고정 간격) 후 키 장애 조치 전에 동일 키로 동일 요청을 재전송합니다 — 일반 텍스트 턴 복구 루프, Responses passthrough, 이미지/비디오 브리지, web-search 사이드카, 터미널 연속 요청을 모두 포함합니다. 재전송 대상은 프리스트림 HTTP 429 응답뿐이며, 커스텀 `runTurn` 전송은 HTTP 재시도 루프에서 제외됩니다. `attempts`는 첫 429 이후의 동일 키 재전송 횟수(총 전송 = `attempts` + 1)이며, 메인 복구 루프·터미널 가드 연속 요청·브리지 재시도가 공유하는 요청 단위 예산입니다. `attempts`를 모두 소진해도 동일 키 재전송만 중단되며, 이후에는 일반 키 장애 조치 또는 최종 오류 처리가 사용 가능한 대상에 따라 진행됩니다 — 키 인증 passthrough 와이어에는 장애 조치가 없으므로 소진된 429가 그대로 반환됩니다. Codex 자체는 429를 재시도하지 않으므로 단일 키 프로바이더의 유일한 방어선입니다. 기본값: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000`(단일 대기는 `maxIntervalMs`로 상한, 그 자체는 600000으로 상한), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 키 인증 `openai-chat` 및 `openai-responses` 프로바이더 전용입니다. `authMode: "forward"` 프로바이더(ChatGPT 계정 풀)는 이 옵션을 읽지 않고 기본 재시도 단계를 유지합니다. 스트림 시작 전의 일시적인 업스트림 상태(500, 502, 503, 504, 520, 521, 522)를 선택적으로 재시도합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 최초 Responses 요청, 터미널 가드 연속 요청, 네이티브 `/v1/chat/completions`, 429/계정 복구 재조회를 포함합니다. `attempts`는 최초 전송을 포함하여 요청 하나에 허용되는 업스트림 전송의 총횟수(1..10, 기본값 3)입니다. 연결 재설정 복구와 요청 단위 예산 하나를 공유하므로 `3`이면 실제로 프로바이더에 도달하는 요청은 최대 세 번입니다. 대기에는 400ms로 고정된 지수 백오프를 사용하고 상한은 5초이며 `Retry-After`를 따릅니다. 속도 제한을 처리하는 `retryOn429`와는 별개이며, 스트림 도중의 실패는 절대 재전송하지 않습니다. | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 네이티브 `openai-responses` 프로바이더 전용이며 `authMode: "forward"`도 포함합니다. 호출자가 아무것도 관측하지 못한 채 실패한 전송을 선택적으로 대체합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 응답 헤더가 오기 전에 연결이 끊어진 경우와, 헤더 이후 SSE 본문이 제어 이벤트만 실은 채 끊어진 경우를 모두 다룹니다. 자체 완결된 요청만 대체합니다. `store: false`, 완전한 `input`, `previous_response_id`·`conversation`·`stream_id` 없음, 클라이언트가 실행하는 도구만 해당합니다. `replacements`는 모든 구간과 모든 콤보 자식을 합쳐 논리 요청 하나가 만들 수 있는 대체 전송 횟수입니다(1..2, 기본값 1). 구간별 재시도 횟수도 전송 예산도 아니므로, 대체 전송도 해당 구간이 이미 가진 전송 허용량 안에 들어가야 합니다. 이미 출력이나 도구 호출을 내보낸 요청은 이 값과 무관하게 대체하지 않습니다. 원본 전송이 이미 시작됐다면 대체한 추론도 과금될 수 있어서 기본값은 꺼짐입니다. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 네이티브 `openai-responses` 프로바이더 전용이며 `authMode: "forward"`도 포함합니다. 호출자가 아무것도 관측하지 못한 채 실패한 전송을 선택적으로 대체합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 응답 헤더가 오기 전에 연결이 끊어진 경우와, 헤더 이후 SSE 본문이 제어 이벤트만 실은 채 끊어진 경우를 모두 다룹니다. canonical ChatGPT 업스트림 WebSocket에서 create 프레임을 보낸 뒤 Responses 이벤트가 오기 전에 소켓이 닫히거나 오류가 난 경우도 같은 방식으로 다루며, 이때 대체 전송은 HTTP로 보냅니다. 자체 완결된 요청만 대체합니다. `store: false`, 완전한 `input`, `previous_response_id`·`conversation`·`stream_id` 없음, 클라이언트가 실행하는 도구만 해당합니다. `replacements`는 모든 구간과 모든 콤보 자식을 합쳐 논리 요청 하나가 만들 수 있는 대체 전송 횟수입니다(1..2, 기본값 1). 구간별 재시도 횟수도 전송 예산도 아니므로, 대체 전송도 해당 구간이 이미 가진 전송 허용량 안에 들어가야 합니다. 이미 출력이나 도구 호출을 내보낸 요청은 이 값과 무관하게 대체하지 않습니다. 원본 전송이 이미 시작됐다면 대체한 추론도 과금될 수 있어서 기본값은 꺼짐입니다. | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`가 `auto` 또는 `none`만 받는 모델입니다. 강제 선택은 낮은 수준으로 바뀝니다. | | `preserveReasoningContentModels?` | `string[]` | chat 기록에서 이전 assistant `reasoning_content`가 필요한 모델입니다. 대시보드에서 저장해도 저장된 목록(`[]` 포함)은 유지됩니다. `PATCH /api/providers?name=`는 배열 또는 지우기 위한 `null`을 받습니다. 어댑터, 기본 URL, 인증 모드를 바꿔 다른 목적지로 옮기는 저장에서는 유지되지 않습니다(아래 절 참고). | | `reasoningDetailsModels?` | `string[]` | thinking을 구조화된 `reasoning_details` 배열로 반환하는 모델(`reasoning_split` 사용 MiniMax M 시리즈). 스트림 델타는 누적 스냅샷이라 prefix-diff로 처리하고, 보존된 reasoning은 `reasoning_content` 문자열 대신 `reasoning_details` 배열로 리플레이합니다. | diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 6adc179aae7..78828bdfb52 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -503,6 +503,16 @@ configuration that names the old id is rewritten at startup. `CompletionConfiguration`, #2 is the output cap and #3 is the context window; swapping those two makes every turn fail with an opaque `invalid_argument`. A temperature of exactly 0 is refused, so it is clamped to the smallest accepted value. +- A pre-output 429 that states a recovery delay is retried in place only when the full stated + delay fits within the remaining cumulative wait allowance. The adapter waits that full delay + and replays the request up to twice; the default cumulative allowance is 30 minutes + (`OPENCODEX_DEVIN_STATED_RESET_WAIT_MS`, hard ceiling one hour). If the delay exceeds the + remaining allowance, the original 429 is surfaced without waiting or replaying. Retrying + earlier than the stated delay is deliberately not attempted — the hint is the provider's best + estimate of its own window, and each replay slot is finite. If the limit still refuses, the + final 429 surfaces to the client with the stated delay preserved as its cooldown hint. A `~` + in the surfaced message marks a delay recovered from a secondhand trailer sentence rather + than an exact header value; clients still receive the parsed number itself. - Experimental unofficial bridge; not shown in the dashboard preset by default. See the [provider guide](/guides/providers/) for login instructions. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index e10b168374b..48e18d25b80 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -267,7 +267,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `webSearchBridge?` | `{ enabled?: boolean; backend?: "ollama" \| "openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"; maxSearches?: number; timeoutMs?: number; endpoint?: string }` | Key-auth `openai-responses` passthrough providers only. Off by default. Codex always declares the hosted `web_search` tool, and the passthrough relays it on the assumption the destination executes it. A gateway that does not run hosted search answers with a `function_call` named `web_search` that nothing runs, and the undeclared-tool guard ends the turn. With `enabled: true` and an explicit `backend` OpenCodex intercepts that call, runs the search itself, feeds the result back to the same upstream, and shows Codex a hosted `web_search_call` cell. Never armed for `authMode: "forward"` (ChatGPT already searches) or for a provider that executes hosted search upstream. `backend` is required; there is no implicit default and a missing credential for the named backend leaves the bridge disarmed rather than falling through to another paid search. `ollama` reuses this provider's own API key on `POST /api/web_search`, so the origin must be `https://ollama.com` unless the operator names `endpoint` explicitly. `openai` / `anthropic` / `xai` / `gemini` / `exa` reuse the matching sidecar executor and that executor's own credential (`webSearchSidecar.exaApiKey` for Exa). The search model comes from `webSearchSidecar.model` only when `webSearchSidecar.backend` resolves to the same backend this bridge names; otherwise the bridge runs that backend's own default, because a model chosen for one vendor is rejected by another. An unset `webSearchSidecar.backend` resolves to `openai`, so an unset-backend model reaches an `openai` bridge and no other. There is no per-provider bridge model override. Streaming turns only. A turn that mixes `web_search` with another client tool call still fails closed rather than dropping the client's call. Assistant text such as XML-like `` prose is not executed. Defaults: `maxSearches: 3` (1..10), `timeoutMs: 60000` (1000..600000). | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key providers only (`authMode: "key"`). Opt-in same-target 429 retry: when `retryOn429` is absent the feature is off; object presence enables it unless `enabled: false`. On 429 the proxy waits (upstream `Retry-After` or the fixed interval) and replays the identical request on the same key before any key failover — across the main text-turn recovery loop, the Responses passthrough wire, the image/video bridge, the web-search sidecar, and terminal continuations. Only pre-stream HTTP 429 responses are eligible for replay; custom `runTurn` transports are outside the HTTP retry loop. `attempts` counts same-key replays after the first 429 (total sends = `attempts` + 1) and is one request-wide budget shared by the main recovery loop, the terminal-guard continuation, and bridge retries. Exhausting `attempts` only stops further same-key replays: normal key failover or final-error handling then applies per the available targets — on the key-auth passthrough wire there is no failover, so the exhausted 429 surfaces as-is. Codex itself never retries 429, so this is the only defense for single-key providers. Defaults: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (any single wait is capped at `maxIntervalMs`, itself capped at 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Key-auth `openai-chat` and `openai-responses` providers only. `authMode: "forward"` providers (the ChatGPT account pool) never read this option and keep the default ladder. Opt-in retry for pre-stream transient upstream statuses (500, 502, 503, 504, 520, 521, 522): absent means off, object presence enables it unless `enabled: false`. Covers the initial Responses request, the Responses passthrough lane and each of its recovery legs (OAuth-401 replay, same-target 429 replay, validated rebuild), the terminal-guard continuation, and native `/v1/chat/completions`. `attempts` is the TOTAL number of upstream sends allowed for one request including the first (1..10, default 3) — it is one budget shared with connection-reset recovery, so `3` means at most three real requests reach the provider. On the Responses passthrough lane the configured value is additionally intersected with the request-wide send allowance, so a value below that allowance narrows the ladder exactly while a value above it does not raise the bound. Waits use a fixed 400 ms exponential backoff capped at 5 s and honor `Retry-After`. Separate from `retryOn429`, which handles rate limiting; mid-stream failures are never replayed. | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Native `openai-responses` providers, including `authMode: "forward"`. Opt-in replacement of a send that failed while the caller had observed nothing: absent means off, object presence enables it unless `enabled: false`. Covers both ambiguous stages — a connection that died before any response header, and an SSE body that died after the header while carrying only control events. Only a self-contained request is ever replaced: `store: false`, complete `input`, no `previous_response_id`, `conversation` or `stream_id`, and only client-executed tools. `replacements` is the number of replacement sends ONE logical request may make across every leg and every combo child (1..2, default 1) — not a per-leg retry count and not a send budget, so a replacement still has to fit inside the send allowance the leg already had. A request that already emitted output or a tool call is never replaced, whatever this is set to. The replacement inference may still be billed if the origin had already started the first one, which is why this is off by default. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Native `openai-responses` providers, including `authMode: "forward"`. Opt-in replacement of a send that failed while the caller had observed nothing: absent means off, object presence enables it unless `enabled: false`. Covers both ambiguous stages — a connection that died before any response header, and an SSE body that died after the header while carrying only control events. A canonical ChatGPT upstream WebSocket that closed or errored after its create frame left, before any Responses event, is covered the same way, and its replacement is sent over HTTP. Only a self-contained request is ever replaced: `store: false`, complete `input`, no `previous_response_id`, `conversation` or `stream_id`, and only client-executed tools. `replacements` is the number of replacement sends ONE logical request may make across every leg and every combo child (1..2, default 1) — not a per-leg retry count and not a send budget, so a replacement still has to fit inside the send allowance the leg already had. A request that already emitted output or a tool call is never replaced, whatever this is set to. The replacement inference may still be billed if the origin had already started the first one, which is why this is off by default. | | `autoToolChoiceOnlyModels?` | `string[]` | Models whose `tool_choice` accepts only `auto` or `none`; forced choices are downgraded. | | `preserveReasoningContentModels?` | `string[]` | Models requiring prior assistant `reasoning_content` in chat history. A provider save that keeps the destination keeps the stored list, including `[]`; see [What a provider save keeps](#what-a-provider-save-keeps). `PATCH /api/providers?name=` accepts an array or `null` to clear it. | | `inlineThinkTagModels?` | `string[]` | Opt-in recovery for `openai-chat` gateways without a server-side reasoning parser. A leading `` / `` / `` block (optionally after whitespace) activates splitting in streamed and buffered replies. All answer whitespace is preserved. Subsequent tags are delimiters anywhere, including same-line interleaving and code fences; this mode does not interpret Markdown. Ordinary text or a code fence before the first tag keeps the whole reply untouched. Off by default; prefer structured upstream reasoning or `reasoningSplitModels` where supported. | diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 0e406b44a1e..0394a17d5f3 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -54,9 +54,10 @@ HTTP 504 with an `upstream_no_response` error. A slow but alive origin therefore client's own deadline or for `connectTimeoutMs` (default 200s), whichever comes first; a connect timeout that fires after the create frame was sent settles as the same 504. A socket that closes or errors before the first Responses event settles as an HTTP 502 with -`upstream_closed_before_response`. These statuses are never retried inside the proxy — the -frame may already be executing upstream, so the client applies its own retry policy exactly as -it would when connected to the backend directly. Once the response has started, a later drop +`upstream_closed_before_response`. The proxy does not retry either status on its own: the frame +may already be executing upstream, so the client applies its own retry policy exactly as it would +when connected to the backend directly. The one exception is that 502 on a provider that opted +into `retryOnReset`, described below. Once the response has started, a later drop surfaces inside the stream as before. `stallTimeoutSec` is unrelated to this window. An ordinary HTTP send has a third case. When the connection dies before any response header @@ -78,7 +79,10 @@ duplicate a turn. A native Responses provider can opt into replacing that send with [`retryOnReset`](/reference/configuration/providers/#provider-entries-ocxproviderconfig). The same grant covers the case where the connection survives the header and the SSE body then dies carrying only control -events, because the caller has observed nothing in either one. A replacement happens only when +events, because the caller has observed nothing in either one. It also covers the canonical +ChatGPT WebSocket above: a socket that closes or errors before the first Responses event is +replaced by one HTTP send, never by a second socket. A silent socket keeps its 504, and a native +steering or injection turn is never replaced. A replacement happens only when the request is self-contained (`store: false`, complete input, client-executed tools only, no server-side continuation state), and one logical request gets the configured number of replacements in total — across every recovery leg and every combo child, not one each. The diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index b64d30b2acc..5f25901ec69 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -163,6 +163,7 @@ ocx combo set balanced \ | Классифицированная ошибка аутентификации, подписки, квоты, rate-limit, перегрузки или upstream-server | Перевести цель в cooldown и переключиться, даже если одного статуса недостаточно. | | Отмена клиентом (499), `origin_rejected`, отказ из-за cyber-policy, переполнение контекста или иной некорректный запрос | Остановиться и вернуть ошибку; другая цель не сделает такой запрос корректным. | | Структурированный HTTP 400, отклоняющий необязательный `user`, неподдерживаемое значение `reasoning.effort`/`reasoning_effort` или специфичный для модели отказ входного изображения (`param: input`) | До начала вывода переходит к следующей допустимой цели без охлаждения; см. «Совместимость необязательных параметров» ниже. | +| Первый вызов инструмента в Responses-ходе внутрипроцессного адаптера (`runTurn`), который текущий запрос не объявлял, до любого вывода и до побочного эффекта, небезопасного для повтора | Охлаждает цель и переходит к следующей с тем же каталогом инструментов. После видимого вывода или небезопасного для повтора побочного эффекта отказ окончателен. Запросы Chat Completions и Anthropic Messages не меняются. | | Любая другая неклассифицированная ошибка | Остановиться и вернуть ошибку. | Если `cooldownMs` не задан, цель после hop использует upstream fallback: 5 секунд для 429, ограничивающих частоту запросов, с кодом upstream `1302` или `1305`, и 60 секунд в остальных случаях. Если он задан, `cooldownMs` применяется, когда нет пригодного сигнала upstream `Retry-After` или сигнала сброса Codex, включая такие 429, ограничивающие частоту запросов. Принимаются числовые секунды в `Retry-After` и значения HTTP-date; любой cooldown ограничен 10 минутами. Приоритет от сильного к слабому: явный `Retry-After` → заголовки сброса Codex (`x-codex-primary-reset-at`, `x-codex-secondary-reset-at` или `x-codex-tertiary-reset-at`) → `cooldownMs` этой combo (если задан) → 5-секундный fallback для rate-limit-кодов upstream `1302`/`1305` → стандартные 60 секунд. Корректный немедленный `Retry-After: 0` сохраняется как немедленная директива upstream, а не заменяется настроенным cooldown. diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index d84233b89bf..26aa73be207 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -147,7 +147,7 @@ cross-route credential fallback не существует. Строки API GPT- | `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Только для провайдеров с API-ключом (`authMode: "key"`). Опциональный повтор при 429 на том же таргете: если `retryOn429` отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. При 429: ожидание (`Retry-After` апстрима или фиксированный интервал) и повтор идентичного запроса на том же ключе до любого фейловера ключей — покрывает основной цикл восстановления текстовых ходов, passthrough-канал Responses, мост изображений/видео, sidecar web-search и терминальные продолжения. Повтор допустим только для HTTP 429, полученных до начала потока; пользовательские транспорты `runTurn` не входят в цикл HTTP-повторов. `attempts` — это число повторов на том же ключе после первого 429 (всего отправок = `attempts` + 1) и единый бюджет на запрос, общий для основного цикла восстановления, терминального продолжения и повторов моста. Исчерпание `attempts` лишь останавливает дальнейшие повторы на том же ключе; далее применяется обычный фейловер ключей или финальная обработка ошибки в зависимости от доступных таргетов — на passthrough-канале с ключевой аутентификацией фейловера нет, поэтому исчерпанный 429 возвращается как есть. Codex сам никогда не повторяет 429, поэтому это единственная защита для провайдеров с одним ключом. По умолчанию: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (любое ожидание ограничено `maxIntervalMs`, который сам ограничен 600000), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Только для провайдеров `openai-chat` и `openai-responses` с аутентификацией по ключу. Провайдеры с `authMode: "forward"` (пул аккаунтов ChatGPT) никогда не читают эту настройку и сохраняют число повторов по умолчанию. Опциональный повтор при временных статусах апстрима до начала потока (500, 502, 503, 504, 520, 521, 522): если параметр отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. Покрывает исходный запрос `Responses`, продолжение терминального предохранителя, нативный `/v1/chat/completions`, а также повторные запросы при восстановлении после 429 или ошибки учётной записи. `attempts` — ОБЩЕЕ число разрешённых отправок в апстрим для одного запроса, включая первую (1..10, по умолчанию 3). Это единый бюджет на запрос, общий с восстановлением после сброса соединения, поэтому `3` означает, что до провайдера дойдут не более трёх реальных запросов. Ожидание использует экспоненциальную задержку с фиксированной начальной величиной 400 мс, ограниченную 5 с, и учитывает `Retry-After`. Параметр не связан с `retryOn429`, который обрабатывает ограничение частоты запросов; сбои после начала потока никогда не воспроизводятся. | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Только для нативных провайдеров `openai-responses`, включая `authMode: "forward"`. Необязательная замена отправки, которая завершилась неудачей, когда вызывающая сторона ещё ничего не наблюдала: если параметр отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. Покрывает обе неоднозначные стадии — обрыв соединения до любого заголовка ответа и обрыв тела SSE после заголовка, когда оно несло только управляющие события. Заменяется только самодостаточный запрос: `store: false`, полный `input`, отсутствие `previous_response_id`, `conversation` и `stream_id`, и только инструменты, исполняемые клиентом. `replacements` — число замещающих отправок, которые ОДИН логический запрос может сделать по всем участкам и всем дочерним запросам комбо (1..2, по умолчанию 1). Это не число повторов на участок и не бюджет отправок, поэтому замена всё равно должна поместиться в уже имеющийся у участка лимит отправок. Запрос, который уже выдал вывод или вызов инструмента, не заменяется никогда, каким бы ни было это значение. Замещающий вывод модели всё равно может быть оплачен, если источник уже начал первый, поэтому параметр выключен по умолчанию. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Только для нативных провайдеров `openai-responses`, включая `authMode: "forward"`. Необязательная замена отправки, которая завершилась неудачей, когда вызывающая сторона ещё ничего не наблюдала: если параметр отсутствует, функция выключена; наличие объекта включает её, если только `enabled: false`. Покрывает обе неоднозначные стадии — обрыв соединения до любого заголовка ответа и обрыв тела SSE после заголовка, когда оно несло только управляющие события. Так же покрывается upstream WebSocket canonical ChatGPT, который закрылся или завершился ошибкой после отправки кадра create и до любого события Responses; замена в этом случае отправляется по HTTP. Заменяется только самодостаточный запрос: `store: false`, полный `input`, отсутствие `previous_response_id`, `conversation` и `stream_id`, и только инструменты, исполняемые клиентом. `replacements` — число замещающих отправок, которые ОДИН логический запрос может сделать по всем участкам и всем дочерним запросам комбо (1..2, по умолчанию 1). Это не число повторов на участок и не бюджет отправок, поэтому замена всё равно должна поместиться в уже имеющийся у участка лимит отправок. Запрос, который уже выдал вывод или вызов инструмента, не заменяется никогда, каким бы ни было это значение. Замещающий вывод модели всё равно может быть оплачен, если источник уже начал первый, поэтому параметр выключен по умолчанию. | | `autoToolChoiceOnlyModels?` | `string[]` | Модели, у которых `tool_choice` принимает только `auto` или `none`; forced choice понижается. | | `preserveReasoningContentModels?` | `string[]` | Модели, которым нужен предыдущий assistant `reasoning_content` в chat history. Сохранение в дашборде не меняет записанный список, в том числе `[]`. `PATCH /api/providers?name=` принимает массив или `null`, чтобы его очистить. Сохранение, которое переносит провайдера на другой адаптер, базовый URL или режим аутентификации, список не сохраняет (см. раздел ниже). | | `reasoningDetailsModels?` | `string[]` | Модели, чей endpoint возвращает thinking как структурированный массив `reasoning_details` (MiniMax M-series с `reasoning_split`); потоковые дельты — кумулятивные снимки, сравниваемые по префиксу, а сохранённый reasoning воспроизводится массивом `reasoning_details` вместо строки `reasoning_content`. | diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md index 3116f94978c..80026db6155 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -231,6 +231,7 @@ ikiye ayrılır. | Sınıflandırılmış kimlik doğrulama, abonelik, kota, hız sınırı, aşırı yük veya yukarı akış sunucu hatası | Yalnızca durum yeterli olmadığında bile hedefi soğutun ve atlayın. | | İstemci iptali (499), `origin_rejected`, siber politika reddi, bağlam taşması veya diğer geçersiz istek | Durun ve hatayı döndürün; başka bir hedef isteği geçerli kılmaz. | | `user` alanını açıkça reddeden, `reasoning.effort`/`reasoning_effort` için desteklenmeyen değer bildiren veya modele özgü görüntü girdisini reddeden (`param: input`) yapılandırılmış HTTP 400 | Çıktı başlamadan önce bekleme süresi kaydetmeden sonraki uygun hedefe atlar; aşağıdaki isteğe bağlı parametre uyumluluğuna bakın. | +| Süreç içi bir bağdaştırıcının (`runTurn`) yürüttüğü Responses turunda, geçerli isteğin bildirmediği ilk araç çağrısı (herhangi bir çıktıdan ve yeniden oynatılamaz yan etkiden önce) | Hedefi bekleme süresine alır ve aynı araç kataloğuyla sonraki hedefe atlar. Görünür çıktıdan veya yeniden oynatılamaz bir yan etkiden sonra ret kesindir. Chat Completions ve Anthropic Messages istekleri değişmez. | | Diğer sınıflandırılmamış hatalar | Durun ve hatayı döndürün. | Atlanan bir hedef varsayılan olarak 60 saniye boyunca soğuma süresine girer. diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 8fa358583c5..d1c8fb099a2 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -148,7 +148,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `responsesSnapshotRepair?` | `boolean` | SSE ve JSON'daki seyrek Responses yaşam döngüsü anlık görüntüleri için varsayılan olarak devre dışı bırakılmış istemciye yönelik onarım. Ham inceleme ve kalıcılık değişmeden kalırken eksik kurallı durumu, çıktıyı ve araç meta verilerini doldurur. | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Yalnızca API anahtarı sağlayıcıları (`authMode: "key"`). İsteğe bağlı aynı hedef 429 yeniden denemesi: `retryOn429` olmadığında özellik kapalıdır; nesnenin varlığı `enabled: false` olmadığı sürece özelliği etkinleştirir. 429'da proxy bekler (yukarı akış `Retry-After` veya sabit aralık) ve herhangi bir anahtar yük devretmesinden önce aynı istek üzerinde aynı anahtarla aynı isteği yeniden oynatır — ana metin turu kurtarma döngüsü, Responses doğrudan geçiş hattı, görsel/video köprüsü, web araması sidecar'ı ve terminal devamları genelinde. Yalnızca akış öncesi HTTP 429 yanıtları yeniden oynatma için uygundur; özel `runTurn` aktarımları HTTP yeniden deneme döngüsünün dışındadır. `attempts`, ilk 429'dan sonraki aynı anahtar yeniden oynatmalarını sayar (toplam gönderim = `attempts` + 1) ve ana kurtarma döngüsü, terminal koruma devamı ve köprü yeniden denemeleri tarafından paylaşılan tek bir istek genelinde bütçedir. `attempts`'ı tüketmek yalnızca daha fazla aynı anahtar yeniden oynatmasını durdurur: normal anahtar yük devretmesi veya nihai hata işleme daha sonra kullanılabilir hedeflere göre geçerli olur — anahtar kimlik doğrulamalı doğrudan geçiş hattında yük devretme yoktur, bu nedenle tükenen 429 olduğu gibi görünür. Codex'in kendisi 429'u asla yeniden denemez, bu nedenle tek anahtarlı sağlayıcılar için tek savunma budur. Varsayılanlar: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (tek bir bekleme `maxIntervalMs` ile sınırlandırılır, kendisi de 600000 ile sınırlandırılır), `respectRetryAfter: true`. | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Yalnızca anahtarla kimlik doğrulanan `openai-chat` ve `openai-responses` sağlayıcıları. `authMode: "forward"` sağlayıcıları (ChatGPT hesap havuzu) bu seçeneği hiç okumaz ve varsayılan merdiveni korur. Akış öncesi geçici yukarı akış durumları (500, 502, 503, 504, 520, 521, 522) için isteğe bağlı yeniden deneme: seçenek belirtilmezse kapalıdır; nesnenin varlığı, `enabled: false` olmadığı sürece özelliği etkinleştirir. İlk Responses isteğini, terminal koruma devamını, yerel `/v1/chat/completions` isteklerini ve 429/hesap kurtarma yeniden getirmelerini kapsar. `attempts`, bir istek için ilk gönderim dahil izin verilen yukarı akış gönderimlerinin TOPLAM sayısıdır (1..10, varsayılan 3) — bağlantı sıfırlama kurtarmasıyla paylaşılan, istek kapsamlı tek bütçedir; dolayısıyla `3`, sağlayıcıya en fazla üç gerçek isteğin ulaşması anlamına gelir. Beklemelerde 400 ms'lik sabit üstel geri çekilme uygulanır, süre 5 sn ile sınırlandırılır ve `Retry-After` dikkate alınır. Hız sınırlamasını işleyen `retryOn429` seçeneğinden ayrıdır; akış ortası hataları hiçbir zaman yeniden oynatılmaz. | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Yalnızca yerel `openai-responses` sağlayıcıları, `authMode: "forward"` dahil. Çağıranın hiçbir şey gözlemlemediği bir anda başarısız olan gönderimin isteğe bağlı olarak değiştirilmesi: seçenek belirtilmezse kapalıdır; nesnenin varlığı, `enabled: false` olmadığı sürece özelliği etkinleştirir. İki belirsiz aşamayı da kapsar: yanıt başlığı gelmeden kopan bağlantı ve başlıktan sonra yalnızca denetim olayları taşırken kopan SSE gövdesi. Yalnızca kendi kendine yeten bir istek değiştirilir: `store: false`, eksiksiz `input`, `previous_response_id`, `conversation` veya `stream_id` bulunmaması ve yalnızca istemcinin yürüttüğü araçlar. `replacements`, BİR mantıksal isteğin tüm bacaklar ve tüm combo alt istekleri boyunca yapabileceği değiştirme gönderimi sayısıdır (1..2, varsayılan 1). Bacak başına yeniden deneme sayısı da gönderim bütçesi de değildir; bu yüzden bir değiştirme gönderimi, ilgili bacağın hâlihazırda sahip olduğu gönderim payına sığmak zorundadır. Halihazırda çıktı veya araç çağrısı üretmiş bir istek, bu değer ne olursa olsun asla değiştirilmez. Kaynak ilk çıkarımı zaten başlatmışsa değiştirilen çıkarım yine ücretlendirilebilir; bu nedenle varsayılan olarak kapalıdır. | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Yalnızca yerel `openai-responses` sağlayıcıları, `authMode: "forward"` dahil. Çağıranın hiçbir şey gözlemlemediği bir anda başarısız olan gönderimin isteğe bağlı olarak değiştirilmesi: seçenek belirtilmezse kapalıdır; nesnenin varlığı, `enabled: false` olmadığı sürece özelliği etkinleştirir. İki belirsiz aşamayı da kapsar: yanıt başlığı gelmeden kopan bağlantı ve başlıktan sonra yalnızca denetim olayları taşırken kopan SSE gövdesi. Kanonik ChatGPT upstream WebSocket'i create çerçevesi gönderildikten sonra, herhangi bir Responses olayından önce kapanır veya hata verirse aynı şekilde kapsanır ve değiştirme gönderimi HTTP üzerinden yapılır. Yalnızca kendi kendine yeten bir istek değiştirilir: `store: false`, eksiksiz `input`, `previous_response_id`, `conversation` veya `stream_id` bulunmaması ve yalnızca istemcinin yürüttüğü araçlar. `replacements`, BİR mantıksal isteğin tüm bacaklar ve tüm combo alt istekleri boyunca yapabileceği değiştirme gönderimi sayısıdır (1..2, varsayılan 1). Bacak başına yeniden deneme sayısı da gönderim bütçesi de değildir; bu yüzden bir değiştirme gönderimi, ilgili bacağın hâlihazırda sahip olduğu gönderim payına sığmak zorundadır. Halihazırda çıktı veya araç çağrısı üretmiş bir istek, bu değer ne olursa olsun asla değiştirilmez. Kaynak ilk çıkarımı zaten başlatmışsa değiştirilen çıkarım yine ücretlendirilebilir; bu nedenle varsayılan olarak kapalıdır. | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`'u yalnızca `auto` veya `none` kabul eden modeller; zorunlu seçimlerin derecesi düşürülür. | | `preserveReasoningContentModels?` | `string[]` | Sohbet geçmişinde önceki asistan `reasoning_content`'ini gerektiren modeller. Dashboard üzerinden yapılan kayıtlar, `[]` dahil saklanan listeyi korur. `PATCH /api/providers?name=`, bir dizi veya temizlemek için `null` kabul eder. Sağlayıcıyı başka bir bağdaştırıcıya, temel URL'ye veya kimlik doğrulama moduna taşıyan bir kayıt listeyi korumaz (aşağıdaki bölüme bakın). | | `reasoningDetailsModels?` | `string[]` | Thinking'i yapılandırılmış bir `reasoning_details` dizisi olarak döndüren modeller (`reasoning_split` ile MiniMax M-serisi); akış deltaları önek farkıyla işlenen kümülatif anlık görüntülerdir ve korunan reasoning, `reasoning_content` dizesi yerine `reasoning_details` dizisi olarak yeniden oynatılır. | diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index 2ebc775f0db..3df8776c3fd 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -152,6 +152,7 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。 | 被分类为认证、订阅、配额、速率限制、过载或上游服务器错误 | 即使仅凭状态码不足以判断,也会使该目标进入冷却并跳转。 | | 客户端取消(499)、`origin_rejected`、cyber-policy 拒绝、上下文溢出,或其他无效请求 | 停止并返回错误;换其他目标也无法让请求变得有效。 | | 结构化 HTTP 400,明确拒绝 `user`、对 `reasoning.effort`/`reasoning_effort` 返回不支持值,或返回模型特定图像输入拒绝(`param: input`) | 在输出开始前跳转到下一个符合条件的目标,且不记录冷却时间;参见下方可选参数兼容性。 | +| 由进程内适配器(`runTurn`)执行的 Responses 回合中,当前请求未声明的第一个工具调用(在任何输出和不可重放的副作用之前) | 让该目标进入冷却,并以相同的工具目录跳转到下一个目标。出现可见输出或不可重放的副作用之后,拒绝即为最终结果。Chat Completions 和 Anthropic Messages 请求不受影响。 | | 任何其他未分类错误 | 停止并返回错误。 | 未设置 `cooldownMs` 时,发生跳转的目标使用上游回退值:对于上游代码为 `1302` 或 `1305` 的请求速率限制 429,等待 5 秒;其他情况等待 60 秒。设置后,只要不存在可用的上游 `Retry-After` 或 Codex 重置信号,就会应用 `cooldownMs`,包括这些请求速率限制 429。接受数字形式的 `Retry-After` 秒数和 HTTP-date 值,每次冷却最多封顶 10 分钟。优先级从强到弱依次为:显式 `Retry-After` → Codex 重置标头(`x-codex-primary-reset-at`、`x-codex-secondary-reset-at` 或 `x-codex-tertiary-reset-at`)→ combo 的 `cooldownMs`(已设置时)→ 上游速率限制代码 `1302`/`1305` 的 5 秒请求速率限制回退值 → 60 秒默认值。有效的即时指令 `Retry-After: 0` 会保留为上游即时指令,不会被配置的冷却替换。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index e0e0502826e..5230ad5dfc1 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -134,7 +134,7 @@ selector,而不是分配一个新名称。 | `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 | | `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | 仅限 API-key 提供商(`authMode: "key"`)。可选的同目标 429 重试:未配置 `retryOn429` 时功能关闭;对象存在即启用,除非 `enabled: false`。收到 429 时等待(上游 `Retry-After` 或固定间隔)后在相同 key 上重放完全相同请求,再进入任何 key 故障转移——覆盖主文本恢复循环、Responses passthrough、图像/视频桥、web-search 侧车与终结续接。重放仅适用于流开始前的 HTTP 429 响应;自定义 `runTurn` 传输不在 HTTP 重试循环范围内。`attempts` 是首个 429 之后的同 key 重放次数(总发送次数 = `attempts` + 1),是主恢复循环、终结守卫续接与桥接重试共享的按请求统一预算;`attempts` 耗尽只会停止进一步的同 key 重放:随后按可用目标进行正常的 key 故障转移或最终错误处理——key 认证的 passthrough 线路上没有故障转移,因此耗尽的 429 会原样透出。Codex 自身从不重试 429,因此这是单 key 提供商唯一的防线。默认值:`enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(单次等待以 `maxIntervalMs` 为上限,其本身上限 600000)、`respectRetryAfter: true`。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 仅限使用 key 认证的 `openai-chat` 与 `openai-responses` 提供商。`authMode: "forward"` 的提供商(ChatGPT 账号池)从不读取此选项,保持默认重试次数。可选的流开始前上游瞬态状态码(500、502、503、504、520、521、522)重试:未配置时关闭;对象存在即启用,除非 `enabled: false`。覆盖初始 Responses 请求、终结守卫续接、原生 `/v1/chat/completions`,以及 429/账户恢复重新获取。`attempts` 是单个请求允许向上游发送的总次数,包含首次发送(1..10,默认 3);它是与连接重置恢复共享的按请求预算,因此 `3` 表示最多只有三个实际请求到达提供商。等待采用固定 400 毫秒的指数退避,上限为 5 秒,并遵循 `Retry-After`。此选项独立于处理速率限制的 `retryOn429`;流开始后的故障绝不会重放。 | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 仅限原生 `openai-responses` 提供商,包含 `authMode: "forward"`。可选地替换一次在调用方尚未观察到任何内容时就失败的发送:未配置时关闭;对象存在即启用,除非 `enabled: false`。涵盖两个不确定阶段——响应头到达前连接断开,以及响应头之后 SSE 正文只承载控制事件时断开。只有自包含的请求才会被替换:`store: false`、完整的 `input`、没有 `previous_response_id`/`conversation`/`stream_id`,且只使用由客户端执行的工具。`replacements` 是单个逻辑请求在所有环节和所有组合子请求中可以进行的替换发送次数(1..2,默认 1);它既不是按环节的重试次数,也不是发送预算,因此替换发送仍必须落在该环节已有的发送额度之内。已经产生输出或工具调用的请求,无论此值为何都不会被替换。如果上游已经开始了第一次推理,被替换的推理仍可能计费,因此该选项默认关闭。 | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 仅限原生 `openai-responses` 提供商,包含 `authMode: "forward"`。可选地替换一次在调用方尚未观察到任何内容时就失败的发送:未配置时关闭;对象存在即启用,除非 `enabled: false`。涵盖两个不确定阶段——响应头到达前连接断开,以及响应头之后 SSE 正文只承载控制事件时断开。canonical ChatGPT 上游 WebSocket 在 create 帧发出之后、任何 Responses 事件到达之前关闭或出错时,也按同样方式处理,其替换发送走 HTTP。只有自包含的请求才会被替换:`store: false`、完整的 `input`、没有 `previous_response_id`/`conversation`/`stream_id`,且只使用由客户端执行的工具。`replacements` 是单个逻辑请求在所有环节和所有组合子请求中可以进行的替换发送次数(1..2,默认 1);它既不是按环节的重试次数,也不是发送预算,因此替换发送仍必须落在该环节已有的发送额度之内。已经产生输出或工具调用的请求,无论此值为何都不会被替换。如果上游已经开始了第一次推理,被替换的推理仍可能计费,因此该选项默认关闭。 | | `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` 只接受 `auto` 或 `none` 的模型;强制选择会被降级。 | | `preserveReasoningContentModels?` | `string[]` | 需要在聊天历史中保留先前 assistant `reasoning_content` 的模型。从仪表板保存时会保留已存储的列表(包括 `[]`)。`PATCH /api/providers?name=` 接受数组,或传入 `null` 清除该字段。将提供方改到其他适配器、base URL 或认证模式的保存不会保留该列表(见下文)。 | | `reasoningDetailsModels?` | `string[]` | 以结构化 `reasoning_details` 数组返回思考内容的模型(启用 `reasoning_split` 的 MiniMax M 系列);流式增量为累积快照,按前缀差分处理,保留的推理以 `reasoning_details` 数组而非 `reasoning_content` 字符串回放。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md index 02b5288a4e5..2b1b4a7d656 100644 --- a/docs-site/src/content/docs/zh-tw/guides/combos.md +++ b/docs-site/src/content/docs/zh-tw/guides/combos.md @@ -166,6 +166,7 @@ Combo 失敗分為**跳轉**失敗與**終端**失敗。 | 分類為認證、訂閱、配額、限流、過載或上游伺服器錯誤 | 冷卻目標並跳轉,即使單靠狀態碼不足。 | | 客戶端取消(499)、`origin_rejected`、cyber-policy 拒絕、上下文溢出或其他無效請求 | 停止並回傳錯誤;另一個目標不會讓請求變為有效。 | | 結構化 HTTP 400,明確拒絕 `user`、對 `reasoning.effort`/`reasoning_effort` 回傳不支援值,或回傳模型特定影像輸入拒絕(`param: input`) | 在輸出開始前跳轉到下一個符合條件的目標,且不記錄冷卻時間;參見下方選用參數相容性。 | +| 由行程內轉接器(`runTurn`)執行的 Responses 回合中,目前請求未宣告的第一個工具呼叫(在任何輸出與不可重播的副作用之前) | 讓該目標進入冷卻,並以相同的工具目錄跳轉到下一個目標。出現可見輸出或不可重播的副作用之後,拒絕即為最終結果。Chat Completions 與 Anthropic Messages 請求不受影響。 | | 任何其他未分類錯誤 | 停止並回傳錯誤。 | 跳轉的目標預設進入 60 秒冷卻。若上游回應包含有效的 `Retry-After` 值,opencodex 改用它。接受數字秒與 HTTP-date 值,且每次冷卻上限為 10 分鐘。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 91bef990796..7b992e4a55e 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -106,7 +106,7 @@ ocx models provider openrouter on | `parallelToolCalls?` | `boolean` | 切換平行工具呼叫。OpenAI Chat 預設開啟;非 chat adapter 僅在明確 `true` 時廣告。 | | `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 預設停用的下游 SSE 修復,用於精確佔位 id 與缺失的終端 id。Function-call id 永不被重寫。 | | `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 僅限使用金鑰認證的 `openai-chat` 與 `openai-responses` 供應商。`authMode: "forward"` 的供應商(ChatGPT 帳號池)從不讀取此選項,維持預設重試次數。選擇性重試串流開始前的暫時性上游狀態(500、502、503、504、520、521、522):未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋初始 `Responses` 請求、終止防護續接、原生 `/v1/chat/completions`,以及 429/帳號復原的重新擷取。`attempts` 是單一請求允許傳送至上游的總次數,包含第一次(1..10,預設 3);這是與連線重設復原共用的單一請求範圍預算,因此 `3` 表示最多只有三個實際請求會送達供應商。等待採固定 400 毫秒、上限 5 秒的指數退避,並遵循 `Retry-After`。此機制獨立於處理速率限制的 `retryOn429`;串流中的失敗絕不重播。 | -| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 僅限原生 `openai-responses` 供應商,包含 `authMode: "forward"`。可選擇性地替換一次在呼叫端尚未觀察到任何內容時就失敗的傳送:未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋兩個不確定階段——回應標頭抵達前連線中斷,以及標頭之後 SSE 內文只載有控制事件時中斷。只有自我完備的請求才會被替換:`store: false`、完整的 `input`、沒有 `previous_response_id`/`conversation`/`stream_id`,且僅使用由用戶端執行的工具。`replacements` 是單一邏輯請求在所有環節與所有組合子請求中可進行的替換傳送次數(1..2,預設 1);它既不是各環節的重試次數,也不是傳送預算,因此替換傳送仍必須落在該環節既有的傳送額度之內。已經產生輸出或工具呼叫的請求,無論此值為何都不會被替換。若上游已經開始第一次推論,被替換的推論仍可能計費,因此此選項預設停用。 | +| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 僅限原生 `openai-responses` 供應商,包含 `authMode: "forward"`。可選擇性地替換一次在呼叫端尚未觀察到任何內容時就失敗的傳送:未設定時停用;只要有此物件即啟用,除非 `enabled: false`。涵蓋兩個不確定階段——回應標頭抵達前連線中斷,以及標頭之後 SSE 內文只載有控制事件時中斷。canonical ChatGPT upstream WebSocket 在 create 訊框送出之後、任何 Responses 事件抵達之前關閉或出錯時,也以相同方式處理,其替換傳送改走 HTTP。只有自我完備的請求才會被替換:`store: false`、完整的 `input`、沒有 `previous_response_id`/`conversation`/`stream_id`,且僅使用由用戶端執行的工具。`replacements` 是單一邏輯請求在所有環節與所有組合子請求中可進行的替換傳送次數(1..2,預設 1);它既不是各環節的重試次數,也不是傳送預算,因此替換傳送仍必須落在該環節既有的傳送額度之內。已經產生輸出或工具呼叫的請求,無論此值為何都不會被替換。若上游已經開始第一次推論,被替換的推論仍可能計費,因此此選項預設停用。 | | `autoToolChoiceOnlyModels?` | `string[]` | 其 `tool_choice` 僅接受 `auto` 或 `none` 的模型;強制選擇被降級。 | | `preserveReasoningContentModels?` | `string[]` | 需要在 chat 歷史中保留先前 assistant `reasoning_content` 的模型。從儀表板儲存時會保留已儲存的清單(包括 `[]`)。`PATCH /api/providers?name=` 接受陣列,或傳入 `null` 清除該欄位。將供應商改到其他轉接器、base URL 或驗證模式的儲存不會保留該清單(見下文)。 | | `reasoningDetailsModels?` | `string[]` | 以結構化 `reasoning_details` 陣列回傳思考內容的模型(啟用 `reasoning_split` 的 MiniMax M 系列);串流增量為累積快照,以前綴差分處理,保留的推理以 `reasoning_details` 陣列而非 `reasoning_content` 字串重播。 | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3dd1302600a..d8534b72812 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1363,6 +1363,7 @@ "responses-azure-opaque-blob-recovery.test.ts": "responses", "responses-bare-echo-helper-fence.test.ts": "responses", "responses-canonical-only-top-level-fields.test.ts": "responses", + "responses-code-mode-goal-helpers.test.ts": "responses", "responses-code-mode-patch-compile.test.ts": "responses", "responses-code-mode-shell-compile.test.ts": "responses", "responses-compact-handoff-admission.test.ts": "responses", @@ -1710,6 +1711,7 @@ "winsw-stop-hardening.test.ts": "windows", "winsw.test.ts": "service", "workflow-budget.test.ts": "lib", + "ws-ambiguous-resend.test.ts": "responses", "ws-endpoint.test.ts": "responses", "ws-failure-stage.test.ts": "responses", "ws-native-injection.test.ts": "responses", diff --git a/src/adapters/devin/cloud-direct/chat.ts b/src/adapters/devin/cloud-direct/chat.ts index 047d16d29ee..ba38ebf812e 100644 --- a/src/adapters/devin/cloud-direct/chat.ts +++ b/src/adapters/devin/cloud-direct/chat.ts @@ -1559,7 +1559,9 @@ export async function* streamChatEvents(req: CloudChatRequest): AsyncGenerator, + classifyFirstEvent?: (event: AdapterEvent) => Extract | undefined, ): Promise { const iterator = source[Symbol.asyncIterator](); const buffered: AdapterEvent[] = []; @@ -165,6 +166,12 @@ export async function preflightAdapterEvents( if (buffered.length > PREFLIGHT_HEARTBEAT_RETAIN_LIMIT) buffered.shift(); continue; } + const classifiedError = replayUnsafe ? undefined : classifyFirstEvent?.(next.value); + if (classifiedError) { + buffered.push(classifiedError); + await iterator.return?.(); + return { stream: replay(buffered, iterator), error: classifiedError, empty: false, replayUnsafe }; + } buffered.push(next.value); if (next.value.type === "error") { await iterator.return?.(); diff --git a/src/lib/request-execution-budget.ts b/src/lib/request-execution-budget.ts index 429c0bc1e9d..cd74c808598 100644 --- a/src/lib/request-execution-budget.ts +++ b/src/lib/request-execution-budget.ts @@ -187,6 +187,8 @@ export interface RequestExecutionBudget extends TransientSendBudget { * that cannot reach it has no operator override, which is the fail-closed answer. */ claimAmbiguousResend?(limit: number): boolean; + /** True once any scope has claimed a replacement for this logical request. */ + readonly ambiguousResendSpent?: boolean; } const RESERVE_FUNDED_CLASSES: ReadonlySet = new Set([ @@ -224,6 +226,7 @@ interface SharedSendLedger { * it in -- it has to ask whoever holds the request's grant. */ claimAmbiguousResend(limit: number): boolean; + readonly ambiguousResendSpent: boolean; readonly observer?: RequestSendObserver; } @@ -240,20 +243,23 @@ const sharedSendLedgers = new WeakMap( * already spent the one replacement a strict row granted buy another as soon as a more * permissive row asked, which is a second duplicate inference of one turn. */ -function createAmbiguousResendGrant(): (limit: number) => boolean { +function createAmbiguousResendGrant(): Pick { let claimed = 0; let ceiling: number | undefined; - return (limit: number): boolean => { - const presented = Number.isFinite(limit) ? Math.trunc(limit) : 0; - // A zero or nonsense ceiling refuses on its own and leaves the request's alone. It is a - // caller that cannot state a grant, not an operator narrowing this request: a leg with no - // policy is refused before it ever claims, so binding the request to a malformed number - // would only let such a caller cancel a grant an opted-in row really made. - if (presented <= 0) return false; - ceiling = ceiling === undefined ? presented : Math.min(ceiling, presented); - if (claimed >= ceiling) return false; - claimed += 1; - return true; + return { + get ambiguousResendSpent(): boolean { return claimed > 0; }, + claimAmbiguousResend(limit: number): boolean { + const presented = Number.isFinite(limit) ? Math.trunc(limit) : 0; + // A zero or nonsense ceiling refuses on its own and leaves the request's alone. It is a + // caller that cannot state a grant, not an operator narrowing this request: a leg with no + // policy is refused before it ever claims, so binding the request to a malformed number + // would only let such a caller cancel a grant an opted-in row really made. + if (presented <= 0) return false; + ceiling = ceiling === undefined ? presented : Math.min(ceiling, presented); + if (claimed >= ceiling) return false; + claimed += 1; + return true; + }, }; } @@ -302,6 +308,7 @@ function createRequestExecutionBudgetWithLedger( claimAmbiguousResend(limit: number): boolean { return counter.claimAmbiguousResend(limit); }, + get ambiguousResendSpent(): boolean { return counter.ambiguousResendSpent; }, reserveDispatch(intent: DispatchIntent): DispatchDecision { if (intent.replaySafe === false) return { allowed: false, reason: "not-replay-safe" }; if (counter.spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" }; @@ -396,10 +403,12 @@ export function createRequestExecutionBudget( logicalRequestId?: string, observer?: RequestSendObserver, ): RequestExecutionBudget { + const grant = createAmbiguousResendGrant(); return createRequestExecutionBudgetWithLedger(policy, logicalRequestId, { spent: 0, pendingExternalSends: 0, - claimAmbiguousResend: createAmbiguousResendGrant(), + claimAmbiguousResend: grant.claimAmbiguousResend, + get ambiguousResendSpent(): boolean { return grant.ambiguousResendSpent; }, ...(observer ? { observer } : {}), }); } @@ -430,10 +439,24 @@ export function deriveRequestExecutionBudget( * share pending external bookings and a durable-spend observer, which are private by * construction; a bridged scope keeps the parent's spend accurate and books nothing of its own. */ +/** + * Grant claims made THROUGH a bridge, keyed by the bridged parent so every scope derived from it + * sees them. A parent that predates `ambiguousResendSpent` can still grant through + * `claimAmbiguousResend`; reading only its missing flag would report "not spent" after a derived + * scope spent the grant, and a combo would then hop on a zero-output 200 from the replacement. + */ +const bridgedGrantClaims = new WeakMap(); + function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger { const existing = sharedSendLedgers.get(parent); if (existing) return existing; let pendingExternalSends = 0; + let bridged = bridgedGrantClaims.get(parent); + if (!bridged) { + bridged = { claimed: false }; + bridgedGrantClaims.set(parent, bridged); + } + const claims = bridged; return { get spent(): number { return parent.used; }, set spent(next: number) { parent.used = next; }, @@ -446,7 +469,12 @@ function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger { // but the grant can -- `claimAmbiguousResend` is public on the parent. A parent that does // not implement it grants nothing, which is the fail-closed answer for a send whose // upstream state is unknown. - claimAmbiguousResend: (limit: number): boolean => parent.claimAmbiguousResend?.(limit) === true, + claimAmbiguousResend: (limit: number): boolean => { + const granted = parent.claimAmbiguousResend?.(limit) === true; + if (granted) claims.claimed = true; + return granted; + }, + get ambiguousResendSpent(): boolean { return claims.claimed || parent.ambiguousResendSpent === true; }, }; } diff --git a/src/lib/request-resend-gate.ts b/src/lib/request-resend-gate.ts index 0107aa648bb..eb75a2a0c9b 100644 --- a/src/lib/request-resend-gate.ts +++ b/src/lib/request-resend-gate.ts @@ -5,9 +5,10 @@ * asked it for a connection that died before any head; #4989 asked it for an SSE body that * died after the head while carrying only control events. Both are the same row of the stage * table: a stage the caller observed nothing at, with a cause that cannot prove the origin did - * not run the turn. `resendPermission` answers `refused-ambiguous` for both, and - * request-failure-model.ts already names the only thing that may override that answer -- a - * narrowly scoped recovery a maintainer opted into and bounded. + * not run the turn. A Codex WebSocket that dies under its create frame before any Responses + * event (#4191) is that row a third time. `resendPermission` answers `refused-ambiguous` for all + * of them, and request-failure-model.ts already names the only thing that may override that + * answer -- a narrowly scoped recovery a maintainer opted into and bounded. * * One override, not two. The reason this module exists rather than a boolean in each caller is * that a request which resets before the head and again after it would otherwise buy a diff --git a/src/lib/retry-delay.ts b/src/lib/retry-delay.ts index 31251770e2a..271a660c4bd 100644 --- a/src/lib/retry-delay.ts +++ b/src/lib/retry-delay.ts @@ -19,6 +19,7 @@ const MAX_COMPONENTS = 16; function durationSeconds(tail: string, allowBareSeconds: boolean): number | undefined { let rest = tail.trimStart(); + if (allowBareSeconds && rest.startsWith("~")) rest = rest.slice(1).trimStart(); let seconds = 0; let components = 0; while (true) { @@ -39,6 +40,10 @@ function durationSeconds(tail: string, allowBareSeconds: boolean): number | unde rest = rest.slice(component[0].length); const separator = SEPARATOR.exec(rest)![0]; const next = rest.slice(separator.length); + // The approximation marker belongs to the whole hint, once, before the + // first component. A second one ("~1 minute ~30 seconds") is malformed and + // must reject the hint rather than shorten it to the first component. + if (next.startsWith("~")) return undefined; if (!/^[+-]?(?:\d|\.\d)/.test(next)) break; // A numeric continuation is part of this duration; a malformed second // component must reject the hint, not silently shorten it to the first. @@ -50,7 +55,8 @@ function durationSeconds(tail: string, allowBareSeconds: boolean): number | unde /** * Supports reset(s) in, try again in and Retry-After/retry after hints; accepts - * compound durations and rounds UP once after summing all components. + * compound durations, the generated Retry-After approximation marker, and + * rounds UP once after summing all components. * A bare number is permitted only for header-style Retry-After hints, never * for "reset in 2026". When a message declares several usable lower bounds, * honour the longest one rather than re-entering a still-live quota window. diff --git a/src/lib/upstream-retry.ts b/src/lib/upstream-retry.ts index 3419b8ffb8a..2b2b546388c 100644 --- a/src/lib/upstream-retry.ts +++ b/src/lib/upstream-retry.ts @@ -440,6 +440,25 @@ function invitesResendAfterReplacement(status: number): boolean { || status === 307 || status === 308 || status === 413 || status >= 500; } +/** + * The answer a request keeps once its one operator replacement has gone out. + * + * A status that invites another send settles as the refusal. Any other answer keeps its real + * status: no client retries it, and the caller needs the evidence (a 400 names the request + * defect). The marker still stops this process from using it as a recovery trigger, such as the + * opaque-blob rebuild of a 400 or a combo hop on a context overflow, because each of those checks + * it before sending again. + */ +export function settleOperatorReplacement(response: Response): Response { + if (response.ok) return response; + if (invitesResendAfterReplacement(response.status)) { + cancelResponseBodyBestEffort(response); + return replayRefusalResponse(); + } + markResponseNonReplayable(response); + return response; +} + export async function fetchWithAttemptDeadline( url: string, init: RequestInit, @@ -624,18 +643,7 @@ export async function fetchWithResetRetry( opts.onSendsConsumed?.(1); try { const response = await doFetch(attempt === 0 ? firstRecovery : "connection-reset"); - if (spentOperatorReplacement && !response.ok) { - if (invitesResendAfterReplacement(response.status)) { - cancelResponseBodyBestEffort(response); - return replayRefusalResponse(); - } - // Any other answer keeps its real status: no client retries it, and the caller needs the - // evidence (a 400 names the request defect). The marker still stops this process from - // using it as a recovery trigger, such as the opaque-blob rebuild of a 400 or a combo hop - // on a context overflow, because each of those checks it before sending again. - markResponseNonReplayable(response); - } - return response; + return spentOperatorReplacement ? settleOperatorReplacement(response) : response; } catch (err) { if (opts.abortSignal?.aborted) throw err; if (!isConnectionResetError(err)) { diff --git a/src/responses/code-mode-helper-compat.ts b/src/responses/code-mode-helper-compat.ts index e103590c4a0..4d4721288ac 100644 --- a/src/responses/code-mode-helper-compat.ts +++ b/src/responses/code-mode-helper-compat.ts @@ -92,6 +92,9 @@ export function compileCodeModeHelperInput( } return `const result = await tools.view_image(${JSON.stringify(viewArgs)});\nif (result && result.image_url) { image(result.image_url); } else { text(result); }`; } + if (helperName === "create_goal" || helperName === "get_goal" || helperName === "update_goal") { + return `const result = await tools.${helperName}(${JSON.stringify(args)});\ntext(result);`; + } return `const result = await tools.exec_command(${JSON.stringify(args)});\ntext(result);`; } diff --git a/src/server/responses/codex-ws-exchange.ts b/src/server/responses/codex-ws-exchange.ts index 62129632cea..474bc2bb1f0 100644 --- a/src/server/responses/codex-ws-exchange.ts +++ b/src/server/responses/codex-ws-exchange.ts @@ -11,7 +11,7 @@ import type { CodexWsSession } from "./codex-ws-session"; import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage, codexWsCreateFrameExceedsLimit, codexWsFailureDetail, codexWsPreResponseFailure, markCodexWsStage, codexWsOcxVersion, - type CodexWsFailureStage, type CodexWsStageRecord } from "./codex-ws-wire"; + markCodexWsSocketDeath, type CodexWsFailureStage, type CodexWsStageRecord } from "./codex-ws-wire"; interface ExchangeOptions { nativeControl?: NativeResponseControl; @@ -213,14 +213,14 @@ export function codexWsExchange(options: ExchangeOptions): Promise { resolve(response); }; - const failStream = (error: unknown, status: 502 | 504 = 502) => { + const failStream = (error: unknown, status: 502 | 504 = 502, { socketDied = false } = {}) => { if (terminal) return; terminal = true; if (sent && !responseCommitted && metadata) { // Nothing has been promised to the client yet, so the honest answer is a gateway // status, not a 200 whose body then fails. The frame may already be executing - // upstream: the response is marked non-replayable so no layer of this process sends - // it again, and the client applies its own retry policy as it would on the direct + // upstream: the response is marked non-replayable so no retry layer of this process + // sends it again, and the client applies its own retry policy as it would on the direct // path. Same settle order as a refused create: snapshot, detach, close, dispose. const prelude = metadata.snapshot(); // Claim the commit slot so no later path can resolve a second, 200 Response. @@ -230,7 +230,13 @@ export function codexWsExchange(options: ExchangeOptions): Promise { session.dispose(); const message = error instanceof Error ? error.message : String(error); const failureResponse = codexWsPreResponseFailure(status, message, prelude); - markCodexWsStage(failureResponse, stageRecord(Buffer.byteLength(frameText, "utf8"))); + const stage = failureStage(); + markCodexWsStage(failureResponse, stageRecord(stage.requestBytes)); + // #4191: a socket that died under the send is the one settle the dispatch may replace, + // once, and only under the operator's `retryOnReset` grant. Native steering and injection + // are left out: their channel may already have sent continuation frames on this socket, so + // the create frame alone no longer describes the turn. + if (socketDied && !nativeControl) markCodexWsSocketDeath(failureResponse, stage); resolve(failureResponse); return; } @@ -549,7 +555,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise { resolve(sseFallback(url, init)); return; } - if (sent && !terminal) failStream(closedBeforeTerminalMessage(event, failureStage())); + if (sent && !terminal) { + failStream(closedBeforeTerminalMessage(event, failureStage()), 502, { socketDied: true }); + } }; const onError = () => { @@ -560,7 +568,10 @@ export function codexWsExchange(options: ExchangeOptions): Promise { cleanup(); session.dispose(); resolve(sseFallback(url, init)); - } else failStream(`codex websocket transport error${codexWsFailureDetail(failureStage())}`); + } else { + const message = `codex websocket transport error${codexWsFailureDetail(failureStage())}`; + failStream(message, 502, { socketDied: true }); + } }; detachOwner = session.bindOwner(reason => cancelExchange(reason)); ws.addEventListener("open", onOpen); diff --git a/src/server/responses/codex-ws-wire.ts b/src/server/responses/codex-ws-wire.ts index e8edb1106c7..b2d904caa5e 100644 --- a/src/server/responses/codex-ws-wire.ts +++ b/src/server/responses/codex-ws-wire.ts @@ -214,10 +214,10 @@ export function classifyCodexWsFailure(stage: CodexWsFailureStage): CodexWsFailu * whose failures cannot be compared with anything else, which is the reported symptom -- every * such failure reached the user as one of two bare sentences. * - * It does not relax the transport's own rule. The no-replay-after-send contract in - * `codex-ws-exchange.ts` holds regardless of what this returns, and the stage below is - * deliberately not consulted as a fallback-eligibility signal; it reports where the exchange got - * to, and `resendPermission` happens to agree that everything past `before-send` is refused. + * It does not relax the transport's own rule. The stage below reports where the exchange got to, + * and `resendPermission` agrees that everything past `before-send` is refused. When a socket dies + * under the send, this stage is what the resend gate is asked with (#4191), so the operator's + * `retryOnReset` grant is the only way past that refusal, as it is for an HTTP reset. */ export const CODEX_WS_FAILURE_PROJECTION = { /** The create frame never left, so the origin provably never saw this turn. */ @@ -236,6 +236,29 @@ export function projectCodexWsFailure( return CODEX_WS_FAILURE_PROJECTION[classifyCodexWsFailure(stage)]; } +const socketDeathStages = new WeakMap(); + +/** + * Record that a pre-response settle came from the socket closing or failing under the send (#4191), + * rather than from silence, a refused frame or a local limit. + * + * A fact about how the exchange ended, not a grant. The settle is the same non-replayable 502 + * either way; whether the turn may go out once more is the resend gate's question, and only the + * operator's `retryOnReset` grant can answer it yes. + */ +export function markCodexWsSocketDeath(response: Response, stage: CodexWsFailureStage): void { + socketDeathStages.set(response, projectCodexWsFailure(stage).stage); +} + +/** + * Where the send stood when its socket died: `pre-header` when nothing came back and + * `protocol-prelude` when frames arrived but none was a Responses event. Undefined for every other + * response. + */ +export function codexWsSocketDeathStage(response: Response): RequestFailureStage | undefined { + return socketDeathStages.get(response); +} + /** * Render the stage as a suffix appended to an existing failure message. * diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 7712904aaf4..1178c4f86a8 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -73,6 +73,7 @@ import { import { preflightComboStreamResponse } from "./combo-stream-preflight"; import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow"; import { mandatoryResponsesReasoningReplayUnavailable } from "./core-replay"; +import { settleOperatorReplacement } from "../../lib/upstream-retry"; /** * Sends one combo target may run on its own before the ladder moves on. A target is a whole @@ -678,9 +679,20 @@ export async function executeComboResponses( attemptRetained = true; lastFailure = failure.response; lastFailedChildLog = childLog; + // A replacement that answers 200 is unmarked, and its zero-output failure only exists once + // preflight has rebuilt the stream as a fresh Response. A spent grant never hops: a status the + // client would resend becomes the refusal, and anything else reaches the client as it is. + const spentReplacement = !failure.nonReplayable && comboSendScope?.ambiguousResendSpent === true; + if (spentReplacement) { + const settled = settleOperatorReplacement(failure.response); + if (settled !== failure.response) { + adoptFailedChildLog(childLog); + return settled; + } + } // A non-replayable failure (the answer to a spent ambiguous-reset replacement) may follow a // send that already ran the turn, so no later target may receive it, whatever its status says. - const failureDecision = failure.nonReplayable + const failureDecision = failure.nonReplayable || spentReplacement ? "stop" : comboFailureDecision(failure.response.status, failure.classificationText, { code: failure.upstreamCode, diff --git a/src/server/responses/core-opaque-recovery.ts b/src/server/responses/core-opaque-recovery.ts index 788e2f2c1ab..b436ebdc9e6 100644 --- a/src/server/responses/core-opaque-recovery.ts +++ b/src/server/responses/core-opaque-recovery.ts @@ -311,14 +311,15 @@ export function shouldAttemptOpaqueBlobRecovery(args: { * Peek the upstream error body for the reasoning-effort downgrade. Only 400/403 are considered * and the body must be complete and display-safe, the same contract the other rejection peeks * use. The match is deliberately narrow: the upstream has to name reasoning effort, so an - * unrelated 400 never triggers a replay. + * unrelated 400 never triggers a replay. A non-replayable answer, such as one to a spent operator + * replacement, is never read: the first send may already have run the turn. */ export async function reasoningEffortRejectionText( response: Response, alreadyAttempted: boolean, signal: AbortSignal, ): Promise { - if (alreadyAttempted) return undefined; + if (alreadyAttempted || isNonReplayableResponse(response)) return undefined; if (response.status !== 400 && response.status !== 403) return undefined; try { const body = await readBoundedResponseBody(response.clone(), { signal }); diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 733081b0bdc..2380d80d946 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -102,7 +102,7 @@ import { clearCodexModelDenialEvidence, recordCodexModelDenialEvidence, } from "../../codex/model-entitlements"; -import { isCodexWsUpstreamResponse, readCodexWsStage } from "./codex-ws-wire"; +import { codexWsSocketDeathStage, isCodexWsUpstreamResponse, readCodexWsStage } from "./codex-ws-wire"; import { linkAbortSignal } from "./core-lifetime"; import type { CodexAuthContext } from "../../codex/auth-context"; import { checkOutboundBodySize, describeOutboundBodyRefusal } from "./outbound-body-guard"; @@ -112,7 +112,10 @@ import { fetchWithTransientRetry, applyUpstreamRecoveryInit, isNonReplayableResponse, + isConnectionResetError, + settleOperatorReplacement, refetchAfterProtocolSafeReset, + replayRefusalResponse, prepareSameTarget429Wait, sleepWithAbort, } from "../../lib/upstream-retry"; @@ -212,6 +215,7 @@ export async function preparePassthroughExchange( | "recoveryClassFor" | "sendBudgetExhausted" | "claimAmbiguousResend" + | "ambiguousResendSpent" | "reserveCredentialHop" | "pendingHopPermit" | "workflowRootId" @@ -751,6 +755,57 @@ export async function preparePassthroughExchange( */ const claimPreHeaderResend = (): boolean => authorizeResendForRecovery("pre-header", "connection-reset", ambiguousResend()).allowed; + /** + * The one replacement send the ambiguous rows at the end of the recovery loop may buy: an SSE + * body that died before any output, and a Codex WebSocket that died under its create frame + * (#4191). + * + * HTTP-only for both. A replacement HTTP body must not open a fresh WebSocket exchange: the SSE + * row replaces an HTTP stream, which a WS create frame is not, and the WebSocket row replaces + * the transport that just failed. + */ + const sendAmbiguousReplacement = ( + signal: AbortSignal = upstream.signal, + ): Promise => fetchWithHeaderTimeout( + request.url, + applyUpstreamRecoveryInit({ + method: request.method, + headers: request.headers, + body: request.body, + }, "connection-reset"), + signal, + connectMs, + true, + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + httpOnly: true, + providerName: route.providerName, + modelId: route.modelId, + dispatchOverride: oauthDispatch(request), + beforeDispatch: headers => { + if (signal.aborted) throw signal.reason; + if (!transportState.selectionIsCurrent(transportState.requestBindings.get(request))) { + throw new Error("Credential selection changed before pre-output stream recovery"); + } + if (isCanonicalOpenAiForwardProvider(route.provider)) { + createCodexReserveDispatchGuard( + admissionState.authCtx, + options.codexAuthPolicy ?? config, + route.modelId, + options.admission, + options.visionDescribeTerminal === true, + )?.(headers); + } + // Recorded with the kind the gate derived its cause from, at the moment the + // send actually leaves. One authorisation, one recorded reason, one send. + transportState.noteRoutedAttemptSend(passthroughEstimate, "connection-reset"); + // Charged to the SAME request counter every other send goes through. The + // replacement is bought here rather than by a nested retry helper, so there is + // one charge for one send and no per-layer counter to reconcile. + noteTransientSends(1); + }, + }), + route.provider.authMode === "forward", + ); /** * Refuse a built body that exceeds the operator's configured ceiling, before it is sent. * @@ -1554,7 +1609,8 @@ export async function preparePassthroughExchange( if (options.abortSignal?.aborted) return transportFailureResponse(options.abortSignal.reason); upstreamResponse = preflight.response; if (preflight.kind === "failed") { - if (!configuredTransientSendBudgetExhausted()) { + // A zero-output failure does not undo an ambiguous replacement already sent. + if (!configuredTransientSendBudgetExhausted() && !sendBudgetState.ambiguousResendSpent) { const streamedOpaqueRecovery = await attemptOpaqueBlobRecovery({ response: upstreamResponse, outboundBody: request.body, @@ -1574,6 +1630,8 @@ export async function preparePassthroughExchange( logCtx.terminalHttpStatus = preflightLog.terminalHttpStatus; logCtx.terminalErrorCode = preflightLog.terminalErrorCode; logCtx.terminalIncompleteReason = preflightLog.terminalIncompleteReason; + // The projected failure must not invite another send after the replacement was spent. + if (sendBudgetState.ambiguousResendSpent) upstreamResponse = settleOperatorReplacement(upstreamResponse); } } // Console Go (opencode-zen / opencode-go) intermittently rejects a body it accepts seconds @@ -1637,6 +1695,47 @@ export async function preparePassthroughExchange( } } + // The WebSocket row of the same table (#4191). A Codex socket that closed or failed under its + // create frame, before any Responses event, settled as a non-replayable 502 and every leg above + // let it through. Whether the turn ran upstream is as unknown as after a reset before the head, + // so the same grant decides, asked with the stage the exchange reached. The replacement's answer + // then goes round the loop like any other, and once the grant is spent nothing may send the + // turn a third time. + const socketDeathStage = codexWsSocketDeathStage(upstreamResponse); + if ( + socketDeathStage + && !upstream.signal.aborted + // Asked before the gate, which claims last: a replacement the budget cannot fund must not + // spend the request's one grant. + && remainingTransientSendBudget(transientSendAttempts()) > 0 + && authorizeResendForRecovery(socketDeathStage, "connection-reset", ambiguousResend()).allowed + ) { + try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } + console.warn(`[upstream-retry] codex websocket died before any Responses event (${safeHostLabel(request.url)}); ` + + "using one replacement over HTTP"); + let replacement: Response | undefined; + while (!replacement) { + try { + replacement = await sendAmbiguousReplacement().then(adoptObservedResponse); + } catch (err) { + if (upstream.signal.aborted) return transportFailureResponse(err); + // A replacement that reset before its head is the pre-header row again: a configured + // second grant (and a send the budget can still fund) may buy one more send. The gate + // claims from the request's finite allowance, so this loop is bounded by it. + if ( + isConnectionResetError(err) + && remainingTransientSendBudget(transientSendAttempts()) > 0 + && claimPreHeaderResend() + ) continue; + break; + } + } + // The first send may already have run the turn, so a replacement that failed settles as + // the refusal rather than as a transport error the client would retry. + upstreamResponse = replacement ? settleOperatorReplacement(replacement) : replayRefusalResponse(); + continue passthroughRecovery; + } + // The post-header row of the same table. A native SSE body can die after the head with the // caller having observed nothing, which is the identical question the pre-header helper // answers -- and the identical grant, because both claim from this request's one allowance. @@ -1660,48 +1759,7 @@ export async function preparePassthroughExchange( upstreamResponse, { model: logCtx.model, provider: logCtx.provider }, (error, stage) => refetchAfterProtocolSafeReset( - (signal = upstream.signal) => fetchWithHeaderTimeout( - request.url, - applyUpstreamRecoveryInit({ - method: request.method, - headers: request.headers, - body: request.body, - }, "connection-reset"), - signal, - connectMs, - true, - providerFetch(route.provider, options.codexWsRuntimeIdentity, { - // A replacement HTTP body must not open a fresh WebSocket exchange: the turn it - // replaces was an HTTP stream, and a WS create frame is a different send. - httpOnly: true, - providerName: route.providerName, - modelId: route.modelId, - dispatchOverride: oauthDispatch(request), - beforeDispatch: headers => { - if (signal.aborted) throw signal.reason; - if (!transportState.selectionIsCurrent(transportState.requestBindings.get(request))) { - throw new Error("Credential selection changed before pre-output stream recovery"); - } - if (isCanonicalOpenAiForwardProvider(route.provider)) { - createCodexReserveDispatchGuard( - admissionState.authCtx, - options.codexAuthPolicy ?? config, - route.modelId, - options.admission, - options.visionDescribeTerminal === true, - )?.(headers); - } - // Recorded with the kind the gate derived its cause from, at the moment the - // send actually leaves. One authorisation, one recorded reason, one send. - transportState.noteRoutedAttemptSend(passthroughEstimate, "connection-reset"); - // Charged to the SAME request counter every other send goes through. The - // replacement is bought here rather than by a nested retry helper, so there is - // one charge for one send and no per-layer counter to reconcile. - noteTransientSends(1); - }, - }), - route.provider.authMode === "forward", - ).then(adoptObservedResponse), + (signal = upstream.signal) => sendAmbiguousReplacement(signal).then(adoptObservedResponse), error, { abortSignal: upstream.signal, diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index 5d8bc2f9132..c78809913e2 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -1,5 +1,6 @@ import { comboFailureDecision } from "../../combos/failover"; import { readBoundedResponseBody } from "../../lib/bounded-body"; +import { isNonReplayableResponse } from "../../lib/upstream-retry"; import { finishRequestAttempt, type RequestLogContext } from "../request-log"; import { linkRequestSessionLane } from "../request-log-conversation"; import type { OcxConfig } from "../../types"; @@ -84,6 +85,8 @@ function errorCodeFromText(text: string): string | undefined { async function shouldHopPolicyCandidate(response: Response, signal?: AbortSignal): Promise { if (response.status < 400 || signal?.aborted) return false; + // A response that must not be sent again cannot open a policy-candidate retry either. + if (isNonReplayableResponse(response)) return false; try { const inspected = await readBoundedResponseBody(response.clone(), { signal }); const text = inspected.displaySafe ? inspected.text : ""; diff --git a/src/server/responses/request-send-budget.ts b/src/server/responses/request-send-budget.ts index 44ca16bc4d1..33f9b3da212 100644 --- a/src/server/responses/request-send-budget.ts +++ b/src/server/responses/request-send-budget.ts @@ -274,6 +274,9 @@ export function createResponsesSendBudget( noteAdapterRecoveryWithheld, sendBudgetExhausted, claimAmbiguousResend, + get ambiguousResendSpent(): boolean { + return isRequestExecutionBudget(sendBudget) && sendBudget.ambiguousResendSpent === true; + }, get pendingHopPermit(): SingleUseDispatchPermit | undefined { return pendingHopPermit; }, @@ -313,6 +316,7 @@ function adapterDispatchBudgetView( get lastTargetKey(): string | undefined { return budget.lastTargetKey; }, remainingBaseSends: (cap: number): number => budget.remainingBaseSends(cap), claimAmbiguousResend: (limit: number): boolean => budget.claimAmbiguousResend?.(limit) === true, + get ambiguousResendSpent(): boolean { return budget.ambiguousResendSpent === true; }, reserveDispatch(intent: DispatchIntent): DispatchDecision { // A dispatch whose upstream state is unknown is refused on its own merits. A hop that // already paid does not make an unsafe replay safe, so that check stays with the budget. diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index 04d61848f9e..2264d9b5fb2 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -18,9 +18,9 @@ import type { AdapterEventQueue } from "../../adapters/run-turn-queue"; import type { AttemptRecoveryKind } from "../../usage/log"; import { providerFetch } from "./fetch-helpers"; import { normalizeLogConversationId } from "../request-log-conversation"; -import type { AdapterEvent, OcxProviderContinuationState } from "../../types"; +import { normalizeDeclaredToolName, type AdapterEvent, type OcxProviderContinuationState } from "../../types"; import { adapterFailureFromMessage, SEND_BUDGET_EXHAUSTED_CODE } from "../../lib/errors"; -import { SendBudgetExhaustedError } from "../../lib/upstream-retry"; +import { SendBudgetExhaustedError, markResponseNonReplayable } from "../../lib/upstream-retry"; import { GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST, hasEligibleGenericOAuthFailoverTarget, @@ -39,6 +39,7 @@ import { import { rememberResponseState } from "../../responses/state"; import { trackStreamLifetime } from "../lifecycle"; import { awaitThoughtSignatureDurability } from "../../responses/thought-signature-replay"; +import { undeclaredToolCallMessage } from "../responses-undeclared-tool-guard"; /** One responsibility of the Responses request pipeline; state owners are explicit. */ export async function executeResponsesRunTurn( @@ -366,6 +367,20 @@ export async function executeResponsesRunTurn( }; const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; + const enforceDeclaredToolNames = inboundWire !== "chat" && inboundWire !== "anthropic"; + const classifyUndeclaredFirstTool = ( + event: AdapterEvent, + ): Extract | undefined => { + if (!enforceDeclaredToolNames || event.type !== "tool_call_start") return undefined; + const effectiveName = normalizeDeclaredToolName(event.name, declaredToolNames); + if (declaredToolNames.has(effectiveName)) return undefined; + return { + type: "error", + status: 502, + errorType: "upstream_error", + message: undeclaredToolCallMessage(effectiveName), + }; + }; if (parsed.stream) { void runTurn(); let eventSource: AsyncIterable = queue.stream(); @@ -375,12 +390,16 @@ export async function executeResponsesRunTurn( eventSource = await preflightRunTurnFailover(eventSource); } if (options.comboAttempt) { - const preflight = await preflightAdapterEvents(eventSource); + const preflight = await preflightAdapterEvents(eventSource, classifyUndeclaredFirstTool); if (preflight.error || preflight.empty) { runTurnAbort.abort(); queue.close(); const message = preflight.error?.message ?? "Adapter ended before producing a response"; - return formatErrorResponse(502, "upstream_error", redactSecretString(message)); + const failure = formatErrorResponse(502, "upstream_error", redactSecretString(message)); + // A replay-unsafe heartbeat means the adapter already ran a local side effect, so the + // combo must not send this turn to another target: the failure stays with this child. + if (preflight.replayUnsafe) markResponseNonReplayable(failure); + return failure; } eventSource = preflight.stream; } @@ -412,7 +431,7 @@ export async function executeResponsesRunTurn( stallTimeoutSec: config.stallTimeoutSec, hideThinkingSummary: parsed.options.hideThinkingSummary, declaredToolNames, - enforceDeclaredToolNames: inboundWire !== "chat" && inboundWire !== "anthropic", + enforceDeclaredToolNames, toolParameterSchemas, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), ...(routedCompaction ? { compaction: true } : {}), @@ -468,12 +487,24 @@ export async function executeResponsesRunTurn( events = runTurnEvents; } if (options.comboAttempt) { - const firstMeaningful = events.find(event => event.type !== "heartbeat"); - if (!firstMeaningful || firstMeaningful.type === "error") { - const message = firstMeaningful?.type === "error" + const firstMeaningfulIndex = events.findIndex(event => event.type !== "heartbeat"); + const firstMeaningful = firstMeaningfulIndex === -1 ? undefined : events[firstMeaningfulIndex]; + // Same boundary as the streaming preflight: a replay-unsafe heartbeat means the adapter + // already ran a local side effect, so an undeclared tool call after it keeps the bridge's + // fail-closed refusal instead of becoming a hop that sends the turn to another target. + const replayUnsafe = events + .slice(0, firstMeaningfulIndex === -1 ? events.length : firstMeaningfulIndex) + .some(event => event.type === "heartbeat" && event.replayUnsafe === true); + const classifiedError = firstMeaningful && !replayUnsafe + ? classifyUndeclaredFirstTool(firstMeaningful) + : undefined; + if (!firstMeaningful || firstMeaningful.type === "error" || classifiedError) { + const message = classifiedError?.message ?? (firstMeaningful?.type === "error" ? firstMeaningful.message - : "Adapter ended before producing a response"; - return formatErrorResponse(502, "upstream_error", redactSecretString(message)); + : "Adapter ended before producing a response"); + const failure = formatErrorResponse(502, "upstream_error", redactSecretString(message)); + if (replayUnsafe) markResponseNonReplayable(failure); + return failure; } } let providerState: OcxProviderContinuationState | undefined; @@ -483,7 +514,7 @@ export async function executeResponsesRunTurn( hideThinkingSummary: parsed.options.hideThinkingSummary, toolNsMap, declaredToolNames, - enforceDeclaredToolNames: inboundWire !== "chat" && inboundWire !== "anthropic", + enforceDeclaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames, diff --git a/src/types/tools.ts b/src/types/tools.ts index c0c7b54a63c..c9066d01b58 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -67,9 +67,11 @@ export function dottedToolName(namespace: string | undefined, name: string): str * Codex's code-mode shell tool is declared as `exec` (a freeform custom tool whose own * description mentions the nested `await tools.exec_command(...)` helper). Some routed providers * echo that helper name as the tool-call name, emitting `exec_command`, `write_stdin`, - * `apply_patch`, or `view_image` instead of the declared `exec`. Accept these nested helper names - * only when the request catalog actually declares `exec` and does not itself declare the emitted - * name (an MCP server may legitimately advertise one under its own namespace). + * `apply_patch`, `view_image`, or one of the goal helpers (`create_goal`, `get_goal`, + * `update_goal`, #5495) instead of the declared `exec`. Accept these nested helper names only + * when the request catalog actually declares `exec` and does not itself declare the emitted name + * (an MCP server may legitimately advertise one under its own namespace). The list is closed: an + * unlisted name is never admitted through `exec`. */ const LEGACY_SHELL_BRIDGE_TOOL_NAMES = ["exec_command", "shell_command"] as const; const CODE_MODE_HELPER_TOOL_NAMES = [ @@ -77,6 +79,9 @@ const CODE_MODE_HELPER_TOOL_NAMES = [ "write_stdin", "apply_patch", "view_image", + "create_goal", + "get_goal", + "update_goal", ] as const; /** @@ -105,7 +110,7 @@ export const CODE_MODE_HELPER_WIRE_NAMES: ReadonlySet = new Set( * * A bare alias is an ordinary compatibility affordance -- providers echo a namespaced tool * without its prefix, and restoring the identity needs the bare spelling registered. For these - * six it is also an authorization decision, because a declared-name set is what + * names it is also an authorization decision, because a declared-name set is what * `normalizeDeclaredToolName` and `declaresCodeModeExec` read: bare `exec` turns nested-helper * normalization on for a catalog that never declared the shell, bare `exec_command` or * `shell_command` turns it off for one that did, and the rest are accepted as declared calls the @@ -131,7 +136,8 @@ export const NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES: ReadonlySet = new Set * is declared and neither `default.` nor `default__` was explicitly declared (#4176). * The same wrapper may surround an already-flattened namespace identity; accept that exact * declared suffix without treating its child name as a bare declaration. - * Also normalizes legacy helper names (`exec_command`, `shell_command`, `apply_patch`, `view_image`) to + * Also normalizes nested helper names (`exec_command`, `shell_command`, `write_stdin`, + * `apply_patch`, `view_image`, `create_goal`, `get_goal`, `update_goal`) to * `exec` when code-mode `exec` is declared in the request catalog. * * @param name - The tool name emitted on the wire by the provider. diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 73b07cd9645..f6933c5ad05 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -97,7 +97,11 @@ Some adapters share another adapter's routed-tool semantics while retaining inde with no evidence the adapter hint is omitted and the encoder still serializes its own 128000 fallback for field #3. Connect trailer diagnostics expose only an allowlisted error code, hexadecimal trace id and typed `retryAfterSeconds`, optionally rendered as - generated `retry after ~Ns` wording; raw text stays internal because it can reflect credentials. Investigation and limits: + generated `retry after ~Ns` wording; the shared retry-delay parser accepts that generated + approximation marker and preserves the same lower-bound delay when the diagnostic returns as an + outer error message. Each bounded replay evaluates its own typed delay or compatible message, so + a later refusal may change between the raw reset sentence and the generated diagnostic without + losing the next wait. Raw text stays internal because it can reflect credentials. Investigation and limits: `devlog/_plan/260917_devin_input_ceiling/000_review.md`. The registry records those relationships with `contractParent`. A parent relationship does **not** mean the registry recursively constructs a parent adapter and injects it into the child. Azure and MiMo keep owning their existing internal composition. This avoids making production constructors depend on test/conformance needs and keeps this authority refactor behavior-neutral. diff --git a/structure/runtime.md b/structure/runtime.md index 36edbaf86d5..27c72b23ac8 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -500,7 +500,7 @@ The combo may advance to its next eligible unattempted target before output comm A definite context-window overflow is the fourth request-local verdict. A heterogeneous combo mixes windows, so "this turn does not fit THIS model" is not "this turn is impossible", and stopping at the first undersized target burned the ladder on turns a later target could hold. Evidence must come from the innermost provider message: `classifyError` remaps any occurrence of `context window`, `context length`, `maximum context` or `too many tokens` anywhere in the blob, and inheriting that looseness would let a `context_length_exceeded` token sitting in a `code` field beside `Unsupported parameter: user` authorize a replay. `src/combos/failover.ts` therefore unwraps only the exact proxy wrapper, within four envelopes and 16,384 characters, and reads the leaf message. A JSON-shaped body that does not parse fails closed, because `normalizeUpstreamErrorText` caps `classificationText` at 500 characters and a long envelope arrives here as a prefix. The verdict is admitted only for statuses that speak about the request — 400, 413, 422 and 5xx — so a 401/403 body that merely quotes context prose keeps its provider-wide cooldown instead of being rescored as request-shaped. Structured `origin_rejected`, cyber policy and the non-replayable post-send codes are all tested before it. -This is also why the classifier cannot duplicate visible output. A streaming child reaches combo classification only through `preflightComboStreamResponse`, which commits the child on any text, tool call or unknown event and synthesizes a failure envelope only for a zero-output terminal, so a turn whose text or tool call the client already saw is never reclassified as a hop. +This is also why the classifier cannot duplicate visible output. Native byte streams reach combo classification only through `preflightComboStreamResponse`, which commits the child on any text, tool call or unknown event and synthesizes a failure envelope only for a zero-output terminal. A `runTurn` adapter has the equivalent boundary in `preflightAdapterEvents`: when the first meaningful event is an undeclared tool call and no replay-unsafe heartbeat recorded a side effect, `src/server/responses/run-turn-execution.ts` checks it against the exact current request catalog and projects the existing fail-closed refusal as a pre-commit 502 so failover can continue without changing the catalog. Any earlier text, tool call, control boundary, unknown event or replay-unsafe heartbeat commits that child, so a turn whose output the client may have seen or whose side effect may have run is never replayed. Regression coverage: `tests/responses/responses-forward-prompt-envelope.test.ts`, `tests/routing/router-combo-failover-classification.test.ts`, `tests/routing/routing-policy-fallback.test.ts`, `tests/helpers/combo-context-overflow-cases.ts`, and `tests/server/server-combo-failover-e2e.test.ts`. diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index acbe91a6044..3b235faa42f 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -41,11 +41,12 @@ abort/sleep helpers from this module. ## Ambiguous-resend gate -A model POST that fails with the caller having observed nothing is one question asked at two -points: before any response head, and after a head whose SSE body carried only control events. -`src/lib/request-resend-gate.ts` is the single answer. It derives stage, cause, permission and -send class from `src/lib/request-failure-model.ts` and adds exactly one thing the table names -but does not implement — the narrowly scoped operator override for `refused-ambiguous`. +A model POST that fails with the caller having observed nothing is one question asked at three +points. Two are HTTP: before any response head, and after a head whose SSE body carried only +control events. The third is a Codex WebSocket that closes or errors under its create frame before +any Responses event (#4191). `src/lib/request-resend-gate.ts` is the single answer. It derives +stage, cause, permission and send class from `src/lib/request-failure-model.ts` and adds exactly +one thing the table names but does not implement — the narrowly scoped operator override for `refused-ambiguous`. The override is bounded on three axes at once. The provider opts in with `providers..retryOnReset`; the request must be one @@ -72,6 +73,17 @@ output cannot drain the replacement a later ambiguous reset would have been enti cause is derived from the `AttemptRecoveryKind` the send will be recorded as, which is what keeps the reason in the log and the reason the gate weighed from being two different values. +The WebSocket row is asked once, at the end of the passthrough recovery loop, after every leg has +let the settled 502 through. The exchange marks only a socket that closed or errored +(`markCodexWsSocketDeath`) and records the stage it reached: `pre-header` when nothing came back, +`protocol-prelude` when frames arrived but none was a Responses event. Silence keeps its 504, and a native steering or +injection exchange is never marked, because its channel may already have sent continuation frames +on that socket. The send budget is asked before the gate, so a replacement the request cannot fund +leaves the grant unspent. The replacement is one HTTP send, never a second socket, and its answer +is sorted exactly like the pre-header row's (see +[ambiguous connection-reset replay boundary](#ambiguous-connection-reset-replay-boundary)) before +it goes round the recovery loop again. + ## Console upload rejection recovery `src/providers/opencode-zen-rate-limit.ts` recognizes the complete Console upload-rejection envelope only at the effective HTTPS opencode.ai Zen/Go generation endpoint. A provider row name cannot authorize another destination. The two recovery loops in `src/server/responses/core.ts` wait 800 ms and replay the captured serialized request once; cancellation, nonreplayable responses, other errors and a second upload rejection keep their failure semantics. The recovery kind is persisted as `console-go-upload-retry` and has a localized Logs label. @@ -143,6 +155,17 @@ relay identity markers are restored on the wrapped response so Windows/Bun strea logging retain their existing owners. A failed child keeps its physical attempt receipt and usage, while the successful child remains the logical request result. +A `runTurn` adapter has the equivalent boundary in `preflightAdapterEvents` +(`src/adapters/run-turn-queue.ts`), streaming and non-streaming alike: when its first meaningful +event is a tool call the current request did not declare, and no earlier replay-unsafe heartbeat +recorded a side effect, `src/server/responses/run-turn-execution.ts` projects the fail-closed +undeclared-tool refusal as a pre-commit 502 so the combo can hop with the unchanged catalog. After +any output or a replay-unsafe heartbeat the refusal stays with that child. Chat Completions and +Anthropic Messages inbound requests do not use this classification. +The same heartbeat also decides an ordinary pre-output adapter error or an empty end: after a +replay-unsafe heartbeat the child's 502 is marked non-replayable, so the combo stops on it instead +of sending the turn to the next target. + HTTP 410 remains terminal by default. It advances and cools only the exact combo target when the structured code or message explicitly identifies a model lifecycle event (end-of-life, retired, deprecated, sunset, decommissioned, or no longer available). An unrelated application-level 410 is @@ -250,7 +273,8 @@ this same refusal. Nothing on that path hands the client a status that invites t to be sent again. See [ambiguous-resend gate](#ambiguous-resend-gate). That includes what the replacement send itself answers. Once the grant is spent, the first send -may already have run the turn, so `fetchWithResetRetry` sorts the replacement's answer: +may already have run the turn, so `settleOperatorReplacement` sorts the replacement's answer, for +the pre-header row in `fetchWithResetRetry` and the WebSocket row alike: | Replacement answer | Result | | --- | --- | @@ -270,12 +294,28 @@ response, so `consumeComboFailure` records `nonReplayable` and the combo stops r on, say, a context overflow. The cost is that a real 401, 402 or 429 on a replacement send is not recorded against its credential on that request. +A 2xx replacement carries no marker, and its stream can still fail before any output. A marker +cannot carry that case, because the combo preflight rebuilds the failure as a fresh Response, so +the request execution budget's `ambiguousResendSpent` is what stops the combo, for all three +replacement rows (pre-header, SSE and WebSocket): a status the client would resend becomes the +refusal, and anything else keeps its status and the non-replayable marker. The direct path skips +the streamed opaque-blob rebuild and settles the preflight's projected failure by the same rule. +Policy fallback does not hop on a marked answer. +A scope derived from a budget this factory did not build (the shape-tested bridge in +`src/lib/request-execution-budget.ts`) remembers a grant it claimed through the bridge, keyed by +the bridged parent, so every sibling scope reports it spent even when that parent predates the +`ambiguousResendSpent` flag. + **An upstream reset observed mid-stream or after a terminal keeps its existing behaviour.** The passthrough read path still settles a genuine upstream reset as a synthetic 502, and the Codex WebSocket transport still settles `upstream_closed_before_response` (socket closed after the create frame) and `upstream_no_response` (origin never produced an event) as 502 -and 504. Those describe something the upstream did after our send, they are the contract the -public server reference already documents, and this release does not move them. +and 504. Those describe something the upstream did after our send, and they are the contract +the public server reference already documents. The 504 and a drop after the response started +are never replaced. Only the 502 of a socket that closed or errored before any Responses event +may be replaced over HTTP, when the provider opted into `retryOnReset` (#4191). That replacement +claims from the request's one allowance; if it resets before its head, that is the pre-header row +again and may use a configured second replacement, otherwise it settles as the refusal. This reclassification is the recorded behaviour change: before it, the pre-header refusal borrowed `upstream_closed_before_response` and its 502, which multiplied the duplicate send diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 9e647c27ab8..15b1127c91d 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -225,6 +225,12 @@ The passthrough guard resolves an emitted name through that same `normalizeDecla whatever it admits it must also EMIT under the resolved name. The two halves disagreed once: `normalizeDefaultNamespaceInItem` implemented only the bare-tool case (#4176), so a `default.`-prefixed code-mode helper was admitted as `exec` (#4412) and then relayed verbatim. +The bounded helper vocabulary includes the goal lifecycle calls that Codex advertises inside its +unified `exec` description (`create_goal`, `get_goal`, and `update_goal`). Routed providers that +echo one of those nested names, with or without an invented `default.` prefix, are restored to the +declared `exec` and compiled back to the matching `tools.(...)` call. A genuinely declared +bare goal tool keeps its bare identity, and a catalog declaring neither that tool nor `exec` still +fails closed. `default.view_image` is not a legal Responses tool name, and Codex stores what it receives, so the one relayed item was refused by `^[a-zA-Z0-9_-]+$` on every later replay of that conversation and the task could not be compacted or continued (#5095). The rewrite now falls back to the resolver @@ -427,15 +433,20 @@ committed. Later quota observations update only the captured serving account; they cannot retroactively change HTTP headers already sent to the client. Control frames remain bounded, and provider credential/cookie headers are not forwarded. Once a WS create may have been sent, a missing prelude, overflow or -disconnect settles as an errored SSE body rather than a retryable fetch failure, -so HTTP fallback cannot duplicate that inference. A standalone no-response +disconnect settles as a non-replayable gateway status before the first Responses +event, or as an errored SSE body after it, rather than as a retryable fetch +failure, so HTTP fallback cannot duplicate that inference. The one exception is a +socket that closed or errored before any Responses event on a provider that opted +into `retryOnReset`: the passthrough dispatch may spend the request's replacement +grant on one HTTP send (see [ambiguous-resend gate](responses-failover.md#ambiguous-resend-gate)). +A standalone no-response exchange has a 90-second prelude deadline in addition to the upgrade deadline. That prelude deadline is a ceiling, not a floor: the exchange runs under the caller's abort signal, so a `connectTimeoutMs` shorter than 90 seconds cancels an already-sent create before the prelude timer fires. These are transport-fidelity guarantees, not a provider-billing guarantee. -Every exchange also leaves a content-free stage record (`CodexWsStageRecord`, #4191): create-frame bytes (measured on failure only — the committed-success record keeps it null so the happy path never byte-counts a megabyte replay frame), send completion, numeric close code, elapsed and first-frame durations, frame counters, liveness ping/pong counts, pool reuse, and the OCX/Bun versions. The exchange pins the record on the resolved Response (`markCodexWsStage`, the same marker seam as `markCodexWsResponse`); `handleResponses` adopts it onto the serving attempt, and usage.jsonl persists it per attempt behind a drop-guard normalizer, so hand-edited rows cannot inject strings into the DTO. Later snapshots update the same response-local record in place, so an attempt holding the committed reference observes final success or failure counters. Each exchange supplies a complete fresh snapshot; separate responses keep distinct records. On eager-relay cancel-drain expiry, upstream cancellation finalizes the transport snapshot before the cancellation hook writes the usage row; an actual terminal observed within the drain still wins over cancellation. The record never carries conversation text, headers, close-reason text, or account identifiers, and it is not a fallback-eligibility signal: the no-replay-after-send contract stands regardless of what it says. +Every exchange also leaves a content-free stage record (`CodexWsStageRecord`, #4191): create-frame bytes (measured on failure only — the committed-success record keeps it null so the happy path never byte-counts a megabyte replay frame), send completion, numeric close code, elapsed and first-frame durations, frame counters, liveness ping/pong counts, pool reuse, and the OCX/Bun versions. The exchange pins the record on the resolved Response (`markCodexWsStage`, the same marker seam as `markCodexWsResponse`); `handleResponses` adopts it onto the serving attempt, and usage.jsonl persists it per attempt behind a drop-guard normalizer, so hand-edited rows cannot inject strings into the DTO. Later snapshots update the same response-local record in place, so an attempt holding the committed reference observes final success or failure counters. Each exchange supplies a complete fresh snapshot; separate responses keep distinct records. On eager-relay cancel-drain expiry, upstream cancellation finalizes the transport snapshot before the cancellation hook writes the usage row; an actual terminal observed within the drain still wins over cancellation. The record never carries conversation text, headers, close-reason text, or account identifiers, and it is not a fallback-eligibility signal: nothing it says permits a resend. The one replacement an operator can grant after a socket dies is the resend gate's decision (see [ambiguous-resend gate](responses-failover.md#ambiguous-resend-gate)). Eligible complete-input creates can retain a canonical upstream socket within one selected account, credential, thread and turn. Model/tier and immutable diff --git a/tests/adapters/run-turn-queue.test.ts b/tests/adapters/run-turn-queue.test.ts index b1f821d4818..b7515820937 100644 --- a/tests/adapters/run-turn-queue.test.ts +++ b/tests/adapters/run-turn-queue.test.ts @@ -317,6 +317,37 @@ describe("run-turn adapter event preflight", () => { expect(await collect(preflight.stream)).toEqual(values); }); + test("first-event classifier replaces only the first meaningful event", async () => { + const values: AdapterEvent[] = [ + heartbeat, + { type: "tool_call_start", id: "call_stale", name: "stale_tool" }, + text("must not run"), + ]; + const classified: Extract = { + type: "error", + status: 502, + message: "undeclared tool", + }; + const preflight = await preflightAdapterEvents(events(values), event => + event.type === "tool_call_start" ? classified : undefined); + expect(preflight.error).toEqual(classified); + expect(preflight.empty).toBe(false); + expect(await collect(preflight.stream)).toEqual([heartbeat, classified]); + }); + + test("first-event classifier cannot replace after a replay-unsafe heartbeat", async () => { + const tool: AdapterEvent = { type: "tool_call_start", id: "call_stale", name: "stale_tool" }; + const values: AdapterEvent[] = [{ type: "heartbeat", replayUnsafe: true }, tool]; + const preflight = await preflightAdapterEvents(events(values), () => ({ + type: "error", + status: 502, + message: "must not replace", + })); + expect(preflight.error).toBeUndefined(); + expect(preflight.replayUnsafe).toBe(true); + expect(await collect(preflight.stream)).toEqual(values); + }); + test("immediate done is a commit", async () => { const preflight = await preflightAdapterEvents(events([done])); expect(preflight.error).toBeUndefined(); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d4065d6db0c..1181ae53918 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1195,6 +1195,7 @@ "responses-azure-opaque-blob-recovery.test.ts": "responses", "responses-bare-echo-helper-fence.test.ts": "responses", "responses-canonical-only-top-level-fields.test.ts": "responses", + "responses-code-mode-goal-helpers.test.ts": "responses", "responses-code-mode-patch-compile.test.ts": "responses", "responses-code-mode-shell-compile.test.ts": "responses", "responses-compact-handoff-admission.test.ts": "responses", @@ -1542,6 +1543,7 @@ "winsw-stop-hardening.test.ts": "windows", "winsw.test.ts": "service", "workflow-budget.test.ts": "lib", + "ws-ambiguous-resend.test.ts": "responses", "ws-endpoint.test.ts": "responses", "ws-failure-stage.test.ts": "responses", "ws-native-injection.test.ts": "responses", diff --git a/tests/lib/ambiguous-resend-composition.test.ts b/tests/lib/ambiguous-resend-composition.test.ts index f9dd56e9bec..4b0fd0838ac 100644 --- a/tests/lib/ambiguous-resend-composition.test.ts +++ b/tests/lib/ambiguous-resend-composition.test.ts @@ -131,6 +131,27 @@ function oneLogicalRequest() { } describe("one resend budget across composed recovery legs", () => { + test("a fresh budget has not spent an ambiguous resend", () => { + const budget = createRequestExecutionBudget(); + expect(budget.ambiguousResendSpent).toBe(false); + expect(budget.claimAmbiguousResend?.(0)).toBe(false); + expect(budget.ambiguousResendSpent).toBe(false); + }); + + test("a derived scope observes the parent's spent ambiguous resend", () => { + const parent = createRequestExecutionBudget(); + const child = deriveRequestExecutionBudget(parent, CODEX_TEXT_GUARDED_BUDGET_POLICY); + expect(parent.claimAmbiguousResend?.(GRANT)).toBe(true); + expect(child.ambiguousResendSpent).toBe(true); + }); + + test("a parent observes a derived scope's spent ambiguous resend", () => { + const parent = createRequestExecutionBudget(); + const child = deriveRequestExecutionBudget(parent, CODEX_TEXT_GUARDED_BUDGET_POLICY); + expect(child.claimAmbiguousResend?.(GRANT)).toBe(true); + expect(parent.ambiguousResendSpent).toBe(true); + }); + test("the whole chain spends the grant once, whatever each leg was separately entitled to", async () => { silenceWarn(); const request = oneLogicalRequest(); @@ -233,17 +254,51 @@ describe("one resend budget across composed recovery legs", () => { get lastTargetKey(): string | undefined { return owner.lastTargetKey; }, remainingBaseSends: (cap: number): number => owner.remainingBaseSends(cap), claimAmbiguousResend: (limit: number): boolean => owner.claimAmbiguousResend?.(limit) === true, + get ambiguousResendSpent(): boolean | undefined { return owner.ambiguousResendSpent; }, reserveDispatch: intent => owner.reserveDispatch(intent), }; const first = deriveRequestExecutionBudget(view, CODEX_TEXT_GUARDED_BUDGET_POLICY); const second = deriveRequestExecutionBudget(view, CODEX_TEXT_GUARDED_BUDGET_POLICY); + expect(second.ambiguousResendSpent).toBe(false); expect(first.claimAmbiguousResend?.(GRANT)).toBe(true); + expect(second.ambiguousResendSpent).toBe(true); + expect(view.ambiguousResendSpent).toBe(true); expect(second.claimAmbiguousResend?.(GRANT)).toBe(false); expect(view.claimAmbiguousResend?.(GRANT)).toBe(false); expect(owner.claimAmbiguousResend?.(GRANT)).toBe(false); }); + test("a bridged parent without a spent flag still reports a grant claimed through the bridge", () => { + // A hand-built parent that predates `ambiguousResendSpent` can still grant through + // `claimAmbiguousResend`. Reading only the parent's missing flag would report "not spent" + // after a scope spent the grant, and a combo would then hop on a zero-output 200 from the + // replacement: a third send of a turn that may already have run. + const owner = createRequestExecutionBudget(CODEX_TEXT_GUARDED_BUDGET_POLICY); + const legacy: RequestExecutionBudget = { + get used(): number { return owner.used; }, + set used(next: number) { owner.used = next; }, + logicalRequestId: owner.logicalRequestId, + policyVersion: owner.policyVersion, + policy: owner.policy, + get reserveSpent(): boolean { return owner.reserveSpent; }, + get alternateTargetSends(): number { return owner.alternateTargetSends; }, + get targetTransitions(): number { return owner.targetTransitions; }, + get lastTargetKey(): string | undefined { return owner.lastTargetKey; }, + remainingBaseSends: (cap: number): number => owner.remainingBaseSends(cap), + claimAmbiguousResend: (limit: number): boolean => owner.claimAmbiguousResend?.(limit) === true, + reserveDispatch: intent => owner.reserveDispatch(intent), + }; + + const first = deriveRequestExecutionBudget(legacy, CODEX_TEXT_GUARDED_BUDGET_POLICY); + const second = deriveRequestExecutionBudget(legacy, CODEX_TEXT_GUARDED_BUDGET_POLICY); + expect(first.ambiguousResendSpent).toBe(false); + expect(first.claimAmbiguousResend?.(GRANT)).toBe(true); + expect(first.ambiguousResendSpent).toBe(true); + expect(second.ambiguousResendSpent).toBe(true); + expect(deriveRequestExecutionBudget(legacy, CODEX_TEXT_GUARDED_BUDGET_POLICY).ambiguousResendSpent).toBe(true); + }); + test("a parent that grants nothing cannot be bridged into a grant", () => { const stub: RequestExecutionBudget = { used: 0, diff --git a/tests/providers/devin-stated-reset-retry.test.ts b/tests/providers/devin-stated-reset-retry.test.ts index 53356089d6f..a94b7117862 100644 --- a/tests/providers/devin-stated-reset-retry.test.ts +++ b/tests/providers/devin-stated-reset-retry.test.ts @@ -127,6 +127,44 @@ describe("streamChatEventsWithResetRetry", () => { expect(out.map(e => e.kind)).toEqual(["text", "finish"]); }); + test("waits the generated approximate retry delay and replays", async () => { + const waits: number[] = []; + let calls = 0; + const stream = () => { + calls += 1; + return calls === 1 + ? exhausting("Cognition chat failed (resource_exhausted); retry after ~180s")() + : events({ kind: "finish", reason: "stop" } as CloudChatEvent); + }; + const out = await drain(streamChatEventsWithResetRetry(REQ, { + stream, + sleep: async (ms) => { waits.push(ms); }, + })); + expect(calls).toBe(2); + expect(waits).toEqual([180_000]); + expect(out.map(e => e.kind)).toEqual(["finish"]); + }); + + test("re-evaluates the delay when retry failures use different wording", async () => { + const waits: number[] = []; + let calls = 0; + const stream = () => { + calls += 1; + if (calls === 1) return exhausting("Your limit will reset in 35 seconds")(); + if (calls === 2) { + return exhausting("Cognition chat failed (resource_exhausted); retry after ~180s")(); + } + return events({ kind: "finish", reason: "stop" } as CloudChatEvent); + }; + const out = await drain(streamChatEventsWithResetRetry(REQ, { + stream, + sleep: async (ms) => { waits.push(ms); }, + })); + expect(calls).toBe(3); + expect(waits).toEqual([35_000, 180_000]); + expect(out.map(e => e.kind)).toEqual(["finish"]); + }); + test("does not replay once any event was yielded", async () => { const stream = () => (async function* (): AsyncGenerator { yield { kind: "text", text: "partial" } as CloudChatEvent; diff --git a/tests/responses/responses-code-mode-goal-helpers.test.ts b/tests/responses/responses-code-mode-goal-helpers.test.ts new file mode 100644 index 00000000000..51a549d18f5 --- /dev/null +++ b/tests/responses/responses-code-mode-goal-helpers.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; +import { restoreRoutedCustomCallsInJson } from "../../src/responses/custom-tool-compat"; +import { compileCodeModeHelperInput } from "../../src/responses/code-mode-helper-compat"; +import { undeclaredToolCallNameInResponse } from "../../src/server/responses-undeclared-tool-guard"; +import { normalizeDeclaredToolName } from "../../src/types/tools"; + +const CODE_MODE = new Set(["exec"]); + +describe("code-mode goal helper recovery", () => { + test("maps bare and default-prefixed goal helpers only through a declared exec", () => { + for (const name of ["create_goal", "get_goal", "update_goal"]) { + expect(normalizeDeclaredToolName(name, CODE_MODE)).toBe("exec"); + expect(normalizeDeclaredToolName(`default.${name}`, CODE_MODE)).toBe("exec"); + expect(normalizeDeclaredToolName(`default.${name}`, new Set([name]))).toBe(name); + expect(normalizeDeclaredToolName(`default.${name}`, new Set())).toBe(`default.${name}`); + } + }); + + test("compiles every helper to its matching nested host call", () => { + const cases = [ + ["create_goal", { objective: "ship the fix" }], + ["get_goal", {}], + ["update_goal", { status: "complete" }], + ] as const; + for (const [name, args] of cases) { + expect(compileCodeModeHelperInput(JSON.stringify(args), name)).toBe( + `const result = await tools.${name}(${JSON.stringify(args)});\ntext(result);`, + ); + } + }); + + test("restores default.update_goal as the declared exec and keeps the guard fail-closed", () => { + const source = { + output: [{ + type: "function_call", + id: "fc_goal", + call_id: "call_goal", + name: "default.update_goal", + arguments: JSON.stringify({ status: "complete" }), + }], + }; + const restored = JSON.parse(restoreRoutedCustomCallsInJson( + JSON.stringify(source), + CODE_MODE, + new Set(), + CODE_MODE, + )); + expect(restored.output).toMatchObject([{ + type: "custom_tool_call", + name: "exec", + call_id: "call_goal", + input: 'const result = await tools.update_goal({"status":"complete"});\ntext(result);', + }]); + expect(undeclaredToolCallNameInResponse(restored, CODE_MODE)).toBeUndefined(); + // The original, unrestored wire name must also pass the guard when exec is declared: + // this is the exact input #5495 was rejected on. + expect(undeclaredToolCallNameInResponse(source, CODE_MODE)).toBeUndefined(); + expect(undeclaredToolCallNameInResponse(source, new Set())).toBe("default.update_goal"); + }); + + test("an unlisted helper-like name is not admitted through exec", () => { + for (const name of ["delete_goal", "default.delete_goal", "set_goal"]) { + expect(normalizeDeclaredToolName(name, CODE_MODE)).toBe(name); + const source = { + output: [{ type: "function_call", id: "fc_x", call_id: "call_x", name, arguments: "{}" }], + }; + expect(undeclaredToolCallNameInResponse(source, CODE_MODE)).toBe(name); + } + }); + + test("a genuinely declared bare goal tool keeps its identity through restoration", () => { + const declared = new Set(["exec", "update_goal"]); + const source = { + output: [{ + type: "function_call", + id: "fc_goal", + call_id: "call_goal", + name: "default.update_goal", + arguments: JSON.stringify({ status: "complete" }), + }], + }; + const restored = JSON.parse(restoreRoutedCustomCallsInJson( + JSON.stringify(source), + CODE_MODE, + new Set(), + declared, + )); + expect(restored.output[0].type).toBe("function_call"); + expect(restored.output[0].name).not.toBe("exec"); + expect(normalizeDeclaredToolName("default.update_goal", declared)).toBe("update_goal"); + expect(undeclaredToolCallNameInResponse(restored, declared)).toBeUndefined(); + }); +}); diff --git a/tests/responses/ws-ambiguous-resend.test.ts b/tests/responses/ws-ambiguous-resend.test.ts new file mode 100644 index 00000000000..38ddd5b8edb --- /dev/null +++ b/tests/responses/ws-ambiguous-resend.test.ts @@ -0,0 +1,483 @@ +import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearAccountNeedsReauth } from "../../src/codex/auth-api"; +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { clearAccountQuota, setAccountQuotaFromParsed } from "../../src/codex/quota"; +import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; +import { handleResponses } from "../../src/server/responses"; +import { codexWsExchange } from "../../src/server/responses/codex-ws-exchange"; +import { CodexWsSession } from "../../src/server/responses/codex-ws-session"; +import { prepareCodexWsRequest } from "../../src/server/responses/codex-ws-request"; +import { + CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, + codexWsSocketDeathStage, + readCodexWsStage, +} from "../../src/server/responses/codex-ws-wire"; +import { + isNonReplayableResponse, + REPLAY_REFUSED_STATUS, + UPSTREAM_RESET_REPLAY_REFUSED_CODE, +} from "../../src/lib/upstream-retry"; +import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { BOUNDED_WS_RUNTIME, codexWsUpstreamFetch, streamingInit } from "../helpers/ws-upstream-fixtures"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * #4191: a Codex WebSocket that dies after its create frame left, before any Responses event, + * leaves the turn in the same unknown state as an HTTP connection that resets before the head. + * The HTTP rows already answer that with the operator's `retryOnReset` grant. These cases hold the + * WebSocket to the same answer: one replacement, over HTTP, only when the grant covers it, and + * never a third send of the turn. + */ + +const CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"; + +type Listener = (event: unknown) => void; + +/** Minimal scriptable stand-in for Bun's WebSocket, mirroring `ws-failure-stage.test.ts`. */ +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + static script: (ws: FakeWebSocket) => void = () => {}; + url: string; + headers: Headers; + sent: string[] = []; + closed = false; + listeners = new Map(); + + constructor(url: string, options?: { headers?: HeadersInit }) { + this.url = url; + this.headers = new Headers(options?.headers); + FakeWebSocket.instances.push(this); + queueMicrotask(() => FakeWebSocket.script(this)); + } + + addEventListener(type: string, listener: Listener) { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + + removeEventListener(type: string, listener: Listener) { + this.listeners.set(type, (this.listeners.get(type) ?? []).filter(value => value !== listener)); + } + + emit(type: string, event: unknown = {}) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + + send(data: string) { + this.sent.push(data); + } + + close() { this.closed = true; } +} + +const RealWebSocket = globalThis.WebSocket; +const RealFetch = globalThis.fetch; +const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"] as const; +let savedProxyEnv: Record; +// A case that calls handleResponses directly never takes the writer lease startServer takes, +// so its dispatch is refused. Dropped in teardown so a throwing case cannot leave it behind. +let releaseSpendHome: (() => void) | undefined; +const takeSpendHome = (): void => { releaseSpendHome ??= acquireOwnedSpendHome(); }; + +beforeEach(() => { + savedProxyEnv = Object.fromEntries(PROXY_ENV_KEYS.map(key => [key, process.env[key]])); + for (const key of PROXY_ENV_KEYS) delete process.env[key]; + FakeWebSocket.instances = []; + FakeWebSocket.script = () => {}; +}); + +afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + globalThis.WebSocket = RealWebSocket; + globalThis.fetch = RealFetch; + FakeWebSocket.instances = []; + FakeWebSocket.script = () => {}; + for (const key of PROXY_ENV_KEYS) { + if (savedProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedProxyEnv[key]; + } +}); + +function installFake(script: (ws: FakeWebSocket) => void) { + FakeWebSocket.script = script; + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; +} + +const QUOTA_FRAME = JSON.stringify({ + type: "codex.rate_limits", rate_limits: { primary: { used_percent: 10, window_minutes: 10080 } }, +}); + +/** The three ways a socket can die under the send before anything was promised to the client. */ +const SOCKET_DEATHS: Array<[string, (ws: FakeWebSocket) => void, "pre-header" | "protocol-prelude"]> = [ + ["nothing came back", ws => { + ws.emit("open", {}); + ws.emit("close", { code: 1006 }); + }, "pre-header"], + ["only quota came back", ws => { + ws.emit("open", {}); + ws.emit("message", { data: QUOTA_FRAME }); + ws.emit("close", { code: 1006 }); + }, "protocol-prelude"], + ["the transport errored", ws => { + ws.emit("open", {}); + ws.emit("error", {}); + }, "pre-header"], +]; + +const noFallback = (async () => { + throw new Error("fallback must not run after open"); +}) as unknown as typeof fetch; + +describe("the exchange records a socket that died under the send (#4191)", () => { + test.each(SOCKET_DEATHS)("when %s it settles the same 502, marked with the stage it reached", + async (_name, script, stage) => { + installFake(script); + const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), noFallback); + expect(response.status).toBe(502); + expect(isNonReplayableResponse(response)).toBe(true); + expect(readCodexWsStage(response)?.sent).toBe(true); + expect(codexWsSocketDeathStage(response)).toBe(stage); + }); + + test("silence keeps its 504 and is not a socket death", async () => { + jest.useFakeTimers(); + const opened = Promise.withResolvers(); + try { + installFake(ws => { ws.emit("open", {}); opened.resolve(); }); + const pending = codexWsUpstreamFetch(CODEX_URL, streamingInit(), noFallback); + await opened.promise; + jest.advanceTimersByTime(CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS); + const response = await pending; + expect(response.status).toBe(504); + expect(codexWsSocketDeathStage(response)).toBeUndefined(); + } finally { + jest.useRealTimers(); + } + }); + + test("a drop after the response started stays a failed body and is not a socket death", async () => { + installFake(ws => { + ws.emit("open", {}); + ws.emit("message", { data: JSON.stringify({ type: "response.created", response: { id: "r1" } }) }); + ws.emit("close", { code: 1006 }); + }); + const response = await codexWsUpstreamFetch(CODEX_URL, streamingInit(), noFallback); + expect(response.status).toBe(200); + expect(codexWsSocketDeathStage(response)).toBeUndefined(); + await expect(response.text()).rejects.toThrow("closed before a Responses terminal event"); + }); + + test("a steering exchange's death is not offered: its channel may have sent more than the create", async () => { + installFake(ws => { + ws.emit("open", {}); + ws.emit("close", { code: 1006 }); + }); + const init = streamingInit(); + const prepared = prepareCodexWsRequest(CODEX_URL, init)!; + const session = new CodexWsSession("wss://chatgpt.com/backend-api/codex/responses", prepared.headers, true); + const nativeControl = { + kind: "steering" as const, + relayActive: false, + attached: false, + ended: false, + attach() { return () => {}; }, + observe() { return false; }, + steer() {}, + continue() { return false; }, + }; + try { + expect(session.reserve()).toBe(true); + const response = await codexWsExchange({ session, url: CODEX_URL, init, prepared, nativeControl, sseFallback: noFallback }); + expect(response.status).toBe(502); + expect(codexWsSocketDeathStage(response)).toBeUndefined(); + } finally { session.dispose(); } + }); +}); + +describe("handleResponses replaces a dead socket's send once under retryOnReset (#4191)", () => { + function forwardConfig(provider: Partial = {}): OcxConfig { + return { + port: 0, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + ...provider, + }, + }, + } as OcxConfig; + } + + /** A turn whose second send can only repeat the inference: nothing stored, no hosted tools. */ + function turn(body: Record = {}): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer test" }, + body: JSON.stringify({ model: "gpt-5.5", input: "hello", stream: true, store: false, ...body }), + }); + } + + function completed(): Response { + return new Response(`event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: { id: "r-http", status: "completed", output: [] }, + })}\n\n`, { status: 200, headers: { "content-type": "text/event-stream" } }); + } + + /** Every HTTP send reaches upstream through here, so its length is the number of HTTP sends. */ + function stubHttp(answer: () => Response): string[] { + const bodies: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + bodies.push(typeof init?.body === "string" ? init.body : ""); + return answer(); + }) as typeof fetch; + return bodies; + } + + async function send( + request: Request, + config: OcxConfig, + logCtx: RequestLogContext = { model: "", provider: "" }, + sendBudget = createRequestExecutionBudget(), + ): Promise { + takeSpendHome(); + return handleResponses(request, config, logCtx, { codexWsRuntimeIdentity: BOUNDED_WS_RUNTIME, sendBudget }); + } + + describe("in pool mode", () => { + const ACCOUNT_ID = "work"; + const OTHER_ACCOUNT_ID = "other"; + const HOME_KEYS = ["HOME", "OPENCODEX_HOME", "CODEX_HOME"] as const; + let home = ""; + let previousHomes: Array; + + function clearPoolState(): void { + clearAccountNeedsReauth(ACCOUNT_ID); + clearAccountNeedsReauth(OTHER_ACCOUNT_ID); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearPoolRotationState(); + clearAccountQuota(); + } + + beforeEach(() => { + previousHomes = HOME_KEYS.map(key => process.env[key]); + home = mkdtempSync(join(tmpdir(), "ocx-ws-ambiguous-pool-")); + for (const key of HOME_KEYS) process.env[key] = home; + takeSpendHome(); + clearPoolState(); + // A primed pool does not issue unrelated background usage requests during the turn. + for (const id of [ACCOUNT_ID, OTHER_ACCOUNT_ID]) { + setAccountQuotaFromParsed(id, { weeklyPercent: 10 }); + } + writeFileSync(join(home, "codex-accounts.json"), JSON.stringify(Object.fromEntries( + [ACCOUNT_ID, OTHER_ACCOUNT_ID].map(id => [id, { + credential: { + accessToken: `${id}-access`, + refreshToken: `${id}-grant`, + expiresAt: Date.now() + 3_600_000, + chatgptAccountId: `acc-${id}`, + }, + generation: 1, + refreshGrantFingerprint: createHash("sha256") + .update(`codex-refresh-grant:${id}-grant`).digest("hex"), + }]), + ))); + }); + + afterEach(() => { + // Release the writer before removing its database or restoring the surrounding home. + releaseSpendHome?.(); + releaseSpendHome = undefined; + clearPoolState(); + for (const [index, key] of HOME_KEYS.entries()) { + if (previousHomes[index] === undefined) delete process.env[key]; + else process.env[key] = previousHomes[index]; + } + removeTreeWithRetry(home); + }); + + for (const [status, body] of [ + [429, JSON.stringify({ error: { message: "quota exhausted" } })], + [503, "busy"], + [400, JSON.stringify({ + detail: "The 'gpt-5.5' model is not supported when using Codex with a ChatGPT account.", + })], + ] as const) { + test(`a replacement ${status} cannot send the turn through the second account`, async () => { + installFake(SOCKET_DEATHS[0]![1]); + const http: Headers[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + http.push(new Headers(init?.headers)); + return new Response(body, { status }); + }) as typeof fetch; + const config: OcxConfig = { + ...forwardConfig({ codexAccountMode: "pool", retryOnReset: {} }), + activeCodexAccountId: ACCOUNT_ID, + autoSwitchThreshold: 0, + accountPoolStrategy: "round-robin", + codexAccounts: [{ id: ACCOUNT_ID, label: "work" }, { id: OTHER_ACCOUNT_ID, label: "other" }], + }; + const request = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.5", input: "hello", stream: true, store: false }), + }); + const response = await send(request, config); + + // Both transports count: the dead socket's 502 must not rotate before the HTTP row. + const credentials = [...FakeWebSocket.instances.map(ws => ws.headers), ...http]; + expect(credentials.map(headers => headers.get("authorization"))).not.toContain("Bearer other-access"); + expect(FakeWebSocket.instances).toHaveLength(1); + const socket = FakeWebSocket.instances[0]!; + expect(socket.headers.get("authorization")).toBe("Bearer work-access"); + expect(socket.sent).toHaveLength(1); + expect(JSON.parse(socket.sent[0]!)).toMatchObject({ type: "response.create" }); + expect(http).toHaveLength(1); + expect(http[0]!.get("authorization")).toBe("Bearer work-access"); + expect(http[0]!.get("chatgpt-account-id")).toBe("acc-work"); + if (status === 400) { + expect(response.status).toBe(400); + expect(await response.text()).toBe(body); + } else { + expect(response.status).toBe(REPLAY_REFUSED_STATUS); + expect(response.headers.get("x-should-retry")).toBe("false"); + expect(await response.json()).toMatchObject({ error: { code: UPSTREAM_RESET_REPLAY_REFUSED_CODE } }); + } + }); + } + }); + + test.each(SOCKET_DEATHS)("when %s, one HTTP send of the same turn serves it", async (_name, script) => { + installFake(script); + const http = stubHttp(completed); + const logCtx: RequestLogContext = { model: "", provider: "" }; + const response = await send(turn(), forwardConfig({ retryOnReset: {} }), logCtx); + + expect(response.status).toBe(200); + expect(await response.text()).toContain("response.completed"); + // Over HTTP: the transport that just failed is not asked again. + expect(FakeWebSocket.instances).toHaveLength(1); + expect(http).toHaveLength(1); + const frame = JSON.parse(FakeWebSocket.instances[0]!.sent[0]!) as { input?: unknown }; + expect((JSON.parse(http[0]!) as { input?: unknown }).input).toEqual(frame.input); + // Both sends are on the record, and the dead socket's evidence stays beside its replacement. + expect(logCtx.activeAttempt?.sendCount).toBe(2); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["connection-reset"]); + expect(logCtx.activeAttempt?.codexWsStage?.sent).toBe(true); + }); + + test.each([ + ["the provider grants nothing", {}, {}], + ["the turn is stored upstream", { retryOnReset: {} }, { store: true }], + ])("the 502 stands and nothing else is sent when %s", async (_name, provider, body) => { + installFake(SOCKET_DEATHS[0]![1]); + const http = stubHttp(completed); + const response = await send(turn(body), forwardConfig(provider)); + + expect(response.status).toBe(502); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(http).toHaveLength(0); + }); + + test.each([ + ["a status the client would retry", () => new Response("busy", { status: 503 })], + ["a reset of its own", () => { throw Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }); }], + ])("a replacement that fails with %s settles as the refusal, with no third send", async (_name, answer) => { + installFake(SOCKET_DEATHS[0]![1]); + const http = stubHttp(answer); + const response = await send(turn(), forwardConfig({ retryOnReset: {} })); + + expect(response.status).toBe(REPLAY_REFUSED_STATUS); + expect(await response.json()).toMatchObject({ error: { code: UPSTREAM_RESET_REPLAY_REFUSED_CODE } }); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(http).toHaveLength(1); + }); + + test("a spent replacement's effort rejection does not start a downgrade send", async () => { + installFake(SOCKET_DEATHS[0]![1]); + const http = stubHttp(() => Response.json( + { error: { param: "reasoning.effort", message: "Unsupported reasoning effort" } }, + { status: 400 }, + )); + const config = forwardConfig({ retryOnReset: {}, reasoningEfforts: ["low", "high"] }); + const response = await send(turn({ reasoning: { effort: "high" } }), config); + + // The 400 answers the replacement, not the send that may already have run the turn. + expect(response.status).toBe(400); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(http).toHaveLength(1); + }); + + test("the grant is not spent on a replacement the send budget cannot fund", async () => { + installFake(SOCKET_DEATHS[0]![1]); + const http = stubHttp(completed); + const sendBudget = createRequestExecutionBudget(); + // Room for the socket's own send and nothing after it. + sendBudget.used = sendBudget.policy.baseSendAllowance - 1; + const response = await send(turn(), forwardConfig({ retryOnReset: {} }), undefined, sendBudget); + + expect(response.status).toBe(502); + expect(http).toHaveLength(0); + expect(sendBudget.claimAmbiguousResend?.(1)).toBe(true); + }); + + test("the SSE row cannot buy a second replacement after the socket's", async () => { + installFake(SOCKET_DEATHS[0]![1]); + const encoder = new TextEncoder(); + // The replacement's stream dies after its prelude with nothing written, the SSE row's case. + const http = stubHttp(() => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`event: response.created\ndata: ${JSON.stringify({ + type: "response.created", response: { id: "r-http", status: "in_progress" }, + })}\n\n`)); + controller.error(Object.assign(new Error("socket hang up"), { code: "ECONNRESET" })); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } })); + const response = await send(turn(), forwardConfig({ retryOnReset: {} })); + await response.text().catch(() => ""); + + expect(FakeWebSocket.instances).toHaveLength(1); + expect(http).toHaveLength(1); + }); + + test("a replacement that resets before its head may use a configured second grant", async () => { + installFake(SOCKET_DEATHS[0]![1]); + let calls = 0; + const http = stubHttp(() => { + calls += 1; + if (calls === 1) throw Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }); + return completed(); + }); + const response = await send(turn(), forwardConfig({ retryOnReset: { replacements: 2 } })); + + expect(response.status).toBe(200); + await response.text(); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(http).toHaveLength(2); + }); + + test("with one grant, a replacement that resets before its head settles as the refusal", async () => { + installFake(SOCKET_DEATHS[0]![1]); + const http = stubHttp(() => { + throw Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }); + }); + const response = await send(turn(), forwardConfig({ retryOnReset: {} })); + + expect(response.status).toBe(REPLAY_REFUSED_STATUS); + expect(await response.json()).toMatchObject({ error: { code: UPSTREAM_RESET_REPLAY_REFUSED_CODE } }); + expect(http).toHaveLength(1); + }); +}); diff --git a/tests/routing/routing-policy-fallback.test.ts b/tests/routing/routing-policy-fallback.test.ts index 299b96b70ea..c09d720d0d7 100644 --- a/tests/routing/routing-policy-fallback.test.ts +++ b/tests/routing/routing-policy-fallback.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { formatErrorResponse } from "../../src/bridge"; import { RequestPacingQueueOverloadError } from "../../src/providers/request-pacing"; -import { fetchWithTransientRetry, isNonReplayableResponse } from "../../src/lib/upstream-retry"; +import { fetchWithTransientRetry, isNonReplayableResponse, markResponseNonReplayable } from "../../src/lib/upstream-retry"; import { shouldRetryCodexPoolAccountQuota } from "../../src/server/responses/core-codex-account"; import type { OcxConfig } from "../../src/types"; import { beginRequestAttempt, type RequestLogContext } from "../../src/server/request-log"; @@ -52,6 +52,27 @@ function seedAttempt(logCtx: RequestLogContext, provider: string, model: string) } describe("policy candidate fallback", () => { + test("a marked context overflow never tries another policy route", async () => { + const failure = Response.json({ error: { + type: "invalid_request_error", code: "context_length_exceeded", message: "Context window exceeded", + } }, { status: 400 }); + markResponseNonReplayable(failure); + let coreCalls = 0; + const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, {} as RequestLogContext, {}, { + runCore: async (req, _config, context, options) => { + coreCalls += 1; + options.onRequestBodyParsed?.(await req.json()); + context.routeDecision = policyTrace(); + return coreCalls === 1 ? failure : Response.json({ status: "completed" }); + }, + }); + + expect(coreCalls).toBe(1); + expect(response).toBe(failure); + expect(response.status).toBe(400); + expect(isNonReplayableResponse(response)).toBe(true); + }); + test.each([false, true])("reset refusal stays terminal across policy and account recovery (replacement=%s)", async replacement => { let sends = 0; let coreCalls = 0; diff --git a/tests/server/replay-refusal-parity.test.ts b/tests/server/replay-refusal-parity.test.ts index d4b3fce38e5..d5b32b895f5 100644 --- a/tests/server/replay-refusal-parity.test.ts +++ b/tests/server/replay-refusal-parity.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; import { startServer } from "../../src/server"; import { REPLAY_REFUSAL_NO_RETRY_HEADER, @@ -33,6 +34,8 @@ let previousHome: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; beforeEach(() => { + clearComboSelectionState(); + clearComboTargetCooldowns(); previousHome = process.env.OPENCODEX_HOME; isolatedCodexHome = installIsolatedCodexHome("ocx-replay-refusal-"); testDir = mkdtempSync(join(tmpdir(), "ocx-replay-refusal-")); @@ -41,6 +44,8 @@ beforeEach(() => { }); afterEach(() => { + clearComboSelectionState(); + clearComboTargetCooldowns(); globalThis.fetch = originalFetch; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; @@ -231,6 +236,129 @@ function comboReplayConfig(): OcxConfig { } as unknown as OcxConfig; } +test("a combo refuses replay after a spent replacement's zero-output stream failure", async () => { + saveConfig(comboReplayConfig()); + let firstSends = 0; + let secondSends = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes(COMBO_FIRST_HOST)) { + firstSends += 1; + if (firstSends === 1) preHeaderReset(); + const failure = { type: "response.failed", response: { + id: "resp_failed", object: "response", status: "failed", output: [], + error: { code: "server_is_overloaded", message: "Server is overloaded" }, + } }; + return new Response(`event: response.failed\ndata: ${JSON.stringify(failure)}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + } + if (url.includes(COMBO_SECOND_HOST)) { + secondSends += 1; + return Response.json({ error: { code: "unexpected_second_target" } }, { status: 400 }); + } + return originalFetch(input as RequestInfo, init); + }) as typeof fetch; + const server = startServer(0); + try { + const { response, attempts, json } = await sendWithClientRetries(new URL("/v1/responses", server.url), { + model: "combo/pair", store: false, stream: true, ...RESPONSES_TURN, + }); + expect({ firstSends, secondSends, attempts }).toEqual({ firstSends: 2, secondSends: 0, attempts: 1 }); + expect(response.status).toBe(REPLAY_REFUSED_STATUS); + expect(json.error?.code).toBe(UPSTREAM_RESET_REPLAY_REFUSED_CODE); + expect(response.headers.get(REPLAY_REFUSAL_NO_RETRY_HEADER)).toBe(REPLAY_REFUSAL_NO_RETRY_VALUE); + } finally { + await server.stop(true); + } +}); + +test("a combo keeps a spent replacement's zero-output context overflow", async () => { + saveConfig(comboReplayConfig()); + let firstSends = 0; + let secondSends = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.includes(COMBO_FIRST_HOST)) { + firstSends += 1; + if (firstSends === 1) preHeaderReset(); + const failure = { type: "response.failed", response: { + id: "resp_overflow", object: "response", status: "failed", output: [], + error: { type: "invalid_request_error", code: "context_length_exceeded", + message: "Input exceeds the model context window." }, + } }; + return new Response(`event: response.failed\ndata: ${JSON.stringify(failure)}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + } + if (url.includes(COMBO_SECOND_HOST)) { + secondSends += 1; + return Response.json({ error: { code: "unexpected_second_target" } }, { status: 400 }); + } + return originalFetch(input as RequestInfo, init); + }) as typeof fetch; + const server = startServer(0); + try { + const { response, attempts, json } = await sendWithClientRetries(new URL("/v1/responses", server.url), { + model: "combo/pair", store: false, stream: true, ...RESPONSES_TURN, + }); + expect({ firstSends, secondSends, attempts }).toEqual({ firstSends: 2, secondSends: 0, attempts: 1 }); + expect(response.status).toBe(400); + expect(json.error?.code).toBe("context_length_exceeded"); + expect(JSON.stringify(json)).toContain("Input exceeds the model context window."); + } finally { + await server.stop(true); + } +}); + +test("the direct path refuses replay after a spent replacement's decrypt failure", async () => { + const config = parityConfig(); + config.providers.bridged.retryOnReset = {}; + saveConfig(config); + let upstreamSends = 0; + const sends = countingUpstream(() => { + upstreamSends += 1; + if (upstreamSends === 1) preHeaderReset(); + const failure = { type: "response.failed", response: { + id: "resp_decrypt_failed", status: "failed", + error: { type: "server_error", code: "upstream_server_error", + message: "Encrypted function output content could not be decrypted or decoded." }, + } }; + return new Response(`event: response.failed\ndata: ${JSON.stringify(failure)}\n\ndata: [DONE]\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + }); + // Canonical key-independent Fernet structure, as in the opaque-blob recovery fixtures. + const encryptedContent = `${Buffer.concat([ + Buffer.from([0x80]), Buffer.alloc(8), Buffer.alloc(16), Buffer.alloc(16), Buffer.alloc(32), + ]).toString("base64url")}==`; + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "bridged/model", store: false, stream: true, + input: [ + { type: "function_call", call_id: "call-encrypted-output", name: "browser_capture", arguments: "{}" }, + { type: "function_call_output", call_id: "call-encrypted-output", output: [ + { type: "encrypted_content", encrypted_content: encryptedContent }, + { type: "input_text", text: "visible tool output" }, + { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }, + ] }, + { role: "user", content: [{ type: "input_text", text: "continue" }] }, + ], + }), + }); + expect(sends()).toBe(2); + expect(response.status).toBe(REPLAY_REFUSED_STATUS); + expect((await response.json()).error.code).toBe(UPSTREAM_RESET_REPLAY_REFUSED_CODE); + expect(response.headers.get(REPLAY_REFUSAL_NO_RETRY_HEADER)).toBe(REPLAY_REFUSAL_NO_RETRY_VALUE); + } finally { + await server.stop(true); + } +}); + test.each([ { name: "a context overflow", status: 400, expectedStatus: 400 }, { name: "a 413", status: 413, expectedStatus: REPLAY_REFUSED_STATUS }, diff --git a/tests/server/retry-after-429.test.ts b/tests/server/retry-after-429.test.ts index 8930ae55322..ba39f2cdef0 100644 --- a/tests/server/retry-after-429.test.ts +++ b/tests/server/retry-after-429.test.ts @@ -101,6 +101,14 @@ describe("resolveClientRetryAfter (#507)", () => { })).toBe("35"); }); + test("keeps a generated approximate Cognition delay in client cooldown metadata", () => { + expect(resolveClientRetryAfter({ + status: 429, + message: "Cognition chat failed (resource_exhausted); retry after ~180s", + includeDefault: false, + })).toBe("180"); + }); + test("reads a stated reset in minutes and hours, not just seconds", () => { expect(resolveClientRetryAfter({ status: 429, diff --git a/tests/server/retry-delay-hardening.test.ts b/tests/server/retry-delay-hardening.test.ts index e0e43d51c50..f0e5fa6db4a 100644 --- a/tests/server/retry-delay-hardening.test.ts +++ b/tests/server/retry-delay-hardening.test.ts @@ -17,6 +17,8 @@ describe("stated reset duration boundaries", () => { ["retry after 1500 milliseconds", 2], ["reset in 1 minute 500 milliseconds", 61], ["retry after 7.2s", 8], + ["retry after ~180s", 180], + ["Retry-After: ~3 minutes", 180], ["Retry-After: 30", 30], ["Retry-After: 0.1", 1], ["Your limit RESETS IN 21 MINUTES", 1260], @@ -33,6 +35,9 @@ describe("stated reset duration boundaries", () => { "reset in 5 minutes 30", "reset in 5 months", "retry after 3 monkeys", + "reset in ~3 minutes", + "retry after ~1 minute ~30 seconds", + "Retry-After: ~~180s", "try again in 1e3s", "Retry-After: 3:30", "Retry-After: 123abc", diff --git a/tests/server/server-combo-zero-output-failover.test.ts b/tests/server/server-combo-zero-output-failover.test.ts index a5a7a9fa292..b4056f69208 100644 --- a/tests/server/server-combo-zero-output-failover.test.ts +++ b/tests/server/server-combo-zero-output-failover.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -17,8 +17,34 @@ import { flushResponseState, responseStatePersistPendingForTests, } from "../../src/responses/state"; -import { handleResponses } from "../../src/server/responses"; -import type { OcxConfig } from "../../src/types"; +import type { ProviderAdapter } from "../../src/adapters/base"; +import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "../../src/types"; + +const actualResolver = await import("../../src/server/adapter-resolve"); +const actualResolveAdapter = actualResolver.resolveAdapter; +let customRunTurn: NonNullable | undefined; + +mock.module("../../src/server/adapter-resolve", () => ({ + ...actualResolver, + resolveAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") { + if (provider.adapter !== "test-run-turn") { + return actualResolveAdapter(provider, cacheRetention); + } + return { + name: "test-run-turn", + buildRequest: () => ({ url: provider.baseUrl, method: "POST", headers: {}, body: "" }), + async *parseStream(): AsyncGenerator { + yield { type: "error", message: "test runTurn adapter does not use parseStream" }; + }, + async runTurn(parsed, incoming, emit) { + if (!customRunTurn) throw new Error("custom runTurn not installed"); + await customRunTurn(parsed, incoming, emit); + }, + } satisfies ProviderAdapter; + }, +})); + +const { handleResponses } = await import("../../src/server/responses"); /** * Zero-output combo failover driven by a bare Responses SSE `error` event. @@ -29,10 +55,10 @@ import type { OcxConfig } from "../../src/types"; * lowers a cap, so the way back under it is to hold new cases in a sibling file rather * than to raise the number. * - * The harness below is the subset of that file's fixture this case actually uses: real + * The harness below is the subset of that file's fixture these cases actually use: real * loopback upstreams, an isolated home, and the combo/request-log state that leaks - * between tests. No module is mocked here, because this case drives the real - * `openai-responses` adapter. + * between tests. The loopback cases drive real adapters; the runTurn case uses the same + * narrow resolver seam as the parent file to emit deterministic adapter events. */ // The parent file raises this for the same reason: a real loopback server plus combo @@ -63,6 +89,7 @@ beforeEach(() => { }); afterEach(async () => { + customRunTurn = undefined; // Release before home teardown to prevent Windows removal failures and a live unlinked database. releaseSpendHome?.(); releaseSpendHome = undefined; @@ -186,4 +213,181 @@ describe("combo zero-output bare Responses error failover", () => { ], }); }); + + test("undeclared first adapter tool call hops without changing the request catalog", async () => { + const requests: Record[] = []; + const upstream = (content: string) => serve(async request => { + requests.push(await request.json() as Record); + return new Response([ + `data: ${JSON.stringify({ choices: [{ delta: JSON.parse(content) }] })}`, + "data: [DONE]", + "", + ].join("\n"), { headers: { "content-type": "text/event-stream" } }); + }); + const a = upstream(JSON.stringify({ + tool_calls: [{ index: 0, id: "call_stale", function: { name: "stale_tool", arguments: "{}" } }], + })); + const b = upstream(JSON.stringify({ content: "tool-safe backup" })); + const config = comboConfig({ + a: provider("openai-chat", baseUrl(a), "key-a"), + b: provider("openai-chat", baseUrl(b), "key-b"), + }); + const tools = [{ type: "function", name: "current_tool", parameters: { type: "object" } }]; + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/free", input: "hello", stream: true, tools }), + }), config, { model: "", provider: "" }); + const body = await response.text(); + expect(body).toContain("tool-safe backup"); + expect(body).not.toContain("stale_tool"); + expect(requests).toHaveLength(2); + expect(requests[0]?.tools).toEqual(requests[1]?.tools); + expect(JSON.stringify(requests[0]?.tools)).toContain("current_tool"); + }); + + test("non-streaming undeclared adapter tool call hops without changing the request catalog", async () => { + const catalogs: unknown[] = []; + const hits: string[] = []; + customRunTurn = async (parsed, _incoming, emit) => { + hits.push(parsed.modelId); + catalogs.push(structuredClone((parsed._rawBody as { tools?: unknown }).tools)); + if (parsed.modelId === "m1") { + emit({ type: "tool_call_start", id: "call_stale", name: "stale_tool" }); + emit({ type: "tool_call_delta", arguments: "{}" }); + emit({ type: "tool_call_end" }); + emit({ type: "done" }); + return; + } + emit({ type: "text_delta", text: "non-streaming tool-safe backup" }); + emit({ type: "done" }); + }; + const config = comboConfig({ + a: provider("test-run-turn", "https://a.test/v1", "key-a"), + b: provider("test-run-turn", "https://b.test/v1", "key-b"), + }); + const tools = [{ type: "function", name: "current_tool", parameters: { type: "object" } }]; + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/free", input: "hello", stream: false, tools }), + }), config, { model: "", provider: "" }); + const body = await response.text(); + expect(response.status).toBe(200); + expect(body).toContain("non-streaming tool-safe backup"); + expect(body).not.toContain("stale_tool"); + expect(hits).toEqual(["m1", "m2"]); + expect(catalogs).toHaveLength(2); + expect(catalogs[0]).toEqual(catalogs[1]); + expect(JSON.stringify(catalogs[0])).toContain("current_tool"); + }); + + for (const stream of [false, true]) { + test(`${stream ? "streaming" : "non-streaming"} undeclared tool call after a replay-unsafe heartbeat is never dispatched again`, async () => { + const hits: string[] = []; + customRunTurn = async (parsed, _incoming, emit) => { + hits.push(parsed.modelId); + if (parsed.modelId === "m1") { + emit({ type: "heartbeat", replayUnsafe: true }); + emit({ type: "tool_call_start", id: "call_stale", name: "stale_tool" }); + emit({ type: "tool_call_delta", arguments: "{}" }); + emit({ type: "tool_call_end" }); + emit({ type: "done" }); + return; + } + emit({ type: "text_delta", text: "must not run" }); + emit({ type: "done" }); + }; + const config = comboConfig({ + a: provider("test-run-turn", "https://a.test/v1", "key-a"), + b: provider("test-run-turn", "https://b.test/v1", "key-b"), + }); + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "combo/free", + input: "hello", + stream, + tools: [{ type: "function", name: "current_tool", parameters: { type: "object" } }], + }), + }), config, { model: "", provider: "" }); + const body = await response.text(); + expect(hits).toEqual(["m1"]); + expect(body).not.toContain("must not run"); + expect(body).not.toContain('"name":"stale_tool"'); + }); + } + + for (const stream of [false, true]) { + test(`${stream ? "streaming" : "non-streaming"} error after a replay-unsafe heartbeat stays on that child`, async () => { + const hits: string[] = []; + customRunTurn = async (parsed, _incoming, emit) => { + hits.push(parsed.modelId); + if (parsed.modelId === "m1") { + emit({ type: "heartbeat", replayUnsafe: true }); + emit({ type: "error", message: "transport lost after a local tool ran" }); + return; + } + emit({ type: "text_delta", text: "must not run" }); + emit({ type: "done" }); + }; + const config = comboConfig({ + a: provider("test-run-turn", "https://a.test/v1", "key-a"), + b: provider("test-run-turn", "https://b.test/v1", "key-b"), + }); + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/free", input: "hello", stream }), + }), config, { model: "", provider: "" }); + const body = await response.text(); + expect(hits).toEqual(["m1"]); + expect(body).not.toContain("must not run"); + }); + } + + test("undeclared adapter tool call after text never replays on backup", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return new Response([ + `data: ${JSON.stringify({ choices: [{ delta: { content: "already visible" } }] })}`, + `data: ${JSON.stringify({ choices: [{ delta: { + tool_calls: [{ index: 0, id: "call_stale", function: { name: "stale_tool", arguments: "{}" } }], + } }] })}`, + "data: [DONE]", + "", + ].join("\n"), { headers: { "content-type": "text/event-stream" } }); + }); + const b = serve(() => { + hits.push("b"); + return new Response("data: [DONE]\n\n", { + headers: { "content-type": "text/event-stream" }, + }); + }); + const config = comboConfig({ + a: provider("openai-chat", baseUrl(a), "key-a"), + b: provider("openai-chat", baseUrl(b), "key-b"), + }); + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "combo/free", + input: "hello", + stream: true, + tools: [{ type: "function", name: "current_tool", parameters: { type: "object" } }], + }), + }), config, { model: "", provider: "" }); + const body = await response.text(); + expect(body).toContain("already visible"); + expect(body).toContain("response.failed"); + expect(hits).toEqual(["a"]); + }); }); From c1905d4b330509f9c714d25c4a06b761c4bf2d64 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 20:24:27 +0900 Subject: [PATCH 07/48] =?UTF-8?q?fix(xai):=20bundle=20lane=20B=20=E2=80=94?= =?UTF-8?q?=20reasoning-model=20stop/penalty=20drops,=20policy=20403=20as?= =?UTF-8?q?=20content=5Ffilter,=20tool-result=20echo=20filter=20(#5676)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(xai): drop penalties for the reasoning models that reject them xAI documents that presencePenalty and frequencyPenalty cannot be used with reasoning models, and grok-4.7 answers 400 invalid-argument when either is sent. noPenaltyModels already exists end to end; the xAI entry never seeded it. Seed the documented reasoning ids so the openai-chat adapter and the Chat passthrough omit both penalties; non-reasoning ids keep caller penalties. Document the seed in structure/providers/xai-grok.md and the provider configuration reference in every locale. Carries #5668. Co-authored-by: sh940701 <100397903+sh940701@users.noreply.github.com> * fix(xai): drop stop for the reasoning models that reject it xAI reasoning models reject stop with 400 invalid-argument ("Model grok-4.x does not support parameter stop."). Claude Code auto-mode always sends stop_sequences, and forwarding them as stop makes its safety classifier mark Grok temporarily unavailable, so every Edit/Write/Bash is refused. Add noStopModels next to noTemperatureModels/noTopPModels (provider config, registry, derive fill, resolved-model policy, routedProviderConfig, OAuth preset reconcile and login copy, and an editor row in PROVIDER_CONFIG_FIELD_POLICY with the same shape as its siblings) and seed the documented xAI reasoning ids. The openai-chat adapter and Chat passthrough omit stop for them. The Responses passthrough drops stop and the penalties for listed models as well (stripRejectedSamplingParams), because grok-4.20-multi-agent-0309 has only the Responses wire and Claude inbound translates stop_sequences into a Responses stop. Non-reasoning ids keep caller stop and penalties. Carries #5669 (which replaced #5280), plus the Responses-wire coverage and the docs placement the review asked for. Co-authored-by: sh940701 <100397903+sh940701@users.noreply.github.com> * fix(xai): surface model policy 403 as a completed content_filter turn xAI sometimes refuses a turn with HTTP 403 and "I can't help with that request." Codex treats that as a transport failure, so the user message is never recorded and retries loop, while a new thread on the same account works. isUpstreamPolicyRefusal allowlists the exact normalized refusal sentences after unwrapping JSON and "Provider error 403:" bodies. On a non-combo Responses request, rewriteUpstreamPolicyRefusal returns an HTTP 200 Responses incomplete/content_filter payload, JSON or SSE, on both the adapter path and the native Responses passthrough; a streamed rewrite keeps the turn admission lease until the body is read. Combo attempts keep the original 403 so failover still works. Subscription, credit, entitlement and model-access 403s stay errors; the xAI plan and credit cues guard only the refusal matcher, so the global subscription classifier and status inference are unchanged. Document the mapping in the proxy-formats and combos guides for every locale, and the delivery and lease contract in structure/. Carries #5667 (which replaced #5279). Refs #5277. Co-authored-by: sh940701 <100397903+sh940701@users.noreply.github.com> * fix(cursor,xai): stop grok mid-turn tool-result echoes from sticking grok-4.6 sometimes ends a turn by pasting a replayed [Tool Result] / [tool_result] envelope after its prose. On Cursor the prefix sniffer never saw a mid-turn paste, and Codex Desktop restoring the pre-remint conversation id without a thread owner resumed the poisoned conversation. On xAI the Responses SSE was relayed verbatim, so the paste was stored as assistant text. One line-aware filter (src/lib/tool-envelope-echo-filter.ts) now runs on Cursor text deltas and xAI Responses output_text deltas. It holds back only a suffix that could still become a marker, so a marker split across deltas cannot leak its prefix, releases prose as soon as it diverges, drops a confirmed marker and its tail, and flushes harmless partial text at the end. output_text.done, response.completed, JSON bodies and the xAI continuation snapshot used for previous_response_id all carry the sanitized text. Cursor remembers restored-id -> reminted-id rewrites in a map keyed by a hashed credential scope, bounded to 2,048 entries with a one-hour expiry, so an id can never be redirected across credentials. The echo-remint budget follows the original conversation across remints, so a missing or changing thread owner no longer bypasses or resets it. Carries #5098, rebuilt on current dev with the split-marker, remint-scope, credential-scope and snapshot defects from its review fixed. Refs #4874. Co-authored-by: MerryEcho <126861868+MerryEcho@users.noreply.github.com> --------- Co-authored-by: sh940701 <100397903+sh940701@users.noreply.github.com> Co-authored-by: MerryEcho <126861868+MerryEcho@users.noreply.github.com> --- .../src/content/docs/fr/guides/claude-code.md | 2 + .../src/content/docs/fr/guides/combos.md | 1 + .../fr/reference/configuration/providers.md | 3 +- .../docs/fr/reference/proxy-formats.md | 6 + .../src/content/docs/guides/claude-code.md | 2 + docs-site/src/content/docs/guides/combos.md | 4 + .../src/content/docs/ja/guides/claude-code.md | 2 + .../src/content/docs/ja/guides/combos.md | 1 + .../ja/reference/configuration/providers.md | 3 +- .../docs/ja/reference/proxy-formats.md | 6 + .../src/content/docs/ko/guides/claude-code.md | 2 + .../src/content/docs/ko/guides/combos.md | 1 + .../ko/reference/configuration/providers.md | 3 +- .../docs/ko/reference/proxy-formats.md | 6 + .../docs/reference/configuration/providers.md | 3 +- .../content/docs/reference/proxy-formats.md | 15 + .../src/content/docs/ru/guides/claude-code.md | 2 + .../src/content/docs/ru/guides/combos.md | 4 + .../ru/reference/configuration/providers.md | 3 +- .../docs/ru/reference/proxy-formats.md | 6 + .../src/content/docs/tr/guides/claude-code.md | 2 + .../src/content/docs/tr/guides/combos.md | 1 + .../tr/reference/configuration/providers.md | 3 +- .../docs/tr/reference/proxy-formats.md | 6 + .../content/docs/zh-cn/guides/claude-code.md | 2 + .../src/content/docs/zh-cn/guides/combos.md | 1 + .../reference/configuration/providers.md | 3 +- .../docs/zh-cn/reference/proxy-formats.md | 6 + .../content/docs/zh-tw/guides/claude-code.md | 2 + .../src/content/docs/zh-tw/guides/combos.md | 1 + .../reference/configuration/providers.md | 3 +- .../docs/zh-tw/reference/proxy-formats.md | 6 + scripts/test-layout/layout.json | 2 + src/adapters/cursor.ts | 42 ++- src/adapters/cursor/envelope-echo.ts | 16 +- src/adapters/cursor/request-builder.ts | 13 +- src/adapters/cursor/thread-continuity.ts | 53 ++++ src/adapters/openai-chat.ts | 4 +- src/adapters/openai-chat/passthrough.ts | 1 + src/adapters/openai-responses/passthrough.ts | 3 +- .../openai-responses/request-strips.ts | 25 ++ src/lib/errors.ts | 87 ++++++ src/lib/tool-envelope-echo-filter.ts | 216 +++++++++++++++ src/oauth/index.ts | 1 + src/oauth/login-cli.ts | 1 + src/providers/derive.ts | 4 + src/providers/model-rename-fields.ts | 1 + src/providers/registry/entries-core.ts | 29 ++ src/providers/registry/model-ids.ts | 1 + src/providers/registry/types.ts | 3 +- src/providers/resolved-model-policy.ts | 3 +- src/router.ts | 2 + src/server/auth-cors.ts | 1 + src/server/grok-upstream-envelope-echo.ts | 120 ++++++++ src/server/responses/adapter-dispatch.ts | 18 +- src/server/responses/passthrough-delivery.ts | 37 ++- src/server/responses/policy-refusal.ts | 67 +++++ src/types/provider.ts | 7 + structure/providers/cursor.md | 41 ++- structure/providers/xai-grok.md | 39 +++ structure/transports/responses.md | 3 +- tests/fixtures/test-layout-expected.json | 2 + tests/helpers/responses-core-source.ts | 1 + .../cursor/cursor-envelope-echo-retry.test.ts | 261 +++++++++++++++++- tests/providers/xai/xai-no-stop.test.ts | 119 ++++++++ tests/providers/xai/xai-transport.test.ts | 60 +++- ...hrough-grok-upstream-envelope-echo.test.ts | 167 +++++++++++ tests/server/errors-adapter-failure.test.ts | 163 +++++++++++ 68 files changed, 1664 insertions(+), 60 deletions(-) create mode 100644 src/lib/tool-envelope-echo-filter.ts create mode 100644 src/server/grok-upstream-envelope-echo.ts create mode 100644 src/server/responses/policy-refusal.ts create mode 100644 tests/providers/xai/xai-no-stop.test.ts create mode 100644 tests/responses/passthrough-grok-upstream-envelope-echo.test.ts diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 45851d1fc0d..8c76860e6d2 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -543,6 +543,8 @@ Le proxy traduit chaque requête Anthropic Messages API au format Codex Response | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +Le mode automatique de Claude Code envoie toujours `stop_sequences`. Pour les modèles de la liste `noStopModels` du fournisseur routé, OpenCodex omet `stop` sur les fils Chat Completions et Responses, afin que les modèles de raisonnement xAI comme grok-4.7 et grok-4.6 ne renvoient pas `400 invalid-argument` et ne soient pas marqués temporairement indisponibles. Voir [`noStopModels`](/fr/reference/configuration/providers/). + Sur l’adaptateur Anthropic prévu, les blocs signés non masqués (y compris thinking vide) et les blocs redacted opaques sont préservés. `hideThinkingSummary` reste inchangé : le texte signé masqué localement n’est pas exposé aux clients Claude ; sa relecture sans perte via cette frontière reste non établie. Les anciennes enveloppes combinées ne permettent pas de rétablir l’ordre après émission du texte en streaming. `claudeCode.compatibility: "enforce"` refuse toujours la relecture thinking. Cela ne prouve ni l’acceptation réelle par Anthropic ni une amélioration du cache ; [#3719](https://github.com/lidge-jun/opencodex/issues/3719) reste ouvert. **Cas d'erreur (400) :** JSON mal formé ; `model` absent ou vide ; `messages` absent ou vide ; rôle non pris en charge ; diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md index 60f39900394..9251951858b 100644 --- a/docs-site/src/content/docs/fr/guides/combos.md +++ b/docs-site/src/content/docs/fr/guides/combos.md @@ -216,6 +216,7 @@ le temps de recharge expire. S’il ne reste aucune cible éligible, le proxy re :::note Le basculement est intentionnellement limité. Il facilite la disponibilité, l'authentification et l'authentification spécifiques à la cible. échecs de quota et de surcharge ; il ne cache pas les erreurs des appelants ni les refus de politique. +Sur une requête Responses hors combo, un 403 de politique xAI de la liste autorisée est réécrit en HTTP 200 `incomplete/content_filter` avant que Codex ne le relance comme un échec de transport ; voir [xAI policy refusals](/fr/reference/proxy-formats/#xai-policy-refusals). Les sauts de combo classent toujours le HTTP 403 d'origine comme un saut. ::: ## Effort de raisonnement par défaut diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 154bef591c0..22c0d2969d3 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -132,7 +132,8 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `noReasoningModels?` | `string[]` | Modèles qui rejettent les paramètres reasoning/thinking. | | `noTemperatureModels?` | `string[]` | Modèles qui rejettent `temperature` spécifié par l’appelant. | | `noTopPModels?` | `string[]` | Modèles qui rejettent `top_p` spécifié par l’appelant. | -| `noPenaltyModels?` | `string[]` | Modèles qui rejettent les pénalités presence/frequency. | +| `noStopModels?` | `string[]` | Modèles qui rejettent `stop` spécifié par l’appelant. L’adaptateur `openai-chat`, le passthrough Chat et le passthrough Responses omettent ce champ pour ces identifiants. Le préréglage `xai` intégré y liste ses modèles de raisonnement (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`), que xAI documente comme le refusant ; `grok-4.20-0309-non-reasoning`, `grok-composer-2.5-fast` conservent le `stop` de l’appelant. | +| `noPenaltyModels?` | `string[]` | Modèles qui rejettent les pénalités presence/frequency. Le préréglage `xai` intégré y liste ses modèles de raisonnement (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`), que xAI documente comme les refusant ; les identifiants sans raisonnement conservent les pénalités de l’appelant. | | `noStructuredOutputModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette `response_format`. Seule une correspondance exacte du modèle demandé omet le champ ; la traduction à sortie structurée reste activée pour tous les autres modèles `openai-chat`. | | `noJsonSchemaModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette un `response_format` `json_schema` mais accepte encore `json_object`. Une telle requête est rétrogradée vers `json_object` au lieu d’être supprimée, donc un appelant qui demande du JSON en reçoit toujours. `noStructuredOutputModels` l’emporte quand un modèle figure dans les deux listes. Les préréglages `opencode go`, `opencode zen` et `opencode free` l’embarquent pour leurs routes DeepSeek. | | `foldDeveloperRoleToSystem?` | `boolean` | Indique si une destination `openai-chat` accepte le rôle `developer`. `foldDeveloperRoleToSystem` non défini envoie `system`, `true` envoie `system` et `false` envoie `developer`. Non défini signifie que rien n'a été enregistré pour cette destination ; `true` enregistre un service en amont qui refuse le rôle ; `false` en enregistre un qui l'accepte. Dans tous les cas le message conserve sa position dans la conversation ; seul le rôle change. Une destination qui refuse le rôle répond `400 role 'developer' is not allowed` et le tour ne démarre pas, d'où l'état non enregistré replié par défaut. | diff --git a/docs-site/src/content/docs/fr/reference/proxy-formats.md b/docs-site/src/content/docs/fr/reference/proxy-formats.md index d5ea90765c7..4483284851b 100644 --- a/docs-site/src/content/docs/fr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/fr/reference/proxy-formats.md @@ -24,6 +24,12 @@ doit choisir parmi plusieurs cibles. Les requêtes de modèle, d’image, de vidéo et de recherche contenant des identifiants ne suivent pas automatiquement les redirections HTTP, même vers la même origine. Configurez l’URL finale de l’API plutôt qu’un alias qui redirige. Le serveur ne renvoie ni les identifiants ni le corps de la requête à la destination d’une redirection. Chaque chemin conserve sa gestion des erreurs ou son relais existant ; les routes Responses natives et compact peuvent renvoyer le 3xx et le `Location` d’origine au client. Le comportement de redirection du client est distinct de cette politique de transport du serveur. +## xAI policy refusals + +Certains refus xAI de Chat Completions arrivent en HTTP 403 avec une phrase de refus exacte, par exemple `I can't help with that request.`, au lieu d'un HTTP 200 avec `finish_reason: content_filter`. Codex traite un 403 comme un échec de transport : le tour utilisateur n'est pas enregistré et la même requête est renvoyée. + +Sur une requête Responses hors combo, OpenCodex réécrit ce 403 de la liste autorisée en réponse Responses HTTP 200 avec `status: "incomplete"` et `incomplete_details.reason: "content_filter"`. La réécriture s'applique au chemin de l'adaptateur openai-chat et au passthrough openai-responses (OAuth grok-4.6 / grok-4.5). Le streaming utilise la même limite incomplete. Un corps 403 vide ou fait d'espaces reste une erreur. Les 403 d'abonnement, de crédits, de droits d'accès et `not allowed to use this model` restent des erreurs. Le basculement de combo voit toujours le HTTP 403 d'origine. + ## Présentation du point de terminaison | Espace client | Point de terminaison | Résultat non-stream réussi | Résultat de flux ou de socket réussi | diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 02d3d78875c..d3ded0de806 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -620,6 +620,8 @@ The proxy translates every Anthropic Messages API request into the Codex Respons | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +Claude Code auto-mode always sends `stop_sequences`. For models in the routed provider's `noStopModels` list, OpenCodex omits `stop` on both the Chat Completions and Responses wires, so xAI reasoning models such as grok-4.7 and grok-4.6 do not return `400 invalid-argument` and get marked temporarily unavailable. See [`noStopModels`](/reference/configuration/providers/). + Replay preserves non-hidden signed blocks (including empty thinking) and opaque redacted blocks on the intended Anthropic adapter. `hideThinkingSummary` remains unchanged: locally hidden signed text is not exposed to Claude clients, and lossless replay through that hidden Claude boundary is not established. Older combined reasoning envelopes cannot recover original block order once streaming text has been emitted. `claudeCode.compatibility: "enforce"` still rejects thinking replay. This does not establish live Anthropic acceptance or cache-hit improvements; [#3719](https://github.com/lidge-jun/opencodex/issues/3719) remains open. **Error cases (400):** malformed JSON; missing/empty `model`; missing/empty `messages`; unsupported diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index c3feb30f58e..166ed292f56 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -248,6 +248,10 @@ used by native account routing. :::note Failover is intentionally bounded. It helps with target-specific availability, authentication, quota, and overload failures; it does not hide caller errors or policy refusals. +On a non-combo Responses request, an allowlisted xAI policy 403 is rewritten to HTTP 200 +`incomplete/content_filter` before Codex retries it as a transport failure; see +[xAI policy refusals](/reference/proxy-formats/#xai-policy-refusals). Combo hops still +classify the original HTTP 403 as a hop. ::: For streaming requests, the upstream HTTP status is not the final decision. OpenCodex buffers a diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index c8ca64b3fee..3d03447eeee 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -407,6 +407,8 @@ Claude Code の `/effort` 設定はアダプターでも維持されます。 | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +Claude Codeの自動モードは常に`stop_sequences`を送ります。ルーティング先プロバイダーの`noStopModels`リストにあるモデルでは、OpenCodexはChat CompletionsとResponsesの両方のワイヤーで`stop`を省きます。そのため、grok-4.7やgrok-4.6などのxAI推論モデルが`400 invalid-argument`を返したり、一時的に利用不可と判定されたりしません。[`noStopModels`](/ja/reference/configuration/providers/)を参照してください。 + 意図した Anthropic アダプターでは、非表示でない署名付きブロック(空の thinking を含む)と不透明な redacted ブロックを保持します。`hideThinkingSummary` は変更しません。ローカルで隠した署名付きテキストは Claude クライアントに公開せず、この非表示境界での無損失再生は未確認です。旧形式の結合エンベロープは、テキスト送信後に元のブロック順を復元できません。`claudeCode.compatibility: "enforce"` は引き続き thinking 再生を拒否します。実際の Anthropic 受理やキャッシュ改善の証明ではなく、[#3719](https://github.com/lidge-jun/opencodex/issues/3719) は未解決です。 **エラー条件(400):** 不正な JSON、欠落または空の `model`、欠落または空の `messages`、未サポートの diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 1412a18481e..e77bba80380 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -135,6 +135,7 @@ ocx combo set balanced \ :::note フェイルオーバーは意図的に制限されています。これは、ターゲット固有の可用性、認証、クォータ、および過負荷の障害に役立ちます。呼び出し元のエラーやポリシーの拒否は隠蔽されません。 +コンボではない Responses リクエストでは、allowlist された xAI ポリシー 403 が Codex の転送失敗再試行の前に HTTP 200 `incomplete/content_filter` へ書き換えられます。[xAI policy refusals](/reference/proxy-formats/#xai-policy-refusals) を参照してください。コンボのホップは元の HTTP 403 をホップとして分類します。 ::: ストリーミング リクエストでは、アップストリームの HTTP ステータスだけで最終判断しません。OpenCodex は、選択した子ターゲットの Responses SSE を出力開始前の上限付き範囲だけバッファします。テキスト、推論、ツール呼び出し、またはその他の出力イベントが始まる前に再試行可能な `response.failed` ターミナルを受け取った場合、その子を失敗として記録し、次の適格なターゲットを試せます。出力が始まるかバッファ上限に達した時点で現在のターゲットにコミットし、その後のストリーム失敗を別プロバイダーへ再送しません。これによりテキストやツール実行の重複を防ぎます。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 5d49fe60aa3..62e09f465cc 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -125,7 +125,8 @@ account を削除しても mapping は保持され、同じ id を再追加す | `noReasoningModels?` | `string[]` |推論/思考パラメーターを拒否するモデル。 | | `noTemperatureModels?` | `string[]` |発信者指定の`temperature`を拒否するモデル。 | | `noTopPModels?` | `string[]` |発信者指定の`top_p`を拒否するモデル。 | -| `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 | +| `noStopModels?` | `string[]` |発信者指定の`stop`を拒否するモデル。`openai-chat`アダプター、Chatパススルー、Responsesパススルーはこれらのモデルに対してこのフィールドを送りません。組み込みの`xai`プリセットは、xAIがこれを拒否すると文書化している推論モデル(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`)をここに登録します。`grok-4.20-0309-non-reasoning`, `grok-composer-2.5-fast`は呼び出し元の`stop`をそのまま受け取ります。 | +| `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 組み込みの`xai`プリセットは、xAIがこれらを拒否すると文書化している推論モデル(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`)をここに登録します。非推論モデルは呼び出し元のペナルティをそのまま受け取ります。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否する正確なモデル ID。要求モデルが項目と完全一致する場合だけフィールドを省略し、その他の `openai-chat` モデルでは structured-output 変換を維持します。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` エンドポイントが `json_schema` 形式は拒否しつつ `json_object` は受け入れる正確なモデル ID。この要求はフィールドを削除せず `json_object` に降格して送るため、JSON を求めた呼び出し側は散文ではなく JSON を受け取れます。両方の一覧に載るモデルでは `noStructuredOutputModels` が優先します。`opencode go` / `opencode zen` / `opencode free` プリセットが DeepSeek 経路に既定で載せます。 | | `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` の宛先が `developer` ロールを受け付けるかを記録します。`foldDeveloperRoleToSystem` が未設定なら `system`、`true` なら `system`、`false` なら `developer` として送ります。未設定はこの宛先について何も記録されていないことを意味し、`true` は上流がロールを拒否する記録、`false` は受け付ける記録です。いずれの場合もメッセージは会話内の位置を保ち、変わるのはロールだけです。ロールを拒否する宛先は `400 role 'developer' is not allowed` を返してターンが始まらないため、未記録の既定は畳む側にしてあります。 | diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index a2e5fcde3c9..7c5cbbb4277 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -18,6 +18,12 @@ provider events → internal adapter events → client dialect 認証情報を含むモデル・画像・動画・検索リクエストは、同一オリジンを含む HTTP リダイレクトを自動追跡しません。リダイレクトする別名ではなく、最終的な上流 API URL を設定してください。サーバーはリダイレクト先に認証情報やリクエスト本文を再送しません。各応答処理の既存のエラー処理・中継動作は維持され、native Responses と compact は元の 3xx と `Location` をクライアントへ返す場合があります。クライアントのリダイレクト動作は、このサーバー転送ポリシーとは別です。 +## xAI policy refusals + +一部の xAI Chat Completions 拒否は、HTTP 200 と `finish_reason: content_filter` ではなく、HTTP 403 と `I can't help with that request.` のような拒否文だけを返します。Codex は 403 を転送失敗として扱うため、ユーザーのターンが記録されず、同じリクエストが再送されます。 + +コンボではない Responses リクエストでは、OpenCodex はその allowlist 対象の 403 を HTTP 200 の Responses、`status: "incomplete"`、`incomplete_details.reason: "content_filter"` に書き換えます。openai-chat アダプタ経路と openai-responses パススルー(grok-4.6 / grok-4.5 OAuth)の両方です。ストリーミングも同じ incomplete 境界です。空本文の 403 はエラーのままです。サブスクリプション、クレジット、権限、`not allowed to use this model` の 403 はエラーのままです。コンボのフェイルオーバーは元の HTTP 403 を見ます。 + ## エンドポイントの概要 |クライアントサーフェス |エンドポイント |非ストリームの結果が成功 |成功したストリームまたはソケットの結果 | diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 88bf75acae5..37f17c01974 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -442,6 +442,8 @@ Claude Code의 `/effort` 설정은 어댑터에서도 유지돼요. | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +Claude Code 자동 모드는 항상 `stop_sequences`를 보냅니다. 라우팅된 제공자의 `noStopModels` 목록에 있는 모델이면 OpenCodex는 Chat Completions와 Responses 양쪽 와이어에서 `stop`을 빼고 보냅니다. 그래서 grok-4.7, grok-4.6 같은 xAI 추론 모델이 `400 invalid-argument`를 돌려주거나 일시적으로 쓸 수 없는 모델로 표시되지 않습니다. [`noStopModels`](/ko/reference/configuration/providers/)를 참고하세요. + 의도한 Anthropic 어댑터에서는 숨기지 않은 서명 블록(빈 thinking 포함)과 불투명 redacted 블록을 보존해요. `hideThinkingSummary` 정책은 유지돼요. 로컬에서 숨긴 서명 텍스트를 Claude 클라이언트에 노출하지 않으며, 이 숨김 경계를 통한 무손실 재생은 아직 보장하지 않아요. 이전 결합 봉투는 스트리밍 텍스트가 이미 전송됐다면 원래 블록 순서를 복원할 수 없어요. `claudeCode.compatibility: "enforce"`는 여전히 thinking 재생을 거절해요. 실제 Anthropic 수락이나 캐시 적중 개선을 증명한 것은 아니며 [#3719](https://github.com/lidge-jun/opencodex/issues/3719)는 열어 둬요. **오류 조건(400):** 잘못된 JSON, 누락되거나 빈 `model`, 누락되거나 빈 `messages`, 지원하지 않는 diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index b9d79ff6079..b49eb1ae35a 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -141,6 +141,7 @@ ocx combo set balanced \ :::note 페일오버는 의도적으로 범위를 제한합니다. 대상별 가용성, 인증, 쿼터, 과부하 실패에는 도움이 되지만, 호출자 오류나 정책 거부를 숨기지는 않습니다. +콤보가 아닌 Responses 요청에서는 allowlist에 오른 xAI 정책 403이 Codex가 전송 실패로 재시도하기 전에 HTTP 200 `incomplete/content_filter`로 바뀝니다. [xAI policy refusals](/reference/proxy-formats/#xai-policy-refusals)를 보세요. 콤보 홉은 원래 HTTP 403을 홉으로 분류합니다. ::: 스트리밍 요청에서는 상위 HTTP 상태만으로 최종 결정을 내리지 않습니다. OpenCodex는 선택한 하위 대상의 Responses SSE를 출력 시작 전의 제한된 구간까지만 버퍼링합니다. 텍스트, 추론, 도구 호출 또는 그 밖의 출력 이벤트가 시작되기 전에 재시도 가능한 `response.failed` 종결 이벤트가 오면 해당 시도를 실패로 기록하고 다음 적합한 대상을 시도할 수 있습니다. 출력이 시작되거나 버퍼 상한에 도달하면 현재 대상에 커밋하며, 이후의 스트림 실패를 다른 공급자에서 다시 실행하지 않습니다. 따라서 텍스트와 도구 실행이 중복되지 않습니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 05969efb075..0511c729375 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -125,7 +125,8 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `noReasoningModels?` | `string[]` | reasoning/thinking 매개변수를 거부하는 모델입니다. | | `noTemperatureModels?` | `string[]` | 호출자가 지정한 `temperature`를 거부하는 모델입니다. | | `noTopPModels?` | `string[]` | 호출자가 지정한 `top_p`를 거부하는 모델입니다. | -| `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. | +| `noStopModels?` | `string[]` | 호출자가 지정한 `stop`을 거부하는 모델입니다. `openai-chat` 어댑터, Chat 패스스루, Responses 패스스루가 이 모델에는 해당 필드를 보내지 않습니다. 기본 `xai` 프리셋은 xAI 문서가 이 값을 거부한다고 밝힌 추론 모델(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`)을 여기에 넣습니다. `grok-4.20-0309-non-reasoning`, `grok-composer-2.5-fast`은 호출자가 보낸 `stop`을 그대로 받습니다. | +| `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. 기본 `xai` 프리셋은 xAI 문서가 이 값을 거부한다고 밝힌 추론 모델(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`)을 여기에 넣습니다. 추론이 없는 모델은 호출자가 보낸 penalty를 그대로 받습니다. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 정확한 모델 ID입니다. 요청 모델이 항목과 정확히 일치할 때만 필드를 생략하며, 그 외 `openai-chat` 모델에서는 structured-output 변환을 유지합니다. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 엔드포인트가 `json_schema` 형식은 거부하지만 `json_object`는 받는 정확한 모델 ID입니다. 이런 요청은 필드를 지우는 대신 `json_object`로 낮춰 보내므로, JSON을 요청한 클라이언트가 산문 대신 JSON을 받습니다. 한 모델이 두 목록에 모두 있으면 `noStructuredOutputModels`가 우선합니다. `opencode go`, `opencode zen`, `opencode free` 프리셋이 DeepSeek 경로에 기본으로 싣습니다. | | `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` 목적지가 `developer` 역할을 받는지 기록합니다. `foldDeveloperRoleToSystem`이 없으면 `system`, `true`이면 `system`, `false`이면 `developer`로 보냅니다. 값이 없다는 것은 이 목적지에 대해 기록된 것이 없다는 뜻이고, `true`는 상위 서비스가 역할을 거부한다는 기록, `false`는 받아들인다는 기록입니다. 어느 경우에도 메시지는 대화 안의 원래 위치를 유지하며 역할만 바뀝니다. 역할을 거부하는 목적지는 `400 role 'developer' is not allowed`로 응답해 턴이 시작조차 못 하므로, 기록이 없는 상태의 기본값을 접는 쪽으로 둡니다. | diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index 26c8c697310..4848304cfbb 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -23,6 +23,12 @@ Responses 표현이 이 연결의 중심입니다. 네이티브 호환 경로는 자격 증명을 포함하는 모델·이미지·동영상·검색 요청은 동일 출처를 포함한 HTTP 리다이렉트를 자동으로 따라가지 않습니다. 리다이렉트하는 별칭 대신 최종 업스트림 API URL을 설정하세요. 서버는 리다이렉트 대상으로 자격 증명이나 요청 본문을 다시 보내지 않습니다. 각 응답 처리 경로의 기존 오류·전달 동작은 유지되며, native Responses와 compact 경로는 원래 3xx와 `Location`을 클라이언트에 반환할 수 있습니다. 클라이언트의 리다이렉트 동작은 이 서버 전송 정책과 별개입니다. +## xAI policy refusals + +일부 xAI Chat Completions 거부는 HTTP 200과 `finish_reason: content_filter` 대신, HTTP 403과 `I can't help with that request.` 같은 거절 문장만 돌려줍니다. Codex는 403을 전송 실패로 보므로 사용자 턴이 기록되지 않고 같은 요청을 다시 보냅니다. + +콤보가 아닌 Responses 요청에서 OpenCodex는 allowlist에 오른 그 403을 HTTP 200 Responses, `status: "incomplete"`, `incomplete_details.reason: "content_filter"`로 바꿉니다. openai-chat 어댑터 경로와 openai-responses passthrough(grok-4.6 / grok-4.5 OAuth) 모두에서 동작합니다. 스트리밍도 같은 incomplete 경계입니다. 빈 본문 403은 오류로 남습니다. 구독, 크레딧, 권한, `not allowed to use this model` 403은 오류로 남습니다. 콤보 페일오버는 원래 HTTP 403을 그대로 봅니다. + ## 엔드포인트 개요 | 클라이언트 표면 | 엔드포인트 | 성공한 비스트리밍 결과 | 성공한 스트리밍 또는 소켓 결과 | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 48e18d25b80..e61dd417923 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -255,7 +255,8 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `noReasoningModels?` | `string[]` | Models that reject reasoning/thinking parameters. | | `noTemperatureModels?` | `string[]` | Models that reject caller-specified `temperature`. | | `noTopPModels?` | `string[]` | Models that reject caller-specified `top_p`. | -| `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. | +| `noStopModels?` | `string[]` | Models that reject caller-specified `stop`. The `openai-chat` adapter, the Chat passthrough and the Responses passthrough omit the field for those ids. The built-in `xai` preset lists its reasoning models (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`), which xAI documents as rejecting it; `grok-4.20-0309-non-reasoning`, `grok-composer-2.5-fast` keep caller `stop`. | +| `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. The built-in `xai` preset lists its reasoning models (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`), which xAI documents as rejecting them; non-reasoning ids keep caller penalties. | | `noStructuredOutputModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects `response_format`. Only an exact requested-model match omits the field; structured-output translation stays enabled for every other `openai-chat` model. | | `noJsonSchemaModels?` | `string[]` | Exact model IDs whose `openai-chat` endpoint rejects a `json_schema` `response_format` but still accepts `json_object`. Such a request is downgraded to `json_object` instead of being dropped, so a caller asking for JSON still gets JSON. `noStructuredOutputModels` wins when a model is on both lists. The `opencode go`, `opencode zen`, and `opencode free` presets ship this for their DeepSeek routes. | | `foldDeveloperRoleToSystem?` | `boolean` | Whether an `openai-chat` destination accepts the `developer` role. `foldDeveloperRoleToSystem` unset sends `system`, `true` sends `system`, and `false` sends `developer`. Unset means nothing has been recorded about this destination; `true` records an upstream that rejects the role; `false` records one that accepts it. The message keeps its position in the conversation in every case — only the role changes. A destination that rejects the role answers `400 role 'developer' is not allowed` and the turn never starts, which is why the unrecorded state is the folded one. A provider save that keeps the destination keeps it; see [What a provider save keeps](#what-a-provider-save-keeps). `PATCH` accepts a boolean or `null` to clear it. | diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 9e0629ac671..ea9c4cfb85d 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -39,6 +39,21 @@ attempt with tools removed and existing results retained. This can incur another request. A second empty answer fails; malformed calls and provider refusal or truncation outcomes are preserved without this retry. +## xAI policy refusals + +Some xAI Chat Completions refusals arrive as HTTP 403 with an exact model-refusal +sentence such as `I can't help with that request.` instead of HTTP 200 plus +`finish_reason: content_filter`. Codex treats a 403 as a transport failure, so the +user turn is never recorded and the same request is retried. + +On a non-combo Responses request, OpenCodex rewrites that allowlisted 403 to an +HTTP 200 Responses payload with `status: "incomplete"` and +`incomplete_details.reason: "content_filter"`. The rewrite runs on the openai-chat +adapter path and on openai-responses passthrough (grok-4.6 / grok-4.5 OAuth). +Streaming uses the same incomplete boundary. Empty or whitespace 403 bodies stay +errors. Subscription, credit, entitlement, and `not allowed to use this +model` 403s stay errors. Combo failover still sees the original HTTP 403. + ## Cursor context overflow Cursor's first bare context overflow is surfaced to the client. Later eligible requests diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index bbb81d3ac44..927394e95b8 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -408,6 +408,8 @@ Claude Code — это лишь учётные данные для доступ | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +Автоматический режим Claude Code всегда отправляет `stop_sequences`. Для моделей из списка `noStopModels` маршрутизируемого провайдера OpenCodex опускает `stop` и в Chat Completions, и в Responses, поэтому модели рассуждения xAI, такие как grok-4.7 и grok-4.6, не возвращают `400 invalid-argument` и не помечаются как временно недоступные. См. [`noStopModels`](/ru/reference/configuration/providers/). + На выбранном адаптере Anthropic сохраняются нескрытые подписанные блоки (включая пустой thinking) и непрозрачные блоки redacted. Политика `hideThinkingSummary` не меняется: локально скрытый подписанный текст не раскрывается клиентам Claude, а воспроизведение без потерь через эту границу пока не подтверждено. Старые объединённые конверты не восстанавливают порядок после отправки потокового текста. `claudeCode.compatibility: "enforce"` по-прежнему отклоняет thinking replay. Приём реальным Anthropic и улучшение кеша не доказаны; [#3719](https://github.com/lidge-jun/opencodex/issues/3719) остаётся открытым. **Случаи ошибок (400):** некорректный JSON; отсутствующий или пустой `model`; отсутствующий или diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index 5f25901ec69..db3e632e09d 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -173,6 +173,10 @@ ocx combo set balanced \ :::note Failover намеренно ограничен. Он помогает при проблемах доступности конкретной цели, аутентификации, квоты и перегрузки; он не скрывает ошибки вызывающей стороны и отказы политики. +На non-combo запросе Responses allowlist-ованный xAI policy 403 переписывается в HTTP 200 +`incomplete/content_filter` до того, как Codex повторит его как транспортный сбой; см. +[xAI policy refusals](/reference/proxy-formats/#xai-policy-refusals). Hop в combo по-прежнему +классифицирует исходный HTTP 403 как hop. ::: Для потоковых запросов одного HTTP-статуса upstream недостаточно для окончательного решения. OpenCodex буферизует только ограниченный префикс Responses SSE выбранной дочерней цели до начала вывода. Если повторяемый terminal `response.failed` приходит до текста, reasoning, вызова инструмента или другого события вывода, попытка отмечается как неудачная и combo может перейти к следующей подходящей цели. После начала вывода или достижения лимита буфера текущая цель считается выбранной; более поздний сбой потока не воспроизводится у другого провайдера. Это предотвращает дублирование текста и выполнения инструментов. diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 26aa73be207..bb5555b55fc 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -138,7 +138,8 @@ cross-route credential fallback не существует. Строки API GPT- | `noReasoningModels?` | `string[]` | Модели, отвергающие параметры reasoning/thinking. | | `noTemperatureModels?` | `string[]` | Модели, отвергающие переданный вызывающей стороной `temperature`. | | `noTopPModels?` | `string[]` | Модели, отвергающие переданный вызывающей стороной `top_p`. | -| `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. | +| `noStopModels?` | `string[]` | Модели, отвергающие переданный вызывающей стороной `stop`. Адаптер `openai-chat`, Chat passthrough и Responses passthrough не передают это поле для таких моделей. Встроенный пресет `xai` перечисляет здесь свои модели рассуждения (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`), которые, по документации xAI, отвергают этот параметр; `grok-4.20-0309-non-reasoning`, `grok-composer-2.5-fast` сохраняют `stop` вызывающей стороны. | +| `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. Встроенный пресет `xai` перечисляет здесь свои модели рассуждения (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`), которые, по документации xAI, отвергают эти параметры; модели без рассуждения сохраняют penalty вызывающей стороны. | | `noStructuredOutputModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format`. Поле опускается только при точном совпадении запрошенной модели; для остальных моделей `openai-chat` преобразование structured output остаётся включённым. | | `noJsonSchemaModels?` | `string[]` | Точные идентификаторы моделей, чей endpoint `openai-chat` отклоняет `response_format` типа `json_schema`, но принимает `json_object`. Такой запрос понижается до `json_object`, а не отбрасывается, поэтому вызывающая сторона всё равно получает JSON. Если модель есть в обоих списках, побеждает `noStructuredOutputModels`. Пресеты `opencode go`, `opencode zen` и `opencode free` включают это для своих маршрутов DeepSeek. | | `foldDeveloperRoleToSystem?` | `boolean` | Принимает ли назначение `openai-chat` роль `developer`. Если `foldDeveloperRoleToSystem` не задан, сообщение уходит как `system`; при `true` — как `system`; при `false` — как `developer`. Не задано означает, что об этом назначении ничего не записано; `true` фиксирует вышестоящий сервис, который роль отклоняет; `false` — тот, который её принимает. В любом случае сообщение сохраняет свою позицию в разговоре, меняется только роль. Назначение, отклоняющее роль, отвечает `400 role 'developer' is not allowed`, и ход не начинается — поэтому незаписанное состояние по умолчанию свёрнуто. | diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index 6dd78e5282b..976972081e2 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -25,6 +25,12 @@ control и safety ответа всё равно происходят на гр Запросы к моделям, изображениям, видео и поиску, содержащие учётные данные, не следуют HTTP-перенаправлениям автоматически, в том числе в пределах одного origin. Укажите конечный URL API вместо перенаправляющего адреса. Сервер не отправляет учётные данные и тело запроса по адресу перенаправления. Существующая обработка ошибок и передача ответа сохраняются; маршруты native Responses и compact могут вернуть клиенту исходные 3xx и `Location`. Поведение перенаправлений клиента не определяется этой транспортной политикой сервера. +## xAI policy refusals + +Некоторые отказы xAI Chat Completions приходят как HTTP 403 с точной фразой отказа, например `I can't help with that request.`, а не как HTTP 200 с `finish_reason: content_filter`. Codex считает 403 транспортным сбоем, поэтому ход пользователя не записывается и тот же запрос повторяется. + +На non-combo запросе Responses OpenCodex переписывает такой allowlist-ованный 403 в HTTP 200 Responses со `status: "incomplete"` и `incomplete_details.reason: "content_filter"`. Это работает и на пути адаптера openai-chat, и на openai-responses passthrough (OAuth grok-4.6 / grok-4.5). Поток использует ту же incomplete-границу. Пустой 403 остаётся ошибкой. 403 подписки, кредитов, прав доступа и `not allowed to use this model` остаются ошибками. Combo failover по-прежнему видит исходный HTTP 403. + ## Обзор endpoint'ов | Клиентская поверхность | Endpoint | Успешный non-stream результат | Успешный результат потока или сокета | diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index a2257ae9775..e61ad63f721 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -601,6 +601,8 @@ dönüştürür: | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +Claude Code otomatik modu her zaman `stop_sequences` gönderir. Yönlendirilen sağlayıcının `noStopModels` listesindeki modeller için OpenCodex `stop` alanını hem Chat Completions hem de Responses hattında göndermez; böylece grok-4.7 ve grok-4.6 gibi xAI akıl yürütme modelleri `400 invalid-argument` döndürmez ve geçici olarak kullanılamaz diye işaretlenmez. Bkz. [`noStopModels`](/tr/reference/configuration/providers/). + Hedeflenen Anthropic adaptöründe gizlenmemiş imzalı bloklar (boş thinking dahil) ve opak redacted blokları korunur. `hideThinkingSummary` değişmez: yerel olarak gizlenen imzalı metin Claude istemcilerine gösterilmez; bu sınır üzerinden kayıpsız yeniden oynatma doğrulanmamıştır. Eski birleşik zarflarda metin akışla gönderildikten sonra özgün blok sırası geri getirilemez. `claudeCode.compatibility: "enforce"` thinking yeniden oynatmasını hâlâ reddeder. Bu, gerçek Anthropic kabulünü veya önbellek iyileşmesini kanıtlamaz; [#3719](https://github.com/lidge-jun/opencodex/issues/3719) açık kalır. **Hata durumları (400):** hatalı biçimlendirilmiş JSON; eksik/boş `model`; diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md index 80026db6155..a837a74e312 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -247,6 +247,7 @@ soğuma süresi dolana kadar onu atlar. Uygun hiçbir hedef kalmazsa proxy Yük devretme kasıtlı olarak sınırlandırılmıştır. Hedefe özgü kullanılabilirlik, kimlik doğrulama, kota ve aşırı yük hatalarına yardımcı olur; arayan hatalarını veya politika retlerini gizlemez. +Kombo olmayan bir Responses isteğinde, izin listesindeki bir xAI politika 403'ü Codex onu taşıma hatası olarak yeniden denemeden önce HTTP 200 `incomplete/content_filter` yanıtına dönüştürülür; bkz. [xAI policy refusals](/tr/reference/proxy-formats/#xai-policy-refusals). Kombo atlamaları özgün HTTP 403'ü yine bir atlama olarak sınıflandırır. ::: ## Varsayılan akıl yürütme çabası diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index d1c8fb099a2..58b54d63ef8 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -139,7 +139,8 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `noReasoningModels?` | `string[]` | Akıl yürütme/düşünme parametrelerini reddeden modeller. | | `noTemperatureModels?` | `string[]` | Arayan tarafından belirtilen `temperature` değerini reddeden modeller. | | `noTopPModels?` | `string[]` | Arayan tarafından belirtilen `top_p` değerini reddeden modeller. | -| `noPenaltyModels?` | `string[]` | Varlık/frekans cezalarını reddeden modeller. | +| `noStopModels?` | `string[]` | Arayan tarafından belirtilen `stop` değerini reddeden modeller. `openai-chat` bağdaştırıcısı, Chat geçişi ve Responses geçişi bu kimlikler için alanı göndermez. Yerleşik `xai` ön ayarı, xAI belgelerine göre bunu reddeden akıl yürütme modellerini (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`) burada listeler; `grok-4.20-0309-non-reasoning`, `grok-composer-2.5-fast` çağıranın `stop` değerini korur. | +| `noPenaltyModels?` | `string[]` | Varlık/frekans cezalarını reddeden modeller. Yerleşik `xai` ön ayarı, xAI belgelerine göre bu değerleri reddeden akıl yürütme modellerini (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`) burada listeler; akıl yürütmesiz modeller çağıranın cezalarını korur. | | `noStructuredOutputModels?` | `string[]` | `openai-chat` uç noktası `response_format`'ı reddeden tam model kimlikleri. Yalnızca tam bir istenen model eşleşmesi alanı atlar; yapılandırılmış çıktı çevirisi diğer her `openai-chat` modeli için etkin kalır. | | `noJsonSchemaModels?` | `string[]` | `openai-chat` uç noktası `json_schema` biçimini reddeden ama `json_object` kabul eden tam model kimlikleri. Böyle bir istek atılmak yerine `json_object` seviyesine düşürülür, böylece JSON isteyen çağıran yine JSON alır. Bir model her iki listede de varsa `noStructuredOutputModels` kazanır. `opencode go`, `opencode zen` ve `opencode free` hazır ayarları bunu DeepSeek rotaları için getirir. | | `foldDeveloperRoleToSystem?` | `boolean` | Bir `openai-chat` hedefinin `developer` rolünü kabul edip etmediğini kaydeder. `foldDeveloperRoleToSystem` ayarlanmamışsa `system`, `true` ise `system`, `false` ise `developer` olarak gönderilir. Ayarlanmamış olması bu hedef için hiçbir şey kaydedilmediği anlamına gelir; `true` rolü reddeden bir üst hizmeti, `false` ise kabul edeni kaydeder. Her durumda mesaj konuşmadaki konumunu korur; yalnızca rol değişir. Rolü reddeden bir hedef `400 role 'developer' is not allowed` yanıtı verir ve tur hiç başlamaz; kaydedilmemiş durumun katlanmış olmasının nedeni budur. | diff --git a/docs-site/src/content/docs/tr/reference/proxy-formats.md b/docs-site/src/content/docs/tr/reference/proxy-formats.md index 5f12bbfd6f1..90ed019103b 100644 --- a/docs-site/src/content/docs/tr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/tr/reference/proxy-formats.md @@ -27,6 +27,12 @@ genel model kimliği birkaç hedef arasından seçim yapması gerektiğinde Kimlik bilgisi taşıyan model, görsel, video ve arama istekleri, aynı origin içindeki yönlendirmeler dâhil HTTP yönlendirmelerini otomatik izlemez. Yönlendiren bir adres yerine son API URL’sini yapılandırın. Sunucu, kimlik bilgilerini veya istek gövdesini yönlendirme hedefine yeniden göndermez. Mevcut hata işleme ve yanıt aktarma davranışı korunur; native Responses ve compact yolları, özgün 3xx ve `Location` değerini istemciye döndürebilir. İstemcinin yönlendirme davranışı bu sunucu aktarım politikasından ayrıdır. +## xAI policy refusals + +Bazı xAI Chat Completions retleri, HTTP 200 ve `finish_reason: content_filter` yerine `I can't help with that request.` gibi tam bir ret cümlesiyle HTTP 403 olarak gelir. Codex 403'ü taşıma hatası sayar; kullanıcı turu kaydedilmez ve aynı istek yeniden gönderilir. + +Kombo olmayan bir Responses isteğinde OpenCodex, izin listesindeki bu 403'ü `status: "incomplete"` ve `incomplete_details.reason: "content_filter"` içeren bir HTTP 200 Responses yanıtına dönüştürür. Dönüştürme openai-chat bağdaştırıcı yolunda ve openai-responses geçişinde (grok-4.6 / grok-4.5 OAuth) çalışır. Akış da aynı incomplete sınırını kullanır. Boş veya yalnızca boşluk içeren 403 gövdeleri hata olarak kalır. Abonelik, kredi, yetki ve `not allowed to use this model` 403'leri hata olarak kalır. Kombo yük devretmesi özgün HTTP 403'ü görmeye devam eder. + ## Uç nokta genel bakışı | İstemci yüzeyi | Uç nokta | Başarılı akışsız sonuç | Başarılı akış veya soket sonucu | diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 25695885e96..29103646afe 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -357,6 +357,8 @@ Claude Code 的 `/effort` 设置会完整保留并传递给适配器: | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +Claude Code 自动模式总是发送 `stop_sequences`。对于路由目标提供方 `noStopModels` 列表中的模型,OpenCodex 在 Chat Completions 和 Responses 两种线路上都会省略 `stop`,因此 grok-4.7、grok-4.6 等 xAI 推理模型不会返回 `400 invalid-argument` 并被标记为暂时不可用。参见 [`noStopModels`](/zh-cn/reference/configuration/providers/)。 + 在预期的 Anthropic 适配器上,保留未隐藏的签名块(包括空 thinking)和不透明的 redacted 块。`hideThinkingSummary` 策略不变:不会向 Claude 客户端公开本地隐藏的签名文本,尚未证明经过此隐藏边界的无损重放。旧版组合信封在流式文本发出后无法恢复原始块顺序。`claudeCode.compatibility: "enforce"` 仍拒绝 thinking 重放。这不证明真实 Anthropic 接受请求或缓存命中改善;[#3719](https://github.com/lidge-jun/opencodex/issues/3719) 仍未关闭。 **错误情况(400):**JSON 格式错误;缺少/空的 `model`;缺少/空的 `messages`;不支持的 diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index 3df8776c3fd..dae57bd773f 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -161,6 +161,7 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。 :::note 故障切换是有边界的。它有助于处理特定目标的可用性、认证、配额和过载失败;它不会掩盖调用方错误或策略拒绝。 +在非 combo 的 Responses 请求上,allowlist 中的 xAI 策略 403 会在 Codex 将其当作传输失败重试之前被改写为 HTTP 200 `incomplete/content_filter`;见 [xAI policy refusals](/reference/proxy-formats/#xai-policy-refusals)。combo 跳转仍把原始 HTTP 403 分类为跳转。 ::: 对于流式请求,上游 HTTP 状态并不是最终决定。OpenCodex 只会缓冲所选子目标在开始输出前的一段有上限的 Responses SSE。若在任何文本、推理、工具调用或其他输出事件开始之前收到可重试的 `response.failed` 终止事件,该次尝试会被记为失败,combo 可以继续尝试下一个合格目标。一旦输出开始或预输出缓冲区达到上限,当前目标就会被提交;之后的流错误不会在其他提供商上重放,从而避免重复文本和重复执行工具。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 5230ad5dfc1..d1e63e3dcab 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -125,7 +125,8 @@ selector,而不是分配一个新名称。 | `noReasoningModels?` | `string[]` | 会拒绝推理/思考参数的模型。 | | `noTemperatureModels?` | `string[]` | 会拒绝调用方指定 `temperature` 的模型。 | | `noTopPModels?` | `string[]` | 会拒绝调用方指定 `top_p` 的模型。 | -| `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 | +| `noStopModels?` | `string[]` | 会拒绝调用方指定 `stop` 的模型。`openai-chat` 适配器、Chat 直通和 Responses 直通会为这些模型省略该字段。内置 `xai` 预设在此列出 xAI 文档说明会拒绝该参数的推理模型(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`);`grok-4.20-0309-non-reasoning`, `grok-composer-2.5-fast` 保留调用方的 `stop`。 | +| `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 内置 `xai` 预设在此列出 xAI 文档说明会拒绝这些参数的推理模型(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`);非推理模型保留调用方的 penalty。 | | `noStructuredOutputModels?` | `string[]` | `openai-chat` 端点拒绝 `response_format` 的精确模型 ID。仅当请求模型与条目完全匹配时才省略该字段;其他 `openai-chat` 模型仍启用 structured-output 转换。 | | `noJsonSchemaModels?` | `string[]` | `openai-chat` 端点拒绝 `json_schema` 形式但仍接受 `json_object` 的精确模型 ID。这类请求会降级为 `json_object` 而不是被丢弃,因此请求 JSON 的调用方仍能拿到 JSON。同一模型同时出现在两个列表时,以 `noStructuredOutputModels` 为准。`opencode go`、`opencode zen`、`opencode free` 预设已为其 DeepSeek 路由内置该项。 | | `foldDeveloperRoleToSystem?` | `boolean` | 记录某个 `openai-chat` 目的地是否接受 `developer` 角色。`foldDeveloperRoleToSystem` 未设置时按 `system` 发送,`true` 时按 `system` 发送,`false` 时按 `developer` 发送。未设置表示尚未记录该目的地的情况;`true` 记录上游拒绝该角色;`false` 记录其接受该角色。无论哪种情况,消息都保留在对话中的原有位置,只有角色改变。拒绝该角色的目的地会返回 `400 role 'developer' is not allowed`,这一轮根本无法开始,这就是未记录状态默认折叠的原因。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index db2363d83bb..c2d1871cfae 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -23,6 +23,12 @@ Responses 表示是这座桥的中心。原生兼容的路由可以跳过部分 携带凭据的模型、图像、视频和搜索请求不会自动跟随 HTTP 重定向,包括同源重定向。请配置最终上游 API URL,而不是会重定向的别名。服务器不会向重定向目标重新发送凭据或请求正文。各响应处理路径保留原有的错误处理或转发行为;原生 Responses 和 compact 路径仍可向客户端返回原始 3xx 和 `Location`。客户端的重定向行为与此服务器传输策略是不同的边界。 +## xAI policy refusals + +部分 xAI Chat Completions 拒绝会以 HTTP 403 加上 `I can't help with that request.` 这类拒绝句返回,而不是 HTTP 200 加 `finish_reason: content_filter`。Codex 把 403 当作传输失败,因此用户回合不会被记录,同一请求会被重试。 + +在非 combo 的 Responses 请求上,OpenCodex 会把该 allowlist 中的 403 改写为 HTTP 200 Responses,`status: "incomplete"`,`incomplete_details.reason: "content_filter"`。openai-chat 适配器路径和 openai-responses passthrough(grok-4.6 / grok-4.5 OAuth)都会改写。流式响应使用同一 incomplete 边界。空正文 403 仍是错误。订阅、额度、权限以及 `not allowed to use this model` 的 403 仍是错误。combo 故障切换仍会看到原始 HTTP 403。 + ## 端点总览 | 客户端表面 | 端点 | 成功的非流式结果 | 成功的流式或套接字结果 | diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index d9b7240c2ef..ffbe50ff790 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -428,6 +428,8 @@ Claude Code 的 `/effort` 設定會完整保留並傳遞給適配器: | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +Claude Code 自動模式總是傳送 `stop_sequences`。對於路由目標提供者 `noStopModels` 清單中的模型,OpenCodex 在 Chat Completions 與 Responses 兩種線路上都會省略 `stop`,因此 grok-4.7、grok-4.6 等 xAI 推理模型不會回傳 `400 invalid-argument` 並被標記為暫時無法使用。參見 [`noStopModels`](/zh-tw/reference/configuration/providers/)。 + 在預期的 Anthropic 適配器上,保留未隱藏的簽名區塊(包括空 thinking)和不透明的 redacted 區塊。`hideThinkingSummary` 政策不變:不會向 Claude 用戶端公開本地隱藏的簽名文字,尚未證明經過此隱藏邊界的無損重播。舊版組合信封在串流文字發出後無法恢復原始區塊順序。`claudeCode.compatibility: "enforce"` 仍拒絕 thinking 重播。這不證明真實 Anthropic 接受請求或快取命中改善;[#3719](https://github.com/lidge-jun/opencodex/issues/3719) 仍未關閉。 **錯誤情況(400):**JSON 格式錯誤;缺少/空的 `model`;缺少/空的 `messages`;不支援的 diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md index 2b1b4a7d656..d41d01736be 100644 --- a/docs-site/src/content/docs/zh-tw/guides/combos.md +++ b/docs-site/src/content/docs/zh-tw/guides/combos.md @@ -175,6 +175,7 @@ Combo 失敗分為**跳轉**失敗與**終端**失敗。 :::note Failover 是刻意受限的。它有助於目標特定的可用性、認證、配額與過載失敗;不會隱藏呼叫者錯誤或策略拒絕。 +在非 combo 的 Responses 請求上,允許清單中的 xAI 政策 403 會在 Codex 將其當作傳輸失敗重試之前,改寫為 HTTP 200 `incomplete/content_filter`;參見 [xAI policy refusals](/zh-tw/reference/proxy-formats/#xai-policy-refusals)。Combo 跳轉仍把原始 HTTP 403 當作一次跳轉。 ::: ## 預設推理 effort diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 7b992e4a55e..773aa219a2e 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -99,7 +99,8 @@ ocx models provider openrouter on | `noReasoningModels?` | `string[]` | 拒絕 reasoning/thinking 參數的模型。 | | `noTemperatureModels?` | `string[]` | 拒絕呼叫者指定 `temperature` 的模型。 | | `noTopPModels?` | `string[]` | 拒絕呼叫者指定 `top_p` 的模型。 | -| `noPenaltyModels?` | `string[]` | 拒絕 presence/frequency penalty 的模型。 | +| `noStopModels?` | `string[]` | 拒絕呼叫者指定 `stop` 的模型。`openai-chat` 轉接器、Chat 直通與 Responses 直通會為這些模型省略該欄位。內建 `xai` 預設在此列出 xAI 文件說明會拒絕該參數的推理模型(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`);`grok-4.20-0309-non-reasoning`, `grok-composer-2.5-fast` 保留呼叫者的 `stop`。 | +| `noPenaltyModels?` | `string[]` | 拒絕 presence/frequency penalty 的模型。 內建 `xai` 預設在此列出 xAI 文件說明會拒絕這些參數的推理模型(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`);非推理模型保留呼叫者的 penalty。 | | `noStructuredOutputModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `response_format` 的精確模型 ID。僅精確符合的請求模型會省略該欄位;structured-output 轉譯對其他每個 `openai-chat` 模型保持啟用。 | | `noJsonSchemaModels?` | `string[]` | 其 `openai-chat` 端點拒絕 `json_schema` 形式但仍接受 `json_object` 的精確模型 ID。這類請求會降級為 `json_object` 而非被丟棄,因此要求 JSON 的呼叫端仍會拿到 JSON。同一模型同時列在兩份清單時,以 `noStructuredOutputModels` 為準。`opencode go`、`opencode zen`、`opencode free` 預設已為其 DeepSeek 路由內建。 | | `foldDeveloperRoleToSystem?` | `boolean` | 記錄某個 `openai-chat` 目的地是否接受 `developer` 角色。`foldDeveloperRoleToSystem` 未設定時以 `system` 傳送,`true` 時以 `system` 傳送,`false` 時以 `developer` 傳送。未設定表示尚未記錄該目的地的情況;`true` 記錄上游拒絕該角色;`false` 記錄其接受該角色。無論何者,訊息都保留在對話中的原有位置,只有角色改變。拒絕該角色的目的地會回應 `400 role 'developer' is not allowed`,該回合根本無法開始,這就是未記錄狀態預設摺疊的原因。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index 6bf51f7f19f..8e90432b06f 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -18,6 +18,12 @@ Responses 表示是橋接的中心。原生相容的路由可跳過部分轉譯 攜帶憑證的模型、圖片、影片和搜尋請求不會自動跟隨 HTTP 重新導向,包括同源重新導向。請設定最終上游 API URL,而非會重新導向的別名。伺服器不會向重新導向目標再次傳送憑證或請求內文。各回應處理路徑保留原有的錯誤處理或轉送行為;原生 Responses 和 compact 路徑仍可向用戶端回傳原始 3xx 與 `Location`。用戶端的重新導向行為與此伺服器傳輸政策屬於不同邊界。 +## xAI policy refusals + +部分 xAI Chat Completions 拒絕不是以 HTTP 200 加 `finish_reason: content_filter` 回傳,而是以 HTTP 403 加上一句完全相符的拒絕語句(例如 `I can't help with that request.`)回傳。Codex 把 403 視為傳輸失敗,因此使用者回合不會被記錄,同一個請求會被重送。 + +在非 combo 的 Responses 請求上,OpenCodex 會把這種在允許清單中的 403 改寫為 HTTP 200 的 Responses 內容,帶有 `status: "incomplete"` 與 `incomplete_details.reason: "content_filter"`。改寫同時作用於 openai-chat 轉接器路徑與 openai-responses 直通(grok-4.6 / grok-4.5 OAuth)。串流使用相同的 incomplete 邊界。空白的 403 本文仍是錯誤。訂閱、點數、權限與 `not allowed to use this model` 的 403 仍是錯誤。Combo failover 仍看到原始的 HTTP 403。 + ## 端點概覽 | 客戶端介面 | 端點 | 成功的非串流結果 | 成功的串流或 socket 結果 | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d8534b72812..f920e6149af 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1203,6 +1203,7 @@ "parser-content-audio.test.ts": "responses", "passive-route-linker.test.ts": "server", "passthrough-abort.test.ts": "responses", + "passthrough-grok-upstream-envelope-echo.test.ts": "responses", "passthrough-headers.test.ts": "responses", "passthrough-override.test.ts": "responses", "phase100-native-parity.test.ts": "e2e-style", @@ -1725,6 +1726,7 @@ "ws-upstream.test.ts": "responses", "xai-client.test.ts": "images", "xai-empty-catalog-tool-choice.test.ts": "providers/xai", + "xai-no-stop.test.ts": "providers/xai", "xai-oauth-retry.test.ts": "providers/xai", "xai-refresh-lock.test.ts": "providers/xai", "xai-responses-adjacency.test.ts": "providers/xai", diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 7a3bbf3a6c4..92801783c08 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -4,7 +4,7 @@ import type { ProviderAdapter } from "./base"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy"; import { isCursorBenignCancelError, isCursorIncompleteToolCallMessage, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; -import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery"; +import { cursorCheckpointModelAffinityId, cursorNeedsExternalToolContinuation, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery"; import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; import { @@ -31,6 +31,7 @@ import { debugProviderDiagnostic } from "../lib/debug"; import { isDebugEnabled } from "../lib/debug-settings"; import { createAdapterTierMetadata } from "../providers/fastwire"; import { estimateTokens } from "../lib/token-estimate"; +import { ToolEnvelopeEchoFilter } from "../lib/tool-envelope-echo-filter"; import { clearCursorIncompleteToolRemint, cursorIncompleteToolRemintScopeKey, @@ -41,6 +42,7 @@ import { recordCursorIncompleteToolRemint, recordCursorEnvelopeEchoRemint, recordCursorOverflowRemint, + rememberCursorConversationRewrite, rememberCursorThreadConversation, shouldSkipCursorOverflowRemint, shouldSurfaceCursorOverflowFirst, @@ -299,13 +301,18 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda // Armed for ANY external turn whose replayed history contains a tool result — echo // priming was observed live on user-action rounds too (the envelope lives in the // flattened history regardless of which role ends the input). - const armEchoSniffer = - isCursorExternalWireModel(activeRequest.modelId) - && (_parsed.context.messages ?? []).some(message => message.role === "toolResult"); + const replaysToolResult = (_parsed.context.messages ?? []).some(message => message.role === "toolResult"); + const armEchoSniffer = isCursorExternalWireModel(activeRequest.modelId) && replaysToolResult; const echoSniffer = armEchoSniffer ? new CursorEnvelopeEchoSniffer() : undefined; // Mid-stream observer (devlog 260828 F1/F2): diagnostic-only; armed with the // prefix sniffer because both fire on flattened tool-result replay priming. - const midstreamObserver = armEchoSniffer ? new CursorMidstreamEchoObserver() : undefined; + // The composer-2.5 builds are native-wire but also replay the tool result as root text + // (cursorNeedsExternalToolContinuation), so the client-visible strip and the next-turn + // remint follow that predicate. The prefix retry above stays external-only: its + // corrective continuation text is encoded for external wire models alone. + const armMidstreamEcho = cursorNeedsExternalToolContinuation(activeRequest.modelId) && replaysToolResult; + const midstreamObserver = armMidstreamEcho ? new CursorMidstreamEchoObserver() : undefined; + const midstreamFilter = armMidstreamEcho ? new ToolEnvelopeEchoFilter() : undefined; const armRoutingCommentarySniffer = isCursorExternalWireModel(activeRequest.modelId) && ( @@ -321,8 +328,11 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda // Exactly-once observation: every client-bound text delta passes through here // exactly once — held deltas only on release, ordinary deltas at emit time. const emitTextObserved = (event: AdapterEvent): void => { - if (event.type === "text_delta") midstreamObserver?.feed(event.text); - emit(event); + if (event.type !== "text_delta") { emit(event); return; } + midstreamObserver?.feed(event.text); + const text = midstreamFilter?.feed(event.text) ?? event.text; + if (midstreamFilter?.matched) sawMidstreamEnvelopeEcho = true; + if (text) emit({ ...event, text }); }; const releaseGuardHeld = () => { for (const held of guardHeld) { @@ -453,8 +463,13 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } if (event.type !== "heartbeat") emittedOutput = true; if (event.type === "done") { + const suffix = midstreamFilter?.finish(); + // A fenced marker released by hold overflow is unproven code: remint to be safe. + if (midstreamFilter?.matched || midstreamFilter?.unverifiedMarker) sawMidstreamEnvelopeEcho = true; + if (suffix) emit({ type: "text_delta", text: suffix }); + // The observer is diagnostic only: the fence-aware filter's verdict decides the + // remint, so a quoted marker in a closed code block does not rotate the thread. const midstreamFindings = midstreamObserver?.findings() ?? []; - if (midstreamFindings.length > 0) sawMidstreamEnvelopeEcho = true; for (const finding of midstreamFindings) { debugProviderDiagnostic("cursor", "midstream-envelope-echo", { wireModel: activeRequest.modelId, @@ -507,6 +522,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda // the next turn does not recompute the stale deterministic thread hash. Isolated helper / // compaction turns must not park their throwaway id under the parent or Desktop owner. const threadOwner = cursorClientThreadOwner(_parsed); + if (_parsed._cursorIsolateConversation !== true) { + rememberCursorConversationRewrite(failedConversationId, next.conversationId, _parsed._cursorIdentityScope); + } if (threadOwner && _parsed._cursorIsolateConversation !== true) { rememberCursorThreadConversation( threadOwner, @@ -629,11 +647,8 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } else if (!sawIncompleteToolCall && completedNormally && incompleteToolRemintScopeKey) { clearCursorIncompleteToolRemint(incompleteToolRemintScopeKey); } - // A mid-stream envelope echo has ALREADY reached the client — the prefix sniffer only - // watches the first bytes of a turn, and grok-4.6 writes a real sentence before pasting - // the envelope. It cannot be quarantined, so the recovery is the same as the - // incomplete-tool case: leave this turn alone and rotate the next turn's id, otherwise - // the stored echo is replayed and primes the model to echo again. + // Strip a split mid-stream marker before it reaches the client, then rotate the next + // conversation because the upstream checkpoint may still contain the echoed envelope. // // Its own budget, not the incomplete-tool one: echoing is cheap and repeatable while an // incomplete client-tool stream is rare and structural, so a shared counter would let a @@ -645,6 +660,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda ? cursorEnvelopeEchoRemintScopeKey( cursorClientThreadOwner(_parsed), _parsed._cursorIdentityScope, + request.conversationId, ) : null; if (sawMidstreamEnvelopeEcho && !sawIncompleteToolCall && envelopeEchoRemintScopeKey) { diff --git a/src/adapters/cursor/envelope-echo.ts b/src/adapters/cursor/envelope-echo.ts index 07c992262d8..184dfc1dea5 100644 --- a/src/adapters/cursor/envelope-echo.ts +++ b/src/adapters/cursor/envelope-echo.ts @@ -12,10 +12,17 @@ * cursor.ts to retry before any invalid text reaches the client. */ +import { closedFenceLines } from "../../lib/tool-envelope-echo-filter"; + const ECHO_MARKERS = ["[Tool Result]", "[Tool Error]", "[tool_result]"] as const; +const REPLAY_ECHO_PREFIXES = ["[Tool call:", "[Tool Call]", "[Tool Result", "[Tool Error", "[tool_result"] as const; function isEchoMarkerLine(line: string): boolean { - return (ECHO_MARKERS as readonly string[]).includes(line.replace(/^[ \t]+/, "")); + const probe = line.replace(/^[ \t]+/, ""); + return probe.startsWith("[Tool call:") + || probe.startsWith("[Tool Call]") + || ["[Tool Result", "[Tool Error", "[tool_result"].some(prefix => + probe === prefix || probe.startsWith(`${prefix}]`)); } /** @@ -42,15 +49,18 @@ function isEchoMarkerLine(line: string): boolean { * Only whole-line markers count, so prose such as "the string [Tool Result] appeared" survives. */ export function stripAssistantEchoedToolEnvelope(text: string): string { - if (!text || !ECHO_MARKERS.some(marker => text.includes(marker))) return text; + if (!text || !REPLAY_ECHO_PREFIXES.some(marker => text.includes(marker))) return text; const newline = text.includes("\r\n") ? "\r\n" : "\n"; const lines = text.split(/\r?\n/); const kept: string[] = []; let dropped = false; let index = 0; + // A marker inside a fenced code block that closes is an example the model showed, not an echo; + // the live filter releases it as code, so replay keeps it too. An unclosed block shields nothing. + const shielded = closedFenceLines(lines); while (index < lines.length) { const line = lines[index] ?? ""; - if (!isEchoMarkerLine(line)) { + if (shielded[index] || !isEchoMarkerLine(line)) { kept.push(line); index += 1; continue; diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 6850b0cf3b2..14d860d9a11 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -26,7 +26,7 @@ import { isCursorExecutionPathTool, isCursorWaitTool, } from "./tool-definitions"; -import { lookupCursorThreadConversation } from "./thread-continuity"; +import { lookupCursorThreadConversation, resolveCursorConversationRewrite } from "./thread-continuity"; import { getCursorCheckpoint, getCursorCheckpointForPrefix, @@ -367,11 +367,16 @@ export function resolveCursorConversationId( // the override check has to exclude it explicitly rather than rely on that flag. if (threadId && parsed._compactionRequest !== true) { const recovered = lookupCursorThreadConversation(threadId, parsed._cursorIdentityScope); - if (recovered) return recovered; + if (recovered) return resolveCursorConversationRewrite(recovered, parsed._cursorIdentityScope); + } + if (parsed._cursorConversationId) { + return resolveCursorConversationRewrite(parsed._cursorConversationId, parsed._cursorIdentityScope); } - if (parsed._cursorConversationId) return parsed._cursorConversationId; if (threadId) { - return cursorConversationIdFromClientThread(`thread:${threadId}`, parsed._cursorIdentityScope); + return resolveCursorConversationRewrite( + cursorConversationIdFromClientThread(`thread:${threadId}`, parsed._cursorIdentityScope), + parsed._cursorIdentityScope, + ); } return generatedCursorConversationId(); } diff --git a/src/adapters/cursor/thread-continuity.ts b/src/adapters/cursor/thread-continuity.ts index 72778ce4c3c..7c550e43591 100644 --- a/src/adapters/cursor/thread-continuity.ts +++ b/src/adapters/cursor/thread-continuity.ts @@ -6,10 +6,56 @@ * recovered id instead of recomputing the stale deterministic thread hash. */ +import { createHash } from "node:crypto"; + const OVERRIDE_TTL_MS = 60 * 60 * 1000; const OVERRIDE_MAX_ENTRIES = 2048; const overrides = new Map(); +const conversationRewrites = new Map(); + +function rewriteKey(conversationId: string, identityScope?: string): string { + const identity = createHash("sha256").update(identityScope?.trim() || "local").digest("hex"); + return `${identity}\0${conversationId}`; +} + +function pruneRewrites(at: number): void { + for (const [key, entry] of conversationRewrites) { + if (at - entry.updatedAt > OVERRIDE_TTL_MS) conversationRewrites.delete(key); + } + while (conversationRewrites.size > OVERRIDE_MAX_ENTRIES) { + const oldest = conversationRewrites.keys().next().value; + if (oldest === undefined) break; + conversationRewrites.delete(oldest); + } +} + +export function rememberCursorConversationRewrite(from: string, to: string, identityScope?: string): void { + if (!from || !to || from === to) return; + const at = now(); + pruneRewrites(at); + const scope = rewriteKey("", identityScope); + const root = conversationRewrites.get(rewriteKey(from, identityScope))?.root ?? from; + const redirects = [...conversationRewrites].filter(([key, entry]) => key.startsWith(scope) && entry.root === root); + for (const [key] of redirects) { + conversationRewrites.delete(key); + conversationRewrites.set(key, { to, root, updatedAt: at }); + } + conversationRewrites.set(rewriteKey(from, identityScope), { to, root, updatedAt: at }); + conversationRewrites.set(rewriteKey(to, identityScope), { to, root, updatedAt: at }); + pruneRewrites(at); +} + +export function resolveCursorConversationRewrite(conversationId: string, identityScope?: string): string { + const at = now(); + pruneRewrites(at); + const key = rewriteKey(conversationId, identityScope); + const entry = conversationRewrites.get(key); + if (!entry) return conversationId; + conversationRewrites.delete(key); + conversationRewrites.set(key, { ...entry, updatedAt: at }); + return entry.to; +} function now(): number { return Date.now(); @@ -64,6 +110,7 @@ export function lookupCursorThreadConversation( export function clearCursorThreadContinuityForTests(): void { overrides.clear(); + conversationRewrites.clear(); } /** Max conversation-id remints after the first surfaced overflow per retained scope. */ @@ -278,7 +325,13 @@ const envelopeEchoRemintBudget = createCursorRemintBudget( export function cursorEnvelopeEchoRemintScopeKey( threadOwner: string | undefined, identityScope?: string, + conversationId?: string, ): string | null { + if (conversationId) { + pruneRewrites(now()); + const root = conversationRewrites.get(rewriteKey(conversationId, identityScope))?.root ?? conversationId; + return `echo\0${rewriteKey(root, identityScope)}`; + } return cursorOverflowRemintScopeKey(threadOwner, identityScope); } diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 5fb1775a940..f0af357487a 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -144,7 +144,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (parsed.options.topP !== undefined && !modelInList(provider.noTopPModels, parsed.modelId)) { body.top_p = parsed.options.topP; } - if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences; + if (parsed.options.stopSequences !== undefined && !modelInList(provider.noStopModels, parsed.modelId)) { + body.stop = parsed.options.stopSequences; + } const reasoningDisabled = modelInList(provider.noReasoningModels, parsed.modelId); const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); const explicitReasoning = applyExplicitChatReasoningWirePolicy({ diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts index 16abcf08d83..cedc86f8c46 100644 --- a/src/adapters/openai-chat/passthrough.ts +++ b/src/adapters/openai-chat/passthrough.ts @@ -96,6 +96,7 @@ export function buildOpenAIChatPassthroughRequest( if (modelInList(provider.noTemperatureModels, modelId)) delete body.temperature; if (modelInList(provider.noTopPModels, modelId)) delete body.top_p; + if (modelInList(provider.noStopModels, modelId)) delete body.stop; if (modelInList(provider.noPenaltyModels, modelId)) { delete body.presence_penalty; delete body.frequency_penalty; diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 19956d6e9ec..0b5ce24702d 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -33,7 +33,7 @@ import { createAdapterTierMetadata, } from "../../providers/fastwire"; import { dropResponsesReasoningInputItems, mapRoutedResponsesReasoningEffort, normalizeConfiguredReasoningSummaryDelivery, sanitizeReasoningInputContent, stripDisabledReasoningSummaries, stripDisabledVerbosity, stripUnsupportedReasoningSummaryDelivery } from "./reasoning"; -import { scrubOcxCompactionItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored } from "./request-strips"; +import { scrubOcxCompactionItems, stripCanonicalOnlyToolFields, stripCanonicalOnlyTopLevelFields, stripInternalChatMessageMetadataPassthrough, stripInvalidItemIds, stripItemIdsWhenUnstored, stripRejectedSamplingParams } from "./request-strips"; import { stripCanonicalForwardPromptCacheOptions, stripDeprecatedPromptCacheRetention } from "./prompt-cache"; import { isPlainObject } from "./internal"; import { normalizeToolSchemas, promoteClientLoadedTools, stripUnsupportedHostedTools } from "./tool-schema"; @@ -323,6 +323,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = normalizeCanonicalForwardContinuationEnvelope(outBody); } } else { + outBody = stripRejectedSamplingParams(outBody, provider, parsed.modelId); outBody = preferConfiguredHostedTools( outBody, provider, diff --git a/src/adapters/openai-responses/request-strips.ts b/src/adapters/openai-responses/request-strips.ts index c632895f96f..f74fa0f54b5 100644 --- a/src/adapters/openai-responses/request-strips.ts +++ b/src/adapters/openai-responses/request-strips.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "../../responses/compaction"; import { debugProviderDiagnostic } from "../../lib/debug"; +import { modelInList } from "../../types"; import { isPlainObject } from "./internal"; import { activateDeferredTool } from "./tool-schema"; import { stripOpenAiOnlyWebSearchFields } from "./web-search"; @@ -165,6 +166,30 @@ export function stripCanonicalOnlyTopLevelFields(body: unknown): unknown { return next; } +/** + * Sampling fields a model on the provider's `noStopModels` / `noPenaltyModels` list rejects on + * every wire. Claude inbound translates `stop_sequences` into a Responses `stop` + * (`src/claude/inbound.ts`), and a direct Responses caller can send penalties. Some listed models + * only have the Responses wire (xAI grok-4.20-multi-agent-0309 answers Chat Completions with 400), + * so the Chat adapter's omission cannot cover them. Returns the input unchanged when nothing is + * removed, so the caller-owned raw body is never mutated. + */ +export function stripRejectedSamplingParams( + body: unknown, + provider: { noStopModels?: string[]; noPenaltyModels?: string[] }, + modelId: string, +): unknown { + if (!isPlainObject(body)) return body; + const dropStop = Object.hasOwn(body, "stop") && modelInList(provider.noStopModels, modelId); + const penalties = ["presence_penalty", "frequency_penalty"].filter(field => Object.hasOwn(body, field)); + const dropPenalties = penalties.length > 0 && modelInList(provider.noPenaltyModels, modelId); + if (!dropStop && !dropPenalties) return body; + const next = { ...body }; + if (dropStop) delete next.stop; + if (dropPenalties) for (const field of penalties) delete next[field]; + return next; +} + /** * When `store` is false, the upstream API does not persist response items. Any item ID * forwarded in `input` is then interpreted as a reference to a stored item that does not diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 58693c5978f..23720bcee33 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -156,6 +156,93 @@ function isSubscriptionGateMessage(text: string): boolean { ); } +/** + * xAI (and similar Chat Completions gateways) sometimes refuse a turn with HTTP 403 + * and a model-refusal sentence instead of 200 + finish_reason=content_filter. + * Codex treats that 403 as a transport failure, so the user message is never + * recorded as a completed turn and retries loop. Keep this allowlist narrow: + * entitlement / plan / model-access 403s must stay errors. + */ +const POLICY_REFUSAL_PHRASES = [ + "i can't help with that request", + "i cannot help with that request", + "i'm unable to help with that request", + "i am unable to help with that request", +] as const; + +function hasModelAccessCue(text: string): boolean { + return ( + text.includes("not allowed to use this model") + || text.includes("not allowed to use this operation") + ); +} + +/** + * xAI plan and credit 403 wording. Checked only by the refusal matcher below: adding these to + * the global subscription classifier would also change status and error-code inference for + * every message-only error that happens to mention credits. + */ +function hasEntitlementCue(text: string): boolean { + return ( + isSubscriptionGateMessage(text) + || text.includes("need a grok subscription") + || text.includes("run out of credits") + ); +} + +/** Lowercase, collapse whitespace, and strip trailing .!? so an exact phrase match is stable. */ +function normalizePolicyRefusalSentence(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/\s+/g, " ") + .replace(/[.!?]+$/g, "") + .trim(); +} + +/** + * True only when the extracted error sentence is exactly a known model-refusal + * phrase. JSON / "Provider error 403:" wrappers are unwrapped first. Extra + * plan, credit, entitlement, or model-access wording keeps the error path. + */ +export function isUpstreamPolicyRefusalMessage(text: string): boolean { + const extracted = extractPolicyRefusalText(text); + const originalLower = text.toLowerCase(); + const extractedLower = extracted.toLowerCase(); + if (hasEntitlementCue(originalLower) || hasEntitlementCue(extractedLower)) return false; + if (hasModelAccessCue(originalLower) || hasModelAccessCue(extractedLower)) return false; + const normalized = normalizePolicyRefusalSentence(extracted); + return (POLICY_REFUSAL_PHRASES as readonly string[]).includes(normalized); +} + +/** HTTP 403 plus {@link isUpstreamPolicyRefusalMessage}; other statuses never rewrite. */ +export function isUpstreamPolicyRefusal(status: number, text: string): boolean { + return status === 403 && isUpstreamPolicyRefusalMessage(text); +} + +/** Pull the human-readable refusal sentence out of a JSON or prefixed error body. */ +export function extractPolicyRefusalText(raw: string): string { + const trimmed = raw.trim(); + try { + const parsed = JSON.parse(trimmed) as { error?: unknown; message?: unknown }; + const nested = parsed.error; + if (typeof nested === "string" && nested.trim()) return nested.trim(); + if (nested && typeof nested === "object") { + const msg = (nested as { message?: unknown; error?: unknown }).message + ?? (nested as { error?: unknown }).error; + if (typeof msg === "string" && msg.trim()) return msg.trim(); + } + if (typeof parsed.message === "string" && parsed.message.trim()) return parsed.message.trim(); + } catch { + /* not JSON */ + } + // The proxy's own error text wraps the upstream JSON (`Provider error 403: {"error": ...}`), + // so the remainder after the prefix gets the same unwrapping. + const prefixed = trimmed.match(/^Provider error 403:\s*([\s\S]+)$/i); + if (prefixed?.[1]?.trim()) return extractPolicyRefusalText(prefixed[1]); + return trimmed; +} + function isLocalAclHardeningMessage(text: string): boolean { const secretPathHardening = text.includes("secret path") && ( text.includes("acl") || diff --git a/src/lib/tool-envelope-echo-filter.ts b/src/lib/tool-envelope-echo-filter.ts new file mode 100644 index 00000000000..39938062d8a --- /dev/null +++ b/src/lib/tool-envelope-echo-filter.ts @@ -0,0 +1,216 @@ +/** Line-aware filter for echoed tool envelopes in incremental assistant text. */ +const MARKERS = ["[Tool Result]", "[Tool Error]", "[tool_result]", "[Tool Call]", "[Tool call:"] as const; +const UNTERMINATED_MARKERS = ["[Tool Result", "[Tool Error", "[tool_result", "[Tool Call"] as const; +const TRUNCATED_MARKERS = [...UNTERMINATED_MARKERS, "[Tool call:"] as const; +const MAX_INDENT = 128; +// Markdown fenced code (CommonMark): an opener is a run of at least three backticks or tildes +// indented at most three spaces; only a run of the same character, at least as long and followed +// by nothing but whitespace, closes it. A backtick opener's info string may not contain a backtick. +const MAX_FENCE_INDENT = 3; +const MAX_FENCE_LINE = 1024; +const FENCE_LINE = /^(\x60{3,}|~{3,})(.*)$/s; +const FENCE_PREFIX = /^(\x60{1,2}|~{1,2})$/; +const FENCE_RUN = /^(\x60{3,}|~{3,})/; + +interface FenceLine { + char: string; + length: number; + rest: string; +} + +/** A complete line (no newline) parsed as a CommonMark fence line, or null. */ +function parseFenceLine(line: string): FenceLine | null { + const probe = line.replace(/^[ \t]*/, ""); + if (line.length - probe.length > MAX_FENCE_INDENT) return null; + const match = FENCE_LINE.exec(probe.replace(/\r?\n?$/, "")); + if (!match) return null; + return { char: match[1]![0]!, length: match[1]!.length, rest: match[2] ?? "" }; +} + +function opensFence(line: FenceLine): boolean { + return !(line.char === "\x60" && line.rest.includes("\x60")); +} + +function closesFence(line: FenceLine, open: { char: string; length: number }): boolean { + return line.char === open.char && line.length >= open.length && line.rest.trim() === ""; +} + +/** + * For complete text (assistant history): true for each line that is a fence line of, or sits + * inside, a fenced block that is closed later in the same text. A block that never closes shields + * nothing, matching the live filter, which drops a held tail when the turn ends inside a fence. + */ +export function closedFenceLines(lines: readonly string[]): boolean[] { + const shielded = lines.map(() => false); + let open: { char: string; length: number; start: number } | null = null; + lines.forEach((text, index) => { + const fence = parseFenceLine(text); + if (!open) { + if (fence && opensFence(fence)) open = { char: fence.char, length: fence.length, start: index }; + return; + } + if (fence && closesFence(fence, open)) { + for (let line = open.start; line <= index; line++) shielded[line] = true; + open = null; + } + }); + return shielded; +} + +// A marker line inside a fence may be a quoted example or an echo pasted into a block that never +// closes. Output from that line is held: a matching closer releases it as code, the end of the turn +// drops it as an echo. The hold is bounded, so a long block cannot stall the stream. +const MAX_HELD_CHARS = 64 * 1024; + +interface Fence { + char: string; + length: number; + holdDisabled: boolean; + /** Its hold overflowed, so its marker is unverified until the block closes. */ + overflowed?: boolean; +} + +export class ToolEnvelopeEchoFilter { + private pending = ""; + private safeLine = false; + private fence: Fence | null = null; + private held: string | null = null; + matched = false; + /** A fenced marker whose hold overflowed was released without proof that the block closes. */ + unverifiedMarker = false; + + feed(delta: string): string { + if (this.matched) return ""; + let output = ""; + for (const char of delta) { + if (this.matched) break; + if (this.safeLine) { + output += this.emit(char); + if (char === "\n") this.safeLine = false; + continue; + } + this.pending += char; + if (char === "\n") { + output += this.completeLine(); + continue; + } + const probe = this.pending.replace(/^[ \t]*/, ""); + const indent = this.pending.length - probe.length; + if (indent <= MAX_INDENT && MARKERS.some(marker => probe === marker)) { + if (!this.fence) { + this.pending = ""; + this.matched = true; + break; + } + this.startHold(); + this.flushLineStart(); + output += this.emit(this.takePending()); + continue; + } + const fenceCandidate = indent <= MAX_FENCE_INDENT + && this.pending.length <= MAX_FENCE_LINE + && (FENCE_PREFIX.test(probe) || FENCE_RUN.test(probe)); + const markerCandidate = indent <= MAX_INDENT + && (probe === "" || MARKERS.some(marker => marker.startsWith(probe))); + const unterminatedCr = char === "\r" + && (UNTERMINATED_MARKERS as readonly string[]).includes(probe.slice(0, -1).trimEnd()); + if (fenceCandidate || markerCandidate || unterminatedCr) continue; + this.flushLineStart(); + output += this.emit(this.takePending()); + } + return output; + } + + /** At normal end, a distinctive truncated marker or a held fence tail is an echo; other text is prose. */ + finish(): string { + if (this.matched) return ""; + // A closing fence may end the stream without a trailing newline; settle it before the hold. + const settled = this.pending !== "" && parseFenceLine(this.pending) ? this.completeLine() : ""; + const pending = this.takePending(); + if (this.held !== null) { + this.held = null; + this.matched = true; + return ""; + } + const probe = pending.replace(/^[ \t]*/, ""); + if ((TRUNCATED_MARKERS as readonly string[]).some(marker => probe.startsWith(marker))) { + this.matched = true; + return settled; + } + return settled + pending; + } + + private completeLine(): string { + const raw = this.takePending(); + const probe = raw.replace(/^[ \t]*/, ""); + const indent = raw.length - probe.length; + const line = probe.replace(/\r?\n$/, ""); + const fenceLine = parseFenceLine(raw); + if (fenceLine) { + if (!this.fence) { + if (opensFence(fenceLine)) { + this.fence = { char: fenceLine.char, length: fenceLine.length, holdDisabled: false }; + } + return this.emit(raw); + } + if (closesFence(fenceLine, this.fence)) { + const out = this.emit(raw); + // Only one block is open at a time, so closing the overflowed one settles the doubt. This + // runs after the closer is emitted, because the closer itself can be what overflows the hold. + if (this.fence.overflowed) this.unverifiedMarker = false; + const released = this.held ?? ""; + this.held = null; + this.fence = null; + return out + released; + } + return this.emit(raw); + } + const trimmed = line.trimEnd(); + const markerLine = indent <= MAX_INDENT && ( + (UNTERMINATED_MARKERS as readonly string[]).includes(trimmed) + || (MARKERS as readonly string[]).includes(trimmed) + ); + if (markerLine) { + if (!this.fence) { + this.matched = true; + return ""; + } + this.startHold(); + } + return this.emit(raw); + } + + private startHold(): void { + if (this.fence && !this.fence.holdDisabled && this.held === null) this.held = ""; + } + + private flushLineStart(): void { + this.safeLine = true; + } + + private takePending(): string { + const pending = this.pending; + this.pending = ""; + return pending; + } + + /** Route text to the client, or into the fence hold while one is open. */ + private emit(text: string): string { + if (this.held === null) return text; + this.held += text; + if (this.held.length <= MAX_HELD_CHARS) return ""; + const released = this.held; + this.held = null; + this.unverifiedMarker = true; + if (this.fence) { + this.fence.holdDisabled = true; + this.fence.overflowed = true; + } + return released; + } +} + +export function stripToolEnvelopeEcho(text: string): string { + const filter = new ToolEnvelopeEchoFilter(); + return filter.feed(text) + filter.finish(); +} diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 9579c1ffe76..8a2cd9d72ee 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1270,6 +1270,7 @@ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [ "modelReasoningEffortMap", "noTemperatureModels", "noTopPModels", + "noStopModels", "noPenaltyModels", "autoToolChoiceOnlyModels", "preserveReasoningContentModels", diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index a842affc9ca..729dca88358 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -189,6 +189,7 @@ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: s ...(def.noReasoningModels ? { noReasoningModels: [...def.noReasoningModels] } : {}), ...(def.noTemperatureModels ? { noTemperatureModels: [...def.noTemperatureModels] } : {}), ...(def.noTopPModels ? { noTopPModels: [...def.noTopPModels] } : {}), + ...(def.noStopModels ? { noStopModels: [...def.noStopModels] } : {}), ...(def.noPenaltyModels ? { noPenaltyModels: [...def.noPenaltyModels] } : {}), ...(def.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...def.autoToolChoiceOnlyModels] } : {}), ...(def.preserveReasoningContentModels ? { preserveReasoningContentModels: [...def.preserveReasoningContentModels] } : {}), diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 331fde912bf..b14078b4ec7 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -41,6 +41,7 @@ export interface DerivedKeyLoginProvider { noReasoningModels?: string[]; noTemperatureModels?: string[]; noTopPModels?: string[]; + noStopModels?: string[]; noPenaltyModels?: string[]; autoToolChoiceOnlyModels?: string[]; preserveReasoningContentModels?: string[]; @@ -269,6 +270,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(entry.noReasoningModels ? { noReasoningModels: [...entry.noReasoningModels] } : {}), ...(entry.noTemperatureModels ? { noTemperatureModels: [...entry.noTemperatureModels] } : {}), ...(entry.noTopPModels ? { noTopPModels: [...entry.noTopPModels] } : {}), + ...(entry.noStopModels ? { noStopModels: [...entry.noStopModels] } : {}), ...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}), ...(entry.parallelToolCalls !== undefined ? { parallelToolCalls: entry.parallelToolCalls } : {}), ...(entry.promptCacheKey !== undefined ? { promptCacheKey: entry.promptCacheKey } : {}), @@ -336,6 +338,7 @@ export function deriveKeyLoginMap(): Record { ...(entry.noReasoningModels ? { noReasoningModels: [...entry.noReasoningModels] } : {}), ...(entry.noTemperatureModels ? { noTemperatureModels: [...entry.noTemperatureModels] } : {}), ...(entry.noTopPModels ? { noTopPModels: [...entry.noTopPModels] } : {}), + ...(entry.noStopModels ? { noStopModels: [...entry.noStopModels] } : {}), ...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}), ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}), ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}), @@ -552,6 +555,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (!prov.noReasoningModels && seed.noReasoningModels) prov.noReasoningModels = [...seed.noReasoningModels]; if (!prov.noTemperatureModels && seed.noTemperatureModels) prov.noTemperatureModels = [...seed.noTemperatureModels]; if (!prov.noTopPModels && seed.noTopPModels) prov.noTopPModels = [...seed.noTopPModels]; + if (!prov.noStopModels && seed.noStopModels) prov.noStopModels = [...seed.noStopModels]; if (!prov.noPenaltyModels && seed.noPenaltyModels) prov.noPenaltyModels = [...seed.noPenaltyModels]; if (prov.parallelToolCalls === undefined && seed.parallelToolCalls !== undefined) prov.parallelToolCalls = seed.parallelToolCalls; if (prov.promptCacheKey === undefined && seed.promptCacheKey !== undefined) prov.promptCacheKey = seed.promptCacheKey; diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index b990a426da3..e3e0ca7ec01 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -101,6 +101,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { noReasoningModels: "list", noTemperatureModels: "list", noTopPModels: "list", + noStopModels: "list", noPenaltyModels: "list", noStructuredOutputModels: "list", noJsonSchemaModels: "list", diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index dbb2b4890cc..f4f4b5c3c5c 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -267,6 +267,22 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // 260813: grok-4.6 added per docs.x.ai/developers/grok-4-6. Context/vision still match // grok-4.5; the reasoning ladder does not — 4.6 adds the documented xhigh rung. models: XAI_MODELS, + // Live 2026-09-20: Chat Completions rejects `stop` on grok-4.6 + // (`400 invalid-argument "Model grok-4.6 does not support parameter stop."`). + // xAI documents `stop` as unsupported for reasoning models. Claude Code + // auto-mode always sends stop_sequences; forwarding that as `stop` makes + // the classifier treat Grok as temporarily unavailable while chat turns + // still work. Keep caller stop sequences on non-reasoning ids. + // Live 2026-09-23: grok-4.7 answers the same 400. + noStopModels: [ + "grok-4.7", + "grok-4.6", + "grok-4.5", + "grok-4.3", + "grok-4.20-multi-agent-0309", + "grok-4.20-0309-reasoning", + "grok-build-0.1", + ], // Measured only on grok-4.6 against cli-chat-proxy.grok.com: even an invalid // `text.verbosity` value is accepted and low/high/omitted output length is non-monotonic. // Apply the resulting opt-out to the whole xAI lineup because `text.verbosity` is an OpenAI @@ -278,6 +294,19 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // absent from xAI's documented API, so a model discovered later has no more support for it // than the seeded ones do. supportsVerbosity: false, + // docs.x.ai/docs/guides/reasoning: presencePenalty and frequencyPenalty "cannot be used with + // reasoning models. Requests that include them return an error." Live 2026-09-23: grok-4.7 + // answers 400 invalid-argument "Model grok-4.7 does not support parameter presencePenalty." + // Non-reasoning ids keep caller penalties. + noPenaltyModels: [ + "grok-4.7", + "grok-4.6", + "grok-4.5", + "grok-4.3", + "grok-4.20-multi-agent-0309", + "grok-4.20-0309-reasoning", + "grok-build-0.1", + ], defaultModel: "grok-4.5", // Grok 4.7/4.6/4.5 subscription Responses callers use the native wire with the existing // namespace/web-search/replay normalization. Chat remains an explicit modelAdapters diff --git a/src/providers/registry/model-ids.ts b/src/providers/registry/model-ids.ts index 4c02ca57cad..219aea75ae1 100644 --- a/src/providers/registry/model-ids.ts +++ b/src/providers/registry/model-ids.ts @@ -107,6 +107,7 @@ export const REGISTRY_FIELD_MODEL_ID_ROLES = { noReasoningModels: NONE, noTemperatureModels: NONE, noTopPModels: NONE, + noStopModels: NONE, noPenaltyModels: NONE, noJsonSchemaModels: NONE, parallelToolCalls: NONE, diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index e04931bdb63..9a4abd38919 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -308,6 +308,7 @@ export interface ProviderRegistryEntry { noReasoningModels?: string[]; noTemperatureModels?: string[]; noTopPModels?: string[]; + noStopModels?: string[]; noPenaltyModels?: string[]; /** * Registry-only seed for `OcxProviderConfig.noJsonSchemaModels`. Merged into the @@ -359,7 +360,7 @@ export type ProviderConfigSeed = Pick< | "modelDisplayNames" | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" - | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels" + | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noStopModels" | "noPenaltyModels" | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "inlineThinkTagModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" | "showThinkingSummary" | "googleMode" | "project" | "location" | "headers" >; diff --git a/src/providers/resolved-model-policy.ts b/src/providers/resolved-model-policy.ts index 1cf0ca6cc49..2689e8c0370 100644 --- a/src/providers/resolved-model-policy.ts +++ b/src/providers/resolved-model-policy.ts @@ -30,7 +30,7 @@ export type StaticProviderPolicyField = | "modelMaxOutputTokens" | "reasoningEfforts" | "modelReasoningEfforts" | "modelReasoningEffortsAuthoritative" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" - | "noTopPModels" | "noPenaltyModels" | "noJsonSchemaModels" | "parallelToolCalls" + | "noTopPModels" | "noStopModels" | "noPenaltyModels" | "noJsonSchemaModels" | "parallelToolCalls" | "promptCacheKey" | "chatServiceTier" | "openaiChatEofTolerance" | "statelessResponses" | "requiresAdjacentResponsesToolResults" | "requiresPairedResponsesToolResults" | "annotateEmptyToolOutputs" | "fastWire" | "supportsServiceTier" | "modelSupportsServiceTier" | "supportsOpenAiWebSearchToolFields" @@ -226,6 +226,7 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode put("modelReasoningEffortMap", modelEffortMap, modelEffortMapSource); for (const key of [ "noVisionModels", "noReasoningModels", "noTemperatureModels", "noTopPModels", + "noStopModels", "noPenaltyModels", "noJsonSchemaModels", "autoToolChoiceOnlyModels", "preserveReasoningContentModels", "requiresReasoningPlaceholderModels", "reasoningSplitModels", "reasoningDetailsModels", "thinkingToggleModels", "thinkingBudgetModels", diff --git a/src/router.ts b/src/router.ts index 145681ce7c5..ceedf9378be 100644 --- a/src/router.ts +++ b/src/router.ts @@ -339,6 +339,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider const noReasoningModels = staticPolicy.noReasoningModels; const noTemperatureModels = staticPolicy.noTemperatureModels; const noTopPModels = staticPolicy.noTopPModels; + const noStopModels = staticPolicy.noStopModels; const noPenaltyModels = staticPolicy.noPenaltyModels; const noJsonSchemaModels = staticPolicy.noJsonSchemaModels; const autoToolChoiceOnlyModels = staticPolicy.autoToolChoiceOnlyModels; @@ -482,6 +483,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(noReasoningModels ? { noReasoningModels } : {}), ...(noTemperatureModels ? { noTemperatureModels } : {}), ...(noTopPModels ? { noTopPModels } : {}), + ...(noStopModels ? { noStopModels } : {}), ...(noPenaltyModels ? { noPenaltyModels } : {}), ...(noJsonSchemaModels ? { noJsonSchemaModels } : {}), ...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}), diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 2def56e1586..bb5c05e10d2 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1042,6 +1042,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { noReasoningModels: "editor", noTemperatureModels: "editor", noTopPModels: "editor", + noStopModels: "editor", noPenaltyModels: "editor", noStructuredOutputModels: "editor", noJsonSchemaModels: "editor", diff --git a/src/server/grok-upstream-envelope-echo.ts b/src/server/grok-upstream-envelope-echo.ts new file mode 100644 index 00000000000..ede86da320d --- /dev/null +++ b/src/server/grok-upstream-envelope-echo.ts @@ -0,0 +1,120 @@ +import { ToolEnvelopeEchoFilter, stripToolEnvelopeEcho } from "../lib/tool-envelope-echo-filter"; +import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; + +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function outputText(value: unknown, replacement?: (index: number, content: number, text: string) => string): void { + if (!record(value) || !Array.isArray(value.output)) return; + value.output.forEach((item: unknown, index: number) => { + if (!record(item) || !Array.isArray(item.content)) return; + item.content.forEach((part: unknown, content: number) => { + if (record(part) && part.type === "output_text" && typeof part.text === "string") { + part.text = replacement?.(index, content, part.text) ?? stripToolEnvelopeEcho(part.text); + } + }); + }); +} + +/** + * The echo only appears when the model has seen a replayed tool envelope: a tool call or tool output + * in this request's input, or a stored conversation continued through previous_response_id (its history + * lives upstream and may hold tool results). A first turn with neither is never filtered, so a + * legitimate answer that starts a line with "[Tool Result]" reaches the client intact. + */ +export function responsesRequestMayReplayToolOutput(rawBody: unknown): boolean { + if (!record(rawBody)) return false; + if (typeof rawBody.previous_response_id === "string" && rawBody.previous_response_id !== "") return true; + if (!Array.isArray(rawBody.input)) return false; + // A tool call counts too: xAI's paired tool-result repair answers a dangling call with a synthetic + // output on the outbound request, so the model sees a tool result the caller never sent. + return rawBody.input.some(item => record(item) && typeof item.type === "string" && /_call(_output)?$/.test(item.type)); +} + +/** Completed non-streaming Responses bodies use the same line-aware marker rule. */ +export function stripGrokUpstreamEnvelopeEchoFromResponsesJson(json: string): string { + try { + const value = JSON.parse(json) as unknown; + outputText(value); + return JSON.stringify(value); + } catch { + return json; + } +} + +type TextState = { + filter: ToolEnvelopeEchoFilter; + delivered: string; + lastDeltaBlock: string; + lastDeltaEvent: Record; +}; + +/** Keep xAI text deltas, done events, terminal snapshots and replay state in agreement. */ +export function createGrokUpstreamEnvelopeEchoBlockRewrite( + onCompletedResponse?: (response: Record) => void, +): SseBlockRewrite { + const states = new Map(); + const key = (output: unknown, content: unknown): string => + `${typeof output === "number" ? output : 0}:${typeof content === "number" ? content : 0}`; + const flush = (state: TextState): string[] => { + const suffix = state.filter.finish(); + if (!suffix) return []; + state.delivered += suffix; + return [replaceSseDataPayload(state.lastDeltaBlock, JSON.stringify({ ...state.lastDeltaEvent, delta: suffix }))]; + }; + return (block) => { + const payload = sseDataPayload(block); + if (payload === null) return [block]; + if (payload === "[DONE]") return [...[...states.values()].flatMap(flush), block]; + let event: unknown; + try { event = JSON.parse(payload); } catch { return [block]; } + if (!record(event) || typeof event.type !== "string") return [block]; + if (event.type === "response.output_text.delta" && typeof event.delta === "string") { + const id = key(event.output_index, event.content_index); + let state = states.get(id); + if (!state) { + state = { filter: new ToolEnvelopeEchoFilter(), delivered: "", lastDeltaBlock: block, lastDeltaEvent: event }; + states.set(id, state); + } + state.lastDeltaBlock = block; + state.lastDeltaEvent = event; + const delta = state.filter.feed(event.delta); + state.delivered += delta; + if (!delta) return []; + return [replaceSseDataPayload(block, JSON.stringify({ ...event, delta }))]; + } + if (event.type === "response.output_text.done") { + const state = states.get(key(event.output_index, event.content_index)); + if (!state) { + if (typeof event.text !== "string") return [block]; + event.text = stripToolEnvelopeEcho(event.text); + return [replaceSseDataPayload(block, JSON.stringify(event))]; + } + const pending = flush(state); + event.text = state.delivered; + return [...pending, replaceSseDataPayload(block, JSON.stringify(event))]; + } + if (event.type === "response.output_item.done" && record(event.item)) { + const index = typeof event.output_index === "number" ? event.output_index : 0; + const pending: string[] = []; + if (Array.isArray(event.item.content)) { + event.item.content.forEach((part: unknown, content: number) => { + if (!record(part) || part.type !== "output_text" || typeof part.text !== "string") return; + const state = states.get(key(index, content)); + if (state) { pending.push(...flush(state)); part.text = state.delivered; } + else part.text = stripToolEnvelopeEcho(part.text); + }); + } + return [...pending, replaceSseDataPayload(block, JSON.stringify(event))]; + } + if (event.type === "response.completed" && record(event.response)) { + const pending = [...states.values()].flatMap(flush); + outputText(event.response, (index, content, text) => + states.get(key(index, content))?.delivered ?? stripToolEnvelopeEcho(text)); + onCompletedResponse?.(event.response); + return [...pending, replaceSseDataPayload(block, JSON.stringify(event))]; + } + return [block]; + }; +} diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 39ba2b7b42d..e1192297a39 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -17,6 +17,7 @@ import { } from "../request-log"; import { clientCancelledResponse, readDisplaySafeErrorText, normalizeUpstreamErrorText } from "./core-errors"; import { redactSecretString } from "../../lib/redact"; +import { rewriteUpstreamPolicyRefusal } from "./policy-refusal"; import { waitForProviderRequestSlot } from "../../providers/request-pacing"; import { providerFetch, fetchWithHeaderTimeout, safeHostLabel } from "./fetch-helpers"; import { @@ -41,7 +42,7 @@ import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery"; import type { AttemptRecoveryKind } from "../../usage/log"; import type { OAuthAccessSnapshot } from "../../oauth"; import { publicOAuthAuthenticationErrorMessage } from "../../oauth"; -import { resolveProviderTransport } from "../../providers/xai-transport"; +import { isXaiResponsesDestination, resolveProviderTransport } from "../../providers/xai-transport"; import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot"; import { resolveWireProtocolOverride } from "../adapter-resolve"; import { bindRouteReasoningReplayScope } from "./core-replay"; @@ -1066,6 +1067,21 @@ export async function prepareAdapterExchange( ? streamingContextOverflowResponse(parsed._responseModelId ?? parsed.modelId, translatorBudget) : jsonContextOverflowResponse(); } + const policyRefusal = rewriteUpstreamPolicyRefusal({ + status: upstreamResponse.status, + errorText, + stream: clientRequestedStream, + modelId: parsed._responseModelId ?? parsed.modelId, + // The same host check covers the openai-chat wire: both xAI hosts serve Chat too. + destinationIsXai: isXaiResponsesDestination(route.provider), + translatorBudget, + turnAdmissionLease: options.turnAdmissionLease, + }); + if (policyRefusal) { + // Codex-facing incomplete/content_filter. openai-responses passthrough + // uses the same helper after its 413 block. + return policyRefusal; + } if (!isFixedCodexAccount(admissionState.authCtx)) { recordSubagentQuotaFailureForThreadSpawn( req.headers, diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 71e19ebd209..54820bef981 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -35,6 +35,7 @@ import { consumeComboFailure } from "./core-combo-failure"; import { readDisplaySafeErrorText } from "./core-errors"; import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow"; import { formatPassthroughUpstreamError } from "./passthrough-error"; +import { rewriteUpstreamPolicyRefusal } from "./policy-refusal"; import { resolvePassthroughWebSearchBridgeAuth, planPassthroughWebSearchBridge, @@ -84,6 +85,12 @@ import { createGrokResponsesTimestampBlockRewrite, } from "../grok-responses-control-frame"; import { createGrokResponsesSparseTerminalBlockRewrite } from "../grok-responses-snapshot-repair"; +import { isXaiResponsesDestination } from "../../providers/xai-transport"; +import { + createGrokUpstreamEnvelopeEchoBlockRewrite, + responsesRequestMayReplayToolOutput, + stripGrokUpstreamEnvelopeEchoFromResponsesJson, +} from "../grok-upstream-envelope-echo"; import { createPlaintextV2AgentMessageCallRestoreRewrite, restorePlaintextV2AgentMessageCallsInJsonResult, @@ -326,6 +333,16 @@ export async function deliverPassthroughResponse( ? streamingContextOverflowResponse(parsed._responseModelId ?? parsed.modelId, translatorBudget) : jsonContextOverflowResponse(); } + const policyRefusal = rewriteUpstreamPolicyRefusal({ + status: upstreamResponse.status, + errorText, + stream: clientRequestedStream, + modelId: parsed._responseModelId ?? parsed.modelId, + destinationIsXai: isXaiResponsesDestination(route.provider), + translatorBudget, + turnAdmissionLease: options.turnAdmissionLease, + }); + if (policyRefusal) return policyRefusal; return formatPassthroughUpstreamError(upstreamResponse.status, errorText, { statusText: upstreamResponse.statusText, headers, @@ -356,6 +373,8 @@ export async function deliverPassthroughResponse( // (src/server/relay-eager.ts; policy: // devlog/_fin/260731_macos_rss_retention/100_darwin_eager_optin.md). // The bundled known-bad runtime remains on tee by default on both platforms. + const grokUpstreamEchoEnabled = isXaiResponsesDestination(route.provider) + && responsesRequestMayReplayToolOutput(parsed._rawBody); if (isEventStream && upstreamResponse.body) { // For streamed passthrough, a successful terminal response means non-error upstream status // before relay starts. Waiting for SSE completion would retain request state across the whole @@ -500,7 +519,7 @@ export async function deliverPassthroughResponse( // are not the Responses wire shapes the snapshot must mirror. // Only validated client blocks may publish plaintext continuation state. // Raw inspection precedes rewriting on eager relays, so it cannot own this write. - const plaintextInspector = responseEffects.plaintextV2AgentMessageToolNames.size > 0 + const plaintextInspector = !grokUpstreamEchoEnabled && responseEffects.plaintextV2AgentMessageToolNames.size > 0 ? createSseInspector({ onCompletedResponse: rememberPassthroughResponseChecked }) : undefined; const plaintextEncoder = plaintextInspector ? new TextEncoder() : undefined; @@ -571,6 +590,11 @@ export async function deliverPassthroughResponse( declaredBareWireToolNames, ) : undefined, + grokUpstreamEchoEnabled + ? createGrokUpstreamEnvelopeEchoBlockRewrite( + rememberPassthroughResponse ? rememberPassthroughResponseChecked : undefined, + ) + : undefined, rememberPlaintextBlock, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); const clientBlockRewrite = blockRewrites.length > 0 @@ -620,7 +644,7 @@ export async function deliverPassthroughResponse( const inspector = createSseInspector({ onTerminal: reportNativeTerminal, logCtx, - onCompletedResponse: rememberPassthroughResponse && responseEffects.plaintextV2AgentMessageToolNames.size === 0 ? rememberPassthroughResponseChecked : undefined, + onCompletedResponse: rememberPassthroughResponse && !grokUpstreamEchoEnabled && responseEffects.plaintextV2AgentMessageToolNames.size === 0 ? rememberPassthroughResponseChecked : undefined, onParsedPayload: noteInspectedPayload, onFirstOutput: options.onFirstOutput, pinCompletedResponseIdToFirstSeen: githubCopilotRepairEnabled, @@ -722,7 +746,7 @@ export async function deliverPassthroughResponse( responseEffects.responseCompletionCancelled = true; options.onNativePassthroughCancel?.(); }, - rememberPassthroughResponse && responseEffects.plaintextV2AgentMessageToolNames.size === 0 ? rememberPassthroughResponseChecked : undefined, + rememberPassthroughResponse && !grokUpstreamEchoEnabled && responseEffects.plaintextV2AgentMessageToolNames.size === 0 ? rememberPassthroughResponseChecked : undefined, options.onFirstOutput, inspectionConsumerOptions, ); @@ -732,7 +756,7 @@ export async function deliverPassthroughResponse( logCtx, turnAc.signal, () => unregisterTurn(turnAc), - rememberPassthroughResponse && responseEffects.plaintextV2AgentMessageToolNames.size === 0 ? rememberPassthroughResponseChecked : undefined, + rememberPassthroughResponse && !grokUpstreamEchoEnabled && responseEffects.plaintextV2AgentMessageToolNames.size === 0 ? rememberPassthroughResponseChecked : undefined, options.onFirstOutput, inspectionConsumerOptions, ); @@ -814,6 +838,9 @@ export async function deliverPassthroughResponse( if (plaintextV2RestoreFailed) { return formatErrorResponse(502, "upstream_error", PLAINTEXT_V2_AGENT_MESSAGE_RESTORE_OVERFLOW_MESSAGE); } + if (grokUpstreamEchoEnabled) { + clientJson = stripGrokUpstreamEnvelopeEchoFromResponsesJson(clientJson); + } // #1700: same fail-closed policy as the SSE relay above. Both the plain JSON answer and // the reframed-SSE branch below are built from this body, so one check covers them. This // runs BEFORE the continuation cache write below: a refused turn must not become state a @@ -844,7 +871,7 @@ export async function deliverPassthroughResponse( commitReasoningReplayServingRoute(nativeExchange.request.headers); try { rememberPassthroughResponseChecked( - JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown; model?: unknown }, + JSON.parse(grokUpstreamEchoEnabled ? clientJson : text) as { id?: unknown; output?: unknown; status?: unknown; model?: unknown }, ); } catch { /* non-JSON despite content-type; recording is best-effort */ } // #875: the transport-neutral reliability policy forced a bounded JSON diff --git a/src/server/responses/policy-refusal.ts b/src/server/responses/policy-refusal.ts new file mode 100644 index 00000000000..76d78f37bdd --- /dev/null +++ b/src/server/responses/policy-refusal.ts @@ -0,0 +1,67 @@ +import { bridgeToResponsesSSE, buildResponseJSON } from "../../bridge"; +import type { AdmissionLease } from "../../lib/admission"; +import type { TranslatorBudget } from "../../lib/translator-budget"; +import type { AdapterEvent } from "../../types"; +import { extractPolicyRefusalText, isUpstreamPolicyRefusal } from "../../lib/errors"; +import { trackStreamLifetime } from "../lifecycle"; + +async function* policyRefusalEvents(message: string): AsyncGenerator { + yield { type: "text_delta", text: message }; + yield { type: "done", stopReason: "content_filter" }; +} + +/** + * Turn an xAI-style HTTP 403 model refusal into a Codex-facing Responses + * incomplete/content_filter payload. Returned from `prepareAdapterExchange` + * and native openai-responses passthrough (the grok-4.6 OAuth wire), like + * the 413 overflow helpers. Combo hops still see the original 403. + * + * The streamed form is a delivered turn, so it carries the turn admission lease + * the way every other streaming return does: the lease is released when the body + * finishes or the client disconnects, not when the handler returns. + * + * Only an xAI destination is rewritten. Another provider's 403 may carry the same + * sentence for an unrelated reason, and turning it into a successful turn would hide it. + */ +export function rewriteUpstreamPolicyRefusal(args: { + status: number; + errorText: string; + stream: boolean; + modelId: string; + /** `isXaiResponsesDestination(route.provider)`: api.x.ai or the Grok CLI proxy. */ + destinationIsXai: boolean; + translatorBudget: TranslatorBudget; + turnAdmissionLease?: AdmissionLease; +}): Response | null { + if (!args.destinationIsXai || !isUpstreamPolicyRefusal(args.status, args.errorText)) return null; + const message = extractPolicyRefusalText(args.errorText); + if (!args.stream) { + const json = buildResponseJSON( + [ + { type: "text_delta", text: message }, + { type: "done", stopReason: "content_filter" }, + ], + args.modelId, + { translatorBudget: args.translatorBudget }, + ); + return Response.json(json, { status: 200, headers: { "Cache-Control": "no-store" } }); + } + const sse = bridgeToResponsesSSE( + policyRefusalEvents(message), + args.modelId, + undefined, + undefined, + undefined, + undefined, + 2_000, + { translatorBudget: args.translatorBudget }, + ); + return new Response(trackStreamLifetime(sse, new AbortController(), undefined, args.turnAdmissionLease), { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/src/types/provider.ts b/src/types/provider.ts index ae60d44b525..fc0f270634f 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -810,6 +810,13 @@ export interface OcxProviderConfig { noTemperatureModels?: string[]; /** Model ids that reject caller-specified top_p. */ noTopPModels?: string[]; + /** + * Model ids that reject caller-specified stop sequences. The openai-chat adapter + * drops `stop` for these (xAI grok-4.6 answers 400 invalid-argument + * "Model grok-4.6 does not support parameter stop.", which makes Claude Code's + * auto-mode safety classifier report the model as temporarily unavailable). + */ + noStopModels?: string[]; /** Model ids that reject caller-specified presence/frequency penalty values. */ noPenaltyModels?: string[]; /** diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index fc07c05dd5f..ae746bf63de 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -244,20 +244,36 @@ that cannot fit the remaining budget settles both sniffers, releases the held ev emitted directly. Each adapter feed is limited to 512 UTF-16 code units for envelope detection and 2,048 for routing-commentary detection. Matches beyond that frame prefix intentionally do not trigger a -corrective retry; the complete text still reaches the client and the diagnostic midstream -observer. These feed limits bound temporary copies and do not promise frame-independent parsing. +corrective retry; the later text still passes the independent line-aware echo filter and +diagnostic observer. These feed limits bound temporary copies and do not promise +frame-independent prefix classification. Adjacent midstream markers retain separate findings, capped at eight. A new marker closes the previous corruption window before consuming its line, including a call-id on the marker's own line. Only marker identities, offsets and corruption booleans survive; held reasoning is released in order before terminal errors, preserving upstream error visibility. -The prefix sniffer only watches the opening bytes of a turn. An external model that writes real -prose first and then pastes a replayed `[Tool Result]` envelope defeats it, so that text reaches -the client and is stored as assistant output. `CursorMidstreamEchoObserver` records those -findings without throwing or withholding output, and at turn end eligible non-isolated turns -remint the conversation id for the NEXT turn. The current send is never retried: the echo is -already delivered and a resend would be an uncertain replay. +The prefix sniffer only watches the opening bytes of a turn. For later text, the shared +`ToolEnvelopeEchoFilter` in the shared lib filter holds only a possible +line-leading marker suffix across deltas, emits prose on divergence, and suppresses the +marker and remaining echo tail. A harmless partial suffix is flushed on normal completion; +a distinctive truncated marker at end is dropped. `CursorMidstreamEchoObserver` still +records findings, and eligible non-isolated turns remint the conversation id for the NEXT +turn. The current send is never retried because leading prose may already have escaped. +Markers inside a Markdown fenced code block are quoted examples. Fences follow CommonMark: an +opener is a run of at least three backticks or tildes indented at most three spaces, and only a run +of the same character, at least as long and followed by nothing but whitespace, closes it. A marker +line inside a fence starts a bounded hold (64K characters): a matching closer releases the held text as +code, and a turn that ends with the fence still open drops it as an echo, so an envelope pasted +into a block that never closes cannot escape. A line-start marker in ordinary prose outside a fence +is still treated as an echo; that is the deliberate tradeoff of a sentinel filter. The assistant-history +replay scrub keeps a marker line only inside a fence that closes later in the stored text +(`closedFenceLines`), and the remint follows the filter's verdict (a confirmed echo, or a fenced marker +released by hold overflow): `CursorMidstreamEchoObserver` findings are diagnostics only. The filter and the next-turn remint +are armed for every model that replays tool results as root text +(`cursorNeedsExternalToolContinuation`, which includes the native-wire composer-2.5 builds); +the opening-bytes prefix retry stays external-only, because its corrective continuation text is +encoded for external wire models alone. That rotation has its own bounded allowance in `src/adapters/cursor/thread-continuity.ts`, separate from the incomplete-tool budget and from the overflow budget. It is bounded because a @@ -267,6 +283,9 @@ structural — one shared counter would let the cheap failure spend the allowanc recovery depends on. Exhaustion records a `midstream-envelope-echo-remint-exhausted` diagnostic and keeps the conversation; a turn that completes without an echo clears only this counter. When an incomplete-tool remint already fired in the same turn, the echo arm does not rotate again. +The allowance follows the identity-scoped original conversation through remints, even when +the client omits or changes its thread owner. A bounded, one-hour conversation rewrite map +redirects restored provider-state ids only within the same opaque credential scope. Assistant root replay drops echoed envelopes before they are sent back upstream (`stripAssistantEchoedToolEnvelope`), so the transcript stops feeding itself. The strip starts at @@ -277,9 +296,9 @@ pasted body contains its own blank line therefore leaves a remainder in replay; remint, not this filter, is the primary defence against a poisoned conversation. `resolveCursorConversationId` prefers the retained thread override over a stored -`_cursorConversationId`. Only the remint path writes that store, so a stored id that disagrees -with it is the pre-remint value; preferring it let a second Responses chain in one Codex thread -keep resuming the conversation the previous turn had rotated away from. Isolated helper turns +`_cursorConversationId`, and rewrites either when it names a reminted conversation. Only +the remint path writes that store, so a stored id that disagrees with it is the pre-remint +value. Isolated helper turns still bypass both and mint their own id. Translated audio/file admission follows the [final-adapter input contract](../adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index a39e7a7dd28..bda93df9cd1 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -18,6 +18,14 @@ answers a replayed tool call whose output never arrived. It is deliberately not The contract for both, and the reason they do not collapse into one, is specified in [chat-compat](./chat-compat.md); it is not restated here. +Native xAI Responses delivery strips a line-leading echoed tool-result or tool-call +envelope across split SSE text deltas. It is armed only when the request can have primed the echo: +a tool call or tool output in the input (a dangling call gets a synthetic output from the paired +tool-result repair), or a `previous_response_id` continuation whose history lives upstream +(`responsesRequestMayReplayToolOutput`); a first turn is delivered untouched. The same filter preserves leading prose and +normalizes text-done events, completed snapshots, non-streaming JSON, and the stored +continuation snapshot. It does not rotate an xAI upstream conversation. + The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. @@ -29,6 +37,37 @@ Shared parsing and streaming follow the [request-copy](../transports/byte-accoun ## Responses request compatibility +### Reasoning-model sampling parameters + +xAI documents that `presencePenalty`, `frequencyPenalty` and `stop` "cannot be used with +reasoning models" and answers them with `400 invalid-argument`. The registry seeds the documented +reasoning ids (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, +`grok-4.20-0309-reasoning`, `grok-build-0.1`) into `noPenaltyModels`, so the openai-chat +adapter, the Chat passthrough and the Responses passthrough omit `presence_penalty` / `frequency_penalty` for them. +`grok-4.20-0309-non-reasoning` and `grok-composer-2.5-fast` keep caller penalties. +Regression coverage: `tests/providers/xai/xai-transport.test.ts` +("xAI reasoning models reject penalty parameters"). + +The same ids are seeded into `noStopModels` (provider config, registry, derive fill, resolved +policy and `routedProviderConfig`, like `noTopPModels`). The openai-chat adapter and the Chat +passthrough omit `stop`, and the Responses passthrough drops a `stop` that Claude inbound +translated from `stop_sequences` (`stripRejectedSamplingParams` in +`src/adapters/openai-responses/request-strips.ts`), because `grok-4.20-multi-agent-0309` has only +the Responses wire. Claude Code auto-mode always sends `stop_sequences`; forwarding it made its +classifier mark Grok temporarily unavailable. Regression coverage: +`tests/providers/xai/xai-no-stop.test.ts`. + +### Policy-refusal 403 + +xAI sometimes refuses a turn with HTTP 403 and a bare refusal sentence (`I can't help with that +request.`) instead of HTTP 200 with `finish_reason: content_filter`. Codex would treat the 403 as a +transport failure and retry the unrecorded turn. `isUpstreamPolicyRefusalMessage` matches only the +exact normalized phrases after unwrapping JSON and `Provider error 403:` bodies; plan, credit and +model-access wording stays an error, and those xAI cues are checked by the refusal matcher alone so +the global subscription classifier is unchanged. The rewrite itself is owned by +[policy-refusal.ts](../transports/responses.md#core-module-ownership). Regression coverage: +`tests/server/errors-adapter-failure.test.ts` ("xAI policy-refusal 403"). + `src/adapters/xai-web-search.ts` omits `auto`/`none` tool selection after normalization if no tools remain in either the top-level catalog or `additional_tools`. Cached-only search removal follows the same rule. When an omitted `none` selector stated the turn's only client-call prohibition, diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 5674169f73a..6c9ec9f1630 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -420,7 +420,8 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | | `passthrough-execution.ts` | Native host-lease transfer and the enclosing dispatch/delivery `finally`. | | `passthrough-dispatch.ts` | Native request preparation, upstream sends and pre-commit recovery. | -| `passthrough-delivery.ts` | Native HTTP/SSE/JSON delivery, rewrite/inspection and terminal accounting. | +| `passthrough-delivery.ts` | Native HTTP/SSE/JSON delivery, rewrite/inspection, terminal accounting, and xAI tool-envelope filtering before continuation storage. | +| `policy-refusal.ts` | Rewrites an allowlisted non-combo HTTP 403 model refusal (`isUpstreamPolicyRefusal` in `src/lib/errors.ts`) from an xAI destination only (`isXaiResponsesDestination`: api.x.ai or the Grok CLI proxy, on either wire) to an HTTP 200 Responses `incomplete` / `content_filter` payload, JSON or SSE, for both `adapter-dispatch.ts` and `passthrough-delivery.ts`. A streamed rewrite takes the turn admission lease and releases it when the body finishes, so the refusal stays inside active-turn accounting. Combo attempts keep the original 403 so failover classifies it as a hop. | | `sidecar-execution.ts` | Image/video versus web-search execution and their shared rotation hook. | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns. | | `adapter-dispatch.ts` | Translated initial dispatch, bounded recovery and the shared continuation retry counter. | diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1181ae53918..0f7a00dd564 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1035,6 +1035,7 @@ "parser-content-audio.test.ts": "responses", "passive-route-linker.test.ts": "server", "passthrough-abort.test.ts": "responses", + "passthrough-grok-upstream-envelope-echo.test.ts": "responses", "passthrough-headers.test.ts": "responses", "passthrough-override.test.ts": "responses", "phase100-native-parity.test.ts": "e2e-style", @@ -1557,6 +1558,7 @@ "ws-upstream.test.ts": "responses", "xai-client.test.ts": "images", "xai-empty-catalog-tool-choice.test.ts": "providers/xai", + "xai-no-stop.test.ts": "providers/xai", "xai-oauth-retry.test.ts": "providers/xai", "xai-refresh-lock.test.ts": "providers/xai", "xai-responses-adjacency.test.ts": "providers/xai", diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 2ab8594f7b6..1411e86f887 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -46,6 +46,7 @@ export const RESPONSES_CORE_MODULES = [ "adapter-dispatch.ts", "adapter-continuation.ts", "adapter-delivery.ts", + "policy-refusal.ts", ] as const; export type ResponsesCoreModule = typeof RESPONSES_CORE_MODULES[number]; diff --git a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts index be422c9845d..07c26e8835d 100644 --- a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts +++ b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts @@ -18,7 +18,11 @@ import { lookupCursorThreadConversation, recordCursorEnvelopeEchoRemint, recordCursorIncompleteToolRemint, + rememberCursorConversationRewrite, + resolveCursorConversationRewrite, } from "../../../src/adapters/cursor/thread-continuity"; +import { resolveCursorConversationId } from "../../../src/adapters/cursor/request-builder"; +import { ToolEnvelopeEchoFilter } from "../../../src/lib/tool-envelope-echo-filter"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; import type { CursorRunRequest, CursorServerMessage } from "../../../src/adapters/cursor/types"; import { withTestTranslatorBudget } from "../../helpers/translator-budget"; @@ -357,8 +361,8 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga for (let i = 0; i < 100; i += 1) { yield { type: "thinking", thinking: "x".repeat(128) } satisfies CursorServerMessage; } - // Once the aggregate hold cap flushes, later marker-like text is ordinary output rather - // than evidence for a retry whose preceding reasoning has already reached the client. + // Once the aggregate hold cap flushes, the turn cannot retry, but the independent + // client-facing filter still removes the echoed envelope. yield { type: "text", text: ECHO_TEXT } satisfies CursorServerMessage; yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; }, @@ -377,7 +381,7 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga expect(attempt).toBe(1); expect(events.filter(event => event.type === "thinking_delta")).toHaveLength(100); - expect(events.filter(event => event.type === "text_delta")).not.toHaveLength(0); + expect(events.filter(event => event.type === "text_delta")).toHaveLength(0); }); test("an oversized first text delta is still classified by the echo sniffer", async () => { @@ -479,7 +483,7 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga test.each([ " ".repeat(513) + ECHO_TEXT + "x".repeat(32 * 1024), "Ordinary prose. ".repeat(150) + "Shell is blocked; switching to exec_command. " + "x".repeat(32 * 1024), - ])("matches beyond bounded feed prefixes remain ordinary output", async text => { + ])("matches beyond bounded retry prefixes still pass the line-aware output filter", async text => { let attempts = 0; const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: (() => ({ @@ -496,7 +500,10 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga const events: AdapterEvent[] = []; await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); expect(attempts).toBe(1); - expect(events.filter(event => event.type === "text_delta").map(event => event.text).join("")).toBe(text); + const delivered = events.filter(event => event.type === "text_delta").map(event => event.text).join(""); + expect(delivered).toBe(text.startsWith(" ".repeat(513)) + ? " ".repeat(513) + "[Tool Result]\n" + : text); expect(events.some(event => event.type === "error")).toBe(false); }); @@ -638,6 +645,18 @@ describe("stripAssistantEchoedToolEnvelope", () => { )).toBe("Checking now.\n\nThe table has 41 rows."); }); + test("keeps a marker line that sits inside a fenced code block", () => { + const fence = "\x60\x60\x60"; + const source = "Example output:\n" + fence + "text\n[Tool Result]\nname: Read\n" + fence + "\nThat is the format."; + expect(stripAssistantEchoedToolEnvelope(source)).toBe(source); + }); + + test("an unclosed block in stored history does not shield an echoed envelope", () => { + const fence = "\x60\x60\x60"; + expect(stripAssistantEchoedToolEnvelope("Intro\n" + fence + "text\n[Tool Result]\nsecret")) + .toBe("Intro\n" + fence + "text"); + }); + test("does not strip an inline mention of the marker", () => { const source = "The string [Tool Result] appeared in the transcript I reviewed."; expect(stripAssistantEchoedToolEnvelope(source)).toBe(source); @@ -646,6 +665,39 @@ describe("stripAssistantEchoedToolEnvelope", () => { test("drops a prefix-only envelope to empty text", () => { expect(stripAssistantEchoedToolEnvelope("[Tool Result]\n[tool_result]\ncall_id: 1\n")).toBe(""); }); + + test("drops truncated result and tool-call replay markers after prose", () => { + expect(stripAssistantEchoedToolEnvelope("Done.\n[Tool Result")).toBe("Done."); + expect(stripAssistantEchoedToolEnvelope("Done.\n[Tool call: Glob\nargs\n")) + .toBe("Done."); + }); +}); + +describe("shared incremental tool-envelope filter", () => { + test("flushes false prefixes promptly and harmless partial text on finish", () => { + const filter = new ToolEnvelopeEchoFilter(); + expect(filter.feed("[Tool")).toBe(""); + expect(filter.feed(" usage] is ordinary.\n[Tool")) + .toBe("[Tool usage] is ordinary.\n"); + expect(filter.feed(" prose\n[Too")).toBe("[Tool prose\n"); + expect(filter.finish()).toBe("[Too"); + expect(filter.matched).toBe(false); + }); + + test("drops a truncated result marker at normal end after preserved prose", () => { + const filter = new ToolEnvelopeEchoFilter(); + expect(filter.feed("Done.\n [Tool Res")).toBe("Done.\n"); + expect(filter.feed("ult")).toBe(""); + expect(filter.finish()).toBe(""); + expect(filter.matched).toBe(true); + }); + + test("drops a truncated result marker followed by a split CRLF line", () => { + const filter = new ToolEnvelopeEchoFilter(); + expect(filter.feed("Safe.\r\n[Tool Result\r")).toBe("Safe.\r\n"); + expect(filter.feed("\nsecret tail")).toBe(""); + expect(filter.matched).toBe(true); + }); }); describe("Cursor midstream envelope-echo remint", () => { @@ -660,7 +712,8 @@ describe("Cursor midstream envelope-echo remint", () => { attempts += 1; if (attempts === 1) { yield { type: "text", text: "I'll write the import script now.\n" } satisfies CursorServerMessage; - yield { type: "text", text: "[Tool Result]\nname: Write\noutput: ok\n" } satisfies CursorServerMessage; + yield { type: "text", text: " [Tool " } satisfies CursorServerMessage; + yield { type: "text", text: "Result]\nname: Write\noutput: ok\n" } satisfies CursorServerMessage; yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; return; } @@ -681,9 +734,9 @@ describe("Cursor midstream envelope-echo remint", () => { const first: AdapterEvent[] = []; await adapter.runTurn?.(body, { headers: new Headers() }, event => first.push(event)); - // The echo already reached the client: it is not withheld, only recovered from. - expect(first.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("")) - .toContain("[Tool Result]"); + const firstText = first.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join(""); + expect(firstText).toContain("I'll write the import script now."); + expect(firstText).not.toContain("[Tool"); expect(body._cursorConversationId).toBeDefined(); expect(body._cursorConversationId).not.toBe(seen[0]); expect(lookupCursorThreadConversation(threadId, "acct-midstream-echo")).toBe(body._cursorConversationId); @@ -698,6 +751,130 @@ describe("Cursor midstream envelope-echo remint", () => { clearCursorEnvelopeEchoRemintForTests(); }); + // composer-2.5-fast is a native-wire model, but since #5673 its tool results are replayed as + // root text like an external model's, so a pasted envelope must be stripped and reminted too. + test.each(["cursor/composer-2.5-fast", "cursor/composer-2.5"])( + "%s strips a mid-message envelope and rotates the conversation", + async modelId => { + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + const seen: string[] = []; + const factory = () => ({ + async *run(request: CursorRunRequest) { + seen.push(request.conversationId); + yield { type: "text", text: "Saved the file.\n[Tool " } satisfies CursorServerMessage; + yield { type: "text", text: "Result]\nname: Write\noutput: ok\n" } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + }); + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: factory as never }); + const body = { + ...toolResultBody(modelId), + _clientThreadId: `fast-echo-${modelId}`, + _cursorIdentityScope: "acct-fast-echo", + _cursorConversationId: undefined, + } as OcxParsedRequest; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join(""); + expect(text).toBe("Saved the file.\n"); + expect(body._cursorConversationId).toBeDefined(); + expect(body._cursorConversationId).not.toBe(seen[0]); + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + }, + ); + + test("a marker quoted in a closed code block is delivered intact and does not rotate the thread", async () => { + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + const fence = "\x60\x60\x60"; + const answer = "The replay looks like this:\n" + fence + "text\n[Tool Result]\nname: Write\n" + fence + "\nDone."; + const seen: string[] = []; + const factory = () => ({ + async *run(request: CursorRunRequest) { + seen.push(request.conversationId); + yield { type: "text", text: answer.slice(0, 40) } satisfies CursorServerMessage; + yield { type: "text", text: answer.slice(40) } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + }); + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: factory as never }); + const body = { + ...toolResultBody("cursor/grok-4.6"), + _clientThreadId: "fenced-marker-thread", + _cursorIdentityScope: "acct-fenced", + _cursorConversationId: "cursor_fenced_conv", + } as OcxParsedRequest; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + expect(events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("")).toBe(answer); + expect(body._cursorConversationId).toBe(seen[0]); + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + }); + + test("a fenced marker released by hold overflow still rotates the thread", async () => { + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + const fence = "\x60\x60\x60"; + const answer = fence + "\n[Tool Result]\n" + "x".repeat(70_000) + "\n"; + const seen: string[] = []; + const factory = () => ({ + async *run(request: CursorRunRequest) { + seen.push(request.conversationId); + yield { type: "text", text: answer } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + }); + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: factory as never }); + const body = { + ...toolResultBody("cursor/grok-4.6"), + _clientThreadId: "overflow-marker-thread", + _cursorIdentityScope: "acct-overflow", + _cursorConversationId: undefined, + } as OcxParsedRequest; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + expect(events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("")).toBe(answer); + expect(body._cursorConversationId).toBeDefined(); + expect(body._cursorConversationId).not.toBe(seen[0]); + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + }); + + test("a closed fenced block longer than the hold does not rotate the thread", async () => { + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + const fence = "\x60\x60\x60"; + const answer = fence + "\n[Tool Result]\n" + "x".repeat(70_000) + "\n" + fence + "\nDone."; + const seen: string[] = []; + const factory = () => ({ + async *run(request: CursorRunRequest) { + seen.push(request.conversationId); + yield { type: "text", text: answer } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + }); + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: factory as never }); + const body = { + ...toolResultBody("cursor/grok-4.6"), + _clientThreadId: "overflow-closed-thread", + _cursorIdentityScope: "acct-overflow-closed", + _cursorConversationId: "cursor_overflow_closed", + } as OcxParsedRequest; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + expect(events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("")).toBe(answer); + expect(body._cursorConversationId).toBe(seen[0]); + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + }); + test("the echo allowance is bounded and independent of the incomplete-tool allowance", () => { clearCursorEnvelopeEchoRemintForTests(); clearCursorIncompleteToolRemintForTests(); @@ -717,4 +894,70 @@ describe("Cursor midstream envelope-echo remint", () => { clearCursorEnvelopeEchoRemintForTests(); clearCursorIncompleteToolRemintForTests(); }); + + test("a missing or changing owner cannot reset the same conversation's remint budget", () => { + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + let id = "poisoned"; + const keys: string[] = []; + for (let index = 0; index <= CURSOR_ENVELOPE_ECHO_REMINT_MAX; index++) { + const owner = index % 2 ? `owner-${index}` : undefined; + const scope = cursorEnvelopeEchoRemintScopeKey(owner, "account-a", id)!; + keys.push(scope); + const allowed = recordCursorEnvelopeEchoRemint(scope); + expect(allowed).toBe(index < CURSOR_ENVELOPE_ECHO_REMINT_MAX); + if (allowed) { + const next = `fresh-${index}`; + rememberCursorConversationRewrite(id, next, "account-a"); + id = next; + } + } + expect(new Set(keys).size).toBe(1); + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + }); + + test("ownerless Desktop restores follow remints and stop after the echo allowance", async () => { + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + const seen: string[] = []; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport: (() => ({ + async *run(request: CursorRunRequest) { + seen.push(request.conversationId); + yield { type: "text", text: "Progress.\n[Tool " } satisfies CursorServerMessage; + yield { type: "text", text: "Result]\nsecret" } satisfies CursorServerMessage; + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + })) as never, + }); + const after: string[] = []; + for (let index = 0; index <= CURSOR_ENVELOPE_ECHO_REMINT_MAX; index++) { + const body = { ...toolResultBody("cursor/grok-4.6"), _cursorConversationId: "restored-poisoned", _cursorIdentityScope: "ownerless-account" }; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + expect(events.filter(event => event.type === "text_delta").map(event => event.text).join("")) + .toBe("Progress.\n"); + after.push(body._cursorConversationId!); + } + expect(seen[0]).toBe("restored-poisoned"); + expect(new Set(seen).size).toBe(CURSOR_ENVELOPE_ECHO_REMINT_MAX + 1); + expect(after.at(-1)).toBe(seen.at(-1)); + clearCursorThreadContinuityForTests(); + clearCursorEnvelopeEchoRemintForTests(); + }); + + test("restored conversation ids rewrite only inside their credential scope", () => { + clearCursorThreadContinuityForTests(); + rememberCursorConversationRewrite("poisoned", "fresh-a", "account-a"); + rememberCursorConversationRewrite("fresh-a", "latest-a", "account-a"); + rememberCursorConversationRewrite("poisoned", "fresh-b", "account-b"); + expect(resolveCursorConversationRewrite("poisoned", "account-a")).toBe("latest-a"); + expect(resolveCursorConversationRewrite("poisoned", "account-b")).toBe("fresh-b"); + expect(resolveCursorConversationRewrite("poisoned", "account-c")).toBe("poisoned"); + const body = { ...toolResultBody("cursor/grok-4.6"), _cursorConversationId: "poisoned", _cursorIdentityScope: "account-b" }; + expect(resolveCursorConversationId(body, "grok-4.6")).toBe("fresh-b"); + clearCursorThreadContinuityForTests(); + }); }); diff --git a/tests/providers/xai/xai-no-stop.test.ts b/tests/providers/xai/xai-no-stop.test.ts new file mode 100644 index 00000000000..b4a29268aef --- /dev/null +++ b/tests/providers/xai/xai-no-stop.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter, buildOpenAIChatPassthroughRequest } from "../../../src/adapters/openai-chat"; +import { createResponsesPassthroughAdapter } from "../../../src/adapters/openai-responses"; +import { PROVIDER_REGISTRY } from "../../../src/providers/registry"; +import { routedProviderConfig } from "../../../src/router"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; + +const XAI_NO_STOP_MODELS = [ + "grok-4.7", + "grok-4.6", + "grok-4.5", + "grok-4.3", + "grok-4.20-multi-agent-0309", + "grok-4.20-0309-reasoning", + "grok-build-0.1", +] as const; + +function xaiProvider(): OcxProviderConfig { + return { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + apiKey: "sk-test", + authMode: "key", + noStopModels: [...XAI_NO_STOP_MODELS], + }; +} + +function parsed(modelId: string): OcxParsedRequest { + return { + modelId, + context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] }, + stream: false, + options: { stopSequences: ["END"] }, + }; +} + +describe("xAI noStopModels", () => { + test("seeds documented reasoning ids that reject Chat Completions stop", () => { + const xai = PROVIDER_REGISTRY.find(provider => provider.id === "xai"); + expect(xai?.noStopModels).toEqual([...XAI_NO_STOP_MODELS]); + expect(xai?.noStopModels).not.toContain("grok-4.20-0309-non-reasoning"); + expect(xai?.noStopModels).not.toContain("grok-composer-2.5-fast"); + }); + + // Live 2026-09-23: `ocx-claude-xai--grok-4.7` + stop_sequences -> 400 "Model grok-4.7 does not + // support parameter stop." — the same auto-mode classifier break, one release later. + test("seed covers grok-4.7", () => { + const xai = PROVIDER_REGISTRY.find(provider => provider.id === "xai"); + expect(xai?.noStopModels).toContain("grok-4.7"); + }); + + test("openai-chat omits stop for grok-4.6 and forwards it for other ids", () => { + const adapter = createOpenAIChatAdapter(xaiProvider()); + const dropped = JSON.parse(adapter.buildRequest(parsed("grok-4.6")).body as string) as { stop?: unknown }; + expect(dropped.stop).toBeUndefined(); + const forwarded = JSON.parse( + adapter.buildRequest(parsed("grok-composer-2.5-fast")).body as string, + ) as { stop?: unknown }; + expect(forwarded.stop).toEqual(["END"]); + }); + + test("routedProviderConfig fills noStopModels on a bare xAI row", () => { + const routed = routedProviderConfig("xai", { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + apiKey: "sk-test", + authMode: "key", + }); + expect(routed.noStopModels).toEqual([...XAI_NO_STOP_MODELS]); + const dropped = JSON.parse( + createOpenAIChatAdapter(routed).buildRequest(parsed("grok-4.6")).body as string, + ) as { stop?: unknown }; + expect(dropped.stop).toBeUndefined(); + }); + + test("passthrough omits stop for grok-4.6 and forwards it for other ids", () => { + const provider = xaiProvider(); + const dropped = JSON.parse(buildOpenAIChatPassthroughRequest( + provider, + { model: "grok-4.6", messages: [], stop: ["END"] }, + "grok-4.6", + false, + ).body) as { stop?: unknown }; + expect(dropped.stop).toBeUndefined(); + const forwarded = JSON.parse(buildOpenAIChatPassthroughRequest( + provider, + { model: "grok-composer-2.5-fast", messages: [], stop: ["END"] }, + "grok-composer-2.5-fast", + false, + ).body) as { stop?: unknown }; + expect(forwarded.stop).toEqual(["END"]); + }); + + // grok-4.20-multi-agent-0309 has no Chat wire, so a Claude Code request reaches it as a + // Responses body whose `stop` came from `stop_sequences` (src/claude/inbound.ts). + test("Responses passthrough omits stop and penalties for listed ids and forwards them for others", () => { + const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter({ + ...xaiProvider(), + noPenaltyModels: [...XAI_NO_STOP_MODELS], + adapter: "openai-responses", + })); + const build = (modelId: string) => JSON.parse(adapter.buildRequest({ + modelId, + context: { messages: [] }, + stream: false, + options: {}, + _rawBody: { model: modelId, input: "hi", stop: ["END"], presence_penalty: 0.1, frequency_penalty: 0.2 }, + }, { headers: new Headers() }).body) as { stop?: unknown; presence_penalty?: unknown; frequency_penalty?: unknown }; + const dropped = build("grok-4.20-multi-agent-0309"); + expect(dropped.stop).toBeUndefined(); + expect(dropped.presence_penalty).toBeUndefined(); + expect(dropped.frequency_penalty).toBeUndefined(); + const kept = build("grok-4.20-0309-non-reasoning"); + expect(kept.stop).toEqual(["END"]); + expect(kept.presence_penalty).toBe(0.1); + expect(kept.frequency_penalty).toBe(0.2); + }); +}); diff --git a/tests/providers/xai/xai-transport.test.ts b/tests/providers/xai/xai-transport.test.ts index 157d38f71b2..4faf3866c1d 100644 --- a/tests/providers/xai/xai-transport.test.ts +++ b/tests/providers/xai/xai-transport.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; import { parseRequest } from "../../../src/responses/parser"; import { buildModelsRequest } from "../../../src/oauth"; import { @@ -11,6 +11,7 @@ import { XAI_GROK_CLIENT_VERSION, } from "../../../src/providers/xai-transport"; import { getProviderRegistryEntry } from "../../../src/providers/registry"; +import { routedProviderConfig } from "../../../src/router"; import { XAI_RESPONSES_OPT_IN_MODELS, xaiResponsesOptInState } from "../../../src/providers/xai-responses-opt-in"; import { resolveWireProtocolOverride } from "../../../src/server/adapter-resolve"; import type { OcxAssistantMessage, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; @@ -816,3 +817,60 @@ describe("xAI reasoning_content cache preservation", () => { expect(req.context.messages).toHaveLength(1); }); }); + +// docs.x.ai/docs/guides/reasoning: "presencePenalty, frequencyPenalty, and stop cannot be used +// with reasoning models. Requests that include them return an error." Live 2026-09-23 through +// the local proxy: xai/grok-4.7 answers 200 without penalties and 400 invalid-argument "Model +// grok-4.7 does not support parameter presencePenalty." with presence_penalty (likewise +// frequency_penalty). +describe("xAI reasoning models reject penalty parameters", () => { + const REASONING = [ + "grok-4.7", + "grok-4.6", + "grok-4.5", + "grok-4.3", + "grok-4.20-multi-agent-0309", + "grok-4.20-0309-reasoning", + "grok-build-0.1", + ]; + const penalties = (modelId: string): OcxParsedRequest => ({ + modelId, + context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] }, + stream: false, + options: { presencePenalty: 0.1, frequencyPenalty: 0.2 }, + }); + const routedXai = (): OcxProviderConfig => routedProviderConfig("xai", { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + apiKey: "sk-test", + authMode: "key", + }); + + test("the registry seeds the documented reasoning ids and leaves non-reasoning ids alone", () => { + const xai = getProviderRegistryEntry("xai"); + expect(xai?.noPenaltyModels).toEqual(REASONING); + expect(xai?.noPenaltyModels).not.toContain("grok-4.20-0309-non-reasoning"); + expect(xai?.noPenaltyModels).not.toContain("grok-composer-2.5-fast"); + }); + + test("openai-chat omits both penalties for grok-4.7 and forwards them for a non-reasoning id", () => { + const adapter = createOpenAIChatAdapter(routedXai()); + const dropped = JSON.parse(adapter.buildRequest(penalties("grok-4.7")).body as string) as Record; + expect(dropped.presence_penalty).toBeUndefined(); + expect(dropped.frequency_penalty).toBeUndefined(); + const kept = JSON.parse(adapter.buildRequest(penalties("grok-composer-2.5-fast")).body as string) as Record; + expect(kept.presence_penalty).toBe(0.1); + expect(kept.frequency_penalty).toBe(0.2); + }); + + test("the Chat passthrough drops both penalties for grok-4.7", () => { + const body = JSON.parse(buildOpenAIChatPassthroughRequest( + routedXai(), + { model: "grok-4.7", messages: [], presence_penalty: 0.1, frequency_penalty: 0.2 }, + "grok-4.7", + false, + ).body) as Record; + expect(body.presence_penalty).toBeUndefined(); + expect(body.frequency_penalty).toBeUndefined(); + }); +}); diff --git a/tests/responses/passthrough-grok-upstream-envelope-echo.test.ts b/tests/responses/passthrough-grok-upstream-envelope-echo.test.ts new file mode 100644 index 00000000000..92a4a32d03d --- /dev/null +++ b/tests/responses/passthrough-grok-upstream-envelope-echo.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test"; +import { + createGrokUpstreamEnvelopeEchoBlockRewrite, + responsesRequestMayReplayToolOutput, + stripGrokUpstreamEnvelopeEchoFromResponsesJson, +} from "../../src/server/grok-upstream-envelope-echo"; +import { repoPath } from "../helpers/repo-root"; +import { relaySseWithBlockRewrite } from "../../src/server/sse-payload-rewrite"; +import { ToolEnvelopeEchoFilter, stripToolEnvelopeEcho } from "../../src/lib/tool-envelope-echo-filter"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const frame = (type: string, fields: Record): string => + `event: ${type}\ndata: ${JSON.stringify({ type, ...fields })}\n\n`; + +async function relay(input: string, onCompleted?: (response: Record) => void) { + const bytes = new TextEncoder().encode(input); + const body = new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); } }); + const output = await new Response(relaySseWithBlockRewrite( + body, createGrokUpstreamEnvelopeEchoBlockRewrite(onCompleted), createTestTranslatorBudget(), + )).text(); + return output.split(/\n\n/).filter(Boolean).map(block => JSON.parse(block.split("\ndata: ")[1]!) as Record); +} + +const snapshot = (text: string) => ({ + id: "resp_echo", status: "completed", model: "grok-4.6", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text }] }], +}); + +describe("xAI Responses upstream tool-envelope echoes", () => { + test("split marker after prose is withheld from deltas, done, terminal and continuation", async () => { + const raw = "Done.\n [Tool Result]\nsecret\n"; + let cached: Record | undefined; + const events = await relay([ + frame("response.output_text.delta", { output_index: 0, content_index: 0, delta: "Done.\n [Tool " }), + frame("response.output_text.delta", { output_index: 0, content_index: 0, delta: "Result]\nsecret\n" }), + frame("response.output_text.done", { output_index: 0, content_index: 0, text: raw }), + frame("response.completed", { response: snapshot(raw) }), + ].join(""), response => { cached = response; }); + const deltas = events.filter(event => event.type === "response.output_text.delta").map(event => event.delta).join(""); + expect(deltas).toBe("Done.\n"); + expect(events.find(event => event.type === "response.output_text.done")?.text).toBe(deltas); + const completed = events.find(event => event.type === "response.completed")?.response; + expect(completed).toEqual(snapshot(deltas)); + expect(cached).toEqual(snapshot(deltas)); + }); + + test("false prefixes are emitted as prose and a harmless EOF suffix is flushed", async () => { + const text = "[Tool usage] is documentation.\n[Tool ordinary prose\n[Too"; + const events = await relay([ + frame("response.output_text.delta", { delta: "[Tool" }), + frame("response.output_text.delta", { delta: " usage] is documentation.\n[Tool" }), + frame("response.output_text.delta", { delta: " ordinary prose\n[Too" }), + frame("response.completed", { response: snapshot(text) }), + ].join("")); + expect(events.filter(event => event.type === "response.output_text.delta").map(event => event.delta).join("")) + .toBe(text); + expect(events.find(event => event.type === "response.completed")?.response).toEqual(snapshot(text)); + }); + + // A marker inside a fenced code block is an example the model is showing, not an echo. Before + // the fence was tracked, this whole answer was cut after its second line. + test("markers inside a fenced code block are kept and an echo after the fence is still removed", async () => { + const kept = "Here is the markdown:\n```text\n[Tool Result]\nvalid code\n```\n~~~\n[tool_result]\n~~~\n"; + const raw = `${kept}[Tool Result]\nsecret\n`; + const events = await relay([ + frame("response.output_text.delta", { output_index: 0, content_index: 0, delta: "Here is the markdown:\n``" }), + frame("response.output_text.delta", { output_index: 0, content_index: 0, delta: "`text\n[Tool Res" }), + frame("response.output_text.delta", { output_index: 0, content_index: 0, delta: raw.slice("Here is the markdown:\n```text\n[Tool Res".length) }), + frame("response.completed", { response: snapshot(raw) }), + ].join("")); + const deltas = events.filter(event => event.type === "response.output_text.delta").map(event => event.delta).join(""); + expect(deltas).toBe(kept); + expect(events.find(event => event.type === "response.completed")?.response).toEqual(snapshot(kept)); + }); + + test("truncated marker at EOF is removed from non-streaming JSON", () => { + const result = JSON.parse(stripGrokUpstreamEnvelopeEchoFromResponsesJson( + JSON.stringify(snapshot("Safe.\n[Tool Result")), + )); + expect(result).toEqual(snapshot("Safe.\n")); + }); + + test("a terminal [DONE] flushes a harmless partial prefix", () => { + const rewrite = createGrokUpstreamEnvelopeEchoBlockRewrite(); + expect(rewrite(frame("response.output_text.delta", { delta: "[Too" }).trimEnd())).toEqual([]); + const flushed = rewrite("data: [DONE]"); + expect(flushed).toHaveLength(2); + expect(JSON.parse(flushed[0]!.split("\ndata: ")[1]!).delta).toBe("[Too"); + expect(flushed[1]).toBe("data: [DONE]"); + }); +}); + +describe("ToolEnvelopeEchoFilter fenced code", () => { + const fence = "\x60\x60\x60"; + const bothWays = (text: string): string => { + const whole = stripToolEnvelopeEcho(text); + const filter = new ToolEnvelopeEchoFilter(); + let split = ""; + for (const char of text) split += filter.feed(char); + split += filter.finish(); + expect(split).toBe(whole); + return whole; + }; + + test("a longer opener is closed only by a matching run, not by an inner shorter fence", () => { + const text = "x\n\x60\x60\x60\x60md\n" + fence + "\n[Tool Result]\ncode\n" + fence + "\n\x60\x60\x60\x60\nafter"; + expect(bothWays(text)).toBe(text); + }); + + test("a fence line with an info string does not close the open block", () => { + const text = "x\n" + fence + "\n" + fence + "js\n[Tool Result]\ncode\n" + fence + "\nafter"; + expect(bothWays(text)).toBe(text); + }); + + test("an echo pasted into a block that never closes is dropped from the marker line", () => { + expect(bothWays(fence + "ts\nconst a = 1;\n[Tool Result]\nsecret")).toBe(fence + "ts\nconst a = 1;\n"); + expect(bothWays(fence + "ts\nconst a = 1;\n")).toBe(fence + "ts\nconst a = 1;\n"); + }); + + test("an echo after a closed block is still removed", () => { + expect(bothWays("a\n" + fence + "\ncode\n" + fence + "\n[Tool Result]\nsecret")).toBe("a\n" + fence + "\ncode\n" + fence + "\n"); + }); + + test("a closing fence that ends the stream without a newline releases the held code", () => { + const text = "Intro\n" + fence + "text\n[Tool Result]\nvalid code\n" + fence; + expect(bothWays(text)).toBe(text); + }); + + test("a closed block settles an overflow even when the closer is what overflows the hold", () => { + for (const filler of [65_519, 65_520, 65_521, 70_000]) { + const text = fence + "\n[Tool Result]\n" + "x".repeat(filler) + "\n" + fence + "\n"; + const filter = new ToolEnvelopeEchoFilter(); + expect(filter.feed(text) + filter.finish()).toBe(text); + expect(filter.unverifiedMarker).toBe(false); + } + }); + + test("the hold inside a block is bounded and releases a long block as code", () => { + const text = fence + "\n[Tool Result]\n" + "x".repeat(70_000) + "\n"; + const filter = new ToolEnvelopeEchoFilter(); + expect(filter.feed(text)).toBe(text); + expect(filter.finish()).toBe(""); + expect(filter.matched).toBe(false); + expect(filter.unverifiedMarker).toBe(true); + }); +}); + +describe("xAI echo filter arming", () => { + // A first turn has never seen a replayed envelope, so its text is delivered untouched. + test("arms only for a replayed tool output or a stored-conversation continuation", () => { + expect(responsesRequestMayReplayToolOutput({ input: "hi" })).toBe(false); + expect(responsesRequestMayReplayToolOutput({ input: [{ type: "message", role: "user", content: "hi" }] })).toBe(false); + expect(responsesRequestMayReplayToolOutput({ + input: [{ type: "function_call", call_id: "c1", name: "exec", arguments: "{}" }, { type: "function_call_output", call_id: "c1", output: "ok" }], + })).toBe(true); + expect(responsesRequestMayReplayToolOutput({ input: [{ type: "custom_tool_call_output", call_id: "c1", output: "ok" }] })).toBe(true); + // A dangling call gets a synthetic output from the paired tool-result repair before it is sent. + expect(responsesRequestMayReplayToolOutput({ input: [{ type: "function_call", call_id: "c1", name: "exec", arguments: "{}" }] })).toBe(true); + expect(responsesRequestMayReplayToolOutput({ previous_response_id: "resp_1", input: "next" })).toBe(true); + expect(responsesRequestMayReplayToolOutput(undefined)).toBe(false); + }); + + test("native passthrough delivery gates the filter on the request, not the host alone", async () => { + const source = await Bun.file(repoPath("src/server/responses/passthrough-delivery.ts")).text(); + expect(source).toContain("isXaiResponsesDestination(route.provider)\n && responsesRequestMayReplayToolOutput(parsed._rawBody)"); + }); +}); diff --git a/tests/server/errors-adapter-failure.test.ts b/tests/server/errors-adapter-failure.test.ts index b712ce3def8..4d2c6373a35 100644 --- a/tests/server/errors-adapter-failure.test.ts +++ b/tests/server/errors-adapter-failure.test.ts @@ -2,9 +2,16 @@ import { describe, expect, test } from "bun:test"; import { adapterFailureFromMessage, classifyError, + extractPolicyRefusalText, + isUpstreamPolicyRefusal, parseRetryAfterFromMessage, } from "../../src/lib/errors"; import { bufferCompactResponse } from "../../src/server/responses"; +import { rewriteUpstreamPolicyRefusal } from "../../src/server/responses/policy-refusal"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { getActiveTurnCount, tryAdmitTurn } from "../../src/server/lifecycle"; +import { collectSse } from "../helpers/responses-conformance"; +import { repoPath } from "../helpers/repo-root"; describe("adapterFailureFromMessage", () => { test("maps resource_exhausted to 429 rate_limit_error", () => { @@ -120,3 +127,159 @@ describe("adapterFailureFromMessage", () => { expect(body.error).toMatchObject({ type: "client_cancelled", code: "client_cancelled" }); }); }); + +describe("xAI policy-refusal 403", () => { + test("detects the xAI refusal sentence and not plan/entitlement 403s", () => { + expect(isUpstreamPolicyRefusal(403, "I can't help with that request.")).toBe(true); + expect(isUpstreamPolicyRefusal(403, JSON.stringify({ error: "I can't help with that request." }))).toBe(true); + expect(isUpstreamPolicyRefusal(403, "Provider error 403: I can't help with that request.")).toBe(true); + expect(isUpstreamPolicyRefusal(200, "I can't help with that request.")).toBe(false); + expect(isUpstreamPolicyRefusal(403, "You have run out of credits or need a Grok subscription.")).toBe(false); + expect(isUpstreamPolicyRefusal(403, "The account is not allowed to use this model")).toBe(false); + expect(isUpstreamPolicyRefusal(403, "forbidden")).toBe(false); + expect(isUpstreamPolicyRefusal(403, "I can't help with that request!!!")).toBe(true); + expect(isUpstreamPolicyRefusal( + 403, + "I can't help with that request. You need a Grok subscription.", + )).toBe(false); + expect(isUpstreamPolicyRefusal( + 403, + "Please retry later; I can't help with that request in this context.", + )).toBe(false); + expect(isUpstreamPolicyRefusal(403, "")).toBe(false); + expect(isUpstreamPolicyRefusal(403, " ")).toBe(false); + }); + + test("extracts the refusal sentence from JSON and prefixed bodies", () => { + expect(extractPolicyRefusalText('{"error":"I can\'t help with that request."}')) + .toBe("I can't help with that request."); + expect(extractPolicyRefusalText("Provider error 403: I can't help with that request.")) + .toBe("I can't help with that request."); + expect(extractPolicyRefusalText("")).toBe(""); + expect(extractPolicyRefusalText(" ")).toBe(""); + }); + + // The xAI plan and credit phrases guard only the refusal matcher. Adding them to the global + // subscription classifier turned these message-only errors into 403s elsewhere. + test("xAI plan and credit wording keeps its existing global classification", () => { + for (const message of ["You need a Grok subscription to use this model.", "You have run out of credits."]) { + expect(adapterFailureFromMessage(message).httpStatus).toBe(502); + expect(isUpstreamPolicyRefusal(403, message)).toBe(false); + } + }); + + // The proxy's own error text is `Provider error : `, so the prefix and + // the JSON arrive together; stripping only the prefix left a JSON literal to match against. + test("unwraps a JSON body that still carries the Provider error prefix", () => { + const body = 'Provider error 403: {"error":"I can\'t help with that request."}'; + expect(extractPolicyRefusalText(body)).toBe("I can't help with that request."); + expect(isUpstreamPolicyRefusal(403, body)).toBe(true); + }); + + // runAdmittedHttpTurn releases a lease the handler did not transfer as soon as it returns, + // which would leave the refusal stream outside active-turn accounting while it is delivered. + test("a streamed refusal keeps its turn lease until the body is read", async () => { + const budget = createTranslatorBudget(); + const lease = tryAdmitTurn(); + expect(lease).not.toBeNull(); + try { + const response = rewriteUpstreamPolicyRefusal({ + status: 403, + errorText: JSON.stringify({ error: "I can't help with that request." }), + stream: true, + modelId: "grok-4.6", + destinationIsXai: true, + translatorBudget: budget, + turnAdmissionLease: lease!, + }); + expect(lease!.isTransferred()).toBe(true); + const active = getActiveTurnCount(); + await response!.text(); + expect(getActiveTurnCount()).toBe(active - 1); + } finally { + lease?.release(); + budget.dispose(); + } + }); + + test("rewriteUpstreamPolicyRefusal returns Codex incomplete/content_filter for both wires", async () => { + const budget = createTranslatorBudget(); + try { + // Another provider's 403 with the same sentence stays an error: only xAI is rewritten. + expect(rewriteUpstreamPolicyRefusal({ + status: 403, + errorText: JSON.stringify({ error: "I can't help with that request." }), + stream: false, + modelId: "gpt-5.5", + destinationIsXai: false, + translatorBudget: budget, + })).toBeNull(); + + expect(rewriteUpstreamPolicyRefusal({ + status: 403, + errorText: "You have run out of credits or need a Grok subscription.", + stream: false, + modelId: "grok-4.6", + destinationIsXai: true, + translatorBudget: budget, + })).toBeNull(); + + const jsonResponse = rewriteUpstreamPolicyRefusal({ + status: 403, + errorText: JSON.stringify({ error: "I can't help with that request." }), + stream: false, + modelId: "grok-4.6", + destinationIsXai: true, + translatorBudget: budget, + }); + expect(jsonResponse?.status).toBe(200); + const json = await jsonResponse!.json() as { + status: string; + incomplete_details?: { reason?: string }; + output?: Array<{ type?: string; content?: Array<{ text?: string }> }>; + }; + expect(json.status).toBe("incomplete"); + expect(json.incomplete_details).toEqual({ reason: "content_filter" }); + const texts = (json.output ?? []).flatMap(item => + (item.content ?? []).map(part => part.text).filter((text): text is string => typeof text === "string"), + ); + expect(texts.join("")).toContain("I can't help with that request."); + + const streamResponse = rewriteUpstreamPolicyRefusal({ + status: 403, + errorText: "Provider error 403: I can't help with that request.", + stream: true, + modelId: "grok-4.6", + destinationIsXai: true, + translatorBudget: budget, + }); + expect(streamResponse?.status).toBe(200); + expect(streamResponse?.headers.get("content-type")).toContain("text/event-stream"); + const frames = await collectSse(streamResponse!.body!); + const terminal = frames.find(frame => frame.event === "response.incomplete"); + expect(terminal).toBeDefined(); + const response = terminal!.data.response as { status?: string; incomplete_details?: { reason?: string } }; + expect(response.status).toBe("incomplete"); + expect(response.incomplete_details).toEqual({ reason: "content_filter" }); + } finally { + budget.dispose(); + } + }); + + test("adapter-dispatch and passthrough-delivery both rewrite non-combo policy 403s", async () => { + const { readFile } = await import("node:fs/promises"); + const dispatch = await readFile(repoPath("src/server/responses/adapter-dispatch.ts"), "utf8"); + const passthrough = await readFile(repoPath("src/server/responses/passthrough-delivery.ts"), "utf8"); + for (const source of [dispatch, passthrough]) { + const overflow = source.indexOf("if (upstreamResponse.status === 413)"); + const rewrite = source.indexOf("rewriteUpstreamPolicyRefusal({", overflow); + expect(overflow).toBeGreaterThan(-1); + expect(rewrite).toBeGreaterThan(overflow); + expect(source.slice(rewrite, rewrite + 400)).toContain("destinationIsXai: isXaiResponsesDestination(route.provider)"); + } + const combo = passthrough.indexOf("if (options.comboAttempt)"); + const rewrite = passthrough.indexOf("rewriteUpstreamPolicyRefusal({"); + expect(combo).toBeGreaterThan(-1); + expect(combo).toBeLessThan(rewrite); + }); +}); From 7f8d5380bff64384ca0c2512a20c58d2973d60e7 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 20:38:24 +0900 Subject: [PATCH 08/48] =?UTF-8?q?fix(desktop,cli):=20bundle=20lane=20G=20?= =?UTF-8?q?=E2=80=94=20sidecar=20signing,=20restart=20warning,=20probe=20c?= =?UTF-8?q?eilings,=20hidden=20autostart,=20mise=20updates,=20Linux=20pack?= =?UTF-8?q?aged=20E2E=20(#5682)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): ad-hoc sign the bun sidecar on macOS after prepare Bun's linker-signed standalone output is killed by macOS page validation (CODESIGNING "Invalid Page"), so the bundled ocx sidecar never ran and the desktop app stayed in "resolving". prepare-sidecar now reseals the copied sidecar with an ad-hoc signature, but only when a macOS host prepares a bun-darwin-* target, through the absolute /usr/bin/codesign; a failed or unlaunchable codesign stops preparation. The decision and the spawn boundary live in desktop/scripts/sidecar-signing.ts so they are tested without running codesign. Carries #5559. Co-authored-by: agentHits <140916359+agentHits@users.noreply.github.com> * fix(cli): warn about state loss before and after codex-restart ocx system codex-restart fully quits and relaunches the Codex desktop app, which can discard unsaved composer drafts, model-picker selections, and pending approval prompts. The missing --yes error, the confirmed human output, the capability metadata, the generated skill surface, and the runtime structure doc now name that concrete loss. The restart request, the --yes gate, and the JSON payload are unchanged. Carries #5488. Refs #4761 (the warning slice only; restart scope is unchanged). Co-authored-by: Yu Zhang <34849476+AaronZ345@users.noreply.github.com> * feat(server): OCX_PROBE_TIMEOUT_MS raises the liveness probe ceilings On hosts where a content filter or EDR network extension adds a fixed cost to every loopback connect, the shipped 750 ms probe expires before a healthy proxy answers and every CLI liveness consumer reports it down. OCX_PROBE_TIMEOUT_MS (whole milliseconds, 1 to 30000) raises the ceilings on such hosts. The override only raises: the 750 ms shared default and the 1500 ms stop/start ownership budgets keep their floors, so a small value can never shorten the budgets that prevent a duplicate proxy. Values above 30 s are ignored so the single-shot stop deadline stays bounded (at most about 90 s). The wiring tests read the constants in child processes, so no other test file can observe an override. The CLI reference in all eight locales and structure/ops/service-and-sidecars.md describe the setting. Carries #5409 with the floor and ceiling fixed during the carry. Co-authored-by: Kinso <5144108+kinsolee@users.noreply.github.com> * perf(desktop): keep a hidden login launch on the startup surface A login launch that starts hidden behind a usable tray no longer loads the full dashboard after Ready. It keeps the small bundled startup page, and the tray's Open Dashboard, a second ordinary launch, and the shell's open command all go through startup::open_dashboard, which performs the run's single navigation before showing the window. Manual launches and visible no-tray launches keep eager navigation. Two gaps in the original change are closed here. An open that arrives during startup is recorded before progress is read, and finish reads it after recording Ready, so whichever side runs second navigates. A WebView that refuses the navigation script gives the one-shot claim back, so the next open retries. Both reset with each run. Rust tests cover the first, repeated, refused, and in-flight opens; the desktop guide in all eight locales, structure/desktop-shell.md, and ADR-5494 describe the behavior. Carries #5498. Refs #5493 (hidden-autostart deferral). Co-authored-by: ingwannu <186453546+Ingwannu@users.noreply.github.com> * fix(update): respect mise-owned installations An opencodex package installed by mise was updated by npm self-update inside mise's tree, behind mise's back. Install detection now recognises a mise install from the adjacent .mise.backend.toml (tool alias plus the canonical npm:@bitkyc08/opencodex backend) on both the lexical and the resolved package path, reports installer "mise", and refuses mutation with "mise upgrade " before any proxy stop, package write, or worker creation: in the Node launcher, ocx update, the dashboard update check and worker, and the sidebar badge. Unreadable or contradictory metadata on either path fails closed without inventing a tool name. The dashboard hides the command chip when there is no verified command, and the lifecycle reference in all eight locales and all ten GUI catalogs describe the behaviour. Changes made while carrying it onto current dev: - ported onto the update ownership transaction and the package-tree restart guard that landed after the PR's base; - two verified owners whose tool roots differ only by a symlinked ancestor (macOS /var -> /private/var) are compared by canonical directory, so a real install behind a symlinked data directory is not reported as contradictory; - the launcher refusal test now runs on Windows too (junction plus npm.cmd), proves the fake npm never runs, and covers contradictory metadata; - the structure note moved to structure/ops/service-and-sidecars.md to keep structure/runtime.md within its line budget. Carries #5316. Co-authored-by: Gary Sassano <10464497+garysassano@users.noreply.github.com> * test(desktop): add the Linux packaged-shell E2E driver desktop/scripts/linux-packaged-e2e.ts boots the real AppImage and deb payloads under a private Xvfb, Openbox and D-Bus session with fresh HOME, XDG, CODEX_HOME and OPENCODEX_HOME roots and a reserved loopback port, then requires a visible OpenCodex window, the bundled sidecar's matching /healthz identity, port and version, and a clean drain after the only window closes. Its report records readiness time and process-tree RSS as evidence, not as budgets. Release asset collection accepts an explicit isolated bundle root, and the AppImage patchelf wrapper follows the active CARGO_TARGET_DIR so each Linux format can build in its own Cargo target. Changes made while carrying it: - the window is closed through the window manager (wmctrl -i -c, the EWMH close request a close button sends) instead of xdotool windowclose, which destroys the X window and can end the app without Tauri's close/drain path; the app must then exit on its own with code 0 and no signal, which is asserted and recorded in the report; - verify-linux-sidecar.sh takes the staged AppImage directory as an optional argument, keeping the local default path; - workflow wiring and the tests that read workflow files are in the following commit. Carries #5502 (driver, scripts, docs). Refs #5493. Co-authored-by: ingwannu <186453546+Ingwannu@users.noreply.github.com> * ci(desktop): run the Linux packaged-shell E2E and isolate Linux release formats CI: a new desktop scope (desktop/, gui/, src/, the standalone build scripts, package.json, bun.lock and ci.yml itself) selects desktop-shell alongside the native scope. When selected, the job builds the dashboard and the bundled sidecar, builds the AppImage and the deb in separate Cargo targets with updater artifacts disabled, stages them read-only, and runs the packaged-shell E2E under dbus-run-session, xvfb-run and Openbox. The report is uploaded with a SHA-pinned upload-artifact. The workflow keeps contents: read, uses no secrets, and installs no package into the runner. The aggregate gate derives the widened desktop-shell expectation the same way the job does. Release: on Linux, each format is built in its own CARGO_TARGET_DIR, staged read-only, and collected from that staged root; the existing job-scoped signing inputs are unchanged. Changes made while carrying it: - current dev's scope step no longer handles a privacy output; only the desktop output was added to it and to the aggregate; - the Linux sidecar verifier moved after the isolated AppImage build and staging, and verifies the staged AppImage directory; before, it would have run before any Linux bundle existed in the default target; - wmctrl is installed for the window-manager close request; - the scope and aggregate tests that landed on dev after the PR's base now model the desktop output, and a new test file carries the CI wiring assertions. Carries #5502 (workflow part). Refs #5493. Co-authored-by: ingwannu <186453546+Ingwannu@users.noreply.github.com> --------- Co-authored-by: agentHits <140916359+agentHits@users.noreply.github.com> Co-authored-by: Yu Zhang <34849476+AaronZ345@users.noreply.github.com> Co-authored-by: Kinso <5144108+kinsolee@users.noreply.github.com> Co-authored-by: ingwannu <186453546+Ingwannu@users.noreply.github.com> Co-authored-by: Gary Sassano <10464497+garysassano@users.noreply.github.com> --- .github/workflows/ci.yml | 124 +++- .github/workflows/release.yml | 51 +- bin/ocx.mjs | 18 +- desktop/package.json | 1 + desktop/scripts/appimage-patchelf.py | 44 +- desktop/scripts/collect-release-assets.ts | 12 +- desktop/scripts/linux-packaged-e2e.ts | 566 ++++++++++++++++++ desktop/scripts/prepare-sidecar.ts | 5 + desktop/scripts/sidecar-signing.ts | 31 + desktop/scripts/verify-linux-sidecar.sh | 5 +- desktop/src-tauri/src/lib.rs | 10 +- desktop/src-tauri/src/startup.rs | 211 ++++++- desktop/src-tauri/src/tray.rs | 4 +- .../src/content/docs/fr/guides/desktop-app.md | 2 +- .../src/content/docs/fr/reference/cli.md | 6 + .../docs/fr/reference/cli/lifecycle.md | 2 + .../src/content/docs/guides/desktop-app.md | 4 +- .../src/content/docs/ja/guides/desktop-app.md | 2 +- .../src/content/docs/ja/reference/cli.md | 6 + .../docs/ja/reference/cli/lifecycle.md | 2 + .../src/content/docs/ko/guides/desktop-app.md | 2 +- .../src/content/docs/ko/reference/cli.md | 6 + .../docs/ko/reference/cli/lifecycle.md | 2 + docs-site/src/content/docs/reference/cli.md | 15 + .../content/docs/reference/cli/lifecycle.md | 2 + .../src/content/docs/ru/guides/desktop-app.md | 2 + .../src/content/docs/ru/reference/cli.md | 6 + .../docs/ru/reference/cli/lifecycle.md | 2 + .../src/content/docs/tr/guides/desktop-app.md | 2 +- .../src/content/docs/tr/reference/cli.md | 6 + .../docs/tr/reference/cli/lifecycle.md | 2 + .../content/docs/zh-cn/guides/desktop-app.md | 2 +- .../src/content/docs/zh-cn/reference/cli.md | 6 + .../docs/zh-cn/reference/cli/lifecycle.md | 2 + .../content/docs/zh-tw/guides/desktop-app.md | 2 +- .../src/content/docs/zh-tw/reference/cli.md | 6 + .../docs/zh-tw/reference/cli/lifecycle.md | 2 + gui/src/components/sidebar-github-row.tsx | 1 + gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/vi.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/dashboard-dialogs.tsx | 4 +- gui/src/pages/dashboard-shared.ts | 4 +- scripts/test-layout/layout.json | 5 + .../ocx/references/01_management_surface.md | 4 +- src/cli/capabilities.ts | 4 +- src/cli/claude.ts | 3 +- src/cli/ready.ts | 2 +- src/cli/system-command.ts | 13 +- src/lib/package-tree-integrity.ts | 2 +- src/server/proxy-liveness.ts | 41 +- src/update/badge.ts | 4 +- src/update/check-types.ts | 9 + src/update/index.ts | 45 +- src/update/install-detection.d.mts | 31 +- src/update/install-detection.mjs | 193 +++++- src/update/job.ts | 29 +- src/update/notify.ts | 3 +- ...DR-5493-linux-packaged-shell-acceptance.md | 12 + ...ADR-5494-lightweight-background-startup.md | 12 + structure/desktop-shell.md | 56 +- structure/ops/service-and-sidecars.md | 11 + structure/runtime.md | 2 +- tests/ci-workflows/ci-privacy-gate.test.ts | 1 + tests/ci-workflows/ci-scope-reduction.test.ts | 29 +- .../linux-desktop-packaged-ci.test.ts | 71 +++ .../linux-desktop-packaged-e2e.test.ts | 122 ++++ .../package-tree-integrity.test.ts | 2 +- .../release-desktop-scripts.test.ts | 72 ++- tests/cli/cli-headless-parity.test.ts | 6 + tests/clients/desktop-startup-surface.test.ts | 29 + tests/fixtures/test-layout-expected.json | 5 + tests/gui/gui-desktop-sidecar-signing.test.ts | 46 ++ tests/helpers/update-bun-ownership-child.ts | 6 +- tests/server/probe-timeout-env.test.ts | 89 +++ tests/update/update-mise.test.ts | 406 +++++++++++++ 83 files changed, 2467 insertions(+), 100 deletions(-) create mode 100644 desktop/scripts/linux-packaged-e2e.ts create mode 100644 desktop/scripts/sidecar-signing.ts create mode 100644 src/update/check-types.ts create mode 100644 structure/decisions/ADR-5493-linux-packaged-shell-acceptance.md create mode 100644 structure/decisions/ADR-5494-lightweight-background-startup.md create mode 100644 tests/ci-workflows/linux-desktop-packaged-ci.test.ts create mode 100644 tests/ci-workflows/linux-desktop-packaged-e2e.test.ts create mode 100644 tests/gui/gui-desktop-sidecar-signing.test.ts create mode 100644 tests/server/probe-timeout-env.test.ts create mode 100644 tests/update/update-mise.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c8c1b458c1..af09564cd08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,6 +182,7 @@ jobs: # step. A missing or malformed filter output must fail this job instead # of silently making every expensive job skip. ci: ${{ steps.scope.outputs.ci }} + desktop: ${{ steps.scope.outputs.desktop }} native: ${{ steps.matrices.outputs.native }} # Matrix include lists for keyring-smoke and npm-global-smoke, built and # shape-checked by the same validation step as `native`. @@ -265,6 +266,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. + desktop: + - 'desktop/**' + - 'gui/**' + - 'src/**' + - 'scripts/build-standalone.ts' + - 'scripts/standalone-targets.ts' + - 'package.json' + - 'bun.lock' + - '.github/workflows/ci.yml' # The docs site is built by nothing else on a pull request. `ci` above # deliberately omits `docs-site/**` -- a prose edit has no business # starting the cross-platform suite -- and `deploy-docs.yml` triggers @@ -342,6 +357,7 @@ jobs: shell: bash env: CI_SCOPE: ${{ steps.filter.outputs.ci }} + DESKTOP_SCOPE: ${{ steps.filter.outputs.desktop }} run: | set -euo pipefail case "$CI_SCOPE" in @@ -353,6 +369,15 @@ jobs: exit 1 ;; esac + case "$DESKTOP_SCOPE" in + true|false) + printf 'desktop=%s\n' "$DESKTOP_SCOPE" >> "$GITHUB_OUTPUT" + ;; + *) + printf '::error::changes.outputs.desktop was %q, expected true or false\n' "$DESKTOP_SCOPE" + exit 1 + ;; + esac - name: Assert the native and matrix outputs are usable id: matrices @@ -1345,11 +1370,11 @@ jobs: desktop-shell: name: desktop shell needs: [changes, gates] - # Native-gated like platform-macos: the Rust shell is formatted, linted - # and tested only when native-capable paths changed. - if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.native == 'true') + # Native shell changes run the Rust checks; package-affecting changes also run the real Linux + # bundle acceptance. The aggregate gate below mirrors this union exactly. + if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && (needs.changes.outputs.native == 'true' || needs.changes.outputs.desktop == 'true')) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 45 steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -1359,7 +1384,11 @@ jobs: - name: Install Tauri Linux dependencies run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf dbus-x11 xvfb xauth wmctrl xdotool openbox + + - name: Setup Bun for packaged E2E + if: needs.changes.outputs.desktop == 'true' + uses: ./.github/actions/setup-project-bun - name: Setup Rust uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master @@ -1385,6 +1414,79 @@ jobs: - name: Run Rust tests run: cargo test --manifest-path desktop/src-tauri/Cargo.toml + - name: Install packaged E2E dependencies + if: needs.changes.outputs.desktop == 'true' + run: | + bun install --frozen-lockfile + cd desktop + bun install --frozen-lockfile + + - name: Build dashboard and bundled sidecar + if: needs.changes.outputs.desktop == 'true' + run: | + bun run build:gui + bun desktop/scripts/prepare-sidecar.ts --target x86_64-unknown-linux-gnu + + # Build separately. One format failing must not delete or hide the other + # format's evidence, and neither verification artifact needs an updater key. + - name: Preserve the compiled Linux sidecar + if: needs.changes.outputs.desktop == 'true' + run: chmod +x desktop/scripts/appimage-patchelf.py + + - name: Build Linux AppImage + if: needs.changes.outputs.desktop == 'true' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-appimage-target + PATCHELF: ${{ github.workspace }}/desktop/scripts/appimage-patchelf.py + run: bunx tauri build --ci --bundles appimage --config '{"bundle":{"createUpdaterArtifacts":false}}' + + - name: Build Linux deb + if: needs.changes.outputs.desktop == 'true' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-deb-target + run: bunx tauri build --ci --bundles deb --config '{"bundle":{"createUpdaterArtifacts":false}}' + + - name: Stage isolated Linux bundles + if: needs.changes.outputs.desktop == 'true' + env: + APPIMAGE_BUNDLE: ${{ runner.temp }}/opencodex-appimage-target/release/bundle/appimage + DEB_BUNDLE: ${{ runner.temp }}/opencodex-deb-target/release/bundle/deb + BUNDLE_ROOT: ${{ runner.temp }}/opencodex-linux-bundles + run: | + set -euo pipefail + mkdir -p "$BUNDLE_ROOT/appimage" "$BUNDLE_ROOT/deb" + cp -a "$APPIMAGE_BUNDLE/." "$BUNDLE_ROOT/appimage/" + cp -a "$DEB_BUNDLE/." "$BUNDLE_ROOT/deb/" + chmod -R a-w "$BUNDLE_ROOT" + + - name: Run Linux packaged-shell E2E + if: needs.changes.outputs.desktop == 'true' + env: + REPORT_PATH: ${{ runner.temp }}/opencodex-linux-e2e/report.json + run: | + set -euo pipefail + mkdir -p "$(dirname "$REPORT_PATH")" + dbus-run-session -- xvfb-run -a -s '-screen 0 1440x900x24' bash -lc ' + openbox >"$RUNNER_TEMP/opencodex-openbox.log" 2>&1 & + wm_pid=$! + trap '\''kill "$wm_pid" 2>/dev/null || true'\'' EXIT + bun desktop/scripts/linux-packaged-e2e.ts \ + --bundle-root "$RUNNER_TEMP/opencodex-linux-bundles" \ + --report "$REPORT_PATH" \ + --version "$(jq -r .version package.json)" + ' + + - name: Upload Linux packaged-shell E2E report + if: always() && needs.changes.outputs.desktop == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-packaged-shell-e2e + path: ${{ runner.temp }}/opencodex-linux-e2e/report.json + if-no-files-found: warn + retention-days: 7 + ci: name: ci if: always() @@ -1414,6 +1516,7 @@ jobs: CHANGES_SETUP_ACTION: ${{ needs.changes.outputs.setup_action }} CHANGES_REMOTE_HELPER: ${{ needs.changes.outputs.remote_helper }} CHANGES_NATIVE: ${{ needs.changes.outputs.native }} + CHANGES_DESKTOP: ${{ needs.changes.outputs.desktop }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -1434,8 +1537,8 @@ jobs: if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_CI" != "true" ]; then scoped=not-requested fi - # platform-macos, widget and desktop-shell carry a compound - # condition: the ordinary scope gate AND the native path filter. + # platform-macos and widget carry the ordinary scope gate AND the native path filter. + # desktop-shell accepts that native set plus the package-E2E set. # This mirrors that expression exactly; where it disagrees with the # jobs' own `if:`, the gate fails by name instead of demanding # success from a job that was deliberately left unselected. @@ -1443,6 +1546,10 @@ jobs: if [ "$EVENT_NAME" != "pull_request" ] || { [ "$CHANGES_CI" = "true" ] && [ "$CHANGES_NATIVE" = "true" ]; }; then native=requested fi + desktop_shell=not-requested + if [ "$EVENT_NAME" != "pull_request" ] || { [ "$CHANGES_CI" = "true" ] && { [ "$CHANGES_NATIVE" = "true" ] || [ "$CHANGES_DESKTOP" = "true" ]; }; }; then + desktop_shell=requested + fi packaging=not-requested if [ "$CHANGES_PACKAGING" = "true" ]; then packaging=requested @@ -1499,8 +1606,9 @@ jobs: changes|select-windows-runner) echo requested ;; test|storage-policy|api-usage|gates|keyring-smoke|docker-smoke) echo "$scoped" ;; - platform-macos|widget|desktop-shell) + platform-macos|widget) echo "$native" ;; + desktop-shell) echo "$desktop_shell" ;; npm-global-smoke) echo "$packaging" ;; docs-site-build) echo "$docs" ;; structure-gate) echo "$structure" ;; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 498fa9246e7..8857697a6a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -414,6 +414,7 @@ jobs: # and updater signatures require maintainer-owned credentials; builds without # those secrets remain useful for local validation but are not release assets. - name: Build desktop bundles + if: runner.os != 'Linux' working-directory: desktop env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -429,9 +430,48 @@ jobs: # diagnostics on the first attempt; Apple signing commands stay non-verbose. run: bunx tauri ${{ runner.os == 'Linux' && '--verbose' || '' }} build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config "${{ runner.os == 'Windows' && format('{0}/opencodex-msi.json', runner.temp) || '{}' }}" + # Tauri patches a bundle-type marker into the application binary for each Linux format. + # Keep each format in its own Cargo target so the deb cannot inherit the AppImage marker + # and linuxdeploy cannot mutate the binary later consumed by the deb build. + - name: Build Linux AppImage bundle + if: runner.os == 'Linux' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-appimage-target + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: bunx tauri build --ci --target ${{ matrix.target }} --bundles appimage + + - name: Build Linux deb bundle + if: runner.os == 'Linux' + working-directory: desktop + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/opencodex-deb-target + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: bunx tauri build --ci --target ${{ matrix.target }} --bundles deb + + - name: Stage isolated Linux release bundles + if: runner.os == 'Linux' + shell: bash + env: + DESKTOP_TARGET: ${{ matrix.target }} + APPIMAGE_TARGET: ${{ runner.temp }}/opencodex-appimage-target + DEB_TARGET: ${{ runner.temp }}/opencodex-deb-target + run: | + set -euo pipefail + bundle_root="$RUNNER_TEMP/opencodex-linux-release-bundles" + mkdir -p "$bundle_root/appimage" "$bundle_root/deb" + cp -a "$APPIMAGE_TARGET/$DESKTOP_TARGET/release/bundle/appimage/." "$bundle_root/appimage/" + cp -a "$DEB_TARGET/$DESKTOP_TARGET/release/bundle/deb/." "$bundle_root/deb/" + chmod -R a-w "$bundle_root" + echo "DESKTOP_BUNDLE_ROOT=$bundle_root" >> "$GITHUB_ENV" + + # After the isolated AppImage exists, and against that staged copy: the default Cargo target + # holds no Linux bundle any more, so verifying there would fail or check a stale artifact. - name: Verify the packaged Linux sidecar if: runner.os == 'Linux' - run: bash desktop/scripts/verify-linux-sidecar.sh + run: bash desktop/scripts/verify-linux-sidecar.sh "$DESKTOP_BUNDLE_ROOT/appimage" - name: Rename release assets shell: bash @@ -439,10 +479,15 @@ jobs: RELEASE_VERSION: ${{ inputs.version }} DESKTOP_TARGET: ${{ matrix.target }} run: | - bun desktop/scripts/collect-release-assets.ts \ + args=( \ --version "$RELEASE_VERSION" \ --target "$DESKTOP_TARGET" \ - --out dist/release + --out dist/release \ + ) + if [[ -n "${DESKTOP_BUNDLE_ROOT:-}" ]]; then + args+=(--bundle-root "$DESKTOP_BUNDLE_ROOT") + fi + bun desktop/scripts/collect-release-assets.ts "${args[@]}" # After the bundle exists, not before: a sweep that runs first passes by finding nothing. - name: Verify every Mach-O in the bundle carries the release identity diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 55729b02c3f..c7f847542d7 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -36,7 +36,7 @@ import { fileURLToPath } from "node:url"; import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs"; import { npmInvocation } from "../src/update/npm-invocation.mjs"; import { pnpmInvocationForPath, resolvePnpmCommands } from "../src/update/pnpm-invocation.mjs"; -import { detectInstallFromPath } from "../src/update/install-detection.mjs"; +import { detectInstallOwnershipFromPath } from "../src/update/install-detection.mjs"; import { pnpmOwnerInvocation, resolvePnpmGlobalOwner, @@ -71,7 +71,8 @@ try { } const require = createRequire(import.meta.url); const here = dirname(fileURLToPath(import.meta.url)); -const installMethod = detectInstallFromPath(here, { exists: existsSync }); +const installOwnership = detectInstallOwnershipFromPath(here, { exists: existsSync }); +const installMethod = installOwnership.installer; const cliPath = join(here, "..", "src", "cli", "index.ts"); const NODE_LAUNCH_CONTEXT_ENV = "OCX_NODE_LAUNCH_CONTEXT"; const NODE_LAUNCH_PROOF_PREFIX = "--ocx-internal-launch-proof="; @@ -924,6 +925,19 @@ if (codexCliUpdateInspection && typeof process.versions.bun === "string") { process.exit(1); } +if (process.argv[2] === "update" && installMethod === "mise") { + if (installOwnership.owner) { + console.error( + `opencodex: this installation is externally managed by mise; update it with: mise upgrade ${installOwnership.owner.tool}`, + ); + } else { + console.error( + "opencodex: this installation appears to be managed by mise, but its ownership metadata is unreadable or inconsistent; repair the mise installation metadata before updating.", + ); + } + process.exit(1); +} + if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstall()) { if (installMethod === "npm") runNpmSelfUpdate(); if (installMethod === "pnpm") runPnpmSelfUpdate(); diff --git a/desktop/package.json b/desktop/package.json index 5966c9235f2..7e469873090 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -5,6 +5,7 @@ "dev": "tauri dev", "build": "tauri build", "build:local": "bun scripts/build-local.ts", + "e2e:linux-packaged": "bun scripts/linux-packaged-e2e.ts", "icons": "bun scripts/generate-icons.ts", "icons:check": "bun scripts/generate-icons.ts --check", "prepare-sidecar": "bun scripts/prepare-sidecar.ts", diff --git a/desktop/scripts/appimage-patchelf.py b/desktop/scripts/appimage-patchelf.py index 4c7e73cd930..63e78ef7416 100644 --- a/desktop/scripts/appimage-patchelf.py +++ b/desktop/scripts/appimage-patchelf.py @@ -5,17 +5,51 @@ import sys +APPDIR_SIDECAR_TAIL = ( + "release", + "bundle", + "appimage", + "OpenCodex.AppDir", + "usr", + "bin", + "ocx", +) + + +def prepared_sidecar(root, candidate, target_root): + """Return the one prepared Linux CLI that the AppDir sidecar exactly mirrors.""" + try: + relative = candidate.resolve().relative_to(target_root.resolve()) + except ValueError: + return None + if tuple(relative.parts[-len(APPDIR_SIDECAR_TAIL):]) != APPDIR_SIDECAR_TAIL: + return None + prefix = relative.parts[:-len(APPDIR_SIDECAR_TAIL)] + if len(prefix) > 1: + return None + + binaries = root / "desktop/src-tauri/binaries" + candidates = sorted(path for path in binaries.glob("ocx-*-linux-gnu") if path.is_file()) + if prefix: + candidates = [path for path in candidates if path.name == f"ocx-{prefix[0]}"] + matches = [path for path in candidates if path.read_bytes() == candidate.read_bytes()] + return matches[0] if len(matches) == 1 else None + + def main(args): root = Path(__file__).resolve().parents[2] - triple = "x86_64-unknown-linux-gnu" - original = root / "desktop/src-tauri/binaries" / f"ocx-{triple}" - sidecar = root / "desktop/src-tauri/target" / triple / "release/bundle/appimage/OpenCodex.AppDir/usr/bin/ocx" - if len(args) == 3 and args[:2] == ["--set-rpath", "$ORIGIN/../lib"] and Path(args[2]).resolve() == sidecar.resolve(): + target_root = Path(os.environ.get("CARGO_TARGET_DIR", root / "desktop/src-tauri/target")) + sidecar = Path(args[2]) if len(args) == 3 else None + if ( + sidecar is not None + and args[:2] == ["--set-rpath", "$ORIGIN/../lib"] + and prepared_sidecar(root, sidecar, target_root) is not None + ): # linuxdeploy's nested GTK pass runs ldd again after patching. Its # patchelf rewrite breaks the compiled Bun ELF. This sidecar depends # only on host glibc libraries; it needs no AppDir library search path. # Never bless an already-modified binary or a different executable. - if sidecar.is_symlink() or original.read_bytes() != sidecar.read_bytes(): + if sidecar.is_symlink(): raise RuntimeError("AppImage sidecar differs from the prepared CLI") print("Preserving compiled ocx bytes (no AppDir RPATH required)", file=sys.stderr) return diff --git a/desktop/scripts/collect-release-assets.ts b/desktop/scripts/collect-release-assets.ts index 2c97de39d44..1d1266283e0 100644 --- a/desktop/scripts/collect-release-assets.ts +++ b/desktop/scripts/collect-release-assets.ts @@ -42,6 +42,7 @@ export interface CollectReleaseAssetsOptions { target: string; out: string; repoRoot?: string; + bundleRoot?: string; } function findBundle(directory: string, kind: BundleKind): string { @@ -61,13 +62,17 @@ export function collectReleaseAssets(options: CollectReleaseAssetsOptions): stri const repoRoot = resolve(options.repoRoot ?? join(import.meta.dir, "../..")); const bundles = bundlesByTarget[options.target]; if (!bundles) throw new Error(`Unsupported desktop target: ${options.target}`); + const bundleRoot = resolve( + options.bundleRoot + ?? join(repoRoot, "desktop", "src-tauri", "target", options.target, "release", "bundle"), + ); const output = resolve(options.out); mkdirSync(output, { recursive: true }); const written: string[] = []; for (const bundle of bundles) { const source = findBundle( - join(repoRoot, "desktop", "src-tauri", "target", options.target, "release", "bundle", bundle.dir), + join(bundleRoot, bundle.dir), bundle.kind, ); const destinationName = `OpenCodex-${options.version}-${bundle.name}`; @@ -98,8 +103,11 @@ if (import.meta.main) { const version = argument("--version"); const target = argument("--target"); const out = argument("--out"); + const bundleRoot = argument("--bundle-root"); if (!version || !target || !out) { throw new Error("Usage: collect-release-assets.ts --version --target --out "); } - for (const path of collectReleaseAssets({ version, target, out })) console.log(`Wrote ${path}`); + const options: CollectReleaseAssetsOptions = { version, target, out }; + if (bundleRoot) options.bundleRoot = bundleRoot; + for (const path of collectReleaseAssets(options)) console.log(`Wrote ${path}`); } diff --git a/desktop/scripts/linux-packaged-e2e.ts b/desktop/scripts/linux-packaged-e2e.ts new file mode 100644 index 00000000000..9ca92352668 --- /dev/null +++ b/desktop/scripts/linux-packaged-e2e.ts @@ -0,0 +1,566 @@ +#!/usr/bin/env bun +/** + * Hosted Linux packaged-shell acceptance. + * + * This is deliberately narrower than installed-gate.ts. It extracts, rather than + * installs, the AppImage and deb payloads so a hosted runner never mutates its package + * database or the runner account's real OpenCodex home. What it proves is the common + * packaged path: the real application executable and bundled resources can show a + * window in a session with no tray host, start their bundled sidecar, identify that + * runtime, and drain both processes when the only window closes. + * + * Real dpkg/AppImage installation, elevation, takeover, and in-place updates remain the + * responsibility of installed-gate.ts on an approved disposable GUI runner. + */ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { + closeSync, + existsSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { createServer } from "node:net"; + +export type LinuxBundleFormat = "appimage" | "deb"; + +export interface LinuxE2eOptions { + bundleRoot: string; + reportPath: string; + version: string; +} + +export interface BundleArtifacts { + appimage: string; + deb: string; +} + +interface RuntimeRecord { + pid: number; + port: number; +} + +interface HealthObservation { + status: number; + body: Record; +} + +interface ReservedLoopbackPort { + port: number; + release: () => Promise; +} + +interface FormatReport { + format: LinuxBundleFormat; + artifact: string; + ok: boolean; + durationMs: number; + windowId?: string; + appPid?: number; + appExitCode?: number | null; + appExitSignal?: string | null; + runtimePid?: number; + runtimeVersion?: string; + configuredPort?: number; + readyMs?: number; + processTreeRssKiB?: number; + error?: string; + stdoutTail?: string[]; + stderrTail?: string[]; +} + +interface AcceptanceReport { + schema: "opencodex-linux-packaged-e2e/1"; + version: string; + startedAt: string; + finishedAt: string; + ok: boolean; + formats: FormatReport[]; +} + +const READY_DEADLINE_MS = 45_000; +const EXIT_DEADLINE_MS = 30_000; +const POLL_MS = 200; +const LOG_TAIL_LINES = 80; +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +function argument(argv: string[], name: string): string | undefined { + const index = argv.indexOf(name); + return index >= 0 ? argv[index + 1] : undefined; +} + +export function parseArguments(argv: string[]): LinuxE2eOptions { + const bundleRoot = argument(argv, "--bundle-root"); + const reportPath = argument(argv, "--report"); + const version = argument(argv, "--version"); + if (!bundleRoot || !reportPath || !version) { + throw new Error("--bundle-root, --report and --version are required"); + } + if (!VERSION.test(version)) throw new Error("--version must be a strict semver"); + return { + bundleRoot: resolve(bundleRoot), + reportPath: resolve(reportPath), + version, + }; +} + +function files(directory: string): string[] { + if (!existsSync(directory)) return []; + return readdirSync(directory) + .map(name => join(directory, name)) + .filter(path => statSync(path).isFile()); +} + +function exactlyOne(paths: string[], label: string): string { + if (paths.length !== 1) { + throw new Error(`expected exactly one ${label}, found ${paths.length}`); + } + return paths[0]!; +} + +export function locateArtifacts(bundleRoot: string): BundleArtifacts { + return { + appimage: exactlyOne( + files(join(bundleRoot, "appimage")).filter(path => path.endsWith(".AppImage")), + "AppImage", + ), + deb: exactlyOne( + files(join(bundleRoot, "deb")).filter(path => path.endsWith(".deb")), + "deb", + ), + }; +} + +function command( + file: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, +): void { + const result = spawnSync(file, args, { + cwd: options.cwd, + env: options.env, + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }); + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "no output").trim(); + throw new Error(`${basename(file)} exited ${result.status ?? "without a status"}: ${detail}`); + } +} + +function executableFiles(directory: string): string[] { + if (!existsSync(directory)) return []; + return readdirSync(directory) + .map(name => join(directory, name)) + .filter(path => { + const stat = statSync(path); + return stat.isFile() && (stat.mode & 0o111) !== 0; + }); +} + +export function extractedExecutable( + format: LinuxBundleFormat, + artifact: string, + destination: string, +): string { + mkdirSync(destination, { recursive: true }); + if (format === "appimage") { + command(artifact, ["--appimage-extract"], { cwd: destination }); + const appRun = join(destination, "squashfs-root", "AppRun"); + if (!existsSync(appRun)) throw new Error("AppImage extraction did not produce AppRun"); + return appRun; + } + + command("dpkg-deb", ["--extract", artifact, destination]); + const candidates = executableFiles(join(destination, "usr", "bin")); + return selectDebExecutable(candidates); +} + +export function selectDebExecutable(candidates: string[]): string { + // The package contains the desktop host and its `ocx` sidecar. The sidecar is deliberately + // executable, but it is not the process whose WebView/window lifecycle this acceptance owns. + return exactlyOne( + candidates.filter(candidate => basename(candidate) !== "ocx"), + "deb desktop executable under usr/bin", + ); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function reserveLoopbackPort(): Promise { + return await new Promise((resolvePort, reject) => { + const server = createServer(); + server.unref(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("could not reserve a temporary loopback port")); + return; + } + let released = false; + resolvePort({ + port: address.port, + release: async () => { + if (released) return; + released = true; + await new Promise((resolveClose, rejectClose) => { + server.close(error => error ? rejectClose(error) : resolveClose()); + }); + }, + }); + }); + }); +} + +async function waitFor(read: () => T | undefined | Promise, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await read(); + if (value !== undefined) return value; + await sleep(POLL_MS); + } + throw new Error(`condition did not settle within ${timeoutMs}ms`); +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +export function readRuntimeRecord(path: string): RuntimeRecord | undefined { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Record; + const pid = positiveInteger(parsed.pid); + const port = positiveInteger(parsed.port); + if (pid === undefined || port === undefined || port > 65_535) return undefined; + return { pid, port }; + } catch { + return undefined; + } +} + +export function assertRuntimeRecordPort(record: RuntimeRecord, configuredPort: number): RuntimeRecord { + if (record.port !== configuredPort) { + throw new Error( + `packaged runtime recorded port ${record.port}, expected isolated port ${configuredPort}`, + ); + } + return record; +} + +function processAlive(pid: number | undefined): boolean { + if (pid === undefined) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return typeof error === "object" && error !== null && "code" in error && error.code === "EPERM"; + } +} + +function processRows(): Array<{ pid: number; ppid: number; rssKiB: number }> { + const result = spawnSync("ps", ["-e", "-o", "pid=,ppid=,rss="], { encoding: "utf8" }); + if (result.status !== 0) return []; + return result.stdout + .trim() + .split(/\r?\n/u) + .map(line => line.trim().split(/\s+/u).map(Number)) + .filter(parts => parts.length === 3 && parts.every(Number.isFinite)) + .map(parts => ({ pid: parts[0]!, ppid: parts[1]!, rssKiB: parts[2]! })); +} + +export interface AppExit { + code: number | null; + signal: string | null; +} + +/** + * The close request goes through the window manager (EWMH _NET_CLOSE_WINDOW), the same path a + * person's close button takes. xdotool's windowclose destroys the X window instead, which can end + * the process without ever running Tauri's close/drain handling and still look like a clean exit. + */ +export function windowManagerCloseArgs(windowId: string): string[] { + const id = Number(windowId); + if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`invalid X11 window id: ${windowId}`); + return ["-i", "-c", `0x${id.toString(16)}`]; +} + +/** A graceful close exits 0 on its own; a signal or a nonzero code is a crash, not a drain. */ +export function assertCleanExit(exit: AppExit | undefined): AppExit { + if (!exit) throw new Error("desktop app did not exit after the close request"); + if (exit.signal !== null || exit.code !== 0) { + throw new Error(`desktop app exited with code ${exit.code ?? "none"} and signal ${exit.signal ?? "none"} instead of a clean close`); + } + return exit; +} + +export function processTreeRssKiB(rootPid: number, rows = processRows()): number { + const selected = new Set([rootPid]); + let changed = true; + while (changed) { + changed = false; + for (const row of rows) { + if (selected.has(row.ppid) && !selected.has(row.pid)) { + selected.add(row.pid); + changed = true; + } + } + } + return rows.filter(row => selected.has(row.pid)).reduce((sum, row) => sum + row.rssKiB, 0); +} + +function xdotoolWindow(): string | undefined { + // WebKit exposes an auxiliary `opencodex-desktop` X11 window before the titled top-level + // `OpenCodex` window. A loose match selected that helper and `windowclose` merely destroyed the + // web process surface, never exercising Tauri's close/drain path. + const result = spawnSync( + "xdotool", + ["search", "--onlyvisible", "--name", "^OpenCodex$"], + { encoding: "utf8" }, + ); + if (result.status !== 0) return undefined; + return result.stdout.trim().split(/\r?\n/u).find(Boolean); +} + +async function health(record: RuntimeRecord): Promise { + try { + const response = await fetch(`http://127.0.0.1:${record.port}/healthz`, { + signal: AbortSignal.timeout(1_000), + cache: "no-store", + }); + const body = await response.json(); + return typeof body === "object" && body !== null + ? { status: response.status, body: body as Record } + : undefined; + } catch { + return undefined; + } +} + +function tail(path: string): string[] { + try { + return readFileSync(path, "utf8").split(/\r?\n/u).filter(Boolean).slice(-LOG_TAIL_LINES); + } catch { + return []; + } +} + +async function stopGroup(child: ChildProcess): Promise { + if (!child.pid || !processAlive(child.pid)) return; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + try { + await waitFor(() => processAlive(child.pid) ? undefined : true, 5_000); + return; + } catch { + // Escalate only inside the detached process group this test created. + } + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } +} + +async function runFormat( + format: LinuxBundleFormat, + artifact: string, + version: string, + root: string, +): Promise { + const started = Date.now(); + const directory = join(root, format); + const extracted = join(directory, "payload"); + const home = join(directory, "home"); + const opencodexHome = join(home, ".opencodex"); + const codexHome = join(home, ".codex"); + const configHome = join(home, ".config"); + const cacheHome = join(home, ".cache"); + const dataHome = join(home, ".local", "share"); + for (const path of [home, opencodexHome, codexHome, configHome, cacheHome, dataHome]) { + mkdirSync(path, { recursive: true, mode: 0o700 }); + } + const stdoutPath = join(directory, "stdout.log"); + const stderrPath = join(directory, "stderr.log"); + mkdirSync(directory, { recursive: true }); + const stdout = openSync(stdoutPath, "w", 0o600); + const stderr = openSync(stderrPath, "w", 0o600); + let child: ChildProcess | undefined; + let runtimePid: number | undefined; + let configuredPort: number | undefined; + let reservedPort: ReservedLoopbackPort | undefined; + try { + const executable = extractedExecutable(format, artifact, extracted); + reservedPort = await reserveLoopbackPort(); + configuredPort = reservedPort.port; + writeFileSync( + join(opencodexHome, "config.json"), + `${JSON.stringify({ port: configuredPort }, null, 2)}\n`, + { mode: 0o600 }, + ); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: configHome, + XDG_CACHE_HOME: cacheHome, + XDG_DATA_HOME: dataHome, + OPENCODEX_HOME: opencodexHome, + CODEX_HOME: codexHome, + NO_PROXY: "127.0.0.1,localhost", + no_proxy: "127.0.0.1,localhost", + WEBKIT_DISABLE_COMPOSITING_MODE: "1", + }; + // Hold the listener while preparing the isolated home so no unrelated process can claim the + // selected port. Release it only at the spawn boundary; the packaged runtime can then bind it. + await reservedPort.release(); + reservedPort = undefined; + child = spawn(executable, [], { + cwd: dirname(executable), + env, + detached: true, + stdio: ["ignore", stdout, stderr], + }); + if (!child.pid) throw new Error("desktop app did not report a pid"); + const appPid = child.pid; + let appExit: AppExit | undefined; + child.once("exit", (code, signal) => { + appExit = { code, signal }; + }); + const windowId = await waitFor(xdotoolWindow, READY_DEADLINE_MS); + const recordPath = join(opencodexHome, "runtime-port.json"); + const record = assertRuntimeRecordPort( + await waitFor(() => readRuntimeRecord(recordPath), READY_DEADLINE_MS), + configuredPort, + ); + runtimePid = record.pid; + let lastHealth: HealthObservation | undefined; + let ready: Record; + try { + ready = await waitFor(async () => { + const observed = await health(record); + if (!observed) return undefined; + lastHealth = observed; + const body = observed.body; + return observed.status >= 200 && observed.status < 300 + && body.service === "opencodex" + && body.pid === record.pid + && body.port === record.port + && body.version === version + ? body + : undefined; + }, READY_DEADLINE_MS); + } catch { + const observed = lastHealth + ? `status ${lastHealth.status}, body ${JSON.stringify(lastHealth.body)}` + : "no readable /healthz response"; + throw new Error(`packaged runtime health identity did not become ready (${observed})`); + } + const readyMs = Date.now() - started; + const rssKiB = processTreeRssKiB(appPid); + + command("wmctrl", windowManagerCloseArgs(windowId)); + await waitFor( + () => appExit && !processAlive(runtimePid) ? true : undefined, + EXIT_DEADLINE_MS, + ); + const exit = assertCleanExit(appExit); + return { + format, + artifact: basename(artifact), + ok: true, + durationMs: Date.now() - started, + windowId, + appPid, + appExitCode: exit.code, + appExitSignal: exit.signal, + runtimePid, + runtimeVersion: typeof ready.version === "string" ? ready.version : undefined, + configuredPort, + readyMs, + processTreeRssKiB: rssKiB, + stdoutTail: tail(stdoutPath), + stderrTail: tail(stderrPath), + }; + } catch (error) { + return { + format, + artifact: basename(artifact), + ok: false, + durationMs: Date.now() - started, + ...(child?.pid ? { appPid: child.pid } : {}), + ...(runtimePid ? { runtimePid } : {}), + ...(configuredPort ? { configuredPort } : {}), + error: error instanceof Error ? error.message : String(error), + stdoutTail: tail(stdoutPath), + stderrTail: tail(stderrPath), + }; + } finally { + await reservedPort?.release(); + if (child) await stopGroup(child); + closeSync(stdout); + closeSync(stderr); + } +} + +export async function runAcceptance(options: LinuxE2eOptions): Promise { + if (process.platform !== "linux") throw new Error("Linux packaged E2E runs only on Linux"); + for (const dependency of ["dpkg-deb", "ps", "wmctrl", "xdotool"]) { + const probe = spawnSync("sh", ["-c", `command -v ${dependency}`]); + if (probe.status !== 0) throw new Error(`missing required command: ${dependency}`); + } + if (!process.env.DISPLAY) throw new Error("DISPLAY is required; run under Xvfb"); + + const artifacts = locateArtifacts(options.bundleRoot); + const root = mkdtempSync(join(tmpdir(), "opencodex-linux-e2e-")); + const startedAt = new Date().toISOString(); + let formats: FormatReport[] = []; + try { + formats = [ + await runFormat("appimage", artifacts.appimage, options.version, root), + await runFormat("deb", artifacts.deb, options.version, root), + ]; + } finally { + const report: AcceptanceReport = { + schema: "opencodex-linux-packaged-e2e/1", + version: options.version, + startedAt, + finishedAt: new Date().toISOString(), + ok: formats.length === 2 && formats.every(format => format.ok), + formats, + }; + mkdirSync(dirname(options.reportPath), { recursive: true }); + writeFileSync(options.reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }); + rmSync(root, { recursive: true, force: true }); + } + return JSON.parse(readFileSync(options.reportPath, "utf8")) as AcceptanceReport; +} + +async function main(): Promise { + const options = parseArguments(process.argv.slice(2)); + const report = await runAcceptance(options); + for (const format of report.formats) { + console.log(`${format.ok ? "PASS" : "FAIL"} ${format.format}: ${format.error ?? `${format.readyMs}ms ready, ${format.processTreeRssKiB} KiB RSS`}`); + } + process.exitCode = report.ok ? 0 : 1; +} + +if (import.meta.main) { + main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/desktop/scripts/prepare-sidecar.ts b/desktop/scripts/prepare-sidecar.ts index 502127870dc..2403e130933 100644 --- a/desktop/scripts/prepare-sidecar.ts +++ b/desktop/scripts/prepare-sidecar.ts @@ -1,5 +1,6 @@ import { copyFileSync, cpSync, existsSync, mkdirSync } from "node:fs"; import { join, resolve } from "node:path"; +import { adHocSignSidecar, shouldAdHocSignSidecar } from "./sidecar-signing"; const targetByTriple: Record = { "aarch64-apple-darwin": "bun-darwin-arm64", @@ -55,5 +56,9 @@ mkdirSync(binaries, { recursive: true }); mkdirSync(resources, { recursive: true }); const destination = join(binaries, `ocx-${triple}${target.startsWith("bun-windows-") ? ".exe" : ""}`); copyFileSync(executable, destination); +if (shouldAdHocSignSidecar(process.platform, target)) { + const signed = adHocSignSidecar(destination); + if (signed !== 0) process.exit(signed); +} cpSync(join(repoRoot, "gui", "dist"), resources, { recursive: true }); console.log(`Prepared ${destination}`); diff --git a/desktop/scripts/sidecar-signing.ts b/desktop/scripts/sidecar-signing.ts new file mode 100644 index 00000000000..a41642b3062 --- /dev/null +++ b/desktop/scripts/sidecar-signing.ts @@ -0,0 +1,31 @@ +// Ad-hoc signing of the prepared desktop sidecar on macOS. +// +// Bun's linker-signed standalone output is killed by macOS page validation +// (CODESIGNING "Invalid Page"), so the copied sidecar is resealed with an +// ad-hoc signature before Tauri bundles it. Only a macOS host preparing a +// bun-darwin-* target signs: a Mac cross-preparing a Linux or Windows sidecar +// must never run codesign on that file. Release builds re-sign the bundled +// binary with Developer ID afterwards; this step only has to leave a runnable +// input. + +export const CODESIGN_PATH = "/usr/bin/codesign"; + +export function shouldAdHocSignSidecar(hostPlatform: string, bunTarget: string): boolean { + return hostPlatform === "darwin" && bunTarget.startsWith("bun-darwin-"); +} + +export function adHocSignArgv(destination: string): string[] { + return [CODESIGN_PATH, "-s", "-", "-f", destination]; +} + +export type SidecarSignSpawn = (argv: string[]) => { exitCode: number | null }; + +const inheritSpawn: SidecarSignSpawn = (argv) => + Bun.spawnSync(argv, { stdout: "inherit", stderr: "inherit" }); + +/** Returns 0 on success, otherwise the nonzero exit code the caller should exit with. */ +export function adHocSignSidecar(destination: string, spawn: SidecarSignSpawn = inheritSpawn): number { + const result = spawn(adHocSignArgv(destination)); + if (result.exitCode === 0) return 0; + return result.exitCode ?? 1; +} diff --git a/desktop/scripts/verify-linux-sidecar.sh b/desktop/scripts/verify-linux-sidecar.sh index 88c5df2ba34..7695767ebe6 100644 --- a/desktop/scripts/verify-linux-sidecar.sh +++ b/desktop/scripts/verify-linux-sidecar.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash # Run only on a Linux packaging runner, against the completed AppImage. +# Usage: verify-linux-sidecar.sh [appimage-bundle-dir] +# The release workflow builds each Linux format in its own Cargo target and stages the AppImage +# into an isolated read-only directory, which it passes here; a local build keeps the default. set -euo pipefail root="$(cd "$(dirname "$0")/../.." && pwd)" -bundle="$root/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage" +bundle="${1:-$root/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage}" original="$root/desktop/src-tauri/binaries/ocx-x86_64-unknown-linux-gnu" shopt -s nullglob images=("$bundle"/*.AppImage) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e9467013ebb..3ae6359e872 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -135,9 +135,7 @@ impl Default for AppState { #[tauri::command] fn show_dashboard(app: tauri::AppHandle) { popup::hide(&app); - if let Some(window) = app.get_webview_window("main") { - window::show(&window); - } + startup::open_dashboard(&app); } #[tauri::command] @@ -191,10 +189,8 @@ fn decide_takeover(app: tauri::AppHandle, approved: bool) { pub fn run() { let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { - if let Some(window) = app.get_webview_window("main") { - popup::hide(app); - window::show(&window); - } + popup::hide(app); + startup::open_dashboard(app); })) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_process::init()) diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs index 3a693e37cda..4b6e8bf059d 100644 --- a/desktop/src-tauri/src/startup.rs +++ b/desktop/src-tauri/src/startup.rs @@ -365,6 +365,18 @@ pub struct Startup { /// before `live`, and never held across an await. reporting: Mutex<()>, running: AtomicBool, + /// Whether this window has already left the bundled bootstrap surface. + /// + /// Explicit open actions can arrive repeatedly from the tray, the single-instance hook, and + /// the shell command. Navigating on every action would recreate the React application and + /// discard renderer state, so the transition is owned here and consumed exactly once per run. + dashboard_loaded: AtomicBool, + /// Whether a person asked for the dashboard during this run. + /// + /// An explicit open that arrives while startup is still running only shows the bootstrap page; + /// `finish` reads this after it has recorded Ready, and `open_dashboard` sets it before it + /// reads progress, so whichever of the two runs second sees the other and navigates. + dashboard_requested: AtomicBool, /// Which run the state belongs to. /// /// A run's deadline guard outlives the run it was started for, and a retry that begins before @@ -385,6 +397,8 @@ impl Startup { }), reporting: Mutex::new(()), running: AtomicBool::new(false), + dashboard_loaded: AtomicBool::new(false), + dashboard_requested: AtomicBool::new(false), generation: AtomicU64::new(0), registered: Mutex::new(None), } @@ -460,6 +474,33 @@ impl Startup { live.consent = ConsentState::Idle; live.reported.clear(); live.latest = Progress::new(Phase::NotStarted, 0); + self.dashboard_loaded.store(false, Ordering::SeqCst); + self.dashboard_requested.store(false, Ordering::SeqCst); + } + + fn should_navigate_dashboard(&self) -> bool { + !self.dashboard_loaded.swap(true, Ordering::SeqCst) + } + + /// Give the one navigation back when the WebView refused the script, so the next open retries. + fn navigation_failed(&self) { + self.dashboard_loaded.store(false, Ordering::SeqCst); + } + + fn request_dashboard(&self) { + self.dashboard_requested.store(true, Ordering::SeqCst); + } + + fn dashboard_requested(&self) -> bool { + self.dashboard_requested.load(Ordering::SeqCst) + } + + /// The dashboard URL once this run is Ready, otherwise nothing. + fn ready_dashboard(&self) -> Option { + let progress = self.latest(); + (progress.phase == Phase::Ready.id()) + .then_some(progress.dashboard) + .flatten() } /// Whether the run has already said how it ended. @@ -1379,10 +1420,72 @@ fn finish(app: &AppHandle, started: Instant, endpoint: ProxyEndpoint) { return; } if let Some(window) = app.get_webview_window("main") { - // justified: replacing the bootstrap page with the dashboard is how this window has always - // navigated, and the string is a URL this process resolved, not anything a page supplied. - let _ = window.eval(format!("window.location.replace({dashboard:?})")); + let visible = window.is_visible().unwrap_or(true); + let startup = app.try_state::(); + let requested = startup + .as_ref() + .is_some_and(|startup| startup.dashboard_requested()); + if loads_dashboard_on_ready(LaunchOrigin::detect(), visible, requested) { + match startup { + Some(startup) => { + navigate_once(&startup, &dashboard, |url| navigate_dashboard(&window, url)); + } + None => { + navigate_dashboard(&window, &dashboard); + } + } + } + } +} + +/// Open the full dashboard only when a person asks for it. +/// +/// A hidden login launch deliberately leaves its WebView on the tiny bundled startup surface after +/// the runtime becomes ready. The tray, a second ordinary application launch, or the bootstrap +/// command reaches this function and pays the dashboard cost at that point. If startup is still in +/// progress the bootstrap is merely shown; `finish` observes the now-visible window and performs +/// the navigation once the endpoint is ready. +pub fn open_dashboard(app: &AppHandle) { + let startup = app.try_state::(); + let Some(window) = app.get_webview_window("main") else { + return; + }; + if let Some(startup) = startup { + // The request is recorded before progress is read; see `dashboard_requested`. + startup.request_dashboard(); + if let Some(dashboard) = startup.ready_dashboard() { + navigate_once(&startup, &dashboard, |url| navigate_dashboard(&window, url)); + } + } + crate::window::show(&window); +} + +fn loads_dashboard_on_ready(origin: LaunchOrigin, window_visible: bool, requested: bool) -> bool { + origin == LaunchOrigin::User || window_visible || requested +} + +/// Perform this run's single dashboard navigation through `navigate`. +/// +/// `navigate` reports whether the WebView accepted the script. Acceptance is not proof that the +/// page finished loading, but a refusal certainly left the bootstrap page in place, so the claim is +/// returned and the next explicit open tries again instead of being suppressed for the whole run. +fn navigate_once(startup: &Startup, dashboard: &str, navigate: impl FnOnce(&str) -> bool) -> bool { + if !startup.should_navigate_dashboard() { + return false; } + if navigate(dashboard) { + return true; + } + startup.navigation_failed(); + false +} + +fn navigate_dashboard(window: &tauri::WebviewWindow, dashboard: &str) -> bool { + // justified: replacing the bootstrap page with the dashboard is how this window has always + // navigated, and the string is a URL this process resolved, not anything a page supplied. + window + .eval(format!("window.location.replace({dashboard:?})")) + .is_ok() } #[allow(clippy::too_many_arguments)] @@ -1489,9 +1592,9 @@ fn elapsed(started: Instant) -> u64 { #[cfg(test)] mod tests { use super::{ - approval_still_current, attach_plan, claim_after_silence, shows_window, - stop_after_approval, unavailable, AttachPlan, ConsentState, Expiry, LaunchOrigin, Phase, - Progress, Startup, AUTOSTART_FLAG, DEADLINE, PHASES, POLL, + approval_still_current, attach_plan, claim_after_silence, loads_dashboard_on_ready, + navigate_once, shows_window, stop_after_approval, unavailable, AttachPlan, ConsentState, + Expiry, LaunchOrigin, Phase, Progress, Startup, AUTOSTART_FLAG, DEADLINE, PHASES, POLL, }; use crate::claim::ClaimResult; use crate::ownership::{Claim, Consent, Owner, Recorded}; @@ -1773,6 +1876,102 @@ mod tests { )); } + #[test] + fn only_a_hidden_login_launch_defers_the_full_dashboard() { + assert!(loads_dashboard_on_ready(LaunchOrigin::User, false, false)); + assert!(loads_dashboard_on_ready(LaunchOrigin::User, true, false)); + assert!(loads_dashboard_on_ready( + LaunchOrigin::Autostart, + true, + false + )); + assert!(!loads_dashboard_on_ready( + LaunchOrigin::Autostart, + false, + false + )); + // An open that arrived during startup counts even if the queued show has not landed yet. + assert!(loads_dashboard_on_ready( + LaunchOrigin::Autostart, + false, + true + )); + } + + #[test] + fn explicit_dashboard_navigation_is_consumed_once_per_run() { + let startup = Startup::new(); + let mut navigations = Vec::new(); + assert!(navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |url| { + navigations.push(url.to_string()); + true + } + )); + assert!(!navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |url| { + navigations.push(url.to_string()); + true + } + )); + assert_eq!( + navigations, + vec!["http://127.0.0.1:10100/#/usage".to_string()] + ); + + startup.restart(); + assert!(navigate_once( + &startup, + "http://127.0.0.1:10101/#/usage", + |_| true + )); + assert!(!navigate_once( + &startup, + "http://127.0.0.1:10101/#/usage", + |_| true + )); + } + + #[test] + fn a_refused_dashboard_navigation_is_retried_on_the_next_open() { + let startup = Startup::new(); + assert!(!navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |_| false + )); + let mut attempts = 0; + assert!(navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |_| { + attempts += 1; + true + } + )); + assert_eq!(attempts, 1); + assert!(!navigate_once( + &startup, + "http://127.0.0.1:10100/#/usage", + |_| true + )); + } + + #[test] + fn an_open_during_startup_is_remembered_until_the_run_restarts() { + let startup = Startup::new(); + assert!(!startup.dashboard_requested()); + assert_eq!(startup.ready_dashboard(), None); + startup.request_dashboard(); + assert!(startup.dashboard_requested()); + startup.restart(); + assert!(!startup.dashboard_requested()); + } + #[test] fn a_login_launch_hides_only_where_there_is_a_tray_to_hide_in() { assert!(!shows_window( diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index ef50233140c..32ef5406a9e 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -162,9 +162,7 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { let _ = popup::show(app, endpoint, anchor); } "open-dashboard" => { - if let Some(window) = app.get_webview_window("main") { - window::show(&window); - } + crate::startup::open_dashboard(app); } "open-browser" => { let Some(endpoint) = app diff --git a/docs-site/src/content/docs/fr/guides/desktop-app.md b/docs-site/src/content/docs/fr/guides/desktop-app.md index 7f4c9215d5e..ee633d9205a 100644 --- a/docs-site/src/content/docs/fr/guides/desktop-app.md +++ b/docs-site/src/content/docs/fr/guides/desktop-app.md @@ -40,7 +40,7 @@ L’icône de zone de notification nécessite un environnement de bureau compati ## Premier lancement -L’application demande à son CLI intégré d’exécuter `ocx resolve --json` et se connecte à un proxy local accessible s’il en existe déjà un. Elle ne démarre son environnement d’exécution intégré que lorsque le CLI établit l’absence de proxy ; un résultat incertain est affiché comme un échec de démarrage. Le tableau de bord s’ouvre alors dans la vue web de l’application, au point de terminaison loopback trouvé. +L’application demande à son CLI intégré d’exécuter `ocx resolve --json` et se connecte à un proxy local accessible s’il en existe déjà un. Elle ne démarre son environnement d’exécution intégré que lorsque le CLI établit l’absence de proxy ; un résultat incertain est affiché comme un échec de démarrage. Le tableau de bord s’ouvre alors dans la vue web de l’application, au point de terminaison loopback trouvé. Un lancement à l’ouverture de session qui démarre masqué dans la zone de notification conserve plutôt la page de démarrage légère et ne charge le tableau de bord qu’à sa première ouverture depuis la zone de notification ou à un nouveau lancement de l’application. Utilisez l’action **Open dashboard** ou **Open in browser** de la zone de notification pour passer du tableau de bord intégré à votre navigateur habituel. Le menu permet aussi de rechercher les mises à jour. diff --git a/docs-site/src/content/docs/fr/reference/cli.md b/docs-site/src/content/docs/fr/reference/cli.md index c736d44c0ac..ff64a5510ff 100644 --- a/docs-site/src/content/docs/fr/reference/cli.md +++ b/docs-site/src/content/docs/fr/reference/cli.md @@ -23,6 +23,12 @@ Pour observer une installation Windows x64, consultez [`attest`](/fr/reference/c L’affichage d’une liste ou d’un état est l’action par défaut lorsqu’il n’y a aucune ambiguïté. Utilisez `--json` pour obtenir des instantanés structurés et `ocx observe logs --follow --jsonl` pour suivre un flux de journaux de requêtes. Le thème, la langue, la navigation et les autres états purement visuels du navigateur n’ont pas d’équivalent dans la CLI. La configuration de Cloudflare Tunnel ne fait pas partie de cet ensemble de commandes. +## Plafond des sondes de disponibilité + +`ocx health`, `ocx status`, `ocx account *`, `ocx login codex` et `ocx ready` trouvent le proxy en cours d'exécution grâce à une courte sonde : 750 ms par tentative par défaut, 1500 ms avec nouvelles tentatives pour les décisions d'arrêt et de démarrage. Si une couche de sécurité (filtre de contenu, extension réseau de type EDR) ajoute un coût fixe à chaque connexion loopback, ces plafonds peuvent expirer avant qu'un proxy sain réponde. + +Définissez `OCX_PROBE_TIMEOUT_MS` pour relever les plafonds, par exemple `OCX_PROBE_TIMEOUT_MS=5000 ocx status`. La valeur est un nombre entier de millisecondes entre 1 et 30000. Elle ne peut que relever : le défaut de 750 ms et les budgets d'arrêt/démarrage de 1500 ms gardent leur plancher, donc `1000` n'allonge que la sonde par défaut. Une valeur absente, vide, fractionnaire, négative, nulle ou supérieure est ignorée. + ## Codes de sortie et confirmation Une commande réussie renvoie le code 0. Une syntaxe non valide, une commande ou une ressource inconnue, l’échec d’une opération d’API ou l’indisponibilité d’un service requis produit un code non nul. Plus précisément, `ocx health` renvoie 0 uniquement lorsque le proxy est sain, et 1 dans le cas contraire ; cette commande peut donc servir de sonde de service. Les scripts doivent tester le code de sortie plutôt que d’analyser le texte destiné aux utilisateurs. diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index f59ebe9916b..99ac08205c3 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -317,6 +317,8 @@ Ouvre le [tableau de bord Web](/fr/guides/web-dashboard/) à l’adresse `http:/ ### `ocx update [--tag latest|preview]` +Lorsque OpenCodex est installé avec mise, cette commande échoue avant d'arrêter le proxy ou de modifier les fichiers du paquet et affiche `mise upgrade ` avec l'alias mise local vérifié. La vérification des mises à jour reste disponible et signale une gestion externe. Des métadonnées de propriété mise illisibles ou incohérentes bloquent aussi toute modification sans deviner le nom de l'outil, et `--tag preview` ne change jamais la sélection configurée dans mise. + Met à jour opencodex depuis npm. Les installations stables utilisent `@latest` ; les préversions restent sur `@preview`, sauf si vous indiquez `--tag latest|preview`. La commande détecte un dépôt de sources et vous invite alors à exécuter `git pull && bun install`. Elle ne fait rien si la version la plus récente correspondant à cette balise est déjà installée. Avant tout arrêt, les installations npm effectuent sous Unix un contrôle borné de la propriété et de l’accès au cache. Les liens symboliques imbriqués sont examinés avec `lstat`, sans être suivis ; Windows ignore explicitement ce contrôle propre à Unix. En cas d’échec, l’opération s’interrompt tandis que l’icône et le proxy fonctionnent encore. Le proxy actif est ensuite arrêté avant le remplacement des fichiers. Un service installé est reconstruit et redémarré automatiquement ; pour une installation au premier plan, la commande indique `ocx start` comme étape suivante. Avant leur conservation, les enregistrements de mise à jour du tableau de bord masquent les chemins de profil et de cache ainsi que les valeurs UID/GID. diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index bcf47866a2d..df6541e21f6 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -52,7 +52,9 @@ The tray icon requires an AppIndicator-capable desktop environment. The app asks its bundled CLI to run `ocx resolve --json` and attaches to a reachable local proxy if one is already running. It starts the bundled runtime only when the CLI proves absence; an uncertain result is shown as a startup failure. The dashboard then opens in -the app's webview at the resolved loopback endpoint. +the app's webview at the resolved loopback endpoint. A login launch that starts hidden in the +tray keeps the lightweight startup page instead, and loads the dashboard the first time you open +it from the tray or launch the app again. Use the tray's **Open dashboard** or **Open in browser** action to move between the embedded dashboard and your normal browser. The tray also provides update checks. diff --git a/docs-site/src/content/docs/ja/guides/desktop-app.md b/docs-site/src/content/docs/ja/guides/desktop-app.md index 753bf95a2ad..c9ceaba2685 100644 --- a/docs-site/src/content/docs/ja/guides/desktop-app.md +++ b/docs-site/src/content/docs/ja/guides/desktop-app.md @@ -40,7 +40,7 @@ sudo apt install ./OpenCodex--linux-amd64.deb ## 初回起動 -アプリは同梱 CLI に `ocx resolve --json` を実行させ、到達可能なローカルプロキシが既に動いていれば接続します。CLI が不在を証明した場合にだけ同梱ランタイムを起動します。結果が不確かな場合は起動エラーとして表示します。その後、特定されたループバックエンドポイントのダッシュボードがアプリ内の WebView で開きます。 +アプリは同梱 CLI に `ocx resolve --json` を実行させ、到達可能なローカルプロキシが既に動いていれば接続します。CLI が不在を証明した場合にだけ同梱ランタイムを起動します。結果が不確かな場合は起動エラーとして表示します。その後、特定されたループバックエンドポイントのダッシュボードがアプリ内の WebView で開きます。ログイン時にトレイへ隠れて起動した場合は軽量な起動画面のままにし、トレイから初めて開いたときかアプリを再度起動したときにダッシュボードを読み込みます。 トレイの **Open dashboard** または **Open in browser** で、埋め込みダッシュボードと通常のブラウザを切り替えられます。トレイから更新の確認もできます。 diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index b3ef24a0365..2329a76087e 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -26,6 +26,12 @@ Windows x64 インストールの観測は [`attest` コマンド](/ja/reference リストまたはステータスは、明確なデフォルトです。構造化スナップショットには `--json` を使用し、ストリーミング リクエスト ログ フィードには `ocx observe logs --follow --jsonl` を使用します。テーマ、言語、ナビゲーション、その他の純粋に視覚的なブラウザーの状態には、同等の CLI がありません。 Cloudflare Tunnel のセットアップはこのコマンド セットの外にあります。 +## ライブネスプローブの上限の上書き + +`ocx health`、`ocx status`、`ocx account *`、`ocx login codex`、`ocx ready` は短いライブネスプローブで実行中のプロキシを探します。既定は1回あたり750 msで、停止と起動の判断ではリトライ付きの1500 msです。セキュリティ層(コンテンツフィルターやEDR系のネットワーク拡張)がループバック接続ごとに固定の遅延を加えるホストでは、正常なプロキシが応答する前にこの上限を超えることがあります。 + +そのようなホストでは `OCX_PROBE_TIMEOUT_MS` で上限を引き上げます(例: `OCX_PROBE_TIMEOUT_MS=5000 ocx status`)。値は1から30000までの整数ミリ秒です。上書きは引き上げのみで、750 msの既定値と1500 msの停止・起動予算は下限を保つため、`1000` は既定のプローブだけを延ばします。未設定、空、小数、負数、0、上限を超える値は無視されます。 + ## 終了コードと確認 成功したコマンドは 0 で終了します。無効な使用法、不明なコマンドまたはリソース、失敗した API 操作、および利用できない必要なサービスは 0 以外で終了します。 `ocx health` は、特にプロキシが正常な場合にのみ 0 で終了し、それ以外の場合は 1 で終了するため、サービス プローブとして使用できます。スクリプトは、人間が判読できる出力をスクレイピングするのではなく、終了コードをテストする必要があります。 diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 877954e9755..c822da6d7b8 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -286,6 +286,8 @@ Windows ステータス トレイ アイコンをインストールして制御 ### `ocx update [--tag latest|preview]` +OpenCodex が mise 経由でインストールされている場合、このコマンドはプロキシの停止やパッケージファイルの変更前に失敗終了し、検証済みのローカル mise エイリアスを使った `mise upgrade ` を表示します。更新確認は引き続き利用でき、外部管理として報告されます。mise の所有権メタデータを読み取れない場合や整合しない場合もツール名を推測せずに変更を拒否し、`--tag preview` は mise の設定済み選択を変更しません。 + npm から opencodex を自己更新します。安定したインストールでは `@latest` を使用します。 `--tag latest|preview` を渡さない限り、プレビュー インストールは `@preview` に残ります。ソース チェックアウトを検出し、代わりに `git pull && bun install` を使用するように指示しますが、そのタグの最新バージョンをすでに使用している場合は何もしません。npm インストールでは、何かを停止する前に Unix キャッシュの所有権とアクセスを上限付きで検査します。ネストされたシンボリックリンクは `lstat` で確認しますが追跡しません。Windows では、この Unix 専用検査を明示的にスキップします。検査に失敗した場合、トレイとプロキシを実行したまま更新を中止します。その後、実行中のプロキシはファイルが置き換えられる前に停止されます。インストールされたサービスは再構築されて自動的に開始されますが、フォアグラウンド インストールでは次のステップとして `ocx start` が出力されます。ダッシュボードの更新記録では、保存前にプロファイル/キャッシュのパスと UID/GID 値が秘匿されます。 ```bash diff --git a/docs-site/src/content/docs/ko/guides/desktop-app.md b/docs-site/src/content/docs/ko/guides/desktop-app.md index 0cbceba4f53..d910373bb80 100644 --- a/docs-site/src/content/docs/ko/guides/desktop-app.md +++ b/docs-site/src/content/docs/ko/guides/desktop-app.md @@ -40,7 +40,7 @@ sudo apt install ./OpenCodex--linux-amd64.deb ## 첫 실행 -앱은 번들 CLI에 `ocx resolve --json` 실행을 요청합니다. 이미 실행 중인 로컬 프록시에 연결할 수 있으면 그 프록시를 사용합니다. CLI가 프록시의 부재를 확인한 경우에만 번들 런타임을 시작하고, 결과가 불확실하면 시작 실패로 표시합니다. 그 뒤 확인된 loopback 엔드포인트의 대시보드를 앱 webview에서 엽니다. +앱은 번들 CLI에 `ocx resolve --json` 실행을 요청합니다. 이미 실행 중인 로컬 프록시에 연결할 수 있으면 그 프록시를 사용합니다. CLI가 프록시의 부재를 확인한 경우에만 번들 런타임을 시작하고, 결과가 불확실하면 시작 실패로 표시합니다. 그 뒤 확인된 loopback 엔드포인트의 대시보드를 앱 webview에서 엽니다. 로그인 시 트레이에 숨겨진 채로 시작한 경우에는 가벼운 시작 화면을 유지하고, 트레이에서 처음 열거나 앱을 다시 실행할 때 대시보드를 불러옵니다. 트레이의 **Open dashboard** 또는 **Open in browser**를 사용하면 내장 대시보드와 일반 브라우저를 오갈 수 있습니다. 트레이에서는 업데이트도 확인할 수 있습니다. diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index d90c479077f..da0ed2b0248 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -23,6 +23,12 @@ Windows x64 설치 관측은 [`attest` 명령](/ko/reference/cli/agents/)을 참 뜻이 분명하면 `list`나 `status`가 기본입니다. 구조화된 스냅샷은 `--json`을, 스트리밍 요청 로그 피드는 `ocx observe logs --follow --jsonl`을 사용합니다. 테마, 언어, 내비게이션처럼 순수하게 시각적인 브라우저 상태에는 CLI 대응이 없습니다. Cloudflare Tunnel 설정은 이 명령 집합 밖입니다. +## 라이브니스 프로브 상한 재정의 + +`ocx health`, `ocx status`, `ocx account *`, `ocx login codex`, `ocx ready`는 짧은 라이브니스 프로브로 실행 중인 프록시를 찾습니다. 기본값은 시도당 750 ms이고, 중지와 시작 판단에는 재시도를 포함해 1500 ms를 씁니다. 콘텐츠 필터나 EDR 계열 네트워크 확장 같은 보안 계층이 루프백 연결마다 고정 지연을 더하는 호스트에서는 정상 프록시가 응답하기 전에 이 상한이 끝날 수 있습니다. + +이런 호스트에서는 `OCX_PROBE_TIMEOUT_MS`로 상한을 올리세요. 예: `OCX_PROBE_TIMEOUT_MS=5000 ocx status`. 값은 1부터 30000까지의 정수 밀리초입니다. 재정의는 올리기만 합니다. 750 ms 기본값과 1500 ms 중지·시작 예산은 하한을 유지하므로 `1000`은 기본 프로브만 늘립니다. 설정하지 않았거나 비어 있거나 소수, 음수, 0, 상한을 넘는 값은 무시됩니다. + ## 종료 코드와 확인 성공한 명령은 종료 코드 0을 반환합니다. 잘못된 사용법, 알 수 없는 명령이나 리소스, 실패한 API 작업, 필요한 서비스가 없음은 0이 아닌 종료 코드를 반환합니다. `ocx health`는 프록시가 건강할 때만 0을, 그렇지 않으면 1을 반환하므로 서비스 probe로 쓸 수 있습니다. 스크립트는 사람이 읽는 출력 대신 종료 코드를 확인해야 합니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 97048c3768e..e17cdfa03f3 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -406,6 +406,8 @@ Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로 ### `ocx update [--tag latest|preview]` +OpenCodex가 mise를 통해 설치된 경우 이 명령은 프록시를 중지하거나 패키지 파일을 변경하기 전에 실패하며 검증된 로컬 mise 별칭을 사용한 `mise upgrade `을 표시합니다. 업데이트 확인은 계속 사용할 수 있고 외부 관리 설치로 보고합니다. mise 소유권 메타데이터를 읽을 수 없거나 일관되지 않아도 도구 이름을 추측하지 않고 변경을 거부하며, `--tag preview`는 mise에 구성된 선택을 변경하지 않습니다. + npm에서 opencodex를 자체 업데이트합니다. 안정판 설치는 `@latest`를 사용하고, 미리보기 설치는 `--tag latest|preview`를 주지 않으면 `@preview`를 유지합니다. 소스 체크아웃을 감지하면 대신 `git pull && bun install`을 실행하라고 안내하고, 해당 태그에서 이미 최신 버전이면 아무 동작도 하지 diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 7e32c2752f4..52e8db3c8e1 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -68,6 +68,21 @@ List or status is the default where unambiguous. Use `--json` for structured sna and other purely visual browser state have no CLI equivalent; Cloudflare Tunnel setup is outside this command set. +## Liveness probe ceiling override + +`ocx health`, `ocx status`, `ocx account *`, `ocx login codex`, and `ocx ready` find the running +proxy through a short liveness probe: 750 ms per attempt by default, and 1500 ms with retries for +stop and start decisions. On hosts where a security layer (a content filter or an EDR-style network +extension) adds a fixed cost to every loopback connection, those ceilings can expire before a +healthy proxy answers, so these commands report the proxy as down while +`curl http://127.0.0.1:10100/healthz` succeeds. + +Set `OCX_PROBE_TIMEOUT_MS` to raise the ceilings on such hosts, for example +`OCX_PROBE_TIMEOUT_MS=5000 ocx status`. The value is whole milliseconds from 1 to 30000. The +override only raises: the 750 ms default and the 1500 ms stop/start budgets keep their floors, so +`1000` lengthens only the default probe. Unset, empty, fractional, negative, zero, or larger values +are ignored and the shipped ceilings apply. + ## Exit codes and confirmation Successful commands exit 0. Invalid usage, unknown commands or resources, failed API operations, diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index d60545cd84b..e8f401f7207 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -700,6 +700,8 @@ package registry or install an update. ### `ocx update [--tag latest|preview]` +When OpenCodex is installed through mise, this command exits unsuccessfully before stopping the proxy or changing package files and shows `mise upgrade `, using the verified local mise alias. Update checks remain available and report the installation as externally managed. An unreadable or inconsistent mise ownership record fails closed without guessing a tool name, and `--tag preview` never changes mise's configured selection. + Self-update opencodex from npm. Stable installs use `@latest`; preview installs stay on `@preview` unless you pass `--tag latest|preview`. It detects a source checkout and tells you to `git pull && bun install` instead, and is a no-op if you are already on the newest version for that diff --git a/docs-site/src/content/docs/ru/guides/desktop-app.md b/docs-site/src/content/docs/ru/guides/desktop-app.md index c2da2fe6afe..d52917226c8 100644 --- a/docs-site/src/content/docs/ru/guides/desktop-app.md +++ b/docs-site/src/content/docs/ru/guides/desktop-app.md @@ -54,6 +54,8 @@ sudo apt install ./OpenCodex--linux-amd64.deb локальному прокси, если тот уже работает. Оно запускает встроенную среду выполнения, только если CLI подтвердил отсутствие прокси; неопределённый результат показывается как ошибка запуска. Затем дашборд открывается в webview приложения по найденному loopback-адресу. +Если приложение запущено при входе в систему и скрыто в системной панели, оно оставляет лёгкую +страницу запуска и загружает дашборд при первом открытии из панели или при повторном запуске. Используйте действия **Open dashboard** или **Open in browser** в системной панели, чтобы переключаться между встроенным дашбордом и обычным браузером. Там же доступны проверки обновлений. diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index 1bbde76795f..63893b3781f 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -40,6 +40,12 @@ runtime port и проверку identity, а не поддерживая вто `ocx observe logs --follow --jsonl`. Theme, language, navigation и прочее чисто визуальное browser-state CLI не покрывает; настройка Cloudflare Tunnel тоже вне этого набора команд. +## Переопределение потолка проб доступности + +`ocx health`, `ocx status`, `ocx account *`, `ocx login codex` и `ocx ready` находят запущенный прокси короткой пробой доступности: по умолчанию 750 мс на попытку и 1500 мс с повторами для решений об остановке и запуске. Если слой безопасности (контент-фильтр или сетевое расширение класса EDR) добавляет фиксированную задержку к каждому loopback-соединению, эти потолки могут истечь раньше, чем ответит исправный прокси. + +На таких хостах задайте `OCX_PROBE_TIMEOUT_MS`, например `OCX_PROBE_TIMEOUT_MS=5000 ocx status`. Значение — целое число миллисекунд от 1 до 30000. Переопределение только повышает потолки: 750 мс по умолчанию и 1500 мс для остановки и запуска сохраняют нижнюю границу, поэтому `1000` удлиняет только пробу по умолчанию. Пустые, дробные, отрицательные, нулевые и большие значения игнорируются. + ## Коды выхода и подтверждение Успешные команды завершаются с кодом 0. Некорректное использование, неизвестные команды или diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 6719e9cf2cb..c446c0d20ba 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -411,6 +411,8 @@ one-click управление прокси. `start` и `stop` управляю ### `ocx update [--tag latest|preview]` +Если OpenCodex установлен через mise, команда завершается с ошибкой до остановки прокси или изменения файлов пакета и показывает `mise upgrade ` с проверенным локальным псевдонимом mise. Проверка обновлений остаётся доступной и сообщает о внешнем управлении. Нечитаемые или противоречивые метаданные владельца mise также запрещают изменения без угадывания имени инструмента, а `--tag preview` никогда не меняет выбранную в mise версию. + Самообновить opencodex из npm. Стабильные установки используют `@latest`; preview-установки остаются на `@preview`, если только вы не передадите `--tag latest|preview`. Команда распознаёт source checkout и предлагает вместо этого `git pull && bun install`, а если у вас уже новейшая diff --git a/docs-site/src/content/docs/tr/guides/desktop-app.md b/docs-site/src/content/docs/tr/guides/desktop-app.md index 4ec471c8464..d0c8c59320b 100644 --- a/docs-site/src/content/docs/tr/guides/desktop-app.md +++ b/docs-site/src/content/docs/tr/guides/desktop-app.md @@ -40,7 +40,7 @@ Tepsi simgesi, AppIndicator destekleyen bir masaüstü ortamı gerektirir. ## İlk açılış -Uygulama, paketindeki CLI'dan `ocx resolve --json` çalıştırmasını ister ve zaten çalışan erişilebilir bir yerel proxy varsa ona bağlanır. Paketindeki çalışma zamanını yalnızca CLI yokluğunu kanıtlarsa başlatır; belirsiz sonuç başlangıç hatası olarak gösterilir. Ardından kontrol paneli, uygulamanın web görünümünde çözümlenen geri döngü uç noktasında açılır. +Uygulama, paketindeki CLI'dan `ocx resolve --json` çalıştırmasını ister ve zaten çalışan erişilebilir bir yerel proxy varsa ona bağlanır. Paketindeki çalışma zamanını yalnızca CLI yokluğunu kanıtlarsa başlatır; belirsiz sonuç başlangıç hatası olarak gösterilir. Ardından kontrol paneli, uygulamanın web görünümünde çözümlenen geri döngü uç noktasında açılır. Oturum açılışında tepside gizli başlayan bir uygulama ise hafif başlangıç sayfasını korur ve kontrol panelini tepsiden ilk açtığınızda ya da uygulamayı yeniden başlattığınızda yükler. Gömülü kontrol paneli ile normal tarayıcınız arasında geçmek için tepsideki **Open dashboard** veya **Open in browser** eylemini kullanın. Tepsi, güncelleme denetimlerini de sunar. diff --git a/docs-site/src/content/docs/tr/reference/cli.md b/docs-site/src/content/docs/tr/reference/cli.md index 903367ba13b..33d7199bd13 100644 --- a/docs-site/src/content/docs/tr/reference/cli.md +++ b/docs-site/src/content/docs/tr/reference/cli.md @@ -46,6 +46,12 @@ logs --follow --jsonl` kullanın. Tema, dil, gezinme ve diğer tamamen görsel tarayıcı durumlarının CLI eşdeğeri yoktur; Cloudflare Tünel kurulumu bu komut kümesinin dışındadır. +## Canlılık yoklaması üst sınırının geçersiz kılınması + +`ocx health`, `ocx status`, `ocx account *`, `ocx login codex` ve `ocx ready`, çalışan proxy'yi kısa bir canlılık yoklamasıyla bulur: varsayılan olarak deneme başına 750 ms, durdurma ve başlatma kararlarında yeniden denemelerle 1500 ms. Bir güvenlik katmanının (içerik filtresi veya EDR tarzı ağ uzantısı) her loopback bağlantısına sabit bir gecikme eklediği ana makinelerde bu sınırlar, sağlıklı bir proxy yanıt vermeden dolabilir. + +Bu durumda sınırları `OCX_PROBE_TIMEOUT_MS` ile yükseltin, örneğin `OCX_PROBE_TIMEOUT_MS=5000 ocx status`. Değer 1 ile 30000 arasında tam sayı milisaniyedir. Geçersiz kılma yalnızca yükseltir: 750 ms varsayılan ve 1500 ms durdurma/başlatma bütçeleri alt sınırlarını korur, bu yüzden `1000` yalnızca varsayılan yoklamayı uzatır. Boş, kesirli, negatif, sıfır veya daha büyük değerler yok sayılır. + ## Çıkış kodları ve onaylama Başarılı komutlar 0 ile çıkar. Geçersiz kullanım, bilinmeyen komutlar veya diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index f1c30392609..b7afadb21be 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -486,6 +486,8 @@ adresindeki [web kontrol panelini](/tr/guides/web-dashboard/) açın; hub'da yö ### `ocx update [--tag latest|preview]` +OpenCodex mise üzerinden kurulduğunda bu komut proxy'yi durdurmadan veya paket dosyalarını değiştirmeden önce başarısız olur ve doğrulanmış yerel mise diğer adını kullanarak `mise upgrade ` komutunu gösterir. Güncelleme denetimi kullanılabilir kalır ve kurulumun harici olarak yönetildiğini bildirir. Okunamayan veya tutarsız mise sahiplik meta verileri de araç adını tahmin etmeden değişikliği reddeder; `--tag preview` mise içinde yapılandırılmış seçimi değiştirmez. + opencodex'i npm'den kendi kendine güncelleyin. Kararlı kurulumlar `@latest` kullanır; önizleme kurulumları `--tag latest|preview` iletmediğiniz sürece `@preview` üzerinde kalır. Bir kaynak kod kopyasını algılar ve bunun yerine `git diff --git a/docs-site/src/content/docs/zh-cn/guides/desktop-app.md b/docs-site/src/content/docs/zh-cn/guides/desktop-app.md index dcb2af1568d..2e94868ce1d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/desktop-app.md +++ b/docs-site/src/content/docs/zh-cn/guides/desktop-app.md @@ -40,7 +40,7 @@ sudo apt install ./OpenCodex--linux-amd64.deb ## 首次启动 -应用会让内置 CLI 运行 `ocx resolve --json`;如果已有可访问的本地 proxy,就连接到它。只有 CLI 证实不存在运行时,应用才会启动内置运行时;结果不确定时会显示启动失败。随后,仪表盘会在应用的 webview 中通过找到的 loopback 端点打开。 +应用会让内置 CLI 运行 `ocx resolve --json`;如果已有可访问的本地 proxy,就连接到它。只有 CLI 证实不存在运行时,应用才会启动内置运行时;结果不确定时会显示启动失败。随后,仪表盘会在应用的 webview 中通过找到的 loopback 端点打开。登录时隐藏在托盘中启动的应用会保留轻量的启动页,直到你第一次从托盘打开或再次启动应用时才加载仪表盘。 使用托盘中的 **Open dashboard** 或 **Open in browser**,可在内嵌仪表盘与常用浏览器之间切换。托盘也提供更新检查。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 530de2d5d26..1b042e81b38 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -23,6 +23,12 @@ opencodex 的 CLI 是 `ocx`。它会根据第一个命令名进行分发;文 在语义明确时,默认操作是 `list` 或 `status`。使用 `--json` 获取结构化快照,使用 `ocx observe logs --follow --jsonl` 获取流式请求日志。主题、语言、导航以及其他纯视觉浏览器状态都没有 CLI 对应项;Cloudflare Tunnel 的设置不在这组命令之内。 +## 存活探测上限覆盖 + +`ocx health`、`ocx status`、`ocx account *`、`ocx login codex` 和 `ocx ready` 通过短时存活探测查找正在运行的代理:默认每次 750 ms,停止和启动判断使用带重试的 1500 ms。如果安全层(内容过滤器或 EDR 类网络扩展)给每个回环连接增加固定延迟,健康的代理可能来不及响应就已超时。 + +在这类主机上可设置 `OCX_PROBE_TIMEOUT_MS` 提高上限,例如 `OCX_PROBE_TIMEOUT_MS=5000 ocx status`。取值为 1 到 30000 的整数毫秒。覆盖只会提高上限:750 ms 默认值和 1500 ms 停止/启动预算保留下限,因此 `1000` 只会延长默认探测。未设置、空值、小数、负数、0 或更大的值都会被忽略。 + ## 退出码与确认 成功的命令退出码为 0。无效用法、未知命令或资源、API 操作失败,以及必需服务不可用时,退出码都非零。`ocx health` 只有在代理健康时才以 0 退出,否则以 1 退出,因此可作为服务探针。脚本应检查退出码,而不是解析人类可读输出。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index b1fdef059a3..a2061e36693 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -273,6 +273,8 @@ ocx codex-shim uninstall ### `ocx update [--tag latest|preview]` +当 OpenCodex 由 mise 安装时,此命令会在停止代理或修改软件包文件之前以失败状态退出,并使用经过验证的本地 mise 别名显示 `mise upgrade `。更新检查仍然可用,并会报告该安装由外部管理。无法读取或不一致的 mise 所有权元数据也会阻止修改,且不会猜测工具名称;`--tag preview` 绝不会更改 mise 中配置的选择。 + 从 npm 自更新 opencodex。稳定版安装使用 `@latest`;预览版安装保持在 `@preview`,除非你传入 `--tag latest|preview`。它会检测源码检出,并提示你改为运行 `git pull && bun install`;如果你已经是该标签的最新版本,则不会执行任何操作。对于 npm 安装,它会在停止任何进程之前,对 Unix 缓存的所有权和访问权限执行有界检查。嵌套符号链接会通过 `lstat` 检查但不会跟随;Windows 会明确跳过这项仅适用于 Unix 的检查。检查失败时,更新会在托盘和代理仍运行的情况下中止。随后才会在替换文件之前停止正在运行的代理;已安装的服务会自动重建并启动,而前台安装则会打印 `ocx start` 作为下一步。持久化前,仪表板更新记录会隐去用户配置文件/缓存路径以及 UID/GID 值。 ```bash diff --git a/docs-site/src/content/docs/zh-tw/guides/desktop-app.md b/docs-site/src/content/docs/zh-tw/guides/desktop-app.md index d1a1670b6b5..111f96f84eb 100644 --- a/docs-site/src/content/docs/zh-tw/guides/desktop-app.md +++ b/docs-site/src/content/docs/zh-tw/guides/desktop-app.md @@ -40,7 +40,7 @@ sudo apt install ./OpenCodex--linux-amd64.deb ## 首次啟動 -應用程式會要求內附的 CLI 執行 `ocx resolve --json`;若既有本機代理可連線,就會附著其上。只有 CLI 證實代理不存在時,才會啟動內附執行環境;結果不確定時會顯示啟動失敗。接著儀表板會在應用程式的 webview 中,以找到的 loopback 端點開啟。 +應用程式會要求內附的 CLI 執行 `ocx resolve --json`;若既有本機代理可連線,就會附著其上。只有 CLI 證實代理不存在時,才會啟動內附執行環境;結果不確定時會顯示啟動失敗。接著儀表板會在應用程式的 webview 中,以找到的 loopback 端點開啟。登入時隱藏在系統匣中啟動的應用程式會保留輕量的啟動頁,直到你第一次從系統匣開啟或再次啟動應用程式時才載入儀表板。 透過系統匣的 **Open dashboard** 或 **Open in browser**,可以在內嵌儀表板與一般瀏覽器間切換。系統匣也提供更新檢查。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli.md b/docs-site/src/content/docs/zh-tw/reference/cli.md index 1919b7e5b0a..0b34df2be26 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli.md @@ -36,6 +36,12 @@ opencodex 的命令列工具是 `ocx`。它依第一個命令名稱分派,有 `ocx observe logs --follow --jsonl` 取得串流的請求 log feed。佈景主題、語言、導覽與 其他純視覺的瀏覽器狀態沒有 CLI 對應;Cloudflare Tunnel 設定不在此命令集內。 +## 存活探測上限覆寫 + +`ocx health`、`ocx status`、`ocx account *`、`ocx login codex` 與 `ocx ready` 透過短時存活探測尋找執行中的代理:預設每次 750 ms,停止與啟動判斷使用含重試的 1500 ms。若安全層(內容過濾器或 EDR 類網路擴充)替每個回送連線增加固定延遲,健康的代理可能來不及回應就已逾時。 + +在這類主機上可設定 `OCX_PROBE_TIMEOUT_MS` 提高上限,例如 `OCX_PROBE_TIMEOUT_MS=5000 ocx status`。值為 1 到 30000 的整數毫秒。覆寫只會提高上限:750 ms 預設值與 1500 ms 停止/啟動預算保留下限,因此 `1000` 只會延長預設探測。未設定、空值、小數、負數、0 或更大的值都會被忽略。 + ## 離開碼與確認 成功的命令離開 0。無效用法、未知命令或資源、失敗的 API 操作以及無法使用的必要服務 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 4317ef37b30..957a9f0ec40 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -258,6 +258,8 @@ ocx codex-shim uninstall ### `ocx update [--tag latest|preview]` +當 OpenCodex 由 mise 安裝時,此命令會在停止代理或修改套件檔案之前以失敗狀態結束,並使用經過驗證的本機 mise 別名顯示 `mise upgrade `。更新檢查仍可使用,並會回報該安裝由外部管理。無法讀取或不一致的 mise 擁有權中繼資料也會阻止修改,且不會猜測工具名稱;`--tag preview` 絕不會變更 mise 中設定的選擇。 + 從 npm 自我更新 opencodex。穩定安裝使用 `@latest`;預覽安裝停留在 `@preview`,除非你傳入 `--tag latest|preview`。它偵測原始碼 checkout 並告訴你改用 `git pull && bun install`,且若你已是該 tag 的最新版本則為 no-op。執行中的代理會在檔案被替換前停止;已安裝的服務會自動重建並啟動,而前景安裝會印出 `ocx start` 作為下一步。 diff --git a/gui/src/components/sidebar-github-row.tsx b/gui/src/components/sidebar-github-row.tsx index 350cf0cb43d..e6e02c239d8 100644 --- a/gui/src/components/sidebar-github-row.tsx +++ b/gui/src/components/sidebar-github-row.tsx @@ -28,6 +28,7 @@ interface StarStatus { interface UpdateBadge { updateAvailable?: boolean; latestVersion?: string | null; + installer?: "bun" | "mise" | "npm" | "pnpm" | "source"; /** True when no cached registry answer exists, so "no update" is unproven. */ unknown?: boolean; } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 18c5c951ca9..85a992dabef 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -435,6 +435,8 @@ export const de: Record = { "dash.updateReason.source_checkout": "Quellcode-Checkout", "dash.updateReason.latest_unavailable": "npm-Registry nicht erreichbar", "dash.updateReason.already_latest": "bereits auf dem neuesten Stand", + "dash.updateReason.externally_managed": "extern von mise verwaltet; führe den angezeigten Befehl aus", + "dash.updateReason.external_ownership_invalid": "mise-Eigentümerdaten sind nicht lesbar oder widersprüchlich", "dash.updateReason.unknown": "Update nicht verfügbar", "dash.updateRestart": "Nach Update neu starten", "dash.updateRestartHint": "Empfohlen. Die aktuelle GUI läuft weiter mit altem Code, bis der Proxy neu startet.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6621a81428c..d48d0ed10bb 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -456,6 +456,8 @@ export const en = { "dash.updateReason.source_checkout": "source checkout", "dash.updateReason.latest_unavailable": "npm registry unreachable", "dash.updateReason.already_latest": "already on latest", + "dash.updateReason.externally_managed": "managed externally by mise; run the shown command", + "dash.updateReason.external_ownership_invalid": "mise ownership metadata is unreadable or inconsistent", "dash.updateReason.unknown": "update unavailable", "dash.updateRestart": "Restart after update", "dash.updateRestartHint": "Recommended. The current GUI keeps running the old code until the proxy restarts.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 658c8cddf70..659ea434de2 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -446,6 +446,8 @@ export const fr: Record = { "dash.updateReason.source_checkout": "extraction du code source", "dash.updateReason.latest_unavailable": "registre npm inaccessible", "dash.updateReason.already_latest": "dernière version déjà installée", + "dash.updateReason.externally_managed": "géré par mise ; exécutez la commande affichée", + "dash.updateReason.external_ownership_invalid": "les métadonnées de propriété mise sont illisibles ou incohérentes", "dash.updateReason.unknown": "mise à jour indisponible", "dash.updateRestart": "Redémarrer après la mise à jour", "dash.updateRestartHint": "Recommandé. L’interface graphique actuelle continue d’exécuter l’ancien code jusqu’au redémarrage du proxy.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 7a60eda1f86..2136f3322e1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -444,6 +444,8 @@ export const ja: Record = { "dash.updateReason.source_checkout": "ソースチェックアウト", "dash.updateReason.latest_unavailable": "npm レジストリに到達できません", "dash.updateReason.already_latest": "最新です", + "dash.updateReason.externally_managed": "mise によって外部管理されています。表示されたコマンドを実行してください", + "dash.updateReason.external_ownership_invalid": "mise の所有権メタデータを読み取れないか、整合していません", "dash.updateReason.unknown": "更新は利用できません", "dash.updateRestart": "更新後に再起動", "dash.updateRestartHint": "推奨。プロキシが再起動されるまで現在の GUI は古いコードを実行し続けます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5d13494c1af..b7a459d52de 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -442,6 +442,8 @@ export const ko: Record = { "dash.updateReason.source_checkout": "소스 체크아웃", "dash.updateReason.latest_unavailable": "npm 레지스트리에 연결할 수 없음", "dash.updateReason.already_latest": "이미 최신 버전", + "dash.updateReason.externally_managed": "mise에서 외부 관리 중입니다. 표시된 명령을 실행하세요", + "dash.updateReason.external_ownership_invalid": "mise 소유권 메타데이터를 읽을 수 없거나 일관되지 않습니다", "dash.updateReason.unknown": "업데이트 불가", "dash.updateRestart": "업데이트 후 재시작", "dash.updateRestartHint": "권장. 프록시를 재시작하기 전까지 현재 GUI는 이전 코드로 계속 실행됩니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 295ac60c796..93965606aa7 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -444,6 +444,8 @@ export const ru: Record = { "dash.updateReason.source_checkout": "установка из исходного кода", "dash.updateReason.latest_unavailable": "реестр npm недоступен", "dash.updateReason.already_latest": "уже установлена последняя версия", + "dash.updateReason.externally_managed": "управляется mise; выполните показанную команду", + "dash.updateReason.external_ownership_invalid": "метаданные владельца mise недоступны или противоречивы", "dash.updateReason.unknown": "обновление недоступно", "dash.updateRestart": "Перезапустить после обновления", "dash.updateRestartHint": "Рекомендуется. Текущий GUI продолжает работать на старом коде, пока прокси не перезапустится.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 0ce90f09561..1856735a5bc 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -448,6 +448,8 @@ export const tr: Record = { "dash.updateReason.source_checkout": "kaynak kod kopyası", "dash.updateReason.latest_unavailable": "npm sunucusuna ulaşılamıyor", "dash.updateReason.already_latest": "zaten en son sürümde", + "dash.updateReason.externally_managed": "mise tarafından harici olarak yönetiliyor; gösterilen komutu çalıştırın", + "dash.updateReason.external_ownership_invalid": "mise sahiplik meta verileri okunamıyor veya tutarsız", "dash.updateReason.unknown": "güncelleme kullanılamıyor", "dash.updateRestart": "Güncellemeden sonra yeniden başlat", "dash.updateRestartHint": "Önerilir. Proxy yeniden başlayana kadar mevcut GUI eski kodu çalıştırmaya devam eder.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 026d9e97d63..a1b8502f152 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -446,6 +446,8 @@ export const vi: Record = { "dash.updateReason.source_checkout": "checkout mã nguồn", "dash.updateReason.latest_unavailable": "không thể kết nối với registry npm", "dash.updateReason.already_latest": "đã ở phiên bản mới nhất", + "dash.updateReason.externally_managed": "được mise quản lý bên ngoài; hãy chạy lệnh được hiển thị", + "dash.updateReason.external_ownership_invalid": "siêu dữ liệu quyền sở hữu của mise không đọc được hoặc không nhất quán", "dash.updateReason.unknown": "cập nhật không khả dụng", "dash.updateRestart": "Khởi động lại sau khi cập nhật", "dash.updateRestartHint": "Khuyên dùng. GUI hiện tại vẫn tiếp tục chạy mã nguồn cũ cho đến khi proxy khởi động lại.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index a4df6f942d3..eba5ce1b2ea 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -334,6 +334,8 @@ export const zhTW: Record = { "dash.updateReason.source_checkout": "原始碼檢出", "dash.updateReason.latest_unavailable": "無法連線 npm 登入檔", "dash.updateReason.already_latest": "已是最新版本", + "dash.updateReason.externally_managed": "由 mise 外部管理;請執行顯示的命令", + "dash.updateReason.external_ownership_invalid": "mise 擁有權中繼資料無法讀取或不一致", "dash.updateReason.unknown": "無法更新", "dash.updateRestart": "更新後重新啟動", "dash.updateRestartHint": "推薦開啟。代理重新啟動前,當前 GUI 仍執行舊程式碼。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 06f31de2362..fe9424b8151 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -439,6 +439,8 @@ export const zh: Record = { "dash.updateReason.source_checkout": "源码检出", "dash.updateReason.latest_unavailable": "无法连接 npm 注册表", "dash.updateReason.already_latest": "已是最新版本", + "dash.updateReason.externally_managed": "由 mise 外部管理;请运行显示的命令", + "dash.updateReason.external_ownership_invalid": "mise 所有权元数据无法读取或不一致", "dash.updateReason.unknown": "无法更新", "dash.updateRestart": "更新后重启", "dash.updateRestartHint": "推荐开启。代理重启前,当前 GUI 仍运行旧代码。", diff --git a/gui/src/pages/dashboard-dialogs.tsx b/gui/src/pages/dashboard-dialogs.tsx index 4b96651d0fa..f0f10050680 100644 --- a/gui/src/pages/dashboard-dialogs.tsx +++ b/gui/src/pages/dashboard-dialogs.tsx @@ -72,7 +72,9 @@ export function DashboardDialogs(d: Dash) { {updateCheck.updateAvailable ? t("dash.updateAvailable") : t("dash.updateCurrent")} -
{t("dash.updateCommand")} {updateCheck.command}
+ {updateCheck.command && ( +
{t("dash.updateCommand")} {updateCheck.command}
+ )} {updateCheck.reason === "source_checkout" && (
{t("dash.updateSource")}
)} diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index ae24f861a2e..d96ec065cdd 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -125,7 +125,7 @@ export interface SidecarPatch { export interface ShadowCallData { enabled: boolean; model: string; sourceModels?: string[] } export type UsageSummary30d = import("../usage-summary-resource").UsageReadMetadata & { summary: { requests: number; totalTokens: number; coverageRatio: number } }; export type UpdateChannel = "latest" | "preview"; -export type Installer = "npm" | "bun" | "source"; +export type Installer = "bun" | "mise" | "npm" | "pnpm" | "source"; export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed"; export interface SyncResult { ok: boolean; @@ -189,6 +189,8 @@ export function updateReasonLabel(reason: string | undefined, t: (key: TKey) => case "source_checkout": return t("dash.updateReason.source_checkout"); case "latest_unavailable": return t("dash.updateReason.latest_unavailable"); case "already_latest": return t("dash.updateReason.already_latest"); + case "externally_managed": return t("dash.updateReason.externally_managed"); + case "external_ownership_invalid": return t("dash.updateReason.external_ownership_invalid"); default: return t("dash.updateReason.unknown"); } } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f920e6149af..a4f1697d2db 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -651,6 +651,9 @@ "config-user-edits.test.ts": "config", "config.test.ts": "server", "configured-native-models.test.ts": "codex-integration", + "gui-desktop-sidecar-signing.test.ts": "gui", + "linux-desktop-packaged-ci.test.ts": "ci-workflows", + "probe-timeout-env.test.ts": "server", "subagent-roster-migration.test.ts": "routing", "consume-for-inspection-cancel.test.ts": "server", "container-bootstrap.test.ts": "service", @@ -999,6 +1002,7 @@ "legacy-shell-compat.test.ts": "responses", "live-call-bindings.test.ts": "server", "live-service-manager-guard.test.ts": "service", + "linux-desktop-packaged-e2e.test.ts": "ci-workflows", "local-aside-sync-capability.test.ts": "server", "local-destinations.test.ts": "lib", "local-management-attestation.test.ts": "server", @@ -1621,6 +1625,7 @@ "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", + "update-mise.test.ts": "update", "update-pnpm.test.ts": "update", "update-stop-classification.test.ts": "update", "update-stop-first.test.ts": "update", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 5682c76ac71..c8cf409f332 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -834,14 +834,14 @@ Restart the Codex desktop app and app-servers. | Flag | Value | Meaning | |---|---|---| -| `--yes` | boolean | Required: fully quits and relaunches the operator's Codex desktop app and restarts its app-servers. | +| `--yes` | boolean | Required: fully quits and relaunches the operator's Codex desktop app, which may discard unsaved composer drafts, model-picker selections, and pending approval prompts; also restarts its app-servers. | | `--json` | boolean | Emit the restart result as JSON. | JSON mode: `payload`. - `sync --restart-codex` is not a substitute: it restarts only as a side effect after a catalog or cache write, so it cannot restart a healthy install on request. - Restarts the Codex desktop app as well as the app-servers, through the same module the CLI uses. When the proxy itself runs inside the Codex app it refuses instead, because restarting the app would kill the request. -- --yes is mandatory because this interrupts a running editor session, which must never happen because an agent guessed a subcommand. +- --yes is mandatory because this interrupts a running editor session and may discard unsaved composer drafts, model-picker selections, and pending approval prompts; it must never happen because an agent guessed a subcommand. ### `ocx integration native` diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index cb117bcba05..bb153d987c2 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -784,7 +784,7 @@ export const CAPABILITIES: readonly Capability[] = [ summary: "Restart the Codex desktop app and app-servers.", routes: [{ method: "POST", path: "/api/system/codex-restart" }], flags: [ - { name: "--yes", value: "boolean", summary: "Required: fully quits and relaunches the operator's Codex desktop app and restarts its app-servers." }, + { name: "--yes", value: "boolean", summary: "Required: fully quits and relaunches the operator's Codex desktop app, which may discard unsaved composer drafts, model-picker selections, and pending approval prompts; also restarts its app-servers." }, { name: "--json", value: "boolean", summary: "Emit the restart result as JSON." }, ], mutates: true, @@ -792,7 +792,7 @@ export const CAPABILITIES: readonly Capability[] = [ details: [ "`sync --restart-codex` is not a substitute: it restarts only as a side effect after a catalog or cache write, so it cannot restart a healthy install on request.", "Restarts the Codex desktop app as well as the app-servers, through the same module the CLI uses. When the proxy itself runs inside the Codex app it refuses instead, because restarting the app would kill the request.", - "--yes is mandatory because this interrupts a running editor session, which must never happen because an agent guessed a subcommand.", + "--yes is mandatory because this interrupts a running editor session and may discard unsaved composer drafts, model-picker selections, and pending approval prompts; it must never happen because an agent guessed a subcommand.", ], }, { diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 846a8c921e6..1ae294c82ad 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -485,7 +485,8 @@ export async function ensureProxyForClaude(deps: ClaudeProxyEnsureDeps = {}): Pr // A proxy that has only just bound can miss a single probe while its event loop // is still settling startup work — the same just-started race the stop paths // already retry for (#764, SERVICE_STOP_LIVENESS). Only the attempts budget is - // borrowed here; the probe timeout remains DEFAULT_PROBE_TIMEOUT_MS (750 ms). + // borrowed here; the probe timeout remains DEFAULT_PROBE_TIMEOUT_MS (750 ms unless + // OCX_PROBE_TIMEOUT_MS raises it). // Without this, `ocx claude` can spawn a second proxy while the first is serving. const live = await (deps.findLiveProxy ?? findLiveProxy)({ attempts: 3 }); if (live) return live.port; diff --git a/src/cli/ready.ts b/src/cli/ready.ts index de918600f41..876344ac839 100644 --- a/src/cli/ready.ts +++ b/src/cli/ready.ts @@ -199,7 +199,7 @@ export async function runReady(args: ReadyArgs, io: ReadyIo = {}): Promise PackageTreeObservation | null; -export type PackageTreeRuntimeInstall = "bun" | "npm" | "pnpm" | "source"; +export type PackageTreeRuntimeInstall = "bun" | "mise" | "npm" | "pnpm" | "source"; const packageManifestUrl = new URL("../../package.json", import.meta.url); diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index 5e004c52aa5..21dd8bb021a 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -84,12 +84,47 @@ export interface LivenessIo { createChallengeFn?: () => string; } +/** + * Operator override for the per-probe fetch ceilings below (`OCX_PROBE_TIMEOUT_MS`), + * integer milliseconds in [1, MAX_PROBE_TIMEOUT_MS]. + * + * Some hosts put a security layer (content filter, EDR network extension) in front of + * loopback TCP that adds a fixed per-connect cost, measured at about one second on an + * affected macOS machine. The shipped 750 ms probe then aborts before a healthy proxy can + * answer, and every CLI liveness consumer reports the proxy as down while a direct + * `curl /healthz` succeeds. + * + * The override only raises: each ceiling keeps its shipped floor (750 ms for the shared + * default, 1500 ms for the stop/start ownership budgets that guard against a duplicate + * proxy, #764, #5004), so a small value can never shorten them. Values above the 30 s + * ceiling are ignored rather than clamped: a stop multiplies its budget by the attempt + * count, and a typo must not turn a stop into a wait of minutes or days. Parsed once at + * module load; anything malformed falls back to the defaults and never breaks startup. + */ +export const MAX_PROBE_TIMEOUT_MS = 30_000; +const SHARED_PROBE_FLOOR_MS = 750; +const OWNERSHIP_PROBE_FLOOR_MS = 1500; + +export function parseProbeTimeoutOverrideMs(raw: string | undefined): number | undefined { + const trimmed = raw?.trim(); + if (!trimmed || !/^\d+$/.test(trimmed)) return undefined; + const n = Number(trimmed); + return n > 0 && n <= MAX_PROBE_TIMEOUT_MS ? n : undefined; +} + +/** The ceiling for a probe whose shipped value is `floorMs`, raised by a valid override only. */ +export function probeCeilingMs(floorMs: number, override: number | undefined): number { + return Math.max(floorMs, override ?? 0); +} + +const probeTimeoutOverrideMs = parseProbeTimeoutOverrideMs(process.env.OCX_PROBE_TIMEOUT_MS); + /** Default per-probe fetch ceiling shared by liveness and readiness probes. */ -export const DEFAULT_PROBE_TIMEOUT_MS = 750; +export const DEFAULT_PROBE_TIMEOUT_MS = probeCeilingMs(SHARED_PROBE_FLOOR_MS, probeTimeoutOverrideMs); /** Default probe options for service stop / orphan cleanup — a just-bound proxy can miss a single 750ms probe. */ export const SERVICE_STOP_LIVENESS: Pick = { - timeoutMs: 1500, + timeoutMs: probeCeilingMs(OWNERSHIP_PROBE_FLOOR_MS, probeTimeoutOverrideMs), attempts: 3, }; @@ -105,7 +140,7 @@ export const SERVICE_STOP_LIVENESS: Pick = * the stop path already uses for the mirror-image decision. */ export const START_OWNERSHIP_LIVENESS: Pick = { - timeoutMs: 1500, + timeoutMs: probeCeilingMs(OWNERSHIP_PROBE_FLOOR_MS, probeTimeoutOverrideMs), attempts: 3, }; diff --git a/src/update/badge.ts b/src/update/badge.ts index 0c373c1b715..fabfe746141 100644 --- a/src/update/badge.ts +++ b/src/update/badge.ts @@ -22,6 +22,7 @@ export interface UpdateBadge { currentVersion: string; latestVersion: string | null; channel: Channel; + installer: ReturnType; /** False for source checkouts, where the GUI cannot offer a one-click update. */ canUpdate: boolean; /** True when no cached registry answer exists yet, so "no update" is unproven. */ @@ -53,7 +54,8 @@ export function readUpdateBadge(deps: UpdateBadgeDeps = defaultDeps): UpdateBadg currentVersion: current, latestVersion: null, channel, - canUpdate: installer !== "source", + installer, + canUpdate: installer !== "source" && installer !== "mise", unknown: true, }; // A source checkout has nothing to compare against, so "unknown" is not useful there. diff --git a/src/update/check-types.ts b/src/update/check-types.ts new file mode 100644 index 00000000000..bbf5ebdbd28 --- /dev/null +++ b/src/update/check-types.ts @@ -0,0 +1,9 @@ +import type { Channel, Installer, InstallOwnership } from "./index"; + +export interface UpdateCheckDeps { + currentVersion: () => string; + detectInstall: () => Installer; + detectInstallOwnership?: () => InstallOwnership; + latestVersion: (tag: Channel) => string | null; + miseUpdateCommand?: (ownership: InstallOwnership) => string | null; +} diff --git a/src/update/index.ts b/src/update/index.ts index 26acd3e92cf..84aa404b757 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -15,7 +15,15 @@ import { unprivilegedOwnershipMutationEnvironment } from "../service/ownership-m import { withUpdateOwnershipLease, readUpdateRuntimeTarget } from "./ownership-transaction"; import { npmInvocation } from "./npm-invocation.mjs"; import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs"; -import { detectInstallFromPath } from "./install-detection.mjs"; +import { + detectInstallFromPath, + detectInstallOwnershipFromPath, +} from "./install-detection.mjs"; +import type { + DetectedInstall, + InstallOwnership, + MiseInstallOwner, +} from "./install-detection.d.mts"; import { pnpmOwnerInvocation, readPnpmGlobalPackage, @@ -50,14 +58,28 @@ export function historyRestoreIncomplete(configDir = getConfigDir()): boolean { export const PKG = "@bitkyc08/opencodex"; const HERE = dirname(fileURLToPath(import.meta.url)); // .../opencodex/src/update -export type Installer = "bun" | "npm" | "pnpm" | "source"; +export type Installer = DetectedInstall; export type Channel = "latest" | "preview"; +export type { InstallOwnership, MiseInstallOwner }; /** Infer how opencodex is installed from the running module's path. */ export function detectInstall(): Installer { return detectInstallFromPath(HERE, { exists: existsSync }); } +/** Resolve installer ownership and verified mise update guidance for this package. */ +export function detectInstallOwnership(): InstallOwnership { + return detectInstallOwnershipFromPath(HERE, { exists: existsSync }); +} + +export function miseUpdateCommand( + ownership: InstallOwnership = detectInstallOwnership(), +): string | null { + return ownership.installer === "mise" && ownership.owner + ? `mise upgrade ${ownership.owner.tool}` + : null; +} + function packageRoot(): string { return resolve(HERE, "..", ".."); } @@ -269,6 +291,9 @@ export function latestVersion( /** The global-install command opencodex would run to update on this channel. */ export function updateCommand(installer: Installer, tag: Channel, resolvedVersion?: string | null): { bin: string; args: string[] } { + if (installer === "mise") { + throw new Error("mise-owned installations must be upgraded through mise"); + } // Immutable target: when the registry resolved a concrete version, install exactly // that version — the dist-tag can move between resolution and install (TOCTOU). const target = resolvedVersion || tag; @@ -361,11 +386,25 @@ async function resolvedRuntimeOwnership(): Promise * Bun binary. */ export async function runUpdate(): Promise { - const installer = detectInstall(); + const ownership = detectInstallOwnership(); + const installer = ownership.installer; const current = currentVersion(); const tag = updateTag(current); console.log(`opencodex v${current} (installed via ${installer}, tag ${tag})`); + if (installer === "mise") { + const command = miseUpdateCommand(ownership); + if (command) { + console.error(`OpenCodex is externally managed by mise. Update it with: ${command}`); + } else { + console.error( + "OpenCodex appears to be managed by mise, but its ownership metadata is unreadable or inconsistent. Repair the mise installation metadata before updating.", + ); + } + process.exitCode = 1; + return; + } + if (installer === "source") { console.log("Running from a source checkout — update with: git pull && bun install"); return; diff --git a/src/update/install-detection.d.mts b/src/update/install-detection.d.mts index 88f68ba7318..7ede7997c27 100644 --- a/src/update/install-detection.d.mts +++ b/src/update/install-detection.d.mts @@ -1,6 +1,33 @@ -export type DetectedInstall = "bun" | "npm" | "pnpm" | "source"; +export type DetectedInstall = "bun" | "mise" | "npm" | "pnpm" | "source"; + +export interface MiseInstallOwner { + tool: string; + backend: string; + installPath: string; + toolRoot: string; +} + +export type InstallOwnership = + | { installer: Exclude } + | { + installer: "mise"; + owner: MiseInstallOwner | null; + error?: "metadata_unreadable" | "metadata_inconsistent"; + }; + +export interface InstallDetectionDeps { + exists?: (path: string) => boolean; + probe?: (path: string) => "present" | "absent" | "unreadable"; + readFile?: (path: string) => string; + realpath?: (path: string) => string; +} export declare function detectInstallFromPath( packagePath: string, - deps?: { exists?: (path: string) => boolean; realpath?: (path: string) => string }, + deps?: InstallDetectionDeps, ): DetectedInstall; + +export declare function detectInstallOwnershipFromPath( + packagePath: string, + deps?: InstallDetectionDeps, +): InstallOwnership; diff --git a/src/update/install-detection.mjs b/src/update/install-detection.mjs index e21064c9b54..6b1c3ac531d 100644 --- a/src/update/install-detection.mjs +++ b/src/update/install-detection.mjs @@ -1,4 +1,26 @@ -import { realpathSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; + +const OPENCODEX_MISE_BACKEND = "npm:@bitkyc08/opencodex"; +const OPENCODEX_MISE_BACKEND_DIR = "npm-bitkyc08-opencodex"; + +/** + * @typedef {{ + * tool: string; + * backend: string; + * installPath: string; + * toolRoot: string; + * }} MiseInstallOwner + */ + +/** + * @typedef {{ + * installer: "bun" | "npm" | "pnpm" | "source"; + * } | { + * installer: "mise"; + * owner: MiseInstallOwner | null; + * error?: "metadata_unreadable" | "metadata_inconsistent"; + * }} InstallOwnership + */ /** * Infer the package manager from the path of the running package. @@ -14,7 +36,30 @@ import { realpathSync } from "node:fs"; * virtual store. */ export function detectInstallFromPath(packagePath, deps = {}) { - const exists = deps.exists; + return detectInstallOwnershipFromPath(packagePath, deps).installer; +} + +/** + * Infer the outer owner of the running package. + * + * mise's npm backend deliberately contains an ordinary npm/aube installation, so + * package-manager layout alone reports npm. The adjacent backend record is the + * stronger ownership signal: it identifies the mise alias and canonical backend, + * while containment proves that the running package belongs to that installation. + * + * @param {string} packagePath + * @param {{ + * exists?: (path: string) => boolean; + * probe?: (path: string) => "present" | "absent" | "unreadable"; + * readFile?: (path: string) => string; + * realpath?: (path: string) => string; + * }} deps + * @returns {InstallOwnership} + */ +export function detectInstallOwnershipFromPath(packagePath, deps = {}) { + const exists = deps.exists ?? existsSync; + const probe = deps.probe ?? probeMetadata; + const readFile = deps.readFile ?? (path => readFileSync(path, "utf8")); const candidates = [String(packagePath)]; try { const resolved = (deps.realpath ?? realpathSync)(String(packagePath)); @@ -24,17 +69,151 @@ export function detectInstallFromPath(packagePath, deps = {}) { // realpath. The lexical path still carries the evidence when it is available. } - let sawNodeModules = false; + let detectedManager = "source"; + /** @type {MiseInstallOwner[]} */ + const miseOwners = []; + /** @type {"metadata_unreadable" | "metadata_inconsistent" | undefined} */ + let miseError; for (const candidate of candidates) { + const mise = detectMiseOwner(candidate, { probe, readFile }); + if (mise.recognized) { + if (mise.owner) miseOwners.push(mise.owner); + else miseError = mise.error; + } const detected = detectInstallCandidate(candidate, exists); - if (detected === "pnpm" || detected === "bun") return detected; - if (detected === "npm") sawNodeModules = true; + if (detected === "pnpm" || detected === "bun") detectedManager = detected; + else if (detected === "npm" && detectedManager === "source") detectedManager = "npm"; } - return sawNodeModules ? "npm" : "source"; + // Any recognized ownership error on either spelling takes precedence over every verified + // owner. Keeping a command from the other candidate could authorize mutation across a + // lexical/resolved-path mismatch, so fail closed without recovery guidance. + if (miseError) return { installer: "mise", owner: null, error: miseError }; + const miseOwner = miseOwners.at(-1); + if (miseOwner) { + // The lexical and resolved spellings of one install differ when an ancestor such as the + // mise data directory is a symlink (macOS /var -> /private/var). Both still name the same + // physical tool directory and the same backend file, so compare canonical directories; + // any other disagreement stays inconsistent. + const realpath = deps.realpath ?? realpathSync; + const consistent = miseOwners.every(owner => + owner.tool === miseOwner.tool + && owner.backend === miseOwner.backend + && sameDirectory(owner.toolRoot, miseOwner.toolRoot, realpath) + ); + return consistent + ? { installer: "mise", owner: miseOwner } + : { installer: "mise", owner: null, error: "metadata_inconsistent" }; + } + return { installer: detectedManager }; +} + +function parseBackendMetadata(content) { + const fields = new Map(); + for (const line of String(content).split(/\r?\n/)) { + const match = /^\s*(short|full)\s*=\s*("(?:[^"\\]|\\.)*"|'[^']*')\s*(?:#.*)?$/.exec(line); + if (!match) continue; + if (fields.has(match[1])) return null; + try { + fields.set( + match[1], + match[2].startsWith('"') ? JSON.parse(match[2]) : match[2].slice(1, -1), + ); + } catch { + return null; + } + } + const tool = fields.get("short"); + const backend = fields.get("full"); + return typeof tool === "string" && typeof backend === "string" + ? { tool, backend } + : null; +} + +function detectMiseOwner(packagePath, deps) { + const windowsPath = /^[A-Za-z]:[\\/]/.test(String(packagePath)) + || String(packagePath).startsWith("\\\\"); + const normalized = (windowsPath ? String(packagePath).replaceAll("\\", "/") : String(packagePath)) + .replace(/\/+$/, ""); + const lower = normalized.toLowerCase(); + let marker = -1; + let installPath; + let toolRoot; + let metadataPath; + while ((marker = lower.indexOf("/node_modules/", marker + 1)) >= 1) { + installPath = normalized.slice(0, marker); + const slash = installPath.lastIndexOf("/"); + if (slash < 1) continue; + toolRoot = installPath.slice(0, slash); + metadataPath = `${toolRoot}/.mise.backend.toml`; + const metadataState = deps.probe(metadataPath); + if (metadataState === "present") break; + if (metadataState === "unreadable") { + return { recognized: true, owner: null, error: "metadata_unreadable" }; + } + metadataPath = undefined; + } + if (!metadataPath || !installPath || !toolRoot) return { recognized: false }; + + let metadata; + try { + metadata = parseBackendMetadata(deps.readFile(metadataPath)); + } catch { + return { recognized: true, owner: null, error: "metadata_unreadable" }; + } + const toolDir = toolRoot.slice(toolRoot.lastIndexOf("/") + 1); + const expectedToolDir = metadata?.tool === OPENCODEX_MISE_BACKEND + ? OPENCODEX_MISE_BACKEND_DIR + : metadata?.tool; + if ( + !metadata + || metadata.backend !== OPENCODEX_MISE_BACKEND + || (metadata.tool !== OPENCODEX_MISE_BACKEND + && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(metadata.tool)) + || !samePath(expectedToolDir, toolDir, windowsPath) + ) { + return { recognized: true, owner: null, error: "metadata_inconsistent" }; + } + return { + recognized: true, + owner: { + tool: metadata.tool, + backend: metadata.backend, + installPath, + toolRoot, + }, + }; +} + +function probeMetadata(path) { + try { + statSync(path); + return "present"; + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? error.code + : undefined; + return code === "ENOENT" || code === "ENOTDIR" ? "absent" : "unreadable"; + } +} + +function sameDirectory(left, right, realpath) { + if (samePath(left, right)) return true; + try { + return samePath(realpath(left), realpath(right)); + } catch { + return false; + } +} + +function samePath(left, right, windows = /^[A-Za-z]:\//.test(left) && /^[A-Za-z]:\//.test(right)) { + return windows ? left.toLowerCase() === right.toLowerCase() : left === right; } function detectInstallCandidate(packagePath, exists) { - const normalized = String(packagePath).replaceAll("\\", "/"); + const path = String(packagePath); + const normalized = /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\") + ? path.replaceAll("\\", "/") + : path; const segments = normalized.split("/").filter(Boolean); // Windows paths are case-insensitive. Treating the structural marker this way also // keeps a preserved-symlink path from being downgraded merely because its casing came diff --git a/src/update/job.ts b/src/update/job.ts index 03765e3f8e0..565a593400e 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -34,19 +34,22 @@ import { import { isServiceInstalled, isServiceViable, readServiceBackend, stopWindows } from "../service"; import { runUpdateRestartWithOwnershipLease, type ServiceOwnershipResolution } from "./restart-ownership"; import { - type Channel, - type Installer, + type Channel, type Installer, PKG, checkUpdatePackageIntegrity, currentVersion, defaultUpdateTag, detectInstall, + detectInstallOwnership, latestVersion, + miseUpdateCommand, updateCommand, updateCommandStr, resolveCurrentPnpmGlobalOwner, resolvePnpmActiveLauncher, } from "./index"; +import type { UpdateCheckDeps } from "./check-types"; +export type { UpdateCheckDeps } from "./check-types"; import type { PnpmGlobalOwner } from "./pnpm-global-install.mjs"; import { isNewer } from "./notify"; import { isRealBunBinary } from "../lib/bun-binary-validator.mjs"; @@ -114,12 +117,6 @@ export class UpdateJobError extends Error { } } -export interface UpdateCheckDeps { - currentVersion: () => string; - detectInstall: () => Installer; - latestVersion: (tag: Channel) => string | null; -} - interface UpdateWorkerProcess { pid?: number; unref(): void; @@ -136,7 +133,9 @@ export interface StartUpdateJobDeps { const defaultCheckDeps: UpdateCheckDeps = { currentVersion, detectInstall, + detectInstallOwnership, latestVersion, + miseUpdateCommand, }; function nodeBin(): string { @@ -489,16 +488,22 @@ export function checkForUpdate( deps: UpdateCheckDeps = defaultCheckDeps, ): UpdateCheckResult { const current = deps.currentVersion(); - const installer = deps.detectInstall(); + const ownership = deps.detectInstallOwnership?.(); + const installer = ownership?.installer ?? deps.detectInstall(); const channel = requestedChannel ?? normalizeUpdateChannel(null, current); const latest = installer === "source" ? null : deps.latestVersion(channel); const updateAvailable = !!latest && isNewer(latest, current, channel); let reason: string | undefined; - let command = installer === "source" ? manualSourceCommand() : updateExecutionCommand(installer, channel).display; + let command = installer === "source" + ? manualSourceCommand() + : installer === "mise" + ? (ownership && deps.miseUpdateCommand?.(ownership)) ?? "" + : updateExecutionCommand(installer, channel).display; if (installer === "source") { reason = "source_checkout"; - command = manualSourceCommand(); + } else if (installer === "mise") { + reason = command ? "externally_managed" : "external_ownership_invalid"; } else if (!latest) { reason = "latest_unavailable"; } else if (!updateAvailable) { @@ -511,7 +516,7 @@ export function checkForUpdate( channel, installer, updateAvailable, - canUpdate: installer !== "source" && updateAvailable, + canUpdate: installer !== "source" && installer !== "mise" && updateAvailable, command, releaseNotesUrl: RELEASE_NOTES_URL, ...(reason ? { reason } : {}), diff --git a/src/update/notify.ts b/src/update/notify.ts index 5764af3992c..9331440aa4d 100644 --- a/src/update/notify.ts +++ b/src/update/notify.ts @@ -139,7 +139,8 @@ export function interactiveGuardOk(): boolean { * the one-time star prompt has already run (first-run yield, O1). */ export function shouldConsider(): { channel: Channel; current: string } | null { - if (detectInstall() === "source") return null; + const installer = detectInstall(); + if (installer === "source" || installer === "mise") return null; const current = currentVersion(); if (current === "?" || isSourceBuildVersion(current)) return null; if (!interactiveGuardOk()) return null; diff --git a/structure/decisions/ADR-5493-linux-packaged-shell-acceptance.md b/structure/decisions/ADR-5493-linux-packaged-shell-acceptance.md new file mode 100644 index 00000000000..9c512cae17b --- /dev/null +++ b/structure/decisions/ADR-5493-linux-packaged-shell-acceptance.md @@ -0,0 +1,12 @@ +# ADR-5493 — decision recorded under "Linux packaged-shell acceptance" + +- Contract owner: [desktop-shell.md](../desktop-shell.md#linux-packaged-shell-acceptance) + +## Decision record + +- 목적과 의도: Make ordinary Linux pull requests prove the package users launch instead of proving only that the Rust shell compiles. +- 기존 구현 및 제약 조건: The Rust-only job used empty sidecar and resource stubs, while the real-install gate needs published releases, operator GUI hooks, and a protected self-hosted runner. Linux keeps Bun as Tauri's external binary through a byte-identity-checked patchelf wrapper, and sequential Linux formats must not share Tauri's patched release binary. +- 검토한 주요 대안: Install deb packages directly on hosted runners; require the privileged installed-artifact gate for every pull request; replace Linux externalBin with a separate resource launcher; or extract both package payloads and exercise their shared runtime path with format-local build roots. +- 선택한 방식: Preserve the existing verified externalBin packaging, build AppImage and deb under independent Cargo targets, stage both outputs read-only, and run the extracted payloads under isolated homes, a loopback port held until spawn, Xvfb, Openbox, and D-Bus. +- 다른 대안 대신 이 방식을 선택한 이유: The selected path covers bundle layout, WebKit startup, the real bundled sidecar, no-tray behavior, and coordinated exit without replacing the already-landed sidecar-integrity boundary, changing the hosted runner's package database, or granting workflow write permissions. +- 장점, 단점 및 영향: Desktop changes gain bounded Linux package acceptance and diagnostic evidence. The lane does not prove dpkg maintainer scripts, desktop integration, elevation, signed updates, or a physical compositor; those remain the installed-artifact gate's responsibility. diff --git a/structure/decisions/ADR-5494-lightweight-background-startup.md b/structure/decisions/ADR-5494-lightweight-background-startup.md new file mode 100644 index 00000000000..7575131ebe7 --- /dev/null +++ b/structure/decisions/ADR-5494-lightweight-background-startup.md @@ -0,0 +1,12 @@ +# ADR-5494 — decision recorded under "Startup, quit and the tray" + +- Contract owner: [desktop-shell.md](../desktop-shell.md#startup-quit-and-the-tray) + +## Decision record + +- 목적과 의도: Keep a login-started desktop shell ready in the background without paying the full dashboard's render and polling cost before a person opens it. +- 기존 구현 및 제약 조건: The shell already owns a small bundled startup surface, but every successful startup replaced it with the loopback dashboard even when the main window remained hidden behind a usable tray. +- 검토한 주요 대안: Destroy and recreate the WebView on every open; add a second dashboard window; suspend individual dashboard pollers; or retain the existing startup surface until the first explicit open. +- 선택한 방식: A hidden autostart launch stays on the bundled ready surface. Manual launches and any visible no-tray launch keep eager dashboard navigation; tray Open Dashboard, a second ordinary launch, and the shell command lazily navigate before showing. +- 다른 대안 대신 이 방식을 선택한 이유: It removes background React work without adding a window, renderer lifecycle, daemon, or new state owner, and it preserves the already-tested visible startup and failure surface. +- 장점, 단점 및 영향: Background login uses less work and explicit opens remain immediate after one navigation. The first open after hidden startup now pays the dashboard load once, while visible/manual behavior is unchanged. diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 462406f7970..99a8d347b71 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -5,9 +5,11 @@ discovers the loopback proxy, lazily retries management authentication, starts the bundled `ocx` sidecar only when the configured endpoint is unreachable, and owns the tray, autostart, single-instance, and window lifecycle behavior. -`desktop/ui/` is the startup surface. Once the runtime reports healthy the shell navigates the -webview to the proxy's loopback dashboard (`/#/usage`) rather than bundling or serving `gui/dist` -itself. The page renders what the shell tells it and probes nothing on its own; it asks +`desktop/ui/` is the startup surface. Once the runtime reports healthy, a visible or manually +launched shell navigates the webview to the proxy's loopback dashboard (`/#/usage`) rather than +bundling or serving `gui/dist` itself. A hidden login launch retains the small bundled ready surface +until a person explicitly opens the dashboard. The page renders what the shell tells it and probes +nothing on its own; it asks `startup_phases` for the state list rather than restating it, takes the current state from `startup_snapshot` on load because the first states finish in milliseconds, and then follows the `startup-phase` event. `startup_snapshot` always answers with a state; it used to be able to @@ -74,6 +76,18 @@ manual launch shows its window before the sequence begins, a login launch after Registering happens once per process, so a retry re-runs only the runtime half and cannot build a second tray icon with its own refresh loop. +A hidden login launch does not preload the full dashboard after Ready. `finish` keeps the bundled +startup surface while the main window remains hidden; Open Dashboard, a second ordinary app launch, +and the shell's explicit open command all pass through `startup::open_dashboard`, which performs the +one lazy navigation before showing the window. A no-tray login launch is already visible and keeps +the eager behavior, as does every manual launch. If a person opens during startup, the bootstrap is +shown immediately and the open is recorded before progress is read; `finish` reads that request +after it records Ready, so whichever side runs second navigates, and the one-shot claim keeps it to +one navigation. A WebView that refuses the navigation script gives the claim back, so the next open +retries instead of being suppressed for the run. Both the claim and the request reset with each run. + +> Decision record: [ADR-5494](decisions/ADR-5494-lightweight-background-startup.md) + `desktop/src-tauri/src/exit.rs` owns what ends the process. Where there is a usable tray, closing the window and the platform's quit gesture both hide; only the tray's Quit asks to end, and an installed update asks for a coordinated restart. Where there is no usable tray, closing the window @@ -230,11 +244,43 @@ marker, which the GUI detects to identify the shell without using IPC. ## Release packaging and updater +### Linux packaged-shell acceptance + +The ordinary hosted Linux lane builds both AppImage and deb bundles with updater artifacts disabled, +extracts each payload into a disposable directory, and boots its real application executable under a +private Xvfb, Openbox, and D-Bus session. Openbox supplies only the window-manager close protocol; +it does not supply a tray host. `desktop/scripts/linux-packaged-e2e.ts` gives each format fresh +`HOME`, `XDG_*`, `CODEX_HOME`, and `OPENCODEX_HOME` roots plus a loopback port held until the app +spawn boundary, then requires a visible OpenCodex window, the bundled sidecar's matching `/healthz` +identity, port and version. It then asks the window manager to close the only window (`wmctrl -i -c`, +the path a close button takes) and requires the app to exit on its own with code 0 and no signal and +the runtime to be gone; destroying the X window or a crash does not count as a drain. Its +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. + +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 +`desktop-installed-gate.yml` remains the authority for real installation, package-manager ownership, +takeover consent, elevation cancellation/acceptance, and in-place updater behavior on explicitly +approved disposable GUI runners. Passing the hosted lane must never be described as passing those +privileged installation flows. + +AppImage and deb are built with independent `CARGO_TARGET_DIR` roots in hosted acceptance and release +jobs, then copied into a read-only staging layout for verification and collection. Tauri patches a +per-format updater marker into the release binary while bundling; sharing one Cargo target lets one +format observe a binary mutated for the other. The isolated roots make the marker and every other +bundler mutation format-local. + +> Decision record: [ADR-5493](decisions/ADR-5493-linux-packaged-shell-acceptance.md) + Linux AppImage packaging uses `desktop/scripts/appimage-patchelf.py` to preserve the compiled Bun CLI when linuxdeploy sets the executable RPATH. Only the exact -AppDir sidecar, still byte-identical to the prepared CLI, is exempt; other ELF +AppDir sidecar under the active `CARGO_TARGET_DIR`, still byte-identical to the +prepared target-matching CLI, is exempt; other ELF operations use the system patchelf. `desktop/scripts/verify-linux-sidecar.sh` -extracts the completed AppImage, compares its CLI bytes and runs its version command +extracts the completed AppImage (the release passes the staged isolated AppImage directory; a local +build keeps the default Cargo target path), compares its CLI bytes and runs its version command on the hosted runner before any release asset is collected. The macOS release combines both prepared CLI architectures with `lipo` into the universal external binary Tauri expects, and checks that both slices are present. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index ff2c772cfa5..cf34f48315c 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -235,3 +235,14 @@ Malformed or unreadable records remain unknown. Recovery requires the same compl identity and proven-dead liveness; unknown or transferred ownership never starts another proxy. Direct recovery retains the lease until readiness or its bounded deadline. The normal successful manual-runtime update still prints the existing restart hint. + +The probe ceilings are module-load constants in `src/server/proxy-liveness.ts`: 750 ms for the +shared default and 1500 ms (three attempts) for `SERVICE_STOP_LIVENESS` and +`START_OWNERSHIP_LIVENESS`. `OCX_PROBE_TIMEOUT_MS` (whole milliseconds, 1 to 30000) only raises +them for hosts whose loopback connects are slowed by a security layer; each ceiling keeps its floor, +so an override can never shorten the budgets that prevent a duplicate proxy, and a value above the +30 s ceiling is ignored so the single-shot stop deadline (`timeoutMs * attempts + 250` in +`src/service/orchestration.ts`) stays bounded. `tests/server/probe-timeout-env.test.ts` reads the +constants in child processes. + +`src/update/install-detection.mjs` examines both lexical and resolved package paths. An enclosing mise installation owns its nested npm/aube package only when the adjacent `.mise.backend.toml` identifies the containing tool alias and the canonical `npm:@bitkyc08/opencodex` backend. That verified outer owner takes precedence over the inner npm layout. Two verified owners whose tool roots differ only by a symlinked ancestor (macOS `/var` -> `/private/var`) are compared by canonical directory and count as one install. An unreadable or contradictory ownership boundary on either path takes precedence over a verified owner on the other path, refusing mutation without inventing a tool name or recovery command. `ocx update`, dashboard update checks, and update workers expose `installer: "mise"`; checks remain read-only, while mutation is refused with `mise upgrade ` before any proxy stop, package write, or worker creation. The package-tree integrity guard remains active for mise packages. diff --git a/structure/runtime.md b/structure/runtime.md index 27c72b23ac8..22495496528 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -65,7 +65,7 @@ Catalog-derived reasoning-level diagnostics are escaped only at the human-output ## CLI Codex restart scope -`ocx system codex-restart` requests a full Codex desktop-app restart and app-server restarts through the management endpoint. `src/cli/capabilities.ts` names that scope in its summary and `--yes` description; `src/cli/system-command.ts` explains the desktop interruption when confirmation is missing and sends no restart request. Human output says the restart was requested, while `--json` preserves the complete server result, including skipped or refused desktop outcomes. An armed test process never reaches the real desktop app. When the test preload's `OCX_TEST_HOME_GUARD=1` is set and the caller injected no `execFile`, `restartCodexDesktopApp` in `src/codex/desktop-app-restart.ts` returns the skipped reason `test_environment` before discovery or signalling, and `handleDesktopAppRestart` in `src/cli/restart-scope.ts` reports that skip. The flag, not `NODE_ENV`, decides, so a real `NODE_ENV=test ocx ...` still restarts the app; adapter tests that inject `execFile` still exercise the full path. `tests/clients/desktop-app-restart.test.ts` covers the skip. +`ocx system codex-restart` requests a full Codex desktop-app restart and app-server restarts through the management endpoint. `src/cli/capabilities.ts` names that scope and warns that unsaved composer drafts, model-picker selections, and pending approval prompts may be discarded. `src/cli/system-command.ts` repeats that concrete state-loss warning both when confirmation is missing and after a confirmed human-readable request; the unconfirmed path sends no restart request. `--json` preserves the complete server result, including skipped or refused desktop outcomes. An armed test process never reaches the real desktop app. When the test preload's `OCX_TEST_HOME_GUARD=1` is set and the caller injected no `execFile`, `restartCodexDesktopApp` in `src/codex/desktop-app-restart.ts` returns the skipped reason `test_environment` before discovery or signalling, and `handleDesktopAppRestart` in `src/cli/restart-scope.ts` reports that skip. The flag, not `NODE_ENV`, decides, so a real `NODE_ENV=test ocx ...` still restarts the app; adapter tests that inject `execFile` still exercise the full path. `tests/clients/desktop-app-restart.test.ts` covers the skip. After a CLI catalog/cache write, advisory restart guidance compares each running Codex app-server's start time with the written catalog mtime. It reports only processes proven stale; a fresh or diff --git a/tests/ci-workflows/ci-privacy-gate.test.ts b/tests/ci-workflows/ci-privacy-gate.test.ts index 75709e518d4..365062a9119 100644 --- a/tests/ci-workflows/ci-privacy-gate.test.ts +++ b/tests/ci-workflows/ci-privacy-gate.test.ts @@ -108,6 +108,7 @@ describe.skipIf(cannotRunAggregate)("the aggregate ci gate, executed", () => { LANE: "", CHANGES_CI: "false", CHANGES_NATIVE: "false", + CHANGES_DESKTOP: "false", CHANGES_PACKAGING: "false", CHANGES_DOCS: "false", CHANGES_STRUCTURE: "false", diff --git a/tests/ci-workflows/ci-scope-reduction.test.ts b/tests/ci-workflows/ci-scope-reduction.test.ts index 253179401e0..2023c551b58 100644 --- a/tests/ci-workflows/ci-scope-reduction.test.ts +++ b/tests/ci-workflows/ci-scope-reduction.test.ts @@ -52,7 +52,7 @@ const NATIVE_GATED = ["platform-macos", "widget", "desktop-shell"] as const; /** The two smoke jobs whose matrix legs shrink with the native selection. */ const MATRIX_JOBS = ["keyring-smoke", "npm-global-smoke"] as const; -type SelectionInputs = { event_name: string; ci: string; native: string }; +type SelectionInputs = { event_name: string; ci: string; native: string; desktop?: string }; function term(source: string, inputs: SelectionInputs): string | boolean { const text = source.trim(); @@ -69,6 +69,7 @@ function term(source: string, inputs: SelectionInputs): string | boolean { if (output) { if (output[1] === "ci") return inputs.ci; if (output[1] === "native") return inputs.native; + if (output[1] === "desktop") return inputs.desktop ?? "false"; } throw new Error(`unsupported expression term: ${text}`); } @@ -345,9 +346,15 @@ describe("the native-gated jobs", () => { const condition = jobs["platform-macos"]?.if ?? ""; test("are exactly platform-macos, widget and desktop-shell on one shared condition", () => { - for (const name of NATIVE_GATED) { - expect(`${name}:${jobs[name]?.if}`).toBe(`${name}:${condition}`); - } + expect(`widget:${jobs.widget?.if}`).toBe(`widget:${condition}`); + // desktop-shell widens only the native term: package-affecting changes also select it so the + // Linux packaged-shell E2E runs. Everything else about the condition is shared. + const widened = condition.replace( + "needs.changes.outputs.native == 'true'", + "(needs.changes.outputs.native == 'true' || needs.changes.outputs.desktop == 'true')", + ); + expect(widened).not.toBe(condition); + expect(`desktop-shell:${jobs["desktop-shell"]?.if}`).toBe(`desktop-shell:${widened}`); // A fourth job carrying the native output would silently join the gate, and // a gate the aggregate does not know about is the failure this file exists // for — so name the full set rather than sampling it. @@ -368,6 +375,20 @@ describe("the native-gated jobs", () => { } }); +describe("the packaged desktop selection", () => { + test("a pull request that changes only package inputs selects desktop-shell and nothing else native", () => { + const inputs = { event_name: "pull_request", ci: "true", native: "false", desktop: "true" }; + expect(evaluate(jobs["desktop-shell"]?.if ?? "", inputs)).toBe(true); + expect(evaluate(jobs["platform-macos"]?.if ?? "", inputs)).toBe(false); + expect(evaluate(jobs.widget?.if ?? "", inputs)).toBe(false); + }); + + test("an out-of-scope pull request never selects desktop-shell through the package filter", () => { + const inputs = { event_name: "pull_request", ci: "false", native: "false", desktop: "true" }; + expect(evaluate(jobs["desktop-shell"]?.if ?? "", inputs)).toBe(false); + }); +}); + describe("the smoke matrices", () => { test("consume their matrices from validated changes outputs", () => { for (const jobName of MATRIX_JOBS) { diff --git a/tests/ci-workflows/linux-desktop-packaged-ci.test.ts b/tests/ci-workflows/linux-desktop-packaged-ci.test.ts new file mode 100644 index 00000000000..abd1d14d1f5 --- /dev/null +++ b/tests/ci-workflows/linux-desktop-packaged-ci.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +// Workflow wiring for the Linux packaged-shell E2E. The driver's own behaviour is covered by +// linux-desktop-packaged-e2e.test.ts; this file only reads .github/workflows/ci.yml. +describe("Linux packaged desktop E2E in CI", () => { + test("CI scopes the real package build and keeps the E2E unprivileged", () => { + const workflow = Bun.YAML.parse(readFileSync(repoPath(".github", "workflows", "ci.yml"), "utf8")) as { + permissions?: Record; + jobs?: Record; + steps?: Array<{ + name?: string; + uses?: string; + if?: string; + run?: string; + env?: Record; + with?: Record; + }>; + }>; + }; + expect(workflow.permissions).toEqual({ contents: "read" }); + const changes = workflow.jobs?.changes; + expect(changes?.outputs?.desktop).toBe("${{ steps.scope.outputs.desktop }}"); + 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'"); + + const shell = workflow.jobs?.["desktop-shell"]; + expect(shell?.if).toContain("needs.changes.outputs.desktop == 'true'"); + const checkResources = shell?.steps?.find(step => step.name === "Prepare desktop check resources"); + expect(checkResources?.run).toContain("binaries/ocx-"); + expect(checkResources?.run).not.toContain("resources/sidecar/ocx"); + const preserve = shell?.steps?.find(step => step.name === "Preserve the compiled Linux sidecar"); + expect(preserve?.run).toContain("chmod +x desktop/scripts/appimage-patchelf.py"); + const appImageBuild = shell?.steps?.find(step => step.name === "Build Linux AppImage"); + const debBuild = shell?.steps?.find(step => step.name === "Build Linux deb"); + expect(appImageBuild?.env?.CARGO_TARGET_DIR).toContain("opencodex-appimage-target"); + expect(appImageBuild?.env?.PATCHELF).toContain("desktop/scripts/appimage-patchelf.py"); + expect(debBuild?.env?.CARGO_TARGET_DIR).toContain("opencodex-deb-target"); + expect(appImageBuild?.env?.CARGO_TARGET_DIR).not.toBe(debBuild?.env?.CARGO_TARGET_DIR); + const stage = shell?.steps?.find(step => step.name === "Stage isolated Linux bundles"); + expect(stage?.run).toContain("$APPIMAGE_BUNDLE/."); + expect(stage?.run).toContain("$DEB_BUNDLE/."); + expect(stage?.run).toContain('chmod -R a-w "$BUNDLE_ROOT"'); + + const aggregate = workflow.jobs?.ci?.steps?.find(step => step.name === "Assert every job this event requested succeeded"); + expect(aggregate?.env?.CHANGES_DESKTOP).toBe("${{ needs.changes.outputs.desktop }}"); + expect(aggregate?.run).toContain("desktop-shell) echo \"$desktop_shell\""); + + const e2e = shell?.steps?.find(step => step.name === "Run Linux packaged-shell E2E"); + expect(e2e?.if).toBe("needs.changes.outputs.desktop == 'true'"); + expect(e2e?.run).toContain("dbus-run-session -- xvfb-run"); + expect(e2e?.run).toContain("openbox"); + expect(e2e?.run).toContain("linux-packaged-e2e.ts"); + expect(e2e?.run).toContain("opencodex-linux-bundles"); + expect(e2e?.run).not.toContain("sudo"); + expect(e2e?.run).not.toContain("dpkg -i"); + const deps = shell?.steps?.find(step => step.name === "Install Tauri Linux dependencies"); + expect(deps?.run).toContain("wmctrl"); + + const upload = shell?.steps?.find(step => step.name === "Upload Linux packaged-shell E2E report"); + expect(upload?.uses).toMatch(/^actions\/upload-artifact@[0-9a-f]{40}$/u); + expect(upload?.if).toContain("always()"); + }); +}); diff --git a/tests/ci-workflows/linux-desktop-packaged-e2e.test.ts b/tests/ci-workflows/linux-desktop-packaged-e2e.test.ts new file mode 100644 index 00000000000..5c8593f06a2 --- /dev/null +++ b/tests/ci-workflows/linux-desktop-packaged-e2e.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + assertCleanExit, + assertRuntimeRecordPort, + locateArtifacts, + parseArguments, + processTreeRssKiB, + readRuntimeRecord, + selectDebExecutable, + windowManagerCloseArgs, +} from "../../desktop/scripts/linux-packaged-e2e"; +import { repoPath } from "../helpers/repo-root"; + +function temporaryDirectory(): string { + return mkdtempSync(join(tmpdir(), "opencodex-linux-e2e-test-")); +} + +describe("Linux packaged desktop E2E driver", () => { + test("requires an explicit bundle root, report and strict version", () => { + expect(() => parseArguments([])).toThrow("required"); + expect(() => parseArguments([ + "--bundle-root", "/bundles", + "--report", "/report.json", + "--version", "latest", + ])).toThrow("strict semver"); + expect(parseArguments([ + "--bundle-root", "/bundles", + "--report", "/report.json", + "--version", "2.61.0-preview.1", + ]).version).toBe("2.61.0-preview.1"); + }); + + test("requires exactly one AppImage and deb from their bundle directories", () => { + const root = temporaryDirectory(); + try { + mkdirSync(join(root, "appimage")); + mkdirSync(join(root, "deb")); + writeFileSync(join(root, "appimage", "OpenCodex.AppImage"), "appimage"); + writeFileSync(join(root, "deb", "OpenCodex.deb"), "deb"); + expect(locateArtifacts(root)).toEqual({ + appimage: join(root, "appimage", "OpenCodex.AppImage"), + deb: join(root, "deb", "OpenCodex.deb"), + }); + writeFileSync(join(root, "deb", "stale.deb"), "deb"); + expect(() => locateArtifacts(root)).toThrow("exactly one deb"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("selects the deb desktop host without mistaking the ocx sidecar for the app", () => { + expect(selectDebExecutable(["/payload/usr/bin/ocx", "/payload/usr/bin/opencodex-desktop"])) + .toBe("/payload/usr/bin/opencodex-desktop"); + expect(() => selectDebExecutable(["/payload/usr/bin/ocx"])) + .toThrow("expected exactly one deb desktop executable"); + }); + + test("accepts only a complete positive runtime record", () => { + const root = temporaryDirectory(); + try { + const record = join(root, "runtime-port.json"); + writeFileSync(record, JSON.stringify({ pid: 42, port: 10100 })); + expect(readRuntimeRecord(record)).toEqual({ pid: 42, port: 10100 }); + expect(assertRuntimeRecordPort({ pid: 42, port: 10100 }, 10100)).toEqual({ + pid: 42, + port: 10100, + }); + expect(() => assertRuntimeRecordPort({ pid: 42, port: 10101 }, 10100)) + .toThrow("recorded port 10101, expected isolated port 10100"); + for (const invalid of [ + { pid: 0, port: 10100 }, + { pid: 42, port: 0 }, + { pid: 42, port: 65_536 }, + { pid: "42", port: 10100 }, + ]) { + writeFileSync(record, JSON.stringify(invalid)); + expect(readRuntimeRecord(record)).toBeUndefined(); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("measures only the selected process tree", () => { + const rows = [ + { pid: 10, ppid: 1, rssKiB: 100 }, + { pid: 11, ppid: 10, rssKiB: 50 }, + { pid: 12, ppid: 11, rssKiB: 25 }, + { pid: 20, ppid: 1, rssKiB: 1_000 }, + ]; + expect(processTreeRssKiB(10, rows)).toBe(175); + expect(processTreeRssKiB(20, rows)).toBe(1_000); + }); + + test("closes through the window manager and accepts only a clean app exit", () => { + expect(windowManagerCloseArgs("4194310")).toEqual(["-i", "-c", "0x400006"]); + expect(() => windowManagerCloseArgs("0")).toThrow("invalid X11 window id"); + expect(() => windowManagerCloseArgs("abc")).toThrow("invalid X11 window id"); + expect(assertCleanExit({ code: 0, signal: null })).toEqual({ code: 0, signal: null }); + expect(() => assertCleanExit(undefined)).toThrow("did not exit"); + expect(() => assertCleanExit({ code: null, signal: "SIGKILL" })).toThrow("signal SIGKILL"); + expect(() => assertCleanExit({ code: 1, signal: null })).toThrow("code 1"); + }); + + test("the driver isolates each package from a runtime already using the default port", () => { + const driver = readFileSync( + repoPath("desktop", "scripts", "linux-packaged-e2e.ts"), + "utf8", + ); + expect(driver).toContain('server.listen(0, "127.0.0.1"'); + expect(driver).toContain('join(opencodexHome, "config.json")'); + expect(driver).toContain("JSON.stringify({ port: configuredPort }"); + expect(driver).not.toContain('port: 10100'); + expect(driver).toContain('["search", "--onlyvisible", "--name", "^OpenCodex$"]'); + expect(driver).toContain('command("wmctrl", windowManagerCloseArgs(windowId))'); + expect(driver).not.toContain('"windowclose"'); + expect(driver).toContain("const exit = assertCleanExit(appExit);"); + }); +}); diff --git a/tests/ci-workflows/package-tree-integrity.test.ts b/tests/ci-workflows/package-tree-integrity.test.ts index 29c119a6ffc..bf5db8e6f58 100644 --- a/tests/ci-workflows/package-tree-integrity.test.ts +++ b/tests/ci-workflows/package-tree-integrity.test.ts @@ -118,7 +118,7 @@ describe("package tree integrity", () => { expect(installedGuard.status()).toEqual({ ok: false, reason: "package_tree_replaced" }); }); - test.each(["npm", "bun"] as const)("%s installs still refuse a replaced package tree", installer => { + test.each(["npm", "bun", "mise"] as const)("%s installs still refuse a replaced package tree", installer => { let observation: PackageTreeObservation = { device: 1n, inode: 10n, diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index 037b2f65ad3..0dd5865942f 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -123,6 +123,43 @@ describe("desktop release scripts", () => { } }); + test("collects Linux formats from an explicitly staged isolated bundle root", () => { + const root = temporaryDirectory(); + try { + const bundleRoot = join(root, "isolated-linux-bundles"); + mkdirSync(join(bundleRoot, "appimage"), { recursive: true }); + mkdirSync(join(bundleRoot, "deb"), { recursive: true }); + writeFileSync(join(bundleRoot, "appimage", "OpenCodex.AppImage"), "appimage"); + writeFileSync(join(bundleRoot, "deb", "OpenCodex.deb"), "deb"); + + const files = collectReleaseAssets({ + version: "2.61.0", + target: "x86_64-unknown-linux-gnu", + out: join(root, "release"), + repoRoot: root, + bundleRoot, + }); + + expect(files.map(path => basename(path))).toEqual([ + "OpenCodex-2.61.0-linux-x86_64.AppImage", + "OpenCodex-2.61.0-linux-x86_64.AppImage.sha256", + "OpenCodex-2.61.0-linux-amd64.deb", + "OpenCodex-2.61.0-linux-amd64.deb.sha256", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("the Linux sidecar verifier takes the staged AppImage directory and keeps the local default", () => { + const verifier = readFileSync(repoPath("desktop", "scripts", "verify-linux-sidecar.sh"), "utf8"); + expect(verifier).toContain('bundle="${1:-$root/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage}"'); + const wrapper = readFileSync(repoPath("desktop", "scripts", "appimage-patchelf.py"), "utf8"); + expect(wrapper).toContain('os.environ.get("CARGO_TARGET_DIR"'); + expect(wrapper).toContain("APPDIR_SIDECAR_TAIL"); + expect(wrapper).not.toContain('desktop/src-tauri/target" / triple'); + }); + test("rejects ambiguous bundle matches", () => { const root = temporaryDirectory(); try { @@ -408,6 +445,31 @@ describe("the desktop build toolchain carries the bundle-type marker", () => { || (major === minimumCliWithBundlePatch.major && minor! >= minimumCliWithBundlePatch.minor), ).toBe(true); }); + + test("the release workflow gives AppImage and deb independent Cargo targets", () => { + const workflow = Bun.YAML.parse( + readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"), + ) as { + jobs?: Record }>; + }>; + }; + const steps = workflow.jobs?.["package-desktop"]?.steps ?? []; + const appImage = steps.find(step => step.name === "Build Linux AppImage bundle"); + const deb = steps.find(step => step.name === "Build Linux deb bundle"); + expect(appImage?.env?.CARGO_TARGET_DIR).toContain("opencodex-appimage-target"); + expect(deb?.env?.CARGO_TARGET_DIR).toContain("opencodex-deb-target"); + expect(appImage?.env?.CARGO_TARGET_DIR).not.toBe(deb?.env?.CARGO_TARGET_DIR); + expect(appImage?.run).toContain("--bundles appimage"); + expect(deb?.run).toContain("--bundles deb"); + + const stage = steps.find(step => step.name === "Stage isolated Linux release bundles"); + expect(stage?.run).toContain("$APPIMAGE_TARGET/$DESKTOP_TARGET/release/bundle/appimage/."); + expect(stage?.run).toContain("$DEB_TARGET/$DESKTOP_TARGET/release/bundle/deb/."); + expect(stage?.run).toContain('chmod -R a-w "$bundle_root"'); + const collect = steps.find(step => step.run?.includes("collect-release-assets.ts")); + expect(collect?.run).toContain('--bundle-root "$DESKTOP_BUNDLE_ROOT"'); + }); }); describe("widget extension signing", () => { @@ -454,10 +516,14 @@ describe("widget extension signing", () => { expect(preserve?.if).toBe("runner.os == 'Linux'"); expect(preserve?.run).toContain("PATCHELF=$GITHUB_WORKSPACE/desktop/scripts/appimage-patchelf.py"); expect(verify?.if).toBe("runner.os == 'Linux'"); - expect(verify?.run).toBe("bash desktop/scripts/verify-linux-sidecar.sh"); - expect(indexOfStep(preserve!.name!)).toBeLessThan(indexOfStep("Build desktop bundles")); - expect(indexOfStep(verify!.name!)).toBeGreaterThan(indexOfStep("Build desktop bundles")); + // The Linux AppImage is built in its own Cargo target and staged read-only; the verifier runs + // after that staging, against the staged copy, and before any asset is collected. + expect(verify?.run).toBe('bash desktop/scripts/verify-linux-sidecar.sh "$DESKTOP_BUNDLE_ROOT/appimage"'); + expect(indexOfStep(preserve!.name!)).toBeLessThan(indexOfStep("Build Linux AppImage bundle")); + expect(indexOfStep(verify!.name!)).toBeGreaterThan(indexOfStep("Build Linux AppImage bundle")); + expect(indexOfStep(verify!.name!)).toBeGreaterThan(indexOfStep("Stage isolated Linux release bundles")); expect(indexOfStep(verify!.name!)).toBeLessThan(indexOfStep("Rename release assets")); + expect(steps.find(step => step.name === "Build desktop bundles")?.if).toBe("runner.os != 'Linux'"); }); test("the release build hands the widget a signing identity and forbids an ad-hoc fallback", () => { diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 1b0096372d3..1fb04d53e9e 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -30,6 +30,9 @@ describe("ocx system codex-restart confirmation", () => { const warning = errors.mock.calls.flat().join(" "); expect(warning).toContain("requires --yes"); expect(warning).toContain("fully quits and relaunches the Codex desktop app"); + expect(warning).toContain("unsaved composer drafts"); + expect(warning).toContain("model-picker selections"); + expect(warning).toContain("pending approval prompts"); } finally { errors.mockRestore(); } }); @@ -48,6 +51,9 @@ describe("ocx system codex-restart confirmation", () => { else { expect(text).toContain("Codex desktop app"); expect(text).toContain("restart requested."); + expect(text).toContain("Unsaved composer drafts"); + expect(text).toContain("model-picker selections"); + expect(text).toContain("pending approval prompts"); expect(text).not.toContain("restarted"); } } finally { output.mockRestore(); } diff --git a/tests/clients/desktop-startup-surface.test.ts b/tests/clients/desktop-startup-surface.test.ts index 5903800ac8b..693cc528e3d 100644 --- a/tests/clients/desktop-startup-surface.test.ts +++ b/tests/clients/desktop-startup-surface.test.ts @@ -159,6 +159,35 @@ describe("desktop startup surface", () => { expect(page).toContain("progress.failedPhase"); }); + test("a hidden login launch keeps the lightweight surface until an explicit open", () => { + const finish = startup.slice( + startup.indexOf("fn finish("), + startup.indexOf("pub fn diagnostic("), + ); + expect(finish).toContain("loads_dashboard_on_ready(LaunchOrigin::detect(), visible, requested)"); + expect(finish).toContain("window.is_visible()"); + expect(finish).toContain("startup.dashboard_requested()"); + expect(finish).toContain("pub fn open_dashboard("); + expect(finish).toContain("startup.request_dashboard();"); + expect(finish).toContain("startup.ready_dashboard()"); + expect(finish).toContain("crate::window::show(&window)"); + // The request is recorded before progress is read, so an open racing Ready is never lost. + const open = finish.slice(finish.indexOf("pub fn open_dashboard(")); + expect(open.indexOf("startup.request_dashboard();")).toBeLessThan(open.indexOf("startup.ready_dashboard()")); + // The Rust behavioral tests own the navigation outcomes; this only pins that they exist. + for (const name of [ + "fn explicit_dashboard_navigation_is_consumed_once_per_run()", + "fn a_refused_dashboard_navigation_is_retried_on_the_next_open()", + "fn an_open_during_startup_is_remembered_until_the_run_restarts()", + ]) expect(startup).toContain(name); + + expect(lib).toContain("startup::open_dashboard(&app)"); + expect(lib).toContain("startup::open_dashboard(app)"); + const tray = code(repoPath(`${SRC}/tray.rs`)); + expect(tray).toContain('"open-dashboard" =>'); + expect(tray).toContain("crate::startup::open_dashboard(app)"); + }); + test("the snapshot answers with a state rather than with nothing", () => { // The page returns early on a falsy progress, so an absent answer was not a neutral one: it // was a window frozen on its own markup, with no diagnostic in it and no event coming. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0f7a00dd564..f360d804c6b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -483,6 +483,9 @@ "config-user-edits.test.ts": "config", "config.test.ts": "server", "configured-native-models.test.ts": "codex-integration", + "gui-desktop-sidecar-signing.test.ts": "gui", + "linux-desktop-packaged-ci.test.ts": "ci-workflows", + "probe-timeout-env.test.ts": "server", "subagent-roster-migration.test.ts": "routing", "consume-for-inspection-cancel.test.ts": "server", "container-bootstrap.test.ts": "service", @@ -831,6 +834,7 @@ "legacy-shell-compat.test.ts": "responses", "live-call-bindings.test.ts": "server", "live-service-manager-guard.test.ts": "service", + "linux-desktop-packaged-e2e.test.ts": "ci-workflows", "local-aside-sync-capability.test.ts": "server", "local-destinations.test.ts": "lib", "local-management-attestation.test.ts": "server", @@ -1453,6 +1457,7 @@ "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", + "update-mise.test.ts": "update", "update-pnpm.test.ts": "update", "update-stop-classification.test.ts": "update", "update-stop-first.test.ts": "update", diff --git a/tests/gui/gui-desktop-sidecar-signing.test.ts b/tests/gui/gui-desktop-sidecar-signing.test.ts new file mode 100644 index 00000000000..21c591a5b8d --- /dev/null +++ b/tests/gui/gui-desktop-sidecar-signing.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { + CODESIGN_PATH, + adHocSignArgv, + adHocSignSidecar, + shouldAdHocSignSidecar, +} from "../../desktop/scripts/sidecar-signing"; +import { repoPath } from "../helpers/repo-root"; + +test("only a macOS host preparing a darwin target signs the sidecar", () => { + expect(shouldAdHocSignSidecar("darwin", "bun-darwin-arm64")).toBe(true); + expect(shouldAdHocSignSidecar("darwin", "bun-darwin-x64")).toBe(true); + for (const target of ["bun-linux-x64", "bun-linux-arm64", "bun-windows-x64"]) { + expect(shouldAdHocSignSidecar("darwin", target)).toBe(false); + } + for (const host of ["linux", "win32"]) { + expect(shouldAdHocSignSidecar(host, "bun-darwin-arm64")).toBe(false); + } +}); + +test("signing runs the absolute codesign with an ad-hoc forced signature", () => { + const calls: string[][] = []; + const code = adHocSignSidecar("/tmp/binaries/ocx-aarch64-apple-darwin", (argv) => { + calls.push(argv); + return { exitCode: 0 }; + }); + expect(code).toBe(0); + expect(calls).toEqual([[CODESIGN_PATH, "-s", "-", "-f", "/tmp/binaries/ocx-aarch64-apple-darwin"]]); + expect(adHocSignArgv("x")[0]).toBe("/usr/bin/codesign"); +}); + +test("a failed or unlaunchable codesign stops preparation with a nonzero code", () => { + expect(adHocSignSidecar("x", () => ({ exitCode: 3 }))).toBe(3); + expect(adHocSignSidecar("x", () => ({ exitCode: null }))).toBe(1); +}); + +test("prepare-sidecar signs through the guarded helper right after copying", async () => { + const script = await Bun.file(repoPath("desktop", "scripts", "prepare-sidecar.ts")).text(); + const copy = script.indexOf("copyFileSync(executable, destination);"); + const guard = script.indexOf("if (shouldAdHocSignSidecar(process.platform, target))"); + const resources = script.indexOf("cpSync(join(repoRoot, \"gui\", \"dist\")"); + expect(copy).toBeGreaterThan(-1); + expect(guard).toBeGreaterThan(copy); + expect(resources).toBeGreaterThan(guard); + expect(script).not.toContain("codesign\", \"-s\""); +}); diff --git a/tests/helpers/update-bun-ownership-child.ts b/tests/helpers/update-bun-ownership-child.ts index a7f8990ede3..fa8c53a9692 100644 --- a/tests/helpers/update-bun-ownership-child.ts +++ b/tests/helpers/update-bun-ownership-child.ts @@ -62,7 +62,11 @@ mock.module(repoPath("src/config/process-state.ts"), () => ({ ...state, readRuntimePort: () => running ? { pid: 4321, port, hostname: "127.0.0.1" } : null, getRuntimePortPath: () => runtime, })); -mock.module(repoPath("src/update/install-detection.mjs"), () => ({ detectInstallFromPath: () => "bun" })); +// runUpdate reads ownership (installer plus any external owner) rather than the bare installer. +mock.module(repoPath("src/update/install-detection.mjs"), () => ({ + detectInstallFromPath: () => "bun", + detectInstallOwnershipFromPath: () => ({ installer: "bun" }), +})); mock.module(repoPath("src/update/registry-integrity.mjs"), () => ({ checkRegistryPackageIntegrity: () => ({ ok: true, integrity: "sha512-fixture" }) })); const liveness = await import("../../src/server/proxy-liveness"); const actualIdentity = liveness.proxyIdentityAt; diff --git a/tests/server/probe-timeout-env.test.ts b/tests/server/probe-timeout-env.test.ts new file mode 100644 index 00000000000..bfe4fa983e6 --- /dev/null +++ b/tests/server/probe-timeout-env.test.ts @@ -0,0 +1,89 @@ +/** + * OCX_PROBE_TIMEOUT_MS: the probe ceilings are module-load constants, so each wiring case + * runs in a fresh child interpreter with its own environment. Nothing in this process's + * environment or module registry changes, so no other test file can observe an override. + */ +import { describe, expect, test } from "bun:test"; +import { + MAX_PROBE_TIMEOUT_MS, + parseProbeTimeoutOverrideMs, + probeCeilingMs, +} from "../../src/server/proxy-liveness"; +import { repoPath } from "../helpers/repo-root"; + +type Ceilings = { defaultMs: number; stopMs: number; stopAttempts: number; startMs: number; startAttempts: number }; + +function ceilingsUnder(value: string | undefined): Ceilings { + const env: Record = { ...process.env }; + if (value === undefined) delete env.OCX_PROBE_TIMEOUT_MS; + else env.OCX_PROBE_TIMEOUT_MS = value; + const script = [ + `const m = await import(${JSON.stringify(repoPath("src", "server", "proxy-liveness.ts"))});`, + "console.log(JSON.stringify({", + " defaultMs: m.DEFAULT_PROBE_TIMEOUT_MS,", + " stopMs: m.SERVICE_STOP_LIVENESS.timeoutMs, stopAttempts: m.SERVICE_STOP_LIVENESS.attempts,", + " startMs: m.START_OWNERSHIP_LIVENESS.timeoutMs, startAttempts: m.START_OWNERSHIP_LIVENESS.attempts,", + "}));", + ].join("\n"); + const child = Bun.spawnSync([process.execPath, "-e", script], { env, stdout: "pipe", stderr: "pipe" }); + if (child.exitCode !== 0) throw new Error(child.stderr.toString()); + const lines = child.stdout.toString().trim().split("\n"); + return JSON.parse(lines[lines.length - 1]!) as Ceilings; +} + +describe("parseProbeTimeoutOverrideMs", () => { + test("accepts positive integer milliseconds up to the ceiling", () => { + expect(parseProbeTimeoutOverrideMs("1")).toBe(1); + expect(parseProbeTimeoutOverrideMs(" 4321 ")).toBe(4321); + expect(parseProbeTimeoutOverrideMs(String(MAX_PROBE_TIMEOUT_MS))).toBe(30_000); + }); + + test("ignores absent, malformed, zero, and over-ceiling values", () => { + for (const raw of [undefined, "", " ", "abc", "1.5", "-5", "+7", "0", "30001", "2147483648", "99999999999999999999"]) { + expect(parseProbeTimeoutOverrideMs(raw)).toBeUndefined(); + } + }); +}); + +describe("probeCeilingMs keeps each shipped floor", () => { + test("an override can raise a ceiling but never lower it", () => { + expect(probeCeilingMs(750, undefined)).toBe(750); + expect(probeCeilingMs(750, 1)).toBe(750); + expect(probeCeilingMs(750, 749)).toBe(750); + expect(probeCeilingMs(750, 1000)).toBe(1000); + expect(probeCeilingMs(1500, 1000)).toBe(1500); + expect(probeCeilingMs(1500, 30_000)).toBe(30_000); + }); +}); + +describe("OCX_PROBE_TIMEOUT_MS wiring at module load", () => { + test("unset keeps the shipped ceilings", () => { + expect(ceilingsUnder(undefined)).toEqual({ defaultMs: 750, stopMs: 1500, stopAttempts: 3, startMs: 1500, startAttempts: 3 }); + }); + + test("values below a floor never shorten it", () => { + for (const value of ["1", "749", "750"]) { + const c = ceilingsUnder(value); + expect(c.defaultMs).toBe(750); + expect(c.stopMs).toBe(1500); + expect(c.startMs).toBe(1500); + } + }); + + test("a value between the floors raises only the shared default", () => { + expect(ceilingsUnder("1000")).toEqual({ defaultMs: 1000, stopMs: 1500, stopAttempts: 3, startMs: 1500, startAttempts: 3 }); + }); + + test("the ceiling raises every budget and keeps the stop wait bounded", () => { + const c = ceilingsUnder("30000"); + expect(c).toEqual({ defaultMs: 30_000, stopMs: 30_000, stopAttempts: 3, startMs: 30_000, startAttempts: 3 }); + // The single-shot stop deadline in src/service/orchestration.ts is timeoutMs * attempts + 250. + expect(c.stopMs * c.stopAttempts + 250).toBeLessThanOrEqual(90_250); + }); + + test("over-ceiling and malformed values fall back to the shipped ceilings", () => { + for (const value of ["30001", "2147483647", "not-a-number", "0"]) { + expect(ceilingsUnder(value)).toEqual({ defaultMs: 750, stopMs: 1500, stopAttempts: 3, startMs: 1500, startAttempts: 3 }); + } + }); +}); diff --git a/tests/update/update-mise.test.ts b/tests/update/update-mise.test.ts new file mode 100644 index 00000000000..d3b3a52cce2 --- /dev/null +++ b/tests/update/update-mise.test.ts @@ -0,0 +1,406 @@ +import { describe, expect, test } from "bun:test"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { spawnSync } from "node:child_process"; +import { delimiter, dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { + detectInstallFromPath, + detectInstallOwnershipFromPath, +} from "../../src/update/install-detection.mjs"; +import { checkForUpdate, startUpdateJob, UpdateJobError } from "../../src/update/job"; +import { readUpdateBadge } from "../../src/update/badge"; +import type { InstallOwnership } from "../../src/update/index"; + +const BACKEND = 'short = "ocx-local"\nfull = "npm:@bitkyc08/opencodex"\nexplicit_backend = false\n'; +const metadataProbe = (exists: (path: string) => boolean) => + (path: string): "present" | "absent" => exists(path) ? "present" : "absent"; + +function misePackage(root: string, version = "2.59.0"): string { + const toolRoot = join(root, "custom mise data", "installs", "ocx-local"); + const packagePath = join( + toolRoot, + version, + "node_modules", + ".mise", + "@bitkyc08+opencodex@2.59.0", + "node_modules", + "@bitkyc08", + "opencodex", + "bin", + ); + mkdirSync(packagePath, { recursive: true }); + writeFileSync(join(toolRoot, ".mise.backend.toml"), BACKEND); + return packagePath; +} + +describe("mise installation ownership", () => { + test("recognises a custom data directory, local alias, and nested aube package", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "ocx-mise-owner-"))); + try { + const packagePath = misePackage(root); + expect(detectInstallOwnershipFromPath(packagePath)).toEqual({ + installer: "mise", + owner: { + tool: "ocx-local", + backend: "npm:@bitkyc08/opencodex", + installPath: join(root, "custom mise data", "installs", "ocx-local", "2.59.0"), + toolRoot: join(root, "custom mise data", "installs", "ocx-local"), + }, + }); + expect(detectInstallFromPath(packagePath)).toBe("mise"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("uses the resolved exact version behind a floating link", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "ocx-mise-link-"))); + try { + const exact = misePackage(root); + const toolRoot = join(root, "custom mise data", "installs", "ocx-local"); + const latest = join(toolRoot, "latest"); + if (process.platform === "win32") { + symlinkSync(join(toolRoot, "2.59.0"), latest, "junction"); + } else { + symlinkSync("2.59.0", latest, "dir"); + } + const floating = join(toolRoot, "latest", exact.slice(join(toolRoot, "2.59.0").length + 1)); + expect(detectInstallOwnershipFromPath(floating)).toMatchObject({ + installer: "mise", + owner: { tool: "ocx-local", installPath: join(toolRoot, "2.59.0") }, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("a symlinked data directory ancestor is the same install, not a contradiction", () => { + const real = realpathSync(mkdtempSync(join(tmpdir(), "ocx-mise-ancestor-"))); + const linkParent = realpathSync(mkdtempSync(join(tmpdir(), "ocx-mise-ancestor-link-"))); + const linked = join(linkParent, "data"); + try { + misePackage(real); + symlinkSync(real, linked, process.platform === "win32" ? "junction" : "dir"); + const lexical = misePackage(linked); + expect(detectInstallOwnershipFromPath(lexical)).toMatchObject({ + installer: "mise", + owner: { tool: "ocx-local", backend: "npm:@bitkyc08/opencodex" }, + }); + } finally { + rmSync(linkParent, { recursive: true, force: true }); + rmSync(real, { recursive: true, force: true }); + } + }); + + test("does not infer mise ownership from a .mise path or mise on PATH", () => { + const path = "/tmp/.mise/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(path, { + exists: () => false, + probe: () => "absent", + realpath: value => value, + })).toEqual({ installer: "npm" }); + }); + + test("finds the install boundary when the custom data directory contains node_modules", () => { + const path = "/tmp/node_modules/mise-data/installs/ocx-local/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = "/tmp/node_modules/mise-data/installs/ocx-local/.mise.backend.toml"; + expect(detectInstallOwnershipFromPath(path, { + exists: value => value === metadata, + probe: metadataProbe(value => value === metadata), + readFile: () => BACKEND, + realpath: value => value, + })).toMatchObject({ + installer: "mise", + owner: { installPath: "/tmp/node_modules/mise-data/installs/ocx-local/2.59.0" }, + }); + }); + + test("preserves literal backslashes in POSIX install paths", () => { + const path = "/tmp/mise\\state/installs/ocx-local/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = "/tmp/mise\\state/installs/ocx-local/.mise.backend.toml"; + expect(detectInstallOwnershipFromPath(path, { + probe: metadataProbe(value => value === metadata), + readFile: () => BACKEND, + realpath: value => value, + })).toMatchObject({ + installer: "mise", + owner: { toolRoot: "/tmp/mise\\state/installs/ocx-local" }, + }); + }); + + test("accepts mise's full npm identifier and encoded directory name", () => { + const path = "/data/installs/npm-bitkyc08-opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = "/data/installs/npm-bitkyc08-opencodex/.mise.backend.toml"; + expect(detectInstallOwnershipFromPath(path, { + probe: metadataProbe(value => value === metadata), + readFile: () => 'short = "npm:@bitkyc08/opencodex"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: value => value, + })).toMatchObject({ + installer: "mise", + owner: { tool: "npm:@bitkyc08/opencodex" }, + }); + }); + + test("fails closed when adjacent ownership metadata is unreadable or contradictory", () => { + const path = "/data/installs/ocx-local/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = "/data/installs/ocx-local/.mise.backend.toml"; + expect(detectInstallOwnershipFromPath(path, { + exists: value => value === metadata, + probe: metadataProbe(value => value === metadata), + readFile: () => { throw new Error("denied"); }, + realpath: value => value, + })).toEqual({ installer: "mise", owner: null, error: "metadata_unreadable" }); + + expect(detectInstallOwnershipFromPath(path, { + exists: value => value === metadata, + probe: metadataProbe(value => value === metadata), + readFile: () => 'short = "different-alias"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: value => value, + })).toEqual({ installer: "mise", owner: null, error: "metadata_inconsistent" }); + }); + + test("fails closed when lexical and resolved ownership evidence disagree", () => { + const lexical = "/data/installs/ocx-local/latest/node_modules/@bitkyc08/opencodex/bin"; + const resolved = "/other/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(lexical, { + exists: value => value.endsWith("/.mise.backend.toml"), + probe: metadataProbe(value => value.endsWith("/.mise.backend.toml")), + readFile: value => value.startsWith("/data/") + ? BACKEND + : 'short = "opencodex"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: () => resolved, + })).toEqual({ installer: "mise", owner: null, error: "metadata_inconsistent" }); + }); + + test("keeps a broken ownership boundary authoritative when the other path verifies", () => { + const lexical = "/data/installs/ocx-local/latest/node_modules/@bitkyc08/opencodex/bin"; + const resolved = "/other/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(lexical, { + exists: value => value.endsWith("/.mise.backend.toml"), + probe: metadataProbe(value => value.endsWith("/.mise.backend.toml")), + readFile: value => { + if (value.startsWith("/other/")) throw new Error("denied"); + return BACKEND; + }, + realpath: () => resolved, + })).toEqual({ installer: "mise", owner: null, error: "metadata_unreadable" }); + }); + + test("does not let a verified owner override contradictory metadata on the other path", () => { + const lexical = "/data/installs/ocx-local/latest/node_modules/@bitkyc08/opencodex/bin"; + const resolved = "/other/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(lexical, { + exists: value => value.endsWith("/.mise.backend.toml"), + probe: metadataProbe(value => value.endsWith("/.mise.backend.toml")), + readFile: value => value.startsWith("/data/") + ? BACKEND + : 'short = "different-alias"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: () => resolved, + })).toEqual({ installer: "mise", owner: null, error: "metadata_inconsistent" }); + }); + + test("handles Windows spelling without treating path case as an ownership mismatch", () => { + const lexical = "C:\\Data Root\\mise\\installs\\OpenCodex\\2.59.0\\node_modules\\@bitkyc08\\opencodex\\bin"; + const resolved = "C:/Data Root/mise/installs/opencodex/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + const metadata = new Set([ + "C:/Data Root/mise/installs/OpenCodex/.mise.backend.toml", + "C:/Data Root/mise/installs/opencodex/.mise.backend.toml", + ]); + expect(detectInstallOwnershipFromPath(lexical, { + exists: value => metadata.has(value), + probe: metadataProbe(value => metadata.has(value)), + readFile: () => 'short = "opencodex"\nfull = "npm:@bitkyc08/opencodex"\n', + realpath: () => resolved, + })).toMatchObject({ installer: "mise", owner: { tool: "opencodex" } }); + }); + + test("fails closed when probing adjacent metadata is unreadable", () => { + const path = "/data/installs/ocx-local/2.59.0/node_modules/@bitkyc08/opencodex/bin"; + expect(detectInstallOwnershipFromPath(path, { + exists: () => false, + probe: () => "unreadable", + realpath: value => value, + })).toEqual({ installer: "mise", owner: null, error: "metadata_unreadable" }); + }); +}); + +describe("mise update refusal", () => { + const ownership: InstallOwnership = { + installer: "mise", + owner: { + tool: "ocx-local", + backend: "npm:@bitkyc08/opencodex", + installPath: "/data/installs/ocx-local/2.59.0", + toolRoot: "/data/installs/ocx-local", + }, + }; + + test("read-only checks succeed with actionable external-management guidance", () => { + const result = checkForUpdate("preview", { + currentVersion: () => "2.59.0", + detectInstall: () => "npm", + detectInstallOwnership: () => ownership, + latestVersion: () => "2.60.0-preview.1", + miseUpdateCommand: value => value.installer === "mise" && value.owner + ? `mise upgrade ${value.owner.tool}` + : null, + }); + + expect(result).toMatchObject({ + installer: "mise", + canUpdate: false, + reason: "externally_managed", + command: "mise upgrade ocx-local", + channel: "preview", + }); + }); + + test("invalid metadata never invents a tool name", () => { + const result = checkForUpdate("latest", { + currentVersion: () => "2.59.0", + detectInstall: () => "mise", + detectInstallOwnership: () => ({ + installer: "mise", + owner: null, + error: "metadata_inconsistent", + }), + latestVersion: () => "2.60.0", + miseUpdateCommand: () => null, + }); + expect(result).toMatchObject({ + installer: "mise", + canUpdate: false, + reason: "external_ownership_invalid", + command: "", + }); + }); + + test("the sidebar badge can report availability without offering mutation", () => { + const badge = readUpdateBadge({ + currentVersion: () => "2.59.0", + detectInstall: () => "mise", + readCache: () => ({ + latest_version: "2.60.0", + last_checked_at: new Date().toISOString(), + tag: "latest", + }), + }); + expect(badge.updateAvailable).toBe(true); + expect(badge.canUpdate).toBe(false); + expect(badge.installer).toBe("mise"); + }); + + test("dashboard update requests are rejected before a worker is created", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-mise-job-")); + const previousHome = process.env.OPENCODEX_HOME; + let spawned = false; + process.env.OPENCODEX_HOME = root; + try { + let thrown: unknown; + try { + startUpdateJob("latest", true, { + checkForUpdateFn: () => ({ + currentVersion: "2.59.0", + latestVersion: "2.60.0", + channel: "latest", + installer: "mise", + updateAvailable: true, + canUpdate: false, + reason: "externally_managed", + command: "mise upgrade ocx-local", + releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + }), + spawnWorkerFn: () => { + spawned = true; + throw new Error("must not spawn"); + }, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(UpdateJobError); + expect(thrown).toMatchObject({ code: "externally_managed", status: 409 }); + expect(spawned).toBe(false); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(root, { recursive: true, force: true }); + } + }); + + function runLauncherUpdate(backend: string) { + const root = mkdtempSync(join(tmpdir(), "ocx-mise-launcher-")); + const toolRoot = join(root, "data root", "installs", "ocx-local"); + const packageParent = join(toolRoot, "2.59.0", "node_modules", "@bitkyc08"); + const packagePath = join(packageParent, "opencodex"); + const fakeBin = join(root, "fake-bin"); + const npmCalled = join(fakeBin, "npm-called"); + mkdirSync(packageParent, { recursive: true }); + mkdirSync(fakeBin); + // A junction needs no privilege on Windows; a directory symlink is the POSIX equivalent. + symlinkSync( + join(import.meta.dir, "..", ".."), + packagePath, + process.platform === "win32" ? "junction" : "dir", + ); + writeFileSync(join(toolRoot, ".mise.backend.toml"), backend); + // The fake npm records that it ran: a refusal must happen before any npm invocation. + if (process.platform === "win32") { + writeFileSync(join(fakeBin, "npm.cmd"), `@echo off\r\necho called> "${npmCalled}"\r\necho 2.59.0\r\n`); + } else { + const fakeNpm = join(fakeBin, "npm"); + writeFileSync(fakeNpm, `#!/bin/sh\n: > '${npmCalled}'\nprintf '%s\n' 2.59.0\n`); + chmodSync(fakeNpm, 0o755); + } + const result = spawnSync( + "node", + ["--preserve-symlinks-main", join(packagePath, "bin", "ocx.mjs"), "update", "--tag", "preview"], + { + encoding: "utf8", + env: { + ...process.env, + OPENCODEX_HOME: join(root, "state"), + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + }, + }, + ); + return { root, result, npmRan: existsSync(npmCalled), stateCreated: existsSync(join(root, "state")) }; + } + + test("the published Node launcher refuses before npm or Bun update handling", () => { + const { root, result, npmRan } = runLauncherUpdate(BACKEND); + try { + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("externally managed by mise"); + expect(result.stderr).toContain("mise upgrade ocx-local"); + expect(result.stderr).not.toContain("tag preview"); + expect(npmRan).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("the launcher refuses contradictory mise metadata without naming a tool or running npm", () => { + const contradictory = 'short = "ocx-local"\nfull = "npm:some-other-package"\nexplicit_backend = false\n'; + const { root, result, npmRan } = runLauncherUpdate(contradictory); + try { + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("ownership metadata is unreadable or inconsistent"); + expect(result.stderr).not.toContain("mise upgrade"); + expect(npmRan).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); From 9d1fa875702110dd382f5553e0631e4e40185d60 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 20:50:03 +0900 Subject: [PATCH 09/48] =?UTF-8?q?fix(claude):=20bundle=20lane=20C=20?= =?UTF-8?q?=E2=80=94=20routed=20windows=20with=20compact,=20picker=20descr?= =?UTF-8?q?iptions,=20launchd=20levers,=20passthrough=20tool=20ids=20(#567?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(claude): cover bounded skill marker paths and document pass-through Carries #5606. Adds the exact 4,096/4,097 UTF-16 code unit boundary cases for POSIX and Windows skill directory markers and a long newline-free carrier, and documents that a longer directory line is sent unchanged. The seven translated Claude Code guides now state the same pass-through rule as the English guide. Supersedes #5606. Co-authored-by: Epinephrine <27862058+luvs01@users.noreply.github.com> * fix(system-env): refresh and drop the launchd levers opencodex owns Carries #5622. A lever opencodex already tracks as injected is refreshed instead of skipped, a tracked lever the current config no longer produces is unset, and PUT /api/claude-code reconciles on every model slot and lever field, not only systemEnv and authMode. A launchd value the user set before injection is never touched. The management API structure note records the PUT reconciliation. Supersedes #5622. Co-authored-by: terin <100397903+sh940701@users.noreply.github.com> * feat(claude): describe routed models instead of "From gateway" Carries #5621. Readable Claude Code /v1/models rows carry a description naming the native model or provider/model they route to, which Claude Code 2.1.257 and later shows in the picker; the 1M copy keeps it and a Fast sibling appends " · Fast". The gateway-model cache preserves string descriptions. The Claude Desktop structure note records the picker description contract. Supersedes #5621. Co-authored-by: terin <100397903+sh940701@users.noreply.github.com> * fix(claude): keep routed model windows without disabling compact Carries #5665. Claude Code aliases are minted as ocx-claude-/ocx-claude2- so the picker keeps them while Claude Code applies CLAUDE_CODE_MAX_CONTEXT_TOKENS without DISABLE_COMPACT; saved claude-ocx-/claude-ocx2- ids keep decoding. maxContextTokens now injects only the window. The gateway cache mirrors the picker's contains-claude rule and keeps #5621's descriptions. Folded review fixes: native fallback recognizes the current prefixes; the context-window map keeps registering the legacy spellings at the same window; no compact lever is ever derived from maxContextTokens (pinned for values outside the 100k-1M compact range); the tracked DISABLE_COMPACT from older releases is removed by the system-env produced-key sweep instead of a one-off cleanup; the Claude Desktop structure note records the alias and env contract. Supersedes #5665. Co-authored-by: terin <100397903+sh940701@users.noreply.github.com> * fix(claude): sanitize tool_use ids on native Anthropic passthrough Carries #5628. The native passthrough bypasses the Anthropic adapter, so tool call ids minted by routed models (Devin's Bash:0#) reached api.anthropic.com verbatim and 400ed. The request-scoped allocator now rewrites non-conforming and overlength ids, keeps call/result pairing, and leaves conforming ids byte-identical. Folded review fixes: an empty id fails locally with a 400 before the upstream fetch instead of being forwarded; regressions cover the empty id, an overlength id and a collision with an existing valid id; the Claude Desktop structure note and the Claude Code guide in all eight languages describe the id rewrite. Supersedes #5628. Co-authored-by: wuwei <27188611+Haven2026@users.noreply.github.com> * fix(claude): keep legacy slot selectors and hand-edited DISABLE_COMPACT safe on upgrade Folds the bundle's adversarial review into the #5665 and #5622 carries. A legacy claude-ocx-/claude-ocx2- selector configured in an OpenCodex model slot is emitted in its current ocx-claude spelling on every env path. The route is identical, and Claude Code then applies the configured window instead of falling back to 200k accounting now that DISABLE_COMPACT is no longer paired with maxContextTokens. A selection saved by Claude Code's own picker stays a documented re-pick. The system-env sweep removes a tracked DISABLE_COMPACT only while it still holds the 1 older releases injected; a value the user changed by hand is released from tracking without being deleted. Co-authored-by: terin <100397903+sh940701@users.noreply.github.com> * fix(claude): keep legacy picker selectors on connected clients and Fable passthrough Folds the Codex and CodeRabbit review of the bundle. A legacy claude-ocx-native--claude-fable-* picker value compared only against the new ocx-claude spelling and fell off the native Anthropic passthrough; both spellings are accepted again, with the legacy value back in the endpoint test. The connected-client context-window map registers the legacy route and native spellings like the local map does, so a saved legacy selector keeps its [1m] subagent marker on a connected hub. Co-authored-by: terin <100397903+sh940701@users.noreply.github.com> --------- Co-authored-by: Epinephrine <27862058+luvs01@users.noreply.github.com> Co-authored-by: terin <100397903+sh940701@users.noreply.github.com> Co-authored-by: wuwei <27188611+Haven2026@users.noreply.github.com> --- .../src/content/docs/fr/guides/claude-code.md | 25 ++- .../src/content/docs/guides/claude-code.md | 29 ++-- .../src/content/docs/ja/guides/claude-code.md | 23 ++- .../src/content/docs/ko/guides/claude-code.md | 20 ++- .../docs/reference/configuration/providers.md | 2 +- .../src/content/docs/ru/guides/claude-code.md | 26 +-- .../src/content/docs/tr/guides/claude-code.md | 33 ++-- .../content/docs/zh-cn/guides/claude-code.md | 25 ++- .../content/docs/zh-tw/guides/claude-code.md | 27 ++- src/claude/alias.ts | 99 ++++++++--- src/claude/context-windows.ts | 21 ++- src/claude/gateway-cache.ts | 17 +- src/claude/model-info.ts | 22 ++- src/cli/claude.ts | 19 ++- src/server/claude-messages.ts | 45 ++++- .../management/agent-settings-routes.ts | 8 +- src/server/system-env-shell.ts | 1 - src/server/system-env.ts | 29 +++- src/types/config.ts | 12 +- structure/clients/claude-desktop.md | 18 ++ structure/gui-and-management-api.md | 2 +- .../claude-agents-inject.test.ts | 52 +++--- tests/claude-integration/claude-alias.test.ts | 53 +++--- tests/claude-integration/claude-cli.test.ts | 50 +++++- .../claude-context-windows.test.ts | 14 +- .../claude-desktop-discovery.test.ts | 2 +- .../claude-gateway-cache.test.ts | 27 +++ .../claude-integration/claude-inbound.test.ts | 28 ++++ .../claude-management-api.test.ts | 46 +++++- .../claude-messages-endpoint.test.ts | 4 +- .../claude-model-info.test.ts | 44 ++++- .../claude-models-discovery.test.ts | 4 +- .../claude-native-passthrough.test.ts | 154 +++++++++++++++++- .../cursor/cursor-fast-listing.test.ts | 12 +- tests/server/system-env.test.ts | 120 +++++++++++++- 35 files changed, 894 insertions(+), 219 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 8c76860e6d2..c31ec48ff41 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -57,7 +57,7 @@ ocx claude | `ANTHROPIC_DEFAULT_{OPUS,SONNET,FABLE}_MODEL` | `claudeCode.tierModels.*` (facultatif) | | `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT` | `1` lorsque `alwaysEnableEffort` est activé (conditionnel) | | `ENABLE_TOOL_SEARCH` | `claudeCode.toolSearch` lorsqu'il est défini (conditionnel ; désactivé par défaut) | -| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | Remplacement du contexte hérité lorsque `maxContextTokens` est défini (conditionnel) | +| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Remplacement du contexte hérité lorsque `maxContextTokens` est défini (conditionnel) | Les variables que vous exportez vous-même gagnent toujours. Les arguments supplémentaires passent par : `ocx claude -p "hello"`. Une exception porte sur *l'origine* d'une variable, et non sur sa priorité. L'environnement d'exécution Bun fourni @@ -233,6 +233,8 @@ la résolution par alias et carte des modèles renvoie le même modèle sans mod l'en-tête d'admission dédié du proxy est valide. Par conséquent, l'avertissement « Les connecteurs claude.ai sont désactivés » n'apparaît plus avec `ocx claude`. +La seule modification du corps concerne les identifiants d'appel d'outil. Un `tool_use.id` ou `tool_result.tool_use_id` qu'Anthropic refuserait (caractères hors de `a-zA-Z0-9_-`, ou plus de 64), par exemple créé plus tôt dans la session par un modèle routé, est réécrit en identifiant conforme sans rompre l'appariement appel/résultat. Les identifiants conformes sont envoyés tels quels, et un identifiant vide reçoit une erreur 400 locale. + Désactivez ce comportement avec `claudeCode.nativePassthrough: false` ; définissez une autre destination avec `claudeCode.anthropicBaseUrl`. @@ -300,12 +302,13 @@ pas les copies externes ; révoquez-la séparément sur le hub si nécessaire. ## Le sélecteur /model (« Depuis la passerelle ») Claude Code 2.1.129+ découvre les modèles de passerelle via `GET /v1/models?limit=1000` et les répertorie dans -le sélecteur natif `/model` intitulé « Depuis la passerelle ». Comme ce sélecteur n'accepte que les identifiants commençant -par `claude` ou `anthropic`, opencodex expose les modèles routés sous forme d'alias stables et réversibles : +le sélecteur natif `/model`. Une ligne sans `description` affiche « From gateway » ; opencodex en envoie une pour +chaque ligne du CLI Claude Code (`Routed by OpenCodex to /` ; lignes natives : `Routed by OpenCodex to native ` ; les lignes Fast ajoutent ` · Fast` et les lignes 1M gardent la description de base), que Claude Code 2.1.257+ affiche +à la place. Claude Code 2.1.278 accepte un identifiant qui contient `claude` ou `anthropic`. Un identifiant inconnu qui commence par `claude-` est compté à 200k sauf si le compactage est désactivé, donc opencodex expose les modèles routés sous forme d'alias stables et réversibles qui contiennent `claude` sans commencer par `claude-` : | Surface | Format | Exemple | | --- | --- | --- | -| Claude Code CLI | `claude-ocx---` (simple) ou `claude-ocx2-…` (échappé) | `claude-ocx-native--gpt-5.6-sol` | +| Claude Code CLI | `ocx-claude---` (simple) ou `ocx-claude2-…` (échappé) | `ocx-claude-native--gpt-5.6-sol` | | Claude Desktop 3P | `claude-opus-4-8-` (hachage base36 de 3 caractères) | `claude-opus-4-8-ncb` | Le proxy choisit la famille pour chaque requête : `?ids=cli` ou `?ids=desktop` est prioritaire ; à défaut, l'agent utilisateur @@ -316,7 +319,10 @@ Chaque entrée porte un nom d'affichage explicite, comme `gemini-3-pro (gemini)` Desktop peut ainsi proposer son sélecteur d'effort. Les véritables modèles Anthropic conservent leurs identifiants canoniques. La date synthétique 2026 désigne un emplacement interne, et non une date de publication. Les anciens alias hachés et les identifiants `claude-ocx---` des configurations antérieures sont -toujours résolus. +toujours résolus, tout comme les identifiants échappés `claude-ocx2---`. Un identifiant hérité +enregistré est toujours acheminé, mais Claude Code continue de le compter à 200k. Choisissez une fois `ocx-claude-` +à la place d'un `claude-ocx-` enregistré, et `ocx-claude2-` à la place d'un `claude-ocx2-` échappé, pour que la vraie +fenêtre de contexte et le compactage s'appliquent tous les deux. Si le sélecteur situé au bas de Claude Desktop ne modifie pas le modèle d'une conversation 3P déjà en cours, vous pouvez essayer `/model `, mais ce contournement peut également échouer sur les versions de Desktop @@ -338,9 +344,9 @@ résolu vers le modèle routé. Avec les anciennes versions de Claude Code, le s `ANTHROPIC_MODEL` ou tapez n'importe quel identifiant routé avec `/model` (Claude Code fait passer les chaînes). **Règles de grammaire des alias :** le fournisseur ne doit contenir ni `/` ni `--`, et ne doit pas être égal à `native`. -Les identifiants de modèle simples, sans `/` ni `~`, conservent le préfixe v1 `claude-ocx-…`. Ceux qui contiennent `/` ou -`~` utilisent le préfixe v2 `claude-ocx2-…` avec des échappements (`/` → `~s`, `~` → `~t`), par exemple : -`openrouter/anthropic/claude-opus-4-8` → `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`. +Les identifiants de modèle simples, sans `/` ni `~`, conservent le préfixe v1 `ocx-claude-…`. Ceux qui contiennent `/` ou +`~` utilisent le préfixe v2 `ocx-claude2-…` avec des échappements (`/` → `~s`, `~` → `~t`), par exemple : +`openrouter/anthropic/claude-opus-4-8` → `ocx-claude2-openrouter--anthropic~sclaude-opus-4-8`. Les alias v1 décodent littéralement (donc un identifiant de modèle historique qui contenait les séquences de deux caractères `~s` / `~t` est conservé) ; les alias v2 développent les échappements. Les routes impossibles à représenter sous une forme lisible utilisent l'alias haché. Les identifiants de modèle peuvent contenir `--` (la résolution se sépare uniquement au premier @@ -434,7 +440,8 @@ Le transfert Anthropic natif reste intact. remplacé par un contenu minimal lorsque l'entrée JSON en minuscules contient un nom bloqué. 2. **Vecteur de bloc de texte :** un bloc de texte utilisateur d'au moins 10 000 caractères commençant par `Base directory for this skill: ` — est reconnu lorsque le nom de base du répertoire correspond à un nom bloqué - (insensible à la casse). + (insensible à la casse). La ligne du répertoire n'est inspectée que jusqu'à 4 096 unités de code UTF-16 ; + une ligne plus longue est envoyée telle quelle, y compris sans saut de ligne final. Configurez cette fonction avec `claudeCode.blockedSkills` (`["claude-api"]` par défaut ; `[]` désactive entièrement l'élision). Le contenu de remplacement préserve l'association entre l'appel d'outil et son résultat. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index d3ded0de806..c0cfa723b3d 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -83,7 +83,7 @@ ocx claude | `ANTHROPIC_DEFAULT_{OPUS,SONNET,FABLE}_MODEL` | `claudeCode.tierModels.*` (optional) | | `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT` | `1` when `alwaysEnableEffort` is on (conditional) | | `ENABLE_TOOL_SEARCH` | `claudeCode.toolSearch` when set (conditional; off by default — see [MCP tool schemas fill the context](#troubleshooting)) | -| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | Legacy context override when `maxContextTokens` is set (conditional) | +| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Legacy context override when `maxContextTokens` is set (conditional) | Variables you export yourself always win. Extra arguments pass through: `ocx claude -p "hello"`. One exception is about *where* a variable comes from, not about precedence. The bundled Bun @@ -277,6 +277,12 @@ When `claudeCode.systemEnv` is set to `true` (default: **off**), `ocx start` use to inject `ANTHROPIC_BASE_URL` and the related Claude Code environment variables system-wide. New terminal windows and tabs therefore route plain `claude` commands through the proxy without requiring the `ocx claude` wrapper. Already-open shells are unaffected and must be reopened. +Changing a model slot or lever (`smallFastModel`, `tierModels`, `maxContextTokens`, auto-context, +`autoCompactWindow`, `alwaysEnableEffort`) re-applies the injection right away: a key opencodex injected earlier is +updated, or unset once the setting no longer produces it. A value you set yourself with +`launchctl setenv` before opencodex injects that key is never overwritten or removed; a key +opencodex injected stays opencodex-owned (refreshed, unset, and removed by `ocx stop`) even if +you change its value by hand. `ocx stop` and proxy shutdown **unset the injected keys** (it does not restore previous values — only the keys opencodex injected are removed). The proxy also writes `~/.opencodex/claude-env.sh`; @@ -309,6 +315,8 @@ alias/model-map resolution returns the same model unchanged; and, on a non-loopb dedicated proxy admission header is valid. This also means the "claude.ai connectors are disabled" warning no longer appears with `ocx claude`. +The one change to the body is tool-call ids. A `tool_use.id` or `tool_result.tool_use_id` that Anthropic would reject (characters outside `a-zA-Z0-9_-`, or longer than 64), such as one a routed model minted earlier in the session, is rewritten to a conforming id with its call/result pairing kept. Conforming ids are sent unchanged, and an empty id is answered with a local 400. + Disable with `claudeCode.nativePassthrough: false`; point elsewhere with `claudeCode.anthropicBaseUrl`. @@ -375,12 +383,13 @@ arbitrary external copies; revoke separately on the hub if desired. ## The /model picker ("From gateway") Claude Code 2.1.129+ discovers gateway models via `GET /v1/models?limit=1000` and lists them in -the native `/model` picker labeled "From gateway". Because the picker only accepts ids beginning -with `claude` or `anthropic`, opencodex exposes routed models as stable, reversible aliases: +the native `/model` picker. A row without a `description` reads "From gateway"; opencodex sends one +for every Claude Code CLI row (`Routed by OpenCodex to /`; native rows use `Routed by OpenCodex to native `, Fast rows append ` · Fast`, and 1M rows keep the base description), which Claude Code +2.1.257+ shows in its place. Claude Code 2.1.278 accepts a picker id that contains `claude` or `anthropic`. An unrecognized id that starts with `claude-` is accounted at 200k unless compact is disabled, so opencodex exposes routed models as stable, reversible aliases that contain `claude` but do not start with `claude-`: | Surface | Format | Example | | --- | --- | --- | -| Claude Code CLI | `claude-ocx---` (plain) or `claude-ocx2-…` (escaped) | `claude-ocx-native--gpt-5.6-sol` | +| Claude Code CLI | `ocx-claude---` (plain) or `ocx-claude2-…` (escaped) | `ocx-claude-native--gpt-5.6-sol` | | Claude Desktop 3P | `claude-opus-4-8-` (3-char base36 hash) | `claude-opus-4-8-ncb` | The proxy picks the family per request: `?ids=cli` or `?ids=desktop` wins; otherwise the @@ -389,8 +398,7 @@ Both families decode forever — a model saved in `settings.json` under either f Each entry carries an honest display name such as `gemini-3-pro (gemini)`, plus full model capabilities (reasoning-effort ladder, thinking types) in the official ModelInfo shape so Claude Desktop's third-party gateway mode can offer its effort selector. Real Anthropic models keep their -canonical ids. The synthetic 2026 date is an internal slot, not a release date. Legacy hash aliases -and `claude-ocx---` ids from older configs still resolve. +canonical ids. The synthetic 2026 date is an internal slot, not a release date. Legacy hash aliases and `claude-ocx---` / `claude-ocx2---` ids from older configs still resolve. A legacy id configured in an OpenCodex model slot (`claudeCode.model`, `tierModels`, `smallFastModel`) is sent to Claude Code in its current spelling automatically. A legacy id saved by Claude Code's own picker still routes, but Claude Code keeps its 200k accounting for that id. Pick `ocx-claude-` for a saved `claude-ocx-` id, and `ocx-claude2-` for a saved escaped `claude-ocx2-` id, so the real context window and compact both apply. If Claude Desktop's footer picker does not change the model for an already-running 3P conversation, you can try `/model `, but this workaround may also fail on affected Desktop @@ -413,9 +421,9 @@ slots via `ANTHROPIC_MODEL` or type any routed id with `/model` (Claude Code passes strings through). **Alias grammar rules:** provider must not contain `/` or `--` or equal `native`. -Plain model ids (no `/` or `~`) keep the v1 prefix `claude-ocx-…`. Model ids that contain `/` or -`~` mint the v2 prefix `claude-ocx2-…` with escapes (`/` → `~s`, `~` → `~t`), e.g. -`openrouter/anthropic/claude-opus-4-8` → `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`. +Plain model ids (no `/` or `~`) keep the v1 prefix `ocx-claude-…`. Model ids that contain `/` or +`~` mint the v2 prefix `ocx-claude2-…` with escapes (`/` → `~s`, `~` → `~t`), e.g. +`openrouter/anthropic/claude-opus-4-8` → `ocx-claude2-openrouter--anthropic~sclaude-opus-4-8`. v1 aliases decode literally (so a historical model id that contained the two-char sequences `~s` / `~t` is preserved); v2 aliases expand the escapes. Routes that the readable form cannot express fall back to the hashed alias. Model ids MAY contain `--` (resolution splits on the first @@ -510,7 +518,8 @@ Anthropic passthrough is untouched. replaced by a stub when the lowercased JSON input contains a blocked name. 2. **Text-block carrier:** a user text block ≥10,000 characters starting with `Base directory for this skill: ` — matched when the directory basename equals a blocked name - (case-insensitive). + (case-insensitive). The directory line is inspected only up to 4,096 UTF-16 code units; + a longer line is sent unchanged, including when it has no terminating newline. Configure with `claudeCode.blockedSkills` (default `["claude-api"]`; `[]` disables elision entirely). The stub keeps tool call/result pairing intact. diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 3d03447eeee..2c654d3b6e8 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -26,7 +26,7 @@ ocx claude | `ANTHROPIC_DEFAULT_{OPUS,SONNET,FABLE}_MODEL` | `claudeCode.tierModels.*` (任意) | | `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT` | `alwaysEnableEffort` がオンなら `1` (条件付き) | | `ENABLE_TOOL_SEARCH` | `claudeCode.toolSearch` が設定されている場合 (条件付き、既定はオフ) | -| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | `maxContextTokens` が設定された場合の従来コンテキスト上書き値 (条件付き) | +| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | `maxContextTokens` が設定された場合の従来コンテキスト上書き値 (条件付き) | 直接 export した変数が常に優先します。追加引数はそのまま渡されます: `ocx claude -p "hello"`。 ### Claude ルーティングが無効なときのネイティブフォールバック @@ -92,6 +92,8 @@ hook を削除します。Claude Desktop は独立した profile を使用し、 では専用プロキシ admission ヘッダーも有効であること。そのため `ocx claude` を 使うとき "claude.ai connectors are disabled" 警告ももう表示されません。 +本文で変更するのはツール呼び出し ID だけです。Anthropic が拒否する `tool_use.id` や `tool_result.tool_use_id`(`a-zA-Z0-9_-` 以外の文字を含むもの、または 64 文字を超えるもの。セッション中にルーティングモデルが作った ID など)は、呼び出しと結果の対応を保ったまま適合する ID に書き換えます。適合する ID はそのまま送り、空の ID にはローカルで 400 を返します。 + `claudeCode.nativePassthrough: false` でオフにでき、`claudeCode.anthropicBaseUrl` で別のアドレスを 指定できます。 @@ -174,18 +176,22 @@ Claude Code CLI 互換性は英語版ドキュメントを参照してくださ ## /model ピッカー("From gateway") Claude Code 2.1.129 以降は `GET /v1/models?limit=1000` でゲートウェイモデルを探し、デフォルトの `/model` -ピッカーの "From gateway" 項目に表示します。ピッカーは `claude` または `anthropic` で始まる ID のみ -受け付けるため、opencodex はルーティングモデルを安定で元に戻せるエイリアスとして公開します。 +ピッカーに表示します。`description` のない行は "From gateway" と表示されます。opencodex は Claude Code CLI +向けの各行に `description`(`Routed by OpenCodex to /`、ネイティブ行は `Routed by OpenCodex to native `、Fast 行は末尾に ` · Fast`、1M 行は元の説明のまま)を送り、Claude Code 2.1.257 以降は +その内容を代わりに表示します。Claude Code 2.1.278 のピッカーは `claude` または `anthropic` を含む ID を受け付けます。`claude-` で始まる未知の ID は compact を無効にしない限り 200k として計算されるため、opencodex はルーティングモデルを `claude` を含みつつ `claude-` で始まらない安定した可逆エイリアスとして公開します。 | 画面 | 形式 | 例 | | --- | --- | --- | -| Claude Code CLI | `claude-ocx---` (plain) または `claude-ocx2-…` (escaped) | `claude-ocx-native--gpt-5.6-sol` | +| Claude Code CLI | `ocx-claude---` (plain) または `ocx-claude2-…` (escaped) | `ocx-claude-native--gpt-5.6-sol` | | Claude Desktop 3P | `claude-opus-4-8-` (3 桁の base36 ハッシュ) | `claude-opus-4-8-ncb` | プロキシはリクエストごとに系列を選びます。`?ids=cli` または `?ids=desktop` が優先し、指定しないと `claude-code/*` user-agent には読みやすい CLI 形式を、他のクライアントには Desktop ハッシュを 提供します。両系列は継続してデコードできるため、どちらの形式でも `settings.json` に保存したモデルは -引き続き動作します。 +引き続き動作します。古い設定の `claude-ocx---` / `claude-ocx2---` も +引き続き解決されますが、保存済みの旧 ID はルーティングされても Claude Code 側では 200k として計算されます。 +保存済みの `claude-ocx-` は `ocx-claude-` に、エスケープ付きの `claude-ocx2-` は `ocx-claude2-` に一度選び直すと、 +実際のコンテキストウィンドウと compact が両方とも適用されます。 Claude Desktop のフッターピッカーで実行中の 3P 会話のモデルが切り替わらない場合は、 `/model ` を試せますが、影響を受ける Desktop ビルドではこの回避策も失敗することがあります。 @@ -201,9 +207,9 @@ OpenCodex の Claude Desktop プロファイルで希望するデフォルトモ **Logs → requestedModel** で確認してください。 **エイリアス構文ルール:** provider には `/` や `--` を含められず `native` と同じでもいけません。 -`/` も `~` も含まない plain な model ID は v1 接頭辞 `claude-ocx-…` のままです。`/` または `~` を含む -model ID は v2 接頭辞 `claude-ocx2-…` で発行し、エスケープします(`/` → `~s`、`~` → `~t`)。例: -`openrouter/anthropic/claude-opus-4-8` → `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`。 +`/` も `~` も含まない plain な model ID は v1 接頭辞 `ocx-claude-…` のままです。`/` または `~` を含む +model ID は v2 接頭辞 `ocx-claude2-…` で発行し、エスケープします(`/` → `~s`、`~` → `~t`)。例: +`openrouter/anthropic/claude-opus-4-8` → `ocx-claude2-openrouter--anthropic~sclaude-opus-4-8`。 v1 エイリアスはリテラルにデコードします(歴史的に model ID に含まれていた 2 文字列 `~s` / `~t` も保持)。 v2 エイリアスはエスケープを展開します。読みやすい形式で表現できないルートはハッシュエイリアスに 置き換えます。モデル ID には `--` を含め**られます**(解析時は最初の `--` だけを基準に分割します)。 @@ -294,6 +300,7 @@ Anthropic パススルーはそのまま維持します。 含まれる場合、対になる `tool_result` 本体をスタブに差し替えます。 2. **テキストブロック配信:** `Base directory for this skill: ` で始まる 10,000 文字以上のユーザー テキストブロックでディレクトリ basename がブロック名と一致するか確認します(大文字小文字区別なし)。 + ディレクトリ行は UTF-16 コード単位で 4,096 までしか調べません。それより長い行は、末尾に改行がない場合も含めてそのまま送られます。 `claudeCode.blockedSkills` で設定できます(デフォルト `["claude-api"]`、`[]` で省略機能を完全に オフ)。スタブはツール呼び出しと結果の対を維持します。 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 37f17c01974..d91b605ca61 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -26,7 +26,7 @@ ocx claude | `ANTHROPIC_DEFAULT_{OPUS,SONNET,FABLE}_MODEL` | `claudeCode.tierModels.*` (선택 사항) | | `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT` | `alwaysEnableEffort`가 켜져 있으면 `1` (조건부) | | `ENABLE_TOOL_SEARCH` | `claudeCode.toolSearch`가 설정된 경우 (조건부, 기본값은 꺼짐) | -| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | `maxContextTokens`가 설정된 경우 기존 컨텍스트 재정의 값 (조건부) | +| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | `maxContextTokens`가 설정된 경우 기존 컨텍스트 재정의 값 (조건부) | 직접 내보낸 변수가 항상 우선해요. 추가 인자는 그대로 전달돼요: `ocx claude -p "hello"`. ### Claude 라우팅이 꺼져 있을 때의 네이티브 폴백 @@ -115,6 +115,8 @@ hook을 제거해요. Claude Desktop은 별도 profile을 사용하며 shell hoo 프록시 admission 헤더도 유효해야 해요. 그래서 `ocx claude`를 사용할 때 "claude.ai connectors are disabled" 경고도 더 이상 나타나지 않아요. +본문에서 바꾸는 것은 도구 호출 ID뿐이에요. Anthropic이 거부할 `tool_use.id`나 `tool_result.tool_use_id`(`a-zA-Z0-9_-` 밖의 문자가 있거나 64자를 넘는 ID, 예를 들어 세션 앞부분에서 라우팅 모델이 만든 ID)는 호출과 결과의 짝을 유지한 채 규칙에 맞는 ID로 바꿔요. 규칙에 맞는 ID는 그대로 보내고, 빈 ID에는 로컬에서 400을 돌려줘요. + `claudeCode.nativePassthrough: false`로 끌 수 있고, `claudeCode.anthropicBaseUrl`로 다른 주소를 지정할 수 있어요. @@ -200,7 +202,7 @@ import/export는 로컬 설정만 다뤄요. 허브 프로필을 바꾸지 않 능력 정보(추론 강도 사다리, thinking 타입)를 실어 보냅니다 — Claude Desktop의 서드파티 게이트웨이 모드가 추론 강도 선택 UI를 열 수 있게 하기 위해서입니다. 실제 Anthropic 모델은 원래 id를 그대로 유지합니다. 합성된 2026 날짜는 내부 슬롯이며 출시일이 아닙니다. 구버전의 -해시 별칭과 `claude-ocx---` 별칭도 계속 해석됩니다. 컨텍스트가 1M인 모델에는 +해시 별칭과 `claude-ocx---`, `claude-ocx2---` 별칭도 계속 해석됩니다. 저장된 `claude-ocx-`는 `ocx-claude-`로, 이스케이프된 `claude-ocx2-`는 `ocx-claude2-`로 한 번 다시 고르면 실제 컨텍스트 창과 compact가 함께 적용됩니다. 컨텍스트가 1M인 모델에는 `…[1m]` 행이 하나 더 생깁니다 — 이걸 고르면 Claude Code가 그 모델의 컨텍스트를 1M로 계산합니다 (자동 요약 유지, 프록시가 표식을 떼고 라우팅). 선택하면 Claude Code의 `settings.json` `model` 필드에 저장되고, 인바운드 요청에서 @@ -208,12 +210,13 @@ import/export는 로컬 설정만 다뤄요. 허브 프로필을 바꾸지 않 지정하거나 `/model`에 라우팅 id를 직접 입력하세요 (Claude Code는 문자열을 그대로 통과시킵니다). Claude Code 2.1.129 이상은 `GET /v1/models?limit=1000`에서 게이트웨이 모델을 찾아 기본 `/model` -선택기의 "From gateway" 항목에 표시해요. 선택기는 `claude` 또는 `anthropic`으로 시작하는 ID만 -받으므로, opencodex는 라우팅 모델을 안정적이고 되돌릴 수 있는 별칭으로 노출해요. +선택기에 표시해요. `description`이 없는 항목은 "From gateway"로 보이는데, opencodex는 Claude Code CLI용 +항목마다 `description`(`Routed by OpenCodex to /`, 네이티브 항목은 `Routed by OpenCodex to native `, Fast 항목은 끝에 ` · Fast`, 1M 항목은 기본 설명 그대로)을 보내고 Claude Code 2.1.257 이상은 +그 내용을 대신 보여줘요. Claude Code 2.1.278 선택기는 `claude` 또는 `anthropic`을 포함한 ID를 받아요. `claude-`로 시작하는 모르는 ID는 compact를 끄지 않으면 200k로 계산되므로, 라우팅 모델은 `claude`를 포함하되 `claude-`로 시작하지 않는 안정적이고 되돌릴 수 있는 별칭으로 노출해요. | 화면 | 형식 | 예시 | | --- | --- | --- | -| Claude Code CLI | `claude-ocx---` (plain) 또는 `claude-ocx2-…` (escaped) | `claude-ocx-native--gpt-5.6-sol` | +| Claude Code CLI | `ocx-claude---` (plain) 또는 `ocx-claude2-…` (escaped) | `ocx-claude-native--gpt-5.6-sol` | | Claude Desktop 3P | `claude-opus-4-8-` (3자리 base36 해시) | `claude-opus-4-8-ncb` | 프록시는 요청마다 계열을 골라요. `?ids=cli` 또는 `?ids=desktop`이 우선하고, 지정하지 않으면 @@ -234,9 +237,9 @@ OpenCodex의 Claude Desktop 프로필에서 원하는 기본 모델을 선택하 클라이언트가 실제로 무엇을 보내는지는 **Logs → requestedModel**에서 확인하세요. **별칭 문법 규칙:** provider에는 `/`나 `--`를 넣을 수 없고 `native`와 같아도 안 돼요. `/`와 `~`가 -없는 plain model ID는 v1 접두사 `claude-ocx-…`를 유지해요. `/` 또는 `~`가 있는 model ID는 v2 -접두사 `claude-ocx2-…`로 만들고 이스케이프해요(`/` → `~s`, `~` → `~t`). 예: -`openrouter/anthropic/claude-opus-4-8` → `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`. +없는 plain model ID는 v1 접두사 `ocx-claude-…`를 유지해요. `/` 또는 `~`가 있는 model ID는 v2 +접두사 `ocx-claude2-…`로 만들고 이스케이프해요(`/` → `~s`, `~` → `~t`). 예: +`openrouter/anthropic/claude-opus-4-8` → `ocx-claude2-openrouter--anthropic~sclaude-opus-4-8`. v1 별칭은 리터럴로 디코딩해요(예전 model ID에 들어 있던 두 글자 시퀀스 `~s` / `~t`도 그대로 보존). v2 별칭은 이스케이프를 펼쳐요. 읽기 쉬운 형식으로 표현할 수 없는 라우트는 해시 별칭으로 대체해요. 모델 ID에는 `--`를 넣을 **수 있어요**(해석할 때 첫 번째 `--`만 기준으로 나눠요). `--`가 포함된 @@ -329,6 +332,7 @@ Anthropic 패스스루는 그대로 유지해요. 있으면 짝을 이루는 `tool_result` 본문을 스텁으로 바꿔요. 2. **텍스트 블록 전달:** `Base directory for this skill: `로 시작하는 10,000자 이상의 사용자 텍스트 블록에서 디렉터리 basename이 차단된 이름과 일치하는지 확인해요(대소문자 구분 없음). + 디렉터리 줄은 UTF-16 코드 단위 4,096개까지만 검사해요. 그보다 긴 줄은 끝에 줄바꿈이 없어도 그대로 보내요. `claudeCode.blockedSkills`로 설정할 수 있어요(기본값 `["claude-api"]`, `[]`이면 생략 기능을 완전히 꺼요). 스텁은 도구 호출과 결과의 짝을 유지해요. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index e61dd417923..2ea195aded2 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -601,7 +601,7 @@ and for `claude-opus-5` its regular family is quarantined upstream. | Surface | `fastMode: true` | |---|---| | Codex | rows stay umbrella rows; the app's Fast toggle selects the variant | -| Claude Code (`?ids=cli`) | lists the fast identity, e.g. `claude-ocx-cursor--claude-opus-5-thinking-fast` | +| Claude Code (`?ids=cli`) | lists the fast identity, e.g. `ocx-claude-cursor--claude-opus-5-thinking-fast` | | OpenAI `/v1/models` | lists `cursor/claude-opus-5-thinking-fast` | | Claude Desktop (3P) | unchanged — its aliases are hashed from the model name | | Dashboard `/api/models` | row ids unchanged; they are the enable/disable keys | diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 927394e95b8..26967b7d8e8 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -27,7 +27,7 @@ ocx claude | `ANTHROPIC_DEFAULT_{OPUS,SONNET,FABLE}_MODEL` | `claudeCode.tierModels.*` (необязательно) | | `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT` | `1`, когда включён `alwaysEnableEffort` (условно) | | `ENABLE_TOOL_SEARCH` | `claudeCode.toolSearch`, когда задан (условно; по умолчанию выключено) | -| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | Устаревшее переопределение контекста, когда задан `maxContextTokens` (условно) | +| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Устаревшее переопределение контекста, когда задан `maxContextTokens` (условно) | Переменные, которые вы экспортируете сами, всегда имеют приоритет. Дополнительные аргументы передаются как есть: `ocx claude -p "hello"`. ### Нативный запасной запуск, когда маршрутизация Claude выключена @@ -99,6 +99,8 @@ Proxy admission secret в любом provider-заголовке удаляет валиден dedicated proxy admission-header. Это также означает, что предупреждение «claude.ai connectors are disabled» с `ocx claude` больше не появляется. +Единственное изменение тела касается идентификаторов вызовов инструментов. `tool_use.id` или `tool_result.tool_use_id`, которые Anthropic отклонил бы (символы вне `a-zA-Z0-9_-` или длина больше 64), например созданные маршрутизируемой моделью раньше в сессии, переписываются в допустимые с сохранением пары вызов/результат. Допустимые идентификаторы отправляются без изменений, а на пустой идентификатор локально возвращается 400. + Отключается параметром `claudeCode.nativePassthrough: false`; другой адрес задаётся через `claudeCode.anthropicBaseUrl`. @@ -162,19 +164,22 @@ Claude Desktop: работающий процесс может хранить п ## Селектор /model («From gateway») Claude Code 2.1.129+ обнаруживает модели шлюза через `GET /v1/models?limit=1000` и показывает их -в нативном селекторе `/model` в разделе «From gateway». Поскольку селектор принимает только id, -начинающиеся с `claude` или `anthropic`, opencodex публикует маршрутизируемые модели как -стабильные обратимые алиасы: +в нативном селекторе `/model`. Строка без `description` подписана «From gateway»; opencodex отправляет +её для каждой строки Claude Code CLI (`Routed by OpenCodex to /`; для нативных строк — `Routed by OpenCodex to native `, строки Fast добавляют ` · Fast`, строки 1M сохраняют базовое описание), и Claude Code 2.1.257+ +показывает этот текст вместо подписи. Claude Code 2.1.278 принимает id, содержащий `claude` или `anthropic`. Неизвестный id, начинающийся с `claude-`, учитывается как 200k, если compact не отключён. Поэтому opencodex публикует маршрутизируемые модели как стабильные обратимые алиасы, которые содержат `claude`, но не начинаются с `claude-`: | Интерфейс | Формат | Пример | | --- | --- | --- | -| Claude Code CLI | `claude-ocx---` (plain) или `claude-ocx2-…` (escaped) | `claude-ocx-native--gpt-5.6-sol` | +| Claude Code CLI | `ocx-claude---` (plain) или `ocx-claude2-…` (escaped) | `ocx-claude-native--gpt-5.6-sol` | | Claude Desktop 3P | `claude-opus-4-8-` (3-символьный base36-хеш) | `claude-opus-4-8-ncb` | Прокси выбирает семейство для каждого запроса: приоритет у `?ids=cli` или `?ids=desktop`; иначе user-agent `claude-code/*` получает читаемую CLI-форму, а остальные клиенты — Desktop-хеш. Оба семейства декодируются бессрочно — модель, сохранённая в `settings.json` в любой из форм, -продолжает работать. +продолжает работать. Устаревшие id `claude-ocx---` и `claude-ocx2---` +из старых конфигураций тоже разрешаются, но Claude Code продолжает считать такой сохранённый id как 200k. +Один раз выберите `ocx-claude-` вместо сохранённого `claude-ocx-` и `ocx-claude2-` вместо экранированного +`claude-ocx2-`, чтобы применялись и реальное окно контекста, и compact. Если нижний селектор Claude Desktop не переключает модель в уже запущенном 3P-диалоге, можно попробовать `/model `, но в затронутых сборках Desktop этот обходной способ тоже может @@ -189,10 +194,10 @@ user-agent `claude-code/*` получает читаемую CLI-форму, а запросе. Проверьте, что отправляет клиент, в **Logs → requestedModel**. **Правила грамматики алиасов:** provider не может содержать `/` или `--` и не может быть равен -`native`. Обычные id моделей (без `/` и `~`) остаются с префиксом v1 `claude-ocx-…`. Id с `/` -или `~` выпускаются с префиксом v2 `claude-ocx2-…` и экранированием (`/` → `~s`, `~` → `~t`), +`native`. Обычные id моделей (без `/` и `~`) остаются с префиксом v1 `ocx-claude-…`. Id с `/` +или `~` выпускаются с префиксом v2 `ocx-claude2-…` и экранированием (`/` → `~s`, `~` → `~t`), например `openrouter/anthropic/claude-opus-4-8` → -`claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`. Алиасы v1 декодируются литерально (исторические +`ocx-claude2-openrouter--anthropic~sclaude-opus-4-8`. Алиасы v1 декодируются литерально (исторические двухсимвольные последовательности `~s` / `~t` в id модели сохраняются); алиасы v2 раскрывают экранирование. Маршруты, которые невозможно выразить читаемой формой, откатываются на хешированный алиас. Id моделей МОГУТ содержать `--` (при разрешении разбиение выполняется только @@ -290,7 +295,8 @@ Anthropic и автоматически срабатывает при упоми `tool_result` заменяется заглушкой, когда JSON-ввод в нижнем регистре содержит заблокированное имя. 2. **Через текстовый блок:** пользовательский текстовый блок длиной ≥10 000 символов, начинающийся с `Base directory for this skill: `, — совпадение засчитывается, когда basename каталога равен - заблокированному имени (без учёта регистра). + заблокированному имени (без учёта регистра). Строка каталога проверяется только до 4 096 единиц кода UTF-16; + более длинная строка отправляется без изменений, в том числе без завершающего перевода строки. Настраивается через `claudeCode.blockedSkills` (по умолчанию `["claude-api"]`; `[]` полностью отключает подмену). Заглушка сохраняет парность вызова инструмента и результата. diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index e61ad63f721..5c6a59c074a 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -65,7 +65,7 @@ bağlanmış olarak Claude Code'u başlatır: | `ANTHROPIC_DEFAULT_{OPUS,SONNET,FABLE}_MODEL` | `claudeCode.tierModels.*` (isteğe bağlı) | | `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT` | `alwaysEnableEffort` açık olduğunda `1` (koşullu) | | `ENABLE_TOOL_SEARCH` | `claudeCode.toolSearch` ayarlandığında (koşullu; varsayılan olarak kapalı) | -| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | `maxContextTokens` ayarlandığında eski bağlam geçersiz kılma (koşullu) | +| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | `maxContextTokens` ayarlandığında eski bağlam geçersiz kılma (koşullu) | Kendi dışa aktardığınız değişkenler her zaman önceliklidir. Ekstra argümanlar doğrudan iletilir: `ocx claude -p "hello"`. @@ -249,6 +249,12 @@ geri döngü olmayan bir bağlantıda özel proxy kabul başlığı geçerli old aynı zamanda "claude.ai connectors are disabled" uyarısının artık `ocx claude` ile görünmediği anlamına gelir. +Gövdede yapılan tek değişiklik araç çağrısı kimlikleridir. Anthropic'in reddedeceği bir `tool_use.id` +veya `tool_result.tool_use_id` (`a-zA-Z0-9_-` dışında karakter içeren ya da 64 karakteri aşan; +örneğin oturumun başında yönlendirilen bir modelin ürettiği) çağrı/sonuç eşleşmesi korunarak uygun +bir kimlikle yeniden yazılır. Uygun kimlikler değiştirilmeden gönderilir, boş bir kimliğe yerel olarak +400 döner. + `claudeCode.nativePassthrough: false` ile devre dışı bırakın; `claudeCode.anthropicBaseUrl` ile başka bir yeri işaret edin. @@ -312,14 +318,15 @@ gerekirse anahtarı hub'da ayrıca iptal edin. ## /model seçici ("From gateway") Claude Code 2.1.129+, `GET /v1/models?limit=1000` aracılığıyla ağ geçidi -modellerini keşfeder ve bunları yerel `/model` seçicisinde "From gateway" -etiketiyle listeler. Seçici yalnızca `claude` veya `anthropic` ile başlayan -kimlikleri kabul ettiğinden, opencodex yönlendirilen modelleri kararlı, tersine +modellerini keşfeder ve bunları yerel `/model` seçicisinde listeler. `description` +alanı olmayan bir satır "From gateway" olarak görünür; opencodex her Claude Code CLI +satırı için bir tane gönderir (`Routed by OpenCodex to /`; yerel satırlarda `Routed by OpenCodex to native `, Fast satırları sona ` · Fast` ekler, 1M satırları temel açıklamayı korur) ve Claude Code +2.1.257+ onun yerine bunu gösterir. Claude Code 2.1.278, `claude` veya `anthropic` içeren bir kimliği kabul eder. `claude-` ile başlayan tanınmayan bir kimlik, compact kapatılmadıkça 200k sayılır. opencodex yönlendirilen modelleri `claude` içeren ama `claude-` ile başlamayan kararlı, tersine çevrilebilir takma adlar olarak sunar: | Yüzey | Format | Örnek | | --- | --- | --- | -| Claude Code CLI | `claude-ocx---` (düz) veya `claude-ocx2-…` (kaçışlı) | `claude-ocx-openai--gpt-5.6-sol` | +| Claude Code CLI | `ocx-claude---` (düz) veya `ocx-claude2-…` (kaçışlı) | `ocx-claude-openai--gpt-5.6-sol` | | Claude Desktop 3P | `claude-opus-4-8-` (3 karakterli base36 karması) | `claude-opus-4-8-ncb` | Proxy, istek başına aileyi seçer: `?ids=cli` veya `?ids=desktop` kazanır; aksi @@ -332,7 +339,11 @@ ModelInfo biçiminde tam model yeteneklerini (akıl yürütme çabası merdiveni düşünme türleri) taşır. Gerçek Anthropic modelleri kurallı kimliklerini korur. Sentetik 2026 tarihi bir çıkış tarihi değil, dahili bir yuvadır. Eski karma takma adlar ve eski yapılandırmalardan gelen `claude-ocx---` -kimlikleri hala çözümlenir. +kimlikleri hala çözümlenir; kaçışlı `claude-ocx2---` kimlikleri de +çözümlenir. Kayıtlı eski bir kimlik yine yönlendirilir, ancak Claude Code o kimlik için +200k hesabını sürdürür. Gerçek bağlam penceresi ve compact birlikte uygulansın diye +kayıtlı `claude-ocx-` yerine bir kez `ocx-claude-`, kaçışlı `claude-ocx2-` yerine +`ocx-claude2-` seçin. Claude Desktop'ın altbilgi seçicisi zaten çalışan bir 3P görüşmesi için modeli değiştirmezse, `/model ` komutunu deneyebilirsiniz; ancak bu geçici çözüm de @@ -361,10 +372,10 @@ Code dizeleri doğrudan iletir). **Takma ad dilbilgisi kuralları:** sağlayıcı `/` veya `--` içeremez veya `native` değerine eşit olamaz. Düz model kimlikleri (`/` veya `~` içermeyen) v1 -önekini `claude-ocx-…` korur. `/` veya `~` içeren model kimlikleri, kaçışlarla -(`/` → `~s`, `~` → `~t`) v2 önekini `claude-ocx2-…` basar, örn. +önekini `ocx-claude-…` korur. `/` veya `~` içeren model kimlikleri, kaçışlarla +(`/` → `~s`, `~` → `~t`) v2 önekini `ocx-claude2-…` basar, örn. `openrouter/anthropic/claude-opus-4-8` → -`claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`. v1 takma adları harfi +`ocx-claude2-openrouter--anthropic~sclaude-opus-4-8`. v1 takma adları harfi harfine çözülür (böylece `~s` / `~t` iki karakterli dizilerini içeren geçmiş bir model kimliği korunur); v2 takma adları kaçışları genişletir. Okunabilir formun ifade edemediği rotalar karma takma ada geri döner. Model kimlikleri `--` @@ -478,7 +489,9 @@ Anthropic doğrudan geçişine dokunulmaz. `tool_result` gövdesi bir taslakla değiştirilir. 2. **Metin bloğu taşıyıcısı:** `Base directory for this skill: ` ile başlayan ≥10.000 karakterlik bir kullanıcı metin bloğu — dizin temel adı engellenen - bir ada eşit olduğunda eşleşir (büyük/küçük harfe duyarsız). + bir ada eşit olduğunda eşleşir (büyük/küçük harfe duyarsız). Dizin satırı + yalnızca 4.096 UTF-16 kod birimine kadar incelenir; daha uzun bir satır, + sonunda satır sonu olmasa bile değiştirilmeden gönderilir. `claudeCode.blockedSkills` ile yapılandırın (varsayılan `["claude-api"]`; `[]` atlamayı tamamen devre dışı bırakır). Taslak, araç çağrısı/sonuç eşleşmesini diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 29103646afe..4b35b4448ad 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -26,7 +26,7 @@ ocx claude | `ANTHROPIC_DEFAULT_{OPUS,SONNET,FABLE}_MODEL` | `claudeCode.tierModels.*`(可选) | | `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT` | 启用 `alwaysEnableEffort` 时设为 `1`(条件注入) | | `ENABLE_TOOL_SEARCH` | 设置了 `claudeCode.toolSearch` 时注入(条件注入,默认关闭) | -| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | 设置 `maxContextTokens` 时使用的旧版上下文覆盖项(条件注入) | +| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | 设置 `maxContextTokens` 时使用的旧版上下文覆盖项(条件注入) | 你自行导出的变量始终优先。额外参数会直接透传:`ocx claude -p "hello"`。 ### Claude 路由关闭时的原生回退 @@ -86,6 +86,8 @@ Anthropic。若任一提供方请求头包含代理准入密钥,该密钥会 解析后返回的模型保持不变;并且在非回环绑定上,专用代理准入请求头有效。这也意味着使用 `ocx claude` 时不再出现 “claude.ai connectors are disabled”警告。 +请求体中唯一会改动的是工具调用 ID。Anthropic 会拒绝的 `tool_use.id` 或 `tool_result.tool_use_id`(含 `a-zA-Z0-9_-` 以外的字符或超过 64 个字符,例如会话早先由路由模型生成的 ID)会被改写为合规 ID,并保持调用与结果的配对。合规 ID 原样发送,空 ID 会在本地直接返回 400。 + 可以设置 `claudeCode.nativePassthrough: false` 来禁用;也可以通过 `claudeCode.anthropicBaseUrl` 指向其他位置。 @@ -139,19 +141,23 @@ apply、轮换/恢复或直接 disconnect 均可处理,无需新参数或事 每个条目带有诚实的显示名(如 `gemini-3-pro (gemini)`),并以官方 ModelInfo 形态附带模型能力 信息(推理强度梯度、thinking 类型),使 Claude Desktop 的第三方网关模式能够启用推理强度选择 UI。真实 Anthropic 模型保留其原始 id。合成的 2026 日期是内部槽位,不是发布日期。旧版哈希 -别名和 `claude-ocx---` 别名仍可解析。拥有 1M 上下文的模型会多出一行 `…[1m]`: +别名和 `claude-ocx---` 别名仍可解析,转义的 `claude-ocx2---` 也同样可解析。 +已保存的旧 id 仍会路由,但 Claude Code 对它仍按 200k 计算。把已保存的 `claude-ocx-` 重新选一次对应的 +`ocx-claude-`,转义的 `claude-ocx2-` 重新选一次 `ocx-claude2-`,即可同时用上真实上下文窗口和 compact。 +拥有 1M 上下文的模型会多出一行 `…[1m]`: 选中后 Claude Code 会按 1M 计算该模型的上下文(自动压缩保留,代理在路由前去掉该标记)。 选中后会保存到 Claude Code 的 `settings.json` `model` 字段;入站请求会将别名解析回路由 模型。旧版 Claude Code 中选择器保持原生 — 通过 `ANTHROPIC_MODEL` 设置槽位,或直接在 `/model` 中输入任意路由 id(Claude Code 会原样传递字符串)。 Claude Code 2.1.129+ 通过 `GET /v1/models?limit=1000` 发现网关模型,并在原生 `/model` -选择器中以“From gateway”标签列出。由于选择器只接受以 `claude` 或 `anthropic` 开头的 ID, -opencodex 会将已路由模型公开为稳定且可逆的别名: +选择器中列出。没有 `description` 的行显示为“From gateway”;opencodex 会为 Claude Code CLI 的每一行发送 +`description`(`Routed by OpenCodex to /`;原生行为 `Routed by OpenCodex to native `,Fast 行末尾加 ` · Fast`,1M 行沿用基础描述),Claude Code 2.1.257+ 会改为显示它。 +Claude Code 2.1.278 接受包含 `claude` 或 `anthropic` 的 ID。以 `claude-` 开头的未知 ID 在不关闭 compact 时按 200k 计算,因此 opencodex 会将已路由模型公开为包含 `claude`、但不以 `claude-` 开头的稳定且可逆别名: | 界面 | 格式 | 示例 | | --- | --- | --- | -| Claude Code CLI | `claude-ocx---`(plain)或 `claude-ocx2-…`(escaped) | `claude-ocx-native--gpt-5.6-sol` | +| Claude Code CLI | `ocx-claude---`(plain)或 `ocx-claude2-…`(escaped) | `ocx-claude-native--gpt-5.6-sol` | | Claude Desktop 3P | `claude-opus-4-8-`(3 字符 base36 哈希) | `claude-opus-4-8-ncb` | 代理会按请求选择别名族:`?ids=cli` 或 `?ids=desktop` 优先;否则,`claude-code/*` @@ -169,9 +175,9 @@ Claude Desktop 1.46388.4 时,无论通过底部选择器还是 `/model` 更改 而是根据每个请求携带的模型 ID 进行路由。请在 **Logs → requestedModel** 中确认客户端实际发送的内容。 **别名语法规则:**provider 不得包含 `/` 或 `--`,也不得等于 `native`。 -不含 `/` 或 `~` 的普通 model ID 继续使用 v1 前缀 `claude-ocx-…`。包含 `/` 或 `~` 的 model ID -会使用 v2 前缀 `claude-ocx2-…` 并转义(`/` → `~s`,`~` → `~t`),例如 -`openrouter/anthropic/claude-opus-4-8` → `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`。 +不含 `/` 或 `~` 的普通 model ID 继续使用 v1 前缀 `ocx-claude-…`。包含 `/` 或 `~` 的 model ID +会使用 v2 前缀 `ocx-claude2-…` 并转义(`/` → `~s`,`~` → `~t`),例如 +`openrouter/anthropic/claude-opus-4-8` → `ocx-claude2-openrouter--anthropic~sclaude-opus-4-8`。 v1 别名按字面解码(历史上 model ID 中包含的两字符序列 `~s` / `~t` 会被保留);v2 别名会展开转义。 易读形式无法表达的路由会回退到哈希别名。模型 ID **可以**包含 `--`(解析时只按第一个 `--` 分割); 含 `--` 的原生 slug 会回退到哈希形式。 @@ -256,7 +262,8 @@ opencodex 会在**已路由**请求中将该技能内容替换为一个短占位 1. **工具结果载体:**assistant 的 `Skill(...)` 调用——当转为小写的 JSON 输入包含被屏蔽名称时, 与之配对的 `tool_result` 正文会被替换为占位说明。 2. **文本块载体:**以 `Base directory for this skill: ` 开头且不少于 10,000 字符的用户 - 文本块——当目录 basename 等于被屏蔽名称时匹配(不区分大小写)。 + 文本块——当目录 basename 等于被屏蔽名称时匹配(不区分大小写)。目录行最多只检查 4,096 个 + UTF-16 代码单元;更长的行会原样发送,包括没有结尾换行的情况。 通过 `claudeCode.blockedSkills` 配置(默认 `["claude-api"]`;`[]` 会完全禁用省略)。 占位说明会保持工具调用/结果的配对关系不变。 diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index ffbe50ff790..e153106796a 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -52,7 +52,7 @@ ocx claude | `ANTHROPIC_DEFAULT_{OPUS,SONNET,FABLE}_MODEL` | `claudeCode.tierModels.*`(可選) | | `CLAUDE_CODE_ALWAYS_ENABLE_EFFORT` | 啟用 `alwaysEnableEffort` 時設為 `1`(條件注入) | | `ENABLE_TOOL_SEARCH` | 設定 `claudeCode.toolSearch` 時注入(條件注入,預設關閉) | -| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | 設定 `maxContextTokens` 時使用的舊版上下文覆蓋項(條件注入) | +| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | 設定 `maxContextTokens` 時使用的舊版上下文覆蓋項(條件注入) | 你自行匯出的變數始終優先。額外引數會直接透傳:`ocx claude -p "hello"`。 ### Claude 路由關閉時的原生回退 @@ -170,6 +170,8 @@ Anthropic。若任一供應商標頭含有代理許可密鑰,該密鑰會被 解析後回傳的模型保持不變;且在非回環綁定上,專用代理許可標頭有效。這也意味著使用 `ocx claude` 時不再出現 “claude.ai connectors are disabled”警告。 +請求本文中唯一會改動的是工具呼叫 ID。Anthropic 會拒絕的 `tool_use.id` 或 `tool_result.tool_use_id`(含 `a-zA-Z0-9_-` 以外的字元或超過 64 個字元,例如工作階段早先由路由模型產生的 ID)會被改寫為合規 ID,並保持呼叫與結果的配對。合規 ID 原樣送出,空 ID 會在本地直接回傳 400。 + 可以設定 `claudeCode.nativePassthrough: false` 來停用;也可以透過 `claudeCode.anthropicBaseUrl` 指向其他位置。 @@ -222,12 +224,13 @@ apply、輪換/復原或直接 disconnect 均可處理,無須新參數或事 ## /model 選擇器(“From gateway”) Claude Code 2.1.129+ 透過 `GET /v1/models?limit=1000` 發現閘道器模型,並在原生 `/model` -選擇器中以“From gateway”標籤列出。由於選擇器只接受以 `claude` 或 `anthropic` 開頭的 ID, -opencodex 會將已路由模型公開為穩定且可逆的別名: +選擇器中列出。沒有 `description` 的列會顯示為“From gateway”;opencodex 會為 Claude Code CLI 的每一列送出 +`description`(`Routed by OpenCodex to /`;原生列為 `Routed by OpenCodex to native `,Fast 列末尾加上 ` · Fast`,1M 列沿用基礎描述),Claude Code 2.1.257+ 會改為顯示它。 +Claude Code 2.1.278 接受包含 `claude` 或 `anthropic` 的 ID。以 `claude-` 開頭的未知 ID 在不關閉 compact 時按 200k 計算,因此 opencodex 會將已路由模型公開為包含 `claude`、但不以 `claude-` 開頭的穩定且可逆別名: | 介面 | 格式 | 示例 | | --- | --- | --- | -| Claude Code CLI | `claude-ocx---` | `claude-ocx-native--gpt-5.6-sol` | +| Claude Code CLI | `ocx-claude---`(plain)或 `ocx-claude2-…`(escaped) | `ocx-claude-native--gpt-5.6-sol` | | Claude Desktop 3P | `claude-opus-4-8-`(3 字元 base36 雜湊) | `claude-opus-4-8-ncb` | 代理會按請求選擇別名族:`?ids=cli` 或 `?ids=desktop` 優先;否則,`claude-code/*` @@ -236,15 +239,22 @@ user-agent 會獲得易讀的 CLI 形式,其他用戶端會獲得 Desktop 雜 每個條目帶有誠實的顯示名(如 `gemini-3-pro (gemini)`),並以官方 ModelInfo 形態附帶完整模型 能力(推理強度階梯、thinking 型別),使 Claude Desktop 的第三方閘道器模式能夠提供其推理強度 選擇器。真實 Anthropic 模型保留其規範 id。合成的 2026 日期是內部槽位,不是釋出日期。舊版雜湊 -別名與較舊設定中的 `claude-ocx---` id 仍可解析。 +別名與較舊設定中的 `claude-ocx---` id 仍可解析,跳脫的 `claude-ocx2---` +也同樣可解析。已儲存的舊 id 仍會路由,但 Claude Code 對它仍按 200k 計算。把已儲存的 `claude-ocx-` +重新選一次對應的 `ocx-claude-`,跳脫的 `claude-ocx2-` 重新選一次 `ocx-claude2-`,即可同時用上真實上下文 +視窗與 compact。 擁有權威 1M 上下文視窗的模型會多出一個 `…[1m]` 選擇器列:選中後 Claude Code 會按完整 1M 上下文 計算該模型(自動壓縮仍開啟)——代理在路由前會去掉該標記。 選中後會儲存到 Claude Code 的 `settings.json` `model` 欄位;入站請求會將別名解析回路由 模型。在較舊的 Claude Code 版本中,選擇器保持原生——可透過 `ANTHROPIC_MODEL` 設定槽位,或在 `/model` 中輸入任意已路由 id(Claude Code 會原樣傳遞字串)。 -**別名語法規則:**provider 不得包含 `/` 或 `--`,也不得等於 `native`;model 不得包含 -`/`。易讀形式無法表達的路由會回退到雜湊別名。模型 ID **可以**包含 `--`(解析時只按第一個 +**別名語法規則:**provider 不得包含 `/` 或 `--`,也不得等於 `native`。 +不含 `/` 或 `~` 的一般 model ID 使用 v1 前綴 `ocx-claude-…`。包含 `/` 或 `~` 的 model ID +使用 v2 前綴 `ocx-claude2-…` 並跳脫(`/` → `~s`,`~` → `~t`),例如 +`openrouter/anthropic/claude-opus-4-8` → `ocx-claude2-openrouter--anthropic~sclaude-opus-4-8`。 +v1 別名按字面解碼(歷史上 model ID 中包含的兩字元序列 `~s` / `~t` 會被保留);v2 別名會展開跳脫。 +易讀形式無法表達的路由會回退到雜湊別名。模型 ID **可以**包含 `--`(解析時只按第一個 `--` 拆分);包含 `--` 的原生 slug 會回退到雜湊形式。 **模型解析順序:**移除 `[1m]` 標記 → 解碼易讀別名 → 解碼 Desktop 雜湊別名 → @@ -327,7 +337,8 @@ opencodex 會在**已路由**請求中將該技能內容替換為一個短佔位 1. **工具結果載體:**assistant 的 `Skill(...)` 呼叫——當轉為小寫的 JSON 輸入包含被遮蔽名稱時, 與之配對的 `tool_result` 正文會被替換為佔位說明。 2. **文字塊載體:**以 `Base directory for this skill: ` 開頭且不少於 10,000 字元的使用者 - 文字塊——當目錄 basename 等於被遮蔽名稱時匹配(不區分大小寫)。 + 文字塊——當目錄 basename 等於被遮蔽名稱時匹配(不區分大小寫)。目錄行最多只檢查 4,096 個 + UTF-16 程式碼單元;更長的行會原樣送出,包括沒有結尾換行的情況。 透過 `claudeCode.blockedSkills` 設定(預設 `["claude-api"]`;`[]` 會完全停用省略)。 佔位說明會保持工具呼叫/結果的配對關係不變。 diff --git a/src/claude/alias.ts b/src/claude/alias.ts index db7d74e0773..2bc5ee66925 100644 --- a/src/claude/alias.ts +++ b/src/claude/alias.ts @@ -1,25 +1,26 @@ /** * Gateway model-discovery aliases (devlog/260711_claude_inbound/020, 003 G1-G6). * - * Claude Code's /model picker only lists discovery entries whose id literally - * begins with `claude` or `anthropic`, so routed models are exposed as - * `claude-ocx---` with an honest display_name. Aliases must be - * deterministic, reversible, and STABLE across releases (picker selections - * persist to Claude Code's settings.json `model` field). + * Claude Code's /model picker accepts discovery ids containing `claude` or + * `anthropic`. New routed models are exposed as `ocx-claude---` + * with an honest display_name. The id must contain `claude` so the picker keeps + * it, but must not START with `claude-`: Claude Code 2.1.278 treats an + * unrecognized `claude-` id as its own model and ignores + * CLAUDE_CODE_MAX_CONTEXT_TOKENS unless DISABLE_COMPACT=1. Starting with + * `ocx-claude-` keeps the real window and leaves compact enabled. * - * Versioned prefixes: - * - `claude-ocx-` (v1) — legacy / plain model ids with no `/` or `~`. Decode - * is literal (no escape expansion), so a persisted model id that literally - * contained the two-char sequences `~s` / `~t` keeps resolving. - * - `claude-ocx2-` (v2) — used whenever the model id needs escape encoding - * (`/` → `~s`, `~` → `~t`). Decode expands those escapes. New slash/tilde - * models always mint v2 so they cannot collide with v1 literals. + * Persisted ids stay decodable: + * - `ocx-claude-` — current plain ids. Decode is literal. + * - `claude-ocx-` (v1) — legacy plain ids. Decode stays literal, so a persisted + * model id that contained the two-char sequences `~s` / `~t` keeps resolving. + * - `claude-ocx2-` (v2) — escape encoding (`/` → `~s`, `~` → `~t`). Decode + * expands those escapes. New slash/tilde models still mint v2. * * Reversibility rules: * - providers containing `--` or `/` are not aliased (split boundary safety); * - model ids MAY contain `/` or `~` — minted under the v2 prefix with escapes * (e.g. openrouter `anthropic/claude-opus-4-8` → - * `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`); + * `ocx-claude2-openrouter--anthropic~sclaude-opus-4-8`); * - model ids MAY contain `--` (resolve splits on the FIRST `--` only); * - native OpenAI slugs use the pseudo-provider `native` and resolve back to * the bare slug; a real provider named "native" is therefore never aliased. @@ -27,15 +28,19 @@ import { desktop3pAlias } from "./desktop-3p"; -/** Legacy / plain readable prefix (literal model portion on decode). */ +/** Current plain prefix. Contains "claude" but does not start with "claude-". */ +export const CLAUDE_ALIAS_PREFIX_CURRENT = "ocx-claude-"; +/** Current escape-encoded prefix (`~s`/`~t` expanded on decode). */ +export const CLAUDE_ALIAS_PREFIX_CURRENT_V2 = "ocx-claude2-"; +/** Legacy plain prefix. Still decoded; no longer minted. */ export const CLAUDE_ALIAS_PREFIX_V1 = "claude-ocx-"; -/** Escape-encoded readable prefix (`~s`/`~t` expanded on decode). */ +/** Legacy escape-encoded prefix. Still decoded; no longer minted. */ export const CLAUDE_ALIAS_PREFIX_V2 = "claude-ocx2-"; /** * Current write prefix for plain (unescaped) model ids. - * Escape-needing models mint {@link CLAUDE_ALIAS_PREFIX_V2} instead. + * Escape-needing models mint {@link CLAUDE_ALIAS_PREFIX_CURRENT_V2} instead. */ -export const CLAUDE_ALIAS_PREFIX = CLAUDE_ALIAS_PREFIX_V1; +export const CLAUDE_ALIAS_PREFIX = CLAUDE_ALIAS_PREFIX_CURRENT; /** Encoded `/` inside the model portion of a v2 Claude Code alias. */ const CLAUDE_ALIAS_SLASH_ENC = "~s"; @@ -90,9 +95,42 @@ export function aliasForRoute(provider: string, modelId: string): string | null if (!provider || provider.includes("--") || provider.includes("/") || provider === NATIVE_PSEUDO_PROVIDER) return null; if (!modelId) return null; if (modelNeedsEscapeEncoding(modelId)) { - return `${CLAUDE_ALIAS_PREFIX_V2}${provider}--${encodeModelId(modelId)}`; + return `${CLAUDE_ALIAS_PREFIX_CURRENT_V2}${provider}--${encodeModelId(modelId)}`; } - return `${CLAUDE_ALIAS_PREFIX_V1}${provider}--${modelId}`; + return `${CLAUDE_ALIAS_PREFIX_CURRENT}${provider}--${modelId}`; +} + +/** + * The spelling a release before `ocx-claude-` minted for the same route. Nothing + * is minted with it any more; it exists so a selector already saved in Claude + * Code's settings.json keeps its context-window lookup (context-windows.ts). + */ +function toLegacyAlias(alias: string | null): string | null { + if (!alias) return null; + if (alias.startsWith(CLAUDE_ALIAS_PREFIX_CURRENT_V2)) return CLAUDE_ALIAS_PREFIX_V2 + alias.slice(CLAUDE_ALIAS_PREFIX_CURRENT_V2.length); + if (alias.startsWith(CLAUDE_ALIAS_PREFIX_CURRENT)) return CLAUDE_ALIAS_PREFIX_V1 + alias.slice(CLAUDE_ALIAS_PREFIX_CURRENT.length); + return null; +} + +/** + * The current spelling of a selector that may still use a legacy prefix. Legacy and + * current prefixes decode to the same route (plain stays literal, v2 expands escapes), so + * swapping the prefix is safe. Only the prefix changes: a trailing `[1m]` marker survives. + */ +export function currentClaudeAliasSpelling(selector: string): string { + if (selector.startsWith(CLAUDE_ALIAS_PREFIX_V2)) return CLAUDE_ALIAS_PREFIX_CURRENT_V2 + selector.slice(CLAUDE_ALIAS_PREFIX_V2.length); + if (selector.startsWith(CLAUDE_ALIAS_PREFIX_V1)) return CLAUDE_ALIAS_PREFIX_CURRENT + selector.slice(CLAUDE_ALIAS_PREFIX_V1.length); + return selector; +} + +/** Legacy `claude-ocx-`/`claude-ocx2-` spelling of {@link aliasForRoute}. */ +export function legacyAliasForRoute(provider: string, modelId: string): string | null { + return toLegacyAlias(aliasForRoute(provider, modelId)); +} + +/** Legacy `claude-ocx-`/`claude-ocx2-` spelling of {@link aliasForNative}. */ +export function legacyAliasForNative(slug: string): string | null { + return toLegacyAlias(aliasForNative(slug)); } /** Alias for a native OpenAI slug (bare model id, no provider namespace). */ @@ -100,9 +138,9 @@ export function aliasForNative(slug: string): string | null { // Reject "/" — native ids are bare slugs. Literal `~` is fine via v2 + ~t. if (!slug || slug.includes("/") || slug.includes("--")) return null; if (modelNeedsEscapeEncoding(slug)) { - return `${CLAUDE_ALIAS_PREFIX_V2}${NATIVE_PSEUDO_PROVIDER}--${encodeModelId(slug)}`; + return `${CLAUDE_ALIAS_PREFIX_CURRENT_V2}${NATIVE_PSEUDO_PROVIDER}--${encodeModelId(slug)}`; } - return `${CLAUDE_ALIAS_PREFIX_V1}${NATIVE_PSEUDO_PROVIDER}--${slug}`; + return `${CLAUDE_ALIAS_PREFIX_CURRENT}${NATIVE_PSEUDO_PROVIDER}--${slug}`; } /** @@ -110,7 +148,15 @@ export function aliasForNative(slug: string): string | null { * routed -> "/", native -> bare slug. Null when not an alias. */ export function resolveAlias(id: string): string | null { - // Check v2 before v1 for clarity (prefixes are disjoint: ocx2 vs ocx-). + // Current v2 before legacy v2, then plain prefixes. `ocx-claude2-` and + // `claude-ocx2-` are disjoint from their plain siblings. + if (id.startsWith(CLAUDE_ALIAS_PREFIX_CURRENT_V2)) { + const parts = splitAlias(id, CLAUDE_ALIAS_PREFIX_CURRENT_V2); + if (!parts) return null; + const model = decodeEscapedModelId(parts.model); + if (!model) return null; + return parts.provider === NATIVE_PSEUDO_PROVIDER ? model : `${parts.provider}/${model}`; + } if (id.startsWith(CLAUDE_ALIAS_PREFIX_V2)) { const parts = splitAlias(id, CLAUDE_ALIAS_PREFIX_V2); if (!parts) return null; @@ -118,8 +164,9 @@ export function resolveAlias(id: string): string | null { if (!model) return null; return parts.provider === NATIVE_PSEUDO_PROVIDER ? model : `${parts.provider}/${model}`; } - if (id.startsWith(CLAUDE_ALIAS_PREFIX_V1)) { - const parts = splitAlias(id, CLAUDE_ALIAS_PREFIX_V1); + for (const prefix of [CLAUDE_ALIAS_PREFIX_CURRENT, CLAUDE_ALIAS_PREFIX_V1]) { + if (!id.startsWith(prefix)) continue; + const parts = splitAlias(id, prefix); if (!parts) return null; // Literal decode — preserves pre-escape aliases whose model id contained // the two-char sequences ~s / ~t. @@ -131,11 +178,11 @@ export function resolveAlias(id: string): string | null { /** * Claude Code (CLI) surface alias — devlog 050 + audit 051 #2. * - * The readable `claude-ocx*` form when representable; otherwise the desktop-3p + * The readable `ocx-claude*` form when representable; otherwise the desktop-3p * hash so the model still appears in discovery (collisions follow the same * first-wins policy as the desktop registry — audit 051 #1). Real Anthropic * models pass through unchanged (they must keep hitting the sk-ant passthrough). - * Both families keep decoding forever in resolveInboundModel, so ids persisted + * Old `claude-ocx*` ids keep decoding forever in resolveInboundModel, so ids persisted * in Claude Code's settings.json never break when the surface style changes. */ export function claudeCodeAlias(provider: string, modelId: string): string { diff --git a/src/claude/context-windows.ts b/src/claude/context-windows.ts index d6904e1183b..c22ac6c47a1 100644 --- a/src/claude/context-windows.ts +++ b/src/claude/context-windows.ts @@ -3,12 +3,13 @@ * (devlog/260712_cli_context_cache/010 B2, audit R2#1/R3#1/R3#4/R4#3). * * The map registers EVERY selector form a Claude Code model slot might store — - * bare native slug, provider/id, desktop3p alias, legacy claude-ocx-* alias — + * bare native slug, provider/id, desktop3p alias, current ocx-claude-* alias and + * the legacy claude-ocx-* spelling a saved selector may still carry — * with first-wins dedupe (mirrors the desktop3p registry collision policy). * Values are authoritative context windows only (native override table / * adapter-reported CatalogModel.contextWindow); nothing is guessed. */ -import { aliasForNative, aliasForRoute } from "./alias"; +import { aliasForNative, aliasForRoute, currentClaudeAliasSpelling, legacyAliasForNative, legacyAliasForRoute } from "./alias"; import { desktop3pAlias } from "./desktop-3p"; import { nativeOpenAiContextWindow, type CatalogModel, type NativeContextLimitsInput } from "../codex/catalog"; @@ -62,9 +63,12 @@ function inAutoCompactRange(value: number): boolean { /** * Resolve the auto-context mode from claudeCode config. Disabled when the user - * turned it off OR when the legacy maxContextTokens override is set — that pair - * (MAX_CONTEXT_TOKENS + DISABLE_COMPACT) takes rule-1 precedence inside the CLI, - * making both AUTO_COMPACT_WINDOW and [1m] accounting inert. + * turned it off OR when maxContextTokens is set. That override injects only + * CLAUDE_CODE_MAX_CONTEXT_TOKENS: compact stays enabled (no DISABLE_COMPACT) and + * Claude Code compacts against that window for ocx-claude-* ids, so no + * CLAUDE_CODE_AUTO_COMPACT_WINDOW is injected beside it and [1m] auto-marking stays + * off. maxContextTokens accepts values outside the compact variable's 100k–1M + * range, so deriving that variable from it could only produce ignored values. * * `envOverride` is the raw CLAUDE_CODE_AUTO_COMPACT_WINDOW the USER already * exported (user-wins injection keeps it): a valid value drives the marking @@ -139,6 +143,7 @@ export function buildClaudeContextWindows( put(slug, window); put(desktop3pAlias("native", slug), window); put(aliasForNative(slug), window); + put(legacyAliasForNative(slug), window); } // Anthropic passthrough guard (audit 021 #3): canonical claude ids ride the // subscription passthrough — marking a sub-1M one would strap [1m]/1M-beta onto @@ -161,6 +166,7 @@ export function buildClaudeContextWindows( put(`${m.provider}/${m.id}`, window); put(desktop3pAlias(m.provider, m.id), window); put(aliasForRoute(m.provider, m.id), window); + put(legacyAliasForRoute(m.provider, m.id), window); if (bareCounts.get(m.id) === 1) put(m.id, window); } return out; @@ -204,8 +210,11 @@ export function effectiveModelEnv( ): Record { const out: Record = {}; const auto = autoOverride ?? resolveAutoContext(claudeCode); + // A slot still configured with a legacy claude-ocx selector is emitted in its current + // ocx-claude spelling. The route is identical, but Claude Code applies the context window + // (and keeps compact) only for ids that do not start with "claude-". const set = (name: string, value: string | undefined) => { - const marked = withOneMillionMarker(value, windows, auto); + const marked = withOneMillionMarker(value === undefined ? undefined : currentClaudeAliasSpelling(value), windows, auto); if (marked) out[name] = marked; }; set("ANTHROPIC_MODEL", claudeCode?.model); diff --git a/src/claude/gateway-cache.ts b/src/claude/gateway-cache.ts index 366017a04ba..0c10f8cb387 100644 --- a/src/claude/gateway-cache.ts +++ b/src/claude/gateway-cache.ts @@ -6,9 +6,12 @@ * subscription-preserving launch deliberately sets no token, so the CLI can never * refresh its picker list itself — it reads whatever cache exists. We therefore * pre-write the cache in the exact on-disk schema the CLI uses: - * { baseUrl, fetchedAt, models: [{ id, display_name? }] } (mode 0600) - * mirroring its `/^(claude|anthropic)/i` usable-id filter. The picker validates + * { baseUrl, fetchedAt, models: [{ id, display_name?, description? }] } (mode 0600) + * mirroring the picker rule that the id must contain `claude` or `anthropic`. + * Current aliases are `ocx-claude-*`, so an anchored `^(claude|anthropic)` filter + * would drop every newly minted routed model. The picker validates * only `baseUrl === ANTHROPIC_BASE_URL`, so a foreign base URL is simply ignored. + * `description` replaces the picker's generic "From gateway" line (Claude Code >= 2.1.257). */ import { mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; @@ -19,6 +22,7 @@ import type { OcxConfig } from "../types"; export interface GatewayModelRow { id: string; display_name?: string; + description?: string; } export interface GatewayModelCacheRefreshOptions { @@ -49,14 +53,18 @@ export function claudeConfigDir(): string { export function writeGatewayModelCache(baseUrl: string, models: readonly GatewayModelRow[], configDir = claudeConfigDir()): string | null { try { // Mirror the CLI's usable-id filter so our file matches what it would cache. - const usable = models.filter(m => /^(claude|anthropic)/i.test(m.id)); + const usable = models.filter(m => /(claude|anthropic)/i.test(m.id)); const cacheDir = join(configDir, "cache"); mkdirSync(cacheDir, { recursive: true }); const path = join(cacheDir, "gateway-models.json"); const payload = { baseUrl, fetchedAt: Date.now(), - models: usable.map(m => (m.display_name === undefined ? { id: m.id } : { id: m.id, display_name: m.display_name })), + models: usable.map(m => ({ + id: m.id, + ...(m.display_name === undefined ? {} : { display_name: m.display_name }), + ...(m.description === undefined ? {} : { description: m.description }), + })), }; writeFileSync(path, JSON.stringify(payload), { encoding: "utf8", mode: 0o600 }); return path; @@ -110,6 +118,7 @@ export async function refreshGatewayModelCacheFromProxy( .map(m => ({ id: m.id as string, display_name: typeof m.display_name === "string" ? m.display_name : undefined, + description: typeof m.description === "string" ? m.description : undefined, })); return writeGatewayModelCache(baseUrl, models, options.configDir); } catch { diff --git a/src/claude/model-info.ts b/src/claude/model-info.ts index 665183c651c..7f5fe0f7266 100644 --- a/src/claude/model-info.ts +++ b/src/claude/model-info.ts @@ -80,9 +80,14 @@ export interface AnthropicModelInfo { capabilities: ReturnType; max_input_tokens: number | null; max_tokens: null; + /** + * Claude Code (>= 2.1.257) shows this under the picker row instead of the generic + * "From gateway". Readable (CLI) rows only; Desktop 3P rows keep the ModelInfo shape. + */ + description?: string; } -function modelInfo(id: string, displayName: string, ladder: readonly string[], imageInput: boolean, contextWindow?: number): AnthropicModelInfo { +function modelInfo(id: string, displayName: string, ladder: readonly string[], imageInput: boolean, contextWindow?: number, description?: string): AnthropicModelInfo { return { id, display_name: displayName, @@ -91,6 +96,7 @@ function modelInfo(id: string, displayName: string, ladder: readonly string[], i capabilities: modelCapabilities(ladder, imageInput), max_input_tokens: typeof contextWindow === "number" && contextWindow > 0 ? contextWindow : null, max_tokens: null, + ...(description === undefined ? {} : { description }), }; } @@ -182,7 +188,13 @@ export function buildAnthropicModelInfos( // A real model always wins its own id, whatever the iteration order. if (realDiscoveryIds.has(id) || seen.has(id)) return; seen.add(id); - out.push({ ...base, id, display_name: `${base.display_name} · Fast` }); + out.push({ + ...base, + id, + display_name: `${base.display_name} · Fast`, + // Fast picks a different tier/variant, so the picker line says so like the name does. + ...(base.description === undefined ? {} : { description: `${base.description} · Fast` }), + }); }; for (const slug of nativeSlugs) { const id = idStyle === "readable" ? claudeCodeNativeAlias(slug) : aliasForRoute("native", slug); @@ -192,7 +204,8 @@ export function buildAnthropicModelInfos( const nativeMaxInput = nativeOpenAiMaxInputTokens(slug, nativeContextCap); // max_input_tokens is an INPUT limit, so it follows the measured input ceiling rather // than the total window whenever the model publishes one. - const info = modelInfo(id, `${slug} (native)`, nativeEffectiveLadder(slug), true, nativeMaxInput ?? nativeWindow); + const description = idStyle === "readable" ? `Routed by OpenCodex to native ${slug}` : undefined; + const info = modelInfo(id, `${slug} (native)`, nativeEffectiveLadder(slug), true, nativeMaxInput ?? nativeWindow, description); out.push(info); push1mVariant(info, nativeWindow, nativeMaxInput); // Natives too, not only routed rows: gpt-5.6-sol is the flagship Fast model, and @@ -225,7 +238,8 @@ export function buildAnthropicModelInfos( ? Math.min(m.maxInputTokens, m.contextWindow) : m.maxInputTokens) : undefined; - const info = modelInfo(id, `${listedModelId} (${m.provider})`, ladder, imageInput, routedMaxInput ?? m.contextWindow); + const description = idStyle === "readable" ? `Routed by OpenCodex to ${m.provider}/${listedModelId}` : undefined; + const info = modelInfo(id, `${listedModelId} (${m.provider})`, ladder, imageInput, routedMaxInput ?? m.contextWindow, description); out.push(info); // Anthropic passthrough guard (audit 021 #3): never auto-widen canonical claude // routes — only a genuine >=1M window earns the variant row there. diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 1ae294c82ad..1f24ab4870e 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -10,7 +10,7 @@ import { spawn } from "node:child_process"; import { loadConfig } from "../config"; import { injectClaudeAgentDefs } from "../claude/agents-inject"; -import { CLAUDE_ALIAS_PREFIX_V1, CLAUDE_ALIAS_PREFIX_V2 } from "../claude/alias"; +import { CLAUDE_ALIAS_PREFIX_CURRENT, CLAUDE_ALIAS_PREFIX_CURRENT_V2, CLAUDE_ALIAS_PREFIX_V1, CLAUDE_ALIAS_PREFIX_V2 } from "../claude/alias"; import { claudeToolSearchEnv, effectiveModelEnv, resolveAutoContext } from "../claude/context-windows"; import { claudeConfigDir, refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache"; import { commandInvocation } from "../lib/win-exec"; @@ -30,7 +30,7 @@ import { readServiceApiTokenState, type ServiceApiTokenState } from "../lib/serv import { DEFAULT_CATALOG_PATH } from "../codex/paths"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { aliasForNative, aliasForRoute } from "../claude/alias"; +import { aliasForNative, aliasForRoute, legacyAliasForNative, legacyAliasForRoute } from "../claude/alias"; import { desktop3pAlias } from "../claude/desktop-3p"; export interface ClaudeLaunchEnv { @@ -373,12 +373,13 @@ export function buildClaudeEnv( // worse than the problem. So this stays opt-in per config rather than // unconditional, and setDefault keeps an operator's own export. setDefault("ENABLE_TOOL_SEARCH", claudeToolSearchEnv(config.claudeCode?.toolSearch)); - // Context-window override: the official pair — MAX_CONTEXT_TOKENS alone is ignored - // for recognized claude-shaped ids unless DISABLE_COMPACT=1 rides along (devlog 135). const maxCtx = config.claudeCode?.maxContextTokens; if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) { setDefault("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx))); - setDefault("DISABLE_COMPACT", "1"); + // Claude Code 2.1.278 honors this without DISABLE_COMPACT when the model id + // does not start with "claude-" (gF). Current ocx-claude aliases qualify. + // A persisted claude-ocx id is still claude-shaped, so that one session keeps + // the 200k accounting until the picker is moved to the new id. } // Auto-context (devlog 260712 020): min(believed window, env) inside the CLI means // one global env acts as a per-model floor — [1m]-marked models compact here while @@ -464,10 +465,15 @@ export function readConnectedClaudeContextWindows(path = DEFAULT_CATALOG_PATH): const id = slug.slice(slash + 1); const routeAlias = aliasForRoute(provider, id); if (routeAlias) put(routeAlias, contextWindow); + // A selector saved under the legacy claude-ocx spelling keeps its window here too. + const legacyRoute = legacyAliasForRoute(provider, id); + if (legacyRoute) put(legacyRoute, contextWindow); put(desktop3pAlias(provider, id), contextWindow); } else { const nativeAlias = aliasForNative(slug); if (nativeAlias) put(nativeAlias, contextWindow); + const legacyNative = legacyAliasForNative(slug); + if (legacyNative) put(legacyNative, contextWindow); put(desktop3pAlias("native", slug), contextWindow); } } @@ -593,7 +599,8 @@ const DESKTOP_3P_ALIAS = /^claude-opus-4(?:-8)?-[a-z][0-9a-z]{2}$/; export function isProxyOnlyModelId(value: string, providerNames: readonly string[] = []): boolean { const id = value.trim().replace(/\[1m\]$/, ""); if (!id) return false; - if (id.startsWith(CLAUDE_ALIAS_PREFIX_V1) || id.startsWith(CLAUDE_ALIAS_PREFIX_V2) || DESKTOP_3P_ALIAS.test(id)) { + const aliasPrefixes = [CLAUDE_ALIAS_PREFIX_CURRENT, CLAUDE_ALIAS_PREFIX_CURRENT_V2, CLAUDE_ALIAS_PREFIX_V1, CLAUDE_ALIAS_PREFIX_V2]; + if (aliasPrefixes.some(prefix => id.startsWith(prefix)) || DESKTOP_3P_ALIAS.test(id)) { return true; } const slash = id.indexOf("/"); diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index ab36c47b4fc..807885f9a5d 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -17,9 +17,10 @@ import { jsonUtf8Bytes } from "../lib/json-byte-size"; import { sseFieldValue } from "../lib/sse-decoder"; import { enforceAnthropicImageLimits, sniffImageDimensions } from "../adapters/anthropic-image-guard"; import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; +import { createToolCallIdAllocator } from "../adapters/tool-call-id"; import { AnthropicRequestError, DesktopModelMappingUnavailableError, anthropicToResponsesTranslation, extractOcxEffortDirective, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; import { isKnownDesktop3pModelId, resolveDesktop3pAlias } from "../claude/desktop-3p"; -import { resolveAlias, claudeCodeNativeAlias } from "../claude/alias"; +import { resolveAlias, claudeCodeNativeAlias, legacyAliasForNative } from "../claude/alias"; import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; import { captureClaudeInbound } from "../claude/inbound-debug"; @@ -112,7 +113,8 @@ function decodeClaudeFastSelector(raw: string, cc?: OcxConfig["claudeCode"]): st function decodeFablePickerAlias(raw: string, cc?: OcxConfig["claudeCode"]): string { const decoded = resolveInboundModel(raw, cc); if (!decoded.startsWith("claude-fable-")) return raw; - return claudeCodeNativeAlias(decoded) === raw ? decoded : raw; + // A picker value saved before the ocx-claude spelling keeps the native passthrough too. + return claudeCodeNativeAlias(decoded) === raw || legacyAliasForNative(decoded) === raw ? decoded : raw; } function isRec(v: unknown): v is Rec { @@ -406,6 +408,44 @@ export function tapAnthropicSseForLog( }); } +/** + * `tool_use.id` / `tool_result.tool_use_id` must match Anthropic's wire contract + * (`^[a-zA-Z0-9_-]+$`, <=64 chars). Third-party models mint other shapes — Devin's + * swe-2 emits `Bash:0#` — and a session history carrying them 400s the moment it + * is switched to a native Anthropic model ("messages.N.content.M.tool_use.id: String + * should match pattern"). The adapter path normalizes these via + * adapters/tool-call-id.ts (#1780); this passthrough bypasses that adapter, so the same + * allocator runs here. Stateless per request: conforming ids pass through byte-identical + * (prompt-cache keys untouched), rewritten ids keep call/result pairing stable. + * An empty id has no representable wire form, and forwarding `""` is what Anthropic + * rejects (#1767), so the request fails locally with a 400 before any upstream fetch. + */ +function sanitizePassthroughToolCallIds(messages: unknown[]): void { + const blocks: Rec[] = []; + for (const message of messages) { + if (!isRec(message) || !Array.isArray(message.content)) continue; + for (const block of message.content) if (isRec(block)) blocks.push(block); + } + const fieldOf = (block: Rec): "id" | "tool_use_id" | undefined => { + if (typeof block.type !== "string") return undefined; + if (block.type.endsWith("tool_use")) return "id"; + if (block.type.endsWith("tool_result")) return "tool_use_id"; + return undefined; + }; + const callIds = createToolCallIdAllocator(); + for (const block of blocks) { + const field = fieldOf(block); + if (field && typeof block[field] === "string") callIds.reserve(block[field] as string); + } + for (const block of blocks) { + const field = fieldOf(block); + if (!field || typeof block[field] !== "string") continue; + const wire = callIds.allocate(block[field] as string); + if (wire === undefined) throw new AnthropicRequestError(`${block.type} block has an empty ${field}`); + block[field] = wire; + } +} + async function anthropicNativePassthrough( req: Request, config: OcxConfig, @@ -435,6 +475,7 @@ async function anthropicNativePassthrough( if (Array.isArray(body.messages)) { await normalizeAnthropicImages(body.messages, { abortSignal: req.signal }); enforceAnthropicImageLimits(body.messages); + sanitizePassthroughToolCallIds(body.messages); } const headers = new Headers(); req.headers.forEach((value, name) => { diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 7c5f6af3605..a2221006c97 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1666,8 +1666,12 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const warnings: string[] = []; // authMode changes must reconcile the injected system env too: switching back to // Subscription has to remove the opencodex-owned dummy ANTHROPIC_AUTH_TOKEN - // (audit R1 blocker #1/#2, devlog 260720_claude_authmode_persist). - if (body.systemEnv !== undefined || body.authMode !== undefined) { + // (audit R1 blocker #1/#2, devlog 260720_claude_authmode_persist). Model slots and + // levers feed the same injection, so a changed or cleared slot must not linger in + // launchd until the next restart. + const systemEnvInputs = ["systemEnv", "authMode", "model", "smallFastModel", "tierModels", + "maxContextTokens", "alwaysEnableEffort", "autoContext", "autoCompactWindow"] as const; + if (systemEnvInputs.some(field => body[field] !== undefined)) { try { await applySystemEnvToggle(config, config.port); } catch (err) { diff --git a/src/server/system-env-shell.ts b/src/server/system-env-shell.ts index b8e1b0176ef..04964bf0478 100644 --- a/src/server/system-env-shell.ts +++ b/src/server/system-env-shell.ts @@ -120,7 +120,6 @@ export function writeShellEnvFile( const maxCtx = config.claudeCode?.maxContextTokens; if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) { lines.push(conditional("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx)))); - lines.push(conditional("DISABLE_COMPACT", "1")); } // Auto-context (devlog 260712 020): same contract as `ocx claude` / launchctl. const autoShell = auto ?? resolveAutoContext(config.claudeCode); diff --git a/src/server/system-env.ts b/src/server/system-env.ts index fc628a8f96a..a499e51f517 100644 --- a/src/server/system-env.ts +++ b/src/server/system-env.ts @@ -282,9 +282,12 @@ export async function injectSystemEnv( } // Lever keys (devlog 136 B6): user-wins — skip any key the user already set in the // launchd domain, and track ONLY the keys we actually injected so revert cannot - // delete a pre-existing user value (audit 139 #3). + // delete a pre-existing user value (audit 139 #3). A key we already track is ours + // (revertSystemEnv unsets it regardless of value), so it is refreshed, not skipped. + const producedLevers = new Set(); const injectLever = (name: string, value: string) => { - if (launchctlGetenv(name) !== undefined) return; + producedLevers.add(name); + if (launchctlGetenv(name) !== undefined && !injectedKeys.includes(name)) return; inject(name, value); }; // Model slots (default + tier defaults + legacy small-fast) with [1m] auto-marking @@ -293,7 +296,9 @@ export async function injectSystemEnv( // Auto-context: a user-owned launchd value drives the marking predicate so the // marker and threshold never separate (audit 021 #2); injectLever's user-wins // check below keeps that value untouched. - const userAutoCompact = launchctlGetenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW"); + const userAutoCompact = injectedKeys.includes("CLAUDE_CODE_AUTO_COMPACT_WINDOW") + ? undefined + : launchctlGetenv("CLAUDE_CODE_AUTO_COMPACT_WINDOW"); const auto = resolveAutoContext(config.claudeCode, userAutoCompact); const { modelEnv, windows } = await computeEffectiveModelEnv(config, auto); for (const [name, value] of Object.entries(modelEnv)) { @@ -303,7 +308,6 @@ export async function injectSystemEnv( const maxCtx = config.claudeCode?.maxContextTokens; if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) { injectLever("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx))); - injectLever("DISABLE_COMPACT", "1"); } // Auto-context (devlog 260712 020): user-wins lever, inert when maxContextTokens set. if (auto.enabled) injectLever("CLAUDE_CODE_AUTO_COMPACT_WINDOW", String(auto.compactWindow)); @@ -315,6 +319,23 @@ export async function injectSystemEnv( // instead of only terminal sessions. injectLever keeps a user-owned launchd value. const toolSearch = claudeToolSearchEnv(config.claudeCode?.toolSearch); if (toolSearch !== undefined) injectLever("ENABLE_TOOL_SEARCH", toolSearch); + // A lever injected on an earlier run that this config no longer produces (a cleared + // smallFastModel, a removed tier slot, or the DISABLE_COMPACT older releases paired with + // maxContextTokens) would otherwise stay in launchd until the proxy stops, and a + // same-port restart keeps the tracking record. Only tracked keys are touched, so a user-owned value is never removed. + for (const name of [...injectedKeys]) { + if ((SYSTEM_ENV_NAMES as readonly string[]).includes(name) || producedLevers.has(name)) continue; + // Older releases only ever injected DISABLE_COMPACT=1. Any other value was set by hand, + // so ownership is released without deleting it. + if (name === "DISABLE_COMPACT" && launchctlGetenv(name) !== "1") { + injectedKeys.splice(injectedKeys.indexOf(name), 1); + writeTracking(port, injectedKeys, tracked); + continue; + } + unsetLaunchctlEnv(name); + injectedKeys.splice(injectedKeys.indexOf(name), 1); + writeTracking(port, injectedKeys, tracked); + } // Shell-hook env file: works for new shells in already-running Terminal.app. writeShellEnvFile(port, config, modelEnv, auto, deps); diff --git a/src/types/config.ts b/src/types/config.ts index 6fc6939cce8..3a8943ae031 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -81,10 +81,11 @@ export interface OcxClaudeCodeConfig { */ authModeMigratedAt?: string; /** - * Context-window override for Claude Code/Desktop clients (devlog 136 B6): - * injected as CLAUDE_CODE_MAX_CONTEXT_TOKENS + DISABLE_COMPACT=1 (the official - * env pair — recognized claude-shaped ids need both). WARNING: DISABLE_COMPACT - * turns off auto-compaction. Unset = client defaults. + * Context-window override for Claude Code/Desktop clients (devlog 136 B6). + * Injected as CLAUDE_CODE_MAX_CONTEXT_TOKENS only. Current ocx-claude aliases do + * not start with claude-, so Claude Code 2.1.278 honors the window without + * DISABLE_COMPACT. A persisted claude-ocx id is still claude-shaped and keeps + * the 200k accounting until the picker selects the new id. Unset = client defaults. */ maxContextTokens?: number; /** @@ -131,8 +132,7 @@ export interface OcxClaudeCodeConfig { * (Claude Code then accounts 1M) and CLAUDE_CODE_AUTO_COMPACT_WINDOW is injected * so compaction fires at the real budget. 2.1.207 semantics (binary-verified): * effective compact window = min(believed window, env) — one global env behaves - * like a per-model floor. Default: enabled. Inert while maxContextTokens is set - * (the legacy DISABLE_COMPACT pair takes rule-1 precedence in the CLI). + * like a per-model floor. Default: enabled. Inert while maxContextTokens is set. */ autoContext?: boolean; /** Compact-window tokens for auto-context. Default 829_800 (AUTO_COMPACT_WINDOW_DEFAULT). */ diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 9c21b161663..c4c6dad7742 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -263,3 +263,21 @@ Native steering generation overrides, explicit public-API eligibility and the co Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting). The [compaction routing override](../transports/responses-failover.md#compaction-routing-overrides) is scoped to Codex Responses metadata and original Responses ingress; Claude Messages replay retains its own routing. + +## Routed bundled-skill text + +`src/claude/inbound.ts` bounds the text-carrier skill-directory probe to 4,096 UTF-16 code units, plus one character to recognize the terminating newline. A longer first line is preserved intact instead of being scanned or stubbed; normal POSIX, Windows, mixed and UNC separators retain their basename matching. The existing 10,000-character payload threshold and `claudeCode.blockedSkills` policy remain: `claude-api` is blocked by default, and an explicit empty list disables elision. Native Anthropic passthrough and tool-call/result pairing are unchanged. `tests/claude-integration/claude-inbound.test.ts` covers the exact 4,096/4,097 boundary and a long newline-free carrier. + +## Claude Code picker descriptions + +`src/claude/model-info.ts` gives every readable (`idStyle: "readable"`, Claude Code CLI) `/v1/models` row a `description` that Claude Code 2.1.257 and later shows under the picker entry instead of the generic "From gateway": `Routed by OpenCodex to native ` for native rows and `Routed by OpenCodex to /` for routed rows. The 1M copy keeps the base description and a Fast sibling appends ` · Fast`. Desktop 3P rows keep the ModelInfo shape without a description. `src/claude/gateway-cache.ts` preserves a string `description` when it refreshes and rewrites the gateway-model cache and drops any other type. `tests/claude-integration/claude-model-info.test.ts` and `tests/claude-integration/claude-gateway-cache.test.ts` cover both. + +## Claude Code routed aliases and the context window + +`src/claude/alias.ts` mints Claude Code CLI aliases as `ocx-claude---`, or `ocx-claude2-` with `~s`/`~t` escapes when the model id holds `/` or `~`. The id contains `claude`, which the picker requires, and does not start with `claude-`: Claude Code 2.1.278 accounts an unrecognized `claude-` id at 200k and applies `CLAUDE_CODE_MAX_CONTEXT_TOKENS` to it only with `DISABLE_COMPACT=1`. Saved `claude-ocx-`/`claude-ocx2-` ids still decode, and `src/claude/context-windows.ts` and the connected-client map `readConnectedClaudeContextWindows` in `src/cli/claude.ts` register both spellings at the same window, and `decodeFablePickerAlias` in `src/server/claude-messages.ts` keeps a legacy native Fable picker value on the native passthrough, so a saved selector keeps its window lookup until it is re-picked. `effectiveModelEnv` emits a legacy selector configured in an OpenCodex slot in its current spelling (`currentClaudeAliasSpelling`), so Claude Code applies the window to it; a selection saved by Claude Code's own picker is outside OpenCodex's ownership and keeps 200k accounting until it is re-picked. `isProxyOnlyModelId` in `src/cli/claude.ts` treats all four prefixes as proxy-only for native fallback. + +`claudeCode.maxContextTokens` injects only `CLAUDE_CODE_MAX_CONTEXT_TOKENS` on the `ocx claude`, launchd system-env and shell-hook paths; compact stays enabled and neither `DISABLE_COMPACT` nor `CLAUDE_CODE_AUTO_COMPACT_WINDOW` is injected beside it, whatever the value. A `DISABLE_COMPACT` an older release injected and tracked is unset by the system-env produced-key sweep while it still holds the injected `1`; a tracked key the user changed to another value is released from tracking without being deleted, and an untracked user value is never touched. `tests/claude-integration/claude-alias.test.ts`, `claude-context-windows.test.ts`, `claude-cli.test.ts` and `tests/server/system-env.test.ts` cover these. + +## Native passthrough tool-call ids + +Native Anthropic passthrough in `src/server/claude-messages.ts` forwards the caller's body except for tool-call ids: `sanitizePassthroughToolCallIds` runs the request-scoped allocator from `src/adapters/tool-call-id.ts` over every `*tool_use` id and `*tool_result` `tool_use_id`. Conforming ids are reserved first and stay byte-identical, a non-conforming or overlength id is rewritten to a conforming id of at most 64 characters with call/result pairing kept, and an empty id throws `AnthropicRequestError`, so the request fails with a local 400 before the upstream fetch. `tests/claude-integration/claude-native-passthrough.test.ts` covers rewriting, pairing, the empty id, the overlength id and collision with an existing valid id. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 215770154fa..f33ff9ac28c 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -177,7 +177,7 @@ this document owns is which module holds which area and what invariant that area | Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. | | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`. Visibility writes trigger catalog sync through the owning server path. | | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | -| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply` (`mode`: `first-party` default, `gateway`, or legacy shapes), `GET /api/claude-desktop/status` (`mode`, `firstParty`), `GET/PUT /api/claude-code`. Gateway apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)); first-party apply writes only the Claude Code proxy env, see [`clients/claude-desktop.md`](clients/claude-desktop.md#desktop-modes-first-party-and-gateway). `gui/src/pages/ClaudeDesktop.tsx` renders the mode selector and sends the chosen `mode` with apply. | +| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply` (`mode`: `first-party` default, `gateway`, or legacy shapes), `GET /api/claude-desktop/status` (`mode`, `firstParty`), `GET/PUT /api/claude-code`. Gateway apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)); first-party apply writes only the Claude Code proxy env, see [`clients/claude-desktop.md`](clients/claude-desktop.md#desktop-modes-first-party-and-gateway). `gui/src/pages/ClaudeDesktop.tsx` renders the mode selector and sends the chosen `mode` with apply. `PUT /api/claude-code` re-runs macOS system-env reconciliation (`src/server/system-env.ts`) whenever the body carries `systemEnv`, `authMode`, a model slot or a lever field: keys opencodex tracks as injected are refreshed or unset once the config stops producing them, and a launchd value the user set before injection is never touched. | | File-integration plans | `src/server/management/integration-routes.ts` and `aside-profile-routes.ts` — `POST /api/client-integrations/preview`, `POST /api/client-integrations/restore/preview`, and `POST /api/client-integrations/aside/profiles/{profileId}/preview`. Management-authenticated, declared non-mutating, and they write nothing: no snapshot, no lock, no maintenance, no recovery. They answer `409 integration_preview_unavailable` rather than gathering a model roster, because discovery refreshes credentials and writes the provider cache. Responses carry only declared managed schema paths, closed change kinds and an opaque fingerprint; no value, filesystem location or selected member identity appears. Mutation routes accept `operation` and `planFingerprint` together or not at all, reject a half-bound request and an operation that disagrees with the change, and answer `409 integration_preview_stale` with a freshly computed plan. Binding is an optimistic token, never authorization. [The integration contract](clients/integrations.md) owns the ordering. | | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | | Claude reset grants | `src/server/management/anthropic-reset-grant-routes.ts` — `GET /api/anthropic/reset-grants`, `POST /api/anthropic/reset-grants/consume` (lazy-loaded). Wire and fail-closed parsing live in `src/providers/anthropic-reset-grants.ts` (the Claude Code 2.1.278 `cedar_ember` contract, sent with `CLAUDE_CLI_USER_AGENT` from `src/providers/claude-cli-identity.ts`); the journal is `src/providers/anthropic-reset-grant-ledger.ts`: a cross-process `BEGIN IMMEDIATE` lock around every synchronous read-modify-write, a 90 s lease, the operation id reused as the upstream `request_id`, same-id retry only inside the vendor's ten-minute window, no settlement inferred from a re-read, and a fail-closed `500 journal_write_failed` when an answer cannot be recorded. Spending requires the `gui-session` principal. The dashboard owner is `gui/src/hooks/useAnthropicResetGrants.ts` with `gui/src/components/provider-workspace/AnthropicResetGrants.tsx` on the Anthropic OAuth rows of `ProviderAuthPanel`; after an unknown outcome the dialog only retries the same id. Design and audit record: [`../devlog/_plan/260923_claude_reset_grants/010_plan.md`](../devlog/_plan/260923_claude_reset_grants/010_plan.md). | diff --git a/tests/claude-integration/claude-agents-inject.test.ts b/tests/claude-integration/claude-agents-inject.test.ts index 2945e81238b..4716efb9446 100644 --- a/tests/claude-integration/claude-agents-inject.test.ts +++ b/tests/claude-integration/claude-agents-inject.test.ts @@ -31,9 +31,9 @@ function generatedBodies(config: OcxConfig, dir: string): string[] { describe("buildClaudeAgentDefs (devlog 070 + audit 071)", () => { test("roster + pinned self mark only authoritative 1M windows; name collision suffix", () => { - const windows = { "claude-ocx-native--gpt-5.6-sol": 372_000, "claude-ocx-cursor--gpt-5.6-sol": 1_000_000 }; + const windows = { "ocx-claude-native--gpt-5.6-sol": 372_000, "ocx-claude-cursor--gpt-5.6-sol": 1_000_000 }; const dir = tempDir(); - writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "claude-ocx-native--gpt-5.6-sol[1m]" })); + writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "ocx-claude-native--gpt-5.6-sol[1m]" })); const defs = buildClaudeAgentDefs(cfg({ subagentModels: ["gpt-5.6-sol", "cursor/gpt-5.6-sol"], claudeCode: { autoContext: true }, @@ -41,10 +41,10 @@ describe("buildClaudeAgentDefs (devlog 070 + audit 071)", () => { const byName = Object.fromEntries(defs.map(d => [d.name, d])); // 372K >= 350K compact default marks the MAIN session (env slots pair with the // compact window), but a generated subagent has no such pairing — it stays bare. - expect(byName["ocx-gpt-5-6-sol"]!.model).toBe("claude-ocx-native--gpt-5.6-sol"); - expect(byName["ocx-gpt-5-6-sol-2"]!.model).toBe("claude-ocx-cursor--gpt-5.6-sol[1m]"); // collision suffix + expect(byName["ocx-gpt-5-6-sol"]!.model).toBe("ocx-claude-native--gpt-5.6-sol"); + expect(byName["ocx-gpt-5-6-sol-2"]!.model).toBe("ocx-claude-cursor--gpt-5.6-sol[1m]"); // collision suffix // Self pins the picker-saved default but cannot inherit an unsafe auto-context marker. - expect(byName["ocx-self"]!.model).toBe("claude-ocx-native--gpt-5.6-sol"); + expect(byName["ocx-self"]!.model).toBe("ocx-claude-native--gpt-5.6-sol"); expect(defs).toHaveLength(3); // Dispatcher directive (live repro: model:"fable" override broke inherit). for (const d of defs) expect(d.description).toContain("`model` argument is ignored"); @@ -81,47 +81,47 @@ describe("buildClaudeAgentDefs (devlog 070 + audit 071)", () => { const catalog = await fetchProviderModels("kimi", kimi, 0); const windows = buildClaudeContextWindows([], catalog); const dir = tempDir(); - writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "claude-ocx-kimi--k3[1m]" })); + writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "ocx-claude-kimi--k3[1m]" })); const defs = buildClaudeAgentDefs(config, windows, dir); const models = Object.fromEntries(defs.map(def => [def.name, def.model])); - expect(windows["claude-ocx-kimi--k3"]).toBe(262_144); - expect(windows["claude-ocx-kimi--k3[1m]"]).toBe(1_048_576); + expect(windows["ocx-claude-kimi--k3"]).toBe(262_144); + expect(windows["ocx-claude-kimi--k3[1m]"]).toBe(1_048_576); expect(models).toEqual({ - "ocx-k3-1m": "claude-ocx-kimi--k3[1m]", - "ocx-self": "claude-ocx-kimi--k3[1m]", + "ocx-k3-1m": "ocx-claude-kimi--k3[1m]", + "ocx-self": "ocx-claude-kimi--k3[1m]", }); // A provider cap below 1M unmarks the same selector. const cappedCatalog = await fetchProviderModels("kimi", kimi, 0, 350_000); const cappedWindows = buildClaudeContextWindows([], cappedCatalog); const cappedDir = tempDir(); - writeFileSync(join(cappedDir, "settings.json"), JSON.stringify({ model: "claude-ocx-kimi--k3[1m]" })); + writeFileSync(join(cappedDir, "settings.json"), JSON.stringify({ model: "ocx-claude-kimi--k3[1m]" })); const cappedDefs = buildClaudeAgentDefs(config, cappedWindows, cappedDir); - expect(cappedWindows["claude-ocx-kimi--k3[1m]"]).toBe(350_000); + expect(cappedWindows["ocx-claude-kimi--k3[1m]"]).toBe(350_000); expect(Object.fromEntries(cappedDefs.map(def => [def.name, def.model]))).toEqual({ - "ocx-k3-1m": "claude-ocx-kimi--k3", - "ocx-self": "claude-ocx-kimi--k3", + "ocx-k3-1m": "ocx-claude-kimi--k3", + "ocx-self": "ocx-claude-kimi--k3", }); }); test("marker case is honored and unknown windows keep the selector as-was", () => { - const windows = { "claude-ocx-cursor--gpt-5.6-sol": 1_000_000 }; + const windows = { "ocx-claude-cursor--gpt-5.6-sol": 1_000_000 }; const dir = tempDir(); // Uppercase [1M] spelling is a genuine marker (the CLI matches /\[1m\]/i). - writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "claude-ocx-cursor--gpt-5.6-sol[1M]" })); + writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "ocx-claude-cursor--gpt-5.6-sol[1M]" })); const defs = buildClaudeAgentDefs(cfg({ subagentModels: ["cursor/gpt-5.6-sol", "cursor/unknown-model"] }), windows, dir); const byName = Object.fromEntries(defs.map(d => [d.name, d])); - expect(byName["ocx-gpt-5-6-sol"]!.model).toBe("claude-ocx-cursor--gpt-5.6-sol[1m]"); + expect(byName["ocx-gpt-5-6-sol"]!.model).toBe("ocx-claude-cursor--gpt-5.6-sol[1m]"); // Incomplete metadata: no window entry -> selector preserved, never unmarked. - expect(byName["ocx-unknown-model"]!.model).toBe("claude-ocx-cursor--unknown-model"); - expect(byName["ocx-self"]!.model).toBe("claude-ocx-cursor--gpt-5.6-sol[1M]"); + expect(byName["ocx-unknown-model"]!.model).toBe("ocx-claude-cursor--unknown-model"); + expect(byName["ocx-self"]!.model).toBe("ocx-claude-cursor--gpt-5.6-sol[1M]"); }); test("placeholder guidance recommends haiku, never sonnet (issue #252)", () => { const dir = tempDir(); - writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "claude-ocx-native--gpt-5.6-sol" })); + writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "ocx-claude-native--gpt-5.6-sol" })); const defs = buildClaudeAgentDefs(cfg({ subagentModels: ["gpt-5.6-sol"] }), {}, dir); expect(defs.length).toBeGreaterThan(0); for (const d of defs) { @@ -165,7 +165,7 @@ describe("buildClaudeAgentDefs (devlog 070 + audit 071)", () => { const levels = ["low", "medium", "high", "xhigh", "max"] as const; for (const effort of levels) { const dir = tempDir(); - writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "claude-ocx-native--gpt-5.6-sol" })); + writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "ocx-claude-native--gpt-5.6-sol" })); const defs = buildClaudeAgentDefs(cfg({ subagentModels: ["gpt-5.6-sol"], claudeCode: { subagentEffort: effort }, @@ -194,7 +194,7 @@ describe("buildClaudeAgentDefs (devlog 070 + audit 071)", () => { test("generated routed agents refuse the default blocked skill before its bundle expands", () => { const dir = tempDir(); - writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "claude-ocx-native--gpt-5.6-sol" })); + writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "ocx-claude-native--gpt-5.6-sol" })); const bodies = generatedBodies(cfg({ subagentModels: ["gpt-5.6-sol"] }), dir); expect(bodies).toHaveLength(2); // roster + ocx-self for (const body of bodies) { @@ -205,7 +205,7 @@ describe("buildClaudeAgentDefs (devlog 070 + audit 071)", () => { test("generated blocked-skill guard mirrors custom names and honors explicit opt-out", () => { const customDir = tempDir(); - writeFileSync(join(customDir, "settings.json"), JSON.stringify({ model: "claude-ocx-native--gpt-5.6-sol" })); + writeFileSync(join(customDir, "settings.json"), JSON.stringify({ model: "ocx-claude-native--gpt-5.6-sol" })); const customBodies = generatedBodies(cfg({ subagentModels: ["gpt-5.6-sol"], claudeCode: { blockedSkills: [" My-Skill "] }, @@ -218,7 +218,7 @@ describe("buildClaudeAgentDefs (devlog 070 + audit 071)", () => { } const offDir = tempDir(); - writeFileSync(join(offDir, "settings.json"), JSON.stringify({ model: "claude-ocx-native--gpt-5.6-sol" })); + writeFileSync(join(offDir, "settings.json"), JSON.stringify({ model: "ocx-claude-native--gpt-5.6-sol" })); const offBodies = generatedBodies(cfg({ subagentModels: ["gpt-5.6-sol"], claudeCode: { blockedSkills: [] }, @@ -280,7 +280,7 @@ describe("syncClaudeAgentDefs ownership contract (audit 071 #2/#3)", () => { test("writes, overwrites, and prunes ONLY marker-verified ocx files", () => { const dir = tempDir(); - writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "claude-ocx-native--gpt-5.6-sol" })); + writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "ocx-claude-native--gpt-5.6-sol" })); const defs = buildClaudeAgentDefs(cfg({ subagentModels: ["gpt-5.6-sol"] }), {}, dir); expect(syncClaudeAgentDefs(defs, dir)!.length).toBe(2); const agentsDir = join(dir, "agents"); @@ -330,7 +330,7 @@ describe("syncClaudeAgentDefs ownership contract (audit 071 #2/#3)", () => { test("injectClaudeAgentDefs prunes owned files when disabled (audit 071 #3)", () => { const dir = tempDir(); - writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "claude-ocx-native--gpt-5.6-sol" })); + writeFileSync(join(dir, "settings.json"), JSON.stringify({ model: "ocx-claude-native--gpt-5.6-sol" })); injectClaudeAgentDefs(cfg({ subagentModels: ["gpt-5.6-sol"] }), {}, dir); expect(readdirSync(join(dir, "agents")).length).toBe(2); injectClaudeAgentDefs(cfg({ subagentModels: ["gpt-5.6-sol"], claudeCode: { injectAgents: false } }), {}, dir); diff --git a/tests/claude-integration/claude-alias.test.ts b/tests/claude-integration/claude-alias.test.ts index c5c94833257..912cbd01a69 100644 --- a/tests/claude-integration/claude-alias.test.ts +++ b/tests/claude-integration/claude-alias.test.ts @@ -4,7 +4,7 @@ import { aliasForRoute, CLAUDE_ALIAS_PREFIX, CLAUDE_ALIAS_PREFIX_V1, - CLAUDE_ALIAS_PREFIX_V2, + CLAUDE_ALIAS_PREFIX_CURRENT_V2, claudeCodeAlias, claudeCodeNativeAlias, resolveAlias, @@ -26,8 +26,9 @@ describe("claude discovery aliases", () => { for (const [provider, model] of cases) { const alias = aliasForRoute(provider, model); expect(alias).not.toBeNull(); - expect(alias!.startsWith("claude")).toBe(true); // picker prefix rule (003 G3) - expect(alias!.startsWith(CLAUDE_ALIAS_PREFIX_V1)).toBe(true); // plain → v1 + expect(alias!.includes("claude")).toBe(true); // picker accepts claude anywhere + expect(alias!.startsWith("claude-")).toBe(false); // claude- prefix locks unknown models at 200k + expect(alias!.startsWith(CLAUDE_ALIAS_PREFIX)).toBe(true); // plain ids mint the current prefix expect(resolveAlias(alias!)).toBe(`${provider}/${model}`); } }); @@ -40,10 +41,10 @@ describe("claude discovery aliases", () => { test("native slugs with literal '~' mint v2 and round-trip via ~t", () => { const alias = aliasForNative("gpt~special"); - expect(alias).toBe(`${CLAUDE_ALIAS_PREFIX_V2}native--gpt~tspecial`); + expect(alias).toBe(`${CLAUDE_ALIAS_PREFIX_CURRENT_V2}native--gpt~tspecial`); expect(resolveAlias(alias!)).toBe("gpt~special"); - expect(claudeCodeNativeAlias("gpt~special")).toBe(`${CLAUDE_ALIAS_PREFIX_V2}native--gpt~tspecial`); - expect(resolveInboundModel(`${CLAUDE_ALIAS_PREFIX_V2}native--gpt~tspecial`, undefined)).toBe("gpt~special"); + expect(claudeCodeNativeAlias("gpt~special")).toBe(`${CLAUDE_ALIAS_PREFIX_CURRENT_V2}native--gpt~tspecial`); + expect(resolveInboundModel(`${CLAUDE_ALIAS_PREFIX_CURRENT_V2}native--gpt~tspecial`, undefined)).toBe("gpt~special"); }); test("non-representable shapes are skipped, not mangled", () => { @@ -58,10 +59,10 @@ describe("claude discovery aliases", () => { test("model ids with '/' mint v2 (~s) and round-trip (OpenRouter-shaped)", () => { const alias = aliasForRoute("openrouter", "anthropic/claude-opus-4-8"); - expect(alias).toBe(`${CLAUDE_ALIAS_PREFIX_V2}openrouter--anthropic~sclaude-opus-4-8`); + expect(alias).toBe(`${CLAUDE_ALIAS_PREFIX_CURRENT_V2}openrouter--anthropic~sclaude-opus-4-8`); expect(resolveAlias(alias!)).toBe("openrouter/anthropic/claude-opus-4-8"); expect(claudeCodeAlias("openrouter", "meta-llama/llama-3.3-70b-instruct:free")).toBe( - `${CLAUDE_ALIAS_PREFIX_V2}openrouter--meta-llama~sllama-3.3-70b-instruct:free`, + `${CLAUDE_ALIAS_PREFIX_CURRENT_V2}openrouter--meta-llama~sllama-3.3-70b-instruct:free`, ); expect(resolveAlias(claudeCodeAlias("openrouter", "meta-llama/llama-3.3-70b-instruct:free"))).toBe( "openrouter/meta-llama/llama-3.3-70b-instruct:free", @@ -69,8 +70,8 @@ describe("claude discovery aliases", () => { }); test("literal '~' mints v2 (~t); v1 bare '~' and literal ~s/~t still resolve", () => { - expect(aliasForRoute("demo", "old~model")).toBe(`${CLAUDE_ALIAS_PREFIX_V2}demo--old~tmodel`); - expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V2}demo--old~tmodel`)).toBe("demo/old~model"); + expect(aliasForRoute("demo", "old~model")).toBe(`${CLAUDE_ALIAS_PREFIX_CURRENT_V2}demo--old~tmodel`); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_CURRENT_V2}demo--old~tmodel`)).toBe("demo/old~model"); // Pre-escape v1 aliases kept literal tildes in the model portion. expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}demo--old~model`)).toBe("demo/old~model"); // v1 literal ~s / ~t are preserved (the versioned-prefix compatibility fix). @@ -79,12 +80,12 @@ describe("claude discovery aliases", () => { }); test("v2 reserved escapes round-trip / and ~ without colliding with v1 literals", () => { - expect(aliasForRoute("demo", "a/b")).toBe(`${CLAUDE_ALIAS_PREFIX_V2}demo--a~sb`); - expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V2}demo--a~sb`)).toBe("demo/a/b"); - expect(aliasForRoute("demo", "a~b")).toBe(`${CLAUDE_ALIAS_PREFIX_V2}demo--a~tb`); - expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V2}demo--a~tb`)).toBe("demo/a~b"); - expect(aliasForRoute("demo", "a~/b")).toBe(`${CLAUDE_ALIAS_PREFIX_V2}demo--a~t~sb`); - expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V2}demo--a~t~sb`)).toBe("demo/a~/b"); + expect(aliasForRoute("demo", "a/b")).toBe(`${CLAUDE_ALIAS_PREFIX_CURRENT_V2}demo--a~sb`); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_CURRENT_V2}demo--a~sb`)).toBe("demo/a/b"); + expect(aliasForRoute("demo", "a~b")).toBe(`${CLAUDE_ALIAS_PREFIX_CURRENT_V2}demo--a~tb`); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_CURRENT_V2}demo--a~tb`)).toBe("demo/a~b"); + expect(aliasForRoute("demo", "a~/b")).toBe(`${CLAUDE_ALIAS_PREFIX_CURRENT_V2}demo--a~t~sb`); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_CURRENT_V2}demo--a~t~sb`)).toBe("demo/a~/b"); // Same wire bytes under v1 stay literal — no silent remap to slash/tilde. expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}demo--a~sb`)).toBe("demo/a~sb"); @@ -97,8 +98,8 @@ describe("claude discovery aliases", () => { expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}noseparator`)).toBeNull(); expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}p--`)).toBeNull(); expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}--m`)).toBeNull(); - expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V2}noseparator`)).toBeNull(); - expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V2}p--`)).toBeNull(); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_CURRENT_V2}noseparator`)).toBeNull(); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_CURRENT_V2}p--`)).toBeNull(); }); test("no collisions across a registry-shaped corpus", () => { @@ -121,14 +122,18 @@ describe("claude discovery aliases", () => { describe("claudeCodeAlias — readable-or-hash shared helper (devlog 050 / audit 051 #2)", () => { test("readable form when representable; both forms decode to the same route", () => { - expect(claudeCodeAlias("gemini", "gemini-3-pro")).toBe("claude-ocx-gemini--gemini-3-pro"); - expect(claudeCodeNativeAlias("gpt-5.6-sol")).toBe("claude-ocx-native--gpt-5.6-sol"); + expect(claudeCodeAlias("gemini", "gemini-3-pro")).toBe("ocx-claude-gemini--gemini-3-pro"); + expect(claudeCodeNativeAlias("gpt-5.6-sol")).toBe("ocx-claude-native--gpt-5.6-sol"); expect(resolveInboundModel(claudeCodeAlias("gemini", "gemini-3-pro"), undefined)).toBe("gemini/gemini-3-pro"); expect(resolveInboundModel(claudeCodeNativeAlias("gpt-5.6-sol"), undefined)).toBe("gpt-5.6-sol"); // Readable id with the [1m] context marker (picker variant row) decodes too — // strip happens before alias resolution, case-insensitively (audit 051 #4). expect(resolveInboundModel("claude-ocx-native--gpt-5.6-sol[1m]", undefined)).toBe("gpt-5.6-sol"); expect(resolveInboundModel("claude-ocx-gemini--gemini-3-pro[1M]", undefined)).toBe("gemini/gemini-3-pro"); + expect(resolveInboundModel("ocx-claude-native--gpt-5.6-sol[1m]", undefined)).toBe("gpt-5.6-sol"); + expect(resolveInboundModel("claude-ocx2-openrouter--anthropic~sclaude-opus-4-8", undefined)).toBe( + "openrouter/anthropic/claude-opus-4-8", + ); }); test("anthropic canonical ids pass through unchanged (native passthrough preserved)", () => { @@ -138,10 +143,10 @@ describe("claudeCodeAlias — readable-or-hash shared helper (devlog 050 / audit test("slash-containing model ids stay readable under v2 (no desktop-3p hash)", () => { expect(claudeCodeAlias("openrouter", "anthropic/claude-opus-4-8")).toBe( - "claude-ocx2-openrouter--anthropic~sclaude-opus-4-8", + "ocx-claude2-openrouter--anthropic~sclaude-opus-4-8", ); - expect(claudeCodeAlias("mock", "path/model")).toBe("claude-ocx2-mock--path~smodel"); - expect(resolveInboundModel("claude-ocx2-openrouter--anthropic~sclaude-opus-4-8", undefined)).toBe( + expect(claudeCodeAlias("mock", "path/model")).toBe("ocx-claude2-mock--path~smodel"); + expect(resolveInboundModel("ocx-claude2-openrouter--anthropic~sclaude-opus-4-8", undefined)).toBe( "openrouter/anthropic/claude-opus-4-8", ); }); @@ -157,6 +162,6 @@ describe("claudeCodeAlias — readable-or-hash shared helper (devlog 050 / audit ]) { expect(id).toMatch(/^claude-opus-4-8-[a-z][0-9a-z]{2}$/); } - expect(claudeCodeAlias("mock", "has~tilde")).toBe("claude-ocx2-mock--has~ttilde"); + expect(claudeCodeAlias("mock", "has~tilde")).toBe("ocx-claude2-mock--has~ttilde"); }); }); diff --git a/tests/claude-integration/claude-cli.test.ts b/tests/claude-integration/claude-cli.test.ts index 6e73cc8ed0f..3ae03ab10f0 100644 --- a/tests/claude-integration/claude-cli.test.ts +++ b/tests/claude-integration/claude-cli.test.ts @@ -8,6 +8,7 @@ import { ensureProxyForClaude, fetchClaudeCodeState, isProxyOnlyModelId, + readConnectedClaudeContextWindows, nativeModelOverride, readPickerDefaultModel, rootSkipPermissionsNotice, @@ -145,6 +146,8 @@ describe("ocx claude native fallback", () => { test("keeps unrelated slash model ids and recognizes configured provider routes", () => { expect(isProxyOnlyModelId("mock/model", ["mock"])).toBe(true); expect(isProxyOnlyModelId("claude-ocx2-abcd")).toBe(true); + expect(isProxyOnlyModelId("ocx-claude-mock--model")).toBe(true); + expect(isProxyOnlyModelId("ocx-claude2-openrouter--a~sb[1m]")).toBe(true); expect(isProxyOnlyModelId("arn:aws:bedrock:region:acct:inference-profile/us.anthropic.model", ["mock"])).toBe(false); expect(isProxyOnlyModelId("claude-opus-5")).toBe(false); }); @@ -157,6 +160,31 @@ describe("ocx claude native fallback", () => { .toEqual({}); }); + test("a connected client's window map keeps legacy claude-ocx selectors next to the current ones", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-claude-connected-catalog-")); + try { + const path = join(dir, "catalog.json"); + writeFileSync(path, JSON.stringify({ models: [ + { slug: "cursor/gpt-5.6-luna", context_window: 1_000_000 }, + { slug: "gpt-5.6-sol", context_window: 272_000 }, + ] })); + const windows = readConnectedClaudeContextWindows(path); + expect(windows["ocx-claude-cursor--gpt-5.6-luna"]).toBe(1_000_000); + expect(windows["claude-ocx-cursor--gpt-5.6-luna"]).toBe(1_000_000); + expect(windows["ocx-claude-native--gpt-5.6-sol"]).toBe(272_000); + expect(windows["claude-ocx-native--gpt-5.6-sol"]).toBe(272_000); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a saved current ocx-claude selector also falls back to the configured native model", () => { + expect(nativeModelOverride("ocx-claude-mock--model", "opus", [], ["mock"])) + .toMatchObject({ flag: ["--model", "opus"] }); + expect(nativeModelOverride("ocx-claude2-openrouter--a~sb", "opus", [], ["mock"])) + .toMatchObject({ flag: ["--model", "opus"] }); + }); + test("preserves the root opt-in on native fallback", () => { const env = buildNativeClaudeEnv(cfg(), {}, { allowRootSkipPermissions: true }); expect(env.IS_SANDBOX).toBe("1"); @@ -272,7 +300,8 @@ describe("ocx claude env assembly", () => { // OAuth — the launcher must leave it unset on an open loopback proxy. expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); expect(env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY).toBe("1"); - expect(env.ANTHROPIC_MODEL).toBe("claude-ocx-gemini--gemini-3-pro"); + // A legacy configured slot leaves in the current spelling (same route, real window). + expect(env.ANTHROPIC_MODEL).toBe("ocx-claude-gemini--gemini-3-pro"); expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("gemini/gemini-3-flash"); expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe("gemini/gemini-3-flash"); // Never both token vars (Claude Code auth-conflict warning, 003 E1). @@ -363,16 +392,16 @@ describe("ocx claude env assembly", () => { expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe("829800"); }); - test("opt-in levers: alwaysEnableEffort=1, maxContextTokens injects the official pair", () => { + test("opt-in levers: maxContextTokens sets the window without disabling compact", () => { const env = buildClaudeEnv(cfg({ claudeCode: { alwaysEnableEffort: true, maxContextTokens: 1_000_000 }, }), 10100, {}); expect(env.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT).toBe("1"); expect(env.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe("1000000"); - // MAX_CONTEXT_TOKENS alone is ignored for recognized claude-shaped ids; the - // official pair requires DISABLE_COMPACT (exact name, no CLAUDE_CODE_ prefix). - expect(env.DISABLE_COMPACT).toBe("1"); - // Legacy override wins rule-1 inside the CLI -> auto-context stays inert. + // Current ocx-claude ids do not start with claude-, so Claude Code honors + // the window without DISABLE_COMPACT. Do not inject it. + expect(env.DISABLE_COMPACT).toBeUndefined(); + // maxContextTokens still disables the auto-compact window env. expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined(); }); @@ -389,6 +418,15 @@ describe("ocx claude env assembly", () => { expect(env.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT).toBe("0"); }); + test("maxContextTokens outside the compact window range never produces a compact lever", () => { + for (const value of [50_000, 2_000_000]) { + const env = buildClaudeEnv(cfg({ claudeCode: { maxContextTokens: value } }), 10100, {}); + expect(env.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe(String(value)); + expect(env.DISABLE_COMPACT).toBeUndefined(); + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined(); + } + }); + test("invalid maxContextTokens values inject nothing", () => { for (const bad of [0, -5, Number.NaN, Number.POSITIVE_INFINITY]) { const env = buildClaudeEnv(cfg({ claudeCode: { maxContextTokens: bad } }), 10100, {}); diff --git a/tests/claude-integration/claude-context-windows.test.ts b/tests/claude-integration/claude-context-windows.test.ts index cc5536303ec..971b46a4e23 100644 --- a/tests/claude-integration/claude-context-windows.test.ts +++ b/tests/claude-integration/claude-context-windows.test.ts @@ -15,11 +15,19 @@ describe("claude context-window map (devlog 260712 B2)", () => { const map = buildClaudeContextWindows([], routed); expect(map["cursor/gpt-5.6-luna"]).toBe(1_000_000); expect(map[desktop3pAlias("cursor", "gpt-5.6-luna")]).toBe(1_000_000); + expect(map["ocx-claude-cursor--gpt-5.6-luna"]).toBe(1_000_000); + // A selector saved under the legacy spelling keeps its window until it is re-picked. expect(map["claude-ocx-cursor--gpt-5.6-luna"]).toBe(1_000_000); expect(map["mock/small-model"]).toBe(128_000); expect(map["mock/no-window"]).toBeUndefined(); }); + test("a saved escaped legacy selector keeps its window next to the current one", () => { + const map = buildClaudeContextWindows([], [{ provider: "openrouter", id: "anthropic/x-model", contextWindow: 400_000 }]); + expect(map["ocx-claude2-openrouter--anthropic~sx-model"]).toBe(400_000); + expect(map["claude-ocx2-openrouter--anthropic~sx-model"]).toBe(400_000); + }); + test("registers native slugs (bare + desktop alias + legacy alias)", () => { const map = buildClaudeContextWindows(["gpt-5.6-sol", "gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4"], []); // Surviving native overrides use their own 272k windows. @@ -27,6 +35,7 @@ describe("claude context-window map (devlog 260712 B2)", () => { // slug passed here does not register. expect(map["gpt-5.6-sol"]).toBe(272_000); expect(map[desktop3pAlias("native", "gpt-5.6-sol")]).toBe(272_000); + expect(map["ocx-claude-native--gpt-5.6-sol"]).toBe(272_000); expect(map["claude-ocx-native--gpt-5.6-sol"]).toBe(272_000); expect(map["gpt-5.5"]).toBe(272_000); expect(map["gpt-5.3-codex-spark"]).toBeUndefined(); @@ -179,7 +188,10 @@ describe("auto-context (devlog 260712 020 + audit 021)", () => { // Default 272k sits under the 829,800 compact window, so the marker stays off. expect(env.ANTHROPIC_MODEL).toBe("gpt-5.6-sol"); const readable = effectiveModelEnv({ model: "claude-ocx-native--gpt-5.6-sol" }, windows); - expect(readable.ANTHROPIC_MODEL).toBe("claude-ocx-native--gpt-5.6-sol"); + // A legacy slot is emitted in the current spelling so Claude Code applies its window. + expect(readable.ANTHROPIC_MODEL).toBe("ocx-claude-native--gpt-5.6-sol"); + const escaped = effectiveModelEnv({ tierModels: { opus: "claude-ocx2-openrouter--a~sb[1m]" } }, windows); + expect(escaped.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("ocx-claude2-openrouter--a~sb[1m]"); // Opting into the measured 922k ceiling clears the compact window and marks [1m]. const opted = buildClaudeContextWindows(["gpt-5.6-sol"], [], 922_000); expect(effectiveModelEnv({ model: "gpt-5.6-sol" }, opted).ANTHROPIC_MODEL).toBe("gpt-5.6-sol[1m]"); diff --git a/tests/claude-integration/claude-desktop-discovery.test.ts b/tests/claude-integration/claude-desktop-discovery.test.ts index e9820cdbbe4..655146f5347 100644 --- a/tests/claude-integration/claude-desktop-discovery.test.ts +++ b/tests/claude-integration/claude-desktop-discovery.test.ts @@ -191,7 +191,7 @@ describe("Desktop snapshot through authenticated model discovery", () => { expect(resolveDesktop3pAlias("claude-opus-4-8-20260304")).toBe("test/model-155"); const cli = await request("?flavor=anthropic&ids=cli"); expect(cli.status).toBe(200); - expect((await cli.json() as { data: Array<{ id: string }> }).data.some(model => model.id.startsWith("claude-ocx-test--"))).toBe(true); + expect((await cli.json() as { data: Array<{ id: string }> }).data.some(model => model.id.startsWith("ocx-claude-test--"))).toBe(true); const openai = await request(""); expect(openai.status).toBe(200); const openaiBody = await openai.json() as { object: string; data: unknown[]; version?: number }; diff --git a/tests/claude-integration/claude-gateway-cache.test.ts b/tests/claude-integration/claude-gateway-cache.test.ts index f690cda6199..9f8ef4ffdba 100644 --- a/tests/claude-integration/claude-gateway-cache.test.ts +++ b/tests/claude-integration/claude-gateway-cache.test.ts @@ -195,3 +195,30 @@ describe("Claude Code gateway-model cache pre-write (devlog 260712 030)", () => } }); }); + +describe("gateway-model cache carries the picker description", () => { + test("writer keeps description so the picker stops reading \"From gateway\"", () => { + const dir = tempDir(); + const path = writeGatewayModelCache("http://127.0.0.1:10100", [ + { id: "claude-ocx-xai--grok-4.7", display_name: "grok-4.7 (xai)", description: "Routed by OpenCodex to xai/grok-4.7" }, + { id: "claude-ocx-native--gpt-5.5", display_name: "gpt-5.5 (native)" }, + ], dir); + expect(JSON.parse(readFileSync(path!, "utf8")).models).toEqual([ + { id: "claude-ocx-xai--grok-4.7", display_name: "grok-4.7 (xai)", description: "Routed by OpenCodex to xai/grok-4.7" }, + { id: "claude-ocx-native--gpt-5.5", display_name: "gpt-5.5 (native)" }, + ]); + }); + + test("proxy refresh copies description from /v1/models and ignores non-strings", async () => { + const dir = tempDir(); + const fetchImpl = (async () => new Response(JSON.stringify({ data: [ + { id: "claude-ocx-xai--grok-4.7", display_name: "grok-4.7 (xai)", description: "Routed by OpenCodex to xai/grok-4.7" }, + { id: "claude-ocx-p--odd", display_name: "odd (p)", description: 42 }, + ] }), { headers: { "content-type": "application/json" } })) as unknown as typeof fetch; + const path = await refreshGatewayModelCacheFromProxy(10100, { timeoutMs: 1000, configDir: dir, env: {}, fetchImpl }); + expect(JSON.parse(readFileSync(path!, "utf8")).models).toEqual([ + { id: "claude-ocx-xai--grok-4.7", display_name: "grok-4.7 (xai)", description: "Routed by OpenCodex to xai/grok-4.7" }, + { id: "claude-ocx-p--odd", display_name: "odd (p)" }, + ]); + }); +}); diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index 308f06b0c80..2390d7f82d9 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -681,6 +681,34 @@ describe("bundled-skill elision for routed models (devlog 260712 060)", () => { const texts = userTexts(requestWithSkillTextBlock("claude-api", 20_000, undefined, oversizedDir)); expect(texts.some(t => t.startsWith(`Base directory for this skill: ${oversizedDir}`))).toBe(true); }); + + for (const [prefix, separator] of [["/", "/"], ["C:\\", "\\"]] as const) { + for (const pathLength of [4_096, 4_097]) { + test(`text-block carrier: ${prefix} marker path at ${pathLength} characters`, () => { + const suffix = `${separator}claude-api${separator}`; + const dir = prefix + "a".repeat(pathLength - prefix.length - suffix.length) + suffix; + const texts = userTexts(requestWithSkillTextBlock("claude-api", 20_000, undefined, dir)); + const bundle = `Base directory for this skill: ${dir}\n\n` + "DOCS ".repeat(4_000); + expect(dir.length).toBe(pathLength); + if (pathLength === 4_096) { + expect(texts.some(text => text.includes("'claude-api'") && text.includes("elided"))).toBe(true); + expect(texts.every(text => text.length < 10_000)).toBe(true); + } else { + expect(texts).toContain(bundle); + } + }); + } + } + + test("text-block carrier: an oversized first line without a newline stays byte-for-byte intact", () => { + const text = "Base directory for this skill: /" + "a/".repeat(10_000) + "claude-api"; + const body = anthropicToResponsesTranslation({ + model: "gemini/gemini-3-pro", + max_tokens: 100, + messages: [{ role: "user", content: [{ type: "text", text }] }], + }).body; + expect(userTexts(body)).toContain(text); + }); }); describe("ocx-route directive (devlog 072)", () => { diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index 9f5fd1b590b..061203b520e 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -82,7 +82,7 @@ test("GET /api/claude-code returns defaults + available + aliases", async () => expect(d.modelMap).toEqual({}); expect(d.available).toContain("mock/test-model"); // Aliases preview uses the readable CLI-surface family (devlog 050 / audit 051 #2). - expect(d.aliases.some((a: { id: string }) => a.id === "claude-ocx-mock--test-model")).toBe(true); + expect(d.aliases.some((a: { id: string }) => a.id === "ocx-claude-mock--test-model")).toBe(true); expect(typeof d.port).toBe("number"); } finally { await server.stop(true); @@ -385,6 +385,50 @@ test("authMode-only PUT triggers system-env reconciliation (audit R2 #1)", async } }); +// Model-slot and lever fields feed injectSystemEnv, so changing one must reconcile launchd too; +// before, only systemEnv/authMode did, and a cleared smallFastModel stayed injected until restart. +test.each([ + ["smallFastModel", ""], + ["model", ""], + ["tierModels", { opus: "mock/test-model" }], + ["maxContextTokens", 1_000_000], + ["alwaysEnableEffort", true], + ["autoContext", false], + ["autoCompactWindow", 400_000], +] as const)("%s-only PUT triggers system-env reconciliation", async (field, value) => { + const applySpy = spyOn(systemEnv, "applySystemEnvToggle").mockResolvedValue({ reverted: false, reason: "test" }); + const server = startServer(0); + try { + const r = await fetch(new URL("/api/claude-code", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [field]: value }), + }); + expect(r.status).toBe(200); + expect(applySpy).toHaveBeenCalled(); + } finally { + applySpy.mockRestore(); + await server.stop(true); + } +}); + +test("a PUT that touches no system-env input does not reconcile launchd", async () => { + const applySpy = spyOn(systemEnv, "applySystemEnvToggle").mockResolvedValue({ reverted: false, reason: "test" }); + const server = startServer(0); + try { + const r = await fetch(new URL("/api/claude-code", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blockedSkills: null }), + }); + expect(r.status).toBe(200); + expect(applySpy).not.toHaveBeenCalled(); + } finally { + applySpy.mockRestore(); + await server.stop(true); + } +}); + test("Claude sidecar overrides round-trip, partially update, clear, and reject unknown backends", async () => { const server = startServer(0); const put = (body: unknown) => fetch(new URL("/api/claude-code", server.url), { diff --git a/tests/claude-integration/claude-messages-endpoint.test.ts b/tests/claude-integration/claude-messages-endpoint.test.ts index c39530d8709..54809b653a0 100644 --- a/tests/claude-integration/claude-messages-endpoint.test.ts +++ b/tests/claude-integration/claude-messages-endpoint.test.ts @@ -1675,7 +1675,7 @@ test("generated agent effort directive restores exact xhigh and max after Claude max_tokens: 32000, stream: true, system: [ - { type: "text", text: "" }, + { type: "text", text: "" }, { type: "text", text: `` }, ], thinking: { type: "enabled", budget_tokens: 31999 }, @@ -1735,7 +1735,7 @@ test("generated agent effort directive preserves routed Anthropic structured out max_tokens: 32000, stream: true, system: [ - { type: "text", text: "" }, + { type: "text", text: "" }, { type: "text", text: "" }, ], thinking: { type: "enabled", budget_tokens: 31999 }, diff --git a/tests/claude-integration/claude-model-info.test.ts b/tests/claude-integration/claude-model-info.test.ts index 7794a730299..70dbd71f0ac 100644 --- a/tests/claude-integration/claude-model-info.test.ts +++ b/tests/claude-integration/claude-model-info.test.ts @@ -79,7 +79,7 @@ describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => expect(infos.map(info => info.id)).toEqual([ "claude-fable-5-1", - "claude-ocx-native--claude-fable-5-1[1m]", + "ocx-claude-native--claude-fable-5-1[1m]", ]); expect(infos[1]!.display_name).toBe("claude-fable-5-1 (anthropic) · 1M"); expect(infos[1]!.max_input_tokens).toBe(1_000_000); @@ -191,7 +191,7 @@ describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => expect(variants[0]!.display_name.includes("claude-big-5")).toBe(true); }); - test("readable id style serves claude-ocx ids with hash fallback + readable [1m] variants (devlog 050)", () => { + test("readable id style serves ocx-claude ids with hash fallback + readable [1m] variants (devlog 050)", () => { const auto = { enabled: true, compactWindow: 350_000 }; const infos = buildAnthropicModelInfos(["gpt-5.5"], [ { provider: "cursor", id: "gpt-5.6-luna", contextWindow: 1_000_000 }, @@ -199,16 +199,16 @@ describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => { provider: "weird--provider", id: "m1", contextWindow: 128_000 }, // unrepresentable -> hash fallback ], auto, "readable"); const ids = infos.map(i => i.id); - expect(ids).toContain("claude-ocx-native--gpt-5.5"); + expect(ids).toContain("ocx-claude-native--gpt-5.5"); // 272k native: NO [1m] variant under the authoritative-window contract. - expect(ids).not.toContain("claude-ocx-native--gpt-5.5[1m]"); - expect(ids).toContain("claude-ocx-cursor--gpt-5.6-luna"); - expect(ids).toContain("claude-ocx-cursor--gpt-5.6-luna[1m]"); + expect(ids).not.toContain("ocx-claude-native--gpt-5.5[1m]"); + expect(ids).toContain("ocx-claude-cursor--gpt-5.6-luna"); + expect(ids).toContain("ocx-claude-cursor--gpt-5.6-luna[1m]"); expect(ids).toContain("claude-opus-4-8"); // anthropic canonical passthrough expect(ids.some(id => /^claude-opus-4-8-[a-z][0-9a-z]{2}$/.test(id))).toBe(true); // fallback row survives // Default style stays hashed (desktop contract untouched). const hashed = buildAnthropicModelInfos(["gpt-5.6-sol"], [], auto); - expect(hashed.map(i => i.id).some(id => id.startsWith("claude-ocx-"))).toBe(false); + expect(hashed.map(i => i.id).some(id => id.startsWith("ocx-claude-"))).toBe(false); }); }); @@ -237,3 +237,33 @@ describe("saved picker order changes groups after identity selection", () => { expect(result.map(row => [row.id, row.display_name])).toEqual([["collision", "a (p)"]]); }); }); + +describe("Claude Code picker description (replaces the generic \"From gateway\" line)", () => { + test("readable rows describe the route OpenCodex serves them through", () => { + const infos = buildAnthropicModelInfos(["gpt-5.5"], [ + { provider: "xai", id: "grok-4.7", contextWindow: 500_000 }, + ], undefined, "readable"); + const native = infos.find(i => i.display_name === "gpt-5.5 (native)"); + const routed = infos.find(i => i.display_name === "grok-4.7 (xai)"); + expect(native?.description).toBe("Routed by OpenCodex to native gpt-5.5"); + expect(routed?.description).toBe("Routed by OpenCodex to xai/grok-4.7"); + }); + + // A 1M row is the same route with a larger window; a Fast row selects a different tier or + // variant, so its description says so the way its display name does. + test("1M siblings keep the base description and Fast siblings name the Fast tier", () => { + const infos = buildAnthropicModelInfos([], [ + { provider: "p", id: "big", contextWindow: 1_000_000 }, + ], undefined, "readable", undefined, undefined, false, () => true); + expect(infos.map(i => [i.display_name, i.description])).toEqual([ + ["big (p)", "Routed by OpenCodex to p/big"], + ["big (p) · 1M", "Routed by OpenCodex to p/big"], + ["big (p) · Fast", "Routed by OpenCodex to p/big · Fast"], + ]); + }); + + test("Desktop 3P rows stay unchanged (no description field)", () => { + const infos = buildAnthropicModelInfos(["gpt-5.5"], [{ provider: "xai", id: "grok-4.7" }], undefined, "desktop3p"); + for (const info of infos) expect("description" in info).toBe(false); + }); +}); diff --git a/tests/claude-integration/claude-models-discovery.test.ts b/tests/claude-integration/claude-models-discovery.test.ts index 4134277120d..ec6c6e147bd 100644 --- a/tests/claude-integration/claude-models-discovery.test.ts +++ b/tests/claude-integration/claude-models-discovery.test.ts @@ -98,7 +98,7 @@ test("anthropic-version header flips /v1/models to the discovery contract", asyn expect(ids).toContain(mockAlias); // Every entry must satisfy the picker prefix rule (003 G3). for (const entry of json.data) { - expect(entry.id.startsWith("claude") || entry.id.startsWith("anthropic")).toBe(true); + expect(entry.id.includes("claude") || entry.id.includes("anthropic")).toBe(true); expect(typeof entry.display_name).toBe("string"); // Full ModelInfo contract (devlog 130 B4b): capabilities ride discovery. expect(entry.type).toBe("model"); @@ -141,7 +141,7 @@ test("per-surface id style: ?ids= wins, claude-code UA gets readable, unknown UA saveConfig(configWithStaticModels()); const server = await startDiscoveryServer(); try { - const readable = "claude-ocx-mock--test-model"; + const readable = "ocx-claude-mock--test-model"; // 1) explicit ?ids=cli -> readable let json = await fetch(new URL("/v1/models?flavor=anthropic&ids=cli", server.url)).then(r => r.json()) as { data: { id: string }[] }; expect(json.data.some(m => m.id === readable)).toBe(true); diff --git a/tests/claude-integration/claude-native-passthrough.test.ts b/tests/claude-integration/claude-native-passthrough.test.ts index 44cf9333f46..fe7b1ccb20f 100644 --- a/tests/claude-integration/claude-native-passthrough.test.ts +++ b/tests/claude-integration/claude-native-passthrough.test.ts @@ -208,12 +208,15 @@ test("count_tokens passes through with native credentials", async () => { } }); -test("Fable 1M picker alias preserves native passthrough on both Messages endpoints", async () => { +// The legacy claude-ocx spelling is what a picker saved before the ocx-claude aliases. +test.each([ + "ocx-claude-native--claude-fable-5-1", + "claude-ocx-native--claude-fable-5-1", +])("Fable 1M picker alias %s preserves native passthrough on both Messages endpoints", async pickerModel => { const captured: Captured[] = []; const upstream = mockAnthropicUpstream(captured); saveConfig(cfg(upstream.url.toString().replace(/\/$/, ""))); const server = startServer(0); - const pickerModel = "claude-ocx-native--claude-fable-5-1"; try { const messagesWithoutMarker = await fetch(new URL("/v1/messages", server.url), { method: "POST", @@ -409,7 +412,7 @@ test("alias/mapped models and non-anthropic credentials do NOT pass through", as const alias = await fetch(new URL("/v1/messages", server.url), { method: "POST", headers: OAUTH_HEADERS, - body: JSON.stringify({ model: "claude-ocx-mock--test-model", max_tokens: 10, messages: [{ role: "user", content: "x" }] }), + body: JSON.stringify({ model: "ocx-claude-mock--test-model", max_tokens: 10, messages: [{ role: "user", content: "x" }] }), }); expect(alias.status).not.toBe(200); @@ -640,3 +643,148 @@ test.each([false, true])("catalog-published native dates retain identity while u buildDesktop3pRegistry([], []); } }, { timeout: SERVER_BUDGET_MS }); + +// --- tool_use.id wire-contract sanitize on the native branch --- +// The Anthropic adapter normalizes tool call ids (#1780), but this branch bypasses that +// adapter, so third-party ids like Devin's `Bash:0#` would reach api.anthropic.com +// verbatim and 400 on `^[a-zA-Z0-9_-]+$`. The passthrough sanitizes before serialize. + +test("non-conforming tool_use ids are rewritten on the wire, pairing preserved, conforming ids untouched", async () => { + const captured: Captured[] = []; + const upstream = mockAnthropicUpstream(captured); + saveConfig(cfg(upstream.url.toString().replace(/\/$/, ""))); + const server = startServer(0); + try { + const pollutedA = "Bash:0#abcdef1234567890"; + const pollutedB = "Read:7#fedcba0987654321"; + const conforming = "toolu_01KeepMeVerbatim"; + const body = { + model: "claude-fable-5", + max_tokens: 1000, + messages: [ + { role: "user", content: "run them" }, + { + role: "assistant", + content: [ + { type: "tool_use", id: pollutedA, name: "Bash", input: { cmd: "a" } }, + { type: "server_tool_use", id: pollutedB, name: "web_search", input: { q: "b" } }, + { type: "tool_use", id: conforming, name: "Read", input: {} }, + ], + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: pollutedA, content: "ok-a" }, + { type: "web_search_tool_result", tool_use_id: pollutedB, content: [] }, + { type: "tool_result", tool_use_id: conforming, content: "ok-c" }, + ], + }, + { role: "user", content: "go on" }, + ], + }; + const res = await postNative(String(server.url), "/v1/messages", body); + expect(res.status).toBe(200); + await res.text(); + + const msgs = captured[0].body.messages as Array<{ content: Array> }>; + const callBlocks = msgs[1].content; + const resultBlocks = msgs[2].content; + const wireA = callBlocks[0].id as string; + const wireB = callBlocks[1].id as string; + for (const wire of [wireA, wireB]) { + expect(wire).toMatch(/^[a-zA-Z0-9_-]+$/); + expect(wire.length).toBeLessThanOrEqual(64); + } + expect(wireA).not.toBe(pollutedA); + expect(wireB).not.toBe(pollutedB); + expect(wireA).not.toBe(wireB); + expect(resultBlocks[0].tool_use_id).toBe(wireA); + expect(resultBlocks[1].tool_use_id).toBe(wireB); + expect(callBlocks[2].id).toBe(conforming); + expect(resultBlocks[2].tool_use_id).toBe(conforming); + + // count_tokens shares the branch; the allocator is deterministic per raw id. + const res2 = await postNative(String(server.url), "/v1/messages/count_tokens", body); + expect(res2.status).toBe(200); + const msgs2 = captured[1].body.messages as Array<{ content: Array> }>; + expect(msgs2[1].content[0].id).toBe(wireA); + expect(msgs2[2].content[0].tool_use_id).toBe(wireA); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +function toolRoundTrip(callId: string, extraCallId?: string) { + const calls: Array> = [{ type: "tool_use", id: callId, name: "Bash", input: { cmd: "a" } }]; + const results: Array> = [{ type: "tool_result", tool_use_id: callId, content: "ok" }]; + if (extraCallId !== undefined) { + calls.push({ type: "tool_use", id: extraCallId, name: "Read", input: {} }); + results.push({ type: "tool_result", tool_use_id: extraCallId, content: "ok-2" }); + } + return { + model: "claude-fable-5", + max_tokens: 1000, + messages: [ + { role: "user", content: "run" }, + { role: "assistant", content: calls }, + { role: "user", content: results }, + ], + }; +} + +test("an empty tool_use id fails locally with 400 and never reaches the upstream", async () => { + const captured: Captured[] = []; + const upstream = mockAnthropicUpstream(captured); + saveConfig(cfg(upstream.url.toString().replace(/\/$/, ""))); + const server = startServer(0); + try { + const res = await postNative(String(server.url), "/v1/messages", toolRoundTrip("")); + expect(res.status).toBe(400); + const payload = await res.json() as { type?: string; error?: { type?: string } }; + expect(payload.error?.type).toBe("invalid_request_error"); + expect(captured).toHaveLength(0); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("an overlength id is rewritten within 64 characters and a colliding valid id stays byte-identical", async () => { + const captured: Captured[] = []; + const upstream = mockAnthropicUpstream(captured); + saveConfig(cfg(upstream.url.toString().replace(/\/$/, ""))); + const server = startServer(0); + try { + const overlength = "toolu_" + "x".repeat(80); + const polluted = "call:a"; + const res = await postNative(String(server.url), "/v1/messages", toolRoundTrip(overlength)); + expect(res.status).toBe(200); + await res.text(); + const msgs = captured[0].body.messages as Array<{ content: Array> }>; + const wire = msgs[1].content[0].id as string; + expect(wire).toMatch(/^[a-zA-Z0-9_-]+$/); + expect(wire.length).toBeLessThanOrEqual(64); + expect(msgs[2].content[0].tool_use_id).toBe(wire); + + // A valid id that equals the polluted id's rewritten form keeps its bytes; the + // rewrite moves aside so the two calls never share a wire id. + const res2 = await postNative(String(server.url), "/v1/messages", toolRoundTrip(polluted, "placeholder")); + await res2.text(); + const rewritten = (captured[1].body.messages as Array<{ content: Array> }>)[1].content[0].id as string; + const res3 = await postNative(String(server.url), "/v1/messages", toolRoundTrip(polluted, rewritten)); + expect(res3.status).toBe(200); + await res3.text(); + const msgs3 = captured[2].body.messages as Array<{ content: Array> }>; + expect(msgs3[1].content[1].id).toBe(rewritten); + expect(msgs3[2].content[1].tool_use_id).toBe(rewritten); + const moved = msgs3[1].content[0].id as string; + expect(moved).not.toBe(rewritten); + expect(moved).toMatch(/^[a-zA-Z0-9_-]+$/); + expect(moved.length).toBeLessThanOrEqual(64); + expect(msgs3[2].content[0].tool_use_id).toBe(moved); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); diff --git a/tests/providers/cursor/cursor-fast-listing.test.ts b/tests/providers/cursor/cursor-fast-listing.test.ts index a53683a67f9..077c98f2c6d 100644 --- a/tests/providers/cursor/cursor-fast-listing.test.ts +++ b/tests/providers/cursor/cursor-fast-listing.test.ts @@ -49,22 +49,22 @@ describe("global fast switch lists -fast identities outside Codex", () => { test("Claude Code discovery lists the umbrella id with the switch off", () => { expect(listIds([cursorModel("claude-opus-5")], false)) - .toContain("claude-ocx-cursor--claude-opus-5"); + .toContain("ocx-claude-cursor--claude-opus-5"); expect(listIds([cursorModel("claude-opus-5")], undefined)) - .toContain("claude-ocx-cursor--claude-opus-5"); + .toContain("ocx-claude-cursor--claude-opus-5"); }); test("Claude Code discovery lists the fast identity with the switch on", () => { expect(listIds([cursorModel("claude-opus-5")], true)) - .toContain("claude-ocx-cursor--claude-opus-5-thinking-fast"); + .toContain("ocx-claude-cursor--claude-opus-5-thinking-fast"); expect(listIds([cursorModel("grok-4.6", 500_000)], true)) - .toContain("claude-ocx-cursor--grok-4.6-fast"); + .toContain("ocx-claude-cursor--grok-4.6-fast"); expect(listIds([cursorModel("grok-4.7", 500_000)], true)) - .toContain("claude-ocx-cursor--grok-4.7-fast"); + .toContain("ocx-claude-cursor--grok-4.7-fast"); }); test("the switch leaves a base without a fast variant alone", () => { - expect(listIds([cursorModel("kimi-k3")], true)).toContain("claude-ocx-cursor--kimi-k3"); + expect(listIds([cursorModel("kimi-k3")], true)).toContain("ocx-claude-cursor--kimi-k3"); }); test("Desktop 3P hashed aliases are untouched by the switch", () => { diff --git a/tests/server/system-env.test.ts b/tests/server/system-env.test.ts index 6a3d61cb7aa..7b7bfb8ac1f 100644 --- a/tests/server/system-env.test.ts +++ b/tests/server/system-env.test.ts @@ -553,16 +553,16 @@ describe("systemEnv lever keys (devlog 136 B6)", () => { expect(await injectSystemEnv(4096, leverConfig)).toEqual({ injected: true }); const setCalls = launchctlCommands(); expect(setCalls).toContain("launchctl setenv CLAUDE_CODE_MAX_CONTEXT_TOKENS 1000000"); - expect(setCalls).toContain("launchctl setenv DISABLE_COMPACT 1"); + expect(setCalls.some(c => c.includes("DISABLE_COMPACT"))).toBe(false); expect(setCalls).toContain("launchctl setenv CLAUDE_CODE_ALWAYS_ENABLE_EFFORT 1"); const trackingWrite = writes.filter(w => w.path.includes("system-env-port")).at(-1); expect(JSON.parse(trackingWrite!.data).injectedKeys).toEqual(expect.arrayContaining([ - "CLAUDE_CODE_MAX_CONTEXT_TOKENS", "DISABLE_COMPACT", "CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", + "CLAUDE_CODE_MAX_CONTEXT_TOKENS", "CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", ])); // Shell env file: lever keys are CONDITIONAL exports so a shell-only user value wins. const shellWrite = writes.find(w => w.path.includes("claude-env.sh")); expect(shellWrite!.data).toContain(`[ -z "\${CLAUDE_CODE_MAX_CONTEXT_TOKENS+x}" ] && export CLAUDE_CODE_MAX_CONTEXT_TOKENS='1000000'`); - expect(shellWrite!.data).toContain(`[ -z "\${DISABLE_COMPACT+x}" ] && export DISABLE_COMPACT='1'`); + expect(shellWrite!.data).not.toContain("DISABLE_COMPACT"); expect(shellWrite!.data).toContain(`[ -z "\${CLAUDE_CODE_ALWAYS_ENABLE_EFFORT+x}" ] && export CLAUDE_CODE_ALWAYS_ENABLE_EFFORT='1'`); }); @@ -572,11 +572,59 @@ describe("systemEnv lever keys (devlog 136 B6)", () => { expect(await injectSystemEnv(4096, leverConfig)).toEqual({ injected: true }); const setCalls = launchctlCommands(); expect(setCalls).not.toContain("launchctl setenv CLAUDE_CODE_MAX_CONTEXT_TOKENS 1000000"); - expect(setCalls).toContain("launchctl setenv DISABLE_COMPACT 1"); + expect(setCalls.some(c => c.includes("DISABLE_COMPACT"))).toBe(false); const trackingWrite = writes.filter(w => w.path.includes("system-env-port")).at(-1); const keys = JSON.parse(trackingWrite!.data).injectedKeys as string[]; expect(keys).not.toContain("CLAUDE_CODE_MAX_CONTEXT_TOKENS"); - expect(keys).toContain("DISABLE_COMPACT"); + expect(keys).not.toContain("DISABLE_COMPACT"); + }); + + // An older release injected DISABLE_COMPACT=1 next to CLAUDE_CODE_MAX_CONTEXT_TOKENS and + // tracked it. A same-port restart keeps that record (this proxy already answers the stale + // probe), so without an explicit unset launchd would keep disabling compact after upgrade. + test("an upgrade unsets the DISABLE_COMPACT an older release injected and tracked", async () => { + const writes = capturedWrites(); + trackingFile = JSON.stringify({ + pid: 123, + port: 4096, + injectedAt: "2026-07-11T00:00:00.000Z", + injectedKeys: [ + "ANTHROPIC_BASE_URL", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", + "CLAUDE_CODE_MAX_CONTEXT_TOKENS", "DISABLE_COMPACT", + ], + }); + launchctlBaseUrl = "http://127.0.0.1:4096"; + launchctlEnvValues.DISABLE_COMPACT = "1"; + expect(await injectSystemEnv(4096, leverConfig)).toEqual({ injected: true }); + expect(launchctlCommands()).toContain("launchctl unsetenv DISABLE_COMPACT"); + const trackingWrite = writes.filter(w => w.path.includes("system-env-port")).at(-1); + expect(JSON.parse(trackingWrite!.data).injectedKeys).not.toContain("DISABLE_COMPACT"); + }); + + test("a tracked DISABLE_COMPACT the user changed by hand is released, not deleted", async () => { + const writes = capturedWrites(); + trackingFile = JSON.stringify({ + pid: 123, + port: 4096, + injectedAt: "2026-07-11T00:00:00.000Z", + injectedKeys: [ + "ANTHROPIC_BASE_URL", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", + "CLAUDE_CODE_MAX_CONTEXT_TOKENS", "DISABLE_COMPACT", + ], + }); + launchctlBaseUrl = "http://127.0.0.1:4096"; + launchctlEnvValues.DISABLE_COMPACT = "0"; + expect(await injectSystemEnv(4096, leverConfig)).toEqual({ injected: true }); + expect(launchctlCommands()).not.toContain("launchctl unsetenv DISABLE_COMPACT"); + const trackingWrite = writes.filter(w => w.path.includes("system-env-port")).at(-1); + expect(JSON.parse(trackingWrite!.data).injectedKeys).not.toContain("DISABLE_COMPACT"); + }); + + test("a DISABLE_COMPACT the user set in launchd is left alone", async () => { + capturedWrites(); + launchctlEnvValues.DISABLE_COMPACT = "1"; + expect(await injectSystemEnv(4096, leverConfig)).toEqual({ injected: true }); + expect(launchctlCommands()).not.toContain("launchctl unsetenv DISABLE_COMPACT"); }); test("levers disabled: no lever keys injected or exported", async () => { @@ -634,6 +682,68 @@ describe("systemEnv lever keys (devlog 136 B6)", () => { const shellWrite = writes.find(w => w.path.includes("claude-env.sh")); expect(shellWrite!.data).toContain('[ -z "${ANTHROPIC_DEFAULT_OPUS_MODEL+x}" ] && export ANTHROPIC_DEFAULT_OPUS_MODEL='); }); + + // A slot opencodex injected earlier is opencodex-owned (revertSystemEnv already unsets every + // tracked key regardless of its value). Re-injection must therefore refresh it and drop it once + // the config stops producing it; before this, the user-wins guard froze the old value in launchd + // until the proxy restarted. + function trackingWithLevers(keys: string[]): string { + return JSON.stringify({ + pid: 123, port: 4096, injectedAt: "2026-07-11T00:00:00.000Z", + injectedKeys: ["ANTHROPIC_BASE_URL", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", ...keys], + }); + } + + test("re-inject refreshes a tracked slot whose configured value changed", async () => { + const writes = capturedWrites(); + trackingFile = trackingWithLevers(["ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_SMALL_FAST_MODEL"]); + launchctlBaseUrl = "http://127.0.0.1:4096"; + launchctlEnvValues.ANTHROPIC_DEFAULT_HAIKU_MODEL = "mock/old-small"; + launchctlEnvValues.ANTHROPIC_SMALL_FAST_MODEL = "mock/old-small"; + const config = { ...baseConfig, claudeCode: { systemEnv: true, smallFastModel: "mock/new-small" } } satisfies OcxConfig; + expect(await injectSystemEnv(4096, config)).toEqual({ injected: true }); + const setCalls = launchctlCommands(); + expect(setCalls).toContain("launchctl setenv ANTHROPIC_DEFAULT_HAIKU_MODEL mock/new-small"); + expect(setCalls).toContain("launchctl setenv ANTHROPIC_SMALL_FAST_MODEL mock/new-small"); + const keys = JSON.parse(writes.filter(w => w.path.includes("system-env-port")).at(-1)!.data).injectedKeys as string[]; + expect(keys).toEqual(expect.arrayContaining(["ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_SMALL_FAST_MODEL"])); + }); + + test("re-inject unsets a tracked slot the config no longer produces", async () => { + const writes = capturedWrites(); + trackingFile = trackingWithLevers(["ANTHROPIC_DEFAULT_HAIKU_MODEL", "ANTHROPIC_SMALL_FAST_MODEL"]); + launchctlBaseUrl = "http://127.0.0.1:4096"; + launchctlEnvValues.ANTHROPIC_DEFAULT_HAIKU_MODEL = "mock/old-small"; + launchctlEnvValues.ANTHROPIC_SMALL_FAST_MODEL = "mock/old-small"; + expect(await injectSystemEnv(4096, baseConfig)).toEqual({ injected: true }); + const setCalls = launchctlCommands(); + expect(setCalls).toContain("launchctl unsetenv ANTHROPIC_DEFAULT_HAIKU_MODEL"); + expect(setCalls).toContain("launchctl unsetenv ANTHROPIC_SMALL_FAST_MODEL"); + const keys = JSON.parse(writes.filter(w => w.path.includes("system-env-port")).at(-1)!.data).injectedKeys as string[]; + expect(keys).not.toContain("ANTHROPIC_DEFAULT_HAIKU_MODEL"); + expect(keys).not.toContain("ANTHROPIC_SMALL_FAST_MODEL"); + expect(keys).toEqual(expect.arrayContaining(["ANTHROPIC_BASE_URL", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"])); + }); + + test("a tracked auto-compact value is refreshed, not read back as a user override", async () => { + capturedWrites(); + trackingFile = trackingWithLevers(["CLAUDE_CODE_AUTO_COMPACT_WINDOW"]); + launchctlBaseUrl = "http://127.0.0.1:4096"; + launchctlEnvValues.CLAUDE_CODE_AUTO_COMPACT_WINDOW = "500000"; + expect(await injectSystemEnv(4096, baseConfig)).toEqual({ injected: true }); + expect(launchctlCommands()).toContain("launchctl setenv CLAUDE_CODE_AUTO_COMPACT_WINDOW 829800"); + }); + + test("an untracked (user-owned) slot is neither overwritten nor unset on re-inject", async () => { + capturedWrites(); + trackingFile = trackingWithLevers([]); + launchctlBaseUrl = "http://127.0.0.1:4096"; + launchctlEnvValues.ANTHROPIC_DEFAULT_HAIKU_MODEL = "user/own-haiku"; + const config = { ...baseConfig, claudeCode: { systemEnv: true, smallFastModel: "mock/new-small" } } satisfies OcxConfig; + expect(await injectSystemEnv(4096, config)).toEqual({ injected: true }); + const haikuCalls = launchctlCommands().filter(c => c.includes("ANTHROPIC_DEFAULT_HAIKU_MODEL") && !c.includes("getenv")); + expect(haikuCalls).toEqual([]); + }); }); test("system-env preserves the shell seam without a back-import", () => { From aa2406b9659d800c43cc5c1610923b960ff43a0c Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 21:27:07 +0900 Subject: [PATCH 10/48] =?UTF-8?q?fix(codex):=20bundle=20lane=20D=20?= =?UTF-8?q?=E2=80=94=20Codex=20home=20and=20WSL=20runtime=20discovery,=20i?= =?UTF-8?q?ntegration=20status,=20quota=20locks,=20discovery=20snapshots?= =?UTF-8?q?=20(#5680)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): keep a fresh local Codex home before config.toml exists (#5441) On WSL an unset CODEX_HOME switched to a discovered Windows Desktop home whenever ~/.codex/config.toml was missing, even when the local ~/.codex directory already existed on a fresh install. Keep the local home when it is a directory; only an absent path or a non-directory lets discovery pick the Windows home, and an unexpected stat failure keeps the local home rather than switching. Structure and the Codex integration guide (all locales) now describe directory presence instead of config.toml presence. Carries #5441. Co-authored-by: Lee Sang Gyu <217872453+lee3Q@users.noreply.github.com> * fix(codex): treat an unchanged sync-cache as success (#5594) ocx sync-cache exited 1 when models_cache.json was already current, because an unchanged cache and a failed rewrite both surfaced as false. The cache invalidation now reports written / unchanged / missing_catalog / desired_disabled / failed; the CLI exits 0 for an unchanged cache, restarts Codex only after a real write, and names the skip in --json. On top of #5594: the human path no longer prints the integration-OFF explanation before the real outcome (an explicit sync-cache refreshes regardless of the toggle), the skip-count comment names all three benign skips, and the composed acceptance test covers the human output and derives the expected skip from whether an OFF sync left a catalog behind. Carries #5594. Co-authored-by: Gary Sassano <10464497+garysassano@users.noreply.github.com> * fix(codex): refresh persisted integration intent in status (#5588) GET /api/native-integrations derived the Codex switch from the server's startup config snapshot, so a completed Codex toggle did not show until the proxy restarted. The status read now takes per-client intent from persisted configuration. On top of #5588: the same fresh intent is used for the Grok and Claude Desktop rows, whose toggles also persist independently (every other field still comes from the snapshot); a Codex OFF toggle whose native restore did not complete keeps the row unsafe on later reads instead of deriving absent from intent; tests cover the stale-snapshot read, an off-then-on round trip, and a failed restore followed by a status read. Carries #5588. Co-authored-by: Gary Sassano <10464497+garysassano@users.noreply.github.com> * fix(codex): retire stale short-window main-account hard locks (#5620) The main-account hard lock kept an old 5h reading forever once an account moved to weekly or monthly windows: policy merging retained omitted blocking short usage, and that stale tuple outranked a fresh weekly reading. A single fresh WHAM response now replaces the short tuple when its primary window is explicitly at least 24h and the secondary and tertiary windows are explicit null or also long. The replacement proof is per observation and never persisted; the current window still blocks at 99%. On top of #5620: a non-null long auxiliary window only counts as proof when it carries a valid used_percent, since unknown usage must never release a block; regression covers a monthly primary with a long secondary or tertiary window that omits used_percent. The policy trusts one reported topology rather than repeated observations; that trade-off is documented in structure/providers/openai-tiers.md. Carries #5620. Co-authored-by: 정우철 <86232509+oocheol@users.noreply.github.com> * fix(catalog): bind model discovery's token and destination to one snapshot (#5647) The provider connection probe resolved a token and then rebuilt its URL from the live credential store, and a refreshing catalog gather captured its URL before resolving a refreshed token. A Copilot account switch, or a refresh that moves an account's API host, could therefore pair one account's bearer with another account's origin. Discovery now rebuilds the send from the same snapshot that supplied the token, keeps separate flights per stored origin, probes Devin at the snapshot's tenant address, and a key row never borrows a stored OAuth account's origin. On top of #5647: negative tests pin that a snapshot without an API host falls back only to static configuration validated against the vendor allowlist or the vendor default, never to the live store (Copilot account switch during refresh; Devin row with a non-allowlisted configured base), and structure/catalog.md states that rule. Carries #5647. Co-authored-by: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> * fix(codex): discover the WSL Desktop runtime under CODEX_HOME/bin/wsl (#5635) Windows Codex Desktop in WSL app-server mode ships its Linux Codex binary under the effective Codex home as bin/wsl//codex. An Ubuntu service whose PATH has no codex resolved no runtime, so the v2 transition failed with "Executable not found in $PATH". On Linux, runtime discovery now enumerates the direct hash-directory children of /bin/wsl newest first, after an explicit runtime, PATH and the ordinary install locations, and probes them through the existing isolated --version seam. The list is re-read on every resolve, so a Desktop update that replaces the hash directory is rediscovered instead of trusted from a remembered path, and CODEX_HOME joins the process memo key. Regressions: absent PATH, replaced hash directory, newest hash first, explicit pin wins, PATH wins, unreadable bin/wsl, and no enumeration on macOS. Closes #5635. * fix(catalog): restore a native row's multi-agent pin after a forced mode (#5636) Returning from forced v1 to default left newer native rows (gpt-6-astra, gpt-6-luna) pinned to v1 when the pristine catalog backup predated them: default mode preserves a live pin that the baseline does not mention, and after a forced pass nothing distinguished the forced stamp from a genuine pin. A forced v1/v2 pass now records the row's pre-override value once, as opencodex_multi_agent_version_origin (a string pin or null), and repeated forced passes never replace it. Default mode consumes the record: pristine baseline and native pins still win, routed-row normalization is unchanged, and only a native row the baseline predates is restored from the record. Rows written before the record existed keep the non-destructive read. Closes #5636. * fix(codex): bootstrap a missing config.toml in an existing Codex home (#5422) A fresh Codex install can have its home directory but no config.toml yet: Codex writes it lazily, and an authless Desktop user who never signs in to OpenAI may never get one. Injection treated that as "Codex config not found ... Is Codex installed?" and blocked third-party provider onboarding. When the resolved Codex home is a directory and config.toml is missing, an applying injection now creates an empty config.toml exclusively (an existing file is never overwritten) and continues; a validate-only preflight reasons about that empty file and writes nothing. A missing home directory is still refused, now with instructions to start Codex once or set CODEX_HOME, so a wrong home stays distinguishable from an uninitialized one. The client-connect preflight rollback scenario used a missing config.toml as its fault; it now uses a deterministic injection refusal (ambiguous managed sub-agent markers) instead. Closes #5422. * fix(clients): accept a relocated Aside root behind a symlinked ~/.aside (#5648) A user who moved ~/.aside (for example to an external volume) and left a symlink behind could not load Aside profiles: the reader refused the root because the path itself was a link, although Aside follows it. asideHomeDir now canonicalizes only that top-level alias, once, and only onto a directory. Every boundary below the canonical root is unchanged: u/, account directories and models.json still refuse links, and a ~/.aside link to a regular file is still refused. Regressions cover the relocated root, linked u/ and account directories and a linked catalog under it. Closes #5648. --------- Co-authored-by: Lee Sang Gyu <217872453+lee3Q@users.noreply.github.com> Co-authored-by: Gary Sassano <10464497+garysassano@users.noreply.github.com> Co-authored-by: 정우철 <86232509+oocheol@users.noreply.github.com> Co-authored-by: Fred Amartey <43480311+FredAmartey@users.noreply.github.com> --- .../docs/fr/guides/codex-integration.md | 2 +- .../content/docs/guides/codex-integration.md | 10 +- .../docs/ja/guides/codex-integration.md | 2 +- .../docs/ko/guides/codex-integration.md | 2 +- .../ko/reference/cli/providers-accounts.md | 7 + .../content/docs/reference/cli/lifecycle.md | 2 + .../docs/reference/cli/providers-accounts.md | 7 + .../docs/ru/guides/codex-integration.md | 2 +- .../docs/tr/guides/codex-integration.md | 2 +- .../docs/zh-cn/guides/codex-integration.md | 2 +- .../docs/zh-tw/guides/codex-integration.md | 2 +- scripts/test-layout/layout.json | 3 + src/cli/dispatch.ts | 43 ++-- src/clients/config-export.ts | 14 +- src/codex/catalog/gather-capture.ts | 31 ++- src/codex/catalog/parsing.ts | 40 +++- src/codex/catalog/provider-models.ts | 14 +- src/codex/catalog/retained-sync.ts | 24 ++- src/codex/catalog/sync.ts | 4 +- src/codex/home.ts | 21 +- src/codex/inject.ts | 65 +++++- src/codex/quota.ts | 56 +++++- src/codex/runtime.ts | 41 +++- .../management/native-integration-routes.ts | 47 ++++- src/server/management/provider-routes.ts | 33 +++- structure/catalog.md | 8 +- structure/codex-home.md | 7 +- structure/gui-and-management-api.md | 2 + structure/providers/openai-tiers.md | 19 +- structure/subagents.md | 7 + tests/clients/aside-profile-paths.test.ts | 50 ++++- tests/clients/client-connect.test.ts | 7 +- .../catalog-oauth-observation.test.ts | 36 +++- .../codex-app-server-processes.test.ts | 4 +- .../codex-composed-acceptance.test.ts | 23 ++- .../codex-gather-authority.test.ts | 143 ++++++++++++++ .../codex-integration/codex-home-wsl.test.ts | 76 ++++++- .../codex-inject-missing-config.test.ts | 98 ++++++++++ .../codex-models-cache-invalidate.test.ts | 19 +- .../codex-runtime-wsl-desktop.test.ts | 164 ++++++++++++++++ .../main-account-hard-lock-recovery.test.ts | 20 ++ .../main-quota-evidence-validation.test.ts | 140 ++++++++++++- .../main-quota-provenance.test.ts | 35 +++- .../multi-agent-origin.test.ts | 66 +++++++ .../native-codex-toggle.test.ts | 65 +++++- tests/config/settings-stream-mode.test.ts | 22 ++- tests/fixtures/test-layout-expected.json | 3 + .../github-copilot-wire-defaults.test.ts | 5 + .../provider-connection-test.test.ts | 185 +++++++++++++++++- .../service-wsl-home-ownership.test.ts | 6 +- 50 files changed, 1565 insertions(+), 121 deletions(-) create mode 100644 tests/codex-integration/codex-inject-missing-config.test.ts create mode 100644 tests/codex-integration/codex-runtime-wsl-desktop.test.ts create mode 100644 tests/codex-integration/multi-agent-origin.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 3b37c327f34..14fccb75d4b 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -153,7 +153,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -Sous WSL, si `CODEX_HOME` n'est pas défini et que `~/.codex/config.toml` n'existe pas côté Linux, opencodex +Sous WSL, si `CODEX_HOME` n'est pas défini et qu'aucun répertoire `~/.codex` n'existe côté Linux, opencodex recherche également un unique répertoire personnel de Codex Desktop pour Windows à l'emplacement `/mnt/c/Users/*/.codex/config.toml`. S'il trouve exactement un candidat, il utilise ce répertoire afin que le mode app-server sous WSL et Codex Desktop sous Windows partagent les mêmes fichiers de configuration et diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 2f7746af571..3c4a7ba0d84 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -8,7 +8,7 @@ opencodex makes Codex route through the proxy by editing two things Codex reads: idempotent and reversible. The **Integrations** overview has a Codex switch for this native integration. Its switch shows -the desired state from OpenCodex's configuration, while the badge reports whether Codex is +the latest saved desired state from OpenCodex's configuration, including immediately after a toggle, while the badge reports whether Codex is currently observed using the proxy; during cleanup those can briefly differ while the badge continues to report the observed state. Disabling names the effective Codex config file, removes OpenCodex's generated routing artifacts, and leaves the proxy running for other @@ -333,10 +333,14 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -On WSL, if `CODEX_HOME` is unset and the Linux `~/.codex/config.toml` is absent, opencodex also +On WSL, if `CODEX_HOME` is unset and there is no Linux `~/.codex` directory, opencodex also checks for a single Windows Codex Desktop home at `/mnt/c/Users/*/.codex/config.toml`. When exactly one candidate exists, it uses that directory so WSL app-server mode and Windows Codex Desktop share -the same config and auth files. Set `CODEX_HOME` explicitly to override this detection. +the same config and auth files. Set `CODEX_HOME` explicitly to override this detection. When Windows Codex Desktop runs its app-server inside WSL, it ships the Linux Codex binary under that home as `bin/wsl//codex`; opencodex finds it there when the service PATH has no `codex`, after any explicitly configured runtime and PATH. + +If the Codex home exists but Codex has not written `config.toml` yet (for example a fresh Desktop install +that was never signed in to OpenAI), opencodex creates an empty `config.toml` there and continues. If +the home directory itself does not exist, start Codex once so it creates it, or set `CODEX_HOME`. Codex can keep SQLite-backed thread state in a separate directory. OpenCodex history operations use the same precedence as Codex: root `sqlite_home` in `config.toml`, then `CODEX_SQLITE_HOME`, then the 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 43e45b69bf6..bae98cd8291 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -109,7 +109,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -WSL では、`CODEX_HOME` が設定されておらず、Linux `~/.codex/config.toml` が存在しない場合、opencodex は `/mnt/c/Users/*/.codex/config.toml` にある単一の Windows Codex デスクトップ ホームもチェックします。候補が 1 つだけ存在する場合は、そのディレクトリが使用されるため、WSL アプリサーバー モードと Windows Codex デスクトップは同じ設定ファイルと認証ファイルを共有します。この検出をオーバーライドするには、`CODEX_HOME` を明示的に設定します。 +WSL では、`CODEX_HOME` が設定されておらず、Linux の `~/.codex` ディレクトリが存在しない場合、opencodex は `/mnt/c/Users/*/.codex/config.toml` にある単一の Windows Codex デスクトップ ホームもチェックします。候補が 1 つだけ存在する場合は、そのディレクトリが使用されるため、WSL アプリサーバー モードと Windows Codex デスクトップは同じ設定ファイルと認証ファイルを共有します。この検出をオーバーライドするには、`CODEX_HOME` を明示的に設定します。 Windows では、ChatGPT/Codex アプリが `%USERPROFILE%\\.codex` を読み取りながら、Orca シェルは `CODEX_HOME` と `ORCA_CODEX_HOME` の両方を Orca のバンドルされたランタイム ホームに設定できます。 `ocx status` および `ocx doctor` は、この正確な不一致について警告し、編集されたターゲット パスを出力します。バックグラウンド サービスが Orca シェルからインストールされている場合は、最初に元のシェルからアンインストールし、次に `CODEX_HOME` をアプリ ホームに設定し、`ORCA_CODEX_HOME` の設定を解除し、同期/復元を再実行して、サービスを再度インストールします。 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 46ed361e0c1..76a88233a41 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -196,7 +196,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -WSL에서는 `CODEX_HOME`이 비어 있고 Linux `~/.codex/config.toml`도 없을 때 `/mnt/c/Users/*/.codex/config.toml` 아래의 단일 Windows Codex Desktop home도 확인합니다. 후보가 정확히 하나면 그 디렉터리를 사용하므로 WSL app-server mode와 Windows Codex Desktop이 같은 config와 auth 파일을 공유합니다. 이 탐지를 덮으려면 `CODEX_HOME`을 명시하세요. +WSL에서는 `CODEX_HOME`이 비어 있고 Linux `~/.codex` 디렉터리도 없을 때 `/mnt/c/Users/*/.codex/config.toml` 아래의 단일 Windows Codex Desktop home도 확인합니다. 후보가 정확히 하나면 그 디렉터리를 사용하므로 WSL app-server mode와 Windows Codex Desktop이 같은 config와 auth 파일을 공유합니다. 이 탐지를 덮으려면 `CODEX_HOME`을 명시하세요. Windows에서 Orca shell은 `CODEX_HOME`과 `ORCA_CODEX_HOME`을 Orca의 번들 런타임 home으로 설정할 수 있지만, ChatGPT/Codex app은 여전히 `%USERPROFILE%\\.codex`를 읽습니다. `ocx status`와 `ocx doctor`는 이 정확한 불일치를 경고하고, 경로는 가린 채 대상 home을 출력합니다. 해당 Orca shell에서 background service를 설치했다면 먼저 원래 shell에서 uninstall하고, `CODEX_HOME`을 app home으로 설정한 뒤 `ORCA_CODEX_HOME`을 해제하고, sync/restore를 다시 실행한 다음 service를 다시 설치하세요. 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 bc618edfbea..5541f8e259e 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 @@ -88,6 +88,13 @@ ocx login anthropic 실제 사용량을 다시 확인하며, 조회 실패나 잘못된 수치는 차단을 풀지 않습니다. 일시정지, 재인증, 서버의 사용량 제한은 별도로 적용됩니다. +새로운 유효한 WHAM 사용량 응답 한 건에서 1차 창의 기간이 **24시간 이상**으로 명시되고, +2차·3차 창이 명시적 `null`이거나 그 기간도 24시간 이상으로 명시되고 사용량 수치도 함께 오면 이전 5h 수치를 대체합니다. +파서의 단기·장기 구분 기준을 따르므로 주간·월간뿐 아니라 하루짜리 창도 해당합니다. +현재 창에는 동일한 99% 기준을 적용합니다. 이 판단은 응답 한 건의 정보에 의존하며 연속 관측을 +요구하지 않습니다. 2차·3차 필드가 생략되었거나, 1차 창의 기간을 모르거나, 응답 헤더만 일부 +도착한 경우에는 이전 차단을 해제하지 않습니다. + 저장되는 옵션은 OpenCodex의 `config.json`에 있는 `"codexMainAccountHardLock": true`이며, 기본값은 꺼짐입니다. 식별된 메인 계정의 새 요청을 막는 기능이지 마지막 1%를 예약하는 기능은 아닙니다. 진행 중 요청, 식별되지 않은 키링 계정, 프록시 밖 요청은 사용량을 더 쓸 수 있습니다. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index e8f401f7207..7d8d6d7693f 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -334,6 +334,8 @@ and this session ends with the app. Invalidate Codex's local model picker cache so it is rebuilt from the active opencodex catalog. The same stale-`app-server` warning and optional restart flags as `ocx sync` apply. +If the derived cache already has identical bytes, the command succeeds without rewriting it or restarting Codex. With `--json`, this is reported as `ok: true`, `wrote: false`, `skipped: true`, and `skippedReason: "unchanged"`; an invalid catalog or failed cache write still exits nonzero. + ### `ocx catalog pull [--auth-env ] [--json] [--restart-codex] [--restart-app-server-only]` Install a complete catalog served by another OpenCodex instance's `/v1/catalog` endpoint, then 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 11d568ab92f..21c8f82a5f6 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -151,6 +151,13 @@ not erase an already measured blocking tuple. A predicted reset time alone does While blocked, the existing once-per-minute background cycle checks fresh owned usage; failed or invalid readings retain the block. 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 +one-day window qualifies as well as weekly/monthly windows. The current window still uses the same +99% 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. + The persisted option is `"codexMainAccountHardLock": true` in OpenCodex's `config.json`; it is off by default. This protects new requests using the identified main account, not the last 1% itself: already-running requests, unmatched caller-owned keyring credentials, and traffic outside the 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 2436de826c4..ec29051ea36 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -156,7 +156,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -В WSL, если `CODEX_HOME` не задан и Linux-файл `~/.codex/config.toml` отсутствует, opencodex +В WSL, если `CODEX_HOME` не задан и Linux-каталог `~/.codex` отсутствует, opencodex дополнительно проверяет, нет ли единственного Windows-home Codex Desktop в `/mnt/c/Users/*/.codex/config.toml`. Если существует ровно один такой кандидат, используется его каталог, чтобы режим app-server в WSL и Windows Codex Desktop разделяли одни и те же config- и 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 eb5da0c8e51..5833c993e28 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -170,7 +170,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -WSL üzerinde, `CODEX_HOME` ayarlanmamışsa ve Linux `~/.codex/config.toml` mevcut +WSL üzerinde, `CODEX_HOME` ayarlanmamışsa ve Linux `~/.codex` dizini mevcut değilse, opencodex `/mnt/c/Users/*/.codex/config.toml` konumunda tek bir Windows Codex Desktop evini de kontrol eder. Tam olarak bir aday mevcut olduğunda bu dizini kullanır, böylece WSL app-server modu ve Windows Codex Desktop aynı 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 6bb570f8de6..db813ea3bde 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 @@ -142,7 +142,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -在 WSL 中,如果未设置 `CODEX_HOME`,且 Linux 侧的 `~/.codex/config.toml` 不存在,opencodex 还会检查 +在 WSL 中,如果未设置 `CODEX_HOME`,且 Linux 侧不存在 `~/.codex` 目录,opencodex 还会检查 `/mnt/c/Users/*/.codex/config.toml` 下是否存在单一的 Windows Codex Desktop home。只要候选项恰好只有一个, 它就会使用那个目录,让 WSL app-server mode 和 Windows Codex Desktop 共享同一份 config 与 auth 文件。 如需覆盖这一检测,请显式设置 `CODEX_HOME`。 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 77d48071607..bd0a9f56cfe 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 @@ -139,7 +139,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -在 WSL 中,如果未設定 `CODEX_HOME`,且 Linux 的 `~/.codex/config.toml` 不存在,opencodex 也會檢查 +在 WSL 中,如果未設定 `CODEX_HOME`,且 Linux 不存在 `~/.codex` 目錄,opencodex 也會檢查 `/mnt/c/Users/*/.codex/config.toml` 下是否只有一個 Windows Codex Desktop home。候選項恰好只有一個時, 會使用該目錄,讓 WSL app-server mode 與 Windows Codex Desktop 共用相同的 config 與 auth 檔案。 若要覆蓋此偵測,請明確設定 `CODEX_HOME`。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a4f1697d2db..e3cdeec8f4e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -530,6 +530,7 @@ "codex-home-wsl.test.ts": "codex-integration", "codex-inject-history-wording.test.ts": "codex-integration", "codex-inject-integration.test.ts": "codex-integration", + "codex-inject-missing-config.test.ts": "codex-integration", "codex-inject-retained-table.test.ts": "codex-integration", "codex-inject-v1-reconcile.test.ts": "codex-integration", "codex-inject-write-lock.test.ts": "codex-integration", @@ -597,6 +598,7 @@ "codex-routing-cache-affinity-detour.test.ts": "codex-integration", "codex-routing.test.ts": "codex-integration", "codex-runtime.test.ts": "codex-integration", + "codex-runtime-wsl-desktop.test.ts": "codex-integration", "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", @@ -1077,6 +1079,7 @@ "moonshot-tool-schema.test.ts": "providers", "multi-agent-compat.test.ts": "codex-integration", "multi-agent-keep-native-v1.test.ts": "codex-integration", + "multi-agent-origin.test.ts": "codex-integration", "muse-passive-quota-cache.test.ts": "providers", "muse-passive-quota-observation.test.ts": "providers", "muse-spark-web-search-compat.test.ts": "providers", diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index c1f5c0092e8..198fe16322e 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -555,24 +555,24 @@ const commandRunners: Record = { const cacheArgs = deps.args.slice(1); const restartScope = readRestartScope(cacheArgs, console); const { withCatalogWriteSerialization } = await import("../codex/catalog-write-serialization"); - const { invalidateCodexModelsCacheWithPermit } = await import("../codex/catalog/sync"); + const { invalidateCodexModelsCacheWithPermitOutcome } = await import("../codex/catalog/sync"); const { getCodexHome } = await import("../codex/paths"); - const { readCodexCatalogPathForHome } = await import("../codex/catalog/parsing"); - const { existsSync } = await import("node:fs"); const owningCodexHome = getCodexHome(); const cacheGateSnapshot = deps.loadConfig(); const desiredDisabled = !shouldSyncCodexOnStart(cacheGateSnapshot); const invalidated = withCatalogWriteSerialization(owningCodexHome, permit => - invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, { allowWhenDesiredDisabled: true })); + invalidateCodexModelsCacheWithPermitOutcome(permit, owningCodexHome, { allowWhenDesiredDisabled: true })); const cacheJson = cacheArgs.includes("--json"); const jsonSafeLog = cacheJson ? { log: (...values: unknown[]) => console.error(...values), error: (...values: unknown[]) => console.error(...values) } : console; // Only warn/restart when models_cache was actually rewritten from a readable catalog. - if (invalidated.kind === "completed" && invalidated.value) { + if (invalidated.kind === "completed" && invalidated.value === "written") { await handleRestartScopeAfterWrite(restartScope, jsonSafeLog); - } else if (desiredDisabled && !cacheJson) { - // Worth saying in the human path, because it explains why nothing was written. + } else if (!cacheJson && invalidated.kind === "completed" && invalidated.value === "desired_disabled") { + // Only when the OFF gate itself stopped the write does OFF explain the outcome. An + // explicit sync-cache refreshes regardless of the toggle, so an unchanged cache, a + // missing catalog, or a contended writer is reported below on its own terms. // Under --json this belongs on the envelope, not as a second stdout line. console.log(localClientSkipMessage( cacheGateSnapshot, @@ -580,8 +580,8 @@ const commandRunners: Record = { "No catalog or cache write resulted.", )); } - // `completed` with a falsy value means the cache was NOT rewritten. Previously every - // outcome exited 0, so a script could not tell a refreshed cache from a skipped one. + // An identical cache is a successful no-op, not a failed refresh. Only a real write + // should restart Codex; a missing catalog or contended writer is also a benign skip. // // Losing the catalog write lock to another process is a skip, not a failure: // serialization working as designed is the expected outcome under concurrency, and a @@ -596,30 +596,25 @@ const commandRunners: Record = { // means the user asked for it regardless of the toggle. Treating OFF as automatic success // would report exit 0 and `skipped: true` for a refresh that actually failed. // - // But `invalidateCodexModelsCacheWithPermit` returns a bare boolean for four different - // situations -- wrote it, no catalog file exists, the OFF gate fired, or it threw -- so - // `false` alone cannot be read as failure either. `!existsSync(catalogPath)` is a - // legitimate nothing-to-do: with no catalog there is no cache to derive, which is the - // normal state of a fully native home and the case - // `codex-composed-acceptance.test.ts` pins at exit 0. It is checked here rather than by - // widening that function's return type, because its boolean is consumed by a dozen - // management routes that have no use for the distinction. - const wrote = invalidated.kind === "completed" && Boolean(invalidated.value); + // The detailed outcome distinguishes an unchanged cache from a failed rewrite while + // the boolean wrapper remains available to callers that only care whether bytes changed. + const wrote = invalidated.kind === "completed" && invalidated.value === "written"; + const unchanged = invalidated.kind === "completed" && invalidated.value === "unchanged"; const contended = invalidated.kind === "unavailable" && invalidated.reason === "busy"; - const noCatalog = !wrote && !existsSync(readCodexCatalogPathForHome(owningCodexHome)); - const ok = wrote || contended || noCatalog; + const noCatalog = invalidated.kind === "completed" && invalidated.value === "missing_catalog"; + const ok = wrote || unchanged || contended || noCatalog; if (cacheJson) { console.log(JSON.stringify({ schemaVersion: 1, ok, wrote, - skipped: contended || noCatalog, + skipped: unchanged || contended || noCatalog, outcome: invalidated.kind, // `outcome` alone cannot separate a contended lock from a hard serialization // failure -- both are `unavailable`. Carry the reason so a caller can. reason: invalidated.kind === "unavailable" ? invalidated.reason : undefined, - // Which of the two benign skips this was, so `skipped: true` is never opaque. - skippedReason: contended ? "contended" : noCatalog ? "no_catalog" : undefined, + // Which of the three benign skips this was, so `skipped: true` is never opaque. + skippedReason: unchanged ? "unchanged" : contended ? "contended" : noCatalog ? "no_catalog" : undefined, desiredDisabled, codexHome: owningCodexHome, }, null, 2)); @@ -627,6 +622,8 @@ const commandRunners: Record = { console.log("Another process owns the catalog write; cache sync skipped."); } else if (noCatalog) { console.log("No Codex catalog to derive a cache from; nothing to sync."); + } else if (unchanged) { + console.log("Codex model cache is already current; nothing to sync."); } else if (!ok) { console.error(`Cache refresh did not complete (${invalidated.kind}). The Codex model cache was not rewritten.`); } diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index f6ae1d1c179..b419d99e67e 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -20,7 +20,7 @@ * targeting it is the caller's explicit act. */ import { homedir } from "node:os"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { shouldInjectApiAuthHeader, standaloneCodexRoutingTarget } from "../codex/inject"; import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize"; @@ -556,7 +556,17 @@ export function omoConfigPath(env: OpencodeLaunchEnv = process.env, home: string * client-owned override to mirror, and this registry does not invent one. */ export function asideHomeDir(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { - return join(home, ".aside"); + const root = join(home, ".aside"); + // A user who relocated Aside (for example to an external volume) leaves ~/.aside as a + // symlink, and Aside itself follows it (issue 5648). Canonicalize only that top-level + // alias, once, and only onto a directory: every boundary below the root (u/, account + // directories, models.json) keeps refusing links against the canonical path. + try { + if (lstatSync(root).isSymbolicLink() && statSync(root).isDirectory()) return realpathSync.native(root); + } catch { + // Missing or unreadable: the literal path lets the profile reader report it. + } + return root; } /** diff --git a/src/codex/catalog/gather-capture.ts b/src/codex/catalog/gather-capture.ts index 656a3b4d3b4..cdca77fb61a 100644 --- a/src/codex/catalog/gather-capture.ts +++ b/src/codex/catalog/gather-capture.ts @@ -25,11 +25,13 @@ import { } from "../model-cache"; import { buildModelsRequest, + getOAuthCredentialApiBaseUrl, getValidAccessTokenSnapshot, observeActiveOAuthAccessToken, resolveModelsAuthToken, type OAuthActiveTokenObservation, } from "../../oauth"; +import { getAccountSet } from "../../oauth/store"; import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; @@ -150,6 +152,12 @@ export interface CapturedProviderGather { readonly metadataModelIdCaseFold: boolean; readonly effectiveAlias?: string | null; readonly observedAuth?: ModelsAuthResolution; + /** + * The active OAuth account a refreshing capture was taken under. It is part of the + * flight's auth identity, so a caller on another account never joins a pending + * discovery started for this one, even when both accounts share an API host. + */ + readonly refreshingOAuthAccountId?: string; /** * Configured model ids this provider must keep even when live discovery omits * them — combo targets that are also listed in providers.*.models (OCX-111). @@ -315,14 +323,12 @@ export function captureTrustedOpenAiApiPolicy( }); } -function captureModelsRequest( +export function captureModelsRequest( name: string, provider: OcxProviderConfig, - observedAuth: ModelsAuthResolution | undefined, + oauthApiBaseUrl: string | undefined, ): CapturedModelsRequest { - const observed = observedAuth - ? { oauthApiBaseUrl: observedAuth.oauthApiBaseUrl } - : undefined; + const observed = { oauthApiBaseUrl }; const withoutCredential = buildModelsRequest(provider, undefined, name, observed); const withCredential = buildModelsRequest(provider, REQUEST_CREDENTIAL_SENTINEL, name, observed); const method = withoutCredential.method ?? "GET"; @@ -378,7 +384,18 @@ export function captureProviderGather( && provider.liveModels !== false ? authResolver.resolve(name, provider) : undefined; - const request = captureModelsRequest(name, provider, observedAuth); + // A refreshing capture carries the stored origin so accounts on different hosts keep separate + // flights. The send is rebuilt from the auth the gather resolves, and the observed path never + // reads the live store. + const oauthApiBaseUrl = observedAuth + ? observedAuth.oauthApiBaseUrl + : authResolver.kind === "refreshing" && provider.authMode === "oauth" + ? getOAuthCredentialApiBaseUrl(name) + : undefined; + const request = captureModelsRequest(name, provider, oauthApiBaseUrl); + const refreshingOAuthAccountId = !observedAuth && authResolver.kind === "refreshing" && provider.authMode === "oauth" + ? getAccountSet(name)?.activeAccountId + : undefined; const resolved = resolveProviderModelDiscovery(name, provider); const discovery = detachedFrozen({ ...(resolved.spec ? { spec: resolved.spec } : {}), @@ -413,6 +430,7 @@ export function captureProviderGather( metadataModelIdCaseFold, effectiveAlias, ...(observedAuth ? { observedAuth: Object.freeze({ ...observedAuth }) } : {}), + ...(refreshingOAuthAccountId ? { refreshingOAuthAccountId } : {}), ...(retainConfiguredModelIds && retainConfiguredModelIds.size > 0 ? { retainConfiguredModelIds } : {}), @@ -447,6 +465,7 @@ export function captureGatherFlight( liveModels: provider.provider.liveModels ?? null, credential: provider.provider.apiKey ?? null, observedAuth: provider.observedAuth ?? null, + oauthAccount: provider.refreshingOAuthAccountId ?? null, headers: provider.request.headersWithCredential, url: provider.request.url, }))), diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index f76953e1851..3975047468b 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -731,6 +731,7 @@ export function applyMultiAgentMode( ): RawEntry[] { if (mode === "v2" && options.keepNativeChatGptOnV1 === true) { for (const entry of entries) { + recordMultiAgentOrigin(entry); entry.multi_agent_version = catalogEntryIsNativeChatGpt(entry) ? "v1" : "v2"; } return entries; @@ -739,6 +740,9 @@ export function applyMultiAgentMode( // Restore upstream defaults: clear any stale forced multi_agent_version and // re-apply upstream pins from the snapshot for native entries that have one. for (const entry of entries) { + // A forced mode recorded what this row carried before it was overwritten; returning + // to default consumes that record whichever branch below decides the row. + const origin = takeMultiAgentOrigin(entry); if (options.preserveDefaultMultiAgentVersion?.(entry)) continue; const slug = typeof entry.slug === "string" ? entry.slug : ""; const nativeAlias = entry.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND; @@ -777,7 +781,17 @@ export function applyMultiAgentMode( && isNativeCatalogEntry && !hasNativeDefault && typeof entry.multi_agent_version === "string") { - continue; + // The baseline predates this native row, so it cannot say whether the live pin + // is genuine. With a recorded origin it no longer has to guess: restore what the + // row carried before the first forced mode (issue 5636). A row without a record + // (older catalogs) keeps the non-destructive read. + if (origin === undefined) continue; + if (typeof origin === "string") { + entry.multi_agent_version = origin; + continue; + } + if (v2FeatureEnabled) entry.multi_agent_version = "v2"; + else delete entry.multi_agent_version; } else if (v2FeatureEnabled) { entry.multi_agent_version = "v2"; } else { @@ -787,11 +801,35 @@ export function applyMultiAgentMode( return entries; } for (const entry of entries) { + recordMultiAgentOrigin(entry); entry.multi_agent_version = mode; } return entries; } +/** + * Provenance for a forced multi-agent stamp: the value the row carried before the first + * forced v1/v2 pass, or null when it carried none. Repeated forced passes never replace it, + * so a v1 -> v2 -> default round trip still restores the original. Codex ignores unknown + * catalog fields, as it does opencodex_catalog_kind. + */ +export const MULTI_AGENT_ORIGIN_FIELD = "opencodex_multi_agent_version_origin"; + +function recordMultiAgentOrigin(entry: RawEntry): void { + if (Object.hasOwn(entry, MULTI_AGENT_ORIGIN_FIELD)) return; + (entry as Record)[MULTI_AGENT_ORIGIN_FIELD] = typeof entry.multi_agent_version === "string" + ? entry.multi_agent_version + : null; +} + +/** Remove and return the recorded origin: a string pin, null for "no pin", undefined when unrecorded. */ +function takeMultiAgentOrigin(entry: RawEntry): string | null | undefined { + if (!Object.hasOwn(entry, MULTI_AGENT_ORIGIN_FIELD)) return undefined; + const raw = (entry as Record)[MULTI_AGENT_ORIGIN_FIELD]; + delete (entry as Record)[MULTI_AGENT_ORIGIN_FIELD]; + return typeof raw === "string" ? raw : raw === null ? null : undefined; +} + export function normalizeRoutedCatalogEntry( entry: RawEntry, parallelToolCalls = false, diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index a302236f61e..23a9696850c 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -105,7 +105,7 @@ import type { import type { CapturedProviderGather, CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, ModelsAuthResolution, ModelsAuthResolver } from "./gather-capture"; import { QUIET_AUTHORITATIVE_CATALOG_PROVIDERS, applyConfigHintsToCachedModels, applyProviderConfigHints, boundedOwnedBy, catalogHintsFromModelsApiItem, catalogHintsFromProviderConfig } from "./model-hints"; import { mergeConfiguredModelsIntoLiveCatalog, shouldExposeProviderModel, warnDroppedConfiguredIdsOnce } from "./model-visibility"; -import { captureProviderGather, materializeCapturedHeaders } from "./gather-capture"; +import { captureModelsRequest, captureProviderGather, materializeCapturedHeaders } from "./gather-capture"; export interface ProviderModelsResult { readonly models: CatalogModel[]; @@ -151,7 +151,7 @@ export async function fetchProviderModelsWithAuth( contextCap: number | undefined, resolveAuth: ModelsAuthResolver, ): Promise { - const { name, provider: prov, discovery, request, metadataModelIdCaseFold } = captured; + const { name, provider: prov, discovery, metadataModelIdCaseFold } = captured; const observed = ( models: CatalogModel[], state: CatalogGatherProviderModelOutcome["state"], @@ -217,10 +217,7 @@ export async function fetchProviderModelsWithAuth( return observed(configured, "authoritative"); } const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing" - ? prov.authMode === "oauth" && ( - effectiveGoogleMode(name, prov) === "cloud-code-assist" - || prov.adapter === "devin" - ) + ? prov.authMode === "oauth" ? await getValidAccessTokenSnapshot(name) .then(snapshot => ({ apiKey: snapshot.accessToken, @@ -475,6 +472,11 @@ export async function fetchProviderModelsWithAuth( "degraded", ); } + // The captured request predates any refresh, so a refreshing gather rebuilds it + // from the auth it resolved: the token and its origin, together. + const request = resolveAuth.kind === "refreshing" + ? captureModelsRequest(name, prov, auth.oauthApiBaseUrl) + : captured.request; const url = request.url; let headers = materializeCapturedHeaders(request, apiKey); // One Ollama authority contract: for canonical ollama-cloud/ollama-native rows, discovery diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index efd3a63413e..aab9ebc205d 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -649,11 +649,13 @@ export async function syncCatalogModels( }; } -export function invalidateCodexModelsCacheWithPermit( +export type CodexCacheInvalidationOutcome = "written" | "unchanged" | "missing_catalog" | "desired_disabled" | "failed"; + +export function invalidateCodexModelsCacheWithPermitOutcome( permit: CatalogWritePermit, owningCodexHome: string, options?: CodexCatalogSyncOptions, -): boolean { +): CodexCacheInvalidationOutcome { try { // This permit is a REACQUISITION: refreshCodexModelCatalog's commit released // K before this rewrite runs, so the commit-path desired-state check cannot @@ -661,9 +663,9 @@ export function invalidateCodexModelsCacheWithPermit( // routed cache write — re-read intent under this permit, same as the commit. // The catalog-only sync override applies here too so an explicit refresh // keeps the cache consistent with the catalog it just wrote. - if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false; + if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return "desired_disabled"; const catalogPath = readCodexCatalogPathForHome(owningCodexHome); - if (!existsSync(catalogPath)) return false; + if (!existsSync(catalogPath)) return "missing_catalog"; const catalog = JSON.parse(readFileSync(catalogPath, "utf8")); const models = catalog.models ?? catalog; const cachePath = join(owningCodexHome, "models_cache.json"); @@ -711,14 +713,22 @@ export function invalidateCodexModelsCacheWithPermit( // `cacheSynced` mean what its name and its consumers already assume, and what // `pullRemoteCatalog` and the early returns in `refreshCodexModelCatalog` // already assert: a write happened. - if (!preparedBytesDifferFromDisk(preparedCache)) return false; + if (!preparedBytesDifferFromDisk(preparedCache)) return "unchanged"; replaceCodexModelsCache(permit, owningCodexHome, preparedCache); - return true; + return "written"; } catch { - return false; + return "failed"; } } +export function invalidateCodexModelsCacheWithPermit( + permit: CatalogWritePermit, + owningCodexHome: string, + options?: CodexCatalogSyncOptions, +): boolean { + return invalidateCodexModelsCacheWithPermitOutcome(permit, owningCodexHome, options) === "written"; +} + export function invalidateCodexModelsCache(options?: CodexCatalogSyncOptions): boolean { const owningCodexHome = getCodexHome(); const outcome = withCatalogWriteSerialization( diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 373bcb43779..83c233ef299 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -46,7 +46,7 @@ export { export { syncCatalogModels, invalidateCodexModelsCache, - invalidateCodexModelsCacheWithPermit, + invalidateCodexModelsCacheWithPermit, invalidateCodexModelsCacheWithPermitOutcome, } from "./retained-sync"; -export type { CodexCatalogSyncOptions } from "./retained-sync"; +export type { CodexCacheInvalidationOutcome, CodexCatalogSyncOptions } from "./retained-sync"; export { restoreCodexCatalog, restoreCodexCatalogWithPermit } from "./restore"; diff --git a/src/codex/home.ts b/src/codex/home.ts index b81392d3fda..d961ded7e7b 100644 --- a/src/codex/home.ts +++ b/src/codex/home.ts @@ -135,11 +135,28 @@ export function findWslWindowsCodexHome(deps: CodexHomeDeps = {}): string | null export function defaultCodexHome(deps: CodexHomeDeps = {}): string { const home = (deps.homedir ?? homedir)(); const defaultHome = join(home, ".codex"); - const exists = deps.existsSync ?? existsSync; - const detected = !exists(join(defaultHome, "config.toml")) ? findWslWindowsCodexHome(deps) : null; + // A local ~/.codex directory is the user's Codex home even before Codex has + // written config.toml into it (a fresh install). Only an absent local home, + // or a path that is not a directory, lets WSL discovery pick a Windows home. + const detected = localCodexHomeIsDirectory(defaultHome, deps) ? null : findWslWindowsCodexHome(deps); return detected ?? defaultHome; } +function localCodexHomeIsDirectory(path: string, deps: CodexHomeDeps): boolean { + const stat = deps.statSync ?? statSync; + // stat, not existsSync: existsSync reports false for an access error too, and that + // must not read as "absent" and hand the user's state to a different home. + try { + return stat(path).isDirectory(); + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code === "ENOENT" || code === "ENOTDIR") return false; + // An unreadable local home is still the local home; never switch to a + // different Codex home because a stat failed for an unexpected reason. + return true; + } +} + export function resolveCodexHomeDir(deps: CodexHomeDeps = {}): string { const raw = (deps.env ?? process.env).CODEX_HOME?.trim(); if (raw) return resolve(expandUserPath(raw)); diff --git a/src/codex/inject.ts b/src/codex/inject.ts index c63c29a3e33..74b35b88931 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -1,4 +1,5 @@ -import { existsSync, readFileSync } from "node:fs"; +import { closeSync, existsSync, openSync, readFileSync, statSync } from "node:fs"; +import { dirname } from "node:path"; import { atomicWriteFile, loadConfig, @@ -211,14 +212,15 @@ async function injectCodexConfigImpl( } catch (error) { return { success: false, message: error instanceof Error ? error.message : "Invalid Codex routing target" }; } - if (!existsSync(CODEX_CONFIG_PATH)) { - return { - success: false, - message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?`, - }; - } + const missingConfig = !existsSync(CODEX_CONFIG_PATH) + ? missingCodexConfigAdmission() + : null; + if (missingConfig && !missingConfig.ok) return { success: false, message: missingConfig.message }; - const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8"); + // An absent config.toml in an existing home is planned as an empty file. The file itself + // is created only inside the write boundary, after the pre-images are captured, so any + // later refusal or failure rolls it back to absent (issue 5422). + const rawContent = missingConfig ? "" : readFileSync(CODEX_CONFIG_PATH, "utf-8"); const activeProvider = externalCodexModelProvider(rawContent); if (activeProvider) { // A launcher may have journaled before the provider manager took ownership. Never let shutdown @@ -402,6 +404,7 @@ async function injectCodexConfigImpl( * flip included — before the result is reported. */ const reconcileAndDerivePlan = (): { plan: CodexInjectionPlanOk; nativeInput: string } => { + if (missingConfig) createEmptyCodexConfigInBoundary(); let nativeInput = rawContent; let plan = admittedPlan; if (v1Reconcile) { @@ -860,3 +863,49 @@ export { setBeforeRestoreConfigForTests, skippedRestoreEnvelope, } from "./inject/restore"; + +type MissingCodexConfig = { ok: true } | { ok: false; message: string }; + +/** + * A fresh Codex install can have its home directory but no config.toml yet: Codex writes + * that file lazily, and a user who never signed in to OpenAI (authless Desktop with a + * third-party provider, issue 5422) may never get one. A missing optional file is not + * evidence that Codex is absent, so injection plans against an empty config.toml and + * creates it inside the write boundary. A missing home DIRECTORY is different: that is + * either an uninitialized install or the wrong home, and guessing would write provider + * state where Codex is not looking. + */ +function missingCodexConfigAdmission(): MissingCodexConfig { + const home = dirname(CODEX_CONFIG_PATH); + let homeIsDirectory = false; + try { + homeIsDirectory = statSync(home).isDirectory(); + } catch { + homeIsDirectory = false; + } + if (homeIsDirectory) return { ok: true }; + return { + ok: false, + message: `Codex home ${home} does not exist yet, so there is no config.toml to route. Start Codex once so it creates its home, then rerun 'ocx sync'. If Codex uses a different home, set CODEX_HOME to it.`, + }; +} + +/** + * Create the planned empty config.toml under the write boundary. It runs after the + * pre-images were captured (config absent), so compensation removes it again. The create is + * exclusive: a file that appeared since admission belongs to another writer, and this plan, + * derived from an absent file, must not replace it. + */ +function createEmptyCodexConfigInBoundary(): void { + try { + closeSync(openSync(CODEX_CONFIG_PATH, "wx", 0o600)); + } catch (error) { + const appeared = (error as NodeJS.ErrnoException | null)?.code === "EEXIST"; + throw new CodexInjectRefusal({ + success: false, + message: appeared + ? `Codex config ${CODEX_CONFIG_PATH} appeared while injection was planned against its absence; nothing was changed. Rerun 'ocx sync'.` + : `Codex config not found at ${CODEX_CONFIG_PATH}, and creating it failed: ${error instanceof Error ? error.message : String(error)}`, + }); + } +} diff --git a/src/codex/quota.ts b/src/codex/quota.ts index d5420ac7e72..7d43a9728a3 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -29,6 +29,8 @@ type QuotaDiskFile = { }; type MainPolicyQuota = { identityKey: string; quota: StoredAccountQuota }; +/** Fresh WHAM topology proof is consumed by the merge, never retained in a cache or DTO. */ +type MainPolicyQuotaObservation = Omit & { shortWindowAbsent?: true }; let mainPolicyQuota: MainPolicyQuota | null = null; let diskHydrated = false; let persistTimer: ReturnType | null = null; @@ -199,6 +201,12 @@ function isExplicitMonthlyWindow(window: WhamUsageWindow | null | undefined): bo && seconds >= MONTHLY_WINDOW_MIN_SECONDS; } +/** Same 24h short/long boundary as the parser; this includes a declared one-day window. */ +function isExplicitLongWindow(window: WhamUsageWindow | null | undefined): boolean { + const seconds = window?.limit_window_seconds; + return typeof seconds === "number" && Number.isFinite(seconds) && seconds >= WEEKLY_WINDOW_MIN_SECONDS; +} + function isExplicitMonthlyWindowMinutes(windowMinutes: unknown): boolean { const minutes = windowMinutes_(windowMinutes); return minutes !== undefined && minutes >= MONTHLY_WINDOW_MIN_MINUTES; @@ -267,12 +275,17 @@ function snapshotHasCustom(quota: Omit): boolea function snapshotHasUsage(quota: Omit): boolean { return snapshotHasWeekly(quota) || snapshotHasMonthly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota); } +/** + * Publish parsed display quota and separately validated main-policy evidence after writer checks. + * A null policy observation retains only the matching main identity's previous evidence; + * transient replacement markers are consumed during merging and never enter stored snapshots. + */ export function setAccountQuotaFromParsed( accountId: string, quota: Omit | null, writerGeneration = captureConfigGeneration(), mainWriter?: MainQuotaWriter, - policyQuota: Omit | null = quota, + policyQuota: MainPolicyQuotaObservation | null = quota, historyEvidence?: QuotaObservationEvidence, ): void { quota = withoutRetiredCodexQuota(quota); @@ -313,9 +326,13 @@ export function setAccountQuotaFromParsed( } } -/** One partial-window merge contract for legacy quota and identity-bound policy evidence. */ +/** + * Merge a partial observation into the legacy or identity-bound policy snapshot. + * Policy mode retains omitted blocking short usage unless this observation authorizes replacement; + * the returned snapshot contains quota fields only, without the transient replacement marker. + */ function mergeAccountQuota( - quota: Omit, + quota: MainPolicyQuotaObservation, existing: StoredAccountQuota | undefined, updatedAt: number, policyEvidence = false, @@ -377,7 +394,7 @@ function mergeAccountQuota( } if (quota.shortResetAt !== undefined) next.shortResetAt = quota.shortResetAt; if (quota.shortWindowSeconds !== undefined) next.shortWindowSeconds = quota.shortWindowSeconds; - } else { + } else if (!policyEvidence || quota.shortWindowAbsent !== true) { // Unknown usage is not a lower reading. Retain the entire known tuple: pairing // its percentage with new metadata would silently extend or shorten its reset. // An elapsed reset is the exception. It describes a window that has already rolled over, @@ -793,13 +810,38 @@ function filterMainPolicyMonthlyQuota( return hasKnownQuotaValue(filtered) || filtered.resetCredits !== undefined ? filtered : null; } -/** Ordinary main policy rejects an entire message containing any invalid numeric window. */ -export function parseMainPolicyUsageQuota(data: WhamUsageResponse): Omit | null { +/** + * 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. + */ +export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQuotaObservation | null { const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window]; if (windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent))) return null; - return filterMainPolicyMonthlyQuota(parseUsageQuota(data), isThirtyDayOnlyCodexPlan(data.plan_type)); + const quota = filterMainPolicyMonthlyQuota(parseUsageQuota(data), isThirtyDayOnlyCodexPlan(data.plan_type)); + const [primary, secondary, tertiary] = windows; + // WHAM explicitly reports absent windows as null; omissions cannot prove replacement. + // Policy trusts one complete snapshot only when every non-null window is >=24h AND + // 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) + && (secondary === null || isMeasuredLongWindow(secondary)) + && (tertiary === null || isMeasuredLongWindow(tertiary))) { + return { ...quota, shortWindowAbsent: true }; + } + return quota; } +function isMeasuredLongWindow(window: WhamUsageWindow | null | undefined): boolean { + return isExplicitLongWindow(window) && normalizeUsagePercent(window?.used_percent) !== undefined; +} + +/** + * Normalize WHAM windows into the display snapshot, preserving declared short-window shape. + * Finite percentages are clamped for compatibility; policy callers must validate raw readings + * separately. Return null when neither a quota value/window nor reset credits are available. + */ export function parseUsageQuota(data: WhamUsageResponse): Omit | null { const resetCredits = typeof data.rate_limit_reset_credits?.available_count === "number" ? data.rate_limit_reset_credits.available_count diff --git a/src/codex/runtime.ts b/src/codex/runtime.ts index aa9a57563af..db7a739cd4e 100644 --- a/src/codex/runtime.ts +++ b/src/codex/runtime.ts @@ -4,6 +4,7 @@ import { homedir, tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation"; +import { resolveCodexHomeDir } from "./home"; import { redactSecretString, redactUserPath } from "../lib/redact"; export type CodexRuntimeSource = @@ -508,16 +509,13 @@ function pathCandidates(deps: ResolveCodexRuntimeDeps): string[] { function installedCodexCandidates(deps: ResolveCodexRuntimeDeps): string[] { const platform = deps.platform ?? process.platform; const env = deps.env ?? process.env; - if (platform === "win32") { - const localAppData = env.LOCALAPPDATA?.trim(); - if (!localAppData) return []; - const root = join(localAppData, "OpenAI", "Codex", "bin"); - const readDir = deps.readdirSync ?? ((path: string) => readdirSync(path)); - const stat = deps.statSync ?? ((path: string) => statSync(path)); + const readDir = deps.readdirSync ?? ((path: string) => readdirSync(path)); + const stat = deps.statSync ?? ((path: string) => statSync(path)); + /** Version directories directly under root, newest first; unreadable roots yield nothing. */ + const versionDirectories = (root: string): string[] => { try { - const names = readDir(root); const dirs: Array<{ name: string; directory: string; mtimeMs: number }> = []; - for (const name of names) { + for (const name of readDir(root)) { const directory = join(root, name); try { const st = stat(directory); @@ -528,18 +526,39 @@ function installedCodexCandidates(deps: ResolveCodexRuntimeDeps): string[] { } } dirs.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name)); - return dirs.map(entry => join(entry.directory, "codex.exe")); + return dirs.map(entry => entry.directory); } catch { return []; } + }; + if (platform === "win32") { + const localAppData = env.LOCALAPPDATA?.trim(); + if (!localAppData) return []; + return versionDirectories(join(localAppData, "OpenAI", "Codex", "bin")) + .map(directory => join(directory, "codex.exe")); } const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir(); - return [ + const posix = [ join(home, ".codex", "packages", "standalone", "current", "bin", "codex"), join(home, ".local", "bin", "codex"), "/usr/local/bin/codex", "/opt/homebrew/bin/codex", ]; + if (platform !== "linux") return posix; + // Windows Codex Desktop in WSL app-server mode ships its Linux binary under the + // effective Codex home as bin/wsl//codex, and a Desktop update replaces + // that hash directory. The service PATH usually has no codex (issue 5635), so these + // rank after PATH and the ordinary locations and are re-enumerated on every resolve + // rather than trusted from a remembered hash. + let codexHome: string; + try { + codexHome = resolveCodexHomeDir({ env }); + } catch { + return posix; + } + const desktopWsl = versionDirectories(join(codexHome, "bin", "wsl")) + .map(directory => join(directory, "codex")); + return [...posix, ...desktopWsl]; } interface RankedCandidate { @@ -735,6 +754,8 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null { localAppData: env.LOCALAPPDATA?.trim() ?? "", homeDir: env.HOME?.trim() ?? "", userProfile: env.USERPROFILE?.trim() ?? "", + // Linux discovery enumerates /bin/wsl, so the home is part of the key. + codexHome: env.CODEX_HOME?.trim() ?? "", home: process.env.OPENCODEX_HOME ?? "", persisted: persistedRuntimeCacheStamp(deps), }); diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 736bdf9aa0b..aa049e9d566 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -19,7 +19,8 @@ import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-sta * 011 (Claude Code), 012 (Grok). */ import { join } from "node:path"; -import { loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../../config"; +import { existsSync } from "node:fs"; +import { getConfigPath, loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../../config"; import { readRuntimePort } from "../../config/process-state"; import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits } from "../../codex/catalog"; import { getCodexHome } from "../../codex/paths"; @@ -183,11 +184,26 @@ function claudeStatus(config: ManagementContext["config"], configPath: string): }; } +/** + * The state the latest Codex toggle in this process reported, keyed by the intent it + * applied. Intent alone cannot describe an apply or restore that did not complete: the + * PUT reports `absent` for a skipped or failed enable and `unsafe` for an incomplete + * restore, and the next GET must not turn those into `current` or `absent`. It applies + * only while the persisted intent still matches; a restart re-runs startup convergence. + */ +let codexLastToggle: { desiredEnabled: boolean; state: NativeStatus["state"] } | null = null; + +function rememberCodexToggle(desiredEnabled: boolean, state: NativeStatus["state"]): NativeStatus["state"] { + codexLastToggle = { desiredEnabled, state }; + return state; +} + function codexStatus(config: ManagementContext["config"], configPath: string): NativeStatus { const desiredEnabled = config.clientIntegrations?.codex !== false; + const reported = codexLastToggle?.desiredEnabled === desiredEnabled ? codexLastToggle.state : null; return { clientId: "codex", - state: desiredEnabled ? "current" : "absent", + state: reported ?? (desiredEnabled ? "current" : "absent"), installed: true, configPath, desiredEnabled, @@ -356,7 +372,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { if (applied.status === "skipped") { return jsonResponse({ ok: true, clientId: "codex", changed: durable && persisted.status === "committed", - state: "absent", + state: rememberCodexToggle(true, "absent"), desiredEnabled: enabled, message: "Codex integration is OFF; enable did not change Codex.", reason: "apply_incomplete", @@ -364,7 +380,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { } return jsonResponse({ ok: true, clientId: "codex", changed: durable && persisted.status === "committed", - state: applied.ok ? "current" : "absent", + state: rememberCodexToggle(true, applied.ok ? "current" : "absent"), desiredEnabled: enabled, message: applied.ok ? "Codex now routes through opencodex" @@ -379,6 +395,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { if (durable && persisted.status === "unchanged") { const { classifyNativeRoutedResidue } = await import("../../codex/native-residue"); if (classifyNativeRoutedResidue().kind === "clean") { + rememberCodexToggle(false, "absent"); return jsonResponse({ ok: true, clientId: "codex", changed: false, state: "absent", desiredEnabled: false, message: "Codex integration is already OFF and native; no Codex files changed.", @@ -390,7 +407,7 @@ async function handleCodexToggle(ctx: ManagementContext): Promise { const restored = await restoreNativeCodexAsync({ revalidateDesiredState: true }); return jsonResponse({ ok: true, clientId: "codex", changed: durable && persisted.status === "committed", - state: restored.success ? "absent" : "unsafe", + state: rememberCodexToggle(false, restored.success ? "absent" : "unsafe"), desiredEnabled: enabled, message: restored.success ? `Codex restored to its native path; the proxy is still serving other clients. ${OCX_NATIVE_REPLAY_RECOVERY_NOTE}` @@ -777,14 +794,30 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { const { req, url, config, deps } = ctx; if (url.pathname === "/api/native-integrations" && req.method === "GET") { - const { getConfigPath } = await import("../../config"); const codexConfigPath = join(getCodexHome(), "config.toml"); + // The Codex, Grok and Claude Desktop toggles persist intent independently of the + // server's startup config snapshot. Read that intent once so the next dashboard + // refresh reflects a completed PUT; fall back to the snapshot if the file is unreadable. + const persisted = persistedIntentConfig(config); return jsonResponse({ - clients: [claudeStatus(config, getConfigPath()), grokStatus(config), codexStatus(config, codexConfigPath), desktopStatus(config)], + clients: [claudeStatus(config, getConfigPath()), grokStatus(persisted), codexStatus(persisted, codexConfigPath), desktopStatus(persisted)], } satisfies NativeStatusListEnvelope); } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index b7dcadf3191..02150cab03d 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -43,6 +43,8 @@ import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost, providerRedirectError } from "../../lib/provider-outbound"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; +import { fetchDevinUsableModels } from "../../adapters/devin/live-models"; +import { resolveDevinApiBaseUrl } from "../../oauth/devin/api-base"; import { fetchQoderModels } from "../../adapters/qoder/live-models"; import { resolveQoderProfile } from "../../adapters/qoder/profiles"; import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; @@ -1602,10 +1604,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise undefined) : undefined; - const apiKey = snapshot?.accessToken ?? await resolveModelsAuthToken(name, prov); + const apiKey = prov.authMode === "oauth" ? snapshot?.accessToken : await resolveModelsAuthToken(name, prov); if (prov.authMode === "oauth" && !apiKey) { return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" }); } @@ -1630,6 +1632,29 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise/bin/wsl//codex newest first, probes each through the isolated --version seam, and re-enumerates on every resolve so a Desktop update that replaces the hash directory is picked up (issue 5635). An explicitly set path that is unreadable or not a directory is an error, not a fallback: silently using a -different home than the operator named would write provider state where nobody is looking for it. +different home than the operator named would write provider state where nobody is looking for it. A fresh install can have that directory but no `config.toml` yet; applying the integration then creates an empty `config.toml` there (never overwriting an existing file) and continues, while a missing home directory is refused with instructions to start Codex once or set `CODEX_HOME` (issue 5422). The managed files are: ```text diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index f33ff9ac28c..7fcc2ebbab0 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -153,6 +153,8 @@ because they are a different plane. Upstream account response reads and OrcaRout The registered route set is larger than the areas described below; the code is the route SOT. What this document owns is which module holds which area and what invariant that area must not break. +`GET /api/native-integrations` reads the Codex, Grok and Claude Desktop desired switch states from persisted configuration because those toggles write intent independently of the server's startup config snapshot; every other field still comes from that snapshot, and without a config file the snapshot's own intent stands. The dashboard can therefore refresh a switch immediately after a successful toggle while its routing badge remains based on observed routing. The Codex row reports the state its latest toggle in this process reported while the persisted intent still matches it, so a skipped or failed enable stays `absent` and an incomplete restore stays `unsafe` instead of being re-derived from intent alone. + | Endpoint area | Responsibility | | --- | --- | | Config/settings | Read safe config/settings views; mutate supported settings only. Full `PUT /api/config` is disabled so masked secrets are not round-tripped. `PUT /api/settings` accepts `codexAutoStart`, `streamMode`, integer `appOwnedMemoryBudgetMb` (64..4096), strict boolean `codexAccountPickerEnabled`, strict boolean `fastRows`, and a validated per-account `codexQuotaAutoRefresh` toggle (each optional, at least one required). `fastRows` defaults on when absent: false is persisted, true deletes the key, and successful writes echo the effective boolean. An effective change converges the Codex catalog and refreshes enabled or already-owned client integrations after persistence. Picker enable initializes an empty UI-managed selector map, persists before one bounded catalog convergence, and reports only `catalogRefreshPending`; allocation/save failure restores every touched live field and skips convergence. Budget changes synchronously enforce the process-wide evictable retained-state cap; this is separate from RSS/native memory. `streamMode` persists the #314 stream-shape selection in config.json (Windows services need persisted input; macOS eager relay is explicit-only). | diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index abaf25a3e92..ba8ce6a555b 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -274,10 +274,10 @@ This stops partial weekly/Spark or credits-only refreshes from renewing obsolete 5h rows through the cache-wide `updatedAt` timestamp. Plan labels do not suppress real windows. The separately retained main-policy snapshot preserves omitted blocking short evidence even after -its reset clock passes. Credits-only, weekly-only, and metadata-only updates cannot remove an +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. Expired non-blocking short evidence is dropped, so it cannot take priority over a fresh blocking -weekly reading. +it. A validated long-primary 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 in `src/quota/reset-seen-store.ts`, with its original observation time. Detection compares only @@ -301,6 +301,19 @@ release the block. Policy validation precedes legacy clamping. Supplementary mon become the fallback governing window without a monthly-only plan or explicit primary-monthly evidence. Previously unobserved usage is unknown, not fabricated headroom. +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. +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; +the resulting weekly/monthly window still blocks at 99%. 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-quota-provenance.test.ts`, and +`tests/codex-integration/main-account-hard-lock-recovery.test.ts`. + The policy reads a separately retained identity-tagged quota snapshot, so the legacy rotation cache's six-hour expiry does not silently release a known block. A confirmed account transition invalidates old evidence. Request-owned bearers are matched only against a credential and effective diff --git a/structure/subagents.md b/structure/subagents.md index 345ec4a9681..ed58bb217dc 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -63,6 +63,13 @@ The override is applied as a final pass in both `buildCatalogEntries` (live `/v1 ensures `normalizeRoutedCatalogEntry` (which deletes `multi_agent_version` from routed entries) does not clobber the forced value. +A forced pass records each row's pre-override value once, in `opencodex_multi_agent_version_origin` +(a string pin, or null for none); repeated forced passes never replace it. Returning to `"default"` +consumes the record. Pristine baseline and native pins still win; the record only decides a native +row the baseline predates, which previously kept the forced stamp because an absent baseline entry +cannot tell a stale forced value from a genuine pin (issue 5636). Rows written before the record +existed keep that non-destructive read. + `getDefaultConfig()` (`src/config/proxy-env.ts`) writes `multiAgentMode: "v1"` explicitly, using the version constant from `src/config/multi-agent-surface.ts`, so v1 is the install default while a v2 native-to-routed child task is undeliverable ciphertext. The repair and salvage merges in diff --git a/tests/clients/aside-profile-paths.test.ts b/tests/clients/aside-profile-paths.test.ts index 355a7de50ac..fe03ea3e96e 100644 --- a/tests/clients/aside-profile-paths.test.ts +++ b/tests/clients/aside-profile-paths.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { - existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, renameSync, + existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -133,6 +133,54 @@ describe("Aside profile filesystem boundary", () => { expect(readFileSync(join(actual, ".aside", "u", "0", "models.json"), "utf8")).toBe("{}"); })); + test("accepts a relocated Aside root behind a top-level ~/.aside link (#5648)", () => fixture((home, root) => { + const relocated = join(home, "external-volume", ".aside"); + mkdirSync(join(home, "external-volume")); + renameSync(root, relocated); + directoryLink(relocated, root); + const profiles = listAsideProfiles({}, home); + const selected = profiles[0]!; + expect(selected.root).toBe(realpathSync.native(relocated)); + assertAsideProfileBoundary(selected, profiles, true); + guardAsideProfileIO(selected, ioFor(home).io, profiles).writeText(selected.configPath, "{}"); + expect(readFileSync(join(relocated, "u", "0", "models.json"), "utf8")).toBe("{}"); + })); + + for (const component of ["u", "account"] as const) { + test(`a relocated root still rejects a linked ${component} directory below it`, () => fixture((home, root) => { + const relocated = join(home, "external-volume", ".aside"); + mkdirSync(join(home, "external-volume")); + renameSync(root, relocated); + directoryLink(relocated, root); + const path = component === "u" ? join(relocated, "u") : join(relocated, "u", "0"); + const moved = join(home, `moved-${component}`); + renameSync(path, moved); + directoryLink(moved, path); + const profiles = listAsideProfiles({}, home); + expect(() => assertAsideProfileBoundary(profiles[0]!, profiles)).toThrow(ClientPathError); + })); + } + + test("a relocated root still rejects a linked model catalog", () => fixture((home, root) => { + const relocated = join(home, "external-volume", ".aside"); + mkdirSync(join(home, "external-volume")); + renameSync(root, relocated); + directoryLink(relocated, root); + const outside = join(home, "outside-models.json"); + writeFileSync(outside, "{}"); + symlinkSync(outside, join(relocated, "u", "0", "models.json"), "file"); + const profiles = listAsideProfiles({}, home); + expect(() => assertAsideProfileBoundary(profiles[0]!, profiles)).toThrow(ClientPathError); + })); + + test("a ~/.aside link to a regular file is still refused", () => fixture((home, root) => { + const file = join(home, "not-a-directory"); + writeFileSync(file, "x"); + rmSync(root, { recursive: true, force: true }); + symlinkSync(file, root, "file"); + expect(() => listAsideProfiles({}, home)).toThrow(ClientPathError); + })); + for (const component of ["root", "u", "account"] as const) { test(`rejects a linked ${component} directory`, () => fixture((home, root) => { const profiles = listAsideProfiles({}, home); diff --git a/tests/clients/client-connect.test.ts b/tests/clients/client-connect.test.ts index 75267847466..76ab94495a9 100644 --- a/tests/clients/client-connect.test.ts +++ b/tests/clients/client-connect.test.ts @@ -2,6 +2,7 @@ import { beforeAll, describe, expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; import { spawn, spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER } from "../../src/codex/subagent-defaults"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -404,7 +405,11 @@ function runTransactionScenario( defaultProvider: "openai", }; writeFileSync(configPath, `${JSON.stringify(originalConfig, null, 2)}\n`, "utf8"); - if (stage !== "preflight") writeFileSync(join(codexHome, "config.toml"), 'model_provider = "openai"\n', "utf8"); + // A missing config.toml is bootstrapped now (issue 5422), so the preflight fault is a + // deterministic injection refusal instead: ambiguous OpenCodex-managed sub-agent markers. + writeFileSync(join(codexHome, "config.toml"), stage === "preflight" + ? [MANAGED_AGENTS_TABLE_MARKER, "[agents]", MANAGED_SUBAGENT_DEFAULT_MARKER, "", 'default_subagent_model = "gpt-5.6-sol"', ""].join("\n") + : 'model_provider = "openai"\n', "utf8"); // A catalog the user already had. Connect overwrites it; disconnect has to put it back. if (stage === "prior-catalog") { writeFileSync(join(codexHome, "opencodex-catalog.json"), PRIOR_CATALOG_BYTES, "utf8"); diff --git a/tests/codex-integration/catalog-oauth-observation.test.ts b/tests/codex-integration/catalog-oauth-observation.test.ts index 97bf3eea3f5..f36ae1b2164 100644 --- a/tests/codex-integration/catalog-oauth-observation.test.ts +++ b/tests/codex-integration/catalog-oauth-observation.test.ts @@ -25,7 +25,7 @@ import { import { parseCatalogBuffer, setCachedCatalogForTests } from "../../src/adapters/devin/cloud-direct/catalog"; import { encodeMessage, encodeString } from "../../src/adapters/devin/cloud-direct/wire"; import { clearModelCache } from "../../src/codex/model-cache"; -import { getAuthRefreshIntentPath } from "../../src/oauth/store"; +import { getAuthRefreshIntentPath, saveCredential } from "../../src/oauth/store"; import { knownModelIdsForProvider } from "../../src/router"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -155,6 +155,40 @@ afterEach(() => { }); describe("catalog gather OAuth observation", () => { + test("refreshing Copilot gather binds the new bearer to the refreshed origin", async () => { + await saveCredential("github-copilot", { + access: "fixture-old-token", refresh: "fixture-refresh", expires: Date.now() - 1, + apiBaseUrl: "https://api.githubcopilot.com", + }); + const originalRefresh = OAUTH_PROVIDERS["github-copilot"]!.refresh; + let refreshCalls = 0; + OAUTH_PROVIDERS["github-copilot"]!.refresh = async () => { + refreshCalls += 1; + return { + access: "fixture-new-token", refresh: "fixture-refresh", expires: Date.now() + 3_600_000, + apiBaseUrl: "https://api.business.githubcopilot.com", + }; + }; + const calls: { url: string; authorization: string | null }[] = []; + try { + const rows = await gatherRoutedModels({ providers: { + "github-copilot": { + ...structuredClone(OAUTH_PROVIDERS["github-copilot"]!.providerConfig), + fetch: async (input, init) => { + calls.push({ url: String(input), authorization: new Headers(init?.headers).get("authorization") }); + return Response.json({ data: [{ id: "fixture-model" }] }); + }, + }, + } }); + + expect(refreshCalls).toBe(1); + expect(calls).toEqual([{ url: "https://api.business.githubcopilot.com/models", authorization: "Bearer fixture-new-token" }]); + expect(rows.map(row => row.id)).toContain("fixture-model"); + } finally { + OAUTH_PROVIDERS["github-copilot"]!.refresh = originalRefresh; + } + }); + test("expired active token stays typed and gather does not refresh or touch the auth store", async () => { const now = Date.now(); const authPath = join(opencodexHome, "auth.json"); diff --git a/tests/codex-integration/codex-app-server-processes.test.ts b/tests/codex-integration/codex-app-server-processes.test.ts index 8f170d2f1ea..e36f91c22e0 100644 --- a/tests/codex-integration/codex-app-server-processes.test.ts +++ b/tests/codex-integration/codex-app-server-processes.test.ts @@ -823,8 +823,8 @@ describe("CLI /api sync wiring for stale app-servers (#476)", () => { // write actually landed, never on a refused/failed serialization attempt. expect(syncCacheCase).toContain("withCatalogWriteSerialization"); // #1931: explicit sync-cache refreshes even when injection is OFF (side profiles). - expect(syncCacheCase).toContain("invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, { allowWhenDesiredDisabled: true })"); - const gate = 'if (invalidated.kind === "completed" && invalidated.value)'; + expect(syncCacheCase).toContain("invalidateCodexModelsCacheWithPermitOutcome(permit, owningCodexHome, { allowWhenDesiredDisabled: true })"); + const gate = 'if (invalidated.kind === "completed" && invalidated.value === "written")'; expect(syncCacheCase).toContain(gate); expect(syncCacheCase).toContain("handleRestartScopeAfterWrite"); expect(syncCacheCase.indexOf(gate)) diff --git a/tests/codex-integration/codex-composed-acceptance.test.ts b/tests/codex-integration/codex-composed-acceptance.test.ts index 8274ca9be2f..b383c7a35c4 100644 --- a/tests/codex-integration/codex-composed-acceptance.test.ts +++ b/tests/codex-integration/codex-composed-acceptance.test.ts @@ -551,11 +551,24 @@ describe("WP13 composed toggle acceptance", () => { expect(result.exitCode).toBe(0); expect(manifest(fx.codex)).toEqual(before); } - for (const argv of [["sync"], ["sync-cache"]]) { - const result = await fx.runCli(argv); - expect(result.exitCode).toBe(0); - expect(manifestWithoutCatalogArtifacts(manifest(fx.codex))).toEqual(manifestWithoutCatalogArtifacts(before)); - } + const synced = await fx.runCli(["sync"]); + expect(synced.exitCode).toBe(0); + expect(manifestWithoutCatalogArtifacts(manifest(fx.codex))).toEqual(manifestWithoutCatalogArtifacts(before)); + const unchangedCache = await fx.runCli(["sync-cache", "--json"]); + expect(unchangedCache.exitCode).toBe(0); + // An OFF sync may or may not leave a catalog behind; either way the explicit cache + // refresh is a benign skip, never a failure, and the envelope names which one. + const hasCatalog = existsSync(join(fx.codex, "opencodex-catalog.json")); + expect(JSON.parse(unchangedCache.stdout)).toMatchObject({ + ok: true, wrote: false, skipped: true, skippedReason: hasCatalog ? "unchanged" : "no_catalog", desiredDisabled: true, + }); + const unchangedHuman = await fx.runCli(["sync-cache"]); + expect(unchangedHuman.exitCode).toBe(0); + expect(unchangedHuman.stdout).toContain(hasCatalog + ? "Codex model cache is already current; nothing to sync." + : "No Codex catalog to derive a cache from; nothing to sync."); + expect(unchangedHuman.stdout).not.toContain("Codex integration is OFF"); + expect(manifestWithoutCatalogArtifacts(manifest(fx.codex))).toEqual(manifestWithoutCatalogArtifacts(before)); const sync = await fx.request(server.runtime, "/api/sync", { method: "POST" }); expect(sync.status).toBe(200); expect(sync.body).toMatchObject({ status: "skipped", skippedReason: "desired_disabled", ok: true }); diff --git a/tests/codex-integration/codex-gather-authority.test.ts b/tests/codex-integration/codex-gather-authority.test.ts index 5e8f287d019..bdf7c4d6104 100644 --- a/tests/codex-integration/codex-gather-authority.test.ts +++ b/tests/codex-integration/codex-gather-authority.test.ts @@ -1,10 +1,17 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { clearGatherRoutedModelsInflight, gatherRoutedModels, } from "../../src/codex/catalog"; +import { captureProviderGather } from "../../src/codex/catalog/gather-capture"; +import { buildModelsRequest, OAUTH_PROVIDERS } from "../../src/oauth"; +import { saveCredential } from "../../src/oauth/store"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; import { clearModelCache } from "../../src/codex/model-cache"; import { PROVIDER_REGISTRY, type ProviderModelDiscoverySpec } from "../../src/providers/registry"; import type { OcxConfig } from "../../src/types"; @@ -245,6 +252,142 @@ describe("catalog gather discovery-policy authority", () => { } }); + test("refreshing Copilot gathers for different accounts on the same host cannot share a flight", async () => { + const previous = { HOME: process.env.HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME, CODEX_HOME: process.env.CODEX_HOME }; + const root = mkdtempSync(join(tmpdir(), "ocx-copilot-gather-same-host-")); + process.env.HOME = join(root, "home"); + process.env.OPENCODEX_HOME = join(root, "opencodex"); + process.env.CODEX_HOME = join(root, "codex"); + const firstStarted = deferred(); + const firstResponse = deferred(); + const pending: Promise>>[] = []; + const calls: { url: string; authorization: string | null }[] = []; + const config: OcxConfig = { + modelCacheTtlMs: 0, + providers: { + "github-copilot": { + ...structuredClone(OAUTH_PROVIDERS["github-copilot"]!.providerConfig), + fetch: async (input, init) => { + const authorization = new Headers(init?.headers).get("authorization"); + calls.push({ url: String(input), authorization }); + if (calls.length === 1) { + firstStarted.resolve(); + await firstResponse.promise; + } + return Response.json({ data: [{ id: authorization === "Bearer fixture-account-a" ? "account-a-model" : "account-b-model" }] }); + }, + }, + }, + }; + try { + clearModelCache(); + clearGatherRoutedModelsInflight(); + await saveCredential("github-copilot", { + accountId: "account-a", access: "fixture-account-a", refresh: "fixture-refresh-a", + expires: Date.now() + 3_600_000, apiBaseUrl: "https://api.githubcopilot.com", + }); + pending.push(gatherRoutedModels(config)); + await firstStarted.promise; + + await saveCredential("github-copilot", { + accountId: "account-b", access: "fixture-account-b", refresh: "fixture-refresh-b", + expires: Date.now() + 3_600_000, apiBaseUrl: "https://api.githubcopilot.com", + }); + pending.push(gatherRoutedModels(config)); + await Bun.sleep(20); + firstResponse.resolve(); + const [first, second] = await Promise.all(pending); + + expect(calls.map(call => call.authorization)).toEqual(["Bearer fixture-account-a", "Bearer fixture-account-b"]); + expect(first!.map(model => model.id)).toEqual(["account-a-model"]); + expect(second!.map(model => model.id)).toEqual(["account-b-model"]); + } finally { + firstResponse.resolve(); + await Promise.allSettled(pending); + clearGatherRoutedModelsInflight(); + clearModelCache(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(root); + } + }); + + test("refreshing Copilot gathers on different account hosts cannot share a flight", async () => { + const previous = { HOME: process.env.HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME, CODEX_HOME: process.env.CODEX_HOME }; + const root = mkdtempSync(join(tmpdir(), "ocx-copilot-gather-authority-")); + process.env.HOME = join(root, "home"); + process.env.OPENCODEX_HOME = join(root, "opencodex"); + process.env.CODEX_HOME = join(root, "codex"); + const firstStarted = deferred(); + const firstResponse = deferred(); + const pending: Promise>>[] = []; + const calls: { url: string; authorization: string | null }[] = []; + const config: OcxConfig = { + modelCacheTtlMs: 0, + providers: { + "github-copilot": { + ...structuredClone(OAUTH_PROVIDERS["github-copilot"]!.providerConfig), + fetch: async (input, init) => { + const url = String(input); + calls.push({ url, authorization: new Headers(init?.headers).get("authorization") }); + if (calls.length === 1) { + firstStarted.resolve(); + await firstResponse.promise; + } + return Response.json({ data: [{ id: url === "https://api.githubcopilot.com/models" ? "account-a-model" : "account-b-model" }] }); + }, + }, + }, + }; + try { + clearModelCache(); + clearGatherRoutedModelsInflight(); + await saveCredential("github-copilot", { + accountId: "account-a", access: "fixture-account-a", refresh: "fixture-refresh-a", + expires: Date.now() + 3_600_000, apiBaseUrl: "https://api.githubcopilot.com", + }); + const provider = config.providers["github-copilot"]!; + const captureA = captureProviderGather("github-copilot", provider, { kind: "refreshing" }); + const devUrlA = buildModelsRequest(provider, undefined, "github-copilot").url; + pending.push(gatherRoutedModels(config)); + await firstStarted.promise; + + await saveCredential("github-copilot", { + accountId: "account-b", access: "fixture-account-b", refresh: "fixture-refresh-b", + expires: Date.now() + 3_600_000, apiBaseUrl: "https://api.business.githubcopilot.com", + }); + const captureB = captureProviderGather("github-copilot", provider, { kind: "refreshing" }); + const devUrlB = buildModelsRequest(provider, undefined, "github-copilot").url; + pending.push(gatherRoutedModels(config)); + await Bun.sleep(20); + firstResponse.resolve(); + const [first, second] = await Promise.all(pending); + + expect(calls).toEqual([ + { url: "https://api.githubcopilot.com/models", authorization: "Bearer fixture-account-a" }, + { url: "https://api.business.githubcopilot.com/models", authorization: "Bearer fixture-account-b" }, + ]); + expect(first!.map(model => model.id)).toEqual(["account-a-model"]); + expect(second!.map(model => model.id)).toEqual(["account-b-model"]); + expect(captureA.request.url).toBe(devUrlA); + expect(captureA.policy.finalUrl).toBe(devUrlA); + expect(captureB.request.url).toBe(devUrlB); + expect(captureB.policy.finalUrl).toBe(devUrlB); + } finally { + firstResponse.resolve(); + await Promise.allSettled(pending); + clearGatherRoutedModelsInflight(); + clearModelCache(); + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(root); + } + }); + /** * The general form of the same defect, found after credentials were fixed. * diff --git a/tests/codex-integration/codex-home-wsl.test.ts b/tests/codex-integration/codex-home-wsl.test.ts index c3cec855e85..623e3c5880e 100644 --- a/tests/codex-integration/codex-home-wsl.test.ts +++ b/tests/codex-integration/codex-home-wsl.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { wslAutomountRoot, listWslWindowsCodexHomes } from "../../src/codex/home"; +import { join } from "node:path"; +import { defaultCodexHome, wslAutomountRoot, listWslWindowsCodexHomes } from "../../src/codex/home"; import { isWindowsInteropDir } from "../../src/codex/shim"; import { currentServiceHomes, serviceCodexHomeMatchesInstall } from "../../src/service"; @@ -45,6 +46,69 @@ describe("wsl.conf automount root", () => { expect(homes).toEqual(["/win/c/Users/example/.codex"]); }); + test("defaultCodexHome keeps a fresh Linux home before config.toml exists", () => { + const usersRoot = ["/mnt/c", "Users"].join("/"); + // Native join: defaultCodexHome builds the local home with the host path module. + const linuxCodexHome = join("/home/example", ".codex"); + const windowsCodexHome = [usersRoot, "windows-user", ".codex"].join("/"); + + expect(defaultCodexHome({ + env: { WSL_DISTRO_NAME: "Ubuntu" }, + platform: "linux", + homedir: () => "/home/example", + usersRoot, + existsSync: (path: string) => path === usersRoot + || path === linuxCodexHome + || path === `${windowsCodexHome}/config.toml`, + readdirSync: () => ["windows-user"], + statSync: (() => ({ isDirectory: () => true })) as never, + realpathSync: (path: string) => path, + })).toBe(linuxCodexHome); + }); + + test("defaultCodexHome still discovers the Windows home when the local ~/.codex is a regular file", () => { + const usersRoot = ["/mnt/c", "Users"].join("/"); + // Native join: defaultCodexHome builds the local home with the host path module. + const linuxCodexHome = join("/home/example", ".codex"); + const windowsCodexHome = [usersRoot, "windows-user", ".codex"].join("/"); + + expect(defaultCodexHome({ + env: { WSL_DISTRO_NAME: "Ubuntu" }, + platform: "linux", + homedir: () => "/home/example", + usersRoot, + existsSync: (path: string) => path === usersRoot + || path === linuxCodexHome + || path === `${windowsCodexHome}/config.toml`, + readdirSync: () => ["windows-user"], + statSync: ((path: string) => ({ isDirectory: () => path !== linuxCodexHome })) as never, + realpathSync: (path: string) => path, + })).toBe(windowsCodexHome); + }); + + test("defaultCodexHome keeps an unreadable local home rather than switching homes", () => { + const usersRoot = ["/mnt/c", "Users"].join("/"); + // Native join: defaultCodexHome builds the local home with the host path module. + const linuxCodexHome = join("/home/example", ".codex"); + const windowsCodexHome = [usersRoot, "windows-user", ".codex"].join("/"); + + expect(defaultCodexHome({ + env: { WSL_DISTRO_NAME: "Ubuntu" }, + platform: "linux", + homedir: () => "/home/example", + usersRoot, + existsSync: (path: string) => path === usersRoot + || path === linuxCodexHome + || path === `${windowsCodexHome}/config.toml`, + readdirSync: () => ["windows-user"], + statSync: ((path: string) => { + if (path === linuxCodexHome) throw Object.assign(new Error("denied"), { code: "EACCES" }); + return { isDirectory: () => true }; + }) as never, + realpathSync: (path: string) => path, + })).toBe(linuxCodexHome); + }); + test("service ownership uses the same discovered Windows Codex home as the runtime", () => { const usersRoot = ["/mnt/c", "Users"].join("/"); const windowsCodexHome = [usersRoot, "windows-user", ".codex"].join("/"); @@ -56,7 +120,10 @@ describe("wsl.conf automount root", () => { existsSync: (path: string) => path === usersRoot || path === `${windowsCodexHome}/config.toml`, readdirSync: () => ["windows-user"], - statSync: (() => ({ isDirectory: () => true })) as never, + statSync: ((path: string) => { + if (path === join("/home/example", ".codex")) throw Object.assign(new Error("absent"), { code: "ENOENT" }); + return { isDirectory: () => true }; + }) as never, realpathSync: (path: string) => path, }); @@ -75,7 +142,10 @@ describe("wsl.conf automount root", () => { existsSync: (path: string) => path === usersRoot || path === `${windowsCodexHome}/config.toml`, readdirSync: () => ["windows-user"], - statSync: (() => ({ isDirectory: () => true })) as never, + statSync: ((path: string) => { + if (path === join("/home/example", ".codex")) throw Object.assign(new Error("absent"), { code: "ENOENT" }); + return { isDirectory: () => true }; + }) as never, realpathSync: (path: string) => path, }; diff --git a/tests/codex-integration/codex-inject-missing-config.test.ts b/tests/codex-integration/codex-inject-missing-config.test.ts new file mode 100644 index 00000000000..68b236881d3 --- /dev/null +++ b/tests/codex-integration/codex-inject-missing-config.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, expect, setDefaultTimeout, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoRoot as resolveRepoRoot } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; + +/** + * Issue 5422: a fresh Codex install can have its home but no config.toml yet (Codex writes it + * lazily; an authless Desktop user may never get one). Injection must bootstrap the file in the + * resolved home instead of reporting "Is Codex installed?", never overwrite an existing file, + * write nothing during a validate-only preflight, and still refuse when the home itself is + * missing. CODEX_HOME is resolved at import, so each case runs in its own process. + */ +const repoRoot = resolveRepoRoot(); +let root: string; + +setDefaultTimeout(SPAWN_BUDGET_MS); + +beforeEach(() => { + root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-inject-missing-config-"))); +}); + +afterEach(() => { + removeTreeWithRetry(root); +}); + +function runInject(env: Record, validateOnly = false): { result: { success: boolean; message: string }; stderr: string } { + const script = ` + const { injectCodexConfig } = require("./src/codex/inject"); + injectCodexConfig(10100, {}, ${validateOnly ? "{ validateOnly: true }" : "{}"}).then(result => { + console.log(JSON.stringify({ success: result.success, message: result.message })); + }); + `; + const spawned = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, CODEX_SQLITE_HOME: "", ...env }, + encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, + }); + const lines = (spawned.stdout ?? "").trim().split("\n"); + return { result: JSON.parse(lines[lines.length - 1] ?? "{}"), stderr: spawned.stderr ?? "" }; +} + +test("a Codex home without config.toml is bootstrapped and routed", () => { + const codexHome = join(root, "codex"); + mkdirSync(codexHome); + const { result, stderr } = runInject({ CODEX_HOME: codexHome, OPENCODEX_HOME: join(root, "ocx") }); + expect(result.success, stderr).toBe(true); + const config = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(config).toContain("opencodex"); +}); + +test("a validate-only preflight passes without creating config.toml", () => { + const codexHome = join(root, "codex"); + mkdirSync(codexHome); + const { result, stderr } = runInject({ CODEX_HOME: codexHome, OPENCODEX_HOME: join(root, "ocx") }, true); + expect(result.success, stderr).toBe(true); + expect(existsSync(join(codexHome, "config.toml"))).toBe(false); +}); + +test("a missing default Codex home is refused with an actionable message and nothing is created", () => { + const home = join(root, "home"); + mkdirSync(home); + const { result } = runInject({ + CODEX_HOME: "", HOME: home, USERPROFILE: home, OPENCODEX_HOME: join(root, "ocx"), + }); + expect(result.success).toBe(false); + expect(result.message).toContain("does not exist yet"); + expect(result.message).toContain("CODEX_HOME"); + expect(existsSync(join(home, ".codex"))).toBe(false); +}); + +test("a failure inside the write boundary after bootstrap rolls config.toml back to absent", () => { + const codexHome = join(root, "codex"); + mkdirSync(codexHome); + const script = ` + const inject = require("./src/codex/inject"); + inject.setBeforeHistoryArtifactCommitForTests(() => { throw new Error("fixture failure after bootstrap"); }); + inject.injectCodexConfig(10100, {}, {}).then( + result => console.log(JSON.stringify({ settled: "result", success: result.success })), + error => console.log(JSON.stringify({ settled: "error", message: String(error && error.message) })), + ); + `; + const spawned = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, CODEX_SQLITE_HOME: "", CODEX_HOME: codexHome, OPENCODEX_HOME: join(root, "ocx") }, + encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, + }); + const lines = (spawned.stdout ?? "").trim().split("\n"); + const outcome = JSON.parse(lines[lines.length - 1] ?? "{}") as { settled?: string; success?: boolean }; + expect(outcome.settled === "error" || outcome.success === false, spawned.stderr).toBe(true); + expect(existsSync(join(codexHome, "config.toml"))).toBe(false); +}); diff --git a/tests/codex-integration/codex-models-cache-invalidate.test.ts b/tests/codex-integration/codex-models-cache-invalidate.test.ts index b644740e40e..c8264d50d0c 100644 --- a/tests/codex-integration/codex-models-cache-invalidate.test.ts +++ b/tests/codex-integration/codex-models-cache-invalidate.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from "node:os"; import { join } from "node:path"; import { invalidateCodexModelsCache } from "../../src/codex/catalog"; -import { invalidateCodexModelsCacheWithPermit } from "../../src/codex/catalog/sync"; +import { invalidateCodexModelsCacheWithPermit, invalidateCodexModelsCacheWithPermitOutcome } from "../../src/codex/catalog/sync"; import { withCatalogWriteSerialization } from "../../src/codex/catalog-write-serialization"; import { collectCodexAppServerCatalogStateForRequest, @@ -64,6 +64,23 @@ describe("invalidateCodexModelsCache write gate (#476 / #518)", () => { expect(cache.models).toEqual([{ slug: "gpt-5.5" }]); }); + test("distinguishes an unchanged cache from a failed refresh", () => { + writeFileSync(join(codexHome, "opencodex-catalog.json"), JSON.stringify({ + models: [{ slug: "gpt-5.5" }], + }, null, 2) + "\n"); + const invalidate = () => withCatalogWriteSerialization(codexHome, permit => + invalidateCodexModelsCacheWithPermitOutcome(permit, codexHome)); + + expect(invalidate()).toMatchObject({ kind: "completed", value: "written" }); + const cachePath = join(codexHome, "models_cache.json"); + const before = readFileSync(cachePath); + expect(invalidate()).toMatchObject({ kind: "completed", value: "unchanged" }); + expect(readFileSync(cachePath)).toEqual(before); + + writeFileSync(join(codexHome, "opencodex-catalog.json"), "{ not-json"); + expect(invalidate()).toMatchObject({ kind: "completed", value: "failed" }); + }); + test("permit-bound invalidation stays on its owning home after ambient drift", () => { const ambientCodexHome = mkdtempSync(join(tmpdir(), "ocx-invalidate-ambient-")); try { diff --git a/tests/codex-integration/codex-runtime-wsl-desktop.test.ts b/tests/codex-integration/codex-runtime-wsl-desktop.test.ts new file mode 100644 index 00000000000..b00d2c73ecd --- /dev/null +++ b/tests/codex-integration/codex-runtime-wsl-desktop.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveCodexHomeDir } from "../../src/codex/home"; +import { persistCodexRuntime, resolveCodexRuntime, type RuntimeExecFile } from "../../src/codex/runtime"; + +/** + * Issue 5635: Windows Codex Desktop in WSL app-server mode ships its Linux Codex binary + * under the effective Codex home as bin/wsl//codex. The Ubuntu service PATH + * has no codex, so discovery must find that binary without outranking an operator pin or + * PATH, and must rediscover it after a Desktop update replaces the hash directory. + */ +const NO_CODEX_PATH = "/usr/bin:/bin"; +const CODEX_HOME = "/mnt/c/Users/example/.codex"; +// Resolved exactly as discovery resolves it, so the fixture holds on a Windows runner too. +const WSL_ROOT = join(resolveCodexHomeDir({ env: { CODEX_HOME } }), "bin", "wsl"); + +function tempConfigDir(): string { + return mkdtempSync(join(tmpdir(), "ocx-runtime-wsl-")); +} + +function wslFs(hashes: Record) { + return { + readdirSync: (path: string) => path === WSL_ROOT ? Object.keys(hashes) : [], + statSync: (path: string) => { + for (const [hash, mtimeMs] of Object.entries(hashes)) { + if (path === join(WSL_ROOT, hash)) return { mtimeMs, isDirectory: () => true }; + } + return { mtimeMs: 0, isDirectory: () => false }; + }, + }; +} + +function versions(map: Record): RuntimeExecFile { + return file => { + const version = map[String(file)]; + if (!version) throw new Error("not found"); + return version; + }; +} + +describe("WSL Desktop runtime discovery (#5635)", () => { + test("finds the Desktop-bundled Linux binary when the service PATH has no codex", () => { + const binary = join(WSL_ROOT, "hash-a", "codex"); + const result = resolveCodexRuntime({ + configDir: tempConfigDir(), + env: { CODEX_HOME, HOME: "/home/example", PATH: NO_CODEX_PATH }, + platform: "linux", + existsSync: path => path === binary, + ...wslFs({ "hash-a": 1_000 }), + execFileSync: versions({ [binary]: "codex-cli 0.155.0-alpha.16" }), + discoverAlternatives: false, + }); + expect(result.runtime).toEqual({ command: binary, version: "0.155.0-alpha.16", source: "installed" }); + }); + + test("rediscovers the binary after a Desktop update replaces the hash directory", () => { + const configDir = tempConfigDir(); + const oldBinary = join(WSL_ROOT, "hash-old", "codex"); + const newBinary = join(WSL_ROOT, "hash-new", "codex"); + persistCodexRuntime({ command: oldBinary, version: "0.154.0", source: "installed" }, { configDir }, "discovered"); + const result = resolveCodexRuntime({ + configDir, + env: { CODEX_HOME, HOME: "/home/example", PATH: NO_CODEX_PATH }, + platform: "linux", + existsSync: path => path === newBinary, + ...wslFs({ "hash-new": 2_000 }), + execFileSync: versions({ [newBinary]: "codex-cli 0.155.0" }), + discoverAlternatives: false, + }); + expect(result.runtime.command).toBe(newBinary); + expect(result.replacedConfigured?.from.command).toBe(oldBinary); + }); + + test("prefers the newest hash directory when several are present", () => { + const older = join(WSL_ROOT, "hash-older", "codex"); + const newer = join(WSL_ROOT, "hash-newer", "codex"); + const probed: string[] = []; + const result = resolveCodexRuntime({ + configDir: tempConfigDir(), + env: { CODEX_HOME, HOME: "/home/example", PATH: NO_CODEX_PATH }, + platform: "linux", + existsSync: path => path === older || path === newer, + ...wslFs({ "hash-older": 1_000, "hash-newer": 2_000 }), + execFileSync: file => { + probed.push(String(file)); + return "codex-cli 0.155.0"; + }, + discoverAlternatives: false, + }); + expect(result.runtime.command).toBe(newer); + expect(probed).not.toContain(older); + }); + + test("an explicit operator pin still wins over a valid Desktop binary", () => { + const configDir = tempConfigDir(); + const pinned = "/opt/codex/bin/codex"; + const binary = join(WSL_ROOT, "hash-a", "codex"); + persistCodexRuntime({ command: pinned, version: "0.150.0", source: "configured" }, { configDir }, "pinned"); + const result = resolveCodexRuntime({ + configDir, + env: { CODEX_HOME, HOME: "/home/example", PATH: NO_CODEX_PATH }, + platform: "linux", + existsSync: path => path === pinned || path === binary, + ...wslFs({ "hash-a": 1_000 }), + execFileSync: versions({ [pinned]: "codex-cli 0.150.0", [binary]: "codex-cli 0.155.0" }), + }); + expect(result.runtime.command).toBe(pinned); + }); + + test("a codex on PATH still ranks ahead of the Desktop binary", () => { + const pathDir = "/usr/local/sbin"; + const onPath = join(pathDir, "codex"); + const binary = join(WSL_ROOT, "hash-a", "codex"); + const result = resolveCodexRuntime({ + configDir: tempConfigDir(), + env: { CODEX_HOME, HOME: "/home/example", PATH: pathDir }, + platform: "linux", + existsSync: path => path === onPath || path === binary, + ...wslFs({ "hash-a": 1_000 }), + execFileSync: versions({ [onPath]: "codex-cli 0.150.0", [binary]: "codex-cli 0.155.0" }), + discoverAlternatives: false, + }); + expect(result.runtime).toMatchObject({ command: onPath, source: "path" }); + }); + + test("an unreadable bin/wsl directory degrades to the ordinary fallback", () => { + const result = resolveCodexRuntime({ + configDir: tempConfigDir(), + env: { CODEX_HOME, HOME: "/home/example", PATH: NO_CODEX_PATH }, + platform: "linux", + existsSync: () => false, + readdirSync: () => { + throw Object.assign(new Error("denied"), { code: "EACCES" }); + }, + statSync: () => ({ mtimeMs: 0, isDirectory: () => false }), + execFileSync: () => { + throw new Error("not found"); + }, + discoverAlternatives: false, + }); + expect(result.runtime).toEqual({ command: "codex", version: null, source: "fallback" }); + }); + + test("macOS does not enumerate a WSL layout", () => { + const listed: string[] = []; + resolveCodexRuntime({ + configDir: tempConfigDir(), + env: { CODEX_HOME, HOME: "/Users/example", PATH: NO_CODEX_PATH }, + platform: "darwin", + existsSync: () => false, + readdirSync: path => { + listed.push(path); + return []; + }, + statSync: () => ({ mtimeMs: 0, isDirectory: () => false }), + execFileSync: () => { + throw new Error("not found"); + }, + }); + expect(listed).not.toContain(WSL_ROOT); + }); +}); 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 f0a803ef764..5517374e4de 100644 --- a/tests/codex-integration/main-account-hard-lock-recovery.test.ts +++ b/tests/codex-integration/main-account-hard-lock-recovery.test.ts @@ -30,10 +30,12 @@ let previousHome: string | undefined; let previousCodexHome: string | undefined; let previousFetch: typeof fetch; +/** Build the minimal proxy configuration with main-account hard-lock recovery enabled. */ function config(): OcxConfig { return { port: 10100, defaultProvider: "openai", providers: {}, codexMainAccountHardLock: true }; } +/** Encode synthetic account and expiry claims for the fixture; this is not a signed credential. */ function bearer(expired = false): string { const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + (expired ? -120 : 86_400), @@ -42,6 +44,7 @@ function bearer(expired = false): string { return `header.${payload}.signature`; } +/** Write fixture credentials into the isolated home and reconcile the active main identity. */ function writeMain(expired = false): void { writeFileSync(join(home, "auth.json"), JSON.stringify({ tokens: { access_token: bearer(expired), refresh_token: "fixture-refresh", account_id: accountId, @@ -49,6 +52,7 @@ function writeMain(expired = false): void { reconcileMainCodexAccountRuntimeState(); } +/** Seed a 99% short-window block for the observed fixture identity, even though its reset elapsed. */ function block(): void { const writer = captureMainQuotaWriter(accountId); if (!writer) throw new Error("Fixture identity must be observed"); @@ -61,6 +65,10 @@ function usage(percent = 0): Response { } }); } +/** + * Stub recovery HTTP calls, requiring a known metadata/token URL and an active native-main drain. + * Return the captured URL list so tests can verify the requests made by background recovery. + */ function fetchWith(handler: (url: string, init?: RequestInit) => Promise) { const calls: string[] = []; globalThis.fetch = Object.assign(async (input: Parameters[0], init?: RequestInit) => { @@ -123,6 +131,18 @@ afterEach(async () => { }); 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, + } })); + await runMainAccountHardLockRecovery(config()); + expect(calls).toEqual([whamUrl]); + expect(getMainAccountHardLockStatus(config())).toEqual({ enabled: true, state: "ready" }); + expect(getMainPolicyQuota()?.shortPercent).toBeUndefined(); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(35); + expect(getNativeMainProfileRequestCount()).toBe(0); + }); + test("existing sweep hook forces fresh WHAM past cache/reset without adding a timer", async () => { let percent = 99; const calls = fetchWith(async () => usage(percent)); diff --git a/tests/codex-integration/main-quota-evidence-validation.test.ts b/tests/codex-integration/main-quota-evidence-validation.test.ts index 1ed42205fed..6a45ba56c82 100644 --- a/tests/codex-integration/main-quota-evidence-validation.test.ts +++ b/tests/codex-integration/main-quota-evidence-validation.test.ts @@ -6,7 +6,7 @@ import { MAIN_CODEX_ACCOUNT_ID as MAIN } from "../../src/codex/account-id"; import { getMainAccountHardLockStatus } from "../../src/codex/main-account-hard-lock"; import { captureMainQuotaWriter, clearMainAccountInfoCache, observeMainQuotaIdentity } from "../../src/codex/main-account-cache"; import { - clearAccountQuota, getAccountQuota, getMainPolicyQuota, parseMainPolicyUsageQuota, + applyAccountQuotaFromUpstreamHeaders, clearAccountQuota, getAccountQuota, getMainPolicyQuota, parseMainPolicyUsageQuota, parseUsageQuota, setAccountQuotaFromParsed, updateAccountQuota, type WhamUsageResponse, } from "../../src/codex/quota"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -30,6 +30,7 @@ afterEach(() => { removeTreeWithRetry(home); }); +/** Observe a synthetic main identity and capture the live writer used to publish its fixture quota. */ function writerFor(accountId = "fixture-main-a") { observeMainQuotaIdentity(accountId); const writer = captureMainQuotaWriter(accountId); @@ -144,6 +145,143 @@ describe("raw policy evidence validation", () => { }); }); +describe("main policy window replacement", () => { + const cfg = { codexMainAccountHardLock: true }; + const weeklySeconds = 7 * 24 * 60 * 60; + const monthlySeconds = 30 * 24 * 60 * 60; + + /** Publish the same fixture through display normalization and strict policy validation. */ + function publish(data: WhamUsageResponse) { + setAccountQuotaFromParsed(MAIN, parseUsageQuota(data), undefined, writerFor(), parseMainPolicyUsageQuota(data)); + } + + /** Hydrate a sixteen-day-old short-window block and assert the replacement test's initial state. */ + function retainedShort() { + const old = Date.now() - 16 * 24 * 60 * 60_000; + writeColdPolicy({ shortPercent: 100, shortWindowSeconds: 18_000, + shortObservedAt: old, shortResetAt: old / 1000 + 300, weeklyPercent: 35 }); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + } + + test.each([86_399, 86_400, 86_401])("primary duration %s follows the exact 24h parser boundary", seconds => { + retainedShort(); + const data = { rate_limit: { + primary_window: { used_percent: 20, limit_window_seconds: seconds }, secondary_window: null, tertiary_window: null, + } }; + const parsed = parseMainPolicyUsageQuota(data); + expect(parsed?.shortWindowAbsent).toBe(seconds >= 86_400 ? true : undefined); + publish(data); + expect(getMainPolicyQuota()?.shortPercent).toBe(seconds < 86_400 ? 20 : undefined); + expect(getMainPolicyQuota()?.weeklyPercent).toBe(seconds < 86_400 ? 35 : 20); + expect(getMainAccountHardLockStatus(cfg).state).toBe("ready"); + }); + + for (const [field, seconds] of [["weeklyPercent", weeklySeconds], ["monthlyPercent", monthlySeconds]] as const) { + test.each([0, 35, 98.99, 99, 100])(`fresh ${field}=%s replaces a retired persisted short window`, percent => { + retainedShort(); + publish({ rate_limit: { + primary_window: { used_percent: percent, limit_window_seconds: seconds }, secondary_window: null, tertiary_window: null, + } }); + const policy = getMainPolicyQuota(); + expect(policy?.[field]).toBe(percent); + for (const key of ["shortPercent", "shortResetAt", "shortObservedAt", "shortWindowSeconds"] as const) { + expect(policy?.[key]).toBeUndefined(); + } + // Replacement proof is per-observation, never a persisted permission to drop future evidence. + expect(policy).not.toHaveProperty("shortWindowAbsent"); + expect(getMainAccountHardLockStatus(cfg).state).toBe(percent < 99 ? "ready" : "blocked"); + publish({ rate_limit: { primary_window: { used_percent: 99, limit_window_seconds: 18_000 } } }); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + }); + } + + test.each([ + { primary_window: { used_percent: 35 } }, + { primary_window: { limit_window_seconds: weeklySeconds }, secondary_window: null, tertiary_window: null }, + { primary_window: { used_percent: -1, limit_window_seconds: weeklySeconds }, secondary_window: null, tertiary_window: null }, + { primary_window: { used_percent: 101, limit_window_seconds: weeklySeconds }, secondary_window: null, tertiary_window: null }, + { primary_window: { used_percent: 35, limit_window_seconds: weeklySeconds }, secondary_window: {}, tertiary_window: null }, + { primary_window: { used_percent: 35, limit_window_seconds: weeklySeconds }, + secondary_window: { used_percent: 99, limit_window_seconds: 18_000 }, tertiary_window: null }, + { primary_window: { used_percent: 35, limit_window_seconds: weeklySeconds }, secondary_window: null, + tertiary_window: { used_percent: 99, limit_window_seconds: 18_000 } }, + ])("partial, invalid or short-bearing metadata retains the old block: %j", rate_limit => { + retainedShort(); + publish({ rate_limit }); + expect(getMainPolicyQuota()?.shortPercent).toBe(100); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + }); + + test("a declared long secondary cannot hide the current weekly limit", () => { + retainedShort(); + publish({ rate_limit: { + primary_window: { used_percent: 35, limit_window_seconds: monthlySeconds }, + secondary_window: { used_percent: 99, limit_window_seconds: weeklySeconds }, tertiary_window: null, + } }); + expect(getMainPolicyQuota()?.shortPercent).toBeUndefined(); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + }); + + test.each([ + {}, + { secondary_window: null }, + { tertiary_window: null }, + { secondary_window: { used_percent: 20, limit_window_seconds: weeklySeconds } }, + { tertiary_window: { used_percent: 20, limit_window_seconds: monthlySeconds } }, + ])("omitted secondary or tertiary windows cannot retire a short block: %j", windows => { + retainedShort(); + const data = { rate_limit: { + primary_window: { used_percent: 35, limit_window_seconds: weeklySeconds }, ...windows, + } }; + expect(parseMainPolicyUsageQuota(data)?.shortWindowAbsent).toBeUndefined(); + publish(data); + expect(getMainPolicyQuota()?.shortPercent).toBe(100); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + }); + + test("an explicit null secondary and long tertiary permit replacement", () => { + retainedShort(); + publish({ rate_limit: { + primary_window: { used_percent: 35, limit_window_seconds: weeklySeconds }, secondary_window: null, + tertiary_window: { used_percent: 20, limit_window_seconds: monthlySeconds }, + } }); + expect(getMainPolicyQuota()?.shortPercent).toBeUndefined(); + expect(getMainAccountHardLockStatus(cfg).state).toBe("ready"); + }); + + test.each([ + { secondary_window: { limit_window_seconds: weeklySeconds }, tertiary_window: null }, + { secondary_window: null, tertiary_window: { limit_window_seconds: monthlySeconds } }, + ])("a long auxiliary window without used_percent cannot retire a short block: %j", windows => { + retainedShort(); + const data = { rate_limit: { + primary_window: { used_percent: 35, limit_window_seconds: monthlySeconds }, ...windows, + } }; + expect(parseMainPolicyUsageQuota(data)?.shortWindowAbsent).toBeUndefined(); + publish(data); + expect(getMainPolicyQuota()?.shortPercent).toBe(100); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + }); + + test("long-window response headers alone do not retire a known short block", () => { + retainedShort(); + applyAccountQuotaFromUpstreamHeaders(MAIN, new Headers({ + "x-codex-primary-used-percent": "35", "x-codex-primary-window-minutes": "10080", + }), undefined, writerFor()); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + }); + + test("a superseded identity cannot retire the current account's short block", () => { + const staleWriter = writerFor("fixture-main-b"); + retainedShort(); + const data = { rate_limit: { + primary_window: { used_percent: 35, limit_window_seconds: weeklySeconds }, secondary_window: null, tertiary_window: null, + } }; + setAccountQuotaFromParsed(MAIN, parseUsageQuota(data), undefined, staleWriter, parseMainPolicyUsageQuota(data)); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); + }); +}); + describe("cold partial writers hydrate only the surviving legacy cache", () => { for (const writerKind of ["parsed", "legacy"] as const) { for (const expired of [false, true]) { diff --git a/tests/codex-integration/main-quota-provenance.test.ts b/tests/codex-integration/main-quota-provenance.test.ts index c6a26280a09..e132854b38f 100644 --- a/tests/codex-integration/main-quota-provenance.test.ts +++ b/tests/codex-integration/main-quota-provenance.test.ts @@ -28,10 +28,12 @@ import { getAccountQuota, getMainPolicyQuota, listAccountQuotas, + parseMainPolicyUsageQuota, parseUsageQuota, setAccountQuotaFromParsed, updateAccountQuota, type StoredAccountQuota, + type WhamUsageResponse, } from "../../src/codex/quota"; import { COLD_SPAWN_WARMUP_HOOK_BUDGET_MS, warmModuleGraph } from "../helpers/cold-spawn-warmup"; import { repoPath, repoRoot } from "../helpers/repo-root"; @@ -49,8 +51,10 @@ let previousCodexHome: string | undefined; let pendingPersist: { run: () => void; timer: ReturnType } | undefined; let timerSpy: ReturnType; -// Exercise the real debounced serializer deterministically, without sleeping or exporting -// a production flush hook. Only quota's 250ms timeout is captured; all others stay native. +/** + * Capture quota's 250ms persistence callback for explicit flushing; leave other timers native. + * Return the timer spy so teardown restores scheduling after exercising the real serializer. + */ function installPersistenceClock() { const nativeSetTimeout = globalThis.setTimeout; return spyOn(globalThis, "setTimeout").mockImplementation((( @@ -63,6 +67,7 @@ function installPersistenceClock() { }) as typeof setTimeout); } +/** Run the captured quota persistence callback and read its actual disk snapshot without a sleep. */ function flushPersistence(): string { if (!pendingPersist) throw new Error("Expected a scheduled quota persistence"); const pending = pendingPersist; @@ -72,6 +77,7 @@ function flushPersistence(): string { return readFileSync(join(testDir, "codex-quota-cache.json"), "utf8"); } +/** Bind a synthetic main identity and return its current generation-scoped quota writer. */ function writerFor(accountId = "fixture-main-a"): MainQuotaWriter { observeMainQuotaIdentity(accountId); const writer = captureMainQuotaWriter(accountId); @@ -297,6 +303,31 @@ describe("main policy quota writes", () => { }); }); +test("window replacement persists without carrying its proof into later partial updates", () => { + const cfg = { codexMainAccountHardLock: true }; + const writer = writerFor(); + /** Publish both parsed projections with the captured writer throughout the simulated restart. */ + const publish = (data: WhamUsageResponse) => setAccountQuotaFromParsed( + MAIN, parseUsageQuota(data), undefined, writer, parseMainPolicyUsageQuota(data), + ); + 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, + } }); + // Execute quota's actual debounced serializer through the existing deterministic clock. + const persisted = flushPersistence(); + expect(JSON.parse(persisted).mainPolicyQuota.quota.weeklyPercent).toBe(35); + expect(persisted).not.toContain("shortWindowAbsent"); + clearAccountQuota(); + writeFileSync(join(testDir, "codex-quota-cache.json"), persisted); + expect(getMainAccountHardLockStatus(cfg).state).toBe("ready"); + expect(getMainPolicyQuota()?.shortPercent).toBeUndefined(); + publish({ rate_limit: { primary_window: { used_percent: 99, limit_window_seconds: 18_000 } } }); + publish({ rate_limit: { primary_window: { used_percent: 0 } } }); + expect(getMainAccountHardLockStatus(cfg).state).toBe("blocked"); +}); + describe("main policy quota durability and lifecycle", () => { // The first loop iteration is this graph's cold child; warm quota provenance imports before its // spawn timeout starts measuring the restart behavior. diff --git a/tests/codex-integration/multi-agent-origin.test.ts b/tests/codex-integration/multi-agent-origin.test.ts new file mode 100644 index 00000000000..58444dd87e1 --- /dev/null +++ b/tests/codex-integration/multi-agent-origin.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { applyMultiAgentMode, MULTI_AGENT_ORIGIN_FIELD, type RawEntry } from "../../src/codex/catalog/parsing"; + +/** + * Issue 5636: returning from a forced multi-agent mode to default left newer native rows + * pinned to the forced value when the pristine baseline predated them. The forced pass now + * records the row's original value, and default mode restores it only where the baseline and + * native metadata say nothing about the row. + */ +const OLD_BASELINE: ReadonlyMap = new Map([["gpt-5.4", "v2"]]); + +/** Serialize and reload rows the way a retained catalog file does between passes. */ +function roundTrip(entries: RawEntry[]): RawEntry[] { + return JSON.parse(JSON.stringify(entries)) as RawEntry[]; +} + +describe("multi-agent mode provenance (#5636)", () => { + test("v1 then default restores a newer native row's original v2 pin across a reload", () => { + const forced = roundTrip(applyMultiAgentMode([{ slug: "gpt-6-luna", multi_agent_version: "v2" }], "v1")); + expect(forced[0]!.multi_agent_version).toBe("v1"); + const restored = applyMultiAgentMode(forced, "default", false, { nativeDefaults: OLD_BASELINE }); + expect(restored[0]!.multi_agent_version).toBe("v2"); + expect(restored[0]).not.toHaveProperty(MULTI_AGENT_ORIGIN_FIELD); + }); + + test("repeated forced modes keep the first recorded origin", () => { + let rows: RawEntry[] = [{ slug: "gpt-6-astra", multi_agent_version: "v2" }]; + rows = roundTrip(applyMultiAgentMode(rows, "v1")); + rows = roundTrip(applyMultiAgentMode(rows, "v2")); + rows = roundTrip(applyMultiAgentMode(rows, "v1")); + const restored = applyMultiAgentMode(rows, "default", false, { nativeDefaults: OLD_BASELINE }); + expect(restored[0]!.multi_agent_version).toBe("v2"); + }); + + test("an originally unpinned row returns to unpinned, or v2 when the native feature is on", () => { + const forcedOff = roundTrip(applyMultiAgentMode([{ slug: "gpt-6-luna" }], "v1")); + expect(applyMultiAgentMode(forcedOff, "default", false, { nativeDefaults: OLD_BASELINE })[0]) + .not.toHaveProperty("multi_agent_version"); + const forcedOn = roundTrip(applyMultiAgentMode([{ slug: "gpt-6-luna" }], "v1")); + expect(applyMultiAgentMode(forcedOn, "default", true, { nativeDefaults: OLD_BASELINE })[0]!.multi_agent_version) + .toBe("v2"); + }); + + test("a pristine baseline entry, including an explicit null, still wins over the recorded origin", () => { + const pinned = roundTrip(applyMultiAgentMode([{ slug: "gpt-5.4", multi_agent_version: "v1" }], "v2")); + const baseline = new Map([["gpt-5.4", "v1"]]); + expect(applyMultiAgentMode(pinned, "default", false, { nativeDefaults: baseline })[0]!.multi_agent_version).toBe("v1"); + + const nulled = roundTrip(applyMultiAgentMode([{ slug: "gpt-5.4", multi_agent_version: "v2" }], "v1")); + const nullBaseline = new Map([["gpt-5.4", null]]); + expect(applyMultiAgentMode(nulled, "default", false, { nativeDefaults: nullBaseline })[0]) + .not.toHaveProperty("multi_agent_version"); + }); + + test("a historical row without a recorded origin keeps its live pin", () => { + const rows: RawEntry[] = [{ slug: "gpt-6-luna", multi_agent_version: "v1" }]; + expect(applyMultiAgentMode(rows, "default", false, { nativeDefaults: OLD_BASELINE })[0]!.multi_agent_version).toBe("v1"); + }); + + test("routed rows keep default-mode normalization regardless of a recorded origin", () => { + const forced = roundTrip(applyMultiAgentMode([{ slug: "xai/grok-4.7", multi_agent_version: "v1" }], "v2")); + const restored = applyMultiAgentMode(forced, "default", false, { nativeDefaults: OLD_BASELINE }); + expect(restored[0]).not.toHaveProperty("multi_agent_version"); + expect(restored[0]).not.toHaveProperty(MULTI_AGENT_ORIGIN_FIELD); + }); +}); diff --git a/tests/codex-integration/native-codex-toggle.test.ts b/tests/codex-integration/native-codex-toggle.test.ts index 19b318183a7..dde313a8718 100644 --- a/tests/codex-integration/native-codex-toggle.test.ts +++ b/tests/codex-integration/native-codex-toggle.test.ts @@ -12,7 +12,7 @@ * act on — rather than artifacts the next start silently undoes. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; @@ -142,6 +142,69 @@ describe("request validation", () => { }); describe("turning Codex off", () => { + test("the status read reflects the persisted switch after a toggle with a stale server config", async () => { + const serverConfig = baseConfig(); + const disabled = await put(serverConfig, { enabled: false }); + expect(disabled.body).toMatchObject({ state: "absent", desiredEnabled: false }); + expect(persistedCodexIntent()).toBe(false); + + const response = await dispatch(serverConfig, "/api/native-integrations"); + const body = await response!.json() as { clients: { clientId: string; state: string; desiredEnabled: boolean }[] }; + expect(body.clients.find(client => client.clientId === "codex")).toMatchObject({ + state: "absent", + desiredEnabled: false, + }); + }); + + test("the status read follows an off-then-on round trip against the same stale server config", async () => { + const serverConfig = baseConfig(); + await put(serverConfig, { enabled: false }); + await put(serverConfig, { enabled: true }); + expect(persistedCodexIntent()).not.toBe(false); + + const response = await dispatch(serverConfig, "/api/native-integrations"); + const body = await response!.json() as { clients: { clientId: string; state: string; desiredEnabled: boolean }[] }; + expect(body.clients.find(client => client.clientId === "codex")).toMatchObject({ + state: "current", + desiredEnabled: true, + }); + }); + + test("a failed native restore stays unsafe on the next status read", async () => { + // A directory where Codex's config file belongs makes the native restore fail. + mkdirSync(join(codexHome, "config.toml")); + const serverConfig = baseConfig(); + const disabled = await put(serverConfig, { enabled: false }); + expect(disabled.body).toMatchObject({ state: "unsafe", reason: "restore_incomplete", desiredEnabled: false }); + + const response = await dispatch(serverConfig, "/api/native-integrations"); + const body = await response!.json() as { clients: { clientId: string; state: string; desiredEnabled: boolean }[] }; + expect(body.clients.find(client => client.clientId === "codex")).toMatchObject({ + state: "unsafe", + desiredEnabled: false, + }); + }); + + test("an enable that did not apply stays absent on the next status read", async () => { + // A hub does not rewrite its own Codex config, so the enable is saved but skipped. + writeFileSync(join(fixtureRoot, "config.json"), JSON.stringify({ ...baseConfig(), runtimeRole: "hub" }, null, 2)); + const serverConfig = { ...baseConfig(), runtimeRole: "hub" } as OcxConfig; + const enabled = await put(serverConfig, { enabled: true }); + expect(enabled.body).toMatchObject({ state: "absent", desiredEnabled: true, reason: "apply_incomplete" }); + + const response = await dispatch(serverConfig, "/api/native-integrations"); + const body = await response!.json() as { clients: { clientId: string; state: string; desiredEnabled: boolean }[] }; + expect(body.clients.find(client => client.clientId === "codex")).toMatchObject({ state: "absent", desiredEnabled: true }); + }); + + test("without a config file the status read keeps the request's in-memory intent", async () => { + rmSync(join(fixtureRoot, "config.json")); + const serverConfig = { ...baseConfig(), clientIntegrations: { codex: false } } as OcxConfig; + const response = await dispatch(serverConfig, "/api/native-integrations"); + const body = await response!.json() as { clients: { clientId: string; desiredEnabled: boolean }[] }; + expect(body.clients.find(client => client.clientId === "codex")?.desiredEnabled).toBe(false); + }); + test("persists the decision so it survives the next start", async () => { const result = await put(baseConfig(), { enabled: false }); expect(result.status).toBe(200); diff --git a/tests/config/settings-stream-mode.test.ts b/tests/config/settings-stream-mode.test.ts index c614aefc9f9..9385ea78b43 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -35,6 +35,7 @@ import { import { resetUsageAggregateCacheForTests } from "../../src/server/management/usage-aggregate-cache"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; import { repoRoot } from "../helpers/repo-root"; +import { MANAGED_AGENTS_TABLE_MARKER, MANAGED_SUBAGENT_DEFAULT_MARKER } from "../../src/codex/subagent-defaults"; import { startupHealthFixture } from "../helpers/startup-health"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -582,8 +583,13 @@ describe("PUT /api/settings", () => { }); test("reports a non-retryable injection refusal without touching the ambient Codex home", () => { - const codexHome = join(TEST_DIR, "codex-missing-config"); + const codexHome = join(TEST_DIR, "codex-ambiguous-config"); mkdirSync(codexHome, { recursive: true }); + // Ambiguous OpenCodex-managed sub-agent markers are a deterministic, non-retryable + // injection refusal. (A missing config.toml no longer is: it is bootstrapped, below.) + writeFileSync(join(codexHome, "config.toml"), [ + MANAGED_AGENTS_TABLE_MARKER, "[agents]", MANAGED_SUBAGENT_DEFAULT_MARKER, "", 'default_subagent_model = "gpt-5.6-sol"', "", + ].join("\n"), "utf8"); const response = putDesktopSwitchInIsolatedHome( codexHome, baseConfig(), @@ -602,6 +608,20 @@ describe("PUT /api/settings", () => { }); }); + test("applies the authless switch on a fresh Codex home without config.toml (#5422)", () => { + const codexHome = join(TEST_DIR, "codex-missing-config"); + mkdirSync(codexHome, { recursive: true }); + const response = putDesktopSwitchInIsolatedHome( + codexHome, + baseConfig(), + { codexDesktopAuthless: true }, + ); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ codexDesktopSwitches: { apply: { applied: true } } }); + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toContain("opencodex"); + }); + test("a paginated Codex home still applies the switch while native history relabeling stands down", async () => { const config = baseConfig(); const codexHome = join(TEST_DIR, "codex-paginated"); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f360d804c6b..ba8dbbb2172 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -362,6 +362,7 @@ "codex-home-wsl.test.ts": "codex-integration", "codex-inject-history-wording.test.ts": "codex-integration", "codex-inject-integration.test.ts": "codex-integration", + "codex-inject-missing-config.test.ts": "codex-integration", "codex-inject-retained-table.test.ts": "codex-integration", "codex-inject-v1-reconcile.test.ts": "codex-integration", "codex-inject-write-lock.test.ts": "codex-integration", @@ -429,6 +430,7 @@ "codex-routing-cache-affinity-detour.test.ts": "codex-integration", "codex-routing.test.ts": "codex-integration", "codex-runtime.test.ts": "codex-integration", + "codex-runtime-wsl-desktop.test.ts": "codex-integration", "codex-service-manager-probe-hardening.test.ts": "codex-integration", "codex-service-manager-probe.test.ts": "codex-integration", "codex-shim-autorestore.test.ts": "codex-integration", @@ -909,6 +911,7 @@ "moonshot-tool-schema.test.ts": "providers", "multi-agent-compat.test.ts": "codex-integration", "multi-agent-keep-native-v1.test.ts": "codex-integration", + "multi-agent-origin.test.ts": "codex-integration", "muse-passive-quota-cache.test.ts": "providers", "muse-passive-quota-observation.test.ts": "providers", "muse-spark-web-search-compat.test.ts": "providers", diff --git a/tests/providers/github-copilot/github-copilot-wire-defaults.test.ts b/tests/providers/github-copilot/github-copilot-wire-defaults.test.ts index cbf633ca7f2..3c6ddf4d0de 100644 --- a/tests/providers/github-copilot/github-copilot-wire-defaults.test.ts +++ b/tests/providers/github-copilot/github-copilot-wire-defaults.test.ts @@ -47,6 +47,10 @@ describe("Copilot discovery-only models do not widen the cold-start seed", () => for (const authMode of ["key", "oauth"] as const) { test(`${authMode} discovery exposes new models but failure retains the configured seed`, async () => { const auth = spyOn(oauth, "resolveModelsAuthToken").mockResolvedValue("test-token"); + // Refreshing OAuth discovery takes the token and its origin from one snapshot. + const snapshot = spyOn(oauth, "getValidAccessTokenSnapshot").mockResolvedValue({ + provider: "github-copilot", accountId: "acct-test", generation: "gen-test", accessToken: "test-token", + }); const original = globalThis.fetch; const provider = { ...providerConfigSeed(getProviderRegistryEntry("github-copilot")!), authMode, apiKey: "test-token" }; try { @@ -62,6 +66,7 @@ describe("Copilot discovery-only models do not widen the cold-start seed", () => } finally { globalThis.fetch = original; auth.mockRestore(); + snapshot.mockRestore(); clearModelCache("github-copilot"); } }); diff --git a/tests/providers/provider-connection-test.test.ts b/tests/providers/provider-connection-test.test.ts index ac285380959..2f196e60f03 100644 --- a/tests/providers/provider-connection-test.test.ts +++ b/tests/providers/provider-connection-test.test.ts @@ -1,9 +1,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync} from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync} from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { setFetchCursorUsableModelsForTests } from "../../src/adapters/cursor/live-models"; import { setFetchQoderModelsForTests } from "../../src/adapters/qoder/live-models"; +import { clearCachedUserJwt } from "../../src/adapters/devin/cloud-direct/auth"; +import { setCachedCatalogForTests } from "../../src/adapters/devin/cloud-direct/catalog"; +import { encodeMessage, encodeString } from "../../src/adapters/devin/cloud-direct/wire"; import { handleManagementAPI } from "../../src/server/management-api"; import { saveConfig } from "../../src/config"; import { OAUTH_PROVIDERS } from "../../src/oauth"; @@ -56,6 +59,186 @@ async function probe(config: OcxConfig, name: string): Promise<{ status: number; } describe("POST /api/providers/test (WP040 connectivity probe)", () => { + test("Devin probes its snapshot's EU tenant destination", async () => { + const baseUrl = "https://eu.windsurf.com/_route/api_server"; + const urls: string[] = []; + const jwt = [ + Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"), + Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64url"), + "fixture-signature", + ].join("."); + globalThis.fetch = (async input => { + const url = String(input); + urls.push(url); + return new Response(new Uint8Array(url.endsWith("/GetUserJwt") + ? encodeString(1, jwt) + : encodeMessage(1, Buffer.concat([encodeString(1, "tenant-model"), encodeString(22, "tenant-model")])))); + }) as typeof fetch; + await saveCredential("devin", { + access: "fixture-devin-eu", refresh: "fixture-devin-eu", + expires: Number.MAX_SAFE_INTEGER, apiBaseUrl: baseUrl, + }); + const config = baseConfig({ devin: { ...structuredClone(OAUTH_PROVIDERS.devin!.providerConfig) } }); + setCachedCatalogForTests(null); + clearCachedUserJwt(); + try { + const { body } = await probe(config, "devin"); + expect(urls.some(url => new URL(url).hostname === "server.codeium.com")).toBe(false); + expect(urls).toEqual([ + `${baseUrl}/exa.auth_pb.AuthService/GetUserJwt`, + `${baseUrl}/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs`, + ]); + expect(body).toMatchObject({ ok: true, models: 1 }); + } finally { + setCachedCatalogForTests(null); + clearCachedUserJwt(); + } + }); + + test("Copilot key probe uses the configured endpoint instead of a stored OAuth host", async () => { + const calls: { url: string; authorization: string | null }[] = []; + globalThis.fetch = (async (input, init) => { + calls.push({ url: String(input), authorization: new Headers(init?.headers).get("authorization") }); + return Response.json({ data: [{ id: "fixture-model" }] }); + }) as typeof fetch; + await saveCredential("github-copilot", { + access: "fixture-oauth", refresh: "fixture-refresh", expires: Date.now() + 3_600_000, + apiBaseUrl: "https://api.business.githubcopilot.com", + }); + const config = baseConfig({ + "github-copilot": { + ...structuredClone(OAUTH_PROVIDERS["github-copilot"]!.providerConfig), + authMode: "key", apiKey: "fixture-row-key", baseUrl: "https://api.githubcopilot.com", + }, + }); + + const { body } = await probe(config, "github-copilot"); + + expect(calls).toEqual([{ url: "https://api.githubcopilot.com/models", authorization: "Bearer fixture-row-key" }]); + expect(body).toMatchObject({ ok: true, models: 1 }); + }); + + test("Copilot probe keeps account A's refreshed bearer and host when the active account switches to B", async () => { + const previous = { HOME: process.env.HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME, CODEX_HOME: process.env.CODEX_HOME }; + const root = mkdtempSync(join(tmpdir(), "ocx-copilot-probe-refresh-")); + process.env.HOME = join(root, "home"); + process.env.OPENCODEX_HOME = join(root, "opencodex"); + process.env.CODEX_HOME = join(root, "codex"); + const originalRefresh = OAUTH_PROVIDERS["github-copilot"]!.refresh; + const calls: { url: string; authorization: string | null }[] = []; + globalThis.fetch = (async (input, init) => { + calls.push({ url: String(input), authorization: new Headers(init?.headers).get("authorization") }); + return Response.json({ data: [{ id: "fixture-model" }] }); + }) as typeof fetch; + let refreshCalls = 0; + try { + await saveCredential("github-copilot", { + accountId: "account-a", access: "fixture-account-a-old", refresh: "fixture-refresh-a", + expires: Date.now() - 1, apiBaseUrl: "https://api.githubcopilot.com", + }); + OAUTH_PROVIDERS["github-copilot"]!.refresh = async () => { + refreshCalls += 1; + await saveCredential("github-copilot", { + accountId: "account-b", access: "fixture-account-b", refresh: "fixture-refresh-b", + expires: Date.now() + 3_600_000, apiBaseUrl: "https://api.business.githubcopilot.com", + }); + return { + accountId: "account-a", access: "fixture-account-a-new", refresh: "fixture-refresh-a", + expires: Date.now() + 3_600_000, apiBaseUrl: "https://api.githubcopilot.com", + }; + }; + const config = baseConfig({ + "github-copilot": { ...structuredClone(OAUTH_PROVIDERS["github-copilot"]!.providerConfig) }, + }); + const { body } = await probe(config, "github-copilot"); + + expect(refreshCalls).toBe(1); + expect(calls).toEqual([{ url: "https://api.githubcopilot.com/models", authorization: "Bearer fixture-account-a-new" }]); + expect(body).toMatchObject({ ok: true, models: 1 }); + } finally { + OAUTH_PROVIDERS["github-copilot"]!.refresh = originalRefresh; + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(root); + } + }); + + test("Copilot probe of a legacy snapshot without an API host never borrows another account's stored host", async () => { + const previous = { HOME: process.env.HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME, CODEX_HOME: process.env.CODEX_HOME }; + const root = mkdtempSync(join(tmpdir(), "ocx-copilot-probe-legacy-")); + process.env.HOME = join(root, "home"); + process.env.OPENCODEX_HOME = join(root, "opencodex"); + process.env.CODEX_HOME = join(root, "codex"); + const originalRefresh = OAUTH_PROVIDERS["github-copilot"]!.refresh; + const calls: { url: string; authorization: string | null }[] = []; + globalThis.fetch = (async (input, init) => { + calls.push({ url: String(input), authorization: new Headers(init?.headers).get("authorization") }); + return Response.json({ data: [{ id: "fixture-model" }] }); + }) as typeof fetch; + try { + await saveCredential("github-copilot", { + accountId: "account-a", access: "fixture-account-a-old", refresh: "fixture-refresh-a", expires: Date.now() - 1, + }); + // The refresh races an account switch: the live store now names account B's business host, + // while account A's refreshed snapshot carries no host of its own. + OAUTH_PROVIDERS["github-copilot"]!.refresh = async () => { + await saveCredential("github-copilot", { + accountId: "account-b", access: "fixture-account-b", refresh: "fixture-refresh-b", + expires: Date.now() + 3_600_000, apiBaseUrl: "https://api.business.githubcopilot.com", + }); + return { accountId: "account-a", access: "fixture-account-a-new", refresh: "fixture-refresh-a", expires: Date.now() + 3_600_000 }; + }; + const config = baseConfig({ + "github-copilot": { ...structuredClone(OAUTH_PROVIDERS["github-copilot"]!.providerConfig) }, + }); + await probe(config, "github-copilot"); + + expect(calls).toEqual([{ url: "https://api.githubcopilot.com/models", authorization: "Bearer fixture-account-a-new" }]); + } finally { + OAUTH_PROVIDERS["github-copilot"]!.refresh = originalRefresh; + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + removeTreeWithRetry(root); + } + }); + + test("Devin probe of a snapshot without an API host falls back only to the allowlisted default", async () => { + const urls: string[] = []; + const jwt = [ + Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"), + Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64url"), + "fixture-signature", + ].join("."); + globalThis.fetch = (async input => { + const url = String(input); + urls.push(url); + return new Response(new Uint8Array(url.endsWith("/GetUserJwt") + ? encodeString(1, jwt) + : encodeMessage(1, Buffer.concat([encodeString(1, "tenant-model"), encodeString(22, "tenant-model")])))); + }) as typeof fetch; + await saveCredential("devin", { + access: "fixture-devin-legacy", refresh: "fixture-devin-legacy", expires: Number.MAX_SAFE_INTEGER, + }); + // A configured base outside the Devin allowlist must never receive the account token. + const config = baseConfig({ devin: { + ...structuredClone(OAUTH_PROVIDERS.devin!.providerConfig), baseUrl: "https://collector.example.test/_route/api_server", + } }); + setCachedCatalogForTests(null); + clearCachedUserJwt(); + try { + await probe(config, "devin"); + expect(urls.length).toBeGreaterThan(0); + for (const url of urls) expect(new URL(url).hostname).toBe("server.codeium.com"); + } finally { + setCachedCatalogForTests(null); + clearCachedUserJwt(); + } + }); + test("Qoder probes the official CLI model list for the configured PAT", async () => { const calls: Array<{ providerId: string; token: string }> = []; setFetchQoderModelsForTests((profile, token) => { diff --git a/tests/service/service-wsl-home-ownership.test.ts b/tests/service/service-wsl-home-ownership.test.ts index b6abc4f7be9..f01f348b006 100644 --- a/tests/service/service-wsl-home-ownership.test.ts +++ b/tests/service/service-wsl-home-ownership.test.ts @@ -39,7 +39,11 @@ describe("WSL service ownership after Windows home discovery", () => { usersRoot, existsSync: (path: string) => path === usersRoot || path === posix.join(windowsHome, "config.toml"), readdirSync: () => ["profile"], - statSync: (() => ({ isDirectory: () => true })) as never, + // The Linux home is absent: discovery classifies the local home with stat alone. + statSync: ((path: string) => { + if (path === join("/home/fixture", ".codex")) throw Object.assign(new Error("absent"), { code: "ENOENT" }); + return { isDirectory: () => true }; + }) as never, realpathSync: (path: string) => path, }; return { root, linuxHome, windowsHome, statePath, recordedHome, deps }; From 782bfb8e279cf84c36c77d49e45c5dc82ace896c Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 23 Sep 2026 21:53:41 +0900 Subject: [PATCH 11/48] test(update): spell expected mise owner paths the way the detector reports them (#5685) On Windows two update-mise tests failed in a lane=all run (windows 7/9): the detector reports installPath and toolRoot for a drive-letter or UNC path with forward slashes, so the lexical and resolved candidates compare in one spelling, while the fixture built its expectations with native path.join (backslashes). Detection itself was correct: installer, tool, backend and install boundary all matched, and the 8.3 RUNNER~1 prefix was identical on both sides. The fixture now spells its expected owner paths through reportedPath, and a platform-independent test pins the reported spelling for backslash and forward-slash Windows inputs, so a change to it fails on every OS instead of only on the Windows shards. --- tests/update/update-mise.test.ts | 42 +++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/tests/update/update-mise.test.ts b/tests/update/update-mise.test.ts index d3b3a52cce2..a613ed3c9cc 100644 --- a/tests/update/update-mise.test.ts +++ b/tests/update/update-mise.test.ts @@ -24,6 +24,15 @@ const BACKEND = 'short = "ocx-local"\nfull = "npm:@bitkyc08/opencodex"\nexplicit const metadataProbe = (exists: (path: string) => boolean) => (path: string): "present" | "absent" => exists(path) ? "present" : "absent"; +/** + * The detector reports owner paths in one spelling for every candidate: a drive-letter or UNC path + * uses forward slashes (so lexical and resolved candidates compare), POSIX paths are unchanged. + * Expected values built with native path.join must be spelled the same way. + */ +function reportedPath(path: string): string { + return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\") ? path.replaceAll("\\", "/") : path; +} + function misePackage(root: string, version = "2.59.0"): string { const toolRoot = join(root, "custom mise data", "installs", "ocx-local"); const packagePath = join( @@ -52,8 +61,8 @@ describe("mise installation ownership", () => { owner: { tool: "ocx-local", backend: "npm:@bitkyc08/opencodex", - installPath: join(root, "custom mise data", "installs", "ocx-local", "2.59.0"), - toolRoot: join(root, "custom mise data", "installs", "ocx-local"), + installPath: reportedPath(join(root, "custom mise data", "installs", "ocx-local", "2.59.0")), + toolRoot: reportedPath(join(root, "custom mise data", "installs", "ocx-local")), }, }); expect(detectInstallFromPath(packagePath)).toBe("mise"); @@ -76,7 +85,7 @@ describe("mise installation ownership", () => { const floating = join(toolRoot, "latest", exact.slice(join(toolRoot, "2.59.0").length + 1)); expect(detectInstallOwnershipFromPath(floating)).toMatchObject({ installer: "mise", - owner: { tool: "ocx-local", installPath: join(toolRoot, "2.59.0") }, + owner: { tool: "ocx-local", installPath: reportedPath(join(toolRoot, "2.59.0")) }, }); } finally { rmSync(root, { recursive: true, force: true }); @@ -101,6 +110,33 @@ describe("mise installation ownership", () => { } }); + test("reports a Windows install in forward-slash spelling whatever the input separators", () => { + const metadata = "C:/Users/RUNNER~1/AppData/Local/mise/installs/ocx-local/.mise.backend.toml"; + const deps = { + exists: () => false, + probe: (path: string) => (path === metadata ? "present" : "absent") as "present" | "absent", + readFile: () => BACKEND, + realpath: (value: string) => value, + }; + const expected = { + installer: "mise", + owner: { + tool: "ocx-local", + backend: "npm:@bitkyc08/opencodex", + installPath: "C:/Users/RUNNER~1/AppData/Local/mise/installs/ocx-local/2.59.0", + toolRoot: "C:/Users/RUNNER~1/AppData/Local/mise/installs/ocx-local", + }, + }; + for (const packagePath of [ + "C:\\Users\\RUNNER~1\\AppData\\Local\\mise\\installs\\ocx-local\\2.59.0\\node_modules\\@bitkyc08\\opencodex\\bin", + "C:/Users/RUNNER~1/AppData/Local/mise/installs/ocx-local/2.59.0/node_modules/@bitkyc08/opencodex/bin", + ]) { + expect(detectInstallOwnershipFromPath(packagePath, deps)).toEqual(expected); + } + expect(reportedPath("C:\\a\\b")).toBe("C:/a/b"); + expect(reportedPath("/tmp/mise\\state")).toBe("/tmp/mise\\state"); + }); + test("does not infer mise ownership from a .mise path or mise on PATH", () => { const path = "/tmp/.mise/node_modules/@bitkyc08/opencodex/bin"; expect(detectInstallOwnershipFromPath(path, { From 129406f1f030dd67dd1d25e73f77963ca06a1270 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 09:49:39 +0900 Subject: [PATCH 12/48] feat(claude): first-party Desktop Code tab model bindings (#5681) * docs(devlog): plan first-party Claude Desktop Code tab model bindings * feat(claude): first-party Desktop Code tab model bindings on the intercept ingress * feat(gui): Code tab model bindings card for first-party Claude Desktop * docs(claude): document first-party Code tab model bindings * test(claude): stop the replay-scope adapter mock from recursing into itself mock.module rewrites the live namespace, so the mock read its own resolveAdapter and froze any later test in the same process that dispatched a real adapter (the new intercept binding case). The binding case also uses a provider id no other suite shares. * fix(gui): use the page model label for binding routes * docs(devlog): record the live first-party binding proof * test(gui): allowlist the picker-id placeholder as an identifier * test(server): pin the intercept flag in the loopback policy source oracle --- .../000_plan.md | 62 +++++++ .../001_probe_evidence.md | 51 ++++++ .../010_roadmap.md | 22 +++ .../020_wp2_first_party_bindings.md | 79 ++++++++ .../030_wp3_live_proof_and_pr.md | 52 ++++++ .../src/content/docs/fr/guides/claude-code.md | 33 ++++ .../src/content/docs/guides/claude-code.md | 33 +++- .../src/content/docs/ja/guides/claude-code.md | 29 +++ .../src/content/docs/ko/guides/claude-code.md | 29 +++ .../src/content/docs/ru/guides/claude-code.md | 31 ++++ .../src/content/docs/tr/guides/claude-code.md | 32 ++++ .../content/docs/zh-cn/guides/claude-code.md | 27 +++ .../content/docs/zh-tw/guides/claude-code.md | 27 +++ .../components/ClaudeFirstPartyBindings.tsx | 169 ++++++++++++++++++ gui/src/i18n/de.ts | 10 ++ gui/src/i18n/en.ts | 10 ++ gui/src/i18n/fr.ts | 10 ++ gui/src/i18n/ja.ts | 10 ++ gui/src/i18n/ko.ts | 10 ++ gui/src/i18n/ru.ts | 10 ++ gui/src/i18n/tr.ts | 10 ++ gui/src/i18n/vi.ts | 10 ++ gui/src/i18n/zh-TW.ts | 10 ++ gui/src/i18n/zh.ts | 10 ++ gui/src/main.tsx | 1 + gui/src/pages/ClaudeDesktop.tsx | 39 ++++ .../styles/claude-first-party-bindings.css | 19 ++ gui/tests/fr-localization.test.ts | 3 + gui/tests/locale-parity.test.ts | 2 + scripts/test-layout/layout.json | 1 + .../ocx/references/01_management_surface.md | 30 +++- src/claude/intercept/model-bindings.ts | 145 +++++++++++++++ src/cli/capabilities.ts | 24 +++ src/cli/claude-desktop.ts | 31 ++++ src/cli/registry.ts | 2 + src/config/schema/config-schema.ts | 16 +- src/providers/provider-id-rewrite.ts | 2 + src/server/claude-messages.ts | 38 ++-- src/server/index/serve-options.ts | 4 +- .../management/agent-settings-routes.ts | 51 ++++++ src/server/management/combo-routes.ts | 8 + src/server/management/route-registry.ts | 1 + .../management/routing-profile-routes.ts | 9 + src/types/config.ts | 7 +- structure/clients/claude-desktop.md | 25 +++ structure/runtime.md | 2 +- .../claude-intercept-model-bindings.test.ts | 161 +++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + .../claude-intercept-integration.test.ts | 74 ++++++++ .../loopback-listener-admission.test.ts | 5 +- 50 files changed, 1454 insertions(+), 23 deletions(-) create mode 100644 devlog/_plan/260923_claude_desktop_first_party_models/000_plan.md create mode 100644 devlog/_plan/260923_claude_desktop_first_party_models/001_probe_evidence.md create mode 100644 devlog/_plan/260923_claude_desktop_first_party_models/010_roadmap.md create mode 100644 devlog/_plan/260923_claude_desktop_first_party_models/020_wp2_first_party_bindings.md create mode 100644 devlog/_plan/260923_claude_desktop_first_party_models/030_wp3_live_proof_and_pr.md create mode 100644 gui/src/components/ClaudeFirstPartyBindings.tsx create mode 100644 gui/src/styles/claude-first-party-bindings.css create mode 100644 src/claude/intercept/model-bindings.ts create mode 100644 tests/claude-integration/claude-intercept-model-bindings.test.ts diff --git a/devlog/_plan/260923_claude_desktop_first_party_models/000_plan.md b/devlog/_plan/260923_claude_desktop_first_party_models/000_plan.md new file mode 100644 index 00000000000..cfa6c6dfe72 --- /dev/null +++ b/devlog/_plan/260923_claude_desktop_first_party_models/000_plan.md @@ -0,0 +1,62 @@ +# Claude Desktop first-party: Code tab model bindings + +First-party mode keeps Claude Desktop signed in to claude.ai and routes only the Code tab's +Claude Code through the local intercept. The routing works, but the Code tab picker is owned by +claude.ai, so none of opencodex's models can appear there and the operator has no way to reach +them from Desktop. This unit adds first-party model bindings: the operator binds a picker model +id (for example `claude-sonnet-4-6`) to an opencodex route, and only requests that arrive +through the intercept honour the binding. `ocx claude` sessions and the public Messages +endpoint are unaffected. Evidence for the constraint is in [001_probe_evidence.md](001_probe_evidence.md); +the decisions are in [010_roadmap.md](010_roadmap.md). + +## Loop spec + +- Loop archetype: satisfy-spec, three work-phases (docs-first, implementation, live proof + PR). +- Trigger: the user reported that first-party still does not work in the Claude app and asked + for a live probe of the injection mechanism with Computer Use, a working Claude app, and a PR + with screenshots. +- Goal: from the Desktop Code tab in first-party mode, the operator can pick a Desktop picker row + and be served by an opencodex route of their choice, with the binding visible in CLI, API, + dashboard and docs. +- Non-goals: adding rows to the Desktop picker (claude.ai owns it), changing the OS trust store or + system proxy, modifying Claude.app, changing gateway mode, merging or releasing the PR, + restarting the user's live service. +- Verifier: focused bun tests named in 020, `bun run typecheck`, `bun run structure:check`, + `bun run skill:surface:check`, `bun run lint:gui`, `bun run build:gui`, then the live + Desktop proof in 030 (usage.jsonl provider + app screenshot). +- Stop condition: PR opened against `dev` with screenshots and exact-head CI reported; no merge. +- Memory artifact: this unit directory; the goalplan at + `.codexclaw/goalplans/opencodex-claude-desktop-first-party-claude-code/`. +- Expected terminal outcomes: DONE when C1-C4 hold; BLOCKED if Desktop needs a login only the + user can perform; NEEDS_HUMAN if claude.ai changes the picker ids mid-run. +- Escalation condition: any need to touch the OS trust store, system proxy, Claude.app, or the + user's live service; any merge decision. +- Resource bounds: local worktree writes only; push limited to the PR branch and screenshot + assets; one live Desktop session probe per proof; no token or time budget was set by the user. + +## Work-phase map + +| Work-phase | Doc | Closes with | +| --- | --- | --- | +| wp1 docs-first | this unit, 001, 010 | roadmap locked, no code | +| wp2 bindings | [020_wp2_first_party_bindings.md](020_wp2_first_party_bindings.md) | focused tests, typecheck, structure/skill checks green | +| wp3 live proof + PR | [030_wp3_live_proof_and_pr.md](030_wp3_live_proof_and_pr.md) | Desktop Code tab served by a bound route, screenshots, PR open | + +## Architect consultation + +- Handle: `01a0cda6-d2ab-73d2-95ea-ead02c2cd992` (devin/swe-2, CXC-ROLE architect, read-only). +- Proposal decisions D1-D5 and main's dispositions are recorded in [010_roadmap.md](010_roadmap.md). +- Reflection on revision r1 (000/010/020/030): ALIGNED. One minor gap: rename migrations rewrite + only `modelMap` values. Disposition: 020 now rewrites `intercept.modelMap` in the provider, routing-profile + and combo rename paths, and records why the legacy OpenAI-id migration is excluded. + +## Audit record + +- Reviewer `01a0cdae-0b0c-7840-a1f4-30fdc5398854` (devin/swe-2, CXC-ROLE reviewer), round 1: + GO-WITH-FIXES (blockers=1). Blocker 1 (native/ targets only normalized in the 3P-alias branch; + PUT validation source unspecified) folded into 020: read-side normalization in + `claudeCodeForIngress` and validation against the unfiltered Desktop route vocabulary. Notes folded: + CSS in a new file (styles.css is at cap), picker-id keys are not migrated on renames, `ocx-route` + precedence documented, scratch-server safety argument written into 030. +- Round 2: reviewer PASS. Architect reflection on r2: ALIGNED with one residual (normalize only the + intercept entries, not merged global values), folded into 020. diff --git a/devlog/_plan/260923_claude_desktop_first_party_models/001_probe_evidence.md b/devlog/_plan/260923_claude_desktop_first_party_models/001_probe_evidence.md new file mode 100644 index 00000000000..44bef2708be --- /dev/null +++ b/devlog/_plan/260923_claude_desktop_first_party_models/001_probe_evidence.md @@ -0,0 +1,51 @@ +# 001 — Probe evidence (2026-09-23) + +All observations were made on the maintainer machine with Claude.app 1.18286.0, Desktop's +bundled Claude Code 2.1.197, standalone Claude Code 2.1.278 and the source-dogfooded proxy +(`/Users/jun/Developer/new/700_projects/opencodex` at 206fbc6b3f) on port 10100, intercept on 10200. +Screenshots and extracted bundles stay outside the repository under `/tmp/ocx-claude-probe/`. + +## Where the Desktop Code tab picker comes from + +- Desktop main process (`app.asar` `.vite/build/index.js`) exposes an IPC + `LocalSessions.setAvailableCodeModels(modelIds)` that the renderer calls; the renderer is the + claude.ai web app. +- The claude.ai bundle (`shared-16-*.js`) calls `setAvailableCodeModels(ae.map(e=>e.id))` with + `{selectableModels:ae}=oy("code")`, and `oy` builds the catalog from `modelSelectorConfig` + (`shared-0-*.js` `function Zj`). Unknown ids only ever become an "Unsupported model" entry for + the current selection; `Yj` adds `[1m]` rows only for models already in the catalog. +- The renderer reads Claude Code settings through `resolveLocalSettings` (`shared-4-*.js` `mN`): + `model`, `availableModels`, `fastMode`, effort and permission keys. `availableModels` only + disables rows; `model` does not add one. Claude Code 2.1.278 supports a `modelPicker` settings + key with labels and `behavesAs`, but Desktop does not read it. +- Live check: with `~/.claude/settings.json` `model` set to `claude-ocx-xai--grok-4.7` and a new + Code session, the picker still listed only Opus 5.5, Sonnet 5, Fable 5.1, Haiku 4.5 and More + models (Opus 5, Fable 5, Opus 4.8, Opus 4.7, Opus 4.6, Sonnet 4.6). The setting was restored. + +Conclusion: in first-party mode no local file can add an opencodex row to the Desktop picker. +The only lever is the request path: the Code tab sends the picker id and the intercept can route it. + +## The intercept path works + +- `ocx claude desktop apply --first-party` pivoted the Desktop library to the standard profile + and wrote only `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` into `~/.claude/settings.json`. +- Standalone CLI: `claude -p ... --model claude-ocx-xai--grok-4.7` returned `PROBE-OK`; + usage.jsonl recorded `xai xai/grok-4.7 200 loopback messages`. +- Desktop Code tab, Haiku 4.5: reply `DESKTOP-1P-PROBE-HAIKU`; usage.jsonl recorded two + `anthropic-native claude-haiku-4-5-20251001 200 loopback` rows, so Desktop's Claude Code does go + through the intercept. +- Desktop Code tab, Sonnet 4.6 after a temporary global `claudeCode.modelMap` + `{"claude-sonnet-4-6":"xai/grok-4.7"}`: usage.jsonl recorded `xai xai/grok-4.7 grok-4.7 200 loopback`. + The global map was the only way to do this, and it also reroutes `ocx claude` sessions. + +## Picker ids observed on the wire + +`claude-opus-5-5`, `claude-opus-5`, `claude-sonnet-4-6`, `claude-haiku-4-5-20251001` (dated), plus the +catalog rows Opus 4.8/4.7/4.6 and Fable 5/5.1. Dated ids reach an undated key through the existing +date-suffix strip in `resolveInboundModel` (src/claude/inbound-model-options.ts). + +## Side observation, not in scope + +Before the probe the saved config said `desktopMode: first-party` while the Desktop library still +applied the opencodex gateway profile, and status reported `first_party_residue`. The running +proxy was 26 commits behind `dev`; this unit does not chase that state. diff --git a/devlog/_plan/260923_claude_desktop_first_party_models/010_roadmap.md b/devlog/_plan/260923_claude_desktop_first_party_models/010_roadmap.md new file mode 100644 index 00000000000..853bde6babc --- /dev/null +++ b/devlog/_plan/260923_claude_desktop_first_party_models/010_roadmap.md @@ -0,0 +1,22 @@ +# 010 — Roadmap and decisions + +Status: locked at the end of wp1 (reviewer PASS, architect ALIGNED on revision r2). + +Order follows the build dependency: the binding has to resolve on the request path before any +surface can edit it, and the live proof needs both. + +1. wp2 — storage, request-path resolution, API, CLI, dashboard, docs ([020](020_wp2_first_party_bindings.md)). +2. wp3 — live Desktop proof with screenshots and the PR ([030](030_wp3_live_proof_and_pr.md)). + +## Architect proposal (handle 01a0cda6) and dispositions + +| ID | Proposal | Disposition | +| --- | --- | --- | +| D1 storage | New `claudeCode.intercept.modelMap`; global `modelMap` stays untouched | Accepted. | +| D1 plumbing | Build a shallow `{...config, claudeCode: {...}}` copy in serve-options for intercept requests | Amended. A config copy can reach `saveConfig`/live-reconcile helpers keyed on the config object and would persist the merged map. Instead serve-options passes `claudeIntercept: true` to the two handlers, and the handlers derive a request-scoped `claudeCode` view (`claudeCodeForIngress`) that only the model-resolution calls read. | +| D2 matching | Reuse `resolveInboundModel` unchanged (exact, date-stripped, `[1m]`, `--fast`) | Accepted; the view overlays `modelMap` so every existing rule applies. Amended after audit round 1: intercept targets written as `native/` are normalized to the bare slug inside the view, because `resolveInboundModel` returns map values verbatim; global `modelMap` values are not normalized. | +| D3 surfaces | CLI `bind`/`unbind`, API, GUI first-party card, schema, docs | Accepted with a dedicated `PUT /api/claude-desktop/first-party-bindings` route instead of widening the gateway profile PUT, which carries conflict checks unrelated to bindings. The CLI drives that route so the running proxy adopts the change immediately. | +| D4 constraints | No capped file touched; register any new test file in both layout maps; lab boundary untouched | Accepted. | +| D5 honesty | Show "picker id → served route"; never claim the Desktop label changes | Accepted; docs and GUI copy say the picker keeps Anthropic's label. | +| Risk (c) | A picker id that is also an alias resolves alias-first | Accepted as is; picker ids are genuine Anthropic ids. | +| Risk (d) | Picker ids can change | Documented; the dashboard offers the observed ids as suggestions and accepts any `claude-` id. | diff --git a/devlog/_plan/260923_claude_desktop_first_party_models/020_wp2_first_party_bindings.md b/devlog/_plan/260923_claude_desktop_first_party_models/020_wp2_first_party_bindings.md new file mode 100644 index 00000000000..5f5c88ffe5b --- /dev/null +++ b/devlog/_plan/260923_claude_desktop_first_party_models/020_wp2_first_party_bindings.md @@ -0,0 +1,79 @@ +# 020 — wp2: first-party model bindings + +## Scope + +IN: storage, request-path resolution for intercepted Messages and count_tokens, status/PUT API, +CLI `bind`/`unbind`, dashboard first-party card, docs and structure. +OUT: Desktop picker rows or labels, OS trust store/system proxy, Claude.app, gateway-mode +behaviour, global `claudeCode.modelMap` semantics, the public `/v1/messages` path. + +## Contract + +- `claudeCode.intercept.modelMap?: Record` — key: a Desktop picker model id + (`claude-` prefix, e.g. `claude-sonnet-4-6`); value: an opencodex route in the Desktop route + vocabulary (`provider/model`, or `native/` for the native OpenAI pool). +- A binding applies only to requests that arrive on the `claude-intercept` ingress. It is + overlaid on the global `modelMap` for that request (binding wins per key), so every existing + resolution rule applies unchanged: alias first, Desktop 3P alias, exact key, date-suffix-stripped + key, `[1m]` strip, `--fast` decode. A bound id is therefore never natively passed through. +- `native/` targets resolve to the bare slug, matching how Desktop 3P aliases resolve + (src/claude/inbound-model-options.ts:48-53). Normalization runs read-side inside + `claudeCodeForIngress`, so a hand-written config, the CLI, the API and the GUI all converge; + the stored value keeps the Desktop route vocabulary (`native/`) for round trips. Only the + intercept entries are normalized before the merge; global `modelMap` values keep today's verbatim + semantics on every path. +- An `ocx-route` body directive (src/server/claude-messages.ts:694-697) still wins over a binding, + because it rewrites the model before resolution. Bindings do not change that precedence. +- PUT validates a target against the whole Desktop route vocabulary from `buildClaudeDesktopState` + (`state.models`, available entries, native routes included). The apply path's filtered `routed` + list (agent-settings-routes.ts:1174) is not used, because it drops `native/` routes. + +## File change map + +| File | Change | +| --- | --- | +| `src/types/config.ts` (~146) | `intercept?: { enabled?; port?; modelMap?: Record }` with doc comment. | +| `src/config/schema/config-schema.ts` (~285) | Validate `intercept.modelMap`: plain object; keys match the picker-id shape; values non-empty strings without whitespace. | +| `src/claude/intercept/model-bindings.ts` (new) | `INTERCEPT_BINDING_ID` shape, `normalizeBindingTarget(route)`, `claudeCodeForIngress(cc, claudeIntercept)` (request-scoped view, never persisted), `applyBindingPatch(current, {set, remove})` with validation errors. | +| `src/server/index/serve-options.ts` (~1478, ~1509) | Pass `{ claudeIntercept: ingress === "claude-intercept" }` to `handleClaudeCountTokens` and `handleClaudeMessages`. | +| `src/server/claude-messages.ts` (~96-210, ~634-790, ~1211-1270) | Derive `cc = claudeCodeForIngress(config.claudeCode, claudeIntercept)` once per request; use it in `decodeFablePickerAlias`, `decodeClaudeFastSelector`, capture, `wantsNativePassthrough` (new `cc` argument) and `anthropicToResponsesTranslation`. `config` itself is never copied. | +| `src/server/management/agent-settings-routes.ts` (~1237-1310) | Status adds `firstParty.modelBindings`; new `PUT /api/claude-desktop/first-party-bindings` taking `{ set?, remove? }`, validating routes against the available Desktop routes, committing through `mutatePersistedConfig` and adopting the committed `claudeCode` into the live config. | +| `src/server/management/route-registry.ts` | Declare the PUT route. | +| `src/cli/claude-desktop.ts` | `ocx claude desktop bind ` and `unbind ` via `runtimeRequest`; help text. | +| `src/cli/capabilities.ts` + `skills/ocx` surface map | Register `claude desktop bind` and `claude desktop unbind`; regenerate with `bun run skill:surface`. | +| `gui/src/components/ClaudeFirstPartyBindings.tsx` (new) + `gui/src/pages/ClaudeDesktop.tsx` + `gui/src/i18n/*.ts` + `gui/src/styles/claude-first-party-bindings.css` (new; `gui/src/styles.css` is at its 2958-line cap and is not touched) | First-party card: rows "picker id → route", add/remove, suggestions of observed picker ids, route select from available Desktop routes, copy that the Desktop label stays Anthropic's. | +| `docs-site/src/content/docs/guides/claude-code.md` + locales | Section "Use opencodex models from the Desktop Code tab"; the CLI-compatibility bullet names bindings next to `modelMap`. | +| `structure/clients/claude-desktop.md`, `structure/runtime.md` | Contract above and the ingress-scoped overlay. | +| `src/providers/provider-id-rewrite.ts` (~97), `src/server/management/routing-profile-routes.ts` (~200), `src/server/management/combo-routes.ts` (~273) | Rewrite `intercept.modelMap` values alongside `modelMap` on provider, routing-profile and combo renames so a binding cannot go stale silently. Keys are not migrated: they are Anthropic picker ids, never opencodex public ids, so a routing-profile rename cannot rename them. `src/providers/openai-tiers.ts` legacy-id migration is left alone: it rewrites pre-existing legacy ids, and bindings are written after it with current ids. | +| Tests | `tests/claude-integration/claude-intercept-model-bindings.test.ts` (new, registered in both layout maps); an intercept-vs-public case in `tests/server/claude-intercept-integration.test.ts`; route/CLI cases next to the existing first-party tests. | + +Field chain for `intercept.modelMap`: creation — CLI `bind`, PUT route, GUI card, hand-written +config; serialization — `mutatePersistedConfig`; deserialization — schema validation on load +(invalid entries reported, never silently used); consumers — `claudeCodeForIngress` in both +handlers, status route, GUI card, CLI output, and the three rename migrations above. + +## Activation scenarios (C-ACTIVATION-GROUNDING-01) + +1. Bound id via intercept: a Messages request for `claude-sonnet-4-6` through the CONNECT proxy + with binding `xai/…` reaches the fake provider, not the fake Anthropic upstream. +2. Same request on the public listener: passes through to the fake Anthropic upstream. +3. Dated id: `claude-haiku-4-5-20251001` reaches a `claude-haiku-4-5` binding. +4. `native/` target resolves to ``. +5. count_tokens on a bound id via intercept is not natively passed through. +6. PUT rejects a non-`claude-` key, an unavailable route and a whitespace value with 400 and + leaves config unchanged; `remove` of an unknown id is a no-op. +7. Schema rejects a non-object `intercept.modelMap` and non-string values. + +## Verifiers (run before writing, PLAN-VERIFIER-REAL-01) + +- `bun test tests/server/claude-intercept-integration.test.ts tests/claude-integration/claude-desktop-first-party.test.ts` + — exit 0, 33 pass on the base; both files import the intercept pair and first-party module directly. +- New test file above, plus `bun run typecheck`, `bun run structure:check`, `bun run skill:surface:check`, + `bun run lint:gui`, `bun run build:gui`, `bun run test:changed` (run in B/C). + +## Delegation + +Main writes server, config, CLI, API, tests, structure and English/Korean docs. One devin/swe-2 +worker writes the GUI component, page wiring, CSS and all ten GUI locales against the API contract +above (disjoint write scope: `gui/` only). A second devin/swe-2 worker translates the new docs +section into fr, ja, ru, tr, zh-cn and zh-tw (write scope: those six files). Main reviews both diffs. diff --git a/devlog/_plan/260923_claude_desktop_first_party_models/030_wp3_live_proof_and_pr.md b/devlog/_plan/260923_claude_desktop_first_party_models/030_wp3_live_proof_and_pr.md new file mode 100644 index 00000000000..15c899159b8 --- /dev/null +++ b/devlog/_plan/260923_claude_desktop_first_party_models/030_wp3_live_proof_and_pr.md @@ -0,0 +1,52 @@ +# 030 — wp3: live Desktop proof and PR + +## Live proof + +The user's service runs from the main checkout; this worktree's code is proven without restarting +or reconfiguring it. A scratch server starts from this worktree through `startServer` directly +(the same entry the integration tests use, so no CLI ensure/sync step touches `~/.codex` or the +service manager), with `OPENCODEX_HOME` pointing at a scratch directory outside the repository. +Its config holds only one provider that authenticates with a static API key (`zai` or `aim`, +copied from the user's config; OAuth providers are excluded because a refresh in the copy could +rotate the user's token), `claudeCode.intercept.port` 10400 and public port 10300. The scratch +server mints its own intercept CA. The scratch config carries no `openai`/Codex provider, so the +Codex sync and quota paths stay inert (src/server/index.ts:276-287, src/codex/quota-auto-refresh.ts:279), +and the service-ownership check reads the default-home records and fails closed to "foreign" +(src/service/state.ts:156-157). The repointed `NODE_EXTRA_CA_CERTS` reads as foreign to the user's +own proxy, so its ensure step does not rewrite it mid-probe (src/claude/intercept/settings.ts:88-99). + +1. Back up `~/.claude/settings.json` (already at `/tmp/ocx-claude-probe/backup/`), then point + `HTTPS_PROXY` at 10400 and `NODE_EXTRA_CA_CERTS` at the scratch CA for the probe. +2. `ocx claude desktop bind claude-sonnet-4-6 /` against the scratch server + (CLI targets it through the scratch home), and the dashboard card for the GUI screenshot. +3. Fully quit and reopen Claude Desktop (first-party), Code tab, pick Sonnet 4.6, send a probe; + pick Haiku 4.5 and send a second probe. +4. Evidence: scratch `usage.jsonl` shows the bound provider for the Sonnet 4.6 probe and + `anthropic-native` for Haiku 4.5; the user's own proxy log shows no new Messages rows for the + probes; screenshots of the Desktop conversation, the picker and the dashboard card, cropped to + exclude account names. +5. Restore: settings.json byte-for-byte from backup, stop the scratch server, delete the scratch + home, fully quit and reopen Desktop, confirm the user's proxy on 10100/10200 is untouched. + +## PR + +- Branch `codex/claude-desktop-first-party-models` → `dev`, repository template (Summary, + Verification, Checklist), screenshots uploaded to the `pr-assets` branch and linked by commit SHA. +- Report exact-head CI; do not merge. + +## Result (2026-09-23) + +- Scratch server from this branch on 10300/10400 (zai only, `claudeCode.desktopMode: first-party`), + binding set with the new CLI: `ocx claude desktop bind claude-sonnet-4-6 zai/glm-5.3-flash`. + The CLI refused `gpt-6` (not a picker id) and `nope/missing` (route not available). +- Claude Desktop 1.18286.0, first-party, Code tab, Sonnet 4.6 picked: the reply arrived, and the + scratch `usage.jsonl` recorded `zai zai/glm-5.3-flash glm-5.3-flash 200 loopback messages` for it + and `anthropic-native claude-haiku-4-5-20251001 200` for Desktop's own title call, so unbound ids + still pass through natively. The user's proxy recorded no Messages rows for the probes. +- The bound model still described itself as Sonnet, because Claude Code's system prompt tells it so. + The docs say this and recommend binding rows the operator does not otherwise use. +- Screenshots on `pr-assets` at 793b39d85d (`260923-claude-desktop-first-party-bindings/`). +- Restored afterwards: `~/.claude/settings.json` byte-identical to the backup, scratch server stopped + and its home deleted, the temporary global `modelMap` used during the probe removed from the + user's proxy, Desktop reopened. Desktop was left in first-party mode, which is the saved + `desktopMode`; before the probe it was running the gateway profile. diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index c31ec48ff41..3d2eb57b9b5 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -148,6 +148,39 @@ jamais écrasé et l'application est refusée. Quittez complètement Desktop pui changement. Les détails et la compatibilité de la CLI Claude Code sont décrits dans la documentation anglaise. +### Utiliser les modèles opencodex depuis l'onglet Code de Desktop (associations first-party) + +En mode first-party, le sélecteur de modèles de l'onglet Code appartient à claude.ai : ses lignes +(Opus 5.5, Sonnet 5, Haiku 4.5 et les anciens modèles sous **More models**) viennent de votre +compte, et aucun réglage local ne peut y ajouter une ligne opencodex. Ce qui arrive à OpenCodex, +c'est l'identifiant de modèle Anthropic du sélecteur à chaque requête ; on associe donc une ligne +du sélecteur à une route opencodex : + +```bash +ocx claude desktop bind claude-sonnet-4-6 xai/grok-4.7 +ocx claude desktop bind claude-opus-4-6 native/gpt-6-sol +ocx claude desktop unbind claude-opus-4-6 +``` + +ou utilisez **Claude → Bureau → Associations de modèles de l'onglet Code** dans le tableau de bord. +Choisir **Sonnet 4.6** dans l'onglet Code est alors servi par `xai/grok-4.7`. Le sélecteur garde le +nom Anthropic, et le modèle continue d'être présenté comme ce modèle Claude par le prompt système de +Claude Code ; préférez donc des lignes que vous n'utilisez pas par ailleurs (les entrées +**More models** sont de bonnes candidates). Les associations s'appliquent dès la requête suivante ; +Desktop n'a pas besoin d'être relancé. + +- Les routes suivent le vocabulaire des routes Desktop : `provider/model`, ou `native/` pour + le pool OpenAI natif. La route doit figurer comme disponible dans le tableau de bord. +- Un identifiant de sélecteur daté (`claude-haiku-4-5-20251001`) correspond à une association non + datée (`claude-haiku-4-5`), et les sélections `[1m]` et du mode rapide suivent la même association. +- Les associations sont enregistrées dans `claudeCode.intercept.modelMap` et ne s'appliquent qu'au + trafic Claude Code qui passe par le proxy d'interception local : l'onglet Code de Desktop et la CLI + `claude` autonome en mode first-party. Les sessions `ocx claude` et le point d'entrée public + `/v1/messages` les ignorent ; le `claudeCode.modelMap` global continue de s'appliquer partout, et + une association l'emporte sur lui pour le même identifiant. +- `ocx claude desktop status --json` rapporte les associations en vigueur sous + `firstParty.modelBindings`. + ## Profil Claude Desktop (mode passerelle) Claude Desktop utilise un profil distinct de Claude Code. Ouvrez **Claude → Bureau** dans le diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index c0cfa723b3d..b7485122739 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -196,6 +196,35 @@ first-party env when the integration is ON and removes it when OFF. Set applied and an implicit apply falls back to gateway. On a connected client the proxy runs on the hub, so `ocx claude desktop apply` there uses the gateway profile. +### Use opencodex models from the Desktop Code tab (first-party bindings) + +In first-party mode the Code tab's model picker belongs to claude.ai: its rows (Opus 5.5, +Sonnet 5, Haiku 4.5, and the older models under **More models**) come from your account, and no +local setting can add an opencodex row. What reaches OpenCodex is the picker's Anthropic model id on +every request, so you bind a picker row to an opencodex route instead: + +```bash +ocx claude desktop bind claude-sonnet-4-6 xai/grok-4.7 +ocx claude desktop bind claude-opus-4-6 native/gpt-6-sol +ocx claude desktop unbind claude-opus-4-6 +``` + +or use **Claude → Desktop → Code tab model bindings** in the dashboard. Picking **Sonnet 4.6** in +the Code tab is then served by `xai/grok-4.7`. The picker keeps Anthropic's label, and the model is +still introduced to itself as that Claude model by Claude Code's system prompt, so prefer rows you +do not otherwise use (the **More models** entries are good candidates). Bindings take effect on the +next request; Desktop does not need a restart. + +- Routes use the Desktop route vocabulary: `provider/model`, or `native/` for the native + OpenAI pool. A route must be one the dashboard lists as available. +- A dated picker id (`claude-haiku-4-5-20251001`) matches an undated binding (`claude-haiku-4-5`), + and `[1m]` and fast-mode selections follow the same binding. +- Bindings are stored in `claudeCode.intercept.modelMap` and apply only to Claude Code traffic that + arrives through the local intercept proxy: Desktop's Code tab and the standalone `claude` CLI in + first-party mode. `ocx claude` sessions and the public `/v1/messages` endpoint ignore them; the + global `claudeCode.modelMap` still applies everywhere, and a binding wins over it for the same id. +- `ocx claude desktop status --json` reports the bindings in effect under `firstParty.modelBindings`. + ### Claude Code CLI compatibility The same `settings.json` env drives the standalone `claude` CLI, so a first-party apply also @@ -203,8 +232,8 @@ covers terminal sessions, `claude -p`, and subagents without `ocx claude`'s `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` shell env. Differences from `ocx claude`: - Model discovery (`/model` → "From gateway") is not available; Claude Code only queries - `GET /v1/models` on a configured gateway. Use `modelMap` to route the built-in Anthropic model - ids, or type an alias directly. + `GET /v1/models` on a configured gateway. Bind a built-in Anthropic model id to a route + (`ocx claude desktop bind`, above), use `modelMap`, or type an alias directly. - `ANTHROPIC_SMALL_FAST_MODEL` and `CLAUDE_CODE_SUBAGENT_MODEL` are chosen by the CLI before the request is sent; set them in `settings.json` yourself if a sidecar or subagent should use a mapped id. diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 2c654d3b6e8..828a5ea948b 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -120,6 +120,35 @@ Claude Desktop は排他的な 2 つのモードのどちらかで OpenCodex を は上書きせず適用を拒否します。切り替え後は Desktop を完全に終了して再起動してください。詳細と Claude Code CLI 互換性は英語版ドキュメントを参照してください。 +### Code タブで opencodex モデルを使う(1P バインディング) + +1P モードでは、Code タブのモデルピッカーは claude.ai が提供します。各行(Opus 5.5、Sonnet 5、 +Haiku 4.5、**More models** 以下の旧モデル)はアカウントから来るもので、ローカル設定で +opencodex の行を追加することはできません。OpenCodex に届くのは、リクエストごとのピッカーの +Anthropic モデル ID なので、代わりにピッカーの行を opencodex のルートにバインドします。 + +```bash +ocx claude desktop bind claude-sonnet-4-6 xai/grok-4.7 +ocx claude desktop bind claude-opus-4-6 native/gpt-6-sol +ocx claude desktop unbind claude-opus-4-6 +``` + +ダッシュボードの **Claude → Desktop → Code タブのモデルバインディング** からも同じ操作ができます。 +こうすると Code タブで **Sonnet 4.6** を選んだとき `xai/grok-4.7` が応答します。ピッカーには +Anthropic の名前がそのまま表示され、Claude Code のシステムプロンプトもモデルにその Claude モデルだと +伝えるため、普段使わない行(**More models** の項目が候補)を選ぶのがおすすめです。バインディングは +次のリクエストから有効になり、Desktop の再起動は不要です。 + +- ルートは Desktop のルート表記を使います: `provider/model`、ネイティブ OpenAI プールは + `native/`。ダッシュボードで利用可能と表示されているルートのみ指定できます。 +- 日付付きのピッカー ID(`claude-haiku-4-5-20251001`)は日付なしのバインディング + (`claude-haiku-4-5`)にマッチし、`[1m]` とファストモードの選択も同じバインディングに従います。 +- バインディングは `claudeCode.intercept.modelMap` に保存され、ローカルのインターセプトプロキシを + 通る Claude Code トラフィック(1P モードの Desktop Code タブとターミナルの `claude` CLI)にのみ + 適用されます。`ocx claude` セッションと公開 `/v1/messages` エンドポイントはこれを無視します。 + グローバルな `claudeCode.modelMap` は引き続き全体に適用され、同じ ID ではバインディングが優先します。 +- 有効なバインディングは `ocx claude desktop status --json` の `firstParty.modelBindings` で確認できます。 + ## リモートハブに接続した Claude Desktop 接続中のマシンで `ocx claude desktop apply` または `ocx claude desktop` を実行すると、 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index d91b605ca61..b15f4708d8d 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -141,6 +141,35 @@ Claude Desktop은 서로 배타적인 두 모드 중 하나로 OpenCodex를 사 거부해요. 전환 후에는 Desktop을 완전히 종료하고 다시 열어 주세요. 자세한 내용과 Claude Code CLI 호환성은 영어 문서를 참고하세요. +### Code 탭에서 opencodex 모델 쓰기 (1P 바인딩) + +1P 모드에서 Code 탭의 모델 선택기는 claude.ai가 채워요. Opus 5.5, Sonnet 5, Haiku 4.5와 +**More models** 아래의 이전 모델은 계정에서 오고, 로컬 설정으로 opencodex 행을 추가할 수는 +없어요. 대신 요청마다 선택기의 Anthropic 모델 ID가 OpenCodex로 들어오므로, 선택기 행을 +opencodex 라우트에 묶어서 씁니다. + +```bash +ocx claude desktop bind claude-sonnet-4-6 xai/grok-4.7 +ocx claude desktop bind claude-opus-4-6 native/gpt-6-sol +ocx claude desktop unbind claude-opus-4-6 +``` + +대시보드의 **Claude → Desktop → Code 탭 모델 바인딩**에서도 같은 작업을 할 수 있어요. 이렇게 묶으면 +Code 탭에서 **Sonnet 4.6**을 고를 때 `xai/grok-4.7`이 응답해요. 선택기에는 Anthropic 이름이 +그대로 보이고, Claude Code 시스템 프롬프트도 모델에게 그 Claude 모델이라고 알려 주므로 평소에 +쓰지 않는 행(**More models** 쪽)을 고르는 편이 좋아요. 바인딩은 다음 요청부터 적용되고 Desktop을 +다시 열 필요는 없어요. + +- 라우트는 Desktop 라우트 표기(`provider/model`, 네이티브 OpenAI 풀은 `native/`)를 쓰고, + 대시보드에 사용 가능으로 표시된 라우트만 지정할 수 있어요. +- 날짜가 붙은 ID(`claude-haiku-4-5-20251001`)는 날짜 없는 바인딩(`claude-haiku-4-5`)에 맞고, + `[1m]`과 빠른 모드 선택도 같은 바인딩을 따라가요. +- 바인딩은 `claudeCode.intercept.modelMap`에 저장되고, 로컬 인터셉트 프록시를 거치는 Claude Code + 트래픽(1P 모드의 Desktop Code 탭과 터미널 `claude` CLI)에만 적용돼요. `ocx claude` 세션과 공개 + `/v1/messages` 엔드포인트는 바인딩을 무시해요. 전역 `claudeCode.modelMap`은 어디서나 그대로 + 적용되고, 같은 ID라면 바인딩이 우선해요. +- 적용 중인 바인딩은 `ocx claude desktop status --json`의 `firstParty.modelBindings`에서 확인해요. + ## 원격 허브에 연결된 Claude Desktop 허브에 연결된 컴퓨터에서 `ocx claude desktop apply` 또는 `ocx claude desktop`을 실행하면 diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 26967b7d8e8..11e6562fe60 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -127,6 +127,37 @@ loopback-адреса. Обновите или настройте хаб и по проброс Anthropic, но преобразованные маршруты Anthropic могут использовать кеш. Сохранение блоков при повторной передаче и сравнение попаданий в кеш остаются отдельной работой. +### Модели opencodex на вкладке Code в Desktop (привязки first-party) + +В режиме first-party селектор моделей вкладки Code принадлежит claude.ai: его строки (Opus 5.5, +Sonnet 5, Haiku 4.5 и более старые модели под **More models**) приходят из вашего аккаунта, и +никакая локальная настройка не может добавить строку opencodex. В OpenCodex при каждом запросе +приходит Anthropic ID модели из селектора, поэтому вместо этого строку селектора привязывают к +маршруту opencodex: + +```bash +ocx claude desktop bind claude-sonnet-4-6 xai/grok-4.7 +ocx claude desktop bind claude-opus-4-6 native/gpt-6-sol +ocx claude desktop unbind claude-opus-4-6 +``` + +или используйте **Claude → Desktop → Привязки моделей вкладки Code** в панели управления. Тогда +выбор **Sonnet 4.6** на вкладке Code обслуживается `xai/grok-4.7`. Селектор сохраняет имя +Anthropic, а системный промпт Claude Code по-прежнему представляет модель как эту Claude-модель, +поэтому предпочитайте строки, которыми вы иначе не пользуетесь (записи **More models** — хорошие +кандидаты). Привязки действуют со следующего запроса; перезапуск Desktop не нужен. + +- Маршруты используют нотацию маршрутов Desktop: `provider/model` или `native/` для + нативного пула OpenAI. Маршрут должен быть из числа доступных в панели управления. +- Датированный ID селектора (`claude-haiku-4-5-20251001`) соответствует недатированной привязке + (`claude-haiku-4-5`), а выборы `[1m]` и быстрого режима следуют той же привязке. +- Привязки хранятся в `claudeCode.intercept.modelMap` и применяются только к трафику Claude Code, + проходящему через локальный прокси перехвата: вкладка Code в Desktop и отдельный CLI `claude` в + режиме first-party. Сессии `ocx claude` и публичная точка `/v1/messages` их игнорируют; + глобальный `claudeCode.modelMap` продолжает действовать везде, и привязка имеет приоритет над ним + для того же ID. +- `ocx claude desktop status --json` показывает действующие привязки в `firstParty.modelBindings`. + ### Ротация ключей, восстановление и отключение Ротация и восстановление обновляют ключ в управляемом подключением профиле Desktop вместе diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 5c6a59c074a..bfe583acb4a 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -200,6 +200,38 @@ Yeni rotalar varsayılan olarak Opus ailesine gider, ancak bir rotayı taşımak `--static`, `--hybrid` ve `--discovery-only` mevcut betikler için kullanılabilir durumda kalır. +### Desktop Code sekmesinden opencodex modellerini kullanma (first-party bağlantıları) + +First-party modunda Code sekmesinin model seçici claude.ai'ye aittir: satırları (Opus 5.5, +Sonnet 5, Haiku 4.5 ve **More models** altındaki eski modeller) hesabınızdan gelir ve hiçbir yerel +ayar opencodex satırı ekleyemez. OpenCodex'e ulaşan, her istekte seçicinin Anthropic model +kimliğidir; bu yüzden bir seçici satırını bir opencodex rotasına bağlarsınız: + +```bash +ocx claude desktop bind claude-sonnet-4-6 xai/grok-4.7 +ocx claude desktop bind claude-opus-4-6 native/gpt-6-sol +ocx claude desktop unbind claude-opus-4-6 +``` + +veya kontrol panelinde **Claude → Desktop → Code sekmesi model bağlantıları**'nı kullanın. Bundan +sonra Code sekmesinde **Sonnet 4.6** seçildiğinde istek `xai/grok-4.7` tarafından sunulur. Seçicide +Anthropic etiketi görünmeye devam eder ve Claude Code'un sistem istemi modelin kendisine hâlâ o +Claude modeli olduğunu söyler; bu yüzden normalde kullanmadığınız satırları tercih edin +(**More models** girdileri iyi adaylardır). Bağlantılar bir sonraki istekte geçerli olur; Desktop'ı +yeniden başlatmak gerekmez. + +- Rotalar Desktop rota sözlüğünü kullanır: `provider/model` veya yerel OpenAI havuzu için + `native/`. Rota, kontrol panelinde kullanılabilir olarak listelenen bir rota olmalıdır. +- Tarihli bir seçici kimliği (`claude-haiku-4-5-20251001`) tarihsiz bir bağlantıyla + (`claude-haiku-4-5`) eşleşir; `[1m]` ve hızlı mod seçimleri de aynı bağlantıyı izler. +- Bağlantılar `claudeCode.intercept.modelMap`'te saklanır ve yalnızca yerel intercept proxy'sinden + geçen Claude Code trafiğine uygulanır: first-party modundaki Desktop Code sekmesi ve bağımsız + `claude` CLI'si. `ocx claude` oturumları ve genel `/v1/messages` uç noktası bunları yok sayar; + genel `claudeCode.modelMap` her yerde geçerli olmaya devam eder ve aynı kimlik için bağlantı + ona üstün gelir. +- `ocx claude desktop status --json`, geçerli bağlantıları `firstParty.modelBindings` altında + raporlar. + ## Sistem Ortamı Entegrasyonu `claudeCode.systemEnv` değeri `true` olarak ayarlandığında (varsayılan: diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 4b35b4448ad..b8174204253 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -110,6 +110,33 @@ Desktop 配置、模型家族分组及默认值由 hub 管理。在 hub 上修 只有代理接入凭证不会启用原生 Anthropic 透传,但经过转换的 Anthropic 路由仍可使用提示缓存。 重放保真和缓存命中率对比仍是独立工作。 +### 在 Desktop Code 标签页使用 opencodex 模型(第一方绑定) + +在第一方模式下,Code 标签页的模型选择器属于 claude.ai:其中的条目(Opus 5.5、Sonnet 5、 +Haiku 4.5 以及 **More models** 下的旧模型)来自你的账户,任何本地设置都无法添加 opencodex +条目。OpenCodex 在每个请求中收到的是选择器里的 Anthropic 模型 ID,因此改为把选择器条目绑定到 +opencodex 路由: + +```bash +ocx claude desktop bind claude-sonnet-4-6 xai/grok-4.7 +ocx claude desktop bind claude-opus-4-6 native/gpt-6-sol +ocx claude desktop unbind claude-opus-4-6 +``` + +也可以在仪表板中通过 **Claude → Desktop → Code 标签页模型绑定** 完成同样操作。绑定之后,在 +Code 标签页选择 **Sonnet 4.6** 时会由 `xai/grok-4.7` 响应。选择器仍显示 Anthropic 名称,且 +Claude Code 的系统提示仍会把模型介绍为那个 Claude 模型,所以建议选择平时不用的条目 +(**More models** 中的条目是不错的候选)。绑定在下一个请求即生效,无需重启 Desktop。 + +- 路由使用 Desktop 路由记法:`provider/model`,原生 OpenAI 池使用 `native/`。路由必须 + 是仪表板中列为可用的路由。 +- 带日期的选择器 ID(`claude-haiku-4-5-20251001`)会匹配无日期的绑定(`claude-haiku-4-5`), + `[1m]` 和快速模式选择也遵循同一绑定。 +- 绑定保存在 `claudeCode.intercept.modelMap` 中,仅适用于经由本地拦截代理的 Claude Code 流量: + 第一方模式下的 Desktop Code 标签页和独立的 `claude` CLI。`ocx claude` 会话和公开的 + `/v1/messages` 端点会忽略绑定;全局 `claudeCode.modelMap` 仍然处处生效,同一 ID 时绑定优先。 +- `ocx claude desktop status --json` 在 `firstParty.modelBindings` 中报告当前生效的绑定。 + ### 密钥轮换、恢复与断开连接 密钥轮换和恢复会同步更新本地连接凭证与该连接管理的 Desktop 配置中的密钥,无需为了迁移 diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index e153106796a..6ae36890b4b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -194,6 +194,33 @@ Desktop 設定檔、模型家族分組及預設值由 hub 管理。在 hub 上 只有代理存取憑證不會啟用原生 Anthropic 透傳,但經過轉換的 Anthropic 路由仍可使用提示快取。 重播保真與快取命中率比較仍是獨立工作。 +### 在 Desktop Code 分頁使用 opencodex 模型(第一方綁定) + +在第一方模式中,Code 分頁的模型選擇器屬於 claude.ai:其中的項目(Opus 5.5、Sonnet 5、 +Haiku 4.5 以及 **More models** 下的舊模型)來自你的帳號,任何本機設定都無法新增 opencodex +項目。OpenCodex 在每個請求中收到的是選擇器裡的 Anthropic 模型 ID,因此改為把選擇器項目綁定到 +opencodex 路由: + +```bash +ocx claude desktop bind claude-sonnet-4-6 xai/grok-4.7 +ocx claude desktop bind claude-opus-4-6 native/gpt-6-sol +ocx claude desktop unbind claude-opus-4-6 +``` + +也可以在儀表板中透過 **Claude → Desktop → Code 分頁模型綁定** 完成同樣操作。綁定之後,在 +Code 分頁選擇 **Sonnet 4.6** 時會由 `xai/grok-4.7` 回應。選擇器仍顯示 Anthropic 名稱,且 +Claude Code 的系統提示仍會把模型介紹為那個 Claude 模型,所以建議選擇平時不用的項目 +(**More models** 中的項目是不錯的候選)。綁定於下一個請求即生效,無需重新啟動 Desktop。 + +- 路由使用 Desktop 路由記法:`provider/model`,原生 OpenAI 池使用 `native/`。路由必須 + 是儀表板中列為可用的路由。 +- 帶日期的選擇器 ID(`claude-haiku-4-5-20251001`)會匹配無日期的綁定(`claude-haiku-4-5`), + `[1m]` 和快速模式選擇也遵循同一綁定。 +- 綁定保存在 `claudeCode.intercept.modelMap` 中,僅適用於經由本機攔截代理的 Claude Code 流量: + 第一方模式下的 Desktop Code 分頁和獨立的 `claude` CLI。`ocx claude` 工作階段和公開的 + `/v1/messages` 端點會忽略綁定;全域 `claudeCode.modelMap` 仍然處處生效,同一 ID 時綁定優先。 +- `ocx claude desktop status --json` 在 `firstParty.modelBindings` 中報告目前生效的綁定。 + ### 金鑰輪換、復原與中斷連線 金鑰輪換和復原會同步更新本機連線憑證與該連線管理的 Desktop 設定中的金鑰,無須為了移轉 diff --git a/gui/src/components/ClaudeFirstPartyBindings.tsx b/gui/src/components/ClaudeFirstPartyBindings.tsx new file mode 100644 index 00000000000..d8c84fcce11 --- /dev/null +++ b/gui/src/components/ClaudeFirstPartyBindings.tsx @@ -0,0 +1,169 @@ +import { useMemo, useState } from "react"; +import { IconChevron, IconX } from "../icons"; +import { Notice } from "../ui"; +import { useI18n } from "../i18n/shared"; +import { readJsonOrThrow } from "../fetch-json"; + +/** The slice of DesktopModel this card needs; the page's full model is compatible. */ +export interface FirstPartyBindingModel { + route: string; + label: string; + available: boolean; +} + +/** The page's labels already name the provider ("glm-5.3-flash (zai)"); fall back to the route. */ +function routeOptionLabel(model: FirstPartyBindingModel): string { + return model.label || model.route; +} + +/** + * First-party "Code tab model bindings" card. Claude Desktop keeps Anthropic's + * picker ids; each binding maps one picker id to an OpenCodex route. Every edit + * saves immediately through PUT /api/claude-desktop/first-party-bindings — there + * is no draft, because the bindings live on the server, not in the profile. + */ +export default function ClaudeFirstPartyBindings({ + apiBase, + bindings, + suggestions, + models, + onSaved, +}: { + apiBase: string; + /** Server-reported bindings (or the parent's post-PUT override). */ + bindings: Record; + /** Picker ids the backend suggests for the datalist. */ + suggestions: string[]; + /** Route options; only available models are offered for new choices. */ + models: FirstPartyBindingModel[]; + /** Receives the PUT response bindings so the parent can mirror them and refresh status. */ + onSaved: (next: Record) => void; +}) { + const { t } = useI18n(); + const [pickerId, setPickerId] = useState(""); + const [route, setRoute] = useState(""); + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + + const routes = useMemo(() => models.filter(model => model.available), [models]); + const boundIds = useMemo(() => new Set(Object.keys(bindings)), [bindings]); + const unboundSuggestions = useMemo( + () => suggestions.filter(id => !boundIds.has(id)), + [suggestions, boundIds], + ); + const sortedBindings = useMemo(() => Object.entries(bindings).sort(([a], [b]) => a.localeCompare(b)), [bindings]); + + const trimmedId = pickerId.trim(); + const canAdd = trimmedId.startsWith("claude-") && route !== "" && pending === null; + + const save = async (body: { set?: Record; remove?: string[] }, pendingKey: string) => { + if (pending !== null) return; + setPending(pendingKey); + setError(null); + try { + const response = await fetch(`${apiBase}/api/claude-desktop/first-party-bindings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const payload = await readJsonOrThrow<{ ok?: boolean; modelBindings?: Record }>( + response, + t("claudeDesktop.firstParty.bindings.saveFailed"), + ); + if (!payload || payload.ok !== true || typeof payload.modelBindings !== "object" || payload.modelBindings === null) { + throw new Error(t("claudeDesktop.firstParty.bindings.saveFailed")); + } + setPickerId(""); + setRoute(""); + onSaved(payload.modelBindings); + } catch (cause) { + setError(cause instanceof Error ? cause.message : t("claudeDesktop.firstParty.bindings.saveFailed")); + } finally { + setPending(null); + } + }; + + return ( +
+

+ {t("claudeDesktop.firstParty.bindings.title")} +

+

{t("claudeDesktop.firstParty.bindings.hint")}

+ + {error && {error}} + + {sortedBindings.length === 0 ? ( +

{t("claudeDesktop.firstParty.bindings.empty")}

+ ) : ( +
    + {sortedBindings.map(([id, bound]) => { + const known = routes.some(model => model.route === bound); + return ( +
  • + {id} +
  • + ); + })} +
+ )} + +
+ setPickerId(event.target.value)} + /> + + {unboundSuggestions.map(id => + + +
+
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 85a992dabef..0cf5717d09a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2837,6 +2837,16 @@ export const de: Record = { "claudeDesktop.firstParty.proxyRunning": "Lokaler Proxy auf 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "Lokaler Proxy 127.0.0.1:{port} läuft nicht — OpenCodex neu starten", "claudeDesktop.firstParty.interceptDisabled": "Intercept-Proxy ist in der Konfiguration deaktiviert (claudeCode.intercept.enabled)", + "claudeDesktop.firstParty.bindings.title": "Modellbindungen des Code-Tabs", + "claudeDesktop.firstParty.bindings.hint": "Claude Desktop behält Anthropics Namen in der Auswahl des Code-Tabs bei. Binden Sie ein Auswahlmodell an ein OpenCodex-Modell, und Anfragen für diesen Eintrag werden vom gebundenen Modell bedient. Nur Claude-Code-Verkehr über den lokalen Proxy (Desktop-Code-Tab und die claude-CLI) nutzt diese Bindungen; ocx claude bleibt unberührt. Bindungen gelten ab der nächsten Anfrage — ein vollständiger Neustart ist nicht nötig.", + "claudeDesktop.firstParty.bindings.empty": "Noch keine Bindungen — Auswahlmodelle folgen ihrer Standardroute.", + "claudeDesktop.firstParty.bindings.pickerLabel": "Modell-ID der Auswahl", + "claudeDesktop.firstParty.bindings.pickerPlaceholder": "claude-sonnet-4-6", + "claudeDesktop.firstParty.bindings.routeLabel": "OpenCodex-Modell", + "claudeDesktop.firstParty.bindings.routePlaceholder": "Modell wählen", + "claudeDesktop.firstParty.bindings.add": "Bindung hinzufügen", + "claudeDesktop.firstParty.bindings.remove": "Bindung {id} entfernen", + "claudeDesktop.firstParty.bindings.saveFailed": "Modellbindungen konnten nicht gespeichert werden", "claudeDesktop.health.lastRequest": "Letzte Anfrage", "claudeDesktop.health.stats": "{count} Anf. / {errors} Fehl.", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d48d0ed10bb..d6a645fcbf9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2937,6 +2937,16 @@ export const en = { "claudeDesktop.firstParty.proxyRunning": "Local proxy on 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "Local proxy 127.0.0.1:{port} is not running — restart OpenCodex", "claudeDesktop.firstParty.interceptDisabled": "Intercept proxy is disabled in config (claudeCode.intercept.enabled)", + "claudeDesktop.firstParty.bindings.title": "Code tab model bindings", + "claudeDesktop.firstParty.bindings.hint": "Claude Desktop keeps Anthropic's names in the Code tab picker. Bind a picker model to an OpenCodex model and requests for that picker entry are served by the bound model. Only Claude Code traffic through the local proxy (the Desktop Code tab and the claude CLI) uses these bindings; ocx claude is unaffected. Bindings apply on the next request — no need to fully quit and reopen.", + "claudeDesktop.firstParty.bindings.empty": "No bindings yet — picker models follow their default route.", + "claudeDesktop.firstParty.bindings.pickerLabel": "Picker model id", + "claudeDesktop.firstParty.bindings.pickerPlaceholder": "claude-sonnet-4-6", + "claudeDesktop.firstParty.bindings.routeLabel": "OpenCodex model", + "claudeDesktop.firstParty.bindings.routePlaceholder": "Choose a model", + "claudeDesktop.firstParty.bindings.add": "Add binding", + "claudeDesktop.firstParty.bindings.remove": "Remove binding {id}", + "claudeDesktop.firstParty.bindings.saveFailed": "Could not save model bindings", "claudeDesktop.health.lastRequest": "Last request", "claudeDesktop.health.stats": "{count} req / {errors} err", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 659ea434de2..9cfb544e72b 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2855,6 +2855,16 @@ export const fr: Record = { "claudeDesktop.firstParty.proxyRunning": "Proxy local sur 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "Le proxy local 127.0.0.1:{port} ne tourne pas — redémarrez OpenCodex", "claudeDesktop.firstParty.interceptDisabled": "Le proxy d'interception est désactivé dans la config (claudeCode.intercept.enabled)", + "claudeDesktop.firstParty.bindings.title": "Associations de modèles de l'onglet Code", + "claudeDesktop.firstParty.bindings.hint": "Claude Desktop conserve les noms d'Anthropic dans le sélecteur de l'onglet Code. Associez un modèle du sélecteur à un modèle OpenCodex et les requêtes de cette entrée seront servies par le modèle associé. Seul le trafic Claude Code passant par le proxy local (l'onglet Code de Desktop et la CLI claude) utilise ces associations ; ocx claude n'est pas affecté. Elles s'appliquent dès la requête suivante — inutile de quitter et rouvrir l'app.", + "claudeDesktop.firstParty.bindings.empty": "Aucune association pour l'instant — les modèles du sélecteur suivent leur route par défaut.", + "claudeDesktop.firstParty.bindings.pickerLabel": "Identifiant du modèle du sélecteur", + "claudeDesktop.firstParty.bindings.pickerPlaceholder": "claude-sonnet-4-6", + "claudeDesktop.firstParty.bindings.routeLabel": "Modèle OpenCodex", + "claudeDesktop.firstParty.bindings.routePlaceholder": "Choisir un modèle", + "claudeDesktop.firstParty.bindings.add": "Ajouter l'association", + "claudeDesktop.firstParty.bindings.remove": "Supprimer l'association {id}", + "claudeDesktop.firstParty.bindings.saveFailed": "Impossible d'enregistrer les associations de modèles", "claudeDesktop.health.lastRequest": "Dernière requête", "claudeDesktop.health.stats": "{count} req. / {errors} err.", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 2136f3322e1..0bd35425fdd 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2659,6 +2659,16 @@ export const ja: Record = { "claudeDesktop.firstParty.proxyRunning": "ローカルプロキシ 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "ローカルプロキシ 127.0.0.1:{port} が停止中 — OpenCodex を再起動", "claudeDesktop.firstParty.interceptDisabled": "設定でインターセプトプロキシが無効です(claudeCode.intercept.enabled)", + "claudeDesktop.firstParty.bindings.title": "Code タブのモデルバインディング", + "claudeDesktop.firstParty.bindings.hint": "Claude Desktop は Code タブのピッカーに Anthropic の名前をそのまま表示します。ピッカーのモデルを OpenCodex のモデルにバインドすると、そのピッカー項目へのリクエストはバインド先のモデルで処理されます。ローカルプロキシ経由の Claude Code トラフィック(Desktop の Code タブと claude CLI)のみがこのバインディングを使用し、ocx claude には影響しません。バインディングは次のリクエストから適用されるため、完全に終了して開き直す必要はありません。", + "claudeDesktop.firstParty.bindings.empty": "バインディングはまだありません — ピッカーのモデルはデフォルトのルートを使います。", + "claudeDesktop.firstParty.bindings.pickerLabel": "ピッカーのモデル ID", + "claudeDesktop.firstParty.bindings.pickerPlaceholder": "claude-sonnet-4-6", + "claudeDesktop.firstParty.bindings.routeLabel": "OpenCodex モデル", + "claudeDesktop.firstParty.bindings.routePlaceholder": "モデルを選択", + "claudeDesktop.firstParty.bindings.add": "バインディングを追加", + "claudeDesktop.firstParty.bindings.remove": "バインディング {id} を削除", + "claudeDesktop.firstParty.bindings.saveFailed": "モデルバインディングを保存できませんでした", "claudeDesktop.health.lastRequest": "最終リクエスト", "claudeDesktop.health.stats": "{count} リクエスト / {errors} エラー", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b7a459d52de..4dbd9be2e45 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2876,6 +2876,16 @@ export const ko: Record = { "claudeDesktop.firstParty.proxyRunning": "로컬 프록시 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "로컬 프록시 127.0.0.1:{port}가 실행 중이 아님 — OpenCodex 재시작", "claudeDesktop.firstParty.interceptDisabled": "설정에서 인터셉트 프록시가 비활성화됨 (claudeCode.intercept.enabled)", + "claudeDesktop.firstParty.bindings.title": "Code 탭 모델 바인딩", + "claudeDesktop.firstParty.bindings.hint": "Claude Desktop은 Code 탭 선택기에 Anthropic 이름을 그대로 유지합니다. 선택기 모델을 OpenCodex 모델에 바인딩하면 해당 선택 항목의 요청이 바인딩된 모델로 처리됩니다. 로컬 프록시를 통과하는 Claude Code 트래픽(Desktop Code 탭과 claude CLI)만 이 바인딩을 사용하며 ocx claude에는 영향이 없습니다. 바인딩은 다음 요청부터 적용되므로 완전히 종료 후 다시 열 필요가 없습니다.", + "claudeDesktop.firstParty.bindings.empty": "아직 바인딩이 없습니다 — 선택기 모델은 기본 경로를 따릅니다.", + "claudeDesktop.firstParty.bindings.pickerLabel": "선택기 모델 ID", + "claudeDesktop.firstParty.bindings.pickerPlaceholder": "claude-sonnet-4-6", + "claudeDesktop.firstParty.bindings.routeLabel": "OpenCodex 모델", + "claudeDesktop.firstParty.bindings.routePlaceholder": "모델 선택", + "claudeDesktop.firstParty.bindings.add": "바인딩 추가", + "claudeDesktop.firstParty.bindings.remove": "바인딩 {id} 제거", + "claudeDesktop.firstParty.bindings.saveFailed": "모델 바인딩을 저장하지 못했습니다", "claudeDesktop.health.lastRequest": "마지막 요청", "claudeDesktop.health.stats": "{count} 요청 / {errors} 에러", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 93965606aa7..93da56494c8 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2730,6 +2730,16 @@ export const ru: Record = { "claudeDesktop.firstParty.proxyRunning": "Локальный прокси на 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "Локальный прокси 127.0.0.1:{port} не запущен — перезапустите OpenCodex", "claudeDesktop.firstParty.interceptDisabled": "Перехватывающий прокси отключён в конфигурации (claudeCode.intercept.enabled)", + "claudeDesktop.firstParty.bindings.title": "Привязки моделей вкладки Code", + "claudeDesktop.firstParty.bindings.hint": "Claude Desktop сохраняет имена Anthropic в селекторе вкладки Code. Привяжите модель селектора к модели OpenCodex, и запросы к этой записи будет обслуживать привязанная модель. Эти привязки использует только трафик Claude Code через локальный прокси (вкладка Code в Desktop и CLI claude); на ocx claude они не влияют. Привязки действуют со следующего запроса — полный перезапуск не нужен.", + "claudeDesktop.firstParty.bindings.empty": "Привязок пока нет — модели селектора используют маршрут по умолчанию.", + "claudeDesktop.firstParty.bindings.pickerLabel": "ID модели селектора", + "claudeDesktop.firstParty.bindings.pickerPlaceholder": "claude-sonnet-4-6", + "claudeDesktop.firstParty.bindings.routeLabel": "Модель OpenCodex", + "claudeDesktop.firstParty.bindings.routePlaceholder": "Выберите модель", + "claudeDesktop.firstParty.bindings.add": "Добавить привязку", + "claudeDesktop.firstParty.bindings.remove": "Удалить привязку {id}", + "claudeDesktop.firstParty.bindings.saveFailed": "Не удалось сохранить привязки моделей", "claudeDesktop.health.lastRequest": "Последний запрос", "claudeDesktop.health.stats": "{count} запр. / {errors} ошиб.", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1856735a5bc..12ee9d37008 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2879,6 +2879,16 @@ export const tr: Record = { "claudeDesktop.firstParty.proxyRunning": "Yerel proxy 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "Yerel proxy 127.0.0.1:{port} çalışmıyor — OpenCodex'i yeniden başlatın", "claudeDesktop.firstParty.interceptDisabled": "Yakalama proxy'si yapılandırmada kapalı (claudeCode.intercept.enabled)", + "claudeDesktop.firstParty.bindings.title": "Code sekmesi model bağlantıları", + "claudeDesktop.firstParty.bindings.hint": "Claude Desktop, Code sekmesi seçicisinde Anthropic'in adlarını korur. Bir seçici modelini OpenCodex modeline bağladığınızda o seçici girdisine gelen istekler bağlı model tarafından sunulur. Bu bağlantıları yalnızca yerel proxy üzerinden geçen Claude Code trafiği (Desktop Code sekmesi ve claude CLI'si) kullanır; ocx claude etkilenmez. Bağlantılar sonraki istekte geçerli olur — tamamen kapatıp yeniden açmak gerekmez.", + "claudeDesktop.firstParty.bindings.empty": "Henüz bağlantı yok — seçici modelleri varsayılan rotayı izler.", + "claudeDesktop.firstParty.bindings.pickerLabel": "Seçici model kimliği", + "claudeDesktop.firstParty.bindings.pickerPlaceholder": "claude-sonnet-4-6", + "claudeDesktop.firstParty.bindings.routeLabel": "OpenCodex modeli", + "claudeDesktop.firstParty.bindings.routePlaceholder": "Model seçin", + "claudeDesktop.firstParty.bindings.add": "Bağlantı ekle", + "claudeDesktop.firstParty.bindings.remove": "{id} bağlantısını kaldır", + "claudeDesktop.firstParty.bindings.saveFailed": "Model bağlantıları kaydedilemedi", "claudeDesktop.health.lastRequest": "Son istek", "claudeDesktop.health.stats": "{count} istek / {errors} hata", "claudeDesktop.effort.supported": "çaba", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index a1b8502f152..9b8410ae1c5 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2868,6 +2868,16 @@ export const vi: Record = { "claudeDesktop.firstParty.proxyRunning": "Proxy cục bộ tại 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "Proxy cục bộ 127.0.0.1:{port} không chạy — khởi động lại OpenCodex", "claudeDesktop.firstParty.interceptDisabled": "Proxy chặn bắt đã bị tắt trong cấu hình (claudeCode.intercept.enabled)", + "claudeDesktop.firstParty.bindings.title": "Liên kết mô hình cho tab Code", + "claudeDesktop.firstParty.bindings.hint": "Claude Desktop giữ nguyên tên của Anthropic trong bộ chọn của tab Code. Liên kết một mô hình trong bộ chọn với mô hình OpenCodex và các yêu cầu tới mục đó sẽ do mô hình được liên kết phục vụ. Chỉ lưu lượng Claude Code đi qua proxy cục bộ (tab Code của Desktop và CLI claude) dùng các liên kết này; ocx claude không bị ảnh hưởng. Liên kết có hiệu lực từ yêu cầu tiếp theo — không cần thoát hẳn rồi mở lại.", + "claudeDesktop.firstParty.bindings.empty": "Chưa có liên kết nào — mô hình trong bộ chọn dùng tuyến mặc định.", + "claudeDesktop.firstParty.bindings.pickerLabel": "ID mô hình trong bộ chọn", + "claudeDesktop.firstParty.bindings.pickerPlaceholder": "claude-sonnet-4-6", + "claudeDesktop.firstParty.bindings.routeLabel": "Mô hình OpenCodex", + "claudeDesktop.firstParty.bindings.routePlaceholder": "Chọn mô hình", + "claudeDesktop.firstParty.bindings.add": "Thêm liên kết", + "claudeDesktop.firstParty.bindings.remove": "Gỡ liên kết {id}", + "claudeDesktop.firstParty.bindings.saveFailed": "Không thể lưu liên kết mô hình", "claudeDesktop.health.lastRequest": "Yêu cầu cuối cùng (Last request)", "claudeDesktop.health.stats": "{count} req / {errors} err", "claudeDesktop.effort.supported": "suy luận (effort)", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index eba5ce1b2ea..4e35f1be38c 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2846,6 +2846,16 @@ export const zhTW: Record = { "claudeDesktop.firstParty.proxyRunning": "本機代理 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "本機代理 127.0.0.1:{port} 未執行 — 請重新啟動 OpenCodex", "claudeDesktop.firstParty.interceptDisabled": "設定中已停用攔截代理(claudeCode.intercept.enabled)", + "claudeDesktop.firstParty.bindings.title": "Code 分頁模型綁定", + "claudeDesktop.firstParty.bindings.hint": "Claude Desktop 在 Code 分頁選擇器中保留 Anthropic 的名稱。將選擇器模型綁定到 OpenCodex 模型後,該選擇器項目的請求將由綁定的模型處理。只有經由本機代理的 Claude Code 流量(Desktop Code 分頁與 claude CLI)使用這些綁定;ocx claude 不受影響。綁定於下一個請求生效,無需完全結束並重新開啟。", + "claudeDesktop.firstParty.bindings.empty": "尚無綁定 — 選擇器模型將使用預設路由。", + "claudeDesktop.firstParty.bindings.pickerLabel": "選擇器模型 ID", + "claudeDesktop.firstParty.bindings.pickerPlaceholder": "claude-sonnet-4-6", + "claudeDesktop.firstParty.bindings.routeLabel": "OpenCodex 模型", + "claudeDesktop.firstParty.bindings.routePlaceholder": "選擇模型", + "claudeDesktop.firstParty.bindings.add": "新增綁定", + "claudeDesktop.firstParty.bindings.remove": "移除綁定 {id}", + "claudeDesktop.firstParty.bindings.saveFailed": "無法儲存模型綁定", "lab.title": "相容性實驗室", "lab.subtitle": "以實驗室投影證據為基礎的唯讀相容性判定矩陣。", "lab.loadFailed": "無法載入相容性實驗室資料", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index fe9424b8151..895a7bd9642 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2857,6 +2857,16 @@ export const zh: Record = { "claudeDesktop.firstParty.proxyRunning": "本地代理 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "本地代理 127.0.0.1:{port} 未运行 — 请重启 OpenCodex", "claudeDesktop.firstParty.interceptDisabled": "配置中已禁用拦截代理(claudeCode.intercept.enabled)", + "claudeDesktop.firstParty.bindings.title": "Code 标签页模型绑定", + "claudeDesktop.firstParty.bindings.hint": "Claude Desktop 在 Code 标签页选择器中保留 Anthropic 的名称。将选择器模型绑定到 OpenCodex 模型后,该选择器条目的请求将由绑定的模型处理。只有经由本地代理的 Claude Code 流量(Desktop Code 标签页和 claude CLI)使用这些绑定;ocx claude 不受影响。绑定在下一个请求时生效,无需完全退出并重新打开。", + "claudeDesktop.firstParty.bindings.empty": "暂无绑定 — 选择器模型将使用默认路由。", + "claudeDesktop.firstParty.bindings.pickerLabel": "选择器模型 ID", + "claudeDesktop.firstParty.bindings.pickerPlaceholder": "claude-sonnet-4-6", + "claudeDesktop.firstParty.bindings.routeLabel": "OpenCodex 模型", + "claudeDesktop.firstParty.bindings.routePlaceholder": "选择模型", + "claudeDesktop.firstParty.bindings.add": "添加绑定", + "claudeDesktop.firstParty.bindings.remove": "移除绑定 {id}", + "claudeDesktop.firstParty.bindings.saveFailed": "无法保存模型绑定", "claudeDesktop.health.lastRequest": "最后请求", "claudeDesktop.health.stats": "{count} 请求 / {errors} 错误", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/main.tsx b/gui/src/main.tsx index 85a65caaa15..4d551be7097 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -14,6 +14,7 @@ import "./styles/usage-chart-accessibility.css"; import "./styles/sidebar-brand.css"; import "./styles/fast-rows-setting.css"; import "./styles/claude-desktop-mode-picker.css"; +import "./styles/claude-first-party-bindings.css"; import "./styles/anthropic-reset-grants.css"; import "./pages/tray.css"; diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index f360a48ade5..2cb478a5b9b 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -8,6 +8,7 @@ import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { readSessionListCacheEntry, writeSessionListCacheEntry } from "../session-list-cache"; import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; +import ClaudeFirstPartyBindings from "../components/ClaudeFirstPartyBindings"; const FAMILIES = ["opus", "fable", "sonnet", "haiku"] as const; type Family = typeof FAMILIES[number]; @@ -52,6 +53,10 @@ interface DesktopFirstPartyStatus { interceptRunning: boolean; proxyPort: number; caCertPath: string; + /** Desktop picker model id → OpenCodex route. Absent on servers that predate bindings. */ + modelBindings?: Record; + /** Common Desktop picker ids offered as add-row suggestions. */ + pickerSuggestions?: string[]; } interface DesktopStatus { @@ -87,6 +92,13 @@ function isDesktopStatus(value: unknown): value is DesktopStatus { || typeof fp.applied !== "boolean" || typeof fp.stale !== "boolean" || typeof fp.interceptEnabled !== "boolean" || typeof fp.interceptRunning !== "boolean" || typeof fp.proxyPort !== "number" || typeof fp.caCertPath !== "string") return false; + if (fp.modelBindings !== undefined) { + const mb = fp.modelBindings; + if (typeof mb !== "object" || mb === null || Array.isArray(mb) + || Object.values(mb).some(route => typeof route !== "string")) return false; + } + if (fp.pickerSuggestions !== undefined + && (!Array.isArray(fp.pickerSuggestions) || fp.pickerSuggestions.some(id => typeof id !== "string"))) return false; } return typeof v.desiredEnabled === "boolean" && typeof v.applied === "boolean" @@ -345,6 +357,20 @@ export default function ClaudeDesktop({ const selectedMode: DesktopMode = chosenMode ?? effectiveMode; const modeDirty = modeKnown && selectedMode !== effectiveMode; + // Post-PUT mirror: the bindings endpoint returns the full map, so the card renders + // it immediately instead of waiting on the 5s status poll. A status payload whose + // bindings differ (another writer, or the poll confirming the save) retakes ownership. + const statusBindings = status?.firstParty?.modelBindings; + const statusBindingsJson = statusBindings ? JSON.stringify(statusBindings) : null; + const [bindingsOverride, setBindingsOverride] = useState | null>(null); + const [seenBindingsJson, setSeenBindingsJson] = useState(statusBindingsJson); + if (seenBindingsJson !== statusBindingsJson) { + setSeenBindingsJson(statusBindingsJson); + setBindingsOverride(null); + } + const firstPartyBindings = bindingsOverride ?? statusBindings ?? {}; + const pickerSuggestions = useMemo(() => status?.firstParty?.pickerSuggestions ?? [], [status]); + const moveModel = (route: string, family: Family) => { if (!profile || profile.assignments[route]?.family === family) return; setProfile(current => { @@ -575,6 +601,19 @@ export default function ClaudeDesktop({ {loadState.showError && {t("claudeDesktop.loadFail")}} {statusFailed && status && {t("claudeDesktop.loadFail")}} + {effectiveMode === "first-party" && ( + { + setBindingsOverride(next); + void statusResource.refresh(); + }} + /> + )} +
{dirty ? t("claudeDesktop.unsaved") : t("claudeDesktop.upToDate")}
diff --git a/gui/src/styles/claude-first-party-bindings.css b/gui/src/styles/claude-first-party-bindings.css new file mode 100644 index 00000000000..80a5b2bf50e --- /dev/null +++ b/gui/src/styles/claude-first-party-bindings.css @@ -0,0 +1,19 @@ +/* ── First-party "Code tab model bindings" card ── */ +.claude-bindings { + margin: 0 0 14px; padding: 10px 14px 12px; + border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); +} +.claude-bindings-title { margin: 0 0 4px; font-size: 13px; font-weight: 600; } +.claude-bindings-hint { margin: 0 0 10px; font-size: 12px; color: var(--muted); line-height: 1.45; text-wrap: pretty; } +.claude-bindings-empty { margin: 0 0 10px; font-size: 12px; color: var(--muted); } +.claude-bindings-list { margin: 0 0 10px; padding: 0; list-style: none; display: grid; gap: 6px; } +.claude-bindings-row { display: flex; align-items: center; gap: 8px; } +.claude-bindings-id { + flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 12px; color: var(--fg); +} +.claude-bindings-arrow { flex: none; color: var(--muted); } +.claude-bindings-select { flex: 1 1 220px; min-width: 0; font-size: 12.5px; } +.claude-bindings-remove { flex: none; padding: 4px; } +.claude-bindings-add { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.claude-bindings-input { flex: 1 1 200px; min-width: 140px; font-size: 12.5px; } diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 64b6bcb4a4e..7b96f3c7a31 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -58,6 +58,9 @@ const INTENTIONAL_ENGLISH = new Set([ "claude.pageTitle", "claude.tabCode", "claude.tabDesktop", + // A literal Claude Desktop picker model id shown as the input placeholder; model ids are + // identical in every locale. + "claudeDesktop.firstParty.bindings.pickerPlaceholder", "claudeDesktop.title", "dash.backendAnthropic", "dash.backendOpenAI", diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index acc20c98140..57d6ad3d7f0 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -68,6 +68,8 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "dash.backendOpenAI", // Claude app labels "claude.pageTitle", + // A literal Claude Desktop picker model id used as the input placeholder, not prose. + "claudeDesktop.firstParty.bindings.pickerPlaceholder", "claude.tabCode", "claude.tabDesktop", // Claude Desktop model-family labels (proper nouns) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e3cdeec8f4e..2b039b54b1c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -373,6 +373,7 @@ "claude-inbound.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", + "claude-intercept-model-bindings.test.ts": "claude-integration", "claude-intercept-proxy.test.ts": "claude-integration", "claude-intercept-settings.test.ts": "claude-integration", "claude-management-api.test.ts": "claude-integration", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index c8cf409f332..ecc6a201cb9 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -843,6 +843,32 @@ JSON mode: `payload`. - Restarts the Codex desktop app as well as the app-servers, through the same module the CLI uses. When the proxy itself runs inside the Codex app it refuses instead, because restarting the app would kill the request. - --yes is mandatory because this interrupts a running editor session and may discard unsaved composer drafts, model-picker selections, and pending approval prompts; it must never happen because an agent guessed a subcommand. +### `ocx claude desktop bind` + +First-party: serve a Claude Desktop Code tab picker model with an opencodex route. + +| Method | Route | +|---|---| +| PUT | `/api/claude-desktop/first-party-bindings` | + +JSON mode: `none`. + +- Takes a picker model id (claude-sonnet-4-6) and a route in the Desktop route vocabulary (provider/model or native/); the route must be one the Desktop profile can offer. +- Only Claude Code traffic that reaches the proxy through the first-party intercept (Desktop's Code tab, the claude CLI) honours it; ocx claude and the public Messages endpoint are unaffected. +- The Desktop picker keeps Anthropic's label; the binding changes which model answers, starting with the next request. + +### `ocx claude desktop unbind` + +Remove a first-party Claude Desktop Code tab picker binding. + +| Method | Route | +|---|---| +| PUT | `/api/claude-desktop/first-party-bindings` | + +JSON mode: `none`. + +- Removing an id that is not bound is a no-op; the remaining bindings are printed. + ### `ocx integration native` Show or toggle the native Claude, Claude Desktop, Codex, and Grok integrations, and read the Cursor status (which builds are installed, gateway values, last request seen). @@ -930,6 +956,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 50 -- of those, state-changing: 25 +- declared capabilities: 52 +- of those, state-changing: 27 - head-resolved invocations: 2 diff --git a/src/claude/intercept/model-bindings.ts b/src/claude/intercept/model-bindings.ts new file mode 100644 index 00000000000..a96dae9e8d8 --- /dev/null +++ b/src/claude/intercept/model-bindings.ts @@ -0,0 +1,145 @@ +import type { OcxClaudeCodeConfig } from "../../types"; + +/** + * First-party model bindings for Claude Code traffic that reaches opencodex through the local + * intercept pair (Claude Desktop's Code tab and the standalone `claude` CLI in first-party mode). + * + * In first-party mode Claude Desktop's Code tab picker is owned by claude.ai: its rows come from + * the account's model selector config and no local setting can add one. What opencodex does see + * is the picker's Anthropic model id on every Messages request. A binding maps such an id + * (`claude-sonnet-4-6`) to an opencodex route (`xai/grok-4.7`), so picking that row in Desktop is + * served by the bound model. The picker keeps Anthropic's label; the binding only changes which + * model answers. + * + * Bindings live in `claudeCode.intercept.modelMap` and apply ONLY to requests that arrived on the + * `claude-intercept` ingress. They are overlaid on the global `claudeCode.modelMap` for that one + * request (binding wins per key), so every existing resolution rule still applies: alias first, + * Desktop 3P alias, exact key, date-suffix-stripped key, `[1m]` strip and `--fast` decode. The + * overlay is a request-scoped view of `claudeCode`; the live config object is never copied or + * persisted with the merged map. + */ + +/** Anthropic picker id shape. Desktop only ever sends `claude-*` ids from its picker. */ +const BINDING_ID_PATTERN = /^claude-[a-z0-9][a-z0-9.\-]*$/i; +const MAX_BINDING_ID_LENGTH = 128; +const MAX_BINDING_ROUTE_LENGTH = 256; +const NATIVE_ROUTE_PREFIX = "native/"; + +/** + * Picker ids Claude Desktop's Code tab offered on 2026-09-23 (the claude.ai model selector config + * for a Max account). claude.ai owns this list; it is a suggestion for the dashboard, never a + * restriction, so any `claude-` id is accepted. + */ +export const DESKTOP_PICKER_ID_SUGGESTIONS: readonly string[] = [ + "claude-opus-5-5", + "claude-sonnet-5", + "claude-fable-5-1", + "claude-haiku-4-5", + "claude-opus-5", + "claude-fable-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", +]; + +export function isInterceptBindingId(value: unknown): value is string { + return typeof value === "string" && value.length <= MAX_BINDING_ID_LENGTH && BINDING_ID_PATTERN.test(value); +} + +export function isInterceptBindingRoute(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= MAX_BINDING_ROUTE_LENGTH && !/\s/.test(value); +} + +/** + * Router model id for a binding target written in the Desktop route vocabulary. `native/` + * is the native OpenAI pool's pseudo-provider and resolves to the bare slug, exactly like a + * Desktop 3P alias does (src/claude/inbound-model-options.ts); every other route is used as is. + */ +export function normalizeBindingTarget(route: string): string { + return route.startsWith(NATIVE_ROUTE_PREFIX) && route.length > NATIVE_ROUTE_PREFIX.length + ? route.slice(NATIVE_ROUTE_PREFIX.length) + : route; +} + +/** Valid bindings from a config value; malformed entries are ignored rather than routed. */ +export function readInterceptBindings(cc: OcxClaudeCodeConfig | undefined): Record { + const raw = cc?.intercept?.modelMap; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const out: Record = {}; + for (const [id, route] of Object.entries(raw)) { + if (isInterceptBindingId(id) && isInterceptBindingRoute(route)) out[id] = route; + } + return out; +} + +/** + * The `claudeCode` view a Messages or count_tokens handler resolves models against. Requests on + * any other ingress, or with no bindings, get the live object back unchanged. Only binding + * targets are normalized; global `modelMap` values keep their verbatim semantics. + */ +export function claudeCodeForIngress( + cc: OcxClaudeCodeConfig | undefined, + claudeIntercept: boolean, +): OcxClaudeCodeConfig | undefined { + if (!claudeIntercept) return cc; + const bindings = readInterceptBindings(cc); + const ids = Object.keys(bindings); + if (ids.length === 0) return cc; + const overlay: Record = { ...(cc?.modelMap ?? {}) }; + for (const id of ids) overlay[id] = normalizeBindingTarget(bindings[id]!); + return { ...(cc ?? {}), modelMap: overlay }; +} + +export interface InterceptBindingPatch { + set?: Record; + remove?: string[]; +} + +export type InterceptBindingPatchResult = + | { ok: true; bindings: Record; changed: boolean } + | { ok: false; error: string }; + +/** Parse an untrusted PUT body into a patch. */ +export function parseInterceptBindingPatch(body: unknown): InterceptBindingPatch | { error: string } { + if (!body || typeof body !== "object" || Array.isArray(body)) return { error: "body must be an object" }; + const { set, remove, ...rest } = body as Record; + const unknown = Object.keys(rest); + if (unknown.length > 0) return { error: `unknown field: ${unknown[0]}` }; + if (set === undefined && remove === undefined) return { error: "set or remove is required" }; + const patch: InterceptBindingPatch = {}; + if (set !== undefined) { + if (!set || typeof set !== "object" || Array.isArray(set)) return { error: "set must be an object of picker id to route" }; + const entries: Record = {}; + for (const [id, route] of Object.entries(set as Record)) { + if (!isInterceptBindingId(id)) return { error: `invalid picker id: ${id} (expected a claude- model id)` }; + if (!isInterceptBindingRoute(route)) return { error: `invalid route for ${id}` }; + entries[id] = route; + } + patch.set = entries; + } + if (remove !== undefined) { + if (!Array.isArray(remove) || remove.some(id => typeof id !== "string")) return { error: "remove must be an array of picker ids" }; + patch.remove = remove as string[]; + } + return patch; +} + +/** + * Apply a patch to the current bindings. Routes must be in `availableRoutes` (the Desktop route + * vocabulary, native routes included); removing an unbound id is a no-op. + */ +export function applyInterceptBindingPatch( + current: Record, + patch: InterceptBindingPatch, + availableRoutes: ReadonlySet, +): InterceptBindingPatchResult { + const next: Record = { ...current }; + for (const [id, route] of Object.entries(patch.set ?? {})) { + if (!availableRoutes.has(route)) return { ok: false, error: `route is not available: ${route}` }; + next[id] = route; + } + for (const id of patch.remove ?? []) delete next[id]; + const changed = JSON.stringify(Object.entries(next).sort()) !== JSON.stringify(Object.entries(current).sort()); + return { ok: true, bindings: next, changed }; +} diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index bb153d987c2..ff549536d2e 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -806,6 +806,30 @@ export const CAPABILITIES: readonly Capability[] = [ "Distinct from `claude desktop show`, which reports what this machine WOULD write; this reports what is actually in effect, which only the running proxy knows.", ], }, + { + command: ["claude", "desktop", "bind"], + summary: "First-party: serve a Claude Desktop Code tab picker model with an opencodex route.", + routes: [{ method: "PUT", path: "/api/claude-desktop/first-party-bindings" }], + flags: [], + mutates: true, + json: "none", + details: [ + "Takes a picker model id (claude-sonnet-4-6) and a route in the Desktop route vocabulary (provider/model or native/); the route must be one the Desktop profile can offer.", + "Only Claude Code traffic that reaches the proxy through the first-party intercept (Desktop's Code tab, the claude CLI) honours it; ocx claude and the public Messages endpoint are unaffected.", + "The Desktop picker keeps Anthropic's label; the binding changes which model answers, starting with the next request.", + ], + }, + { + command: ["claude", "desktop", "unbind"], + summary: "Remove a first-party Claude Desktop Code tab picker binding.", + routes: [{ method: "PUT", path: "/api/claude-desktop/first-party-bindings" }], + flags: [], + mutates: true, + json: "none", + details: [ + "Removing an id that is not bound is a no-op; the remaining bindings are printed.", + ], + }, { command: ["integration", "native"], summary: "Show or toggle the native Claude, Claude Desktop, Codex, and Grok integrations, and read the Cursor status (which builds are installed, gateway values, last request seen).", diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index e637ba00d96..3a526e8504a 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -23,6 +23,7 @@ import { isClaudeDesktopMode, recordClaudeDesktopMode, removeDesktopFirstParty, + resolveClaudeDesktopMode, resolveClaudeDesktopApplyMode, type ClaudeDesktopMode, } from "../claude/desktop-first-party"; @@ -48,6 +49,10 @@ function printDesktopHelp(): void { --gateway install the third-party gateway profile for the whole app ocx claude desktop show [--json] ocx claude desktop status [--json] + ocx claude desktop bind + first-party: serve a Code tab picker model (e.g. claude-sonnet-4-6) with an opencodex model; + the picker keeps Anthropic's label, and only Claude Code traffic through the local proxy uses it + ocx claude desktop unbind ocx claude desktop move [--default] ocx claude desktop default ocx claude desktop export @@ -471,6 +476,32 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf } return 0; } + // Bindings are API-backed for the same reason as `status`: the running proxy routes with + // its live config, so the change must land there, not only in the file. + if (command === "bind" || command === "unbind") { + const [, pickerId, route, ...extra] = argv; + const usage = command === "bind" + ? "Usage: ocx claude desktop bind " + : "Usage: ocx claude desktop unbind "; + if (!pickerId || (command === "bind" ? !route || extra.length > 0 : route !== undefined)) throw new CliUsageError(usage); + const body = command === "bind" ? { set: { [pickerId]: route! } } : { remove: [pickerId] }; + const result = await runtimeRequest<{ modelBindings?: Record }>("/api/claude-desktop/first-party-bindings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const bindings = result.modelBindings ?? {}; + console.log(command === "bind" + ? `Code 탭 피커의 ${pickerId}를 ${route}로 연결했습니다. 다음 요청부터 적용됩니다.` + : `${pickerId} 연결을 해제했습니다.`); + const ids = Object.keys(bindings).sort(); + if (ids.length === 0) console.log("현재 연결된 피커 모델이 없습니다."); + for (const id of ids) console.log(` ${id} -> ${bindings[id]}`); + if (resolveClaudeDesktopMode(config) === "gateway") { + console.warn("⚠️ Desktop이 gateway 모드입니다. 바인딩은 first-party 모드(ocx claude desktop apply --first-party)의 Code 탭과 claude CLI에만 적용됩니다."); + } + return 0; + } const state = await buildClaudeDesktopState(config); if (command === "show") { const rest = argv.slice(1); diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 8deb44a3a4c..2fe74418cfd 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -451,6 +451,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ " ocx claude desktop [apply] Save and apply the four-family profile", " ocx claude desktop show [--json] Show routes, families, and defaults", " ocx claude desktop status [--json] Show applied state, drift, and health", + " ocx claude desktop bind First-party: serve a Code tab picker model with a route", + " ocx claude desktop unbind Remove a first-party binding", " ocx claude desktop move [--default]", " ocx claude desktop default ", " ocx claude desktop export Export versioned JSON (`-` = stdout)", diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index b072b615032..c215f32e6c4 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -60,6 +60,7 @@ import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget"; import { hasFastWireCapabilityConflict } from "../../providers/fastwire"; import { parseDesktopProfile } from "../../claude/desktop-profile"; +import { isInterceptBindingId, isInterceptBindingRoute } from "../../claude/intercept/model-bindings"; import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, MAX_APP_OWNED_MEMORY_BUDGET_MB, MIN_APP_OWNED_MEMORY_BUDGET_MB } from "../../lib/app-owned-memory"; export const configSchema = z.object({ @@ -284,13 +285,26 @@ export const configSchema = z.object({ if (!intercept || typeof intercept !== "object" || Array.isArray(intercept)) { ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept"], message: "intercept must be an object" }); } else { - const { enabled, port } = intercept as { enabled?: unknown; port?: unknown }; + const { enabled, port, modelMap } = intercept as { enabled?: unknown; port?: unknown; modelMap?: unknown }; if (enabled !== undefined && typeof enabled !== "boolean") { ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "enabled"], message: "intercept.enabled must be a boolean" }); } if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) { ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "port"], message: "intercept.port must be an integer between 1 and 65535" }); } + if (modelMap !== undefined) { + if (!modelMap || typeof modelMap !== "object" || Array.isArray(modelMap)) { + ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "modelMap"], message: "intercept.modelMap must be an object of picker id to route" }); + } else { + for (const [id, route] of Object.entries(modelMap as Record)) { + if (!isInterceptBindingId(id)) { + ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "modelMap", id], message: "intercept.modelMap keys must be claude- picker model ids" }); + } else if (!isInterceptBindingRoute(route)) { + ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "modelMap", id], message: "intercept.modelMap values must be non-empty routes without whitespace" }); + } + } + } + } } } if (claude.desktopProfile !== undefined) { diff --git a/src/providers/provider-id-rewrite.ts b/src/providers/provider-id-rewrite.ts index 91c630fc32d..daa248e657e 100644 --- a/src/providers/provider-id-rewrite.ts +++ b/src/providers/provider-id-rewrite.ts @@ -95,6 +95,8 @@ export function rewriteProviderReferences(config: OcxConfig, from: string, to: s routeRecordValues(config.claudeCode?.tierModels as Record | undefined); routeRecordValues(config.claudeCode?.modelMap as Record | undefined); + // First-party picker bindings hold routes too; their keys are Anthropic picker ids. + routeRecordValues(config.claudeCode?.intercept?.modelMap); // Bare provider ids. for (const model of config.customModels ?? []) { diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 807885f9a5d..e8444ffc161 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -24,6 +24,7 @@ import { resolveAlias, claudeCodeNativeAlias, legacyAliasForNative } from "../cl import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; import { captureClaudeInbound } from "../claude/inbound-debug"; +import { claudeCodeForIngress } from "../claude/intercept/model-bindings"; import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility"; import { applyReplayRefusalClientHeaders, @@ -86,6 +87,11 @@ import { type Rec = Record; +/** Which listener a Claude Messages request arrived on. Only the intercept ingress honours bindings. */ +export interface ClaudeIngressOptions { + claudeIntercept?: boolean; +} + /** * Decode a Claude selector that may carry the fast marker. * @@ -197,8 +203,9 @@ function wantsNativePassthrough( config: OcxConfig, requestPolicy: RequestPolicyView, model: unknown, + cc: OcxConfig["claudeCode"] = config.claudeCode, ): model is string { - if (config.claudeCode?.nativePassthrough === false) return false; + if (cc?.nativePassthrough === false) return false; if (typeof model !== "string" || !/^(claude|anthropic)/i.test(model)) return false; // Authorization and x-api-key both belong to the upstream on this branch. An exposed listener // therefore requires the dedicated admission header even though the routed Messages surface @@ -209,7 +216,8 @@ function wantsNativePassthrough( } if (!hasAnthropicNativeCredential(req, config)) return false; // An alias or modelMap hit means the user asked for a ROUTED model: translate instead. - return resolveInboundModel(model, config.claudeCode) === model; + // `cc` carries first-party intercept bindings for requests on the claude-intercept ingress. + return resolveInboundModel(model, cc) === model; } function shouldForwardNativeHeader(name: string, value: string, config: OcxConfig): boolean { @@ -678,11 +686,12 @@ export async function handleClaudeMessages( logCtx: RequestLogContext, logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission }, requestPolicy: RequestPolicyView = config, + ingress: ClaudeIngressOptions = {}, ): Promise { const translatorBudget = createTranslatorBudget(); try { return finalizeTranslatorBudgetResponse( - await handleClaudeMessagesWithBudget(req, config, logCtx, translatorBudget, logIds, requestPolicy), + await handleClaudeMessagesWithBudget(req, config, logCtx, translatorBudget, logIds, requestPolicy, ingress), translatorBudget, ); } catch (error) { @@ -704,6 +713,7 @@ async function handleClaudeMessagesWithBudget( translatorBudget: TranslatorBudget, logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission }, requestPolicy: RequestPolicyView = config, + ingress: ClaudeIngressOptions = {}, ): Promise { logCtx.surface = "claude"; const disabled = claudeInboundDisabled(config); @@ -711,6 +721,8 @@ async function handleClaudeMessagesWithBudget( if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 403, { closeReason: "non_stream" }); return disabled; } + // Model resolution reads this view; every other claudeCode setting keeps reading `config`. + const cc = claudeCodeForIngress(config.claudeCode, ingress.claudeIntercept === true); let anthropicBody: unknown; let internalBody: Rec; @@ -739,7 +751,7 @@ async function handleClaudeMessagesWithBudget( } } if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { - anthropicBody.model = decodeFablePickerAlias(anthropicBody.model, config.claudeCode); + anthropicBody.model = decodeFablePickerAlias(anthropicBody.model, cc); } if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { requestedModel = anthropicBody.model; @@ -750,7 +762,7 @@ async function handleClaudeMessagesWithBudget( ({ fastRow, effortRow } = parseSyntheticRowId( requestedModel, config, - () => decodeClaudeFastSelector(requestedModel, config.claudeCode), + () => decodeClaudeFastSelector(requestedModel, cc), )); if (effortRow) { anthropicBody.model = effortRow.baseId; @@ -764,7 +776,7 @@ async function handleClaudeMessagesWithBudget( "messages", anthropicBody, isRec(anthropicBody) && typeof anthropicBody.model === "string" - ? resolveInboundModel(anthropicBody.model, config.claudeCode) + ? resolveInboundModel(anthropicBody.model, cc) : undefined, req.headers.get("anthropic-beta") ?? undefined, ); @@ -785,7 +797,7 @@ async function handleClaudeMessagesWithBudget( // caller's body with the caller's credential and never runs the Anthropic adapter, so the // proxy-owned `speed` + beta (anthropic-speed wire) and its usage.speed observation would be // silently skipped. Translation reaches the adapter, which owns both. - if (!effortRow && !fastRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model)) { + if (!effortRow && !fastRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model, cc)) { return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages"); } // Capture source semantics before effort rewriting or translation drops fields. @@ -821,7 +833,7 @@ async function handleClaudeMessagesWithBudget( }; delete anthropicBody.thinking; } - const translation = anthropicToResponsesTranslation(anthropicBody, config.claudeCode, translatorBudget); + const translation = anthropicToResponsesTranslation(anthropicBody, cc, translatorBudget); internalBody = translation.body; // The Anthropic translator builds its body from model/input/store/stream plus sampling // fields only, so the caller intent is applied to the TRANSLATED body rather than the @@ -1253,9 +1265,11 @@ export async function handleClaudeCountTokens( req: Request, config: OcxConfig, requestPolicy: RequestPolicyView = config, + ingress: ClaudeIngressOptions = {}, ): Promise { const disabled = claudeInboundDisabled(config); if (disabled) return disabled; + const cc = claudeCodeForIngress(config.claudeCode, ingress.claudeIntercept === true); let body: unknown; const translatorBudget = createTranslatorBudget(); @@ -1287,20 +1301,20 @@ export async function handleClaudeCountTokens( model = stripOneMillionMarker(countRoute); raw.model = model; } - model = decodeFablePickerAlias(model, config.claudeCode); + model = decodeFablePickerAlias(model, cc); raw.model = model; // Fast-only: count_tokens never parsed an effort row, so it must not start. It returns a // token estimate and sends no tier, so only the IDENTITY is corrected - without this the // synthetic id reaches native passthrough as a model Anthropic has never heard of. const countFastRow = parseFastOnlyRowId( - config, () => decodeClaudeFastSelector(model, config.claudeCode), + config, () => decodeClaudeFastSelector(model, cc), ); if (countFastRow) { model = countFastRow.baseId; raw.model = model; } - captureClaudeInbound("count_tokens", raw, resolveInboundModel(model, config.claudeCode), req.headers.get("anthropic-beta") ?? undefined); - if (wantsNativePassthrough(req, config, requestPolicy, model)) { + captureClaudeInbound("count_tokens", raw, resolveInboundModel(model, cc), req.headers.get("anthropic-beta") ?? undefined); + if (wantsNativePassthrough(req, config, requestPolicy, model, cc)) { return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens"); } const inputTokens = estimateClaudeRequestTokens(raw, model); diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 8cd278c399c..31e3b153882 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -1475,7 +1475,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy); } return runAdmittedHttpTurn(req, policy, async () => withCors( - await handleClaudeCountTokens(req, config, policy), + await handleClaudeCountTokens(req, config, policy, { claudeIntercept: ingress === "claude-intercept" }), req, policy, )); @@ -1506,7 +1506,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { // pre-translation stream + native passthrough callbacks) — do not re-wrap the // translated Anthropic stream here. return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors( - await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy), + await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy, { claudeIntercept: ingress === "claude-intercept" }), req, policy, ), { requestId, start, logCtx }); diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index a2221006c97..ca84dde8e7f 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1240,6 +1240,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const { inspectDesktop3pConfigLibrary } = await import("../../claude/desktop-3p"); const { resolveClaudeDesktopApplyMode, inspectDesktopFirstParty } = await import("../../claude/desktop-first-party"); const { getClaudeInterceptState } = await import("../../claude/intercept/runtime"); + const { DESKTOP_PICKER_ID_SUGGESTIONS, readInterceptBindings } = await import("../../claude/intercept/model-bindings"); const persisted = loadConfig(); const savedFingerprint = persisted.claudeCode?.desktopProfile?.appliedFingerprint ?? null; const observed = inspectDesktop3pConfigLibrary({ appliedFingerprint: savedFingerprint }); @@ -1257,6 +1258,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise interceptRunning: intercept !== null, proxyPort: intercept?.proxyPort ?? firstPartySeen.proxyPort, caCertPath: firstPartySeen.caCertPath, + // What the running proxy routes with right now (live config), not the file on disk. + modelBindings: readInterceptBindings(config.claudeCode), + pickerSuggestions: [...DESKTOP_PICKER_ID_SUGGESTIONS], }; const applied = mode === "first-party" ? firstPartySeen.applied : gatewayApplied; // "Needs update" is only meaningful while the integration is wanted. When the @@ -1312,6 +1316,53 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } } + // First-party model bindings: Claude Desktop Code tab picker id -> opencodex route, honoured + // only on the claude-intercept ingress (src/claude/intercept/model-bindings.ts). + if (url.pathname === "/api/claude-desktop/first-party-bindings" && req.method === "PUT") { + let body: unknown; + try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + try { + const { applyInterceptBindingPatch, parseInterceptBindingPatch, readInterceptBindings } = await import("../../claude/intercept/model-bindings"); + const patch = parseInterceptBindingPatch(body); + if ("error" in patch) return jsonResponse({ error: patch.error }, 400); + // Same route vocabulary the Desktop profile and the dashboard use, native routes included. + const state = await buildClaudeDesktopState(config); + const availableRoutes = new Set(state.models.filter(model => model.available).map(model => model.route)); + let rejection: string | null = null; + const outcome = mutatePersistedConfig(persisted => { + const current = readInterceptBindings(persisted.claudeCode); + const next = applyInterceptBindingPatch(current, patch, availableRoutes); + if (!next.ok) { + rejection = next.error; + return { changed: false, value: persisted.claudeCode }; + } + if (!next.changed) return { changed: false, value: structuredClone(persisted.claudeCode) }; + const claudeCode = { ...(persisted.claudeCode ?? {}) }; + const intercept = { ...(claudeCode.intercept ?? {}) }; + if (Object.keys(next.bindings).length > 0) intercept.modelMap = next.bindings; + else delete intercept.modelMap; + if (Object.keys(intercept).length > 0) claudeCode.intercept = intercept; + else delete claudeCode.intercept; + persisted.claudeCode = claudeCode; + return { changed: true, value: structuredClone(persisted.claudeCode) }; + }); + if (rejection) return jsonResponse({ error: rejection }, 400); + if (outcome.status === "unavailable") { + return jsonResponse({ error: `First-party bindings could not be saved (config ${outcome.reason})` }, outcome.reason === "conflict" ? 409 : 500); + } + adoptPersistedClaudeCode(config, outcome.value); + // Pin the committed leaf: an unarmed live snapshot may otherwise keep its old intercept block. + const committedIntercept = outcome.value?.intercept; + const liveClaudeCode = { ...(config.claudeCode ?? {}) }; + if (committedIntercept) liveClaudeCode.intercept = structuredClone(committedIntercept); + else delete liveClaudeCode.intercept; + config.claudeCode = liveClaudeCode; + return jsonResponse({ ok: true, modelBindings: readInterceptBindings(config.claudeCode) }); + } catch (error) { + return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); + } + } + // Claude Code inbound settings (GUI "Claude ON" toggle + Claude page). if (url.pathname === "/api/claude-code" && req.method === "GET") { const models = await fetchAllModels(config); diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index cdc567a6ebd..e990252538e 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -275,6 +275,14 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise [source, migrateAgentReference(model)]), ); } + if (claudeCode.intercept?.modelMap) { + claudeCode.intercept = { + ...claudeCode.intercept, + modelMap: Object.fromEntries( + Object.entries(claudeCode.intercept.modelMap).map(([pickerId, route]) => [pickerId, migrateAgentReference(route)]), + ), + }; + } config.claudeCode = claudeCode; } } diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index b2b9913c199..bf3c8c7da9f 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -152,6 +152,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "POST", path: "/api/anthropic/reset-grants/consume", module: "server/management/anthropic-reset-grant-routes", mutates: true, exempt: { reason: "session-only", why: "Spending a Claude reset grant requires the gui-session principal (anthropic-reset-grant-routes.ts handleConsume); the admin token is refused." } }, { method: "PUT", path: "/api/claude-code", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/claude-desktop", module: "server/management/agent-settings-routes", mutates: true }, + { method: "PUT", path: "/api/claude-desktop/first-party-bindings", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/codex-auth/features/default-mode-request-user-input", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/effort-caps", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/grok/selection", module: "server/management/agent-settings-routes", mutates: true }, diff --git a/src/server/management/routing-profile-routes.ts b/src/server/management/routing-profile-routes.ts index 7cd7730db16..31bdbfd3c98 100644 --- a/src/server/management/routing-profile-routes.ts +++ b/src/server/management/routing-profile-routes.ts @@ -207,6 +207,15 @@ function migrateProfileModelReferences( ]), ); } + if (claudeCode.intercept?.modelMap) { + // First-party picker bindings: keys are Anthropic picker ids, only the route follows the rename. + claudeCode.intercept = { + ...claudeCode.intercept, + modelMap: Object.fromEntries( + Object.entries(claudeCode.intercept.modelMap).map(([pickerId, route]) => [pickerId, migrateAgentReference(route)]), + ), + }; + } config.claudeCode = claudeCode; } return shouldSyncClaudeAgentDefs; diff --git a/src/types/config.ts b/src/types/config.ts index 3a8943ae031..6eb6e26f12a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -142,8 +142,13 @@ export interface OcxClaudeCodeConfig { * traffic without any `ANTHROPIC_BASE_URL` rewrite (src/claude/intercept). Claude Code reaches * it via `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` in its settings env. Default: enabled on a * hub; the proxy port defaults to the public port + 100. + * + * `modelMap` holds first-party model bindings (src/claude/intercept/model-bindings.ts): a + * Claude Desktop Code tab picker id such as `claude-sonnet-4-6` mapped to an opencodex route in + * the Desktop route vocabulary (`provider/model`, or `native/`). Bindings apply only to + * requests that arrive through the intercept pair, overlaid on the global `modelMap`. */ - intercept?: { enabled?: boolean; port?: number }; + intercept?: { enabled?: boolean; port?: number; modelMap?: Record }; /** * Bundled-skill content elision for ROUTED (non-Anthropic) models (devlog 260712 * 060): Skill-tool results whose skill name matches an entry here are replaced diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index c4c6dad7742..3e4a8c2d3f1 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -78,6 +78,31 @@ configuration. Ordinary Chat-tab traffic is out of scope for both modes. `src/claude/desktop-gateway-state.ts` adopts the exact committed Claude subtree and rebases the live hand-edit guard only after persistence succeeds. Pending disjoint live edits survive; later hand edits remain protected during unrelated whole-config saves. Gateway mode and fingerprint are recorded before cleanup and diagnostic awaits. +### First-party model bindings + +`src/claude/intercept/model-bindings.ts` owns `claudeCode.intercept.modelMap`. In first-party mode the +Code tab picker is filled by claude.ai's model selector config, so no local file can add an opencodex +row; the only lever is the picker's Anthropic id on each request. A binding maps such an id +(`claude-sonnet-4-6`) to a route in the Desktop route vocabulary (`provider/model` or `native/`). +`src/server/index/serve-options.ts` passes `claudeIntercept` to `handleClaudeMessages` and +`handleClaudeCountTokens` only for the `claude-intercept` ingress; the handlers resolve models against +`claudeCodeForIngress`, a request-scoped `claudeCode` view whose `modelMap` is the global map with the +bindings overlaid (binding wins per key, `native/` targets normalized to the bare slug, global values +left verbatim). The live config object is never copied or persisted with the merged map. Every other +resolution rule is unchanged, so a bound id is translated rather than natively passed through, dated +ids reach undated keys, and an `ocx-route` directive still wins. `ocx claude` sessions and the public +Messages listener never see bindings. + +`PUT /api/claude-desktop/first-party-bindings` (`{ set?, remove? }`) validates ids and routes against +`buildClaudeDesktopState().models` (available routes, native included), commits through +`mutatePersistedConfig` and adopts the committed `claudeCode` into the live config; `GET +/api/claude-desktop/status` reports `firstParty.modelBindings` and `firstParty.pickerSuggestions`. +Surfaces: `ocx claude desktop bind|unbind` (`src/cli/claude-desktop.ts`) and the dashboard card +`gui/src/components/ClaudeFirstPartyBindings.tsx`. Provider, routing-profile and combo renames rewrite +binding values alongside `modelMap`; keys are Anthropic ids and are never migrated. Invariant tests: +`tests/claude-integration/claude-intercept-model-bindings.test.ts` and the intercept-versus-public case +in `tests/server/claude-intercept-integration.test.ts`. + Production apply and status routes use the asynchronous, read-only policy probe in `src/claude/desktop-policy.ts`. Concurrent requests share one in-flight probe, and its settled state is cached for 30 seconds. Each registry query is bounded to two seconds; diff --git a/structure/runtime.md b/structure/runtime.md index 22495496528..23ae1f37ea6 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -253,7 +253,7 @@ configured Anthropic upstream. The pair is on by default on a hub (`claudeCode.i its proxy port defaults to the public port + 100 (`claudeCode.intercept.port`), and a bind failure degrades to a startup warning rather than a startup failure; stop joins both sockets. A server asked for an ephemeral public port (`startServer(0)`, the shape every in-process test fixture uses) has no -stable port to derive from, so the pair stays off unless `claudeCode.intercept.port` is explicit. +stable port to derive from, so the pair stays off unless `claudeCode.intercept.port` is explicit. Requests on this ingress also honour first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). Auxiliary listener bind failures carry the listener key and effective address through `AuxiliaryListenerBindError` in `src/server/ports.ts`. `src/cli/index.ts` reports them without retrying the public port. Startup still rolls back every earlier socket synchronously. diff --git a/tests/claude-integration/claude-intercept-model-bindings.test.ts b/tests/claude-integration/claude-intercept-model-bindings.test.ts new file mode 100644 index 00000000000..30e4e574198 --- /dev/null +++ b/tests/claude-integration/claude-intercept-model-bindings.test.ts @@ -0,0 +1,161 @@ +/** + * First-party model bindings (claudeCode.intercept.modelMap): a Claude Desktop Code tab picker id + * mapped to an opencodex route, honoured only for requests on the claude-intercept ingress. + * The request-path proof through a real CONNECT tunnel lives in + * tests/server/claude-intercept-integration.test.ts. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyInterceptBindingPatch, + claudeCodeForIngress, + normalizeBindingTarget, + parseInterceptBindingPatch, + readInterceptBindings, +} from "../../src/claude/intercept/model-bindings"; +import { resolveInboundModel } from "../../src/claude/inbound-model-options"; +import { saveConfig, validateConfigCandidate } from "../../src/config"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxClaudeCodeConfig, OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root = ""; +const previousHome = process.env.OPENCODEX_HOME; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-intercept-bindings-")); + process.env.OPENCODEX_HOME = root; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (root) removeTreeWithRetry(root); + root = ""; +}); + +test("bindings apply only to the intercept ingress and win over the global map per key", () => { + const cc: OcxClaudeCodeConfig = { + modelMap: { "claude-sonnet-4-6": "global/target", "claude-opus-4-6": "global/opus" }, + intercept: { modelMap: { "claude-sonnet-4-6": "xai/grok-4.7" } }, + }; + expect(claudeCodeForIngress(cc, false)).toBe(cc); + const view = claudeCodeForIngress(cc, true); + expect(view).not.toBe(cc); + expect(view?.modelMap).toEqual({ "claude-sonnet-4-6": "xai/grok-4.7", "claude-opus-4-6": "global/opus" }); + // The live object is untouched: the overlay is request-scoped. + expect(cc.modelMap).toEqual({ "claude-sonnet-4-6": "global/target", "claude-opus-4-6": "global/opus" }); + expect(resolveInboundModel("claude-sonnet-4-6", view)).toBe("xai/grok-4.7"); + expect(resolveInboundModel("claude-sonnet-4-6", cc)).toBe("global/target"); +}); + +test("a dated picker id reaches an undated binding and [1m] is ignored", () => { + const view = claudeCodeForIngress({ intercept: { modelMap: { "claude-haiku-4-5": "zai/glm-5.3-flash" } } }, true); + expect(resolveInboundModel("claude-haiku-4-5-20251001", view)).toBe("zai/glm-5.3-flash"); + expect(resolveInboundModel("claude-haiku-4-5[1m]", view)).toBe("zai/glm-5.3-flash"); + expect(resolveInboundModel("claude-sonnet-5", view)).toBe("claude-sonnet-5"); +}); + +test("native/ targets resolve to the bare slug, global map values stay verbatim", () => { + expect(normalizeBindingTarget("native/gpt-6-sol")).toBe("gpt-6-sol"); + expect(normalizeBindingTarget("native/")).toBe("native/"); + expect(normalizeBindingTarget("xai/grok-4.7")).toBe("xai/grok-4.7"); + const view = claudeCodeForIngress({ + modelMap: { "claude-opus-4-7": "native/kept-verbatim" }, + intercept: { modelMap: { "claude-opus-4-6": "native/gpt-6-sol" } }, + }, true); + expect(resolveInboundModel("claude-opus-4-6", view)).toBe("gpt-6-sol"); + expect(resolveInboundModel("claude-opus-4-7", view)).toBe("native/kept-verbatim"); +}); + +test("malformed stored bindings are ignored rather than routed", () => { + const cc = { intercept: { modelMap: { "gpt-6": "xai/grok-4.7", "claude-ok": "", "claude-sonnet-4-6": "has space" } } } as unknown as OcxClaudeCodeConfig; + expect(readInterceptBindings(cc)).toEqual({}); + expect(claudeCodeForIngress(cc, true)).toBe(cc); +}); + +test("patch parsing rejects bad ids, bad routes and unknown fields", () => { + expect(parseInterceptBindingPatch({ set: { "claude-sonnet-4-6": "xai/grok-4.7" } })).toEqual({ set: { "claude-sonnet-4-6": "xai/grok-4.7" } }); + expect(parseInterceptBindingPatch({ remove: ["claude-sonnet-4-6"] })).toEqual({ remove: ["claude-sonnet-4-6"] }); + expect("error" in parseInterceptBindingPatch({})).toBe(true); + expect("error" in parseInterceptBindingPatch({ set: { "gpt-6": "xai/grok-4.7" } })).toBe(true); + expect("error" in parseInterceptBindingPatch({ set: { "claude-sonnet-4-6": "has space" } })).toBe(true); + expect("error" in parseInterceptBindingPatch({ set: {}, extra: true })).toBe(true); + expect("error" in parseInterceptBindingPatch({ remove: [1] })).toBe(true); +}); + +test("applying a patch checks routes against the available vocabulary", () => { + const routes = new Set(["xai/grok-4.7", "native/gpt-6-sol"]); + const bound = applyInterceptBindingPatch({}, { set: { "claude-sonnet-4-6": "native/gpt-6-sol" } }, routes); + expect(bound).toEqual({ ok: true, bindings: { "claude-sonnet-4-6": "native/gpt-6-sol" }, changed: true }); + expect(applyInterceptBindingPatch({}, { set: { "claude-sonnet-4-6": "nope/missing" } }, routes).ok).toBe(false); + expect(applyInterceptBindingPatch({ "claude-opus-4-6": "xai/grok-4.7" }, { remove: ["claude-unbound"] }, routes)) + .toEqual({ ok: true, bindings: { "claude-opus-4-6": "xai/grok-4.7" }, changed: false }); +}); + +test("config validation rejects a malformed intercept.modelMap", () => { + const base = { port: 10100, providers: { openai: { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authMode: "forward" } }, defaultProvider: "openai" }; + expect(validateConfigCandidate({ ...base, claudeCode: { intercept: { modelMap: { "claude-sonnet-4-6": "xai/grok-4.7" } } } }).ok).toBe(true); + for (const modelMap of [["x"], { "gpt-6": "xai/grok-4.7" }, { "claude-sonnet-4-6": 7 }, { "claude-sonnet-4-6": "has space" }]) { + expect(validateConfigCandidate({ ...base, claudeCode: { intercept: { modelMap } } }).ok).toBe(false); + } +}); + +function bindingConfig(): OcxConfig { + return { + port: 10100, + defaultProvider: "fake", + providers: { + fake: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:9/v1", allowPrivateNetwork: true, apiKey: "sk-fake", models: ["fake-model"], liveModels: false }, + }, + } as unknown as OcxConfig; +} + +async function putBindings(config: OcxConfig, body: unknown): Promise { + const url = new URL("http://127.0.0.1:10100/api/claude-desktop/first-party-bindings"); + const response = await handleManagementAPI(new Request(url, { + method: "PUT", + headers: { Host: url.host, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), url, config); + if (!response) throw new Error("route not handled"); + return response; +} + +test("PUT first-party-bindings persists, adopts into the live config and reports in status", async () => { + const live = bindingConfig(); + saveConfig(live); + const saved = await putBindings(live, { set: { "claude-sonnet-4-6": "fake/fake-model" } }); + expect(saved.status).toBe(200); + expect(await saved.json()).toEqual({ ok: true, modelBindings: { "claude-sonnet-4-6": "fake/fake-model" } }); + expect(live.claudeCode?.intercept?.modelMap).toEqual({ "claude-sonnet-4-6": "fake/fake-model" }); + const onDisk = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(onDisk.claudeCode?.intercept?.modelMap).toEqual({ "claude-sonnet-4-6": "fake/fake-model" }); + expect(onDisk.claudeCode?.modelMap).toBeUndefined(); + + const statusUrl = new URL("http://127.0.0.1:10100/api/claude-desktop/status"); + const status = await handleManagementAPI(new Request(statusUrl, { headers: { Host: statusUrl.host } }), statusUrl, live); + const body = await status!.json() as { firstParty: { modelBindings: Record; pickerSuggestions: string[] } }; + expect(body.firstParty.modelBindings).toEqual({ "claude-sonnet-4-6": "fake/fake-model" }); + expect(body.firstParty.pickerSuggestions).toContain("claude-sonnet-4-6"); + + const removed = await putBindings(live, { remove: ["claude-sonnet-4-6"] }); + expect(await removed.json()).toEqual({ ok: true, modelBindings: {} }); + const cleared = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(cleared.claudeCode?.intercept).toBeUndefined(); +}); + +test("PUT first-party-bindings refuses an unavailable route and leaves config unchanged", async () => { + const live = bindingConfig(); + saveConfig(live); + const before = readFileSync(join(root, "config.json"), "utf8"); + const refused = await putBindings(live, { set: { "claude-sonnet-4-6": "nope/missing" } }); + expect(refused.status).toBe(400); + expect(((await refused.json()) as { error: string }).error).toContain("not available"); + const badId = await putBindings(live, { set: { "gpt-6": "fake/fake-model" } }); + expect(badId.status).toBe(400); + expect(readFileSync(join(root, "config.json"), "utf8")).toBe(before); + expect(live.claudeCode?.intercept).toBeUndefined(); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ba8dbbb2172..b107a99efd7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -205,6 +205,7 @@ "claude-inbound.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", + "claude-intercept-model-bindings.test.ts": "claude-integration", "claude-intercept-proxy.test.ts": "claude-integration", "claude-intercept-settings.test.ts": "claude-integration", "claude-management-api.test.ts": "claude-integration", diff --git a/tests/server/claude-intercept-integration.test.ts b/tests/server/claude-intercept-integration.test.ts index 6dc046fe1eb..dd77a2d3d9b 100644 --- a/tests/server/claude-intercept-integration.test.ts +++ b/tests/server/claude-intercept-integration.test.ts @@ -107,6 +107,80 @@ test("Messages through CONNECT reach the router; other paths relay to the config expect(getClaudeInterceptState()).toBeNull(); }, SERVER_BUDGET_MS); +test("a first-party binding routes a picker id on the intercept only; the public listener still passes it through", async () => { + const upstreamHits: string[] = []; + const fakeAnthropic = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + upstreamHits.push(`${req.method} ${new URL(req.url).pathname}`); + return Response.json({ id: "msg_upstream", type: "message", role: "assistant", model: "claude-sonnet-4-6", content: [{ type: "text", text: "upstream" }], stop_reason: "end_turn", usage: { input_tokens: 1, output_tokens: 1 } }); + }, + }); + const providerHits: Array<{ path: string; model: unknown }> = []; + const fakeProvider = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + const body = await req.json().catch(() => ({})) as { model?: unknown; stream?: unknown }; + providerHits.push({ path: new URL(req.url).pathname, model: body.model }); + const chunk = { id: "c1", object: "chat.completion.chunk", created: 1, model: "fake-model", choices: [{ index: 0, delta: { role: "assistant", content: "bound" }, finish_reason: null }] }; + const done = { id: "c1", object: "chat.completion.chunk", created: 1, model: "fake-model", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }; + if (body.stream) { + return new Response(`data: ${JSON.stringify(chunk)}\n\ndata: ${JSON.stringify(done)}\n\ndata: [DONE]\n\n`, { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ id: "c1", object: "chat.completion", created: 1, model: "fake-model", choices: [{ index: 0, message: { role: "assistant", content: "bound" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }); + }, + }); + const interceptPort = await findAvailablePort(0, "127.0.0.1"); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: interceptPort }); + saveConfig({ + port: publicPort, + hostname: "127.0.0.1", + defaultProvider: "bindtarget", + providers: { + bindtarget: { adapter: "openai-chat", baseUrl: `http://127.0.0.1:${fakeProvider.port}/v1`, allowPrivateNetwork: true, apiKey: "sk-fake", models: ["fake-model"], liveModels: false }, + }, + claudeCode: { + anthropicBaseUrl: `http://127.0.0.1:${fakeAnthropic.port}`, + intercept: { port: interceptPort, modelMap: { "claude-sonnet-4-6": "bindtarget/fake-model" } }, + }, + } as unknown as OcxConfig); + const server = startServer(publicPort); + try { + const state = await waitForIntercept(); + const ca = readFileSync(state.caCertPath, "utf8"); + const proxy = `http://127.0.0.1:${state.proxyPort}`; + const request = { model: "claude-sonnet-4-6", max_tokens: 8, messages: [{ role: "user", content: "hi" }] }; + const anthropicHeaders = { "content-type": "application/json", "anthropic-version": "2023-06-01", "x-api-key": "sk-ant-not-real" }; + + // Desktop's Code tab: the picker id arrives through the CONNECT tunnel and is served by the binding. + const bound = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", proxy, tls: { ca }, headers: anthropicHeaders, body: JSON.stringify(request) }); + expect(bound.status).toBe(200); + expect(providerHits.map(hit => hit.model)).toEqual(["fake-model"]); + expect(upstreamHits).toEqual([]); + + // count_tokens for a bound id is estimated locally, never passed through to Anthropic. + const counted = await fetch("https://api.anthropic.com/v1/messages/count_tokens", { method: "POST", proxy, tls: { ca }, headers: anthropicHeaders, body: JSON.stringify(request) }); + expect(counted.status).toBe(200); + expect(upstreamHits).toEqual([]); + + // The same id on the public listener is not a first-party request: native passthrough as before. + const direct = await fetch(`http://127.0.0.1:${publicPort}/v1/messages`, { + method: "POST", + headers: { ...anthropicHeaders, "x-opencodex-api-key": "public-secret" }, + body: JSON.stringify(request), + }); + expect(direct.status).toBe(200); + expect(upstreamHits).toEqual(["POST /v1/messages"]); + expect(providerHits).toHaveLength(1); + } finally { + await server.stop(true); + fakeAnthropic.stop(true); + fakeProvider.stop(true); + } +}, SERVER_BUDGET_MS); + test("an ephemeral public port starts no proxy unless intercept.port is explicit", async () => { const base = { hostname: "127.0.0.1", diff --git a/tests/server/loopback-listener-admission.test.ts b/tests/server/loopback-listener-admission.test.ts index 48d7a366bea..8e30cdc17d0 100644 --- a/tests/server/loopback-listener-admission.test.ts +++ b/tests/server/loopback-listener-admission.test.ts @@ -72,10 +72,11 @@ describe("loopback listener policy view", () => { expect(messagesStart).toBeGreaterThan(countTokensStart); expect(chatStart).toBeGreaterThan(messagesStart); expect(source.slice(countTokensStart, messagesStart)).toContain( - "await handleClaudeCountTokens(req, config, policy)", + // First-party bindings ride on the same call: only the intercept ingress sets the flag. + 'await handleClaudeCountTokens(req, config, policy, { claudeIntercept: ingress === "claude-intercept" })', ); expect(source.slice(messagesStart, chatStart)).toContain( - "await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy)", + 'await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy, { claudeIntercept: ingress === "claude-intercept" })', ); for (const branch of [ source.slice(countTokensStart, messagesStart), From bb003b7aff53556a6a48cfe02677af8d98421ce5 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 09:55:45 +0900 Subject: [PATCH 13/48] docs(readme): put npm first and fold desktop downloads into small chips (#5718) The README led with three large download buttons and a long desktop section. npm install is the first command again; the desktop downloads are four npm-sized badges (macOS .dmg, Windows .msi, Linux .AppImage and .deb) in a collapsed block under it, and the Quick start desktop section is one short paragraph after the CLI install. The unused download SVGs are removed from assets and the npm files list. All seven locales are resynced. --- README.md | 48 +++++++++++++--------------------- assets/download-linux.svg | 10 -------- assets/download-macos.svg | 10 -------- assets/download-windows.svg | 10 -------- package.json | 3 --- readme/README.fr.md | 49 +++++++++++++---------------------- readme/README.ja.md | 47 +++++++++++++--------------------- readme/README.ko.md | 47 +++++++++++++--------------------- readme/README.ru.md | 51 +++++++++++++------------------------ readme/README.tr.md | 49 ++++++++++++----------------------- readme/README.zh-CN.md | 46 ++++++++++++--------------------- readme/README.zh-TW.md | 46 ++++++++++++--------------------- readme/i18n-manifest.json | 14 +++++----- 13 files changed, 140 insertions(+), 290 deletions(-) delete mode 100644 assets/download-linux.svg delete mode 100644 assets/download-macos.svg delete mode 100644 assets/download-windows.svg diff --git a/README.md b/README.md index b215b5d0514..c9814930e6c 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,27 @@ Two commands, and every one of them runs any LLM you point it at.

Follow @claudeebum on X - Latest desktop release npm version license node version

-

- Download OpenCodex for macOS - Download OpenCodex for Windows - Download OpenCodex for Linux -

-

Desktop app (beta): macOS universal .dmg · Windows x64 .msi · Linux x86_64 .AppImage / .deb. Prefer the terminal? Install the CLI:

- ```bash npm install -g @bitkyc08/opencodex ocx start ``` +
+Desktop app (beta) — macOS · Windows · Linux +
+

+ Download for macOS (.dmg) + Download for Windows (.msi) + Download for Linux (.AppImage) + Download for Linux (.deb) +

+
+
@@ -90,29 +93,6 @@ account while existing threads stay pinned to the account that started them. ## Quick start -### Desktop app (beta) - -The desktop app is the same proxy and dashboard in a native window, with a tray and bundled `ocx`. -It attaches to a proxy that is already running, or starts its bundled one, and the dashboard stays -on the proxy port (**http://localhost:10100** unless you configured another). Pick the file for your -platform from the [latest release](https://github.com/lidge-jun/opencodex/releases/latest): - -| Platform | File | Notes | -|---|---|---| -| macOS 13+ (Apple Silicon and Intel) | `OpenCodex--macos.dmg` | Universal build, signed with a Developer ID and notarized | -| Windows (x64) | `OpenCodex--windows-x64.msi` | Not code-signed yet: SmartScreen asks once, choose **More info → Run anyway** | -| Linux (x86_64) | `OpenCodex--linux-x86_64.AppImage` or `-linux-amd64.deb` | The tray needs an AppIndicator-capable desktop | - -Every file has a `.sha256` next to it on the release page. On macOS 14+ the app also ships a -WidgetKit extension that shows proxy status, today's usage and provider quotas; the snapshot model -it renders lives in [`app/`](./app) (`MenuBarCore`). To build the app yourself, run -`bun install && bun run build:gui` at the repository root, then in `desktop/` run -`bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local` on macOS, -or `bun install && bun run prepare-sidecar && bun run build:local` on Windows and Linux (the widget -step needs macOS). The [Desktop App guide](https://opencodex.me/guides/desktop-app/) and the -[macOS Menu Bar App guide](https://opencodex.me/guides/macos-menu-bar/) cover first launch, and -[`AGENTS_INSTALL.md`](./AGENTS_INSTALL.md#where-things-are-installed) lists everything written to disk. - ### Personal install (CLI) ```bash @@ -126,6 +106,12 @@ Open **http://localhost:10100** and configure everything in the web dashboard (40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` re-opens the dashboard at any time. +### Desktop app (beta) + +The same proxy and dashboard in a native window, with a tray and a bundled `ocx`. Download the universal `.dmg` +(macOS 13+), the `.msi` (Windows x64, not code-signed yet) or the `.AppImage` / `.deb` (Linux x86_64) +from the [latest release](https://github.com/lidge-jun/opencodex/releases/latest). The [Desktop App guide](https://opencodex.me/guides/desktop-app/) covers first launch and local builds. + ### ChatGPT account pool opencodex can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, diff --git a/assets/download-linux.svg b/assets/download-linux.svg deleted file mode 100644 index b2555c9a92f..00000000000 --- a/assets/download-linux.svg +++ /dev/null @@ -1,10 +0,0 @@ - - Download OpenCodex for Linux - - - - Download for - Linux - - .AppImage - diff --git a/assets/download-macos.svg b/assets/download-macos.svg deleted file mode 100644 index db9427bed9f..00000000000 --- a/assets/download-macos.svg +++ /dev/null @@ -1,10 +0,0 @@ - - Download OpenCodex for macOS - - - - Download for - macOS - - .dmg - diff --git a/assets/download-windows.svg b/assets/download-windows.svg deleted file mode 100644 index 28e2b1c6455..00000000000 --- a/assets/download-windows.svg +++ /dev/null @@ -1,10 +0,0 @@ - - Download OpenCodex for Windows - - - - Download for - Windows - - .msi - diff --git a/package.json b/package.json index bb40cda5b7a..f6e6177f735 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,6 @@ "src", "gui/dist", "assets/banner.png", - "assets/download-macos.svg", - "assets/download-windows.svg", - "assets/download-linux.svg", "assets/architecture.png", "assets/claude-code-models.gif", "assets/codex-app-picker.png", diff --git a/readme/README.fr.md b/readme/README.fr.md index 44a45659640..f933b1b5fa9 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -8,24 +8,27 @@ Deux commandes suffisent pour que chacun d'eux exécute le LLM de votre choix. Suivre @claudeebum sur X - Dernière version de l'application de bureau version npm licence version de Node

-

- Télécharger OpenCodex pour macOS - Télécharger OpenCodex pour Windows - Télécharger OpenCodex pour Linux -

-

Application de bureau (bêta) : macOS universel .dmg · Windows x64 .msi · Linux x86_64 .AppImage / .deb. Vous préférez le terminal ? Installez la CLI :

- ```bash npm install -g @bitkyc08/opencodex ocx start ``` +
+Application de bureau (bêta) — macOS · Windows · Linux +
+

+ Télécharger pour macOS (.dmg) + Télécharger pour Windows (.msi) + Télécharger pour Linux (.AppImage) + Télécharger pour Linux (.deb) +

+
+
@@ -91,30 +94,6 @@ tandis que les fils existants restent associés au compte qui les a démarrés. ## Démarrage rapide -### Application de bureau (bêta) - -L'application de bureau reprend le même proxy et le même tableau de bord dans une fenêtre native, avec une icône dans la barre d'état et le binaire `ocx` inclus. -Elle se rattache à un proxy déjà en cours d'exécution ou démarre celui qui est fourni, et le tableau de bord reste -sur le port du proxy (**http://localhost:10100** sauf si vous en avez configuré un autre). Choisissez le fichier -correspondant à votre plateforme sur la page de la [dernière version publiée](https://github.com/lidge-jun/opencodex/releases/latest) : - -| Plateforme | Fichier | Remarques | -|---|---|---| -| macOS 13+ (Apple Silicon et Intel) | `OpenCodex--macos.dmg` | Compilation universelle, signée avec un identifiant Developer ID et notariée | -| Windows (x64) | `OpenCodex--windows-x64.msi` | Pas encore signée numériquement : SmartScreen demande une confirmation, choisissez **Informations complémentaires → Exécuter quand même** | -| Linux (x86_64) | `OpenCodex--linux-x86_64.AppImage` ou `-linux-amd64.deb` | La barre d'état nécessite un environnement de bureau compatible AppIndicator | - -Chaque fichier est accompagné d'un `.sha256` sur la page de la version. Sous macOS 14+, l'application embarque -également une extension WidgetKit qui affiche l'état du proxy, l'utilisation du jour et les quotas des -fournisseurs ; le modèle de données des instantanés qu'elle affiche se trouve dans [`app/`](../app) -(`MenuBarCore`). Pour compiler l'application vous-même, exécutez -`bun install && bun run build:gui` à la racine du dépôt, puis, dans `desktop/`, -`bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local` sous macOS, -ou `bun install && bun run prepare-sidecar && bun run build:local` sous Windows et Linux (l'étape du widget -exige macOS). Le [guide de l'application de bureau](https://opencodex.me/fr/guides/desktop-app/) et le -[guide de l'application macOS dans la barre des menus](https://opencodex.me/fr/guides/macos-menu-bar/) détaillent le premier lancement, et -[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) répertorie tout ce qui est écrit sur le disque. - ### Installation personnelle (CLI) ```bash @@ -129,6 +108,12 @@ fournisseurs (plus de 40 intégrés, ou n'importe quel point de terminaison comp choisissez les modèles, gérez les comptes. `ocx gui` rouvre le tableau de bord à tout moment. +### Application de bureau (bêta) + +Le même proxy et le même tableau de bord dans une fenêtre native, avec une icône dans la barre système et `ocx` intégré. +Téléchargez depuis la [dernière version](https://github.com/lidge-jun/opencodex/releases/latest) le `.dmg` universel (macOS 13+), le `.msi` (Windows x64, pas encore signé) +ou l'`.AppImage` / le `.deb` (Linux x86_64). Le [guide de l'application de bureau](https://opencodex.me/fr/guides/desktop-app/) décrit le premier lancement et la compilation locale. + ### Groupe de comptes ChatGPT opencodex peut également gérer un **groupe de comptes ChatGPT** pour l'authentification Codex. Ajoutez plusieurs diff --git a/readme/README.ja.md b/readme/README.ja.md index 5817cab5df2..1c9f5b6c02d 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -8,24 +8,27 @@

X で @claudeebum をフォロー - 最新のデスクトップリリース npm version license node version

-

- macOS 向け OpenCodex をダウンロード - Windows 向け OpenCodex をダウンロード - Linux 向け OpenCodex をダウンロード -

-

デスクトップアプリ(ベータ版): macOS ユニバーサル .dmg · Windows x64 .msi · Linux x86_64 .AppImage / .deb。ターミナル派なら CLI をインストール:

- ```bash npm install -g @bitkyc08/opencodex ocx start ``` +
+デスクトップアプリ(ベータ版) — macOS · Windows · Linux +
+

+ macOS 版をダウンロード (.dmg) + Windows 版をダウンロード (.msi) + Linux 版をダウンロード (.AppImage) + Linux 版をダウンロード (.deb) +

+
+
@@ -90,28 +93,6 @@ Desktop、Grok Build から使えます。Codex 認証用の **ChatGPT アカウ ## クイックスタート -### デスクトップアプリ(ベータ版) - -デスクトップアプリは、同じプロキシとダッシュボードをネイティブウィンドウに収め、トレイと同梱の `ocx` を備えたものです。 -すでに起動しているプロキシに接続するか、同梱のプロキシを起動します。ダッシュボードはプロキシの -ポートで開きます(別のポートを設定していなければ **http://localhost:10100**)。 -[最新リリース](https://github.com/lidge-jun/opencodex/releases/latest)から、お使いのプラットフォーム向けのファイルを選んでください。 - -| プラットフォーム | ファイル | 備考 | -|---|---|---| -| macOS 13 以降(Apple Silicon と Intel) | `OpenCodex--macos.dmg` | ユニバーサルビルド。Developer ID で署名・公証済み | -| Windows(x64) | `OpenCodex--windows-x64.msi` | まだコード署名なし。SmartScreen が一度だけ確認するので、**詳細情報 → 実行**を選択 | -| Linux(x86_64) | `OpenCodex--linux-x86_64.AppImage` または `-linux-amd64.deb` | トレイには AppIndicator 対応のデスクトップが必要 | - -リリースページでは各ファイルの横に `.sha256` があります。macOS 14 以降では、プロキシの状態、 -今日の使用量、プロバイダーのクォータを表示する WidgetKit 拡張も付属します。表示に使う -スナップショットモデルは [`app/`](../app)(`MenuBarCore`)にあります。アプリを自分でビルドするには、 -リポジトリのルートで `bun install && bun run build:gui` を実行し、 -`desktop/` で macOS なら `bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local`、Windows と Linux なら `bun install && bun run prepare-sidecar && bun run build:local` を実行します(ウィジェットの手順は macOS 専用です)。 -[デスクトップアプリガイド](https://opencodex.me/ja/guides/desktop-app/)と -[macOS メニューバーアプリガイド](https://opencodex.me/ja/guides/macos-menu-bar/)で初回起動について説明しています。 -[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) にはディスクに書き込まれるすべてのものをまとめています。 - ### 個人向けインストール(CLI) ```bash @@ -125,6 +106,12 @@ ocx start # プロキシとダッシュボードが loca 組み込み、または任意の OpenAI 互換エンドポイント)、モデルの選択、アカウントの管理はここで行います。 `ocx gui` でいつでもダッシュボードを開き直せます。 +### デスクトップアプリ(ベータ版) + +同じプロキシとダッシュボードをネイティブウィンドウで使えるアプリで、トレイと `ocx` を同梱しています。 +[最新リリース](https://github.com/lidge-jun/opencodex/releases/latest)から、macOS 13 以降向けユニバーサル `.dmg`、Windows x64 向け `.msi`(まだコード署名なし)、 +Linux x86_64 向け `.AppImage` / `.deb` をダウンロードしてください。初回起動とローカルビルドは[デスクトップアプリガイド](https://opencodex.me/ja/guides/desktop-app/)を参照してください。 + ### ChatGPT アカウントプール opencodex では、Codex 認証用の **ChatGPT アカウントプール**も管理できます。ChatGPT / Codex のアカウントを diff --git a/readme/README.ko.md b/readme/README.ko.md index bb775214799..5f141d77792 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -8,24 +8,27 @@

X에서 @claudeebum 팔로우 - 최신 데스크톱 릴리스 npm version license node version

-

- macOS용 OpenCodex 다운로드 - Windows용 OpenCodex 다운로드 - Linux용 OpenCodex 다운로드 -

-

데스크톱 앱 (베타): macOS 유니버설 .dmg · Windows x64 .msi · Linux x86_64 .AppImage / .deb. 터미널이 더 편하다면 CLI를 설치하세요:

- ```bash npm install -g @bitkyc08/opencodex ocx start ``` +
+데스크톱 앱 (베타) — macOS · Windows · Linux +
+

+ macOS용 다운로드 (.dmg) + Windows용 다운로드 (.msi) + Linux용 다운로드 (.AppImage) + Linux용 다운로드 (.deb) +

+
+
@@ -90,28 +93,6 @@ DeepSeek, Kimi, Qwen, Ollama를 비롯한 어떤 LLM이든 Codex, Claude Code, C ## 빠른 시작 -### 데스크톱 앱 (베타) - -데스크톱 앱은 같은 프록시와 대시보드를 네이티브 창에 담은 것으로, 트레이와 번들된 `ocx`를 갖춥니다. -이미 실행 중인 프록시에 붙거나 번들된 프록시를 시작하며, 대시보드는 프록시 포트에서 열립니다 -(다른 포트를 설정하지 않았다면 **http://localhost:10100**). 플랫폼에 맞는 파일을 -[최신 릴리스](https://github.com/lidge-jun/opencodex/releases/latest)에서 고르세요. - -| 플랫폼 | 파일 | 참고 | -|---|---|---| -| macOS 13+ (Apple Silicon 및 Intel) | `OpenCodex--macos.dmg` | 유니버설 빌드, Developer ID로 서명 및 공증됨 | -| Windows (x64) | `OpenCodex--windows-x64.msi` | 아직 코드 서명되지 않음: SmartScreen이 한 번 물으면 **추가 정보 → 실행**을 선택 | -| Linux (x86_64) | `OpenCodex--linux-x86_64.AppImage` 또는 `-linux-amd64.deb` | 트레이는 AppIndicator를 지원하는 데스크톱이 필요 | - -릴리스 페이지에서 모든 파일 옆에 `.sha256`이 함께 있습니다. macOS 14 이상에서는 프록시 상태, -오늘의 사용량, 프로바이더 쿼터를 보여 주는 WidgetKit 확장도 함께 설치됩니다. 위젯이 그리는 -스냅샷 모델은 [`app/`](../app)의 `MenuBarCore`에 있습니다. 앱을 직접 빌드하려면 저장소 루트에서 -`bun install && bun run build:gui`를 실행한 다음, -`desktop/`에서 macOS라면 `bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local`을, Windows와 Linux라면 `bun install && bun run prepare-sidecar && bun run build:local`을 실행하세요. 위젯 빌드 단계는 macOS에서만 돌아갑니다. -[데스크톱 앱 가이드](https://opencodex.me/ko/guides/desktop-app/)와 -[macOS 메뉴 막대 앱 가이드](https://opencodex.me/ko/guides/macos-menu-bar/)에서 첫 실행 안내를 볼 수 있고, -[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed)에는 디스크에 쓰는 모든 항목이 정리되어 있습니다. - ### 개인 설치 (CLI) ```bash @@ -124,6 +105,12 @@ ocx start # 프록시 + 대시보드: localhost:10100 **http://localhost:10100**을 열고 웹 대시보드에서 전부 설정하세요. 프로바이더 추가(내장 40개 이상, 또는 OpenAI 호환 엔드포인트), 모델 선택, 계정 관리까지 모두 여기서 합니다. `ocx gui`로 대시보드를 언제든 다시 엽니다. +### 데스크톱 앱 (베타) + +같은 프록시와 대시보드를 네이티브 창으로 띄우는 앱으로, 트레이와 `ocx`가 함께 들어 있습니다. +[최신 릴리스](https://github.com/lidge-jun/opencodex/releases/latest)에서 macOS 13 이상용 유니버설 `.dmg`, Windows x64용 `.msi`(아직 코드 서명 전), +Linux x86_64용 `.AppImage` / `.deb`를 받으세요. 첫 실행과 직접 빌드하는 방법은 [데스크톱 앱 가이드](https://opencodex.me/ko/guides/desktop-app/)에 있습니다. + ### ChatGPT 계정 풀 opencodex는 Codex 인증용 **ChatGPT 계정 풀**도 관리합니다. ChatGPT / Codex 계정을 여러 개 넣고, diff --git a/readme/README.ru.md b/readme/README.ru.md index 95edd73b330..21ea349b83d 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -8,24 +8,27 @@

Подписывайтесь на @claudeebum в X - Последний релиз настольного приложения версия npm лицензия версия Node

-

- Скачать OpenCodex для macOS - Скачать OpenCodex для Windows - Скачать OpenCodex для Linux -

-

Настольное приложение (бета): универсальный .dmg для macOS · .msi для Windows x64 · .AppImage / .deb для Linux x86_64. Предпочитаете терминал? Установите CLI:

- ```bash npm install -g @bitkyc08/opencodex ocx start ``` +
+Настольное приложение (бета) — macOS · Windows · Linux +
+

+ Скачать для macOS (.dmg) + Скачать для Windows (.msi) + Скачать для Linux (.AppImage) + Скачать для Linux (.deb) +

+
+
@@ -92,32 +95,6 @@ Ollama или любую другую LLM с Codex, Claude Code, Claude Desktop ## Быстрый старт -### Настольное приложение (бета) - -Настольное приложение — это тот же прокси и та же панель управления в нативном окне, -с иконкой в трее и встроенным `ocx`. Оно подключается к уже запущенному прокси либо -запускает встроенный, а панель остаётся на порту прокси (**http://localhost:10100**, -если вы не настроили другой). Выберите файл для своей платформы в -[последнем релизе](https://github.com/lidge-jun/opencodex/releases/latest): - -| Платформа | Файл | Примечания | -|---|---|---| -| macOS 13+ (Apple Silicon и Intel) | `OpenCodex--macos.dmg` | Универсальная сборка, подписана Developer ID и нотариализована | -| Windows (x64) | `OpenCodex--windows-x64.msi` | Пока без цифровой подписи: SmartScreen спросит один раз — выберите **Подробнее → Выполнить в любом случае** | -| Linux (x86_64) | `OpenCodex--linux-x86_64.AppImage` или `-linux-amd64.deb` | Для трея нужен рабочий стол с поддержкой AppIndicator | - -Рядом с каждым файлом на странице релиза есть `.sha256`. На macOS 14+ приложение также -поставляется с расширением WidgetKit, которое показывает состояние прокси, расход за -сегодня и квоты провайдеров; модель снимков, которую оно отображает, находится в -[`app/`](../app) (`MenuBarCore`). Чтобы собрать приложение самостоятельно, выполните -`bun install && bun run build:gui` в корне репозитория, затем в `desktop/` выполните -`bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local` на macOS -или `bun install && bun run prepare-sidecar && bun run build:local` на Windows и Linux (шаг с виджетом -работает только на macOS). В [руководстве по настольному приложению](https://opencodex.me/ru/guides/desktop-app/) и -[руководстве по приложению macOS в строке меню](https://opencodex.me/ru/guides/macos-menu-bar/) -описан первый запуск, а -[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) перечисляет всё, что записывается на диск. - ### Личная установка (CLI) ```bash @@ -131,6 +108,12 @@ ocx start # прокси + панель управлен (40+ встроенных или любой OpenAI-совместимый endpoint), выберите модели, управляйте аккаунтами. `ocx gui` в любой момент снова откроет панель. +### Настольное приложение (бета) + +Тот же прокси и дашборд в нативном окне, с треем и встроенным `ocx`. Скачайте из [последнего релиза](https://github.com/lidge-jun/opencodex/releases/latest) +универсальный `.dmg` (macOS 13+), `.msi` (Windows x64, пока без цифровой подписи) или `.AppImage` / `.deb` +(Linux x86_64). Первый запуск и локальная сборка описаны в [руководстве по настольному приложению](https://opencodex.me/ru/guides/desktop-app/). + ### Пул аккаунтов ChatGPT opencodex также умеет управлять **пулом аккаунтов ChatGPT** для аутентификации Codex. Добавьте diff --git a/readme/README.tr.md b/readme/README.tr.md index 7d802bb2f61..ebc26dab394 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -8,24 +8,27 @@

X üzerinde @claudeebum hesabını takip et - En güncel masaüstü sürümü npm sürümü lisans node sürümü

-

- macOS için OpenCodex'i indir - Windows için OpenCodex'i indir - Linux için OpenCodex'i indir -

-

Masaüstü uygulaması (beta): macOS evrensel .dmg · Windows x64 .msi · Linux x86_64 .AppImage / .deb. Terminali mi tercih ediyorsunuz? CLI'yı kurun:

- ```bash npm install -g @bitkyc08/opencodex ocx start ``` +
+Masaüstü uygulaması (beta) — macOS · Windows · Linux +
+

+ macOS için indir (.dmg) + Windows için indir (.msi) + Linux için indir (.AppImage) + Linux için indir (.deb) +

+
+
@@ -90,31 +93,6 @@ kullanılan sağlıklı hesaba kendiliğinden gitsin; mevcut dizilerse onları b ## Hızlı başlangıç -### Masaüstü uygulaması (beta) - -Masaüstü uygulaması; aynı proxy ve kontrol panelini yerel bir pencerede, menü çubuğu simgesi ve -paketlenmiş `ocx` ile sunar. Zaten çalışan bir proxy'ye bağlanır ya da kendi paketlenmiş proxy'sini -başlatır; kontrol paneli proxy bağlantı noktasında kalır (başka bir tane yapılandırmadıysanız -**http://localhost:10100**). Platformunuza uygun dosyayı -[en güncel sürümden](https://github.com/lidge-jun/opencodex/releases/latest) seçin: - -| Platform | Dosya | Notlar | -|---|---|---| -| macOS 13+ (Apple Silicon ve Intel) | `OpenCodex--macos.dmg` | Evrensel derleme, Developer ID ile imzalı ve noter onaylı | -| Windows (x64) | `OpenCodex--windows-x64.msi` | Henüz kod imzalı değil: SmartScreen bir kez sorar, **Diğer bilgiler → Yine de çalıştır** seçin | -| Linux (x86_64) | `OpenCodex--linux-x86_64.AppImage` veya `-linux-amd64.deb` | Menü çubuğu simgesi AppIndicator destekli bir masaüstü gerektirir | - -Her dosyanın yanında sürüm sayfasında bir `.sha256` bulunur. macOS 14 ve üzerinde uygulama ayrıca -proxy durumunu, bugünkü kullanımı ve sağlayıcı kotalarını gösteren bir WidgetKit uzantısıyla gelir; -görüntülediği anlık görüntü modeli [`app/`](../app) dizinindedir (`MenuBarCore`). Uygulamayı kendiniz -derlemek için depo kökünde `bun install && bun run build:gui`, ardından `desktop/` içinde macOS'ta -`bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local`, -Windows ve Linux'ta ise `bun install && bun run prepare-sidecar && bun run build:local` çalıştırın (widget adımı yalnızca macOS'ta çalışır). -[Masaüstü uygulaması kılavuzu](https://opencodex.me/tr/guides/desktop-app/) ve -[macOS menü çubuğu uygulaması kılavuzu](https://opencodex.me/tr/guides/macos-menu-bar/) ilk açılışı -anlatır; [`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) diske yazılan her -şeyi listeler. - ### Kişisel kurulum (CLI) ```bash @@ -128,6 +106,11 @@ Arka planda çalıştırmak için `ocx service` kullanın. ekleyin (40'tan fazla hazır sağlayıcı ya da herhangi bir OpenAI uyumlu uç nokta), model seçin, hesap yönetin. `ocx gui` paneli istediğiniz zaman yeniden açar. +### Masaüstü uygulaması (beta) + +Aynı proxy ve kontrol paneli, tepsi simgesi ve yerleşik `ocx` ile yerel bir pencerede. [Son sürümden](https://github.com/lidge-jun/opencodex/releases/latest) +evrensel `.dmg` (macOS 13+), `.msi` (Windows x64, henüz kod imzalı değil) veya `.AppImage` / `.deb` (Linux x86_64) +dosyasını indirin. İlk açılış ve yerel derleme [masaüstü uygulaması kılavuzunda](https://opencodex.me/tr/guides/desktop-app/) anlatılıyor. ### ChatGPT hesap havuzu diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index ebe804682c7..d25de14a62b 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -8,24 +8,27 @@

在 X 上关注 @claudeebum - 最新桌面版发布 npm 版本 许可证 Node 版本

-

- 下载 macOS 版 OpenCodex - 下载 Windows 版 OpenCodex - 下载 Linux 版 OpenCodex -

-

桌面应用(测试版):macOS 通用 .dmg · Windows x64 .msi · Linux x86_64 .AppImage / .deb。更喜欢终端?安装 CLI:

- ```bash npm install -g @bitkyc08/opencodex ocx start ``` +
+桌面应用(测试版) — macOS · Windows · Linux +
+

+ 下载 macOS 版 (.dmg) + 下载 Windows 版 (.msi) + 下载 Linux 版 (.AppImage) + 下载 Linux 版 (.deb) +

+
+
@@ -89,28 +92,6 @@ Codex 认证管理一个 **ChatGPT 账户池**:添加账户,在仪表板中 ## 快速开始 -### 桌面应用(测试版) - -桌面应用把同一个代理和仪表板装进原生窗口,附带系统托盘和内置的 `ocx`。 -它会连接已在运行的代理,或启动自带的代理;仪表板仍使用代理端口 -(未另行配置时为 **http://localhost:10100**)。从 -[最新发布版本](https://github.com/lidge-jun/opencodex/releases/latest)中选择适合你平台的文件: - -| 平台 | 文件 | 说明 | -|---|---|---| -| macOS 13+(Apple Silicon 和 Intel) | `OpenCodex--macos.dmg` | 通用构建,使用 Developer ID 签名并完成公证 | -| Windows (x64) | `OpenCodex--windows-x64.msi` | 尚未进行代码签名:SmartScreen 会询问一次,选择 **更多信息 → 仍要运行** | -| Linux (x86_64) | `OpenCodex--linux-x86_64.AppImage` 或 `-linux-amd64.deb` | 托盘需要支持 AppIndicator 的桌面环境 | - -每个文件在发布页面上都带有对应的 `.sha256`。在 macOS 14+ 上,应用还附带一个 -WidgetKit 扩展,可显示代理状态、今日用量和提供商配额;它所呈现的快照模型位于 -[`app/`](../app)(`MenuBarCore`)。如需自行构建应用,先在仓库根目录运行 -`bun install && bun run build:gui`,然后在 -`desktop/` 中运行:macOS 上用 `bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local`,Windows 和 Linux 上用 `bun install && bun run prepare-sidecar && bun run build:local`(小组件步骤只能在 macOS 上执行)。 -[桌面应用指南](https://opencodex.me/zh-cn/guides/desktop-app/)和 -[macOS 菜单栏应用指南](https://opencodex.me/zh-cn/guides/macos-menu-bar/)介绍了首次启动, -[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed)列出了写入磁盘的所有内容。 - ### 个人安装(CLI) ```bash @@ -124,6 +105,11 @@ ocx start # 代理 + 仪表板:localhost:10100 (40 多个内置,或任意 OpenAI 兼容端点)、选择模型、管理账户。随时运行 `ocx gui` 可重新打开仪表板。 +### 桌面应用(测试版) + +同一个代理和控制台,装进原生窗口,附带托盘和内置的 `ocx`。从[最新版本](https://github.com/lidge-jun/opencodex/releases/latest)下载 macOS 13+ 通用 `.dmg`、 +Windows x64 `.msi`(尚未代码签名)或 Linux x86_64 `.AppImage` / `.deb`。首次启动和本地构建见[桌面应用指南](https://opencodex.me/zh-cn/guides/desktop-app/)。 + ### ChatGPT 账户池 opencodex 还能为 Codex 认证管理一个 **ChatGPT 账户池**。添加多个 ChatGPT / Codex 账户, diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index abc4f6a03e5..64a8e939486 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -8,24 +8,27 @@

在 X 上關注 @claudeebum - 最新桌面版發行 npm 版本 授權 Node 版本

-

- 下載 macOS 版 OpenCodex - 下載 Windows 版 OpenCodex - 下載 Linux 版 OpenCodex -

-

桌面應用程式(Beta):macOS 通用 .dmg · Windows x64 .msi · Linux x86_64 .AppImage / .deb。偏好終端機?安裝 CLI:

- ```bash npm install -g @bitkyc08/opencodex ocx start ``` +
+桌面應用程式(Beta) — macOS · Windows · Linux +
+

+ 下載 macOS 版 (.dmg) + 下載 Windows 版 (.msi) + 下載 Linux 版 (.AppImage) + 下載 Linux 版 (.deb) +

+
+
@@ -88,28 +91,6 @@ Gemini、Grok、GLM、DeepSeek、Kimi、Qwen、Ollama 或任何其他 LLM。它 ## 快速開始 -### 桌面應用程式(Beta) - -桌面應用程式是同一套代理與儀表板的原生視窗版本,附系統匣與內建的 `ocx`。 -它會接上已在執行的代理,或啟動內建的那一個;儀表板仍使用代理的連接埠 -(除非你設定了其他連接埠,否則為 **http://localhost:10100**)。請從 -[最新發行版](https://github.com/lidge-jun/opencodex/releases/latest)挑選適合你平台的檔案: - -| 平台 | 檔案 | 說明 | -|---|---|---| -| macOS 13+(Apple Silicon 與 Intel) | `OpenCodex--macos.dmg` | 通用建置,以 Developer ID 簽章並經過公證 | -| Windows(x64) | `OpenCodex--windows-x64.msi` | 尚未經程式碼簽章:SmartScreen 會詢問一次,選擇 **More info → Run anyway** | -| Linux(x86_64) | `OpenCodex--linux-x86_64.AppImage` 或 `-linux-amd64.deb` | 系統匣需要支援 AppIndicator 的桌面環境 | - -每個檔案在發行頁面上都附有 `.sha256`。在 macOS 14+ 上,應用程式還附帶 -WidgetKit 擴充套件,可顯示代理狀態、今日用量與供應商配額;它所呈現的快照模型位於 -[`app/`](../app)(`MenuBarCore`)。若要自行建置應用程式,先在儲存庫根目錄執行 -`bun install && bun run build:gui`,再於 -`desktop/` 執行:macOS 上用 `bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local`,Windows 與 Linux 上用 `bun install && bun run prepare-sidecar && bun run build:local`(小工具步驟只能在 macOS 上執行)。 -[桌面應用程式指南](https://opencodex.me/zh-tw/guides/desktop-app/) 與 -[macOS 選單列應用程式指南](https://opencodex.me/zh-tw/guides/macos-menu-bar/) 涵蓋首次啟動, -[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) 列出所有寫入磁碟的內容。 - ### 個人安裝(CLI) ```bash @@ -123,6 +104,11 @@ ocx start # 代理 + 儀表板位於 localhost:10100 (40+ 內建,或任何 OpenAI 相容端點)、挑選模型、管理帳號。隨時可用 `ocx gui` 重新開啟儀表板。 +### 桌面應用程式(Beta) + +同一個代理與儀表板,放進原生視窗,附帶系統匣與內建的 `ocx`。請從[最新版本](https://github.com/lidge-jun/opencodex/releases/latest)下載 macOS 13+ 通用 `.dmg`、 +Windows x64 `.msi`(尚未進行程式碼簽署)或 Linux x86_64 `.AppImage` / `.deb`。首次啟動與本機建置請見[桌面應用程式指南](https://opencodex.me/zh-tw/guides/desktop-app/)。 + ### ChatGPT 帳號池 opencodex 也能為 Codex 認證管理 **ChatGPT 帳號池**。新增多個 ChatGPT / Codex 帳號, diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index a2805b162aa..a74517ea89f 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "84b94b81fd4db947b7ad86fd77ce3404f1370e70ef32e23ee42b4bf7a7378813" + "sourceSha256": "ae4326e89fb9f7c432f10a4ee65eac7aaa72b1ffcf76c75f05cbb571b4ccfb89" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "84b94b81fd4db947b7ad86fd77ce3404f1370e70ef32e23ee42b4bf7a7378813" + "sourceSha256": "ae4326e89fb9f7c432f10a4ee65eac7aaa72b1ffcf76c75f05cbb571b4ccfb89" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "84b94b81fd4db947b7ad86fd77ce3404f1370e70ef32e23ee42b4bf7a7378813" + "sourceSha256": "ae4326e89fb9f7c432f10a4ee65eac7aaa72b1ffcf76c75f05cbb571b4ccfb89" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "84b94b81fd4db947b7ad86fd77ce3404f1370e70ef32e23ee42b4bf7a7378813" + "sourceSha256": "ae4326e89fb9f7c432f10a4ee65eac7aaa72b1ffcf76c75f05cbb571b4ccfb89" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "84b94b81fd4db947b7ad86fd77ce3404f1370e70ef32e23ee42b4bf7a7378813" + "sourceSha256": "ae4326e89fb9f7c432f10a4ee65eac7aaa72b1ffcf76c75f05cbb571b4ccfb89" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "84b94b81fd4db947b7ad86fd77ce3404f1370e70ef32e23ee42b4bf7a7378813" + "sourceSha256": "ae4326e89fb9f7c432f10a4ee65eac7aaa72b1ffcf76c75f05cbb571b4ccfb89" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "84b94b81fd4db947b7ad86fd77ce3404f1370e70ef32e23ee42b4bf7a7378813" + "sourceSha256": "ae4326e89fb9f7c432f10a4ee65eac7aaa72b1ffcf76c75f05cbb571b4ccfb89" } } } From 37f93da6ac48db348ba83194aa773981fdee775e Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 09:59:09 +0900 Subject: [PATCH 14/48] docs(readme): show desktop chips openly and fold the full desktop section under the CLI install (#5719) The four download chips under the npm command are no longer collapsed. The full Desktop app (beta) section (platform table, checksums, widget, local build) returns under Personal install (CLI) inside a collapsed block. All seven locales resynced. --- README.md | 32 ++++++++++++++++++++++++-------- readme/README.fr.md | 33 +++++++++++++++++++++++++-------- readme/README.ja.md | 31 +++++++++++++++++++++++-------- readme/README.ko.md | 31 +++++++++++++++++++++++-------- readme/README.ru.md | 35 +++++++++++++++++++++++++++-------- readme/README.tr.md | 34 ++++++++++++++++++++++++++-------- readme/README.zh-CN.md | 30 +++++++++++++++++++++++------- readme/README.zh-TW.md | 30 +++++++++++++++++++++++------- readme/i18n-manifest.json | 14 +++++++------- 9 files changed, 201 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index c9814930e6c..f663fb97072 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,12 @@ npm install -g @bitkyc08/opencodex ocx start ``` -
-Desktop app (beta) — macOS · Windows · Linux -

Download for macOS (.dmg) Download for Windows (.msi) Download for Linux (.AppImage) Download for Linux (.deb)

-
@@ -106,11 +102,31 @@ Open **http://localhost:10100** and configure everything in the web dashboard (40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` re-opens the dashboard at any time. -### Desktop app (beta) +
+Desktop app (beta) + +The desktop app is the same proxy and dashboard in a native window, with a tray and bundled `ocx`. +It attaches to a proxy that is already running, or starts its bundled one, and the dashboard stays +on the proxy port (**http://localhost:10100** unless you configured another). Pick the file for your +platform from the [latest release](https://github.com/lidge-jun/opencodex/releases/latest): + +| Platform | File | Notes | +|---|---|---| +| macOS 13+ (Apple Silicon and Intel) | `OpenCodex--macos.dmg` | Universal build, signed with a Developer ID and notarized | +| Windows (x64) | `OpenCodex--windows-x64.msi` | Not code-signed yet: SmartScreen asks once, choose **More info → Run anyway** | +| Linux (x86_64) | `OpenCodex--linux-x86_64.AppImage` or `-linux-amd64.deb` | The tray needs an AppIndicator-capable desktop | + +Every file has a `.sha256` next to it on the release page. On macOS 14+ the app also ships a +WidgetKit extension that shows proxy status, today's usage and provider quotas; the snapshot model +it renders lives in [`app/`](./app) (`MenuBarCore`). To build the app yourself, run +`bun install && bun run build:gui` at the repository root, then in `desktop/` run +`bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local` on macOS, +or `bun install && bun run prepare-sidecar && bun run build:local` on Windows and Linux (the widget +step needs macOS). The [Desktop App guide](https://opencodex.me/guides/desktop-app/) and the +[macOS Menu Bar App guide](https://opencodex.me/guides/macos-menu-bar/) cover first launch, and +[`AGENTS_INSTALL.md`](./AGENTS_INSTALL.md#where-things-are-installed) lists everything written to disk. -The same proxy and dashboard in a native window, with a tray and a bundled `ocx`. Download the universal `.dmg` -(macOS 13+), the `.msi` (Windows x64, not code-signed yet) or the `.AppImage` / `.deb` (Linux x86_64) -from the [latest release](https://github.com/lidge-jun/opencodex/releases/latest). The [Desktop App guide](https://opencodex.me/guides/desktop-app/) covers first launch and local builds. +
### ChatGPT account pool diff --git a/readme/README.fr.md b/readme/README.fr.md index f933b1b5fa9..08961e23f41 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -18,16 +18,12 @@ npm install -g @bitkyc08/opencodex ocx start ``` -
-Application de bureau (bêta) — macOS · Windows · Linux -

Télécharger pour macOS (.dmg) Télécharger pour Windows (.msi) Télécharger pour Linux (.AppImage) Télécharger pour Linux (.deb)

-
@@ -108,11 +104,32 @@ fournisseurs (plus de 40 intégrés, ou n'importe quel point de terminaison comp choisissez les modèles, gérez les comptes. `ocx gui` rouvre le tableau de bord à tout moment. -### Application de bureau (bêta) +
+Application de bureau (bêta) + +L'application de bureau reprend le même proxy et le même tableau de bord dans une fenêtre native, avec une icône dans la barre d'état et le binaire `ocx` inclus. +Elle se rattache à un proxy déjà en cours d'exécution ou démarre celui qui est fourni, et le tableau de bord reste +sur le port du proxy (**http://localhost:10100** sauf si vous en avez configuré un autre). Choisissez le fichier +correspondant à votre plateforme sur la page de la [dernière version publiée](https://github.com/lidge-jun/opencodex/releases/latest) : + +| Plateforme | Fichier | Remarques | +|---|---|---| +| macOS 13+ (Apple Silicon et Intel) | `OpenCodex--macos.dmg` | Compilation universelle, signée avec un identifiant Developer ID et notariée | +| Windows (x64) | `OpenCodex--windows-x64.msi` | Pas encore signée numériquement : SmartScreen demande une confirmation, choisissez **Informations complémentaires → Exécuter quand même** | +| Linux (x86_64) | `OpenCodex--linux-x86_64.AppImage` ou `-linux-amd64.deb` | La barre d'état nécessite un environnement de bureau compatible AppIndicator | + +Chaque fichier est accompagné d'un `.sha256` sur la page de la version. Sous macOS 14+, l'application embarque +également une extension WidgetKit qui affiche l'état du proxy, l'utilisation du jour et les quotas des +fournisseurs ; le modèle de données des instantanés qu'elle affiche se trouve dans [`app/`](../app) +(`MenuBarCore`). Pour compiler l'application vous-même, exécutez +`bun install && bun run build:gui` à la racine du dépôt, puis, dans `desktop/`, +`bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local` sous macOS, +ou `bun install && bun run prepare-sidecar && bun run build:local` sous Windows et Linux (l'étape du widget +exige macOS). Le [guide de l'application de bureau](https://opencodex.me/fr/guides/desktop-app/) et le +[guide de l'application macOS dans la barre des menus](https://opencodex.me/fr/guides/macos-menu-bar/) détaillent le premier lancement, et +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) répertorie tout ce qui est écrit sur le disque. -Le même proxy et le même tableau de bord dans une fenêtre native, avec une icône dans la barre système et `ocx` intégré. -Téléchargez depuis la [dernière version](https://github.com/lidge-jun/opencodex/releases/latest) le `.dmg` universel (macOS 13+), le `.msi` (Windows x64, pas encore signé) -ou l'`.AppImage` / le `.deb` (Linux x86_64). Le [guide de l'application de bureau](https://opencodex.me/fr/guides/desktop-app/) décrit le premier lancement et la compilation locale. +
### Groupe de comptes ChatGPT diff --git a/readme/README.ja.md b/readme/README.ja.md index 1c9f5b6c02d..dd5b8543a63 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -18,16 +18,12 @@ npm install -g @bitkyc08/opencodex ocx start ``` -
-デスクトップアプリ(ベータ版) — macOS · Windows · Linux -

macOS 版をダウンロード (.dmg) Windows 版をダウンロード (.msi) Linux 版をダウンロード (.AppImage) Linux 版をダウンロード (.deb)

-
@@ -106,11 +102,30 @@ ocx start # プロキシとダッシュボードが loca 組み込み、または任意の OpenAI 互換エンドポイント)、モデルの選択、アカウントの管理はここで行います。 `ocx gui` でいつでもダッシュボードを開き直せます。 -### デスクトップアプリ(ベータ版) +
+デスクトップアプリ(ベータ版) + +デスクトップアプリは、同じプロキシとダッシュボードをネイティブウィンドウに収め、トレイと同梱の `ocx` を備えたものです。 +すでに起動しているプロキシに接続するか、同梱のプロキシを起動します。ダッシュボードはプロキシの +ポートで開きます(別のポートを設定していなければ **http://localhost:10100**)。 +[最新リリース](https://github.com/lidge-jun/opencodex/releases/latest)から、お使いのプラットフォーム向けのファイルを選んでください。 + +| プラットフォーム | ファイル | 備考 | +|---|---|---| +| macOS 13 以降(Apple Silicon と Intel) | `OpenCodex--macos.dmg` | ユニバーサルビルド。Developer ID で署名・公証済み | +| Windows(x64) | `OpenCodex--windows-x64.msi` | まだコード署名なし。SmartScreen が一度だけ確認するので、**詳細情報 → 実行**を選択 | +| Linux(x86_64) | `OpenCodex--linux-x86_64.AppImage` または `-linux-amd64.deb` | トレイには AppIndicator 対応のデスクトップが必要 | + +リリースページでは各ファイルの横に `.sha256` があります。macOS 14 以降では、プロキシの状態、 +今日の使用量、プロバイダーのクォータを表示する WidgetKit 拡張も付属します。表示に使う +スナップショットモデルは [`app/`](../app)(`MenuBarCore`)にあります。アプリを自分でビルドするには、 +リポジトリのルートで `bun install && bun run build:gui` を実行し、 +`desktop/` で macOS なら `bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local`、Windows と Linux なら `bun install && bun run prepare-sidecar && bun run build:local` を実行します(ウィジェットの手順は macOS 専用です)。 +[デスクトップアプリガイド](https://opencodex.me/ja/guides/desktop-app/)と +[macOS メニューバーアプリガイド](https://opencodex.me/ja/guides/macos-menu-bar/)で初回起動について説明しています。 +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) にはディスクに書き込まれるすべてのものをまとめています。 -同じプロキシとダッシュボードをネイティブウィンドウで使えるアプリで、トレイと `ocx` を同梱しています。 -[最新リリース](https://github.com/lidge-jun/opencodex/releases/latest)から、macOS 13 以降向けユニバーサル `.dmg`、Windows x64 向け `.msi`(まだコード署名なし)、 -Linux x86_64 向け `.AppImage` / `.deb` をダウンロードしてください。初回起動とローカルビルドは[デスクトップアプリガイド](https://opencodex.me/ja/guides/desktop-app/)を参照してください。 +
### ChatGPT アカウントプール diff --git a/readme/README.ko.md b/readme/README.ko.md index 5f141d77792..385bc7f30f9 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -18,16 +18,12 @@ npm install -g @bitkyc08/opencodex ocx start ``` -
-데스크톱 앱 (베타) — macOS · Windows · Linux -

macOS용 다운로드 (.dmg) Windows용 다운로드 (.msi) Linux용 다운로드 (.AppImage) Linux용 다운로드 (.deb)

-
@@ -105,11 +101,30 @@ ocx start # 프록시 + 대시보드: localhost:10100 **http://localhost:10100**을 열고 웹 대시보드에서 전부 설정하세요. 프로바이더 추가(내장 40개 이상, 또는 OpenAI 호환 엔드포인트), 모델 선택, 계정 관리까지 모두 여기서 합니다. `ocx gui`로 대시보드를 언제든 다시 엽니다. -### 데스크톱 앱 (베타) +
+데스크톱 앱 (베타) + +데스크톱 앱은 같은 프록시와 대시보드를 네이티브 창에 담은 것으로, 트레이와 번들된 `ocx`를 갖춥니다. +이미 실행 중인 프록시에 붙거나 번들된 프록시를 시작하며, 대시보드는 프록시 포트에서 열립니다 +(다른 포트를 설정하지 않았다면 **http://localhost:10100**). 플랫폼에 맞는 파일을 +[최신 릴리스](https://github.com/lidge-jun/opencodex/releases/latest)에서 고르세요. + +| 플랫폼 | 파일 | 참고 | +|---|---|---| +| macOS 13+ (Apple Silicon 및 Intel) | `OpenCodex--macos.dmg` | 유니버설 빌드, Developer ID로 서명 및 공증됨 | +| Windows (x64) | `OpenCodex--windows-x64.msi` | 아직 코드 서명되지 않음: SmartScreen이 한 번 물으면 **추가 정보 → 실행**을 선택 | +| Linux (x86_64) | `OpenCodex--linux-x86_64.AppImage` 또는 `-linux-amd64.deb` | 트레이는 AppIndicator를 지원하는 데스크톱이 필요 | + +릴리스 페이지에서 모든 파일 옆에 `.sha256`이 함께 있습니다. macOS 14 이상에서는 프록시 상태, +오늘의 사용량, 프로바이더 쿼터를 보여 주는 WidgetKit 확장도 함께 설치됩니다. 위젯이 그리는 +스냅샷 모델은 [`app/`](../app)의 `MenuBarCore`에 있습니다. 앱을 직접 빌드하려면 저장소 루트에서 +`bun install && bun run build:gui`를 실행한 다음, +`desktop/`에서 macOS라면 `bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local`을, Windows와 Linux라면 `bun install && bun run prepare-sidecar && bun run build:local`을 실행하세요. 위젯 빌드 단계는 macOS에서만 돌아갑니다. +[데스크톱 앱 가이드](https://opencodex.me/ko/guides/desktop-app/)와 +[macOS 메뉴 막대 앱 가이드](https://opencodex.me/ko/guides/macos-menu-bar/)에서 첫 실행 안내를 볼 수 있고, +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed)에는 디스크에 쓰는 모든 항목이 정리되어 있습니다. -같은 프록시와 대시보드를 네이티브 창으로 띄우는 앱으로, 트레이와 `ocx`가 함께 들어 있습니다. -[최신 릴리스](https://github.com/lidge-jun/opencodex/releases/latest)에서 macOS 13 이상용 유니버설 `.dmg`, Windows x64용 `.msi`(아직 코드 서명 전), -Linux x86_64용 `.AppImage` / `.deb`를 받으세요. 첫 실행과 직접 빌드하는 방법은 [데스크톱 앱 가이드](https://opencodex.me/ko/guides/desktop-app/)에 있습니다. +
### ChatGPT 계정 풀 diff --git a/readme/README.ru.md b/readme/README.ru.md index 21ea349b83d..1f97ac6282b 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -18,16 +18,12 @@ npm install -g @bitkyc08/opencodex ocx start ``` -
-Настольное приложение (бета) — macOS · Windows · Linux -

Скачать для macOS (.dmg) Скачать для Windows (.msi) Скачать для Linux (.AppImage) Скачать для Linux (.deb)

-
@@ -108,11 +104,34 @@ ocx start # прокси + панель управлен (40+ встроенных или любой OpenAI-совместимый endpoint), выберите модели, управляйте аккаунтами. `ocx gui` в любой момент снова откроет панель. -### Настольное приложение (бета) +
+Настольное приложение (бета) + +Настольное приложение — это тот же прокси и та же панель управления в нативном окне, +с иконкой в трее и встроенным `ocx`. Оно подключается к уже запущенному прокси либо +запускает встроенный, а панель остаётся на порту прокси (**http://localhost:10100**, +если вы не настроили другой). Выберите файл для своей платформы в +[последнем релизе](https://github.com/lidge-jun/opencodex/releases/latest): + +| Платформа | Файл | Примечания | +|---|---|---| +| macOS 13+ (Apple Silicon и Intel) | `OpenCodex--macos.dmg` | Универсальная сборка, подписана Developer ID и нотариализована | +| Windows (x64) | `OpenCodex--windows-x64.msi` | Пока без цифровой подписи: SmartScreen спросит один раз — выберите **Подробнее → Выполнить в любом случае** | +| Linux (x86_64) | `OpenCodex--linux-x86_64.AppImage` или `-linux-amd64.deb` | Для трея нужен рабочий стол с поддержкой AppIndicator | + +Рядом с каждым файлом на странице релиза есть `.sha256`. На macOS 14+ приложение также +поставляется с расширением WidgetKit, которое показывает состояние прокси, расход за +сегодня и квоты провайдеров; модель снимков, которую оно отображает, находится в +[`app/`](../app) (`MenuBarCore`). Чтобы собрать приложение самостоятельно, выполните +`bun install && bun run build:gui` в корне репозитория, затем в `desktop/` выполните +`bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local` на macOS +или `bun install && bun run prepare-sidecar && bun run build:local` на Windows и Linux (шаг с виджетом +работает только на macOS). В [руководстве по настольному приложению](https://opencodex.me/ru/guides/desktop-app/) и +[руководстве по приложению macOS в строке меню](https://opencodex.me/ru/guides/macos-menu-bar/) +описан первый запуск, а +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) перечисляет всё, что записывается на диск. -Тот же прокси и дашборд в нативном окне, с треем и встроенным `ocx`. Скачайте из [последнего релиза](https://github.com/lidge-jun/opencodex/releases/latest) -универсальный `.dmg` (macOS 13+), `.msi` (Windows x64, пока без цифровой подписи) или `.AppImage` / `.deb` -(Linux x86_64). Первый запуск и локальная сборка описаны в [руководстве по настольному приложению](https://opencodex.me/ru/guides/desktop-app/). +
### Пул аккаунтов ChatGPT diff --git a/readme/README.tr.md b/readme/README.tr.md index ebc26dab394..7fd1592b045 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -18,16 +18,12 @@ npm install -g @bitkyc08/opencodex ocx start ``` -
-Masaüstü uygulaması (beta) — macOS · Windows · Linux -

macOS için indir (.dmg) Windows için indir (.msi) Linux için indir (.AppImage) Linux için indir (.deb)

-
@@ -106,11 +102,33 @@ Arka planda çalıştırmak için `ocx service` kullanın. ekleyin (40'tan fazla hazır sağlayıcı ya da herhangi bir OpenAI uyumlu uç nokta), model seçin, hesap yönetin. `ocx gui` paneli istediğiniz zaman yeniden açar. -### Masaüstü uygulaması (beta) +
+Masaüstü uygulaması (beta) + +Masaüstü uygulaması; aynı proxy ve kontrol panelini yerel bir pencerede, menü çubuğu simgesi ve +paketlenmiş `ocx` ile sunar. Zaten çalışan bir proxy'ye bağlanır ya da kendi paketlenmiş proxy'sini +başlatır; kontrol paneli proxy bağlantı noktasında kalır (başka bir tane yapılandırmadıysanız +**http://localhost:10100**). Platformunuza uygun dosyayı +[en güncel sürümden](https://github.com/lidge-jun/opencodex/releases/latest) seçin: + +| Platform | Dosya | Notlar | +|---|---|---| +| macOS 13+ (Apple Silicon ve Intel) | `OpenCodex--macos.dmg` | Evrensel derleme, Developer ID ile imzalı ve noter onaylı | +| Windows (x64) | `OpenCodex--windows-x64.msi` | Henüz kod imzalı değil: SmartScreen bir kez sorar, **Diğer bilgiler → Yine de çalıştır** seçin | +| Linux (x86_64) | `OpenCodex--linux-x86_64.AppImage` veya `-linux-amd64.deb` | Menü çubuğu simgesi AppIndicator destekli bir masaüstü gerektirir | + +Her dosyanın yanında sürüm sayfasında bir `.sha256` bulunur. macOS 14 ve üzerinde uygulama ayrıca +proxy durumunu, bugünkü kullanımı ve sağlayıcı kotalarını gösteren bir WidgetKit uzantısıyla gelir; +görüntülediği anlık görüntü modeli [`app/`](../app) dizinindedir (`MenuBarCore`). Uygulamayı kendiniz +derlemek için depo kökünde `bun install && bun run build:gui`, ardından `desktop/` içinde macOS'ta +`bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local`, +Windows ve Linux'ta ise `bun install && bun run prepare-sidecar && bun run build:local` çalıştırın (widget adımı yalnızca macOS'ta çalışır). +[Masaüstü uygulaması kılavuzu](https://opencodex.me/tr/guides/desktop-app/) ve +[macOS menü çubuğu uygulaması kılavuzu](https://opencodex.me/tr/guides/macos-menu-bar/) ilk açılışı +anlatır; [`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) diske yazılan her +şeyi listeler. -Aynı proxy ve kontrol paneli, tepsi simgesi ve yerleşik `ocx` ile yerel bir pencerede. [Son sürümden](https://github.com/lidge-jun/opencodex/releases/latest) -evrensel `.dmg` (macOS 13+), `.msi` (Windows x64, henüz kod imzalı değil) veya `.AppImage` / `.deb` (Linux x86_64) -dosyasını indirin. İlk açılış ve yerel derleme [masaüstü uygulaması kılavuzunda](https://opencodex.me/tr/guides/desktop-app/) anlatılıyor. +
### ChatGPT hesap havuzu diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index d25de14a62b..aeb6ec6b402 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -18,16 +18,12 @@ npm install -g @bitkyc08/opencodex ocx start ``` -
-桌面应用(测试版) — macOS · Windows · Linux -

下载 macOS 版 (.dmg) 下载 Windows 版 (.msi) 下载 Linux 版 (.AppImage) 下载 Linux 版 (.deb)

-
@@ -105,10 +101,30 @@ ocx start # 代理 + 仪表板:localhost:10100 (40 多个内置,或任意 OpenAI 兼容端点)、选择模型、管理账户。随时运行 `ocx gui` 可重新打开仪表板。 -### 桌面应用(测试版) +
+桌面应用(测试版) + +桌面应用把同一个代理和仪表板装进原生窗口,附带系统托盘和内置的 `ocx`。 +它会连接已在运行的代理,或启动自带的代理;仪表板仍使用代理端口 +(未另行配置时为 **http://localhost:10100**)。从 +[最新发布版本](https://github.com/lidge-jun/opencodex/releases/latest)中选择适合你平台的文件: + +| 平台 | 文件 | 说明 | +|---|---|---| +| macOS 13+(Apple Silicon 和 Intel) | `OpenCodex--macos.dmg` | 通用构建,使用 Developer ID 签名并完成公证 | +| Windows (x64) | `OpenCodex--windows-x64.msi` | 尚未进行代码签名:SmartScreen 会询问一次,选择 **更多信息 → 仍要运行** | +| Linux (x86_64) | `OpenCodex--linux-x86_64.AppImage` 或 `-linux-amd64.deb` | 托盘需要支持 AppIndicator 的桌面环境 | + +每个文件在发布页面上都带有对应的 `.sha256`。在 macOS 14+ 上,应用还附带一个 +WidgetKit 扩展,可显示代理状态、今日用量和提供商配额;它所呈现的快照模型位于 +[`app/`](../app)(`MenuBarCore`)。如需自行构建应用,先在仓库根目录运行 +`bun install && bun run build:gui`,然后在 +`desktop/` 中运行:macOS 上用 `bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local`,Windows 和 Linux 上用 `bun install && bun run prepare-sidecar && bun run build:local`(小组件步骤只能在 macOS 上执行)。 +[桌面应用指南](https://opencodex.me/zh-cn/guides/desktop-app/)和 +[macOS 菜单栏应用指南](https://opencodex.me/zh-cn/guides/macos-menu-bar/)介绍了首次启动, +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed)列出了写入磁盘的所有内容。 -同一个代理和控制台,装进原生窗口,附带托盘和内置的 `ocx`。从[最新版本](https://github.com/lidge-jun/opencodex/releases/latest)下载 macOS 13+ 通用 `.dmg`、 -Windows x64 `.msi`(尚未代码签名)或 Linux x86_64 `.AppImage` / `.deb`。首次启动和本地构建见[桌面应用指南](https://opencodex.me/zh-cn/guides/desktop-app/)。 +
### ChatGPT 账户池 diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 64a8e939486..aba62f02a59 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -18,16 +18,12 @@ npm install -g @bitkyc08/opencodex ocx start ``` -
-桌面應用程式(Beta) — macOS · Windows · Linux -

下載 macOS 版 (.dmg) 下載 Windows 版 (.msi) 下載 Linux 版 (.AppImage) 下載 Linux 版 (.deb)

-
@@ -104,10 +100,30 @@ ocx start # 代理 + 儀表板位於 localhost:10100 (40+ 內建,或任何 OpenAI 相容端點)、挑選模型、管理帳號。隨時可用 `ocx gui` 重新開啟儀表板。 -### 桌面應用程式(Beta) +
+桌面應用程式(Beta) + +桌面應用程式是同一套代理與儀表板的原生視窗版本,附系統匣與內建的 `ocx`。 +它會接上已在執行的代理,或啟動內建的那一個;儀表板仍使用代理的連接埠 +(除非你設定了其他連接埠,否則為 **http://localhost:10100**)。請從 +[最新發行版](https://github.com/lidge-jun/opencodex/releases/latest)挑選適合你平台的檔案: + +| 平台 | 檔案 | 說明 | +|---|---|---| +| macOS 13+(Apple Silicon 與 Intel) | `OpenCodex--macos.dmg` | 通用建置,以 Developer ID 簽章並經過公證 | +| Windows(x64) | `OpenCodex--windows-x64.msi` | 尚未經程式碼簽章:SmartScreen 會詢問一次,選擇 **More info → Run anyway** | +| Linux(x86_64) | `OpenCodex--linux-x86_64.AppImage` 或 `-linux-amd64.deb` | 系統匣需要支援 AppIndicator 的桌面環境 | + +每個檔案在發行頁面上都附有 `.sha256`。在 macOS 14+ 上,應用程式還附帶 +WidgetKit 擴充套件,可顯示代理狀態、今日用量與供應商配額;它所呈現的快照模型位於 +[`app/`](../app)(`MenuBarCore`)。若要自行建置應用程式,先在儲存庫根目錄執行 +`bun install && bun run build:gui`,再於 +`desktop/` 執行:macOS 上用 `bun install && bun run prepare-sidecar && bun run prepare-widget && bun run build:local`,Windows 與 Linux 上用 `bun install && bun run prepare-sidecar && bun run build:local`(小工具步驟只能在 macOS 上執行)。 +[桌面應用程式指南](https://opencodex.me/zh-tw/guides/desktop-app/) 與 +[macOS 選單列應用程式指南](https://opencodex.me/zh-tw/guides/macos-menu-bar/) 涵蓋首次啟動, +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) 列出所有寫入磁碟的內容。 -同一個代理與儀表板,放進原生視窗,附帶系統匣與內建的 `ocx`。請從[最新版本](https://github.com/lidge-jun/opencodex/releases/latest)下載 macOS 13+ 通用 `.dmg`、 -Windows x64 `.msi`(尚未進行程式碼簽署)或 Linux x86_64 `.AppImage` / `.deb`。首次啟動與本機建置請見[桌面應用程式指南](https://opencodex.me/zh-tw/guides/desktop-app/)。 +
### ChatGPT 帳號池 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index a74517ea89f..b776953a508 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "ae4326e89fb9f7c432f10a4ee65eac7aaa72b1ffcf76c75f05cbb571b4ccfb89" + "sourceSha256": "a4b735537038608f98122c331b8cd144f521f077dad30395712ee2a6cb70e26c" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "ae4326e89fb9f7c432f10a4ee65eac7aaa72b1ffcf76c75f05cbb571b4ccfb89" + "sourceSha256": "a4b735537038608f98122c331b8cd144f521f077dad30395712ee2a6cb70e26c" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "ae4326e89fb9f7c432f10a4ee65eac7aaa72b1ffcf76c75f05cbb571b4ccfb89" + "sourceSha256": "a4b735537038608f98122c331b8cd144f521f077dad30395712ee2a6cb70e26c" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "ae4326e89fb9f7c432f10a4ee65eac7aaa72b1ffcf76c75f05cbb571b4ccfb89" + "sourceSha256": "a4b735537038608f98122c331b8cd144f521f077dad30395712ee2a6cb70e26c" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "ae4326e89fb9f7c432f10a4ee65eac7aaa72b1ffcf76c75f05cbb571b4ccfb89" + "sourceSha256": "a4b735537038608f98122c331b8cd144f521f077dad30395712ee2a6cb70e26c" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "ae4326e89fb9f7c432f10a4ee65eac7aaa72b1ffcf76c75f05cbb571b4ccfb89" + "sourceSha256": "a4b735537038608f98122c331b8cd144f521f077dad30395712ee2a6cb70e26c" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "ae4326e89fb9f7c432f10a4ee65eac7aaa72b1ffcf76c75f05cbb571b4ccfb89" + "sourceSha256": "a4b735537038608f98122c331b8cd144f521f077dad30395712ee2a6cb70e26c" } } } From 0996ecb59bf1189806a13f6b767a02e1c7440fa5 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 10:19:49 +0900 Subject: [PATCH 15/48] fix: close four regressions from the 260923 bundle round (#5720) * docs(devlog): plan the 260924 regression-risk fixes * docs(devlog): note the wp1 re-walk * fix(codex): keep a discovered Windows home when the local WSL ~/.codex holds no Codex state #5441 made any local ~/.codex directory the Codex home on WSL, even when config.toml is missing. A WSL user whose ~/.codex exists but holds no Codex state, and who ran against the discovered Windows Codex home, was moved to an empty local home on upgrade: auth and sessions disappeared and a sync wrote a new local config. The local home now wins only when Codex is already using it: config.toml, auth.json, sessions or history.jsonl is present (an unexpected stat error counts as present, so doubt never switches homes). #5441's fresh install keeps its local home once Codex has logged in or run; a bare directory falls back to Windows discovery as before #5441. * fix(update): treat npm -g under a mise-managed Node as an npm install on Windows On Windows, npm -g under a mise-managed Node installs OpenCodex directly into /installs/node//node_modules. The mise ownership walk then read Node's own .mise.backend.toml (short = "node", full = "core:node") as contradictory OpenCodex metadata and refused ocx update with metadata_inconsistent, although the install is plain npm. That exact runtime record, with the package directly in the runtime's global node_modules, now falls through to ordinary npm detection. Any other backend or alias under a node tool root, a deeper nested layout, unreadable metadata and every OpenCodex mismatch stay fail-closed. POSIX is unaffected: its lib/node_modules layout never reaches the Node record. * fix(xai,cursor): only a marker alone on its line is an echoed tool envelope The shared tool-envelope echo filter from #5676 matched as soon as a line started with a marker and dropped that line and everything after it. It is armed on almost every agentic turn (input with tool calls or outputs, or a previous_response_id continuation), so an answer line such as "[Tool Result] shows the build passed." silently truncated the rest of the reply on xAI and Cursor. The Cursor history stripper had been widened to the same prefix rule and removed such prose from replayed history. The envelope OpenCodex replays is a marker alone on its line. A result, error or call marker now counts only when it is the whole line (trailing whitespace and CR allowed), decided when the line completes; the end of the stream follows the same rule, and a bare truncated marker still counts. The "[Tool call:" line keeps its prefix rule because a call echo wraps when its arguments do. The Cursor replay stripper uses the same isWholeLineEchoMarker. Fenced markers, the xAI Responses JSON path and the stored continuation snapshot follow from the shared filter. * fix(openai-chat): bound the streaming hold of an unmatched serialized tool call #5548 holds everything after a bare opening until the stream ends, so a model that writes such a block and then keeps answering without a structured call delivered the rest of its answer only at the end of the turn, and could approach the translator budget. Streaming content now goes through ingestStreaming. Once a closed block is followed by more than 8 KiB of prose with no block open after it, or held text plus queued events would pass 4 MiB (checked before the next delta is retained), everything held is released in order with nothing suppressed. A duplicated block is the tail of the content, so matching blocks followed by their structured call are still removed; past a bound the stream prefers delivery over suppression, the behaviour before #5548. Buffered responses keep the unbounded ingest because their structured calls are already known. openai-chat.ts keeps its line count. * fix(openai-chat): close the streaming hold bound's single-delta and queued-event paths A delta that opens a block and already passes 4 MiB is no longer retained, an oversized delta after an open block is delivered after the held text, and queued non-text events count toward the same bound. The fresh WSL home test now models a fresh install (auth.json present, config.toml absent) instead of a mock that reported every path present. Review decisions are recorded in the plan. * test: place the whole-line echo test in the adapters domain its name seeds --- .../000_overview.md | 14 ++ .../010_wsl_home.md | 20 +++ .../020_windows_mise_node.md | 19 +++ .../030_echo_filter.md | 30 ++++ .../040_tool_call_hold.md | 23 +++ .../050_delivery.md | 4 + .../docs/fr/guides/codex-integration.md | 3 +- .../content/docs/guides/codex-integration.md | 3 +- .../docs/ja/guides/codex-integration.md | 2 +- .../docs/ko/guides/codex-integration.md | 2 +- .../docs/ru/guides/codex-integration.md | 3 +- .../docs/tr/guides/codex-integration.md | 2 +- .../docs/zh-cn/guides/codex-integration.md | 2 +- .../docs/zh-tw/guides/codex-integration.md | 2 +- scripts/test-layout/layout.json | 4 + src/adapters/cursor/envelope-echo.ts | 11 +- src/adapters/openai-chat.ts | 4 +- .../serialized-tool-call-content.ts | 50 ++++++ src/codex/home.ts | 32 +++- src/lib/tool-envelope-echo-filter.ts | 44 +++-- src/update/install-detection.mjs | 27 ++++ structure/codex-home.md | 9 +- .../ADR-5548-serialized-tool-call-content.md | 1 + structure/ops/service-and-sidecars.md | 2 +- structure/providers/chat-compat.md | 2 +- structure/providers/cursor.md | 3 +- structure/providers/xai-grok.md | 6 +- ...at-serialized-tool-call-hold-bound.test.ts | 150 ++++++++++++++++++ .../tool-envelope-echo-whole-line.test.ts | 79 +++++++++ .../codex-home-wsl-local-state.test.ts | 65 ++++++++ .../codex-integration/codex-home-wsl.test.ts | 7 +- tests/fixtures/test-layout-expected.json | 4 + tests/update/update-mise-node-runtime.test.ts | 63 ++++++++ 33 files changed, 649 insertions(+), 43 deletions(-) create mode 100644 devlog/_plan/260924_regression_risk_fixes/000_overview.md create mode 100644 devlog/_plan/260924_regression_risk_fixes/010_wsl_home.md create mode 100644 devlog/_plan/260924_regression_risk_fixes/020_windows_mise_node.md create mode 100644 devlog/_plan/260924_regression_risk_fixes/030_echo_filter.md create mode 100644 devlog/_plan/260924_regression_risk_fixes/040_tool_call_hold.md create mode 100644 devlog/_plan/260924_regression_risk_fixes/050_delivery.md create mode 100644 tests/adapters/openai/openai-chat-serialized-tool-call-hold-bound.test.ts create mode 100644 tests/adapters/tool-envelope-echo-whole-line.test.ts create mode 100644 tests/codex-integration/codex-home-wsl-local-state.test.ts create mode 100644 tests/update/update-mise-node-runtime.test.ts diff --git a/devlog/_plan/260924_regression_risk_fixes/000_overview.md b/devlog/_plan/260924_regression_risk_fixes/000_overview.md new file mode 100644 index 00000000000..f5ce3c3fd84 --- /dev/null +++ b/devlog/_plan/260924_regression_risk_fixes/000_overview.md @@ -0,0 +1,14 @@ +# 260924 regression-risk fixes + +The 260923 bundle round landed eight lane PRs (#5672-#5685). A post-merge review found four behaviour changes that can silently hurt existing users. The owner asked to fix them directly, merge to dev without waiting for PR CI, and then drive ci.yml lane=all on the dev tip to success. + +- 010_wsl_home.md — WSL Codex home switch (#5441 carry). +- 020_windows_mise_node.md — Windows npm global under mise-managed Node refused (#5316 carry). +- 030_echo_filter.md — tool-envelope echo filter truncates ordinary answers (#5098 carry). +- 040_tool_call_hold.md — unbounded hold of an unmatched bare block (#5548 carry). +- 050_delivery.md — branch, merge and dev CI. + +Accepted and out of scope: Claude Code <=2.1.222 picker filter (/^(claude|anthropic)/i) drops ocx-claude-* ids; other residual risks are documented in the round's landing log only. + + +Cycle note: wp1's first close attempt failed on a malformed receipt command and the cycle was re-walked with the same artifacts. diff --git a/devlog/_plan/260924_regression_risk_fixes/010_wsl_home.md b/devlog/_plan/260924_regression_risk_fixes/010_wsl_home.md new file mode 100644 index 00000000000..09b49b6f7aa --- /dev/null +++ b/devlog/_plan/260924_regression_risk_fixes/010_wsl_home.md @@ -0,0 +1,20 @@ +# WSL Codex home + +Defect: src/codex/home.ts defaultCodexHome now returns the local ~/.codex whenever it is a directory. Before #5441 a local home without config.toml let WSL discovery pick the Windows Codex home. A WSL user whose ~/.codex exists but holds no Codex state, and who ran against the Windows home, is moved to an empty local home on upgrade: auth and sessions disappear and sync writes a new local config. + +#5441's case is a fresh local install that Codex itself is using before config.toml exists. Codex writes auth.json on login and sessions/ plus history.jsonl on first use. + +Change (src/codex/home.ts): + +- keep localCodexHomeIsDirectory (stat, ENOENT/ENOTDIR = absent, other errors = present). +- add localCodexHomeInUse(home, deps): true when any of config.toml, auth.json, sessions, history.jsonl is present by the same stat rule (unexpected stat error counts as present, never switch on doubt). +- defaultCodexHome: not a directory -> discovery ?? local (unchanged); directory and in use -> local; directory with no Codex state -> findWslWindowsCodexHome ?? local (the pre-#5441 behaviour). + +Tests (new tests/codex-integration/codex-home-wsl-local-state.test.ts, registered in layout.json and test-layout-expected.json): bare local dir + Windows home -> Windows; local dir with auth.json -> local; local dir with sessions -> local; unexpected stat error on markers -> local; non-WSL unaffected. The existing codex-home-wsl.test.ts fresh-home case keeps passing (its statSync mock reports every path present). + +Docs: structure/codex-home.md and the Codex integration guide sentence that says directory presence decides. + + +## Build note + +A local ~/.codex that exists but is empty, with a discoverable Windows home, resolves to the Windows home (the pre-#5441 behaviour). That is the accepted direction: moving an existing user off the Windows home loses their auth and sessions, while a fresh user who has not run Codex locally yet loses nothing and CODEX_HOME overrides. The existing fresh-home test now models a fresh install honestly (auth.json present, config.toml absent) instead of a mock that reported every path present. diff --git a/devlog/_plan/260924_regression_risk_fixes/020_windows_mise_node.md b/devlog/_plan/260924_regression_risk_fixes/020_windows_mise_node.md new file mode 100644 index 00000000000..2b4ef6e5a0d --- /dev/null +++ b/devlog/_plan/260924_regression_risk_fixes/020_windows_mise_node.md @@ -0,0 +1,19 @@ +# Windows npm global under mise-managed Node + +Defect: src/update/install-detection.mjs detectMiseOwner walks /node_modules/ markers and reads /.mise.backend.toml. On Windows, npm -g under a mise-managed Node installs to /installs/node//node_modules/@bitkyc08/opencodex, so toolRoot is the Node tool root and its metadata (short = "node", full = "core:node") is read as contradictory OpenCodex ownership: ocx update is refused with metadata_inconsistent. POSIX is unaffected (lib/node_modules puts toolRoot one level deeper, where no metadata exists). + +Change: after parsing, if the metadata names a Node runtime (short is node or nodejs) and its backend is not an npm: backend, the package sits in that runtime's global node_modules and mise did not install OpenCodex: return { recognized: false }, so ordinary npm detection applies. Every other mismatch, including npm:some-other-package, stays metadata_inconsistent (fail closed). + +Tests (new tests/update/update-mise-node-runtime.test.ts): Windows and POSIX-shaped paths under a core:node tool root detect as npm with no mise error; short = "node" with an npm: backend stays inconsistent; the existing contradictory-metadata tests keep passing. + +Docs: structure owner of src/update (install detection section). + + +## Audit fold (round 1 FAIL) + +The exemption is narrowed: only metadata that is exactly the mise core Node runtime (short = "node", full = "core:node") AND a package path that is that tool's /node_modules/ (the Windows npm global layout, installPath directly under toolRoot) is classified as not mise-owned. Every other backend for short node/nodejs, unreadable metadata and every OpenCodex mismatch stay fail-closed. + + +## Residual + +detectMiseOwner treats a backslash UNC path (\\server\share) as Windows but a slash-form //server/share path as POSIX, as before this change; on such a path a case difference in the node tool directory misses the exemption and keeps the old metadata_inconsistent refusal. diff --git a/devlog/_plan/260924_regression_risk_fixes/030_echo_filter.md b/devlog/_plan/260924_regression_risk_fixes/030_echo_filter.md new file mode 100644 index 00000000000..d5142c4e031 --- /dev/null +++ b/devlog/_plan/260924_regression_risk_fixes/030_echo_filter.md @@ -0,0 +1,30 @@ +# Tool-envelope echo filter + +Defect: src/lib/tool-envelope-echo-filter.ts ToolEnvelopeEchoFilter.feed matches as soon as a line starts with a marker (probe === marker) and drops that line and everything after it. The filter arms on almost every Codex agentic turn (input carrying tool calls/outputs or previous_response_id), so a legitimate line such as "[Tool Result] shows the build passed." silently truncates the answer. The replayed envelope OpenCodex builds is always a marker alone on its line ("[Tool Result]\n", protobuf-request.ts), and the replay-side stripper in cursor/envelope-echo.ts already requires whole-line markers. + +Change (tool-envelope-echo-filter.ts only): + +- feed: a line that equals a marker is kept pending (candidate) instead of matching immediately; trailing spaces/CR after a marker stay candidates; a "[Tool call:" line stays pending up to a bounded length (MAX_CALL_LINE = 4096) and is released as prose beyond it. +- completeLine decides: whole-line marker (exact after trimEnd) or a "[Tool call:" line ending with "]" is an echo (outside a fence: match; inside: hold as today). Anything else is prose. +- finish applies the same whole-line rule to an unterminated last line; a truncated marker prefix ("[Tool Res") or an unterminated "[Tool call:" line at stream end still counts as an echo, as today. + +Tests (new tests/lib/tool-envelope-echo-whole-line.test.ts): prose starting with a marker survives whole and char-by-char; marker alone mid-answer still drops the tail; "[Tool call: x (call_id: 1) with args: {}]" line drops; final unterminated "[Tool Result] header text" survives; existing passthrough-grok-upstream-envelope-echo and cursor-envelope-echo-retry tests keep passing. + + +## Audit fold (round 1 FAIL) + +Two Cursor paths share the marker vocabulary: + +- The replay-side stripper isEchoMarkerLine (src/adapters/cursor/envelope-echo.ts) was whole-line at 685321e297 (exact marker) and #5676 widened it to prefix matching, so assistant history prose such as "[Tool Result] shows ..." is now stripped with its following non-blank lines. Restore whole-line semantics: trimmed line equals a marker (with or without the closing bracket for the truncated forms), or a "[Tool call:" line that ends with "]". Test it. +- The first-bytes prefix sniffer CursorEnvelopeEchoSniffer is unchanged since before the round (probe.startsWith(marker) at 685321e297) and triggers a remint retry rather than truncation. Not a regression of the round; left as is and recorded. + +finish() replaces the startsWith(truncated marker) rule with: exact truncated prefix of a marker, whole-line marker, or an unterminated "[Tool call:" line. Tests cover CRLF, trailing whitespace, fenced markers, streaming char-by-char and the buffered Responses JSON path (stripGrokUpstreamEnvelopeEchoFromResponsesJson). + + +## Audit round 2 rebuttal + +The Cursor first-bytes sniffer stays out of scope: it predates the round (685321e297) and its false positive costs a remint retry, not a truncated answer. Narrowing a pre-existing echo guard is a separate decision; recorded as a residual. + +## Build note + +The "[Tool call:" line keeps its prefix rule instead of the planned completed-line rule. An existing Cursor replay test showed why: a call echo wraps when its arguments do ("[Tool call: Glob" then "args"), so a completed-line rule leaks it. Prose that opens with "[Tool call:" is rare, while "[Tool Result] ..." prose is common, so only the result/error markers move to the whole-line rule. The diff review flagged the prefix rule; this is the recorded reason for keeping it. diff --git a/devlog/_plan/260924_regression_risk_fixes/040_tool_call_hold.md b/devlog/_plan/260924_regression_risk_fixes/040_tool_call_hold.md new file mode 100644 index 00000000000..9432cc1da25 --- /dev/null +++ b/devlog/_plan/260924_regression_risk_fixes/040_tool_call_hold.md @@ -0,0 +1,23 @@ +# Bound the serialized tool-call hold + +Defect: src/adapters/openai-chat/serialized-tool-call-content.ts SerializedToolCallContentBuffer.ingest appends everything after a bare opening to the held text until the stream ends. An unmatched block followed by a long answer is delivered only at the end of the turn and can approach the translator budget. + +Change: + +- MAX_HELD_CHARS = 64 KiB: when the held text exceeds it, the buffer releases everything it holds (text and queued events, in order, nothing suppressed) and resumes scanning from the carried context. +- MAX_TRAILING_CHARS = 4 KiB: once the held text contains a closed and the non-whitespace text after the last closer exceeds this, release as above: a duplicated block is the tail of the content, not the head of a long answer. +- The adapter (openai-chat.ts emitContent) drains the released events through a new buffer method so ordering stays intact; no other adapter change (openai-chat.ts is ratchet-capped, so the change stays inside the helper plus a one-line call). + +Tests (new tests/adapters/openai-chat-serialized-tool-call-hold-bound.test.ts): a bare block followed by >4 KiB of prose emits text before the stream ends and suppresses nothing; a block exceeding 64 KiB is released; a real duplicate (block then matching structured call) is still suppressed. + + +## Audit fold (round 1 FAIL) + +Streaming and buffered paths are separated. ingest() keeps its current unbounded behaviour because reconcileSerializedToolCallEvents (buffered responses, structured calls already known) uses it. A new ingestEvents(delta) is used only by the streaming emitContent in openai-chat.ts (two lines replaced by two lines, inside the 822-line cap). + +Overflow policy, stated: past the bound the stream prefers showing text over suppressing a possible duplicate. A duplicate block larger than the bound, or one followed by more than MAX_TRAILING_CHARS of prose before its structured call, reaches the client as raw markup, which is the behaviour before #5548. Bounds: MAX_HELD_BYTES = 64 KiB measured on this.bytes (held text plus queued events) and checked BEFORE retaining the next delta, so one large delta cannot push retention past the bound; MAX_TRAILING_CHARS = 8 KiB of non-whitespace text after the last closed . + + +## Audit fold (round 2) + +MAX_HELD_BYTES rises to 4 MiB: it is a runaway guard only, well under the translator budget, so a large duplicated write inside the block itself is still reconciled. The latency case is handled by the 8 KiB trailing-prose rule alone. A duplicated block is the tail of the content (#5548's observed shape), so a closed block followed by more than 8 KiB of prose before any structured call is treated as prose; that narrow residual is accepted. diff --git a/devlog/_plan/260924_regression_risk_fixes/050_delivery.md b/devlog/_plan/260924_regression_risk_fixes/050_delivery.md new file mode 100644 index 00000000000..d4b1012dee8 --- /dev/null +++ b/devlog/_plan/260924_regression_risk_fixes/050_delivery.md @@ -0,0 +1,4 @@ +# Delivery + +One branch codex/260924-regression-risk-fixes from origin/dev with one commit per fix plus this plan. Local gate: bun run typecheck, bun run structure:check, focused tests for each touched area, test-layout and file-size-ratchet tests. Open one PR to dev with the repository template and merge it immediately with gh pr merge --admin --squash (owner instruction: no PR CI wait). Then dispatch gh workflow run ci.yml --ref dev -F lane=all on the merged tip and require every job completed success; a multi-file timeout is rerun once, a repeat is a defect fixed by another PR merged the same way, followed by a fresh lane=all on the new tip. + 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 14fccb75d4b..1ee7d02c42d 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -153,7 +153,8 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -Sous WSL, si `CODEX_HOME` n'est pas défini et qu'aucun répertoire `~/.codex` n'existe côté Linux, opencodex +Sous WSL, si `CODEX_HOME` n'est pas défini et que le répertoire `~/.codex` côté Linux est absent ou ne contient aucun état Codex +(`config.toml`, `auth.json`, `sessions`, `history.jsonl`), opencodex recherche également un unique répertoire personnel de Codex Desktop pour Windows à l'emplacement `/mnt/c/Users/*/.codex/config.toml`. S'il trouve exactement un candidat, il utilise ce répertoire afin que le mode app-server sous WSL et Codex Desktop sous Windows partagent les mêmes fichiers de configuration et diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 3c4a7ba0d84..cf0515fbc4e 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -333,7 +333,8 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -On WSL, if `CODEX_HOME` is unset and there is no Linux `~/.codex` directory, opencodex also +On WSL, if `CODEX_HOME` is unset and the Linux `~/.codex` directory is absent or holds no Codex state +(`config.toml`, `auth.json`, `sessions`, `history.jsonl`), opencodex also checks for a single Windows Codex Desktop home at `/mnt/c/Users/*/.codex/config.toml`. When exactly one candidate exists, it uses that directory so WSL app-server mode and Windows Codex Desktop share the same config and auth files. Set `CODEX_HOME` explicitly to override this detection. When Windows Codex Desktop runs its app-server inside WSL, it ships the Linux Codex binary under that home as `bin/wsl//codex`; opencodex finds it there when the service PATH has no `codex`, after any explicitly configured runtime and PATH. 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 bae98cd8291..5845d23865e 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -109,7 +109,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -WSL では、`CODEX_HOME` が設定されておらず、Linux の `~/.codex` ディレクトリが存在しない場合、opencodex は `/mnt/c/Users/*/.codex/config.toml` にある単一の Windows Codex デスクトップ ホームもチェックします。候補が 1 つだけ存在する場合は、そのディレクトリが使用されるため、WSL アプリサーバー モードと Windows Codex デスクトップは同じ設定ファイルと認証ファイルを共有します。この検出をオーバーライドするには、`CODEX_HOME` を明示的に設定します。 +WSL では、`CODEX_HOME` が設定されておらず、Linux の `~/.codex` ディレクトリが存在しないか、Codex の状態 (`config.toml`, `auth.json`, `sessions`, `history.jsonl`) を持たない場合、opencodex は `/mnt/c/Users/*/.codex/config.toml` にある単一の Windows Codex デスクトップ ホームもチェックします。候補が 1 つだけ存在する場合は、そのディレクトリが使用されるため、WSL アプリサーバー モードと Windows Codex デスクトップは同じ設定ファイルと認証ファイルを共有します。この検出をオーバーライドするには、`CODEX_HOME` を明示的に設定します。 Windows では、ChatGPT/Codex アプリが `%USERPROFILE%\\.codex` を読み取りながら、Orca シェルは `CODEX_HOME` と `ORCA_CODEX_HOME` の両方を Orca のバンドルされたランタイム ホームに設定できます。 `ocx status` および `ocx doctor` は、この正確な不一致について警告し、編集されたターゲット パスを出力します。バックグラウンド サービスが Orca シェルからインストールされている場合は、最初に元のシェルからアンインストールし、次に `CODEX_HOME` をアプリ ホームに設定し、`ORCA_CODEX_HOME` の設定を解除し、同期/復元を再実行して、サービスを再度インストールします。 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 76a88233a41..d3e00f37cac 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -196,7 +196,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -WSL에서는 `CODEX_HOME`이 비어 있고 Linux `~/.codex` 디렉터리도 없을 때 `/mnt/c/Users/*/.codex/config.toml` 아래의 단일 Windows Codex Desktop home도 확인합니다. 후보가 정확히 하나면 그 디렉터리를 사용하므로 WSL app-server mode와 Windows Codex Desktop이 같은 config와 auth 파일을 공유합니다. 이 탐지를 덮으려면 `CODEX_HOME`을 명시하세요. +WSL에서는 `CODEX_HOME`이 비어 있고 Linux `~/.codex` 디렉터리가 없거나 Codex 상태(`config.toml`, `auth.json`, `sessions`, `history.jsonl`)가 전혀 없을 때 `/mnt/c/Users/*/.codex/config.toml` 아래의 단일 Windows Codex Desktop home도 확인합니다. 후보가 정확히 하나면 그 디렉터리를 사용하므로 WSL app-server mode와 Windows Codex Desktop이 같은 config와 auth 파일을 공유합니다. 이 탐지를 덮으려면 `CODEX_HOME`을 명시하세요. Windows에서 Orca shell은 `CODEX_HOME`과 `ORCA_CODEX_HOME`을 Orca의 번들 런타임 home으로 설정할 수 있지만, ChatGPT/Codex app은 여전히 `%USERPROFILE%\\.codex`를 읽습니다. `ocx status`와 `ocx doctor`는 이 정확한 불일치를 경고하고, 경로는 가린 채 대상 home을 출력합니다. 해당 Orca shell에서 background service를 설치했다면 먼저 원래 shell에서 uninstall하고, `CODEX_HOME`을 app home으로 설정한 뒤 `ORCA_CODEX_HOME`을 해제하고, sync/restore를 다시 실행한 다음 service를 다시 설치하세요. 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 ec29051ea36..0be6555da10 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -156,7 +156,8 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -В WSL, если `CODEX_HOME` не задан и Linux-каталог `~/.codex` отсутствует, opencodex +В WSL, если `CODEX_HOME` не задан и Linux-каталог `~/.codex` отсутствует или не содержит состояния Codex +(`config.toml`, `auth.json`, `sessions`, `history.jsonl`), opencodex дополнительно проверяет, нет ли единственного Windows-home Codex Desktop в `/mnt/c/Users/*/.codex/config.toml`. Если существует ровно один такой кандидат, используется его каталог, чтобы режим app-server в WSL и Windows Codex Desktop разделяли одни и те же config- и 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 5833c993e28..ad8eb6be39d 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -171,7 +171,7 @@ $CODEX_HOME/models_cache.json ``` WSL üzerinde, `CODEX_HOME` ayarlanmamışsa ve Linux `~/.codex` dizini mevcut -değilse, opencodex `/mnt/c/Users/*/.codex/config.toml` konumunda tek bir Windows +değilse ya da hiçbir Codex durumu (`config.toml`, `auth.json`, `sessions`, `history.jsonl`) içermiyorsa, opencodex `/mnt/c/Users/*/.codex/config.toml` konumunda tek bir Windows Codex Desktop evini de kontrol eder. Tam olarak bir aday mevcut olduğunda bu dizini kullanır, böylece WSL app-server modu ve Windows Codex Desktop aynı yapılandırma ve kimlik doğrulama dosyalarını paylaşır. Bu algılamayı geçersiz 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 db813ea3bde..a1ee583f0b3 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 @@ -142,7 +142,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -在 WSL 中,如果未设置 `CODEX_HOME`,且 Linux 侧不存在 `~/.codex` 目录,opencodex 还会检查 +在 WSL 中,如果未设置 `CODEX_HOME`,且 Linux 侧的 `~/.codex` 目录不存在或不含任何 Codex 状态(`config.toml`, `auth.json`, `sessions`, `history.jsonl`),opencodex 还会检查 `/mnt/c/Users/*/.codex/config.toml` 下是否存在单一的 Windows Codex Desktop home。只要候选项恰好只有一个, 它就会使用那个目录,让 WSL app-server mode 和 Windows Codex Desktop 共享同一份 config 与 auth 文件。 如需覆盖这一检测,请显式设置 `CODEX_HOME`。 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 bd0a9f56cfe..3996ae69462 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 @@ -139,7 +139,7 @@ $CODEX_HOME/opencodex-catalog.json $CODEX_HOME/models_cache.json ``` -在 WSL 中,如果未設定 `CODEX_HOME`,且 Linux 不存在 `~/.codex` 目錄,opencodex 也會檢查 +在 WSL 中,如果未設定 `CODEX_HOME`,且 Linux 的 `~/.codex` 目錄不存在或不含任何 Codex 狀態(`config.toml`, `auth.json`, `sessions`, `history.jsonl`),opencodex 也會檢查 `/mnt/c/Users/*/.codex/config.toml` 下是否只有一個 Windows Codex Desktop home。候選項恰好只有一個時, 會使用該目錄,讓 WSL app-server mode 與 Windows Codex Desktop 共用相同的 config 與 auth 檔案。 若要覆蓋此偵測,請明確設定 `CODEX_HOME`。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2b039b54b1c..4cddb97828f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -168,6 +168,7 @@ }, "explicit": { "abort-idle-deadline.test.ts": "lib", + "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", "account-pool-management-api.test.ts": "server", @@ -529,6 +530,7 @@ "codex-history-worker.test.ts": "codex-integration", "codex-history-writer.test.ts": "codex-integration", "codex-home-wsl.test.ts": "codex-integration", + "codex-home-wsl-local-state.test.ts": "codex-integration", "codex-inject-history-wording.test.ts": "codex-integration", "codex-inject-integration.test.ts": "codex-integration", "codex-inject-missing-config.test.ts": "codex-integration", @@ -1172,6 +1174,7 @@ "openai-chat-reasoning-wire-policy.test.ts": "adapters/openai", "openai-chat-sanitization-review-regressions.test.ts": "adapters/openai", "openai-chat-serialized-tool-call-content.test.ts": "adapters/openai", + "openai-chat-serialized-tool-call-hold-bound.test.ts": "adapters/openai", "openai-chat-serialized-tool-call-think.test.ts": "adapters/openai", "openai-chat-system-order.test.ts": "adapters/openai", "responses-chat-tool-call-content.test.ts": "responses", @@ -1630,6 +1633,7 @@ "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", "update-mise.test.ts": "update", + "update-mise-node-runtime.test.ts": "update", "update-pnpm.test.ts": "update", "update-stop-classification.test.ts": "update", "update-stop-first.test.ts": "update", diff --git a/src/adapters/cursor/envelope-echo.ts b/src/adapters/cursor/envelope-echo.ts index 184dfc1dea5..7cafe7b06f0 100644 --- a/src/adapters/cursor/envelope-echo.ts +++ b/src/adapters/cursor/envelope-echo.ts @@ -12,18 +12,13 @@ * cursor.ts to retry before any invalid text reaches the client. */ -import { closedFenceLines } from "../../lib/tool-envelope-echo-filter"; +import { closedFenceLines, isWholeLineEchoMarker } from "../../lib/tool-envelope-echo-filter"; const ECHO_MARKERS = ["[Tool Result]", "[Tool Error]", "[tool_result]"] as const; const REPLAY_ECHO_PREFIXES = ["[Tool call:", "[Tool Call]", "[Tool Result", "[Tool Error", "[tool_result"] as const; -function isEchoMarkerLine(line: string): boolean { - const probe = line.replace(/^[ \t]+/, ""); - return probe.startsWith("[Tool call:") - || probe.startsWith("[Tool Call]") - || ["[Tool Result", "[Tool Error", "[tool_result"].some(prefix => - probe === prefix || probe.startsWith(`${prefix}]`)); -} +// Whole-line only, the same rule as the live filter: prose that starts with a marker survives. +const isEchoMarkerLine = isWholeLineEchoMarker; /** * Drop echoed tool-result envelopes from assistant history before Cursor root replay. diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index f0af357487a..8b96b64a03d 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -376,8 +376,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // Any other event keeps its place behind held text instead of overtaking it. if (event.type !== "text_delta") { yield* toolCallContent.hold(event); continue; } sawUserFacingOutput = true; - const text = toolCallContent.ingest(event.text); - yield text.length > 0 ? { type: "text_delta", text } : { type: "heartbeat" }; + const released = toolCallContent.ingestStreaming(event.text); + yield* released.length > 0 ? released : [{ type: "heartbeat" } as AdapterEvent]; } }; diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts index d874849a458..5bcd579df9f 100644 --- a/src/adapters/openai-chat/serialized-tool-call-content.ts +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -2,7 +2,17 @@ import type { TranslatorBudget } from "../../lib/translator-budget"; import type { AdapterEvent } from "../../types"; const OPEN_TAG = ""; +const CLOSE_TAG = ""; const FUNCTION_TAG = " MAX_HELD_BYTES) { + const released = this.drain([]); + // A delta that alone passes the bound is delivered as text rather than retained. + if (deltaBytes > MAX_HELD_BYTES) { + this.context = contextAfter(delta, this.context); + return [...released, ...textEvents(delta)]; + } + return [...released, ...textEvents(this.ingest(delta))]; + } + const text = this.ingest(delta); + // Checked after ingest too: one delta can open a block and already carry more than a bound. + if (this.hasOpenTag && (this.bytes > MAX_HELD_BYTES || proseAfterClosedBlock(this.text) > MAX_TRAILING_CHARS)) { + return [...textEvents(text), ...this.drain([])]; + } + return textEvents(text); + } + /** Exposes held text as evidence for narrowly repairing duplicated argument prefixes. */ current(): string { return this.text; @@ -175,6 +210,8 @@ export class SerializedToolCallContentBuffer { hold(event: AdapterEvent): AdapterEvent[] { if (!this.hasOpenTag) return [...this.drain([]), event]; const eventBytes = Buffer.byteLength(JSON.stringify(event)); + // Queued events count toward the same runaway bound as held text. + if (this.bytes + eventBytes > MAX_HELD_BYTES) return [...this.drain([]), event]; this.budget.reserveTransient(eventBytes, { kind: "live_transient" }).commitRetained(); this.bytes += eventBytes; this.queued.push({ offset: this.text.length, event }); @@ -352,3 +389,16 @@ export function reconcileSerializedToolCallEvents( } events.splice(start, end - start, ...reconciled); } + +function textEvents(text: string): AdapterEvent[] { + return text.length > 0 ? [{ type: "text_delta", text }] : []; +} + +/** Non-whitespace characters after the last closed block, or 0 while a later block is still open. */ +function proseAfterClosedBlock(text: string): number { + const closer = text.lastIndexOf(CLOSE_TAG); + if (closer < 0) return 0; + const tail = text.slice(closer + CLOSE_TAG.length); + if (tail.includes(OPEN_TAG)) return 0; + return tail.replace(/\s+/g, "").length; +} diff --git a/src/codex/home.ts b/src/codex/home.ts index d961ded7e7b..9440d5aec47 100644 --- a/src/codex/home.ts +++ b/src/codex/home.ts @@ -135,11 +135,33 @@ export function findWslWindowsCodexHome(deps: CodexHomeDeps = {}): string | null export function defaultCodexHome(deps: CodexHomeDeps = {}): string { const home = (deps.homedir ?? homedir)(); const defaultHome = join(home, ".codex"); - // A local ~/.codex directory is the user's Codex home even before Codex has - // written config.toml into it (a fresh install). Only an absent local home, - // or a path that is not a directory, lets WSL discovery pick a Windows home. - const detected = localCodexHomeIsDirectory(defaultHome, deps) ? null : findWslWindowsCodexHome(deps); - return detected ?? defaultHome; + // A local ~/.codex that Codex is already using is the user's Codex home even before + // config.toml exists (a fresh install: login writes auth.json, first use writes + // sessions/ and history.jsonl). A local directory with none of that state is not + // evidence of a local Codex: before #5441 such a home let WSL discovery pick the + // Windows home, and existing WSL users who run against that Windows home must not + // be moved to an empty local one on upgrade. + if (localCodexHomeIsDirectory(defaultHome, deps) && localCodexHomeInUse(defaultHome, deps)) return defaultHome; + return findWslWindowsCodexHome(deps) ?? defaultHome; +} + +/** Files and directories Codex itself writes into a home it is using. */ +const LOCAL_CODEX_STATE = ["config.toml", "auth.json", "sessions", "history.jsonl"] as const; + +function localCodexHomeInUse(home: string, deps: CodexHomeDeps): boolean { + return LOCAL_CODEX_STATE.some(entry => pathPresent(join(home, entry), deps)); +} + +/** stat-based presence: an unexpected stat error counts as present, never as a reason to switch homes. */ +function pathPresent(path: string, deps: CodexHomeDeps): boolean { + const stat = deps.statSync ?? statSync; + try { + stat(path); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + return !(code === "ENOENT" || code === "ENOTDIR"); + } } function localCodexHomeIsDirectory(path: string, deps: CodexHomeDeps): boolean { diff --git a/src/lib/tool-envelope-echo-filter.ts b/src/lib/tool-envelope-echo-filter.ts index 39938062d8a..9525116c2b3 100644 --- a/src/lib/tool-envelope-echo-filter.ts +++ b/src/lib/tool-envelope-echo-filter.ts @@ -1,7 +1,13 @@ /** Line-aware filter for echoed tool envelopes in incremental assistant text. */ const MARKERS = ["[Tool Result]", "[Tool Error]", "[tool_result]", "[Tool Call]", "[Tool call:"] as const; const UNTERMINATED_MARKERS = ["[Tool Result", "[Tool Error", "[tool_result", "[Tool Call"] as const; -const TRUNCATED_MARKERS = [...UNTERMINATED_MARKERS, "[Tool call:"] as const; +/** Lines that are an echoed envelope marker on their own (after trimming trailing whitespace). */ +const WHOLE_LINE_MARKERS: readonly string[] = [...MARKERS, ...UNTERMINATED_MARKERS]; +/** + * The coding-agent call line "[Tool call: name (call_id: ...) with args: ...]" can wrap across lines + * when its arguments do, so it is recognised by its prefix, as before; prose rarely opens that way. + */ +const TOOL_CALL_LINE = "[Tool call:"; const MAX_INDENT = 128; // Markdown fenced code (CommonMark): an opener is a run of at least three backticks or tildes // indented at most three spaces; only a run of the same character, at least as long and followed @@ -12,6 +18,16 @@ const FENCE_LINE = /^(\x60{3,}|~{3,})(.*)$/s; const FENCE_PREFIX = /^(\x60{1,2}|~{1,2})$/; const FENCE_RUN = /^(\x60{3,}|~{3,})/; +/** + * The envelope OpenCodex replays is a marker alone on its line ("[Tool Result]\n"). Only a + * line that is exactly such a marker is an echo; prose that merely starts with one ("[Tool Result] + * shows the build passed.") is an answer. The call line keeps its prefix rule (TOOL_CALL_LINE). + */ +export function isWholeLineEchoMarker(line: string): boolean { + const trimmed = line.replace(/^[ \t]*/, "").trimEnd(); + return WHOLE_LINE_MARKERS.includes(trimmed) || trimmed.startsWith(TOOL_CALL_LINE); +} + interface FenceLine { char: string; length: number; @@ -96,7 +112,7 @@ export class ToolEnvelopeEchoFilter { } const probe = this.pending.replace(/^[ \t]*/, ""); const indent = this.pending.length - probe.length; - if (indent <= MAX_INDENT && MARKERS.some(marker => probe === marker)) { + if (indent <= MAX_INDENT && probe === TOOL_CALL_LINE) { if (!this.fence) { this.pending = ""; this.matched = true; @@ -107,21 +123,28 @@ export class ToolEnvelopeEchoFilter { output += this.emit(this.takePending()); continue; } + // A marker is decided when its line completes (completeLine): until then a line that is a + // marker so far or a marker plus trailing whitespace stays pending. Anything else on the + // line makes it prose and releases it. const fenceCandidate = indent <= MAX_FENCE_INDENT && this.pending.length <= MAX_FENCE_LINE && (FENCE_PREFIX.test(probe) || FENCE_RUN.test(probe)); const markerCandidate = indent <= MAX_INDENT - && (probe === "" || MARKERS.some(marker => marker.startsWith(probe))); - const unterminatedCr = char === "\r" - && (UNTERMINATED_MARKERS as readonly string[]).includes(probe.slice(0, -1).trimEnd()); - if (fenceCandidate || markerCandidate || unterminatedCr) continue; + && (probe === "" + || MARKERS.some(marker => marker.startsWith(probe)) + || WHOLE_LINE_MARKERS.includes(probe.trimEnd())); + if (fenceCandidate || markerCandidate) continue; this.flushLineStart(); output += this.emit(this.takePending()); } return output; } - /** At normal end, a distinctive truncated marker or a held fence tail is an echo; other text is prose. */ + /** + * At normal end, a held fence tail, a last line that is a whole marker (or a bare unterminated + * marker such as "[Tool Result") or a "[Tool call:" line is an echo; other text, including a + * line that merely starts with a result or error marker, is prose. + */ finish(): string { if (this.matched) return ""; // A closing fence may end the stream without a trailing newline; settle it before the hold. @@ -133,7 +156,7 @@ export class ToolEnvelopeEchoFilter { return ""; } const probe = pending.replace(/^[ \t]*/, ""); - if ((TRUNCATED_MARKERS as readonly string[]).some(marker => probe.startsWith(marker))) { + if (isWholeLineEchoMarker(probe)) { this.matched = true; return settled; } @@ -166,10 +189,7 @@ export class ToolEnvelopeEchoFilter { return this.emit(raw); } const trimmed = line.trimEnd(); - const markerLine = indent <= MAX_INDENT && ( - (UNTERMINATED_MARKERS as readonly string[]).includes(trimmed) - || (MARKERS as readonly string[]).includes(trimmed) - ); + const markerLine = indent <= MAX_INDENT && isWholeLineEchoMarker(trimmed); if (markerLine) { if (!this.fence) { this.matched = true; diff --git a/src/update/install-detection.mjs b/src/update/install-detection.mjs index 6b1c3ac531d..1db3f2d2df0 100644 --- a/src/update/install-detection.mjs +++ b/src/update/install-detection.mjs @@ -2,6 +2,9 @@ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; const OPENCODEX_MISE_BACKEND = "npm:@bitkyc08/opencodex"; const OPENCODEX_MISE_BACKEND_DIR = "npm-bitkyc08-opencodex"; +/** mise's core Node runtime. npm -g under it is an npm install that mise did not make. */ +const MISE_NODE_RUNTIME = { tool: "node", backend: "core:node" }; +const OPENCODEX_PACKAGE_SEGMENT = "/node_modules/@bitkyc08/opencodex"; /** * @typedef {{ @@ -161,6 +164,20 @@ function detectMiseOwner(packagePath, deps) { return { recognized: true, owner: null, error: "metadata_unreadable" }; } const toolDir = toolRoot.slice(toolRoot.lastIndexOf("/") + 1); + // On Windows, npm -g under a mise-managed Node writes the package straight into + // /installs/node//node_modules, so the adjacent record is Node's own + // (short = "node", full = "core:node"), not a statement about OpenCodex. Only that exact + // runtime record with the package directly in the runtime's global node_modules is an + // npm install; any other backend or layout stays fail-closed below. + if ( + metadata + && metadata.tool === MISE_NODE_RUNTIME.tool + && metadata.backend === MISE_NODE_RUNTIME.backend + && samePath(toolDir, MISE_NODE_RUNTIME.tool, windowsPath) + && isRuntimeGlobalPackage(normalized, installPath, windowsPath) + ) { + return { recognized: false }; + } const expectedToolDir = metadata?.tool === OPENCODEX_MISE_BACKEND ? OPENCODEX_MISE_BACKEND_DIR : metadata?.tool; @@ -184,6 +201,16 @@ function detectMiseOwner(packagePath, deps) { }; } +/** + * True when the package sits directly in the runtime's global node_modules: + * //node_modules/@bitkyc08/opencodex[/...]. + */ +function isRuntimeGlobalPackage(packagePath, installPath, windowsPath) { + const rest = packagePath.slice(installPath.length); + const probe = windowsPath ? rest.toLowerCase() : rest; + return probe === OPENCODEX_PACKAGE_SEGMENT || probe.startsWith(`${OPENCODEX_PACKAGE_SEGMENT}/`); +} + function probeMetadata(path) { try { statSync(path); diff --git a/structure/codex-home.md b/structure/codex-home.md index b259fa843ce..ae7c6afca32 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -33,9 +33,12 @@ issuing deferred inference warmups. Already dispatched requests retain their cap `src/codex/paths.ts` resolves Codex state from `CODEX_HOME` when set and valid, otherwise from `~/.codex`. An unset `CODEX_HOME` falls back to `~/.codex`, including WSL discovery. On WSL, -discovery of a Windows Desktop home applies only when the Linux `~/.codex` is absent or is not a -directory; a fresh `~/.codex` directory without `config.toml` stays the home, and a stat failure -other than absence keeps the local home rather than switching to a different one. Codex runtime discovery (src/codex/runtime.ts) also reads this home on Linux: after an explicit runtime, PATH, and the ordinary install locations, it enumerates the direct children of /bin/wsl//codex newest first, probes each through the isolated --version seam, and re-enumerates on every resolve so a Desktop update that replaces the hash directory is picked up (issue 5635). An explicitly +discovery of a Windows Desktop home applies when the Linux `~/.codex` is absent, is not a directory, +or is a directory holding no Codex state (none of `config.toml`, `auth.json`, `sessions`, +`history.jsonl`; `defaultCodexHome` in `src/codex/home.ts`). A fresh local home Codex is already using +stays the home before `config.toml` exists (issue 5441), a bare directory does not move an existing +user off a Windows home they were running against, and a stat failure other than absence keeps the +local home rather than switching to a different one. Codex runtime discovery (src/codex/runtime.ts) also reads this home on Linux: after an explicit runtime, PATH, and the ordinary install locations, it enumerates the direct children of /bin/wsl//codex newest first, probes each through the isolated --version seam, and re-enumerates on every resolve so a Desktop update that replaces the hash directory is picked up (issue 5635). An explicitly set path that is unreadable or not a directory is an error, not a fallback: silently using a different home than the operator named would write provider state where nobody is looking for it. A fresh install can have that directory but no `config.toml` yet; applying the integration then creates an empty `config.toml` there (never overwriting an existing file) and continues, while a missing home directory is refused with instructions to start Codex once or set `CODEX_HOME` (issue 5422). The managed files are: diff --git a/structure/decisions/ADR-5548-serialized-tool-call-content.md b/structure/decisions/ADR-5548-serialized-tool-call-content.md index b0677e485fe..13b2a1c31d0 100644 --- a/structure/decisions/ADR-5548-serialized-tool-call-content.md +++ b/structure/decisions/ADR-5548-serialized-tool-call-content.md @@ -10,3 +10,4 @@ - Choice: Hold only a possible complete markup block and suppress or repair it only when the function name and duplicated body agree with a structured call in the same response. - Why: Agreement between both representations is deterministic and avoids changing ordinary commentary, mismatched markup, or unrelated providers' valid text. - Consequences: Matching calls no longer appear twice; same-name/different-body examples remain visible; the small held region is translator-budgeted and emits heartbeats while held; terminal failures retain held text without dispatching tools; malformed concatenated arguments are repaired only for the exact duplicated wrapper shape. +- Follow-up (260924): the streaming hold is bounded (8 KiB of prose after a closed block, 4 MiB total); past a bound held text is released unsuppressed. See structure/providers/chat-compat.md. diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index cf34f48315c..d8d1fc7d678 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -245,4 +245,4 @@ so an override can never shorten the budgets that prevent a duplicate proxy, and `src/service/orchestration.ts`) stays bounded. `tests/server/probe-timeout-env.test.ts` reads the constants in child processes. -`src/update/install-detection.mjs` examines both lexical and resolved package paths. An enclosing mise installation owns its nested npm/aube package only when the adjacent `.mise.backend.toml` identifies the containing tool alias and the canonical `npm:@bitkyc08/opencodex` backend. That verified outer owner takes precedence over the inner npm layout. Two verified owners whose tool roots differ only by a symlinked ancestor (macOS `/var` -> `/private/var`) are compared by canonical directory and count as one install. An unreadable or contradictory ownership boundary on either path takes precedence over a verified owner on the other path, refusing mutation without inventing a tool name or recovery command. `ocx update`, dashboard update checks, and update workers expose `installer: "mise"`; checks remain read-only, while mutation is refused with `mise upgrade ` before any proxy stop, package write, or worker creation. The package-tree integrity guard remains active for mise packages. +`src/update/install-detection.mjs` examines both lexical and resolved package paths. An enclosing mise installation owns its nested npm/aube package only when the adjacent `.mise.backend.toml` identifies the containing tool alias and the canonical `npm:@bitkyc08/opencodex` backend. That verified outer owner takes precedence over the inner npm layout. Two verified owners whose tool roots differ only by a symlinked ancestor (macOS `/var` -> `/private/var`) are compared by canonical directory and count as one install. An unreadable or contradictory ownership boundary on either path takes precedence over a verified owner on the other path, refusing mutation without inventing a tool name or recovery command. One boundary is not OpenCodex's at all: on Windows, npm -g under a mise-managed Node puts the package directly in `/installs/node//node_modules`, whose adjacent record is Node's own (`short = "node"`, `full = "core:node"`). That exact record with the package directly in the runtime's global `node_modules` is an npm install and falls through to npm detection; any other backend, alias or deeper layout stays fail-closed (`tests/update/update-mise-node-runtime.test.ts`). `ocx update`, dashboard update checks, and update workers expose `installer: "mise"`; checks remain read-only, while mutation is refused with `mise upgrade ` before any proxy stop, package write, or worker creation. The package-tree integrity guard remains active for mise packages. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index f595d2fff5c..00e0662f93e 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -330,7 +330,7 @@ arguments with the same freeform body, the adapter keeps the JSON suffix only wh prefix, and wrapper's `input` value all agree. Mismatched markup and arguments remain byte-exact. Silent held-content frames emit adapter heartbeats. Terminal errors and transport read failures drain all held text, including matching serialized blocks, because pending tools are not dispatched. -The held bytes use the shared translator budget. For a model opted into inline `` splitting, +The held bytes use the shared translator budget. The streaming hold is bounded (`ingestStreaming`): once a closed block is followed by more than 8 KiB of prose with no block open after it, or held text plus queued events would pass 4 MiB, everything held is released in order with nothing suppressed, so an unmatched block no longer delays the rest of the answer to the end of the turn. A duplicate is the tail of the content, so its reconciliation is unaffected; past either bound the stream prefers delivery (the pre-#5548 raw markup) over suppression. Buffered responses keep the unbounded `ingest` because their structured calls are already known (`tests/adapters/openai/openai-chat-serialized-tool-call-hold-bound.test.ts`). For a model opted into inline `` splitting, reconciliation sees only the answer text the splitter emits. A reasoning event that arrives while a block candidate is held waits behind it and is released in its original position, so event order never changes and a duplicate is not exposed early; line and fence context carry across the diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index ae746bf63de..b772ac6e86f 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -289,7 +289,8 @@ redirects restored provider-state ids only within the same opaque credential sco Assistant root replay drops echoed envelopes before they are sent back upstream (`stripAssistantEchoedToolEnvelope`), so the transcript stops feeding itself. The strip starts at -a whole-line marker and ends at the next blank line rather than at the end of the message: the +a whole-line marker (the live filter's rule, `isWholeLineEchoMarker`: a result or error marker alone on its +line, or a `[Tool call:` line; prose that starts with a result marker is kept) and ends at the next blank line rather than at the end of the message: the envelope has no recognisable terminator and observed copies are not byte-exact, and truncating to the end discarded a genuine answer whenever the model resumed after the echo. An envelope whose pasted body contains its own blank line therefore leaves a remainder in replay; conversation diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index bda93df9cd1..4d553d3d90d 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -22,7 +22,11 @@ Native xAI Responses delivery strips a line-leading echoed tool-result or tool-c envelope across split SSE text deltas. It is armed only when the request can have primed the echo: a tool call or tool output in the input (a dangling call gets a synthetic output from the paired tool-result repair), or a `previous_response_id` continuation whose history lives upstream -(`responsesRequestMayReplayToolOutput`); a first turn is delivered untouched. The same filter preserves leading prose and +(`responsesRequestMayReplayToolOutput`); a first turn is delivered untouched. Only a line that is the marker alone +(`[Tool Result]`, `[Tool Error]`, `[tool_result]`, `[Tool Call]`, trailing whitespace allowed) or a +`[Tool call:` line counts (`isWholeLineEchoMarker` in `src/lib/tool-envelope-echo-filter.ts`): prose that +merely starts with a result or error marker, such as `[Tool Result] shows the build passed.`, is an +answer and reaches the client whole (`tests/adapters/tool-envelope-echo-whole-line.test.ts`). The same filter preserves leading prose and normalizes text-done events, completed snapshots, non-streaming JSON, and the stored continuation snapshot. It does not rotate an xAI upstream conversation. diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-hold-bound.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-hold-bound.test.ts new file mode 100644 index 00000000000..a1dd4f70c97 --- /dev/null +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-hold-bound.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { SerializedToolCallContentBuffer } from "../../../src/adapters/openai-chat/serialized-tool-call-content"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "../../helpers/translator-budget"; + +// An unmatched bare block must not hold the rest of a streamed answer until the turn +// ends (#5548 follow-up). A duplicate is the tail of the content, so prose after a closed block is +// released once it passes the bound; a real duplicate followed by its structured call is still removed. +const BLOCK = "text('ok');\n"; +const PROSE = "The answer continues here. ".repeat(400); // > 8 KiB of non-whitespace text +const texts = (events: AdapterEvent[]) => events + .filter((event): event is Extract => event.type === "text_delta") + .map(event => event.text) + .join(""); + +describe("bounded streaming hold", () => { + test("prose after a closed unmatched block is released in order, nothing suppressed", () => { + const buffer = new SerializedToolCallContentBuffer(createTestTranslatorBudget()); + expect(buffer.ingestStreaming("Intro\n")).toEqual([{ type: "text_delta", text: "Intro\n" }]); + expect(buffer.ingestStreaming(BLOCK)).toEqual([]); + expect(buffer.ingestStreaming("short tail")).toEqual([]); + const released = buffer.ingestStreaming(PROSE); + expect(texts(released)).toBe(BLOCK + "short tail" + PROSE); + // Scanning resumes: later text passes straight through. + expect(buffer.ingestStreaming(" more")).toEqual([{ type: "text_delta", text: " more" }]); + buffer.dispose(); + }); + + test("a block still open is not released by the prose rule", () => { + const buffer = new SerializedToolCallContentBuffer(createTestTranslatorBudget()); + expect(buffer.ingestStreaming("")).toEqual([]); + expect(buffer.ingestStreaming(PROSE)).toEqual([]); + buffer.dispose(); + }); + + test("a second block open after a closed one keeps the hold", () => { + const buffer = new SerializedToolCallContentBuffer(createTestTranslatorBudget()); + expect(buffer.ingestStreaming(BLOCK + "\n")).toEqual([]); + expect(buffer.ingestStreaming(PROSE)).toEqual([]); + buffer.dispose(); + }); + + test("the size bound releases a runaway hold before retaining the next delta", () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 16 * 1024 * 1024 }); + const buffer = new SerializedToolCallContentBuffer(budget); + const chunk = "x".repeat(1024 * 1024); + expect(buffer.ingestStreaming("")).toEqual([]); + for (let i = 0; i < 3; i++) expect(buffer.ingestStreaming(chunk)).toEqual([]); + const released = buffer.ingestStreaming(chunk + chunk); + expect(texts(released)).toBe("" + chunk.repeat(5)); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, overflows: 0 }); + buffer.dispose(); + }); + + test("buffered ingest keeps its unbounded hold", () => { + const buffer = new SerializedToolCallContentBuffer(createTestTranslatorBudget()); + expect(buffer.ingest(BLOCK)).toBe(""); + expect(buffer.ingest(PROSE)).toBe(""); + buffer.dispose(); + }); +}); + +const MODEL = "mimo-v2.6-flash"; +function adapter() { + const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://gateway.example.test/v1", apiKey: "key" }; + const built = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + const parsed: OcxParsedRequest = { + modelId: MODEL, + stream: true, + options: {}, + context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] }, + }; + built.buildRequest(parsed); + return built; +} +const frame = (value: unknown) => "data: " + JSON.stringify(value) + "\n\n"; + +describe("openai-chat streaming", () => { + test("an unmatched block followed by a long answer is delivered before the stream ends", async () => { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + for (const text of ["Intro\n", BLOCK, PROSE]) controller.enqueue(encoder.encode(frame({ choices: [{ delta: { content: text } }] }))); + // The stream stays open: nothing may depend on its end. + }, + }); + let seen = ""; + const iterator = adapter().parseStream(new Response(body))[Symbol.asyncIterator](); + const deadline = Date.now() + 3000; + while (!seen.includes(PROSE) && Date.now() < deadline) { + const next = await Promise.race([ + iterator.next(), + new Promise>(resolve => setTimeout(() => resolve({ done: true, value: undefined }), 1000)), + ]); + if (next.done) break; + if (next.value.type === "text_delta") seen += next.value.text; + } + await iterator.return?.(); + expect(seen).toBe("Intro\n" + BLOCK + PROSE); + }); + + test("a duplicated block followed by its structured call is still suppressed", async () => { + const call = { index: 0, id: "call_exec", function: { name: "exec", arguments: JSON.stringify({ input: "text('ok');" }) } }; + const body = frame({ choices: [{ delta: { content: "Running it.\n" } }] }) + + frame({ choices: [{ delta: { content: BLOCK } }] }) + + frame({ choices: [{ delta: { tool_calls: [call] } }] }) + + frame({ choices: [{ delta: {}, finish_reason: "tool_calls" }] }) + + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapter().parseStream(new Response(body))) events.push(event); + expect(texts(events)).toBe("Running it.\n"); + }); +}); + + +describe("bound bypass paths", () => { + test("one delta that opens a block and passes the size bound is not retained", () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 16 * 1024 * 1024 }); + const buffer = new SerializedToolCallContentBuffer(budget); + const big = "" + "x".repeat(5 * 1024 * 1024); + expect(texts(buffer.ingestStreaming(big))).toBe(big); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0 }); + buffer.dispose(); + }); + + test("an oversized delta after an open block is delivered after the held text", () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 32 * 1024 * 1024 }); + const buffer = new SerializedToolCallContentBuffer(budget); + expect(buffer.ingestStreaming("")).toEqual([]); + const big = "" + "y".repeat(5 * 1024 * 1024); + expect(texts(buffer.ingestStreaming(big))).toBe("" + big); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0 }); + buffer.dispose(); + }); + + test("queued non-text events count toward the size bound", () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 16 * 1024 * 1024 }); + const buffer = new SerializedToolCallContentBuffer(budget); + expect(buffer.ingestStreaming("")).toEqual([]); + const reasoning = { type: "reasoning_raw_delta", text: "r".repeat(1024 * 1024) } as AdapterEvent; + for (let i = 0; i < 3; i++) expect(buffer.hold(reasoning)).toEqual([{ type: "heartbeat" }]); + const released = buffer.hold(reasoning); + expect(released.at(-1)).toEqual(reasoning); + expect(released.filter(event => event.type === "reasoning_raw_delta")).toHaveLength(4); + expect(texts(released)).toBe(""); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0 }); + buffer.dispose(); + }); +}); diff --git a/tests/adapters/tool-envelope-echo-whole-line.test.ts b/tests/adapters/tool-envelope-echo-whole-line.test.ts new file mode 100644 index 00000000000..7a448a0b41c --- /dev/null +++ b/tests/adapters/tool-envelope-echo-whole-line.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { ToolEnvelopeEchoFilter, stripToolEnvelopeEcho } from "../../src/lib/tool-envelope-echo-filter"; +import { stripGrokUpstreamEnvelopeEchoFromResponsesJson } from "../../src/server/grok-upstream-envelope-echo"; +import { stripAssistantEchoedToolEnvelope } from "../../src/adapters/cursor/envelope-echo"; + +// The replayed envelope is a marker alone on its line. An answer line that only STARTS with a +// marker is prose and must reach the client whole, on the live filter (streamed and buffered), +// the xAI Responses JSON path and the Cursor history replay stripper. +function streamed(text: string): string { + const filter = new ToolEnvelopeEchoFilter(); + let out = ""; + for (const char of text) out += filter.feed(char); + return out + filter.finish(); +} + +function bothWays(text: string): string { + const whole = stripToolEnvelopeEcho(text); + expect(streamed(text)).toBe(whole); + return whole; +} + +const FENCE = "\x60\x60\x60"; + +describe("whole-line echo markers", () => { + test("prose that starts with a marker survives with everything after it", () => { + const text = "Here is the log:\n[Tool Result] shows the build passed.\nNext steps follow.\n"; + expect(bothWays(text)).toBe(text); + expect(bothWays("[Tool Error] means the call failed, as explained below.\nMore.")).toBe("[Tool Error] means the call failed, as explained below.\nMore."); + }); + + test("a marker alone on its line still drops the echoed tail", () => { + expect(bothWays("Done.\n[Tool Result]\nsecret\n")).toBe("Done.\n"); + expect(bothWays("Done.\n [tool_result]\ncall_id: c1\n")).toBe("Done.\n"); + }); + + test("CRLF and trailing whitespace after a marker are still a whole-line marker", () => { + expect(bothWays("Done.\r\n[Tool Result]\r\nsecret")).toBe("Done.\r\n"); + expect(bothWays("Done.\n[Tool Result] \nsecret")).toBe("Done.\n"); + }); + + test("a [Tool call: line keeps its prefix rule, including a call whose arguments wrap", () => { + expect(bothWays("[Tool call: read (call_id: c1) with args: {}]\nsecret")).toBe(""); + expect(bothWays("Done.\n[Tool call: Glob (call_id: c2) with args: {\n \"a\": 1\n}]")).toBe("Done.\n"); + }); + + test("the last line without a newline follows the same rule", () => { + expect(bothWays("Answer:\n[Tool Result] header text")).toBe("Answer:\n[Tool Result] header text"); + expect(bothWays("Answer:\n[Tool Result")).toBe("Answer:\n"); + expect(bothWays("Answer:\n[Tool call: read (call_id: c1")).toBe("Answer:\n"); + }); + + test("markers inside a closed fence stay code", () => { + const text = "x\n" + FENCE + "\n[Tool Result] example\n[Tool Result]\n" + FENCE + "\nafter"; + expect(bothWays(text)).toBe(text); + }); + +}); + +describe("xAI Responses JSON path", () => { + test("prose starting with a marker is kept; a whole-line echo is stripped", () => { + const body = (text: string) => JSON.stringify({ output: [{ type: "message", content: [{ type: "output_text", text }] }] }); + const read = (json: string) => (JSON.parse(json) as { output: { content: { text: string }[] }[] }).output[0]!.content[0]!.text; + expect(read(stripGrokUpstreamEnvelopeEchoFromResponsesJson(body("[Tool Result] shows x.\nMore.")))).toBe("[Tool Result] shows x.\nMore."); + expect(read(stripGrokUpstreamEnvelopeEchoFromResponsesJson(body("ok\n[Tool Result]\nsecret")))).toBe("ok\n"); + }); +}); + +describe("Cursor assistant-history replay", () => { + test("prose starting with a marker is not stripped from replayed history", () => { + const text = "[Tool Result] shows the tests passed.\nThe fix is in place."; + expect(stripAssistantEchoedToolEnvelope(text)).toBe(text); + }); + + test("a whole-line envelope is still stripped with its payload run", () => { + expect(stripAssistantEchoedToolEnvelope("Intro\n\n[Tool Result]\npayload line\n\nAfter")).toBe("Intro\n\n\nAfter"); + expect(stripAssistantEchoedToolEnvelope("[Tool call: read (call_id: c1) with args: {}]\npayload")).toBe(""); + }); +}); + diff --git a/tests/codex-integration/codex-home-wsl-local-state.test.ts b/tests/codex-integration/codex-home-wsl-local-state.test.ts new file mode 100644 index 00000000000..e7351067cbe --- /dev/null +++ b/tests/codex-integration/codex-home-wsl-local-state.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { defaultCodexHome } from "../../src/codex/home"; + +// An existing WSL user can have a local ~/.codex directory that Codex never used while running +// against the discovered Windows Codex home. Only local Codex state keeps the local home (#5441's +// fresh install); a bare directory must not move that user to an empty home on upgrade. +const usersRoot = ["/mnt/c", "Users"].join("/"); +const linuxCodexHome = join("/home/example", ".codex"); +const windowsCodexHome = [usersRoot, "windows-user", ".codex"].join("/"); + +function enoent(): never { + throw Object.assign(new Error("absent"), { code: "ENOENT" }); +} + +function resolveWith(localEntries: Record, env: NodeJS.ProcessEnv = { WSL_DISTRO_NAME: "Ubuntu" }): string { + return defaultCodexHome({ + env, + platform: "linux", + homedir: () => "/home/example", + usersRoot, + existsSync: (path: string) => path === usersRoot || path === `${windowsCodexHome}/config.toml`, + readdirSync: () => ["windows-user"], + statSync: ((path: string) => { + if (path === linuxCodexHome) return { isDirectory: () => true }; + if (path.startsWith(`${linuxCodexHome}/`) || path.startsWith(`${linuxCodexHome}\\`)) { + const entry = path.slice(linuxCodexHome.length + 1); + const state = localEntries[entry] ?? "absent"; + if (state === "absent") enoent(); + if (state === "denied") throw Object.assign(new Error("denied"), { code: "EACCES" }); + return { isDirectory: () => entry === "sessions" }; + } + return { isDirectory: () => true }; + }) as never, + realpathSync: (path: string) => path, + }); +} + +describe("WSL Codex home with a local ~/.codex directory", () => { + test("a bare local directory keeps the discovered Windows home", () => { + expect(resolveWith({})).toBe(windowsCodexHome); + }); + + test("a local home Codex has logged into stays the home before config.toml exists", () => { + expect(resolveWith({ "auth.json": "present" })).toBe(linuxCodexHome); + }); + + test("local sessions or history keep the local home", () => { + expect(resolveWith({ sessions: "present" })).toBe(linuxCodexHome); + expect(resolveWith({ "history.jsonl": "present" })).toBe(linuxCodexHome); + }); + + test("a local config.toml keeps the local home", () => { + expect(resolveWith({ "config.toml": "present" })).toBe(linuxCodexHome); + }); + + test("an unreadable state entry keeps the local home rather than switching", () => { + expect(resolveWith({ "auth.json": "denied" })).toBe(linuxCodexHome); + }); + + test("outside WSL a bare local directory is still the home", () => { + expect(resolveWith({}, {})).toBe(linuxCodexHome); + }); +}); + diff --git a/tests/codex-integration/codex-home-wsl.test.ts b/tests/codex-integration/codex-home-wsl.test.ts index 623e3c5880e..e56c60d1695 100644 --- a/tests/codex-integration/codex-home-wsl.test.ts +++ b/tests/codex-integration/codex-home-wsl.test.ts @@ -51,6 +51,8 @@ describe("wsl.conf automount root", () => { // Native join: defaultCodexHome builds the local home with the host path module. const linuxCodexHome = join("/home/example", ".codex"); const windowsCodexHome = [usersRoot, "windows-user", ".codex"].join("/"); + // Fresh and in use: Codex has logged in (auth.json) but not written config.toml yet. + const localState = new Set([linuxCodexHome, join(linuxCodexHome, "auth.json")]); expect(defaultCodexHome({ env: { WSL_DISTRO_NAME: "Ubuntu" }, @@ -61,7 +63,10 @@ describe("wsl.conf automount root", () => { || path === linuxCodexHome || path === `${windowsCodexHome}/config.toml`, readdirSync: () => ["windows-user"], - statSync: (() => ({ isDirectory: () => true })) as never, + statSync: ((path: string) => { + if (path.startsWith(linuxCodexHome) && !localState.has(path)) throw Object.assign(new Error("absent"), { code: "ENOENT" }); + return { isDirectory: () => true }; + }) as never, realpathSync: (path: string) => path, })).toBe(linuxCodexHome); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b107a99efd7..b281e5d60c9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { "abort-idle-deadline.test.ts": "lib", + "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", "account-pool-management-api.test.ts": "server", @@ -361,6 +362,7 @@ "codex-history-worker.test.ts": "codex-integration", "codex-history-writer.test.ts": "codex-integration", "codex-home-wsl.test.ts": "codex-integration", + "codex-home-wsl-local-state.test.ts": "codex-integration", "codex-inject-history-wording.test.ts": "codex-integration", "codex-inject-integration.test.ts": "codex-integration", "codex-inject-missing-config.test.ts": "codex-integration", @@ -1004,6 +1006,7 @@ "openai-chat-reasoning-wire-policy.test.ts": "adapters/openai", "openai-chat-sanitization-review-regressions.test.ts": "adapters/openai", "openai-chat-serialized-tool-call-content.test.ts": "adapters/openai", + "openai-chat-serialized-tool-call-hold-bound.test.ts": "adapters/openai", "openai-chat-serialized-tool-call-think.test.ts": "adapters/openai", "openai-chat-system-order.test.ts": "adapters/openai", "responses-chat-tool-call-content.test.ts": "responses", @@ -1462,6 +1465,7 @@ "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", "update-mise.test.ts": "update", + "update-mise-node-runtime.test.ts": "update", "update-pnpm.test.ts": "update", "update-stop-classification.test.ts": "update", "update-stop-first.test.ts": "update", diff --git a/tests/update/update-mise-node-runtime.test.ts b/tests/update/update-mise-node-runtime.test.ts new file mode 100644 index 00000000000..0e779696d52 --- /dev/null +++ b/tests/update/update-mise-node-runtime.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { detectInstallOwnershipFromPath } from "../../src/update/install-detection.mjs"; + +// npm -g under a mise-managed Node on Windows installs straight into the runtime's +// /node_modules, where the adjacent .mise.backend.toml is Node's own record. +// That install is npm-owned; it must not be refused as contradictory mise metadata. +const NODE_RUNTIME = 'short = "node"\nfull = "core:node"\n'; + +function detect(path: string, metadataPath: string, content: string) { + return detectInstallOwnershipFromPath(path, { + exists: () => false, + probe: (value: string) => (value === metadataPath ? "present" : "absent"), + readFile: () => content, + realpath: (value: string) => value, + }); +} + +describe("npm global under a mise-managed Node runtime", () => { + test("Windows npm -g under mise core Node is an npm install", () => { + const root = "C:/Users/example/AppData/Local/mise/installs/node"; + const metadata = root + "/.mise.backend.toml"; + for (const path of [ + "C:\\Users\\example\\AppData\\Local\\mise\\installs\\node\\22.12.0\\node_modules\\@bitkyc08\\opencodex\\bin", + root + "/22.12.0/node_modules/@bitkyc08/opencodex/bin", + root + "/22.12.0/node_modules/@bitkyc08/opencodex", + ]) { + expect(detect(path, metadata, NODE_RUNTIME)).toEqual({ installer: "npm" }); + } + }); + + test("a POSIX runtime layout never reaches the Node record", () => { + const root = "/home/example/.local/share/mise/installs/node"; + expect(detect(root + "/22.12.0/lib/node_modules/@bitkyc08/opencodex/bin", root + "/.mise.backend.toml", NODE_RUNTIME)) + .toEqual({ installer: "npm" }); + }); + + test("any other runtime record under a node tool root stays fail-closed", () => { + const root = "C:/mise/installs/node"; + const path = root + "/22.12.0/node_modules/@bitkyc08/opencodex/bin"; + for (const content of [ + 'short = "node"\nfull = "asdf:someone/node"\n', + 'short = "nodejs"\nfull = "core:node"\n', + ]) { + expect(detect(path, root + "/.mise.backend.toml", content)) + .toMatchObject({ installer: "mise", owner: null, error: "metadata_inconsistent" }); + } + }); + + test("the Node record does not cover a package nested deeper than the runtime's global node_modules", () => { + const root = "C:/mise/installs/node"; + const path = root + "/22.12.0/node_modules/some-tool/node_modules/@bitkyc08/opencodex/bin"; + expect(detect(path, root + "/.mise.backend.toml", NODE_RUNTIME)) + .toMatchObject({ installer: "mise", owner: null, error: "metadata_inconsistent" }); + }); + + test("an OpenCodex record aliased as node is still mise-owned", () => { + const root = "C:/mise/installs/node"; + const path = root + "/2.65.0/node_modules/@bitkyc08/opencodex/bin"; + expect(detect(path, root + "/.mise.backend.toml", 'short = "node"\nfull = "npm:@bitkyc08/opencodex"\n')) + .toMatchObject({ installer: "mise", owner: { tool: "node", backend: "npm:@bitkyc08/opencodex" } }); + }); +}); + From 560db33fb8bb7e286cf701b06795498d94db4084 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 10:40:32 +0900 Subject: [PATCH 16/48] fix(codex): keep the WSL home state list out of the module's dead zone (#5721) #5720 declared the Codex-state list as a module-level const below defaultCodexHome. The storage workers reach defaultCodexHome during module initialisation through an import cycle, and with a ~/.codex directory present (every Windows CI runner, and most user machines) the call read the const before it was initialised: ReferenceError in the trash-restore and policy-run workers. The list is now local to the function. The CodeBuddy compiled-MCP test also removes its temp directory with the shared retrying helper: Windows keeps the compiled ocx executable locked briefly after the process exits, and a plain rmSync failed with EBUSY. --- src/codex/home.ts | 8 ++++---- tests/providers/codebuddy-mcp-server.test.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/codex/home.ts b/src/codex/home.ts index 9440d5aec47..71a2ad0a0a9 100644 --- a/src/codex/home.ts +++ b/src/codex/home.ts @@ -145,11 +145,11 @@ export function defaultCodexHome(deps: CodexHomeDeps = {}): string { return findWslWindowsCodexHome(deps) ?? defaultHome; } -/** Files and directories Codex itself writes into a home it is using. */ -const LOCAL_CODEX_STATE = ["config.toml", "auth.json", "sessions", "history.jsonl"] as const; - function localCodexHomeInUse(home: string, deps: CodexHomeDeps): boolean { - return LOCAL_CODEX_STATE.some(entry => pathPresent(join(home, entry), deps)); + // Files and directories Codex itself writes into a home it is using. Kept local: defaultCodexHome + // runs during other modules' initialisation (the storage workers reach it through an import + // cycle), and a module-level const declared below it is still in its temporal dead zone then. + return ["config.toml", "auth.json", "sessions", "history.jsonl"].some(entry => pathPresent(join(home, entry), deps)); } /** stat-based presence: an unexpected stat error counts as present, never as a reason to switch homes. */ diff --git a/tests/providers/codebuddy-mcp-server.test.ts b/tests/providers/codebuddy-mcp-server.test.ts index d5d1a5d4b16..3deac6e24a4 100644 --- a/tests/providers/codebuddy-mcp-server.test.ts +++ b/tests/providers/codebuddy-mcp-server.test.ts @@ -1,9 +1,10 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; import { CODEBUDDY_TOOL_LIMITS } from "../../src/adapters/codebuddy/tool-bridge"; import { codeBuddyMcpInvocation } from "../../src/adapters/coding-agent/turn"; @@ -49,7 +50,10 @@ async function rejectedCatalog(rawCatalog: string): Promise { afterEach(() => { for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); + // Windows keeps the compiled ocx executable locked for a moment after its process exits, so + // a plain rmSync fails with EBUSY; the shared helper waits on the same bounded schedule as + // every other fixture teardown. + removeTreeWithRetry(dir); } }); From 6b7a91f5750c3a83ae299f3c08170772d7e20c30 Mon Sep 17 00:00:00 2001 From: Terry Tan Date: Wed, 23 Sep 2026 19:42:19 -0700 Subject: [PATCH 17/48] fix(cursor): route flat effort wire ids for opus 5.5 (#5723) Cursor Connect rejects claude-opus-5-5-thinking-* with not_found because the live GetUsableModels roster exposes flat effort-suffixed ids (claude-opus-5-5-{low..max} and -fast) without a thinking infix. Set defaultVariant to regular and update effort mapping to match the live Cursor wire specification. Fixes #5722. --- src/adapters/cursor/catalog.ts | 12 ++++-------- src/adapters/cursor/effort-map.ts | 8 ++------ tests/providers/cursor/cursor-catalog.test.ts | 6 ++++++ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/adapters/cursor/catalog.ts b/src/adapters/cursor/catalog.ts index 5453d8e223e..c4e2c5c94f6 100644 --- a/src/adapters/cursor/catalog.ts +++ b/src/adapters/cursor/catalog.ts @@ -184,19 +184,15 @@ export const CURSOR_CAPABILITIES: Record = { thinkingFast: { levels: FULL, order: T }, }, }, - // 260923 Claude Opus 5.5: cursor.com/docs/models/claude-opus-5-5 publishes the id - // `claude-opus-5-5`, a thinking variant, a `claude-opus-5-5-fast` tier and a 1M max context. - // The ladders mirror the measured claude-opus-5 rows (fast stops at high) until the live - // GetUsableModels roster is dumped; the live filter drops any id the account cannot use. + // 260923 Claude Opus 5.5: live GetUsableModels roster advertises flat effort-suffixed + // wire ids (claude-opus-5-5-{low..max} and -fast) rather than a thinking variant. "claude-opus-5-5": { displayName: "Claude Opus 5.5", window: CONTEXT_1M, - defaultVariant: "thinking", + defaultVariant: "regular", variants: { regular: { levels: FULL }, - thinking: { levels: FULL, order: T }, - fast: { levels: ["low", "medium", "high"] }, - thinkingFast: { levels: FULL, order: T }, + fast: { levels: FULL }, }, }, "glm-5.2": { diff --git a/src/adapters/cursor/effort-map.ts b/src/adapters/cursor/effort-map.ts index fd78b6105a6..737430cb4ab 100644 --- a/src/adapters/cursor/effort-map.ts +++ b/src/adapters/cursor/effort-map.ts @@ -36,9 +36,9 @@ const CURSOR_MODEL_EFFORT_TIERS: Record = { "claude-opus-4-8-fast": ["low", "medium", "high", "xhigh", "max"], "claude-opus-5": ["low", "medium", "high", "xhigh", "max"], "claude-opus-5-fast": ["low", "medium", "high"], - // 260923 Opus 5.5 (cursor.com/docs/models/claude-opus-5-5), mirroring the measured opus-5 rows. + // 260923 Opus 5.5: live GetUsableModels roster advertises low..max in both regular and fast forms. "claude-opus-5-5": ["low", "medium", "high", "xhigh", "max"], - "claude-opus-5-5-fast": ["low", "medium", "high"], + "claude-opus-5-5-fast": ["low", "medium", "high", "xhigh", "max"], "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"], "glm-5.2": ["high", "max"], // 260825 live GetUsableModels. gemini-3.6-flash was the first Cursor model exposing @@ -56,8 +56,6 @@ const CURSOR_MODEL_EFFORT_TIERS: Record = { // 4.6-opus thinks only at high/max, 4.5-opus only at high, 4.6-sonnet only at medium. "claude-opus-5-thinking": ["low", "medium", "high", "xhigh", "max"], "claude-opus-5-thinking-fast": ["low", "medium", "high", "xhigh", "max"], - "claude-opus-5-5-thinking": ["low", "medium", "high", "xhigh", "max"], - "claude-opus-5-5-thinking-fast": ["low", "medium", "high", "xhigh", "max"], "claude-opus-4-8-thinking": ["low", "medium", "high", "xhigh", "max"], "claude-opus-4-8-thinking-fast": ["low", "medium", "high", "xhigh", "max"], "claude-opus-4-7-thinking": ["low", "medium", "high", "xhigh", "max"], @@ -139,8 +137,6 @@ const CANONICAL_CODEX_EFFORT_ORDER = ["low", "medium", "high", "xhigh", "max"] a const CURSOR_THINKING_FAMILIES: Readonly> = { "claude-opus-5-thinking": { source: "claude-opus-5", order: "thinking-then-effort" }, "claude-opus-5-thinking-fast": { source: "claude-opus-5-fast", order: "thinking-then-effort" }, - "claude-opus-5-5-thinking": { source: "claude-opus-5-5", order: "thinking-then-effort" }, - "claude-opus-5-5-thinking-fast": { source: "claude-opus-5-5-fast", order: "thinking-then-effort" }, "claude-opus-4-8-thinking": { source: "claude-opus-4-8", order: "thinking-then-effort" }, "claude-opus-4-8-thinking-fast": { source: "claude-opus-4-8-fast", order: "thinking-then-effort" }, "claude-opus-4-7-thinking": { source: "claude-opus-4-7", order: "thinking-then-effort" }, diff --git a/tests/providers/cursor/cursor-catalog.test.ts b/tests/providers/cursor/cursor-catalog.test.ts index f4b1580ad7f..85d260faeef 100644 --- a/tests/providers/cursor/cursor-catalog.test.ts +++ b/tests/providers/cursor/cursor-catalog.test.ts @@ -184,6 +184,12 @@ describe("cursor umbrella catalog (devlog 260828_cursor_umbrella_catalog)", () = expect(resolved.wireId).toBe("claude-opus-5-thinking-high"); }); + test("claude-opus-5-5 routes flat effort-suffixed wire ids without a thinking infix (#5722)", () => { + expect(resolveCursorSelection("claude-opus-5-5", "medium").wireId).toBe("claude-opus-5-5-medium"); + expect(resolveCursorSelection("claude-opus-5-5", "high").wireId).toBe("claude-opus-5-5-high"); + expect(resolveCursorSelection("claude-opus-5-5", "medium", undefined, { fast: true }).wireId).toBe("claude-opus-5-5-medium-fast"); + }); + test("bare-thinking families ignore effort", () => { expect(resolveCursorSelection("claude-4-sonnet", "max").wireId).toBe("claude-4-sonnet-thinking"); }); From 764dc1361af07fb27a15f6ca95395fadc0215f41 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 12:43:32 +0900 Subject: [PATCH 18/48] fix(anthropic): handle Opus 5.5 forced tool choice (#5729) * fix(anthropic): degrade Opus 5.5 forced tool choice safely * test(anthropic): preserve Opus 5.5 non-forced tool choices * docs(anthropic): record Opus 5.5 disabled-thinking probe --- .../010_plan.md | 43 ++++++++++ src/adapters/anthropic.ts | 19 +++++ structure/providers/chat-compat.md | 7 ++ .../anthropic-parallel-tool-disable.test.ts | 79 ++++++++++++++++++- 4 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 devlog/_plan/260924_anthropic_forced_tool_choice/010_plan.md diff --git a/devlog/_plan/260924_anthropic_forced_tool_choice/010_plan.md b/devlog/_plan/260924_anthropic_forced_tool_choice/010_plan.md new file mode 100644 index 00000000000..d66a0b19b3a --- /dev/null +++ b/devlog/_plan/260924_anthropic_forced_tool_choice/010_plan.md @@ -0,0 +1,43 @@ +# Anthropic forced tool choice and Opus 5.5 + +## Scope +One implementation unit: inspect adapter-generated body and live status for required/named/auto tool choices on Opus 5.5 and a control, with and without explicit reasoning. A current token is read only; no proxy starts and no token/body is printed. + +## Hypotheses +H1: adaptive thinking and forced any/tool conflict; falsifier is a 2xx live response for the same adapter-produced body. +H2: Opus 5.5 rejects forced any/tool regardless of thinking; falsifier is a 2xx response with thinking omitted or disabled. +H3: proxy/router rather than upstream is responsible; falsifier is the same upstream error from the adapter-produced request. + +## Diff-level plan +If live evidence confirms H1, change only src/adapters/anthropic.ts around its reasoning and tool_choice construction, retaining forced any/tool and suppressing or disabling thinking only when live evidence proves the wire shape works. Add a focused test in tests/adapters/anthropic/ proving required/named semantics and unaffected auto/none cases. Update owning structure document if required; test the focused file, typecheck, structure:check, privacy:scan. Obtain gpt-6-sol adversarial review, push one branch and open a template PR to dev. Do not run the full suite, merge, or touch main/preview. + +## Audit and outcome + +Live adapter-generated probe, 2026-09-24, using the active Anthropic OAuth access token read +in memory from `~/.opencodex/auth.json` (token and response bodies were never printed). The +probe sent 12 small Messages requests through `createAnthropicAdapter(...).buildRequest()`; +the pre-fix wire fields and status were: + +| Model | Effort | Choice | Wire choice | Thinking | Status | +|---|---|---|---|---|---:| +| claude-opus-5-5 | medium / omitted | required | any | adaptive / omitted | 400 | +| claude-opus-5-5 | medium / omitted | named | tool | adaptive / omitted | 400 | +| claude-opus-5-5 | medium / omitted | auto | auto | adaptive / omitted | 200 | +| claude-opus-5 | medium / omitted | required | any | adaptive / omitted | 200 | +| claude-opus-5 | medium / omitted | named | tool | adaptive / omitted | 200 | +| claude-opus-5 | medium / omitted | auto | auto | adaptive / omitted | 200 | + +After the fix, the same 12 requests returned 200. Opus 5.5 required/named choices are sent +as `auto`; Opus 5 keeps `any`/`tool`. This agrees with Anthropic's published migration guide: +https://platform.claude.com/docs/en/models/opus-5-5/whats-new-opus-5-5 (forced tool use is +unsupported for Opus 5.5; `auto` and `none` are supported). The compatibility downgrade +preserves a named or allowed tool's candidate set and preserves `disable_parallel_tool_use`, +but it cannot preserve the upstream forced-call guarantee. + +For completeness, two supplementary requests manually overrode the adapter body to send +`thinking:{type:"disabled"}` together with `tool_choice` `any` and `tool`. Both returned +HTTP 400. The published guide also states that Opus 5.5 rejects disabled thinking, so there +is no thinking-off wire shape that can retain forced tool use for this model. + +## Plan reflection after vendor source check +Official Opus 5.5 change guide (https://platform.claude.com/docs/en/models/opus-5-5/whats-new-opus-5-5, read 2026-09-24) says thinking cannot be disabled and any/tool forced choices return 400; auto/none are supported. This falsifies a semantics-preserving repair for Opus 5.5. If the adapter-generated live probe agrees, change only Opus 5.5 forced choices to auto and explicitly document that required/named callers lose the guarantee; preserve the choice where upstream accepts it. The guide says the same restriction applies to Fable 5.1, but that model needs separate live or test evidence before expansion. diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index a9330a64e94..9f5ebefde09 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -613,6 +613,11 @@ function supportsExplicitThinkingDisable(modelId: string): boolean { return meetsFamilyMinimum(modelId, EXPLICIT_THINKING_DISABLE_FAMILY_MINIMUMS); } +function rejectsForcedToolChoice(modelId: string): boolean { + const parsed = claudeFamilyVersion(modelId); + return parsed?.family === "opus" && parsed.major === 5 && parsed.minor === 5; +} + /** `output_config.effort` accepts low|medium|high|xhigh|max — "minimal" is rejected with a 400. */ function adaptiveEffort(effort: string): string { return effort === "minimal" ? "low" : effort; @@ -1098,6 +1103,20 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // has to be stated before the flag has somewhere to live. body.tool_choice = { type: "auto" }; } + const selectedToolChoice = body.tool_choice as { type?: string; name?: string } | undefined; + if (rejectsForcedToolChoice(parsed.modelId) && (selectedToolChoice?.type === "any" || selectedToolChoice?.type === "tool")) { + // Claude Opus 5.5 rejects forced tool use regardless of whether adaptive thinking is + // explicit. Anthropic's migration guidance recommends auto plus a prompt instruction; + // this keeps the request usable but cannot preserve the caller's forced-tool guarantee. + if (selectedToolChoice.type === "tool" && Array.isArray(body.tools)) { + // A named choice still narrows the candidate set even though the upstream cannot + // enforce the forced call. Do not let the compatibility downgrade widen it to every + // declared tool. + body.tools = body.tools.filter(tool => tool && typeof tool === "object" && "name" in tool + && (tool as { name?: unknown }).name === selectedToolChoice.name); + } + body.tool_choice = { type: "auto" }; + } // disable_parallel_tool_use is nested in tool_choice and caps the model at one // tool call for auto/any/tool. Under type "none" tool use is already off, so the // flag is irrelevant there, and with no tools on the wire no tool_choice exists. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 00e0662f93e..b6ebdcef87e 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -518,6 +518,13 @@ true `parallelToolCalls` is byte-identical to previous behavior. The flag constrains the model's output, not execution ordering. Sequential tool use is enforced by the caller's own loop returning each `tool_result` before issuing the next request; this mapping does not provide that. + +Claude Opus 5.5 is an upstream exception to the forced-choice mapping: Anthropic rejects +`tool_choice: {type:"any"}` and `{type:"tool",name:...}` for that model, with or without +adaptive thinking. The Anthropic adapter sends `{type:"auto"}` for those choices so the +request succeeds, but the caller's forced-tool guarantee cannot be preserved; the prompt +must provide any required tool-use instruction. Other Claude model families retain the +normal forced-choice mapping unless their own upstream contract says otherwise. ## Unmapped modalities are recorded, not dropped The translated Chat route has no video mapping — this adapter does not implement one. diff --git a/tests/adapters/anthropic/anthropic-parallel-tool-disable.test.ts b/tests/adapters/anthropic/anthropic-parallel-tool-disable.test.ts index 71903263e3f..ca18f2a9270 100644 --- a/tests/adapters/anthropic/anthropic-parallel-tool-disable.test.ts +++ b/tests/adapters/anthropic/anthropic-parallel-tool-disable.test.ts @@ -20,19 +20,40 @@ import type { OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../../src/ const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "sk-x", authMode: "apiKey" } as unknown as OcxProviderConfig; const TOOL = { name: "lookup", description: "Look something up", parameters: { type: "object", properties: {} } } as OcxTool; +const OTHER_TOOL = { name: "write", description: "Write something", parameters: { type: "object", properties: {} } } as OcxTool; -async function toolChoiceOf(options: Record, withTools = true): Promise | undefined> { +async function toolChoiceOf( + options: Record, + withTools = true, + modelId = "anthropic/claude-sonnet-4.5", + tools = [TOOL], +): Promise | undefined> { const parsed = { - modelId: "anthropic/claude-sonnet-4.5", + modelId, stream: false, options, - context: { messages: [{ role: "user", content: "hi", timestamp: 0 }], ...(withTools ? { tools: [TOOL] } : {}) }, + context: { messages: [{ role: "user", content: "hi", timestamp: 0 }], ...(withTools ? { tools } : {}) }, } as unknown as OcxParsedRequest; const { body } = await createAnthropicAdapter(provider).buildRequest(parsed); const parsedBody = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as { tool_choice?: Record }; return parsedBody.tool_choice; } +async function wireBodyOf( + options: Record, + modelId: string, + tools: OcxTool[], +): Promise> { + const parsed = { + modelId, + stream: false, + options, + context: { messages: [{ role: "user", content: "hi", timestamp: 0 }], tools }, + } as unknown as OcxParsedRequest; + const { body } = await createAnthropicAdapter(provider).buildRequest(parsed); + return JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as Record; +} + describe("F4 parallel=false maps onto nested disable_parallel_tool_use", () => { test("implicit auto is synthesized so the intent has somewhere to live", async () => { expect(await toolChoiceOf({ parallelToolCalls: false })) @@ -66,6 +87,58 @@ describe("F4 parallel=false maps onto nested disable_parallel_tool_use", () => { }); }); +describe("Claude Opus 5.5 forced tool choice compatibility", () => { + test("required becomes auto because Opus 5.5 rejects forced tool use with adaptive thinking", async () => { + const choice = await toolChoiceOf({ toolChoice: "required", reasoning: "medium" }, true, "anthropic/claude-opus-5-5"); + expect(choice).toEqual({ type: "auto" }); + }); + + test("named becomes auto even when reasoning is omitted", async () => { + const choice = await toolChoiceOf({ toolChoice: { name: "lookup" } }, true, "anthropic/claude-opus-5-5"); + expect(choice).toEqual({ type: "auto" }); + }); + + test("named downgrade keeps the selected tool as the only candidate", async () => { + const body = await wireBodyOf({ toolChoice: { name: "lookup" } }, "anthropic/claude-opus-5-5", [TOOL, OTHER_TOOL]); + expect(body.tool_choice).toEqual({ type: "auto" }); + expect((body.tools as Array<{ name: string }>).map(tool => tool.name)).toEqual(["lookup"]); + }); + + test("allowed required keeps its allowlist when it becomes auto", async () => { + const body = await wireBodyOf( + { toolChoice: { allowedTools: ["lookup"], mode: "required" } }, + "anthropic/claude-opus-5-5", + [TOOL, OTHER_TOOL], + ); + expect(body.tool_choice).toEqual({ type: "auto" }); + expect((body.tools as Array<{ name: string }>).map(tool => tool.name)).toEqual(["lookup"]); + }); + + test("dotted routed ids use the same Opus 5.5 compatibility rule", async () => { + const choice = await toolChoiceOf({ toolChoice: "required" }, true, "anthropic/claude-opus-5.5"); + expect(choice).toEqual({ type: "auto" }); + }); + + test("Opus 5.5 auto and none choices remain unchanged", async () => { + expect(await toolChoiceOf({ toolChoice: "auto" }, true, "anthropic/claude-opus-5-5")).toEqual({ type: "auto" }); + expect(await toolChoiceOf({ toolChoice: "none" }, true, "anthropic/claude-opus-5-5")).toEqual({ type: "none" }); + }); + + test("the parallel-call limit stays attached after the compatibility downgrade", async () => { + const choice = await toolChoiceOf( + { toolChoice: "required", reasoning: "medium", parallelToolCalls: false }, + true, + "anthropic/claude-opus-5-5", + ); + expect(choice).toEqual({ type: "auto", disable_parallel_tool_use: true }); + }); + + test("the Opus 5 forced choice contract remains unchanged", async () => { + const choice = await toolChoiceOf({ toolChoice: "required", reasoning: "medium" }, true, "anthropic/claude-opus-5"); + expect(choice).toEqual({ type: "any" }); + }); +}); + describe("F4 cases that must not change", () => { test("none stays bare — tool use is already off, so the flag is irrelevant", async () => { expect(await toolChoiceOf({ toolChoice: "none", parallelToolCalls: false })).toEqual({ type: "none" }); From 3a301dc9508d5a5ad01ec5ada1dcb715b144756f Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 13:00:21 +0900 Subject: [PATCH 19/48] fix(usage): price Cursor Claude Fast variants (#5730) * fix(usage): price Cursor Claude Fast variants * test(usage): pin Cursor Fast provenance * test(usage): reject unsupported Cursor Fast ladders --- .../260924_cursor_fast_pricing/000_plan.md | 30 ++++++ .../010_implementation.md | 8 ++ .../020_verification.md | 8 ++ scripts/test-layout/layout.json | 1 + src/usage/cost.ts | 35 ++++++- src/usage/expected-prices.ts | 57 ++++++++++++ structure/dashboard-and-usage.md | 5 + tests/fixtures/test-layout-expected.json | 1 + tests/usage/usage-cost.test.ts | 24 +++-- tests/usage/usage-cursor-fast-pricing.test.ts | 91 +++++++++++++++++++ 10 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 devlog/_plan/260924_cursor_fast_pricing/000_plan.md create mode 100644 devlog/_plan/260924_cursor_fast_pricing/010_implementation.md create mode 100644 devlog/_plan/260924_cursor_fast_pricing/020_verification.md create mode 100644 tests/usage/usage-cursor-fast-pricing.test.ts diff --git a/devlog/_plan/260924_cursor_fast_pricing/000_plan.md b/devlog/_plan/260924_cursor_fast_pricing/000_plan.md new file mode 100644 index 00000000000..f72d87d59db --- /dev/null +++ b/devlog/_plan/260924_cursor_fast_pricing/000_plan.md @@ -0,0 +1,30 @@ +# Cursor Fast pricing correction + +## Objective + +Price Cursor Claude Opus Fast usage at Cursor's published Fast rates for Opus 4.8, 5 and 5.5. + +## Scope + +- Modify `src/usage/expected-prices.ts` to recognize explicit Cursor Claude `-fast` IDs and register Cursor Fast multipliers for base model selections. +- Modify `src/usage/cost.ts` to apply the explicit-ID rate after user overlays have won and before the final expected-price result is returned. +- Add focused usage tests covering explicit fast IDs, persisted `tierOutcome` from `computeEntryCost`, standard turns, and user overlay precedence. +- Do not modify request logging: `src/adapters/cursor.ts:138-148` creates a `cursor-variant` tier outcome, `src/server/request-log.ts:767-790` stores it, and `src/usage/summary.ts:270-292` consumes it. + +## Rates + +Cursor's official model pages ([Opus 4.8](https://cursor.com/docs/models/claude-opus-4-8), +[Opus 5](https://cursor.com/docs/models/claude-opus-5), [Opus 5.5](https://cursor.com/docs/models/claude-opus-5-5)) publish: + +- Opus 4.8: standard 5/25/0.5/6.25; Fast 10/50/1/12.5. +- Opus 5: standard 5/25/0.5/6.25; Fast 10/50/1/12.5. +- Opus 5.5: standard 4/20/0.2/5; Fast 8/40/0.4/10. + +Opus 4.7 Fast is excluded because Anthropic says Fast requests error for that model. + +## Acceptance + +- Explicit Cursor Claude `-fast` IDs resolve to an official Fast tuple through the cost resolver. +- A persisted Cursor `tierOutcome` with `canonical:"priority", wireKind:"cursor-variant", wireValue:"fast", fastOutcome:"applied"` doubles the base rate for the three supported models. +- Standard and user configured prices remain correct. +- Focused usage tests and typecheck pass. diff --git a/devlog/_plan/260924_cursor_fast_pricing/010_implementation.md b/devlog/_plan/260924_cursor_fast_pricing/010_implementation.md new file mode 100644 index 00000000000..a2d5ed9f169 --- /dev/null +++ b/devlog/_plan/260924_cursor_fast_pricing/010_implementation.md @@ -0,0 +1,8 @@ +# Implementation + +1. Add a Cursor Claude Fast multiplier helper in `src/usage/expected-prices.ts` using the existing `normalizeCursorClaudeId` parser. It returns 2 only for fast Opus 4.8, 5 and 5.5 IDs. +2. Add Cursor priority pricing rules for the canonical base IDs. These rules do not require a response echo because the Cursor adapter's variant serialization is the observed wire evidence. +3. In `resolveMatchedPriceExact`, return user overlays first as today. For compiled Cursor expected or model-level prices, multiply only explicit fast IDs. Preserve `source`, `sourceRef`, and user overlay behavior. + Unsupported Fast spellings are fail-closed; supported explicit Fast rows carry Cursor's + direct source URL and `verified` provenance. +4. Add a new usage test file in the usage domain and register it in both layout registries. diff --git a/devlog/_plan/260924_cursor_fast_pricing/020_verification.md b/devlog/_plan/260924_cursor_fast_pricing/020_verification.md new file mode 100644 index 00000000000..2901bb16da0 --- /dev/null +++ b/devlog/_plan/260924_cursor_fast_pricing/020_verification.md @@ -0,0 +1,8 @@ +# Verification + +- Run the new Cursor Fast usage test and the existing usage cost test. +- Run `bun run typecheck`. +- Run `bun run structure:check` after staging the plan files. +- Run `bun run privacy:scan` because usage and pricing metadata are changed. +- Run a gpt-6-sol read-only adversarial review of the final diff. +- Push `codex/260924-cursor-fast-pricing` and open one PR to `dev`; the coordinator merges it. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 4cddb97828f..34ca5d0a67e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1650,6 +1650,7 @@ "url-normalization.test.ts": "config", "usage-aggregate-cache.test.ts": "usage", "usage-anthropic-fast-pricing.test.ts": "usage", + "usage-cursor-fast-pricing.test.ts": "usage", "usage-attempt-delivery.test.ts": "usage", "usage-cost.test.ts": "usage", "usage-debug.test.ts": "usage", diff --git a/src/usage/cost.ts b/src/usage/cost.ts index deb7f6f20b2..790ba444303 100644 --- a/src/usage/cost.ts +++ b/src/usage/cost.ts @@ -25,6 +25,9 @@ import { findExpectedPriceOverlay, findVerifiedPriceOverride, findPriorityPricingRule, + cursorFastPriceMultiplier, + cursorFastPriceSource, + cursorFastPriceSupported, findContextTier, isLongContext, type Cost4, @@ -257,6 +260,7 @@ function resolveMatchedPriceExact( // operator's explicit price is authoritative for the ~$ estimate. const userOverlay = userOverlayMatch(provider, modelId, userOverlays); if (userOverlay) return userOverlay; + if (!cursorFastPriceSupported(provider, modelId)) return null; const verifiedOverride = overlays === EXPECTED_PRICE_OVERLAYS ? findVerifiedPriceOverride(provider, modelId) : undefined; @@ -264,11 +268,12 @@ function resolveMatchedPriceExact( return { provider, modelId, - cost4: verifiedOverride.cost4, + cost4: multiplyCursorFastCost(verifiedOverride.cost4, provider, modelId), source: "expected", sourceRef: verifiedOverride.source, verifiedAt: verifiedOverride.verifiedAt, status: verifiedOverride.status, + ...cursorFastPriceProvenance(provider, modelId), }; } const metadataProvider = resolveMetadataProvider(provider); @@ -280,28 +285,50 @@ function resolveMatchedPriceExact( provider, modelId, jawcodeProvider: metadataProvider, - cost4: bundled.cost, + cost4: multiplyCursorFastCost(bundled.cost, provider, modelId), source: "jawcode", status: "verified", + ...cursorFastPriceProvenance(provider, modelId), }; } const overlay = findExpectedPriceOverlay(provider, modelId, overlays); if (!overlay || !validCost4(overlay.cost4) || !hasNonZeroCost(overlay.cost4)) { - return options.allowModelLevelFallback === false ? null : resolveModelLevelPrice(provider, modelId); + if (options.allowModelLevelFallback === false) return null; + const fallback = resolveModelLevelPrice(provider, modelId); + return fallback + ? { ...fallback, cost4: multiplyCursorFastCost(fallback.cost4, provider, modelId), ...cursorFastPriceProvenance(provider, modelId) } + : null; } if (overlay.status === "unverified") return null; return { provider, modelId, ...(metadataProvider ? { jawcodeProvider: metadataProvider } : {}), - cost4: overlay.cost4, + cost4: multiplyCursorFastCost(overlay.cost4, provider, modelId), source: "expected", sourceRef: overlay.source, verifiedAt: overlay.verifiedAt, status: overlay.status, + ...cursorFastPriceProvenance(provider, modelId), }; } +function multiplyCursorFastCost(cost4: Cost4, provider: string, modelId: string): Cost4 { + const multiplier = cursorFastPriceMultiplier(provider, modelId); + if (multiplier === 1) return cost4; + return { + input: cost4.input * multiplier, + output: cost4.output * multiplier, + cacheRead: cost4.cacheRead * multiplier, + cacheWrite: cost4.cacheWrite * multiplier, + }; +} + +function cursorFastPriceProvenance(provider: string, modelId: string): Pick | Record { + const sourceRef = cursorFastPriceSource(provider, modelId); + return sourceRef ? { sourceRef, status: "verified" } : {}; +} + /** User-configured overlay match; explicit zero rates are authoritative too. */ function userOverlayMatch( provider: string, diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index c27d5c485d3..07a30475a1c 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -111,6 +111,7 @@ const CLAUDE_FABLE_51: Cost4 = { input: 10, output: 50, cacheRead: 0.25, cacheWr // Opus 5 was first priced from the maintainer's confirmation that it matched Opus 4.6. The // pricing page now lists it at that same 5 / 25 / 0.50 / 6.25 tuple (re-verified 2026-09-23). const CLAUDE_OPUS_5 = CLAUDE_OPUS_46; +const CURSOR_OPUS_48 = CLAUDE_OPUS_46; // Claude Opus 5.5 (claude-opus-5-5, released 2026-09-22): 4 / 20, 5m cache write 5.00. Cache // hits are 0.05x base input (0.20), a model-specific footnote on the pricing page, NOT the // 0.1x most families use. 1M context and 128K output at one flat rate (no long-context tier). @@ -119,6 +120,50 @@ const ANTHROPIC_PRICING = "https://platform.claude.com/docs/en/about-claude/pric const CLAUDE_OPUS_5_SOURCE = `anthropic official Claude Opus 5 ${ANTHROPIC_PRICING}`; const CLAUDE_OPUS_55_SOURCE = `anthropic official Claude Opus 5.5 ${ANTHROPIC_PRICING}; cache hit = 0.05x base input`; const CURSOR_OPUS_55_PRICING = "https://cursor.com/docs/models/claude-opus-5-5 (Cursor Other Models pool; same list rate as Anthropic, Fast Mode billed separately)"; +const CURSOR_OPUS_48_FAST_PRICING = "https://cursor.com/docs/models/claude-opus-4-8"; +const CURSOR_OPUS_5_FAST_PRICING = "https://cursor.com/docs/models/claude-opus-5"; +const CURSOR_OPUS_55_FAST_PRICING = "https://cursor.com/docs/models/claude-opus-5-5"; + +const CURSOR_FAST_PRICE_MODELS = new Set(["claude-opus-4-8", "claude-opus-5", "claude-opus-5-5"]); +const CURSOR_FAST_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); +const CURSOR_FAST_PRICE_SOURCES: Readonly> = { + "claude-opus-4-8": CURSOR_OPUS_48_FAST_PRICING, + "claude-opus-5": CURSOR_OPUS_5_FAST_PRICING, + "claude-opus-5-5": CURSOR_OPUS_55_FAST_PRICING, +}; + +function supportsCursorFastId(parsed: ReturnType): boolean { + if (!parsed?.fast || !CURSOR_FAST_PRICE_MODELS.has(parsed.canonicalBaseId)) return false; + if (parsed.canonicalBaseId === "claude-opus-5-5" && parsed.thinking) return false; + if (parsed.level !== undefined && !CURSOR_FAST_LEVELS.has(parsed.level)) { + return false; + } + if (parsed.canonicalBaseId === "claude-opus-5" && !parsed.thinking + && parsed.level !== undefined && !["low", "medium", "high"].includes(parsed.level)) { + return false; + } + return true; +} + +/** Cursor's published Fast rows are exactly 2x the standard rows for these Opus models. */ +export function cursorFastPriceMultiplier(provider: string, modelId: string): number { + if (provider !== "cursor") return 1; + const parsed = normalizeCursorClaudeId(modelId); + return supportsCursorFastId(parsed) ? 2 : 1; +} + +/** Whether a parsed Cursor Fast id belongs to a published, supported Fast ladder. */ +export function cursorFastPriceSupported(provider: string, modelId: string): boolean { + if (provider !== "cursor") return true; + const parsed = normalizeCursorClaudeId(modelId); + return !parsed?.fast || supportsCursorFastId(parsed); +} + +export function cursorFastPriceSource(provider: string, modelId: string): string | undefined { + if (provider !== "cursor") return undefined; + const parsed = normalizeCursorClaudeId(modelId); + return supportsCursorFastId(parsed) ? CURSOR_FAST_PRICE_SOURCES[parsed!.canonicalBaseId] : undefined; +} const GEMINI_PRICING = "https://ai.google.dev/gemini-api/docs/pricing (2026-07-22); cacheWrite=0: storage is billed per-hour, not per-token"; const GEMINI_37_PRICING = "https://ai.google.dev/gemini-api/docs/pricing (2026-08-14); promotional rate through 2026-12-31, rises to 1.50/7.50 on 2027-01-01; cacheWrite=0: storage is billed per-hour, not per-token"; @@ -224,6 +269,7 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ // anthropic row would not cover cursor/kiro — each exposing provider needs its own. { provider: "anthropic", modelId: "claude-opus-5", cost4: CLAUDE_OPUS_5, source: CLAUDE_OPUS_5_SOURCE, verifiedAt: "2026-09-23", status: "verified" }, { provider: "cursor", modelId: "claude-opus-5", cost4: CLAUDE_OPUS_5, source: `${CLAUDE_OPUS_5_SOURCE}; vendor list price applied to the Cursor surface`, verifiedAt: "2026-09-23", status: "verified-derived" }, + { provider: "cursor", modelId: "claude-opus-4-8", cost4: CURSOR_OPUS_48, source: "https://cursor.com/docs/models/claude-opus-4-8", verifiedAt: "2026-09-24", status: "verified" }, { provider: "kiro", modelId: "claude-opus-5", cost4: CLAUDE_OPUS_5, source: `${CLAUDE_OPUS_5_SOURCE}; vendor list price applied to the Kiro credit surface`, verifiedAt: "2026-09-23", status: "verified-derived" }, // Claude Opus 5.5. The anthropic bundle row wins for the bare provider id; these overlays // cover account-label namespaces. Cursor publishes the same list rate on its own model page. @@ -566,6 +612,17 @@ export const PRIORITY_PRICING_RULES: readonly PriorityPricingRule[] = [ verifiedAt: "2026-09-23", })), ), + ...[ + ["claude-opus-4-8", CURSOR_OPUS_48_FAST_PRICING], + ["claude-opus-5", CURSOR_OPUS_5_FAST_PRICING], + ["claude-opus-5-5", CURSOR_OPUS_55_FAST_PRICING], + ].map(([modelId, source]): PriorityPricingRule => ({ + provider: "cursor", + modelId, + multiplier: 2, + source, + verifiedAt: "2026-09-24", + })), ]; /** Exact provider/model priority-pricing lookup. */ diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 2569a1e40fd..5a6024c90fe 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -529,3 +529,8 @@ Anthropic Fast pricing applies a 2x list-price multiplier only when the response `tests/usage/usage-anthropic-fast-pricing.test.ts` pins that distinction. The request-metrics recovery label `anthropic-fast-downgrade` projects to `fast_downgrade`, separate from reasoning-effort `effort_downgrade`. + +Cursor Claude Fast pricing applies the published Fast tuples to Opus 4.8, Opus 5 and Opus 5.5. +Explicit `-fast` model IDs use the Fast tuple directly; a Cursor variant tier outcome applies +the same 2x multiplier to a base model estimate. Opus 4.7 remains standard-priced because its +upstream Fast mode is unavailable. Configured model prices retain precedence over compiled rows. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b281e5d60c9..82739a30cf8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1482,6 +1482,7 @@ "url-normalization.test.ts": "config", "usage-aggregate-cache.test.ts": "usage", "usage-anthropic-fast-pricing.test.ts": "usage", + "usage-cursor-fast-pricing.test.ts": "usage", "usage-attempt-delivery.test.ts": "usage", "usage-cost.test.ts": "usage", "usage-debug.test.ts": "usage", diff --git a/tests/usage/usage-cost.test.ts b/tests/usage/usage-cost.test.ts index 573954a9331..553361b37e8 100644 --- a/tests/usage/usage-cost.test.ts +++ b/tests/usage/usage-cost.test.ts @@ -192,14 +192,18 @@ describe("resolveMatchedPrice", () => { jawcodeProvider: "anthropic", status: "verified-derived", }); - // Cursor publishes the same list rate; every variant spelling collapses onto one row. - for (const spelling of ["claude-opus-5-5", "claude-opus-5-5-thinking-high", "claude-opus-5-5-thinking-high-fast"]) { - expect(resolveMatchedPrice("cursor", spelling), spelling).toMatchObject({ - cost4: COST4, - source: "expected", - status: "verified", - }); - } + // Cursor publishes the standard row and a separate Fast row; the old thinking IDs were + // removed when the live Cursor roster proved Opus 5.5 uses flat effort IDs. + expect(resolveMatchedPrice("cursor", "claude-opus-5-5")).toMatchObject({ + cost4: COST4, + source: "expected", + status: "verified", + }); + expect(resolveMatchedPrice("cursor", "claude-opus-5-5-high-fast")).toMatchObject({ + cost4: { input: 8, output: 40, cacheRead: 0.4, cacheWrite: 10 }, + source: "expected", + status: "verified", + }); for (const provider of ["devin", "devin-cli"]) { expect(resolveMatchedPrice(provider, "claude-opus-5-5"), provider).toMatchObject({ cost4: COST4, @@ -414,8 +418,8 @@ describe("resolveMatchedPrice", () => { } }); - test("16. shipped overlay membership: 142 keys, including canonical Fable 5.1, Opus 5, Opus 5.5, OpenCode Go and compatibility prices", () => { - expect(EXPECTED_PRICE_OVERLAYS.length).toBe(142); + test("16. shipped overlay membership: 143 keys, including canonical Fable 5.1, Opus 5, Opus 5.5, OpenCode Go and compatibility prices", () => { + expect(EXPECTED_PRICE_OVERLAYS.length).toBe(143); expect(EXPECTED_PRICE_OVERLAYS.some(row => row.status === "unverified")).toBe(false); const keys = new Set(EXPECTED_PRICE_OVERLAYS.map(row => `${row.provider}/${row.modelId}`)); for (const expected of [ diff --git a/tests/usage/usage-cursor-fast-pricing.test.ts b/tests/usage/usage-cursor-fast-pricing.test.ts new file mode 100644 index 00000000000..d8a8efe873b --- /dev/null +++ b/tests/usage/usage-cursor-fast-pricing.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import type { AttemptTierOutcome } from "../../src/types"; +import type { PersistedUsageEntry } from "../../src/usage/log"; +import { computeEntryCost } from "../../src/usage/summary"; +import { estimateRequestCost, resolveMatchedPrice } from "../../src/usage/cost"; + +const usage = { inputTokens: 100_000, outputTokens: 10_000, cacheReadInputTokens: 20_000, cacheCreationInputTokens: 10_000 }; +const USER_ROWS = [{ + provider: "cursor", + modelId: "claude-opus-5-5", + cost4: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 }, + source: "config:providers.cursor.modelCosts[claude-opus-5-5]", + verifiedAt: "user-configured", + status: "verified", +}] as const; + +const cursorFastOutcome: AttemptTierOutcome = { + canonical: "priority", + wireKind: "cursor-variant", + wireValue: "fast", + fastOutcome: "applied", + confirmation: "assumed", +}; + +function entry(model: string, tierOutcome?: AttemptTierOutcome): PersistedUsageEntry { + return { + requestId: `cursor-fast-${model}`, + timestamp: 1, + provider: "cursor", + model, + status: 200, + durationMs: 1, + usageStatus: "reported", + usage, + ...(tierOutcome ? { tierOutcome } : {}), + }; +} + +describe("Cursor Fast pricing", () => { + test("explicit Fast model ids use Cursor's published Fast tuples", () => { + expect(resolveMatchedPrice("cursor", "claude-opus-4-8-high-fast")).toMatchObject({ + cost4: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, + sourceRef: "https://cursor.com/docs/models/claude-opus-4-8", + status: "verified", + }); + expect(resolveMatchedPrice("cursor", "claude-opus-5-high-fast")?.cost4) + .toEqual({ input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }); + expect(resolveMatchedPrice("cursor", "claude-opus-5-5-high-fast")?.cost4) + .toEqual({ input: 8, output: 40, cacheRead: 0.4, cacheWrite: 10 }); + expect(resolveMatchedPrice("cursor", "claude-opus-5-high-fast")).toMatchObject({ + sourceRef: "https://cursor.com/docs/models/claude-opus-5", + status: "verified", + }); + expect(resolveMatchedPrice("cursor", "claude-opus-5-minimal-fast")).toBeNull(); + expect(resolveMatchedPrice("cursor", "claude-opus-4-8-minimal-fast")).toBeNull(); + expect(resolveMatchedPrice("cursor", "claude-opus-4-8-none-fast")).toBeNull(); + expect(resolveMatchedPrice("cursor", "claude-opus-5-thinking-minimal-fast")).toBeNull(); + expect(resolveMatchedPrice("cursor", "claude-opus-5-thinking-none-fast")).toBeNull(); + expect(resolveMatchedPrice("cursor", "claude-opus-5-5-minimal-fast")).toBeNull(); + expect(resolveMatchedPrice("cursor", "claude-opus-5-5-none-fast")).toBeNull(); + expect(resolveMatchedPrice("cursor", "claude-opus-5-5-thinking-high-fast")).toBeNull(); + }); + + test("standard Cursor rows remain at their base rates", () => { + expect(resolveMatchedPrice("cursor", "claude-opus-5-5")?.cost4) + .toEqual({ input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 }); + expect(resolveMatchedPrice("cursor", "claude-opus-5")?.cost4) + .toEqual({ input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }); + }); + + test("persisted Cursor Fast tier outcome doubles the request estimate", () => { + const standard = computeEntryCost(entry("claude-opus-5-5")); + const fast = computeEntryCost(entry("claude-opus-5-5", cursorFastOutcome)); + expect(standard.estimate?.cost.total).toBeCloseTo(0.534, 10); + expect(fast.estimate?.cost.total).toBeCloseTo(1.068, 10); + expect(fast.estimate?.priorityMultiplier).toBe(2); + }); + + test("configured Cursor model prices remain authoritative for explicit Fast ids", () => { + const price = resolveMatchedPrice("cursor", "claude-opus-5-5-high-fast", undefined, USER_ROWS); + expect(price).toMatchObject({ source: "user", cost4: USER_ROWS[0].cost4 }); + const estimate = estimateRequestCost({ + provider: "cursor", + model: "claude-opus-5-5-high-fast", + usage, + usageStatus: "reported", + }, undefined, USER_ROWS); + expect(estimate?.cost.total).toBeCloseTo(0.094, 10); + expect(estimate?.price?.source).toBe("user"); + }); +}); From 66d4cf91338181e49074fafc92deaf85eb0067f3 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 13:31:43 +0900 Subject: [PATCH 20/48] feat(claude): gateway by default, first-party risk warning, and Desktop picker mode (#5728) * docs(devlog): plan Claude Desktop gateway default, first-party risk warning and picker mode * docs(devlog): close the picker-mode roadmap cycle * docs(devlog): fold the wp2 audit into the gateway-default plan * feat(claude-desktop): default Desktop to gateway and flag first-party account risk Gateway (3P) is now the default Desktop mode. First-party stays available by explicit choice, and pre-field installs keep what they run: a selected owned gateway row or apply marker keeps gateway, owned first-party env keeps first-party. Foreign proxy env is never read as first-party. Every first-party surface (CLI apply and help, POST /api/claude-desktop/apply, status, native toggle) carries one account-suspension risk notice from src/claude/desktop-risk.ts. The /api/sync writer and the roster auto-apply never write a gateway profile behind a first-party Desktop, rechecked after discovery. * feat(gui): mark gateway as the Desktop default and show the first-party account risk The mode picker badges gateway as default, falls back to gateway before /status answers, and shows the account-risk note whenever first-party is selected or its env is still applied. Copy is in all ten locales. * docs(claude-desktop): document the gateway default and the first-party account risk The Claude Code guide in all eight locales and the structure contracts now describe gateway as the default, first-party as an explicit opt-in with an account-risk caution, and the guards that keep sync and roster updates from overwriting a first-party Desktop. * docs(devlog): fold the wp3 audit into the picker core plan * docs(devlog): fold the wp3 round-2 audit * feat(claude-intercept): add the name-constrained picker CA and keychain trust checks The picker CA lives under /claude-picker/ with a 0600 key and a critical nameConstraints extension that permits only claude.ai; a persisted CA without it is regenerated on reload. Trust is inspected, added and removed through an injectable macOS security runner: trusted needs the current CA's SHA-1 in the login keychain and a passing verify-cert for the claude.ai leaf. The intercept CA is unchanged. * feat(claude-intercept): relay claude.ai and add opencodex routes to the Code picker bootstrap A loopback node:https HTTP/1.1 terminator relays claude.ai with upstream certificate verification, raw headers, streamed bodies and WebSocket upgrades. Only the bootstrap response is rewritten: its code surface gains one cloned entry per route, under encoded and decoded caps, and any failure or overflow passes the original bytes through unchanged. * feat(claude-intercept): picker model snapshot, per-connection tunnel choice and the picker preference Picker entries mirror the gateway's rendered Desktop profile with opencodex aliases, served from a snapshot persisted to claude-picker/models.json. The CONNECT proxy takes an optional per-connection selectTunnel; async decisions hold the client until they settle and never dial for a client that left. claudeCode.intercept.picker is the new preference (absent = on). * feat(claude-intercept): picker runtime on a dedicated Desktop egress proxy The picker runtime caches when claude.ai may be terminated (macOS, first-party, Desktop intent, preference, no disarm latch, listener up, CA trusted) and serves it to a second CONNECT proxy on the intercept port + 1, used only as Desktop's egress proxy. The Claude Code proxy keeps its behaviour and never consults the picker. Start failures release every bound socket. * docs(devlog): record the dedicated Desktop egress proxy amendment * docs(structure): move the picker contract into the Claude Desktop doc runtime.md sits at its 600-line budget; it now links to the picker section in clients/claude-desktop.md. * docs(devlog): fold the wp4 pre-audit into the picker activation plan * docs(devlog): fold the wp4 round-2 audit * feat(claude-desktop): own a picker egress profile in Desktop's config library The opencodex-picker row is an owned standard row whose profile holds only egressProxyUrl, pointed at the dedicated picker CONNECT proxy. The previous selection lives in claude-picker/profile-state.json, never in Desktop's _meta.json; removal reselects it (or an owned standard row), and gateway writes and cleanup leave the row alone. * feat(claude-desktop): one picker controller serializes enable, disable and mode transitions Enable rechecks the persisted first-party mode, Desktop intent, preference and bound picker proxy before and after the keychain step, applies the egress profile and rearms the runtime; any refusal after trust it (or a CLI caller) added removes that trust. Disable disarms first, then removes the profile and the trust. With no controller, transitions run with offline ops that only clean up leftover artifacts. * feat(server): turn picker mode on with first-party and off with gateway GET/PUT /api/claude-desktop/picker drive the controller; status reports firstParty.picker. First-party apply and the native enable run their switch under the picker lock in today's order and then enable the picker unless claudeCode.intercept.picker is false; gateway apply, native disable, /api/sync and the roster auto-apply run under the same lock, and the gateway paths disable the picker first. The runtime creates the controller next to the picker runtime. * feat(cli): ocx claude desktop picker on|off|status|trust The CLI drives picker mode through the running server; trust runs the macOS keychain step in the terminal and lets the server compensate a refused enable. First-party apply delegates to the server on the local hub path. Durable-OFF reconciliation is async and turns the picker off. * feat(gui): picker mode card on the Claude Desktop page In first-party mode the page shows a picker card with a toggle, the state and next step from the controller's reason, the model count and the offline note, in all ten locales. * docs(claude-desktop): document picker mode The Claude Code guide in eight locales and the structure contracts describe picker mode: on by default in first-party on macOS, the one-time keychain step for a CA limited to claude.ai, the offline dependency, the commands and the dashboard card. * docs(devlog): point the wp5 egress check at the picker proxy port * fix(claude-desktop): keep picker trust while its profile is selected, report refusals, lock profile writes Security review follow-ups. Disabling picker mode removes the CA's trust only after Desktop no longer selects the picker profile, so a failed metadata write never leaves Desktop pinned to a proxy whose certificate it rejects; untrust is a no-op when the current CA is not in the login keychain. PUT /api/claude-desktop/picker answers refusals with 409 and incomplete cleanup with 500, and the CLI exits non-zero for them. Picker profile apply and removal run under the client lifecycle and config mutation locks, like the gateway writer. * feat(claude-intercept): log the picker bootstrap rewrite outcome The service log now records, per bootstrap, whether the Code picker surface was rewritten and how many entries were added, or which piece was missing (selector config, code surface with its surface ids, template, routes, cap or decode). Only metadata is logged, never values. * fix(claude-intercept): add picker routes to the Desktop Code tab's ccd surface The Desktop Code tab reads the ccd surface of the bootstrap's model_selector_config and falls back to code only when ccd has no catalog, so routes added to code alone never showed. Routes now go into ccd and code; the remote ccr surface stays untouched because a remote session never reaches this machine's proxy. Found in the live Desktop proof. * fix(claude-intercept): route Desktop's spawned Claude Code through the intercept on the egress proxy Desktop hands its pinned egress proxy to the Claude Code processes it spawns, so a Code-tab turn with an opencodex route reached Anthropic unintercepted and failed as an unknown model. The egress proxy now chooses per client from the CONNECT head: without a browser User-Agent (Claude Code, trusting only the intercept CA) api.anthropic.com gets the intercept and claude.ai stays blind; with Chromium's User-Agent (the app, trusting the login keychain) the picker decides as before. Found in the live Desktop proof. * fix(claude-desktop): ask for a Desktop restart only after the picker profile changed Desktop reads its egress profile only at launch, so picker status reports restart_required only until Desktop has fetched a bootstrap after this process changed the profile. A plain opencodex restart changes nothing Desktop reads and now reports active. * fix(claude-intercept): cap single-read CONNECT heads and keep body values out of picker logs Review follow-ups. A CONNECT head that arrives in one oversized read is refused like one spread across reads. The bootstrap outcome log names only known surface ids and counts the rest. The User-Agent tunnel choice is documented and tested as a routing hint: a faked or missing one reaches only what any local process already reaches and breaks only that client's TLS. * test(claude-intercept): use a neutral marker for the surface-log test * test(claude-desktop): expect the macOS-only reason in offline picker status off macOS --- .../000_plan.md | 170 ++++++++ .../001_research.md | 83 ++++ .../010_wp2_gateway_default_and_warning.md | 285 ++++++++++++++ .../020_wp3_picker_core.md | 341 ++++++++++++++++ .../030_wp4_picker_activation.md | 194 ++++++++++ .../040_wp5_live_proof_pr_merge.md | 61 +++ .../src/content/docs/fr/guides/claude-code.md | 85 ++-- .../src/content/docs/guides/claude-code.md | 54 ++- .../src/content/docs/ja/guides/claude-code.md | 73 ++-- .../src/content/docs/ko/guides/claude-code.md | 70 +++- .../src/content/docs/ru/guides/claude-code.md | 52 +++ .../src/content/docs/tr/guides/claude-code.md | 58 ++- .../content/docs/zh-cn/guides/claude-code.md | 47 +++ .../content/docs/zh-tw/guides/claude-code.md | 52 ++- gui/src/components/ClaudeDesktopPicker.tsx | 157 ++++++++ gui/src/i18n/de.ts | 14 + gui/src/i18n/en.ts | 14 + gui/src/i18n/fr.ts | 14 + gui/src/i18n/ja.ts | 14 + gui/src/i18n/ko.ts | 14 + gui/src/i18n/ru.ts | 14 + gui/src/i18n/tr.ts | 14 + gui/src/i18n/vi.ts | 14 + gui/src/i18n/zh-TW.ts | 14 + gui/src/i18n/zh.ts | 14 + gui/src/main.tsx | 1 + gui/src/pages/ClaudeDesktop.tsx | 46 ++- gui/src/styles/claude-desktop-mode-picker.css | 7 +- gui/src/styles/claude-desktop-picker.css | 36 ++ gui/tests/claude-desktop-mode-picker.test.tsx | 21 +- gui/tests/claude-desktop-picker.test.tsx | 103 +++++ scripts/test-layout/layout.json | 10 + .../ocx/references/01_management_surface.md | 53 ++- src/claude/desktop-3p-library.ts | 5 +- src/claude/desktop-3p.ts | 2 +- src/claude/desktop-first-party.ts | 85 ++-- src/claude/desktop-picker-profile.ts | 302 +++++++++++++++ src/claude/desktop-picker.ts | 365 ++++++++++++++++++ src/claude/desktop-risk.ts | 20 + src/claude/intercept/connect-proxy.ts | 135 +++++-- src/claude/intercept/local-ca.ts | 64 ++- src/claude/intercept/picker-bootstrap.ts | 141 +++++++ src/claude/intercept/picker-ca.ts | 115 ++++++ src/claude/intercept/picker-listener.ts | 213 ++++++++++ src/claude/intercept/picker-models.ts | 101 +++++ src/claude/intercept/picker-runtime.ts | 330 ++++++++++++++++ src/claude/intercept/picker-trust.ts | 91 +++++ src/claude/intercept/runtime.ts | 151 +++++++- src/cli/capabilities.ts | 45 +++ src/cli/claude-desktop.ts | 323 ++++++++++++++-- src/cli/ensure-desired-integrations.ts | 33 +- src/config/schema/config-schema.ts | 5 +- .../index/claude-intercept-lifecycle.ts | 24 ++ src/server/management-api.ts | 2 + .../management/agent-settings-routes.ts | 257 ++++++------ .../claude-desktop-picker-routes.ts | 128 ++++++ src/server/management/config-routes.ts | 64 +-- .../management/native-integration-routes.ts | 215 ++++++----- src/server/management/route-registry.ts | 3 + src/types/config.ts | 18 +- structure/clients/claude-desktop.md | 90 ++++- structure/gui-and-management-api.md | 17 +- structure/runtime.md | 2 +- .../claude-desktop-cli.test.ts | 146 +++++++ .../claude-desktop-first-party-guards.test.ts | 209 ++++++++++ .../claude-desktop-first-party.test.ts | 110 ++++-- .../claude-desktop-mode-explanation.test.ts | 25 +- .../claude-desktop-picker-profile.test.ts | 173 +++++++++ .../claude-desktop-picker-routes.test.ts | 247 ++++++++++++ .../claude-desktop-picker.test.ts | 291 ++++++++++++++ .../claude-intercept-proxy.test.ts | 95 ++++- .../claude-picker-bootstrap.test.ts | 141 +++++++ .../claude-picker-ca.test.ts | 137 +++++++ .../claude-picker-listener.test.ts | 212 ++++++++++ .../claude-picker-models.test.ts | 63 +++ .../claude-picker-runtime.test.ts | 356 +++++++++++++++++ .../claude-picker-trust.test.ts | 78 ++++ tests/fixtures/test-layout-expected.json | 10 + tests/providers/xai/grok-lifecycle.test.ts | 6 +- 79 files changed, 7365 insertions(+), 479 deletions(-) create mode 100644 devlog/_plan/260924_claude_desktop_picker_mode/000_plan.md create mode 100644 devlog/_plan/260924_claude_desktop_picker_mode/001_research.md create mode 100644 devlog/_plan/260924_claude_desktop_picker_mode/010_wp2_gateway_default_and_warning.md create mode 100644 devlog/_plan/260924_claude_desktop_picker_mode/020_wp3_picker_core.md create mode 100644 devlog/_plan/260924_claude_desktop_picker_mode/030_wp4_picker_activation.md create mode 100644 devlog/_plan/260924_claude_desktop_picker_mode/040_wp5_live_proof_pr_merge.md create mode 100644 gui/src/components/ClaudeDesktopPicker.tsx create mode 100644 gui/src/styles/claude-desktop-picker.css create mode 100644 gui/tests/claude-desktop-picker.test.tsx create mode 100644 src/claude/desktop-picker-profile.ts create mode 100644 src/claude/desktop-picker.ts create mode 100644 src/claude/desktop-risk.ts create mode 100644 src/claude/intercept/picker-bootstrap.ts create mode 100644 src/claude/intercept/picker-ca.ts create mode 100644 src/claude/intercept/picker-listener.ts create mode 100644 src/claude/intercept/picker-models.ts create mode 100644 src/claude/intercept/picker-runtime.ts create mode 100644 src/claude/intercept/picker-trust.ts create mode 100644 src/server/management/claude-desktop-picker-routes.ts create mode 100644 tests/claude-integration/claude-desktop-first-party-guards.test.ts create mode 100644 tests/claude-integration/claude-desktop-picker-profile.test.ts create mode 100644 tests/claude-integration/claude-desktop-picker-routes.test.ts create mode 100644 tests/claude-integration/claude-desktop-picker.test.ts create mode 100644 tests/claude-integration/claude-picker-bootstrap.test.ts create mode 100644 tests/claude-integration/claude-picker-ca.test.ts create mode 100644 tests/claude-integration/claude-picker-listener.test.ts create mode 100644 tests/claude-integration/claude-picker-models.test.ts create mode 100644 tests/claude-integration/claude-picker-runtime.test.ts create mode 100644 tests/claude-integration/claude-picker-trust.test.ts diff --git a/devlog/_plan/260924_claude_desktop_picker_mode/000_plan.md b/devlog/_plan/260924_claude_desktop_picker_mode/000_plan.md new file mode 100644 index 00000000000..5fe499d77be --- /dev/null +++ b/devlog/_plan/260924_claude_desktop_picker_mode/000_plan.md @@ -0,0 +1,170 @@ +# Claude Desktop: gateway by default, first-party with a risk warning, and a picker that lists opencodex models + +Claude Desktop reaches opencodex in two ways. Gateway (3P) switches the whole app to the local +gateway and shows every opencodex model by name. First-party (1P) keeps the app on claude.ai and +routes only the Code tab's Claude Code through a local interception proxy; until now its model +picker could only show Anthropic's models, because claude.ai builds that list. This unit makes +gateway the default for new installs, warns that first-party sends a Claude subscription through +a local interception proxy and can get the account suspended, and adds a first-party **picker +mode**, on by default, that shows opencodex routes by their real names in the Code tab picker. +Picker mode points Desktop's own traffic at the opencodex CONNECT proxy through the +`egressProxyUrl` config-library key (supported in 1P since Desktop 1.44121.1), trusts a local CA +whose name constraints permit only `claude.ai` and its subdomains, and adds entries to the Code surface of the claude.ai +bootstrap. Evidence is in [001_research.md](001_research.md). The threat model stays in scratch +space (`.tmp/260924_claude_desktop_picker_mode/threat_model.md`, untracked) per AGENTS.md until the +change ships; its controls are restated in the decade docs. + +## Loop spec + +- Loop archetype: satisfy-spec, five work-phases (docs-first, then four dependency-ordered cycles). +- Trigger: the user asked for gateway as the default, a first-party account-suspension warning, + and first-party picker mode on by default, delivered through a merged PR. +- Goal: a fresh install applies gateway; choosing first-party shows the risk everywhere it can be + chosen or seen; with first-party applied on macOS the Desktop Code tab picker lists opencodex + routes by name and a turn with one of them is served by opencodex. +- Non-goals: Chat tab and other non-CLI surfaces; picker trust on Windows and Linux (reported as + unsupported); OS-wide proxy settings; changing `api.anthropic.com` interception; mentioning or + copying any third-party project. +- Verifier: per-phase focused suites named in each decade doc, `bun run typecheck`, + `bun run structure:check`, `bun run skill:surface:check`, `bun run privacy:scan`, + `bun run lint:gui`, `bun run build:gui`, `bun run test:changed` on the merge result, then the + live Desktop proof in [040](040_wp5_live_proof_pr_merge.md). +- Stop condition: PR merged to `dev` by squash after exact-head CI, merged tree verified. +- Memory artifact: this unit; goalplan + `.codexclaw/goalplans/opencodex-claude-desktop-default-to-gateway-3p-w/`. +- Expected terminal outcomes: DONE when every goalplan criterion has fresh evidence; NEEDS_HUMAN + only for the macOS keychain password dialog during the live proof; BLOCKED on repeated external + CI blockage; UNSAFE if picker mode could leave Desktop without connectivity. +- Escalation condition: any change to the operator's live service beyond the documented restart, + any need to disable upstream TLS verification, or a live bootstrap shape that contradicts 001. +- Resource bounds: writes limited to this worktree, `/private/tmp` scratch, the operator's + Desktop config library and login keychain during the live proof; pushes limited to the PR + branch and `pr-assets`; gpt-6-sol subagents for discovery, bounded slices and review; no token or + time budget was set by the user. + +## Work-phase map + +| Work-phase | Doc | Closes with | +| --- | --- | --- | +| wp1 docs-first | 000, 001 and the decade docs | roadmap locked, no code | +| wp2 gateway default + 1P warning | [010](010_wp2_gateway_default_and_warning.md) | resolver/CLI/API/sync tests, typed locales, docs parity | +| wp3 picker core | [020](020_wp3_picker_core.md) | CA/trust, CONNECT decision, claude.ai relay, bootstrap rewrite, route snapshot tests | +| wp4 picker activation | [030](030_wp4_picker_activation.md) | egress profile, CLI/API/GUI controls, docs, default-on in 1P | +| wp5 live proof + PR + merge | [040](040_wp5_live_proof_pr_merge.md) | Desktop screenshots, usage.jsonl proof, CI green, squash merge | + +The phases follow build order: the mode contract (wp2) is consumed by activation (wp4); the relay +and CA (wp3) must exist before anything selects the egress profile (wp4); nothing is shown to a +real Desktop before wp5. One branch, one PR, ordered commits. + +## Decisions + +Decision IDs come from the architect proposal; dispositions are main's. + +| ID | Decision | Disposition | +| --- | --- | --- | +| D1 | Default `gateway`; resolver takes observations (owned first-party settings applied/stale → legacy first-party) with precedence explicit → observed gateway → gateway fingerprint → owned first-party settings → gateway. Implicit applies persist the preserved mode. `/api/sync` stops writing a gateway profile when the resolved mode is first-party. | Accepted. Status stays read-only. Reflections r1/r2: the selected owned gateway row joins the observation; the intercept-disabled fallback is removed, so an observed first-party install keeps its mode and an apply with the intercept disabled is refused with `intercept_disabled`. | +| D2 | One owner for the risk text (`src/claude/desktop-risk.ts`), `riskWarning` in status, CLI apply/status, native toggle response, dashboard selector + active card, 10 locales, 8 guides; default badge moves to gateway. | Accepted. | +| D3 | Separate picker CA under `/claude-picker/`, critical nameConstraints permitting `claude.ai`, leaf SAN `claude.ai` only; macOS `security add-trusted-cert -r trustRoot -p ssl -s claude.ai -k `, trust checked with `security verify-cert -q -L -c -p ssl -n claude.ai`, removal with `security remove-trusted-cert`. | Accepted, amended: tests use a fake command runner; no real keychain in CI. Reflection r1 gap 4 folded: "trusted" also requires the login keychain to hold a certificate whose SHA-1 equals the current picker CA (`security find-certificate -a -Z -c `), and `verify-cert` searches that keychain (`-k`). Audit r1 blocker 1 folded: the claim is narrowed to "`claude.ai` and its subdomains" (an RFC 5280 dNSName subtree cannot be exact), and it is only claimed for verifiers shown to enforce it: a Bun/BoringSSL test rejects an off-host leaf issued by the picker CA, and wp5 runs Apple `verify-cert` on an ephemeral off-host leaf after trust; whatever that shows is what the PR states. The primary control stays the 0600 key that never leaves the machine. | +| D4 | CONNECT decides per connection: `messages` (api.anthropic.com), `picker` (claude.ai, only when desired + first-party effective + listener ready + cached trust matches the CA fingerprint), else `blind`. Trust cache invalidated on toggle/rotation, rechecked on a bounded interval. | Accepted. | +| D5 | Dedicated `node:https` HTTP/1.1 terminator for claude.ai with `request` and `upgrade` handlers, fixed upstream `claude.ai:443`, verified TLS, raw headers and bodies streamed. | Accepted after a spike on Bun 1.4.0 (`/private/tmp/ocx-picker-spike/spike.ts`): gzip bytes, two `Set-Cookie` headers and a WebSocket upgrade passed through unchanged. | +| D6 | Rewrite only GET bootstrap responses (`/edge-api/bootstrap`, `/edge-api/bootstrap/{org}/app_start`, `/api/bootstrap…`) with a `code` surface; clone a selectable Claude entry per route; decode gzip/br/deflate under compressed and decompressed caps; fail open with the original bytes. | Accepted, amended: the bootstrap request's `accept-encoding` is narrowed to `gzip, deflate, br` so zstd never arrives. Reflection r1 gap 6 partly folded: clones drop `fast_mode` and every version-gate key (`/version/i`) with the presentation fields; `thinking` and `capabilities` stay because the intercept translates effort and handles images for routed models. | +| D7 | Picker routes mirror the gateway's rendered Desktop profile, ids minted with `aliasForRoute`/`claudeCodeNativeAlias`, served from a snapshot so bootstrap never waits on provider discovery. | Accepted, amended: snapshot built at server start (when picker is desired), on picker on/apply, and refreshed stale-while-revalidate after 10 minutes. | +| D8 | Picker egress profile is an owned standard row containing only `egressProxyUrl`; previous selection recorded in opencodex state (never in `_meta.json`); off/removal/gateway switch: stop terminating, reselect previous, delete owned row, untrust CA; partial cleanup reported. | Accepted. Config field is `claudeCode.intercept.picker?: boolean` (absent = on in 1P on macOS). Reflections r1–r3: every disable first calls the running runtime's `disarm()` (new claude.ai CONNECTs go blind at once, independent of the preference and of the cached mode), then removes the profile, then untrusts. Only an explicit `picker off` persists `false`; mode-transition cleanup leaves the preference unset so first-party turns the picker back on. | +| D9 | `ocx claude desktop picker on|off|status|trust`, `GET/PUT /api/claude-desktop/picker` (standard management auth: CLI admin token and dashboard session), `firstParty.picker` in status with desired/effective/reason/hint/residual. | Accepted; reshaped by D11: `on`/`off` go through the server when one runs; `trust` is the only CLI-local mutation; `off` and transition cleanup run locally only when no server runs. | +| D10 | Live mode propagation: the runtime's `refresh()` decides from a fresh persisted read (resolved mode, Desktop intent, picker preference); `selectTunnel` reads only the cached decision; a disarm latch that only a completed, verified enable clears; server paths adopt committed `claudeCode` for status. | Accepted with the architect's amendments; the POST disarm/refresh routes of earlier revisions are dropped under D11. | +| D11 | While a server runs, every picker mutation (enable, disable, transition cleanup) runs in one server-side `DesktopPickerController`, serialized by one lock; CLI first-party apply delegates to `POST /api/claude-desktop/apply` like gateway apply already does; enable re-reads persisted state, commits an explicit preference first, checks mode/intent/preference/bound proxy before and after trust, and compensates trust it added on any later failure; startup never trusts or writes a profile. | Added at the second P re-entry after audit round 5 (ordering and race findings from rounds 4–5 all came from two mutation sites); architect reflection below. | + +## Field chains (PLAN-FIELD-CHAIN-01) + +| Field | Creation | Serialization | Deserialization | Consumers | +| --- | --- | --- | --- | --- | +| `claudeCode.intercept.picker?: boolean` | `DesktopPickerController.enable({ persist: true })` / `.disable({ persist: true })` (CLI `picker on|off` and the dashboard toggle through `PUT /api/claude-desktop/picker`); offline CLI `picker off` writes `false` locally before `removeDesktopPickerArtifacts`; first-party apply and transition cleanup never write it (absent = on) | `config.json` through the field-scoped config writer; `src/types/config.ts:151` type; `src/config/schema/config-schema.ts` boolean validation | `loadConfig` → `OcxClaudeCodeConfig.intercept.picker`; a non-boolean fails schema validation | `pickerDesired` (picker-runtime `refresh()`), `DesktopPickerController.enable` checks, `DesktopPickerController.status`, status payload | +| `riskWarning: { code, message } \| null` | status builder (agent-settings-routes), first-party apply response | JSON response | GUI `DesktopStatus` (`gui/src/pages/ClaudeDesktop.tsx:62`); CLI `status` prints every key | GUI callout (localized by `claudeDesktop.mode.firstPartyRisk`, gated on presence), CLI status | +| `firstParty.picker: DesktopPickerStatus` | `DesktopPickerController.status()` in the status builder (with no controller running: a static "proxy_unavailable" status); `GET/PUT /api/claude-desktop/picker` | JSON response | GUI `DesktopFirstPartyStatus` (`ClaudeDesktop.tsx:49`); CLI `picker status` | `ClaudeDesktopPicker` card, CLI output | +| config-library row name `opencodex-picker` | `applyDesktopPickerProfile` | `_meta.json` `entries[].name` (Desktop's schema; no opencodex keys added) | `parseMetadata` (desktop-3p-library) | `isOwnedDesktopEntry` (owned, inspection kind `standard` because the profile has no `inferenceProvider`), `removeDesktopPickerProfile`; `isOwnedDesktopGatewayEntry` stays `name === "opencodex"`, so gateway writes and gateway cleanup never pick it | +| `PUT /api/claude-desktop/picker` body `{ enabled, persist, trustedLocally?, callerAddedTrust? }` | CLI `picker on|off|trust`, dashboard toggle, CLI transition helpers | JSON request | route handler validation (booleans only; unknown keys rejected) | `DesktopPickerController.enable/disable`; `trustedLocally` only selects the trust-outcome wording (`trust_declined` vs `trust_pending`) and never skips a check; `callerAddedTrust` makes the server compensate that trust inside its lock on refusal or failure | +| `ClaudeDesktopModeObservation` | `observeClaudeDesktopMode` | N/A — in-process value | N/A | `resolveClaudeDesktopMode`, `resolveClaudeDesktopApplyMode` callers listed in 010 | + +## Guard bypasses (PLAN-BYPASS-NAMED-01) + +The trust gate on claude.ai termination is a safety guard, not enforcement. + +- Tier: runtime check in process (no OS or build gate). +- Executing surface: `PickerRuntime.selectTunnel` on every new CONNECT to `claude.ai:443`. +- Known bypass: trust removed outside opencodex (Keychain Access) stays cached as trusted for up to + the 60 s refresh interval; connections opened in that window fail TLS in Desktop until the next + refresh. An operator who edits the config library by hand can point Desktop elsewhere. While a + server runs, enable and disable are serialized by the picker controller's lock, and the disarm + latch is cleared only at the end of an enable whose checks all passed; with no server running, + nothing can terminate claude.ai. A process that edits opencodex's config or the keychain + directly, outside these paths, is not constrained. +- Residual risk: Desktop has no network while its pinned egress proxy is down; stated in CLI, GUI + and docs. +- Wording downgrade: described as a guard everywhere; no document calls it enforcement. +- Final layer: none. + +## Architect consultation + +- Handle: `01a0d104-9116-7d22-b92d-81bc155eab50` (gpt-6-sol, CXC-ROLE architect, read-only). +- Proposal D1–D9 above; dispositions recorded in the table. +- Reflection on revision r1: MISALIGNED with six gaps. Gap 1 (security working note tracked in + devlog) folded: the threat model moved to scratch. Gap 2 partly folded, gaps 3–5 folded, gap 6 + partly folded; dispositions are in the decision table. +- Reflection r2: MISALIGNED (3 gaps: intercept-disabled fallback, picker mode from config alone, + transition cleanup persisting false) — all folded into 010/020/030. +- Reflection r3: MISALIGNED (cleanup could keep terminating while the cached mode was still + first-party; stale table wording) — folded: `disarm()` first, table updated. +- Reflection r4: MISALIGNED (no server path to disarm without changing the preference; in-flight + `ensureStarted()` could re-arm) — folded: `POST /api/claude-desktop/picker/disarm`, arm + generation guard, route and race tests. +- Reflection r5: **ALIGNED**, no remaining material gap. +- After audit round 1 and round 2 amendments: rechecks MISALIGNED (overflow chunk, failed compensation; + grok-lifecycle source assertions) → folded → **ALIGNED**. +- P re-entry after audit round 3 (LOOP-REPAIR-01): D10 proposed by main, amended by the architect + (disarm latch, integration intent, native adoption, OFF→ON test), reflection MISALIGNED once + (integration intent not visible to the runtime) → decision source changed to a fresh persisted + read → **ALIGNED**. +- Second P re-entry after audit round 5: D11 (single server-side controller) proposed by main; + the architect amended it three times (transition lease, CLI trust compensation, preference commit + after independent checks; then restart_required and lost-response handling) → **ALIGNED**. + +## Audit record + +- Reviewer `01a0d114-541c-7d13-8277-ed9f711dad59` (gpt-6-sol, CXC-ROLE reviewer). +- Round 1: FAIL (7 blockers: CA boundary claim, /api/sync race, durable-OFF cleanup, oversize + fail-open handoff, signature mismatches, trust compensation, trust_pending test) → all folded. +- Round 2: FAIL (5: overflow triggering chunk, async ensure caller, stale mode passed to enable, + missing cap seam, hint/declined contract) → all folded. +- Round 3: FAIL (1: committed mode not reaching the running runtime) → returned to P, D10 added. +- Round 4: FAIL (4: stranding Desktop without a bound proxy, failed mode write, latch cleared by a + caller claim, CLI/route auth mismatch) → folded. +- Round 5: FAIL (4: off→on refused by its own guard, stale intent in CLI apply, concurrent enable + during cleanup, second-guard compensation) → second P re-entry, D11. +- Round 6: FAIL (4: /api/sync outside the lock, busy guard vs in-lock rearm, lost CLI response + racing server enable, live-proof rollback leaving picker state) → folded; architect rechecks added + early-refusal compensation. +- Round 7: FAIL (2: startup refresh missing, config-routes.ts absent from the wp4 inventory) → + folded; architect recheck added the bounded first-CONNECT wait and the persisted route snapshot. +- Round 8: **GO-WITH-FIXES (blockers=1)** — field-chain rows still named pre-D11 functions → + folded. Main's judgment: near-pass; no High/Critical blocker remains. + +## wp1 close (D) + +Roadmap locked at D1–D11; the next cycle is wp2 (gateway default and the first-party risk warning). + +- What changed: the unit's plan, research and four diff-level decade docs; the threat model stays in + scratch. +- Hypotheses that died: "no local change can add a row to the first-party picker" (true only for + Desktop builds older than 1.44121.1, which lack `egressProxyUrl` in 1P); "Desktop filters + non-Anthropic model ids" (only the custom-3P provider does; 1P returns `{ok:true}`); "Cloudflare + in front of claude.ai rejects a re-originated TLS client" — a Bun `node:https` GET of + `/edge-api/bootstrap` and `/api/bootstrap` returned 200 JSON (brotli) with no `cf-mitigated` + header (`/private/tmp/ocx-picker-spike/cf.ts`, 2026-09-24). +- What did not improve: the audit needed eight rounds and two returns to P; every late finding was + about ordering between two mutation sites, which D11 removed. The activation surface is still the + largest part of the change. +- Evidence that would show the direction is wrong: a logged-in bootstrap without a `code` surface + in `model_selector_config` (the logged-out bootstrap has no `model_selector_config` at all, so + this is only checkable in wp5); Desktop's Chromium refusing a login-keychain-trusted root with + name constraints; the launchd service never able to raise the keychain dialog (then + `trust_pending` + `picker trust` is the only path, which the plan already supports). diff --git a/devlog/_plan/260924_claude_desktop_picker_mode/001_research.md b/devlog/_plan/260924_claude_desktop_picker_mode/001_research.md new file mode 100644 index 00000000000..1c3e3b2f88c --- /dev/null +++ b/devlog/_plan/260924_claude_desktop_picker_mode/001_research.md @@ -0,0 +1,83 @@ +# 001 — Research: how the Desktop Code tab picker can list opencodex models (2026-09-24) + +Evidence was read from Claude.app 2.7032.0 (`/Applications/Claude.app/Contents/Resources/app.asar`, +byte offsets below), from claude.ai renderer bundles saved during the 2026-09-23 probe (kept outside +the repository under `/tmp/ocx-claude-probe/web/`; they may be older than the live renderer), and +from this repository at `37f93da6ac`. Nothing here was observed on the wire yet; the items marked +**live** are verified in wp5. + +## Desktop app facts + +- `egressProxyUrl` is a config-library key supported in both deployment scopes: + `support:{enabled:{scopes:["3p","1p"],availableInVersion:"1.44121.1"}}`, `appBehaviorOnly:!0` + (app.asar ≈11009657). It is read once at launch and applied as Chromium `--proxy-server` with a + bypass list for loopback and `*.local`; PAC (`egressProxyPacUrl`) takes precedence + (≈20277302–20278073). Startup logs `[egress-proxy] pinned …; OS proxy settings ignored` + (≈20329028). Claude Code processes the app spawns receive `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY`. + The 1.18286.0 build probed for the previous unit predates this key, which is why that unit could + not reach the picker. +- On macOS the app reads its config library from the user-data directory with a `-3p` suffix + (`~/Library/Application Support/Claude-3p/configLibrary/`) in both scopes; `_meta.json` + `appliedId` selects `.json` (≈11136500–11138403, ≈11158102). opencodex already owns this + writer (`src/claude/desktop-3p-library.ts`, `src/claude/desktop-3p-paths.ts:47`). +- The model-list keys `modelCatalogUrl`, `modelCatalogEnabled` and `modelDiscoveryEnabled` are + 3P-only (`scopes:["3p"]`). No config key sets an app-level trusted CA, so Desktop's renderer + relies on the operating system trust store for claude.ai. +- The first-party provider class (`hasClaudeAiProductFeatures(){return!0}`, + `managesProviderRouting(){return!1}`) implements `validateSessionModel(e,t){return{ok:!0}}` + (≈12961260). The non-Anthropic model regex (`…|gpt|grok|kimi|…`, ≈10697300) applies only to the + custom-3P provider's `validateSessionModel` (≈12952304). In 1P, a picked id reaches Claude Code + unchanged through `query.setModel`. + +## claude.ai renderer facts (saved bundles) + +- The picker catalog comes from the bootstrap response field `model_selector_config`: an array of + surfaces `{ id, models[], description?, presets?, featured?, auto_compact_window? }`. The Desktop + bridge takes the selectable models of the surface `"code"` and sends their ids to the app with + `setAvailableCodeModels`. `"cowork"` has its own catalog; `"ccr"`/`"ccd"` share Code-like + selection persistence but are not shown to feed this bridge. +- A model entry carries `id`, `name`, `section`, `description`, `badge`, `tooltip`, + `disabled`, `disabled_reason`, `context_window`, `thinking`, `fast_mode`, `capabilities` and + optional version gates. It is listed when `section` is `"main"` or `"overflow"` and selectable when + it is not disabled, has no `disabled_reason` and is not `"deprecated"`. The default selection is a + separate `model_selector_state`; `contextWindowByModel` is derived from each entry's + `context_window`. +- The bootstrap is fetched with credentials from `/edge-api/bootstrap/{org}/app_start` or + `/edge-api/bootstrap` with `statsig_hashing_algorithm=djb2&growthbook_format=sdk&cache_bust=1` + (another provider defaults to an `/api` prefix) and parsed with `response.json()`; no body + signature or integrity check exists on that path. A `bootstrap_push_revision` guard keeps a newer + catalog revision when an older network result arrives. +- claude.ai also carries WebSockets (`/v1/sessions/ws/{id}/subscribe`, Code terminal + `/v1/code/sessions/{id}/terminal`, `/api/ws/…` voice) and streaming responses + (`/v1/code/sessions/{id}/events/stream`, `/v1/code/sessions/watch`, SSE chat, an MCP + `EventSource`). A terminating proxy has to pass all of them through. + +## opencodex facts + +- Mode: `DEFAULT_CLAUDE_DESKTOP_MODE = "first-party"` (src/claude/desktop-first-party.ts:36); + `resolveClaudeDesktopMode` returns an explicit `desktopMode`, then `gateway` for an applied + gateway fingerprint, then the default (:49–53). Every apply path persists the chosen mode through + `recordClaudeDesktopMode` (:62). An install that applied first-party before the field existed + would silently resolve to gateway if only the constant changed, and the next implicit apply would + retire its env. Five tests in tests/claude-integration/claude-desktop-first-party.test.ts assert + the current default (:64, :78, :86, :138, :182). +- Intercept: the CONNECT proxy splices only `api.anthropic.com` (a startup snapshot, + src/claude/intercept/connect-proxy.ts:15, :121) and blind-tunnels the rest; loopback targets get + 403 and non-CONNECT requests 405. The TLS listener is one `Bun.serve` with one certificate + (listener.ts:106) and relays non-Messages paths through `fetch`, which decodes bodies and drops + `Upgrade` (listener.ts:19–29, :62–88), so it cannot carry claude.ai as is. +- CA: `local-ca.ts` builds P-256 certificates with local DER helpers (`tlv`, `contextTag`, + `objectIdentifier`, `extension`, :48–109) and keeps the CA out of OS trust (:9); a + nameConstraints extension (OID 2.5.29.30) is expressible with the same helpers. +- Routed ids: `aliasForRoute` mints `ocx-claude---` (src/claude/alias.ts:94) and the + Messages path already resolves it, so a picker entry with that id routes without a binding. +- File-size ratchet: none of the touched intercept/desktop files has a cap; `src/server/index.ts` is + at 883 of 893, so no new wiring lands there. + +## Open questions verified live in wp5 + +1. The live bootstrap still carries `model_selector_config` with a `"code"` surface, and a cloned + entry renders and is selectable. +2. Chromium in Desktop accepts a claude.ai leaf chained to a login-keychain-trusted root that + carries nameConstraints. +3. Desktop behaves with an HTTP/1.1-only terminator (ALPN) for claude.ai, including WebSockets. diff --git a/devlog/_plan/260924_claude_desktop_picker_mode/010_wp2_gateway_default_and_warning.md b/devlog/_plan/260924_claude_desktop_picker_mode/010_wp2_gateway_default_and_warning.md new file mode 100644 index 00000000000..950a89e8402 --- /dev/null +++ b/devlog/_plan/260924_claude_desktop_picker_mode/010_wp2_gateway_default_and_warning.md @@ -0,0 +1,285 @@ +# 010 — wp2: gateway by default, first-party account-risk warning + +Consumes: D1, D2 in [000](000_plan.md). Produces the mode contract wp4 builds on. + +## Files + +| Path | Change | +| --- | --- | +| `src/claude/desktop-first-party.ts` | MODIFY: default constant, observation-aware resolver, observation helper, header comment | +| `src/claude/desktop-risk.ts` | NEW: the single owner of the first-party account-risk text | +| `src/cli/claude-desktop.ts` | MODIFY: help text, `defaultDesktopApplyMode` observes settings, apply prints the risk | +| `src/cli/ensure-desired-integrations.ts` | MODIFY: resolve with observation | +| `src/server/management/agent-settings-routes.ts` | MODIFY: apply default + status `riskWarning` | +| `src/server/management/native-integration-routes.ts` | MODIFY: status/enable observe; enable message carries the risk | +| `src/server/management/config-routes.ts` | MODIFY: `/api/sync` skips the gateway writer when the resolved mode is first-party | +| `src/server/management/agent-settings-routes.ts` (also) | MODIFY: `autoApplyDesktopBestEffort` (:212, the roster-update gateway writer) returns early when the resolved mode is first-party, both before and after its model discovery await | +| `src/types/config.ts` | MODIFY: the `desktopMode` doc comment (:183) names gateway as the default and first-party's risk | +| `tests/claude-integration/claude-desktop-mode-explanation.test.ts` | MODIFY: explanation cases for the new default | +| `structure/gui-and-management-api.md` | MODIFY: the Desktop apply default (:182) is gateway; `riskWarning` in the status payload | +| `gui/src/styles/claude-desktop-mode-picker.css` | MODIFY: header comment (:1) — gateway is the default, first-party is the opt-in with the risk callout (plus the callout style if it fits here) | +| `gui/src/pages/ClaudeDesktop.tsx` (also) | MODIFY: stale comments at :240 and :351 that call first-party the default | +| `gui/src/pages/ClaudeDesktop.tsx` | MODIFY: default badge + fallback mode = gateway; first-party risk callout | +| `gui/src/pages/ClaudeDesktop.tsx` (Desktop status type lives here) | MODIFY: `riskWarning` in the status type | +| `gui/src/i18n/{en,de,fr,ko,zh,zh-TW,ru,ja,tr,vi}.ts` | MODIFY: `claudeDesktop.mode.firstPartyRisk`; hints no longer call first-party the default | +| `docs-site/src/content/docs/{,fr/,ja/,ko/,ru/,tr/,zh-cn/,zh-tw/}guides/claude-code.md` | MODIFY: gateway is the default; caution block in the first-party section | +| `structure/clients/claude-desktop.md` | MODIFY: mode contract (default, legacy observation, risk warning, sync guard) | +| `tests/claude-integration/claude-desktop-first-party.test.ts` | MODIFY: the five default assertions + new cases | + +## Diff + +`src/claude/desktop-first-party.ts` + +```diff +- * - `first-party` (default): the app keeps its ordinary claude.ai login, … ++ * - `gateway` (default): the third-party deployment profile (src/claude/desktop-3p.ts) … ++ * - `first-party`: the app keeps its claude.ai login … Carries an account-risk warning ++ * (src/claude/desktop-risk.ts). +-export const DEFAULT_CLAUDE_DESKTOP_MODE: ClaudeDesktopMode = "first-party"; ++export const DEFAULT_CLAUDE_DESKTOP_MODE: ClaudeDesktopMode = "gateway"; ++ ++/** What the resolver may learn from disk. Only owned rows and owned settings count. */ ++export interface ClaudeDesktopModeObservation { ++ /** Desktop's selected config-library row is our gateway (current or drifted). */ ++ ownedGatewaySelected?: boolean; ++ ownedFirstPartySettings?: boolean; ++} ++ ++/** Observe owned first-party settings (applied or stale). Never throws; unreadable = none. */ ++export function observeClaudeDesktopMode( ++ config: Pick, ++ options: DesktopFirstPartyOptions = {}, ++): ClaudeDesktopModeObservation { ++ const observed: ClaudeDesktopModeObservation = {}; ++ try { ++ const library = inspectDesktop3pConfigLibrary({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null }); ++ observed.ownedGatewaySelected = library.kind === "gateway_ours" || library.kind === "gateway_drifted"; ++ } catch { /* unreadable library: no gateway evidence */ } ++ try { ++ const kind = inspectDesktopFirstParty(config, options).settings.kind; ++ observed.ownedFirstPartySettings = kind === "applied" || kind === "stale"; ++ } catch { /* unreadable settings: no first-party evidence */ } ++ return observed; ++} +-export function resolveClaudeDesktopMode(config: DesktopModeConfig): ClaudeDesktopMode { ++export function resolveClaudeDesktopMode( ++ config: DesktopModeConfig, ++ observed: ClaudeDesktopModeObservation = {}, ++): ClaudeDesktopMode { + const explicit = config.claudeCode?.desktopMode; + if (isClaudeDesktopMode(explicit)) return explicit; ++ if (observed.ownedGatewaySelected) return "gateway"; + if (config.claudeCode?.desktopProfile?.appliedFingerprint) return "gateway"; ++ // An install that applied first-party before the mode was persisted keeps first-party. ++ if (observed.ownedFirstPartySettings) return "first-party"; + return DEFAULT_CLAUDE_DESKTOP_MODE; + } + export function resolveClaudeDesktopApplyMode( + config: Pick, ++ observed: ClaudeDesktopModeObservation = {}, +): ClaudeDesktopMode { +- const resolved = resolveClaudeDesktopMode(config); ++ const resolved = resolveClaudeDesktopMode(config, observed); +- if (resolved === "gateway" || isClaudeDesktopMode(config.claudeCode?.desktopMode)) return resolved; +- return claudeInterceptEnabled(config) ? "first-party" : "gateway"; ++ return resolved; +``` + +With gateway as the default, an implied first-party can only come from observed legacy settings, +so the intercept-disabled fallback branch is removed: an observed first-party install keeps its +mode, and an apply with the intercept disabled is refused with `intercept_disabled` exactly like an +explicit first-party (reflection r2 gap 1). `resolveClaudeDesktopApplyMode` stays as the named +entry point callers use; `claudeInterceptEnabled` is no longer imported by it. +`observeClaudeDesktopMode` is placed after `inspectDesktopFirstParty` (it calls it) and reaches +`inspectDesktop3pConfigLibrary` without a new static import cycle (confirm the import graph at P). +Resolver stays pure; callers that decide an apply or a write pass `observeClaudeDesktopMode(config)`. + +`src/claude/desktop-risk.ts` (NEW) + +```ts +/** Account-risk notice every first-party surface shows. One owner so the wording cannot drift. */ +export const FIRST_PARTY_ACCOUNT_RISK = { + code: "first_party_account_suspension_risk", + message: "First-party mode sends Claude subscription traffic through a local interception proxy. " + + "Anthropic may treat this as a violation of its terms and suspend the account. Use it at your own risk; " + + "gateway mode is the default.", +} as const; +export type FirstPartyAccountRisk = typeof FIRST_PARTY_ACCOUNT_RISK; +``` + +`src/cli/claude-desktop.ts` + +```diff +- --first-party (default) keep Desktop on claude.ai; route only the Code tab's Claude Code +- through the local intercept proxy via ~/.claude/settings.json env +- --gateway install the third-party gateway profile for the whole app ++ --gateway (default) install the third-party gateway profile for the whole app ++ --first-party keep Desktop on claude.ai; route only the Code tab's Claude Code through the ++ local intercept proxy. Risk: Anthropic may suspend the account. +-export function defaultDesktopApplyMode( +- config: Pick, ++export function defaultDesktopApplyMode( ++ config: Pick, +- const resolved = resolveClaudeDesktopApplyMode(config); ++ const resolved = resolveClaudeDesktopApplyMode(config, observeClaudeDesktopMode(config)); + if (target.kind === "first-party") { + console.log(`Claude Desktop first-party 설정을 적용했습니다: ${result.path}`); + console.log("Desktop 앱 설정은 그대로이며, Code 탭의 Claude Code만 로컬 프록시를 거칩니다."); ++ console.warn(`⚠️ ${FIRST_PARTY_ACCOUNT_RISK.message}`); +``` + +`gatewayModeExplanation` (src/cli/claude-desktop.ts:212–247) is rewritten for the new default: when gateway +was applied without an explicit flag and first-party can run here (not a connected client, intercept +enabled), it prints "Gateway is the default.", the first-party alternative +(`ocx claude desktop apply --first-party`) and `FIRST_PARTY_ACCOUNT_RISK.message`; explicit gateway +requests and machines that cannot run first-party get nothing. Its doc comment drops the "help calls +first-party the default" premise. + +The bindings gateway warning (`resolveClaudeDesktopMode(config) === "gateway"`) also passes the +observation. `status` prints the payload, which now carries `riskWarning`. `parseDesktopApplyArgs` +(the only caller of `defaultDesktopApplyMode`) widens its config type the same way; its callers +already pass a loaded `OcxConfig`. The gateway-mode explanation that suggests +`ocx claude desktop apply --first-party` (`gatewayModeExplanation`, src/cli/claude-desktop.ts:243) +appends `FIRST_PARTY_ACCOUNT_RISK.message` under the suggestion. + +`src/cli/ensure-desired-integrations.ts` + +```diff +- if (resolveClaudeDesktopMode(config) !== "first-party") return; ++ if (resolveClaudeDesktopMode(config, (deps.observeClaudeDesktopMode ?? observeClaudeDesktopMode)(config)) !== "first-party") return; +``` + +`src/server/management/agent-settings-routes.ts` + +```diff +- let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config); ++ let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config, observeClaudeDesktopMode(config)); + …status… +- const mode = gatewayApplied ? "gateway" : resolveClaudeDesktopApplyMode(persisted); ++ const mode = gatewayApplied ? "gateway" : resolveClaudeDesktopApplyMode(persisted, observeClaudeDesktopMode(persisted)); ++ const { FIRST_PARTY_ACCOUNT_RISK } = await import("../../claude/desktop-risk"); ++ const riskWarning = mode === "first-party" || firstPartySeen.applied || firstPartySeen.stale ++ ? { ...FIRST_PARTY_ACCOUNT_RISK } : null; + return jsonResponse({ + desiredEnabled, + mode, ++ riskWarning, + firstParty, +``` + +The first-party apply response adds `riskWarning: { ...FIRST_PARTY_ACCOUNT_RISK }`. + +`src/server/management/native-integration-routes.ts`: `desktopStatus` and the enable branch call +`resolveClaudeDesktopApplyMode(config, observeClaudeDesktopMode(config))`; the first-party enable +message appends `FIRST_PARTY_ACCOUNT_RISK.message`. + +`src/server/management/config-routes.ts` (`/api/sync` client integrations) + +```diff +- if (claudeDesktopIntegrationEnabled(config)) { ++ if (claudeDesktopIntegrationEnabled(config) ++ && resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party") { + … + const latest = loadConfig(); +- if (claudeDesktopIntegrationEnabled(latest)) { ++ // Discovery awaited: the mode may have changed meanwhile. Re-resolve on the fresh read, ++ // immediately before the writer, so a first-party switch during fetchAllModels still wins. ++ if (claudeDesktopIntegrationEnabled(latest) ++ && resolveClaudeDesktopMode(latest, observeClaudeDesktopMode(latest)) !== "first-party") { +``` + +wp4 moves this post-discovery block (the re-read, the re-resolve and `writeDesktop3pConfig`) +inside the picker controller's `transition` when a controller exists (030, D11), so a sync that +resumes while a first-party apply is between gateway cleanup and its mode commit waits for the +lock and then sees first-party. wp2 lands the re-resolve; wp4 adds the lock. + +A first-party Desktop no longer gets a gateway profile written and selected by a catalog sync. + +`autoApplyDesktopBestEffort` (agent-settings-routes.ts:212) gets the same guard twice: after +`loadConfig()` into `admitted` and again after the `fetchAllModels` await on `current`: + +```diff + if (!claudeDesktopIntegrationEnabled(admitted)) return; ++ if (resolveClaudeDesktopMode(admitted, observeClaudeDesktopMode(admitted)) === "first-party") return; + … + if (!claudeDesktopIntegrationEnabled(current)) return; ++ if (resolveClaudeDesktopMode(current, observeClaudeDesktopMode(current)) === "first-party") return; +``` + +`gui/src/pages/ClaudeDesktop.tsx` + +```diff +- const effectiveMode: DesktopMode = status?.mode ?? "first-party"; ++ const effectiveMode: DesktopMode = status?.mode ?? "gateway"; +- {mode === "first-party" && {t("claudeDesktop.mode.defaultBadge")}} ++ {mode === "gateway" && {t("claudeDesktop.mode.defaultBadge")}} + …after the mode options… ++ {selectedMode === "first-party" && ( ++

{t("claudeDesktop.mode.firstPartyRisk")}

++ )} +``` + +The callout also renders under the status bar when `status.riskWarning` is set and the picker +fieldset is not showing first-party (so an applied first-party install sees it after reload). +Styling goes in the existing Claude Desktop stylesheet only if it has room under the ratchet; +otherwise in a new `gui/src/styles/claude-desktop-risk.css` imported from `gui/src/main.tsx`. + +i18n: English source + +```ts +"claudeDesktop.mode.firstPartyRisk": "Account risk: first-party sends your Claude subscription traffic through a local interception proxy. Anthropic may treat this as a terms violation and suspend the account. Gateway is the default.", +``` + +plus the nine translations with the same three facts (proxy, possible suspension, gateway default). + +Docs (all eight guides): the mode section states gateway is the default; the first-party +subsection opens with + +```md +:::caution[Account risk] +First-party mode sends your Claude subscription traffic through a local interception proxy. +Anthropic may treat this as a violation of its terms and suspend the account. Gateway is the +default; choose first-party only if you accept that risk. +::: +``` + +and the dashboard recap line (`claude-code.md:760` in English) names gateway as the default. + +## Tests + +`tests/claude-integration/claude-desktop-first-party.test.ts` + +1. "mode resolution: explicit wins, applied gateway fingerprint keeps gateway, owned first-party + settings keep first-party, otherwise gateway" (replaces :64). +2. "implied apply mode is gateway; a legacy first-party install keeps first-party unless the + intercept is disabled" (replaces :78) → renamed "implied apply mode is gateway; a legacy + first-party install keeps first-party, and with the intercept disabled the apply is refused + with intercept_disabled instead of switching to gateway". +3. "CLI apply flags: default gateway, --first-party explicit, legacy shape flags imply gateway, + conflicts rejected" (replaces :86). +4. "POST /api/claude-desktop/apply defaults to gateway, first-party on request, and returns the + risk warning" (replaces :138). +5. "native toggle: enable applies gateway by default; a legacy first-party install keeps + first-party and its message carries the risk" (replaces :182). +6. NEW "status carries riskWarning for first-party and null for gateway". +7. NEW "/api/sync does not write a gateway profile while first-party is resolved" (fake + `writeDesktop3pConfig` dep asserts it is not called). Activation: config with explicit + `desktopMode: "first-party"` and `claudeDesktop` integration enabled. +7b. NEW "/api/sync re-resolves after discovery": a fake `fetchAllModels` resolves only after the + test persists `desktopMode: "first-party"`; the writer must not be called. +8. NEW "a foreign HTTPS_PROXY in settings.json is not first-party evidence" (settings kind + `foreign` → gateway). +9. NEW "a selected owned gateway row outranks legacy first-party settings" (both observed → + gateway). +10. NEW "a roster update does not write a gateway profile on an explicit first-party install" and + "… nor when the mode switches to first-party while its discovery is pending" (fake + `fetchAllModels` resolves after the switch; fake `writeDesktop3pConfig` must not be called). + +`tests/claude-integration/claude-desktop-mode-explanation.test.ts`: implicit gateway where +first-party can run → the default line, the first-party command and the risk text; explicit +`--gateway` → nothing; connected client or disabled intercept → nothing. + +Verifier (run at P of wp2, before writing it into the plan as proof): +`bun test tests/claude-integration/claude-desktop-first-party.test.ts tests/claude-integration/claude-desktop-cli.test.ts tests/claude-integration/claude-desktop-mode-explanation.test.ts`, +`bun run typecheck` (locale records are `Record`, so a missing key fails), +`cd gui && bun test tests/locale-parity.test.ts`, `bun run structure:check`. diff --git a/devlog/_plan/260924_claude_desktop_picker_mode/020_wp3_picker_core.md b/devlog/_plan/260924_claude_desktop_picker_mode/020_wp3_picker_core.md new file mode 100644 index 00000000000..ff6fb25d6e5 --- /dev/null +++ b/devlog/_plan/260924_claude_desktop_picker_mode/020_wp3_picker_core.md @@ -0,0 +1,341 @@ +# 020 — wp3: picker core (CA, trust, CONNECT decision, claude.ai relay, bootstrap rewrite, routes) + +Consumes: D3–D7 in [000](000_plan.md) and facts in [001](001_research.md). Controls this phase +carries: only claude.ai is terminated and only while trusted; the upstream leg always verifies +certificates; exactly one response (the bootstrap) is rewritten and the rewrite fails open; no +body, cookie or token is logged; every listener is loopback. Nothing here selects the Desktop +egress profile; with no profile applied, none of this code sees Desktop traffic. wp4 turns it on. + +## Files + +| Path | Change | +| --- | --- | +| `src/claude/intercept/local-ca.ts` | MODIFY: export a generic authority/leaf issuer; add the nameConstraints OID and encoder; existing intercept CA unchanged | +| `src/claude/intercept/picker-ca.ts` | NEW: picker CA (`/claude-picker/`), claude.ai leaf, persisted leaf PEM for trust checks | +| `src/claude/intercept/picker-trust.ts` | NEW: macOS login-keychain trust/verify/untrust through an injectable `security` runner | +| `src/claude/intercept/picker-bootstrap.ts` | NEW: bootstrap request match, accept-encoding narrowing, decode, JSON injection, header rewrite | +| `src/claude/intercept/picker-models.ts` | NEW: picker entries from the rendered Desktop profile + snapshot holder | +| `src/claude/intercept/picker-listener.ts` | NEW: `node:https` HTTP/1.1 terminator for claude.ai with request + upgrade relay | +| `src/claude/intercept/picker-runtime.ts` | NEW: desired/trust/listener state, tunnel decision, lazy start/stop, trust cache | +| `src/claude/intercept/connect-proxy.ts` | MODIFY: per-connection `selectTunnel`; header comment | +| `src/claude/intercept/runtime.ts` | MODIFY: create the picker runtime, pass `selectTunnel`, stop it; expose picker state | +| `src/server/index/claude-intercept-lifecycle.ts` | MODIFY: pass the picker route loader (dynamic imports; `src/server/index.ts` untouched) | +| `src/claude/desktop-3p.ts` | MODIFY: export `displayModelId` (label parity) | +| `src/types/config.ts`, `src/config/schema/config-schema.ts` | MODIFY: `claudeCode.intercept.picker?: boolean` | +| `structure/runtime.md` | MODIFY: intercept pair gains the picker terminator; invariants | +| tests (below) + `scripts/test-layout/layout.json`, `tests/fixtures/test-layout-expected.json` | NEW files registered in both | + +## local-ca.ts + +```diff + const OID = { ++ nameConstraints: "2.5.29.30", + … ++export interface AuthorityOptions { commonName: string; permittedDnsNames?: readonly string[] } ++ ++/** RFC 5280 NameConstraints with permittedSubtrees of dNSName bases only. */ ++function nameConstraints(permitted: readonly string[]): Uint8Array { ++ const subtrees = permitted.map(name => sequence(contextTag(2, new TextEncoder().encode(name), false))); ++ return sequence(contextTag(0, concat(...subtrees))); ++} ++ ++export function createCertificateAuthority(options: AuthorityOptions): LocalInterceptCa { …same body as ++ createLocalInterceptCa, with commonName from options and, when permittedDnsNames is non-empty, ++ extension(OID.nameConstraints, true, nameConstraints(options.permittedDnsNames)) … } ++export function issueServerLeaf(ca: LocalInterceptCa, issuerCommonName: string, hosts: readonly string[]): PemKeyPair +-export function createLocalInterceptCa(): LocalInterceptCa { … ++export function createLocalInterceptCa(): LocalInterceptCa { ++ return createCertificateAuthority({ commonName: CLAUDE_INTERCEPT_CA_COMMON_NAME }); ++} +-export function issueLocalInterceptLeaf(ca, hosts) { … ++export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly string[]): PemKeyPair { ++ return issueServerLeaf(ca, CLAUDE_INTERCEPT_CA_COMMON_NAME, hosts); ++} +``` + +The persistence helpers become parameterized by directory and common name +(`ensurePersistedAuthority(dir, options, lockName)`) so the picker CA reuses loading, validation, +atomic 0600 key writes and the lifecycle lease without copying them. + +## picker-ca.ts + +```ts +export const PICKER_HOST = "claude.ai"; +export const PICKER_CA_COMMON_NAME = "opencodex Claude Desktop Picker CA"; +export const PICKER_STATE_DIR = "claude-picker"; +export function pickerStateDir(configDir: string): string; // /claude-picker +export function pickerCaCertPath(configDir: string): string; // …/ca.pem +export function pickerLeafCertPath(configDir: string): string; // …/leaf.pem (public) +export interface PickerCa extends LocalInterceptCa { fingerprint: string } // sha256 of the CA DER +export function ensurePickerCa(configDir: string): PickerCa; // permittedDnsNames: [PICKER_HOST] +export function issuePickerLeaf(ca: PickerCa, configDir: string): PemKeyPair; // SAN claude.ai; writes leaf.pem 0644 +``` + +Reload validation (audit wp3 r1, High). The shared loader only checks CA status, key pairing and +self-signature, so `ensurePersistedAuthority` gains `accept?: (cert: X509Certificate) => boolean`, +and `ensurePickerCa` passes one that requires subject CN `PICKER_CA_COMMON_NAME` and a **critical** +nameConstraints extension whose permittedSubtrees hold exactly one dNSName, `claude.ai`, and no +excludedSubtrees. A persisted CA that fails it (for example a valid, key-matching CA without +constraints) is regenerated under the lease. The new fingerprint makes trust `untrusted` until the +operator trusts it again, so an unconstrained root is never loaded and trusted as the picker CA. + +## picker-trust.ts + +```ts +export type PickerTrustState = "trusted" | "untrusted" | "unsupported" | "unknown"; +export interface SecurityResult { code: number | null; stdout: string; stderr: string } +export type SecurityRunner = (args: readonly string[]) => Promise; +export const defaultSecurityRunner: SecurityRunner; // Bun.spawn(["/usr/bin/security", …]) +export function loginKeychainPath(home?: string): string; // ~/Library/Keychains/login.keychain-db +export async function inspectPickerTrust(leafPath: string, caSha1: string, run?: SecurityRunner, platform?: NodeJS.Platform): Promise; +// darwin only; "trusted" needs both: +// 1. ["find-certificate", "-a", "-Z", "-c", PICKER_CA_COMMON_NAME, loginKeychainPath()] lists caSha1 (the current CA) +// 2. ["verify-cert", "-q", "-L", "-c", leafPath, "-p", "ssl", "-n", "claude.ai", "-k", loginKeychainPath()] exits 0 +// missing record or exit 1 → untrusted; a runner failure → unknown +export async function trustPickerCa(caPath: string, run?: SecurityRunner, platform?: NodeJS.Platform): Promise<{ ok: boolean; reason?: "unsupported" | "declined_or_failed" }>; +// ["add-trusted-cert", "-r", "trustRoot", "-p", "ssl", "-s", "claude.ai", "-k", loginKeychainPath(), caPath] +export async function untrustPickerCa(caPath: string, fingerprintSha1: string, run?: SecurityRunner, platform?: NodeJS.Platform): Promise<{ ok: boolean }>; +// ["remove-trusted-cert", caPath] then ["delete-certificate", "-Z", fingerprintSha1, loginKeychainPath()] +``` + +Only stdout/stderr lengths are logged, never content. + +## picker-bootstrap.ts + +```ts +export const BOOTSTRAP_MAX_ENCODED_BYTES = 4 * 1024 * 1024; +export const BOOTSTRAP_MAX_DECODED_BYTES = 16 * 1024 * 1024; +export const PICKER_SURFACE_ID = "code"; +const BOOTSTRAP_PATH = /^\/(?:edge-api|api)\/bootstrap(?:\/[A-Za-z0-9-]+\/app_start)?\/?$/; +export function isPickerBootstrapRequest(method: string, pathname: string): boolean; // GET/HEAD-free: GET only +export function narrowBootstrapAcceptEncoding(): string; // "gzip, deflate, br" +export interface PickerModelEntry { id: string; name: string; contextWindow?: number } +export function injectPickerModels(bootstrap: unknown, models: readonly PickerModelEntry[]): number; +export function rewriteBootstrapBody(encoded: Buffer, contentEncoding: string | undefined, models: readonly PickerModelEntry[]): Buffer | null; +export function rewrittenHeaders(raw: readonly string[], bodyLength: number): string[]; +``` + +`injectPickerModels`: find `model_selector_config` (array) → surface `id === "code"` with array +`models`; template = first entry whose id starts with `claude-`, not `disabled`, no +`disabled_reason`, section not `deprecated`; for each model whose id is not already present: +`structuredClone(template)`, set `id`, `name`, `section: "main"`, set or delete +`context_window`, delete `disabled`, `disabled_reason`, `badge`, `tooltip`, `description`, +`fast_mode` and every key matching `/version/i` (version gates); keep `thinking` and +`capabilities`, which the intercept serves for routed models (effort translation, image +handling); push. Returns the number added (0 = leave the body untouched). `rewriteBootstrapBody` decodes +`gzip`/`x-gzip`/`deflate`/`br`/identity with `maxOutputLength` = the decoded cap, rejects +anything else or oversize, parses JSON, injects, and returns identity-encoded UTF-8 or `null`. +`rewrittenHeaders` drops `content-encoding`, `content-length`, `etag`, `digest`, `content-md5`, +`transfer-encoding` and sets `content-length`. + +## picker-models.ts + +```ts +export interface PickerRouteInput { nativeSlugs: string[]; routedModels: Desktop3pRoutedModel[]; profile?: OcxClaudeDesktopProfile; nativeContextCap?: NativeContextLimitsInput } +export function buildPickerModels(input: PickerRouteInput): PickerModelEntry[]; +export interface PickerModelSnapshot { current(): { models: PickerModelEntry[]; builtAt: number } | null; refresh(): Promise; refreshIfStale(maxAgeMs: number): void } +export function createPickerModelSnapshot(load: () => Promise, persistPath?: string): PickerModelSnapshot; +// persistPath (/claude-picker/models.json, 0600): written after each successful build and +// read synchronously at construction, so a bootstrap served right after a restart already injects +// the last known routes while discovery refreshes in the background. +``` + +With a profile: `reconcileDesktopProfile` + `renderDesktopProfile` (src/claude/desktop-profile.ts:230, +:296) in the gateway's order and labels; without one: every candidate, label +`${displayModelId(id)} (${provider})`. Id: `native/` → `claudeCodeNativeAlias(slug)`, +otherwise `aliasForRoute(provider, id)`; routes whose alias is `null` and real +`anthropic/claude-*` routes are skipped (Anthropic's own rows already exist). Context window from +the candidate. Refresh errors keep the previous snapshot. + +## picker-listener.ts + +```ts +export interface PickerListenerOptions { + leaf: PemKeyPair; + models: () => readonly PickerModelEntry[]; + upstream?: { host: string; port: number; servername: string; ca?: string }; // test seam; default claude.ai:443, system roots + /** Test seam: encoded bootstrap cap; production uses BOOTSTRAP_MAX_ENCODED_BYTES. */ + maxEncodedBytes?: number; + log?: (line: string) => void; +} +export interface PickerListenerHandle { port: number; close(): Promise } +export function startPickerListener(options: PickerListenerOptions): Promise; +``` + +`https.createServer({ cert, key, ALPNProtocols: ["http/1.1"] })` on 127.0.0.1:0. +- `request`: build upstream headers from `req.rawHeaders` minus hop-by-hop + (`connection`, `keep-alive`, `proxy-connection`, `proxy-authorization`, `te`, `trailer`, + `transfer-encoding`, `upgrade`); `https.request` with `servername`, + `rejectUnauthorized: true`, `agent: false` or a dedicated keep-alive agent. Non-bootstrap: + `res.writeHead(status, filteredRawHeaders)` and `upRes.pipe(res)`; `req.pipe(upReq)`. + Bootstrap (`isPickerBootstrapRequest`, status 200, JSON content type): set accept-encoding to + `narrowBootstrapAcceptEncoding()` and hold the response head. Collect upstream chunks in order + while the running total stays within the encoded cap. When a chunk would push the total past + the cap, stop collecting: `writeHead` with the original filtered headers, write every collected + chunk and then that triggering chunk in order (each byte exactly once), and only then + `upRes.pipe(res)` for later data (the complete original body, unmodified). The collection + listener is removed before piping, and `upRes` is paused while the head and collected bytes are + written so no chunk is emitted between the switch; `pipe` then owns backpressure. If the + upstream ends within the cap, call `rewriteBootstrapBody`; on `null` or zero injections + `writeHead` the original headers and write the collected bytes; otherwise `writeHead` with + `rewrittenHeaders` and the rewritten body. An upstream error before `writeHead` → 502; after + it → destroy the client socket (the client sees a truncated response, never a spliced one). +- `upgrade`: `tls.connect` to upstream with the same verification, write the request line and + raw headers, then `head`, and pipe both ways; destroy both on either error/close. +- Errors: 502 with an empty body; log `picker ` only. + +## picker-runtime.ts + +```ts +export type TunnelChoice = { kind: "intercept"; port: number } | { kind: "blind" }; +export interface PickerRuntime { + /** null → default behaviour. For claude.ai while the first refresh is pending, returns a promise that + * settles on `ready` or after PICKER_STARTUP_WAIT_MS (3 s), whichever is first; timeout or failure → blind. */ + selectTunnel(host: string, port: number): TunnelChoice | null | Promise; + refreshTrust(): Promise; + /** + * Re-read the persisted config (options.readConfig, default loadConfig), recompute the mode + * (resolveClaudeDesktopMode(fresh, observeClaudeDesktopMode(fresh))) and pickerDesired(fresh, mode), + * refresh trust, then ensureStarted() when armable, else stop terminating. Never clears the + * disarm latch. The 60 s interval calls this. selectTunnel only reads the cached result. + */ + refresh(): Promise; + /** + * Stop terminating claude.ai now, set the disarm latch and bump the arm generation. While latched, + * refresh() and ensureStarted() never arm. An ensureStarted() already in flight captured the old + * generation and must not arm when it completes. + */ + disarm(): void; + /** + * Owner-only: called by the picker controller while it holds its lock, at the end of an enable + * whose checks passed. Clears the latch and recomputes the decision with the isBusy() check + * bypassed, so the next CONNECT is intercepted even before the lock is released. + */ + rearm(): Promise; + ensureStarted(): Promise; // CA, leaf, listener, snapshot + /** First refresh() now, then the refresh interval; resolves with that first refresh. Called once after the CONNECT proxy binds. */ + start(): Promise; + readonly ready: Promise; + status(): PickerRuntimeStatus; // desired, supported, trust, listenerReady, effective, reason, models, snapshotAt + stop(): Promise; +} +export function createPickerRuntime(options: { config: OcxConfig; readConfig?: () => OcxConfig; isBusy?: () => boolean; configDir: string; loadRoutes: () => Promise; security?: SecurityRunner; platform?: NodeJS.Platform; now?: () => number; trustTtlMs?: number }): PickerRuntime; +export function pickerDesired(config: Pick, mode: ClaudeDesktopMode, platform?: NodeJS.Platform): boolean; +// darwin && claudeDesktopIntegrationEnabled(config) (src/codex/desired-state.ts:214) && mode === "first-party" +// && config.claudeCode?.intercept?.picker !== false +// `mode` is the observation-aware resolved mode (010): the runtime computes +// resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) at start and on every trust +// refresh and caches it; status, apply wiring and the CLI pass the same resolved mode. +``` + +`selectTunnel` returns `{ kind: "intercept", port: listener.port }` for `claude.ai:443` only when +the decision cached by the last `refresh()` says armed (desired from the persisted config, not +latched), the listener +is up, and the cached trust is `trusted` for the current CA fingerprint; `{ kind: "blind" }` for +`claude.ai` otherwise; `null` for every other host. Trust is refreshed on `ensureStarted`, on +`refreshTrust` and on a 60 s interval while desired; the interval never blocks the event loop. + +## connect-proxy.ts + +```diff + export interface ConnectProxyOptions { + interceptPort: number; + interceptHosts?: readonly string[]; ++ /** Per-connection override, consulted before `interceptHosts`; `null` keeps the default. */ ++ selectTunnel?: (host: string, port: number) => TunnelDecision | null | Promise; ++ // TunnelDecision = { kind: "intercept"; port: number } | { kind: "blind" } + dialUpstream?: (host: string, port: number) => Socket; + } +- const intercept = target.port === 443 && options.interceptHosts.includes(target.host); +- const upstream = intercept +- ? connect({ host: "127.0.0.1", port: options.interceptPort }) +- : options.dialUpstream(target.host, target.port); ++ // The client socket is already paused; an async decision (only claude.ai during the picker's ++ // first refresh, bounded by the runtime) keeps it paused until it settles; a rejection → blind. ++ const selected = await Promise.resolve(options.selectTunnel?.(target.host, target.port) ?? null) ++ .catch(() => ({ kind: "blind" as const })); ++ const choice = selected ++ ?? (target.port === 443 && options.interceptHosts.includes(target.host) ++ ? { kind: "intercept" as const, port: options.interceptPort } ++ : { kind: "blind" as const }); ++ const upstream = choice.kind === "intercept" ++ ? connect({ host: "127.0.0.1", port: choice.port }) ++ : options.dialUpstream(target.host, target.port); +``` + +Loopback 403 and non-CONNECT 405 stay before the choice. The header comment names claude.ai as the +only host that picker mode may terminate. + +Callback shape (audit wp3 r1, High). The diff above is the decision logic, not the literal code: +`onData` stays synchronous. After the 405/403 checks it calls `dialFor(choice)` in the same tick +when `selectTunnel` is absent or returns a non-promise, so the default path is unchanged; otherwise +it runs `void Promise.resolve(decision).catch(() => blind).then(dialFor)`. `dialFor` returns +without dialing when `socket.destroyed` (the client left while the decision was pending) and then +runs today's dial, connect-timeout, error and splice block unchanged. `handleConnection` takes a +`ResolvedConnectProxyOptions` type that adds the optional `selectTunnel`, and `startConnectProxy` +copies `options.selectTunnel` into the resolved object. + +## runtime.ts / lifecycle + +`startClaudeIntercept` gains `loadPickerRoutes?: () => Promise`; it creates the +picker runtime (`createPickerRuntime({ config: options.config, … })`), passes +`selectTunnel: picker.selectTunnel` to `startConnectProxy`, and, once the CONNECT proxy has bound, +starts the picker with `picker.start()`: an immediate `refresh()` (which calls `ensureStarted()` +when armable and fills the cached decision) followed by the 60 s refresh interval. `start()` +returns the first refresh's promise (`picker.ready`) so tests and status can await it. It stops the +runtime in `stop()`. `getClaudePickerRuntime()` mirrors +`getClaudeInterceptState()` for status and the wp4 routes. `claude-intercept-lifecycle.ts` passes a +loader built from `fetchAllModels`, `filterCatalogVisibleModels`, `desktopVisibleNativeSlugs` and +`nativeContextLimits` through dynamic imports (the same inputs `/api/sync` uses, +src/server/management/config-routes.ts:213–231). `startServer` stays synchronous; no line is added +to `src/server/index.ts`. + +Startup settlement (audit wp3 r1, High). `refresh()` catches its own failures (trust runner, CA, +listener bind, snapshot load), records them as `status().reason` and leaves the decision blind, so +`start()` resolves. `startClaudeIntercept` awaits `picker.start()` inside the same `try` that +guards `startConnectProxy`. The picker handle is nullable (`let picker: PickerRuntime | null = null`). If creating the picker +or `start()` still throws or rejects, it awaits `picker?.stop()` (picker listener and interval, only +when construction returned), then always `proxy.close()` and `listener.stop(true)`, before +rethrowing, so the lifecycle's catch never leaves a bound socket without a handle. `stop()` closes +the picker, then the proxy, then the listener. A `createPicker` option on +`StartClaudeInterceptOptions` is the test seam. + +## Tests (NEW unless noted; every new file registered in layout.json and test-layout-expected.json) + +| File | Cases (activation → observable) | +| --- | --- | +| `tests/claude-integration/claude-picker-ca.test.ts` | CA has critical nameConstraints permitting only claude.ai (parse extension bytes); leaf SAN is exactly claude.ai and chains (`X509Certificate.verify`); a TLS handshake through Bun (BoringSSL) with the picker CA as the only root accepts the claude.ai leaf and rejects a test-only leaf for `example.com` issued by the same CA; key file 0600; corrupt key regenerates; intercept CA has no nameConstraints (unchanged); a valid key-matching CA without the claude.ai constraint in the picker directory is regenerated with a new fingerprint | +| `tests/claude-integration/claude-picker-trust.test.ts` | fake runner receives the exact argv for find/verify/trust/untrust; a verified leaf whose SHA-1 record is missing or different → untrusted; exit 0/1/other → trusted/untrusted/unknown; non-darwin → unsupported without spawning | +| `tests/claude-integration/claude-picker-bootstrap.test.ts` | path matcher (both prefixes, org app_start, rejects others and POST); injection clones template, skips existing ids, drops fast_mode and version gates, leaves cowork and model_selector_state; gzip/br/deflate/identity round trip; malformed JSON, missing surface, unknown encoding and oversize → `null`; headers rewritten | +| `tests/claude-integration/claude-picker-models.test.ts` | routed alias `ocx-claude-xai--grok-4.7` and native alias; profile order/labels match the gateway render; anthropic/claude routes skipped; snapshot keeps last good on loader failure | +| `tests/claude-integration/claude-picker-listener.test.ts` | local https upstream (picker CA-issued fixture via the upstream seam): gzip body and two Set-Cookie headers pass byte-identical; SSE chunks arrive before the upstream ends; WebSocket upgrade echoes; bootstrap gets the injected entry with identity encoding; a bootstrap larger than the encoded cap (`maxEncodedBytes` seam set low, with the cap crossed in the middle of an upstream chunk) arrives byte-identical with its original headers; malformed bootstrap JSON arrives byte-identical; upstream with an untrusted cert → 502 | +| `tests/claude-integration/claude-picker-runtime.test.ts` | startup: with a pre-existing selected picker profile, trusted current CA, persisted first-party and intent on, the first CONNECT to claude.ai after `start()` resolves (`await picker.ready`) is `intercept`, with no timer tick; | +| (same file, continued) | a claude.ai CONNECT arriving while the first refresh is pending waits and is intercepted once `ready` resolves; with `ready` held past the 3 s bound it is blind; a snapshot persisted to `models.json` is injected into the first bootstrap after a restart before discovery completes; | +| (same file, continued) | selectTunnel: claude.ai blind until desired+trusted+listening, intercept after; trust loss flips back on refresh; non-claude hosts → null; non-darwin never intercepts | +| (same file, continued) | legacy install: owned first-party env in Claude Code settings, no saved `desktopMode`, picker intent unset → `pickerDesired` is true, so picker mode stays on by default across the upgrade; a `createPicker` that throws, and one whose `start()` rejects, each make `startClaudeIntercept` reject, and the proxy port binds again at once | +| `tests/claude-integration/claude-intercept-proxy.test.ts` (MODIFY) | a selectTunnel override is consulted per connection; an async decision keeps the client socket paused and pipelined bytes are delivered after it settles; a rejected decision is blind; loopback/405 refusals unchanged; a client that closes while the decision is pending causes no upstream dial | + +Verifier: `bun test` on the files above plus `tests/claude-integration/claude-intercept*.test.ts`, +`tests/server/claude-intercept-integration.test.ts`, `tests/lab/core-lab-boundary.test.ts`, +`tests/test-layout.test.ts`, `tests/test-layout-tooling.test.ts`, +`tests/ci-workflows/file-size-ratchet.test.ts`; `bun run typecheck`; `bun run structure:check`. + + +## Desktop egress proxy (B-phase amendment) + +Found while wiring: one CONNECT proxy cannot serve both clients. The Code tab's Claude Code trusts +only the intercept CA (`NODE_EXTRA_CA_CERTS`), so a claude.ai connection it opens through that proxy +would fail against the picker terminator; Desktop trusts only the login keychain, so its own +api.anthropic.com connections would fail against the intercept listener. Picker mode therefore gets +its own CONNECT proxy on `claudePickerProxyPort` (the intercept proxy port + 1, or − 1 at 65535), +started by `startClaudeIntercept` only when `loadPickerRoutes` is given (the server lifecycle always +passes `loadPickerRoutes`), with `interceptHosts: []` and `selectTunnel` from the picker runtime. On +it every host is blind except claude.ai while armed. The Claude Code proxy keeps its exact current +behaviour and gets no `selectTunnel`. `ClaudeInterceptState.pickerProxyPort` (null when unwired or +unbound) is what wp4 writes into `egressProxyUrl`. A bind failure on that port logs a warning, stops +the picker and leaves the intercept pair running. Tests: the Claude Code proxy never consults the +picker; the egress proxy blind-tunnels api.anthropic.com. + +## Audit record + +- wp3 round 1 (reviewer, FAIL, 3 High): persisted picker CA reload lacked constraint validation; the CONNECT diff awaited inside a synchronous callback and missed the options plumbing; picker startup failure could leave bound sockets. All three folded above. Round 2 GO-WITH-FIXES (1 High): a picker construction failure before assignment; folded as a nullable handle with a createPicker-throws test. Architect reflection ALIGNED, with the legacy first-party upgrade case added to the runtime tests. diff --git a/devlog/_plan/260924_claude_desktop_picker_mode/030_wp4_picker_activation.md b/devlog/_plan/260924_claude_desktop_picker_mode/030_wp4_picker_activation.md new file mode 100644 index 00000000000..bf25035e4f4 --- /dev/null +++ b/devlog/_plan/260924_claude_desktop_picker_mode/030_wp4_picker_activation.md @@ -0,0 +1,194 @@ +# 030 — wp4: picker activation (egress profile, controls, default-on in first-party) + +Consumes: D8–D11 in [000](000_plan.md) and the wp3 runtime. After this phase a first-party apply +on macOS turns picker mode on unless `claudeCode.intercept.picker === false`. + +The organising rule (D11): **while an opencodex server runs, every picker mutation — enable, +disable, transition cleanup — runs inside that server, in one controller, serialized by one +lock.** The CLI and the dashboard call its routes. The only thing a CLI does locally while a +server runs is the keychain trust step, because the password dialog belongs to the operator's +terminal session. With no server running, nothing can terminate claude.ai, so the CLI may remove +picker artifacts locally, and enabling is refused. + +> B-phase amendment from wp3 (see 020, "Desktop egress proxy"): Desktop's `egressProxyUrl` names +> `getClaudeInterceptState().pickerProxyPort`, the dedicated picker CONNECT proxy, never the Claude +> Code proxy port. Every "proxy bound" check below means `pickerProxyPort !== null`, and +> `applyDesktopPickerProfile({ proxyPort })` receives that port. + +## Files + +| Path | Change | +| --- | --- | +| `src/claude/desktop-3p-library.ts` | MODIFY: `DESKTOP_PICKER_ENTRY_NAME = "opencodex-picker"`; `isOwnedDesktopEntry` accepts it; gateway predicate does not | +| `src/claude/desktop-picker-profile.ts` | NEW: apply/remove/inspect the owned egress profile; state in `/claude-picker/profile-state.json` | +| `src/claude/desktop-picker.ts` | NEW: `DesktopPickerController` (server) with `enable`/`disable`/`status` under one async lock; `removeDesktopPickerArtifacts` (local cleanup when no server runs) | +| `src/claude/desktop-first-party.ts` | MODIFY: nothing picker-specific beyond exports used by the controller | +| `src/claude/intercept/runtime.ts` | MODIFY (audit wp4 pre-audit, Medium 5): create the controller next to the picker runtime (`isBusy: () => controller?.busy() ?? false`), expose `getClaudePickerController()`, clear it on stop and on a failed start | +| `src/cli/claude-desktop.ts` | MODIFY: first-party apply delegates to the server when one runs; gateway apply and removal ask the server to clean up; `picker on|off|status|trust` subcommand; help | +| `src/cli/ensure-desired-integrations.ts` | MODIFY: `ensureClaudeDesktopMatchesDesired` async and awaited by reconcile; durable-OFF picker cleanup | +| `tests/providers/xai/grok-lifecycle.test.ts` | MODIFY: source-boundary assertions (:61, :79) expect the async declaration and (:86) the awaited call | +| `src/server/management/agent-settings-routes.ts` | MODIFY: `GET/PUT /api/claude-desktop/picker`; status `firstParty.picker`; first-party apply/remove and gateway apply call the controller in process | +| `src/server/management/native-integration-routes.ts` | MODIFY: first-party enable/disable call the controller; `persistDesktopModeMarker` returns the committed subtree and callers adopt it | +| `src/server/management/config-routes.ts` | MODIFY: `/api/sync` Claude Desktop writer runs its post-discovery re-read, re-resolve and write inside `controller.transition` when a controller exists; race test in `claude-desktop-picker.test.ts` | +| `src/server/management/route-registry.ts` | MODIFY: register GET and PUT `/api/claude-desktop/picker` as standard entries (like `/api/claude-desktop/first-party-bindings`, :155) | +| `src/cli/capabilities.ts`, `skills/ocx/references/01_management_surface.md` (generated) | MODIFY: `claude-desktop.picker` capability for both routes; regenerate with `bun run skill:surface` | +| `gui/src/components/ClaudeDesktopPicker.tsx` + `gui/src/styles/claude-desktop-picker.css` | NEW: picker card (toggle, state, offline and restart notes) mounted in first-party mode | +| `gui/src/pages/ClaudeDesktop.tsx`, `gui/src/main.tsx` | MODIFY: mount the card; import CSS | +| `gui/src/i18n/*.ts` (10) | MODIFY: `claudeDesktop.picker.*` keys | +| `docs-site/src/content/docs/**/guides/claude-code.md` (8) | MODIFY: picker mode subsection (what it does, keychain prompt, offline dependency, how to turn off) | +| `structure/clients/claude-desktop.md`, `structure/gui-and-management-api.md` | MODIFY: picker contract, controller, routes, card | + +## desktop-picker-profile.ts + +```ts +export interface DesktopPickerProfileState { entryId: string; previousAppliedId: string | null } +export type DesktopPickerProfileInspection = + | { kind: "absent" } | { kind: "applied"; entryId: string; proxyUrl: string } + | { kind: "not_selected"; entryId: string } | { kind: "unsafe"; reason: string }; +export function pickerEgressUrl(proxyPort: number): string; // http://127.0.0.1: +export function applyDesktopPickerProfile(options: { proxyPort: number; configDir?: string } & Desktop3pConfigLibraryOptions): + { ok: true; changed: boolean; path: string } | { ok: false; reason: "gateway_selected" | "foreign_unreadable" | "write_failed" }; +export function removeDesktopPickerProfile(options: { configDir?: string } & Desktop3pConfigLibraryOptions): + { ok: true; changed: boolean } | { ok: false; reason: string; residualPaths?: string[] }; +export function inspectDesktopPickerProfile(options?: Desktop3pConfigLibraryOptions & { configDir?: string }): DesktopPickerProfileInspection; +``` + +Apply: refuse while an owned gateway row is selected; reuse the existing picker row or create one +(`randomUUID`, name `opencodex-picker`); write exactly `{"egressProxyUrl":"http://127.0.0.1:"}\n` +atomically; write `profile-state.json` with the current `appliedId` (unless it already is the picker +row); then set `appliedId` to the picker row. Remove: if the picker row is selected, reselect +`previousAppliedId` when that row still exists, else the owned standard row (created as `{}` like +`removeDesktop3pStandardPivot`); delete the picker profile and its `.bak`; drop the metadata row; +delete `profile-state.json`. Foreign rows and `_meta.json` keys other than `appliedId`/`entries` are +preserved; `_meta.json` never carries opencodex keys. + +## desktop-picker.ts + +```ts +export type DesktopPickerReason = "active" | "restart_required" | "unsupported_platform" | "not_first_party" + | "integration_off" | "disabled" | "proxy_unavailable" | "mode_not_committed" | "trust_pending" + | "trust_declined" | "profile_failed"; +export interface DesktopPickerStatus { desired: boolean; supported: boolean; trust: PickerTrustState; + profile: DesktopPickerProfileInspection["kind"]; listenerReady: boolean; effective: boolean; + reason: DesktopPickerReason; models: number; snapshotAt: number | null; lastBootstrapAt: number | null; + hint?: string; residual?: string[] } +export interface DesktopPickerController { + enable(options: { persist: boolean; context: "cli-trusted" | "server"; callerAddedTrust?: boolean }): Promise; + disable(options: { persist: boolean }): Promise; + /** + * Run a whole Desktop mode transition under the controller lock: callers stage slow work + * (model discovery) first, then inside `fn` do cleanup → mode/profile commit → optional enable + * with the lock-free inner helpers `ops.disableLocked` / `ops.enableLocked`. + */ + transition(fn: (ops: { disableLocked(o: { persist: boolean }): Promise; + enableLocked(o: { persist: boolean; context: "cli-trusted" | "server"; callerAddedTrust?: boolean }): Promise }) => Promise): Promise; + status(): Promise; + busy(): boolean; +} +export function createDesktopPickerController(deps: { runtime: PickerRuntime; readConfig: () => OcxConfig; + persistPreference: (value: boolean) => boolean; proxyPort: () => number | null; configDir: string; security?: SecurityRunner; + platform?: NodeJS.Platform }): DesktopPickerController; +export function removeDesktopPickerArtifacts(options: { configDir?: string; security?: SecurityRunner }): Promise<{ ok: boolean; residual?: string[] }>; +``` + +One promise-chain lock serializes `enable`, `disable` and `transition`. The runtime's periodic and +startup `refresh()` calls `isBusy()` and does not arm while the lock is held; the lock owner arms +through `runtime.rearm()`, which is owner-only and bypasses that check (it is only ever called from +inside the lock). Wiring in `src/claude/intercept/runtime.ts`: `let controller: DesktopPickerController +| null = null; const picker = createPickerRuntime({ …, isBusy: () => controller?.busy() ?? false }); +controller = createDesktopPickerController({ runtime: picker, … });` and `getClaudePickerController()` +exposes it to the management routes. + +**enable({ persist, context })**, inside the lock: +1. Re-read the persisted config (`readConfig`) and check the conditions that do not depend on the + preference: macOS, persisted first-party mode, Desktop intent on, proxy bound. A failure returns + its reason and writes nothing. Then, if `persist`, commit `claudeCode.intercept.picker = true` + (an explicit `picker on` from a false preference is allowed). +2. Re-read the persisted config again and check everything: macOS; persisted resolved mode is + first-party (`resolveClaudeDesktopMode(fresh, observeClaudeDesktopMode(fresh))`); Desktop + integration intent on; preference not false; the intercept proxy is bound + (`getClaudeInterceptState() !== null`). Failures → `unsupported_platform` / `mode_not_committed` / + `integration_off` / `disabled` / `proxy_unavailable`, nothing written. +3. `ensurePickerCa`, `issuePickerLeaf`, `inspectPickerTrust`. If not trusted: `context: "server"` tries + `trustPickerCa` once and re-inspects; still untrusted → `trust_pending` with + `hint: "ocx claude desktop picker trust"`. `context: "cli-trusted"` means the CLI already ran the + trust step; untrusted then → `trust_declined`. Record whether this attempt added trust. +4. Re-run the step-2 checks. +5. `applyDesktopPickerProfile({ proxyPort })`. +6. `runtime.rearm()` (clears the latch, refreshes, arms when everything holds) → `restart_required` + until a bootstrap has been served, then `active`. +Any failure after step 3 that follows trust added by this attempt calls `untrustPickerCa`. For a request with `callerAddedTrust: true`, every refusal or failure at any step (including the step-1 and step-2 checks) compensates, but only when the trusted certificate is the current picker CA (SHA-1 match) and the owned picker profile is not selected — a selected profile means an earlier successful enable still depends on that trust; if that fails too the +status carries `residual: ["trust"]`, `effective: false` and `hint: "ocx claude desktop picker off"`. + +**disable({ persist })**, inside the lock: `runtime.disarm()` (latch + generation bump) → if +`persist`, commit `claudeCode.intercept.picker = false` → `removeDesktopPickerProfile` → +`untrustPickerCa`. Failed steps are listed in `residual`; status is never "off" while the profile is +selected or trust remains. `persist: true` only for an explicit `picker off`; mode-transition +cleanup passes `false` and leaves the preference unset, so returning to first-party turns the +picker back on. + +## Callers + +Every server-side Desktop mode change goes through `runDesktopTransition(fn)` (audit wp4 pre-audit, High 2): with a controller it is `controller.transition(fn)`; with none (intercept disabled, client role, failed intercept or picker proxy bind) it calls `fn` with offline ops, where `disableLocked` runs `removeDesktopPickerArtifacts` (no runtime exists, so nothing can terminate claude.ai) and `enableLocked` returns `proxy_unavailable` without writing anything. Gateway apply, first-party removal, native enable and disable, and `/api/sync` therefore keep today's behaviour when no controller exists, plus leftover-artifact cleanup. Tests: with the intercept disabled, gateway apply and native disable succeed and remove a leftover picker row; with the picker proxy unbound, first-party apply succeeds and reports the picker as `proxy_unavailable`. + +| Caller | Runs where | Picker call | +| --- | --- | --- | +| Management first-party apply (`POST /api/claude-desktop/apply`) | server | the whole transition inside `runDesktopTransition`, in today's order (audit wp4 pre-audit, High 1): env write with its rollback, then gateway cleanup with today's partial-cleanup reporting, then the committed and adopted mode, then `enableLocked({ persist: false, context: "server" })` when the preference is not false; a partial apply whose mode write failed does not enable | +| `/api/sync` Claude Desktop writer (src/server/management/config-routes.ts:211–237) | server | discovery (`fetchAllModels`) staged first; then inside `runDesktopTransition`: re-read, re-resolve (010), and `writeDesktop3pConfig` only when the resolved mode is not first-party; no controller (intercept not running) → unchanged behaviour | +| Management first-party removal / gateway apply | server | model discovery staged first; then inside `runDesktopTransition`: `disableLocked({ persist: false })`, the gateway write or env removal, and the mode commit | +| Native enable (first-party branch) / native disable | server | inside `runDesktopTransition`: enable after `persistDesktopModeMarker` returns the committed subtree and it is adopted; disable before OFF cleanup and the intent commit | +| `GET/PUT /api/claude-desktop/picker` | server | `PUT { enabled, persist }` → `enable({ persist, context })` with `context: "cli-trusted"` when the request carries `trustedLocally: true` (sent only by the CLI after its trust step), else `"server"`; or `disable({ persist })` | +| CLI `ocx claude desktop apply --first-party` | CLI | on the local hub path with a live proxy, the same branch where gateway apply already delegates (src/cli/claude-desktop.ts:326), delegate to `POST /api/claude-desktop/apply { mode: "first-party" }` (audit wp4 pre-audit, High 3: the connected-client branch before it stays unchanged and never touches the picker); without one: apply locally as today and report the picker as `proxy_unavailable` | +| CLI gateway apply / first-party removal | CLI | with a live proxy: the delegated server apply performs the disable; without one: `removeDesktopPickerArtifacts` locally | +| CLI `picker on` | CLI | requires a live proxy (else `proxy_unavailable`); `PUT { enabled: true, persist: true }`; if the answer is `trust_pending`, run `picker trust` below and repeat the PUT with `trustedLocally: true` | +| CLI `picker trust` | CLI | local `ensurePickerCa` read + `trustPickerCa` (operator's dialog), recording whether this run added trust; then `PUT { enabled: true, persist: false, trustedLocally: true, callerAddedTrust }`. Compensation for trust the CLI added is done by the server inside the lock: enable treats `callerAddedTrust: true` like trust added by the attempt itself, so any refusal or failure after its trust check untrusts it (residual reported if that fails), and success keeps it. The CLI compensates locally only when the PUT could not be delivered at all (connection refused: no server, so nothing can race). A timeout or lost response is ambiguous: the CLI does not touch trust and prints "state unknown — run `ocx claude desktop picker status`" | +| CLI `picker off` | CLI | with a live proxy: `PUT { enabled: false, persist: true }`; without: persist false locally, then `removeDesktopPickerArtifacts`; prints "Fully quit and reopen Claude Desktop" | +| `ensureClaudeDesktopMatchesDesired` durable OFF | CLI / update hook | becomes async and is awaited by `reconcileEnsureDesiredIntegrations` (:188, :199); with a live proxy `PUT { enabled: false, persist: false }`, else `removeDesktopPickerArtifacts` | + +Auth: GET and PUT `/api/claude-desktop/picker` are standard registry entries like +`/api/claude-desktop/first-party-bindings` (route-registry.ts:155), so both the CLI admin token +(`runtimeRequest`, src/cli/runtime-api.ts:140) and the dashboard gui-session are accepted; the +`claude-desktop.picker` capability names both routes, as `tests/server/management-route-registry.test.ts` +requires. `trustedLocally` is only a wording hint for the trust outcome; it never skips a check. + +Server startup: the runtime's first `refresh()` arms only when every piece already exists +(persisted first-party, intent on, preference not false, trust for the current CA, profile selected +with the current proxy URL, listener up). It never trusts or writes a profile; status reports what is +missing with the `ocx claude desktop picker on` hint. + +Native persistence: `persistDesktopModeMarker` (native-integration-routes.ts:656) returns +`{ ok: true; claudeCode } | { ok: false }`; callers adopt with `adoptPersistedClaudeCode` +(src/config/live-reconcile.ts:123), fixing today's unadopted marker. The runtime itself decides from +persisted reads, so adoption is for status and later whole-config saves. + +## GUI + +`ClaudeDesktopPicker` card (first-party only): title, one-line explanation, toggle +(`PUT { enabled, persist: true }`), state line from `reason` (active / restart Desktop / waiting for +the keychain step with the `hint` command / declined / proxy not running / unsupported on this OS), +model count, and a fixed note: "While picker mode is on, Claude Desktop reaches the network through +OpenCodex. If OpenCodex stops, Desktop is offline until it restarts or picker mode is turned off." +Keys `claudeDesktop.picker.{title,hint,toggle,state.active,state.restart,state.trustPending, +state.trustDeclined,state.proxyUnavailable,state.unsupported,state.notFirstParty,state.profileFailed, +models,offlineNote}` in all ten catalogs. + +## Tests (NEW files registered in layout.json + test-layout-expected.json) + +| File | Cases | +| --- | --- | +| `tests/claude-integration/claude-desktop-picker-profile.test.ts` | apply creates the row, writes only egressProxyUrl, records previousAppliedId, selects it; re-apply idempotent; remove restores the previous selection, falls back to a standard row when it vanished, keeps foreign rows; gateway selected → refused; metadata write failure rolls back; gateway removal (`removeDesktop3pStandardPivot`) leaves the picker row alone | +| `tests/claude-integration/claude-desktop-picker.test.ts` | enable order CA → trust → recheck → profile → rearm (recorded); preference false → `enable({ persist: true })` → armed; `enable({ persist: true })` with a failed independent condition (mode, intent, proxy, platform) leaves the preference unchanged; each failed precondition (mode not committed, intent off, proxy unbound, non-darwin) → its reason, nothing written; server-context trust failure → trust_pending + hint, no profile; cli-trusted but untrusted → trust_declined; newly added trust removed when the step-4 recheck fails or the profile write fails, pre-existing trust kept, failed untrust → residual ["trust"] and not effective; disable order disarm → persist (only when asked) → remove → untrust; **serialization**: an enable held pending, then a disable queued → after both, the runtime is disarmed and the profile absent; a disable held pending, then an enable queued → the enable runs after cleanup and re-arms only if its checks pass; an enable requested while a gateway `transition` is between cleanup and mode commit waits and then fails `mode_not_committed`; after a successful enable the CONNECT decision is `intercept` both while the lock is still held (owner `rearm()`) and after release, while a periodic `refresh()` during a pending disable does not arm; a `/api/sync` whose discovery resolves while a first-party `transition` holds the lock waits and then writes nothing; a server enable with `callerAddedTrust: true` that fails its recheck untrusts; one refused at the step-1 checks (for example `integration_off`) also untrusts; one refused while the owned profile is already selected from an earlier enable keeps the trust | +| `tests/claude-integration/claude-picker-runtime.test.ts` (wp3 file, extended) | `disarm()` makes the next claude.ai CONNECT blind while the cached mode is still first-party; periodic `refresh()` never clears the latch and skips arming while the controller is busy; a disarm during an in-flight `ensureStarted()` stays disarmed; startup refresh arms only with every piece present | +| `tests/claude-integration/claude-desktop-picker-routes.test.ts` | `PUT { enabled:false, persist:true }` disarms and persists false; `PUT { enabled:true, persist:true }` from a false preference re-arms; both routes accept the admin token; CONNECT decision (`selectTunnel("claude.ai", 443)`, trust and listener faked) is `intercept` after management and native first-party apply from an explicit `desktopMode: "gateway"` marker and after native OFF→ON on a server whose runtime started with the integration OFF, and `blind` after native OFF and after management gateway apply even when a periodic refresh runs | +| `tests/claude-integration/claude-desktop-cli.test.ts` (MODIFY) | `picker on|off|status|trust` parsing and usage errors; `picker trust` sends `callerAddedTrust` truthfully; a refused PUT leaves compensation to the server (fake server untrusts under its lock); connection refused before sending → local untrust of trust this run added; a PUT timeout while the server enable is held before profile selection → the CLI leaves trust alone and the later successful enable keeps it; `on` without a live proxy → proxy_unavailable; `off` offline removes artifacts locally; CLI first-party apply with a live proxy delegates to `POST /api/claude-desktop/apply { mode: "first-party" }` (fake runtimeRequest), also from a durable-OFF start | +| `tests/claude-integration/claude-desktop-first-party.test.ts` (MODIFY) | first-party apply (management, native) enables the picker by default and not when `intercept.picker === false` or when the mode write failed; gateway apply disables it without writing the preference; `ensureClaudeDesktopMatchesDesired` is awaited and its OFF branch removes a selected picker row (live: PUT; offline: local) | +| `tests/server/management-route-registry.test.ts` (MODIFY) | picker routes registered as standard entries and named by the capability | +| `tests/ci-workflows/skill-ocx.test.ts` | surface map current | + +Verifier: the files above plus `tests/providers/xai/grok-lifecycle.test.ts`, `bun run typecheck`, +`bun run skill:surface:check`, `bun run structure:check`, `bun run lint:gui`, `bun run build:gui`, +`cd gui && bun test --isolate tests`. + +## Audit record + +- wp4 pre-audit (reviewer, FAIL: 3 High, 2 Medium) folded: first-party apply keeps its env-first order inside the transition; runDesktopTransition defines the no-controller path; CLI delegation is limited to the local hub branch; callerAddedTrust is in the enable signatures and forwarded by the route (test in claude-desktop-picker-routes); runtime.ts is in the file inventory. Round 2 GO-WITH-FIXES (2): the controller gets a late-bound proxyPort accessor; DesktopPickerStatus carries lastBootstrapAt. diff --git a/devlog/_plan/260924_claude_desktop_picker_mode/040_wp5_live_proof_pr_merge.md b/devlog/_plan/260924_claude_desktop_picker_mode/040_wp5_live_proof_pr_merge.md new file mode 100644 index 00000000000..ec35fd1ed34 --- /dev/null +++ b/devlog/_plan/260924_claude_desktop_picker_mode/040_wp5_live_proof_pr_merge.md @@ -0,0 +1,61 @@ +# 040 — wp5: live proof, PR, CI, squash merge + +## Live proof (macOS, this machine) + +The operator's service runs the `dev` checkout. The proof runs this branch as that service only +for the proof window, then returns it to `dev` if the PR does not merge. + +1. Commit everything; record the branch head. +2. In `/Users/jun/Developer/new/700_projects/opencodex` (clean `dev`): `git fetch origin + codex/claude-desktop-picker-mode` and `git switch --detach FETCH_HEAD`; `bun run build:gui`; + `ocx service restart`; verify `/healthz`, PID path, listener. +3. `ocx claude desktop apply --first-party` (the operator's saved mode is already first-party): + the CLI delegates to the running service; expect the risk warning and a picker status. If the + service could not raise the keychain dialog the status is `trust_pending`: run + `ocx claude desktop picker trust` in the terminal, where the macOS password dialog is the + operator's step (NEEDS_HUMAN). Record which path showed the dialog (service or CLI). Then + `ocx claude desktop picker status` → `active` or `restart_required`. +3b. Name-constraint check on the Apple path: issue an ephemeral leaf for `example.com` from the + picker CA into `/private/tmp` (never into the config directory) and run + `security verify-cert -q -L -c -p ssl -n example.com -k `; record the + exit code. Non-zero → the PR may state that macOS enforces the constraint; zero → the PR states + that only the key's confidentiality protects other names on this OS. Delete the ephemeral leaf. +4. Quit and reopen Claude Desktop (Computer Use). Check `main.log` for the egress pin line pointing + at the picker proxy port (`pickerProxyPort`, the intercept port + 1, never the Claude Code proxy + port), record the applied profile's exact `egressProxyUrl`, and find the picker log line + `picker GET bootstrap 200`. +5. Code tab → model picker: screenshot showing opencodex models by name next to Anthropic's. +6. Pick one (e.g. the xai Grok route), send "Reply with exactly: OCX-PICKER-PROBE. Do not use any + tools." Screenshot the reply; `usage.jsonl` must show the routed provider on the `messages` + ingress within the minute. +7. Connectivity: Chat tab still loads (screenshot), a claude.ai WebSocket session (Code session + list refresh) still works. +8. Dashboard screenshots: mode selector with the gateway default badge and the first-party risk + callout; picker card active. +9. If the operator declines the dialog: record NEEDS_HUMAN for criterion c-8, keep the rest. +10. After the proof, if the PR has not merged, roll back in this order while the branch service is + still running: `ocx claude desktop picker off` (branch CLI), verify the `opencodex-picker` row is + gone from `_meta.json`, `security find-certificate -a -Z -c "opencodex Claude Desktop Picker CA"` + finds nothing in the login keychain, and `claudeCode.intercept.picker` is false; fully quit and + reopen Desktop and confirm its log shows no egress pin; only then return the service checkout to + `dev` (`git switch dev`, rebuild GUI, restart). Record each result as rollback proof. If the PR + has merged, fast-forward `dev` and restart instead; picker stays under the new code's control. + +## PR + +- Title: `feat(claude): gateway by default, first-party risk warning, and Desktop picker mode`. +- Body per `.github/PULL_REQUEST_TEMPLATE.md`: Summary (problem, behavior before/after), screenshots + uploaded to the `pr-assets` branch and linked by commit SHA, Verification (commands + results, + what was not run locally and why), Checklist. No mention of third-party projects. +- Security review: an independent gpt-6-sol reviewer reads the full diff against the untracked + threat model at `.tmp/260924_claude_desktop_picker_mode/threat_model.md`; findings are folded + before merge and summarized in the PR. + +## CI and merge + +- Exact-head check-runs for the PR head (aggregate `ci` and its producers) must be completed and + successful; skipped path-gated jobs are listed as skipped, not as passed. +- Merge-result preflight: `git merge-tree --write-tree origin/dev HEAD`, file-size preflight on that + tree (offenders 0), typecheck and focused suites on a worktree of the merge result. +- `gh pr merge --squash --admin --match-head-commit ` (user authorized merge). +- Verify `origin/dev` tip is the squash commit whose tree equals the verified merge-result tree. diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 3d2eb57b9b5..1b6aad466cd 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -121,32 +121,60 @@ Sur macOS, l'intégration automatique (`claudeCode.systemEnv`) suit la même ré `claude` lancée sans passer par `ocx` se comporte donc de la même manière. Le fichier d'environnement est un instantané actualisé au démarrage du proxy ou lors de l'enregistrement des paramètres, tandis que `ocx claude` effectue toujours une résolution immédiate. -## Modes Claude Desktop : first-party (par défaut) et passerelle - -Claude Desktop utilise OpenCodex dans l'un de deux modes mutuellement exclusifs. Choisissez-le dans -**Claude → Bureau → Mode de connexion** du tableau de bord ou avec -`ocx claude desktop apply --first-party|--gateway`. - -- **First-party (par défaut)** : Desktop lui-même n'est pas reconfiguré. La connexion claude.ai, - l'onglet Chat, les connecteurs et le contrôle à distance continuent de fonctionner. OpenCodex - n'écrit que deux valeurs dans le bloc `env` de `~/.claude/settings.json` : - `HTTPS_PROXY=http://127.0.0.1:` et - `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem`. Seuls Claude Code lancé par Desktop - pour l'onglet Code (sous-agents compris) et la CLI `claude` du terminal les lisent et passent par le - proxy d'interception local ; seuls `POST /v1/messages` et `count_tokens` sont traités par OpenCodex, - les autres chemins de `api.anthropic.com` sont relayés tels quels vers Anthropic. L'AC n'est jamais - installée dans le magasin de confiance du système. -- **Passerelle (tiers)** : l'ancien mode ; le profil ci-dessous fait basculer toute l'application sur - OpenCodex comme passerelle. Sélectionnez-le explicitement (`--gateway`, ou les anciens - `--static`/`--hybrid`/`--discovery-only`). - -Le mode est enregistré dans `claudeCode.desktopMode`. Les installations ayant déjà appliqué un profil -passerelle le conservent après mise à jour ; seules les nouvelles installations démarrent en -first-party. Changer de mode supprime la configuration de l'autre mode (uniquement les valeurs -écrites par OpenCodex) ; un `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` étranger (proxy d'entreprise) n'est -jamais écrasé et l'application est refusée. Quittez complètement Desktop puis rouvrez-le après un -changement. Les détails et la compatibilité de la CLI Claude Code sont décrits dans la documentation -anglaise. +## Modes Claude Desktop : passerelle (par défaut) et first-party + +Choisissez le mode dans **Claude → Bureau → Mode de connexion** ou avec +`ocx claude desktop apply --first-party|--gateway`. Les deux modes sont exclusifs. + +### Passerelle (par défaut) + +Une nouvelle installation applique par défaut le profil passerelle : toute l'application utilise +OpenCodex, y compris l'onglet Chat. Les fonctions réservées à claude.ai ne sont alors pas disponibles. +Les anciens indicateurs `--static`, `--hybrid` et `--discovery-only` sélectionnent aussi ce mode. + +### First-party (sur demande) + +:::caution[Risque pour le compte] +Le mode first-party fait passer le trafic de votre abonnement Claude par un proxy local d'interception. +Anthropic peut y voir une violation de ses conditions et suspendre votre compte. La passerelle est +le choix par défaut ; n'activez first-party que si vous acceptez ce risque. +::: + +Desktop reste connecté à claude.ai : Chat, les connecteurs et le contrôle à distance continuent de +fonctionner. OpenCodex écrit seulement `HTTPS_PROXY` et `NODE_EXTRA_CA_CERTS` dans le bloc `env` de +`~/.claude/settings.json` (ou le répertoire `CLAUDE_CONFIG_DIR`). Le Claude Code lancé par l'onglet +Code, ses sous-agents et la CLI `claude` passent par le proxy local ; les autres chemins de +`api.anthropic.com` sont relayés vers Anthropic. L'AC n'est jamais installée dans le magasin de +confiance du système ; seuls les processus Node qui lisent `NODE_EXTRA_CA_CERTS` lui font confiance. + +Le mode est enregistré dans `claudeCode.desktopMode`. Une installation ayant déjà appliqué le mode +first-party, même avant cette version, le conserve ; un profil passerelle existant reste aussi en +passerelle. Sans mode explicite, le profil passerelle sélectionné et détenu par OpenCodex, puis son +empreinte enregistrée, priment sur les réglages first-party détenus dans `settings.json` ; sans ces +indices, le mode est passerelle. La synchronisation du catalogue et la mise à jour de la liste des +modèles n'écrivent jamais un profil passerelle sur une installation first-party. Si +`claudeCode.intercept.enabled: false`, l'application d'un mode first-party existant est refusée +(`intercept_disabled`) ; une nouvelle installation applique la passerelle. Un proxy d'entreprise +étranger n'est pas écrasé. Quittez complètement Desktop puis rouvrez-le après un changement. + +### Mode picker : modèles opencodex dans le sélecteur Code first-party + +Le mode picker fait partie du mode first-party. Sur macOS, il est activé par défaut lorsque first-party +est sélectionné, sauf si `claudeCode.intercept.picker: false` est défini. Il modifie le sélecteur de +modèles de l'onglet Code de Desktop first-party pour y afficher les modèles opencodex disponibles par +leur nom. Lors de la première activation, macOS peut demander l'autorisation d'une autorité de certification +locale dans le trousseau de connexion. Cette autorité est limitée à `claude.ai` et à ses sous-domaines ; +la demande correspond à cette étape de confiance unique pour cette AC locale. + +Lorsque le mode picker est actif, Claude Desktop accède au réseau par OpenCodex. Si OpenCodex s'arrête, +Desktop reste hors ligne jusqu'à son redémarrage complet ou jusqu'à la désactivation du mode picker. +Consultez l'état avec `ocx claude desktop picker status`, relancez l'étape de confiance avec +`ocx claude desktop picker trust`, ou désactivez-le avec `ocx claude desktop picker off`. Le tableau +de bord propose le même interrupteur dans **Claude → Bureau**. Après la sélection du profil picker, +quittez complètement puis rouvrez Claude Desktop. + +Le mode picker fait partie de first-party : le [risque pour le compte du mode first-party](#first-party-sur-demande) +s'applique donc aussi à ce mode. ### Utiliser les modèles opencodex depuis l'onglet Code de Desktop (associations first-party) @@ -183,6 +211,8 @@ Desktop n'a pas besoin d'être relancé. ## Profil Claude Desktop (mode passerelle) +Le profil ci-dessous n'est écrit que lorsque le mode passerelle est sélectionné. + Claude Desktop utilise un profil distinct de Claude Code. Ouvrez **Claude → Bureau** dans le tableau de bord afin de placer chaque route disponible dans l'une des quatre familles : Opus, Fable, Sonnet ou Haiku. Dans un nouveau profil, toutes les routes appartiennent initialement à Opus. La première route Opus devient la route globale @@ -207,7 +237,8 @@ ocx claude desktop export ocx claude desktop import [--apply] ``` -`ocx claude desktop` et `apply` écrivent tous deux le profil actuel dans Claude Desktop. `show` affiche un +`ocx claude desktop` et `apply` appliquent le mode sélectionné : first-party écrit l'environnement +du proxy Claude Code, tandis que passerelle écrit le profil Desktop. `show` affiche un résumé lisible ; ajoutez `--json` pour les scripts. `export -` écrit le document JSON versionné sur la sortie standard. L'importation valide le fichier entier avant tout enregistrement : un fichier invalide laisse donc le profil actuel inchangé. Ajoutez `--apply` pour écrire immédiatement un profil importé valide dans Claude Desktop. Utilisez `none` uniquement diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index b7485122739..b95e3a6682d 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -147,13 +147,28 @@ the proxy starts or you save settings, while `ocx claude` always resolves live. ## System environment integration (macOS) -## Claude Desktop modes: first-party (default) and gateway +## Claude Desktop modes: gateway (default) and first-party Claude Desktop can use OpenCodex in one of two mutually exclusive modes. Pick it in **Claude → Desktop → Connection mode** in the dashboard or with `ocx claude desktop apply --first-party|--gateway`. -**First-party** is the default for new installs. Desktop itself is not reconfigured: it stays +### Gateway (default) + +New installs use **gateway** by default. Its third-party profile, described below, switches the +whole app to OpenCodex as its inference gateway. Chat runs locally through OpenCodex and the +claude.ai-only features are unavailable. The legacy `--static` / `--hybrid` / +`--discovery-only` flags also select gateway. + +### First-party (opt-in) + +:::caution[Account risk] +First-party mode sends your Claude subscription traffic through a local interception proxy. +Anthropic may treat this as a violation of its terms and suspend the account. Gateway is the +default; choose first-party only if you accept that risk. +::: + +Desktop itself is not reconfigured: it stays signed in to claude.ai, and the Chat tab, connectors, cloud sessions and remote control keep working. OpenCodex only writes two variables into the `env` block of `~/.claude/settings.json` (honoured by `CLAUDE_CONFIG_DIR`): @@ -178,13 +193,12 @@ usage) is relayed byte-for-byte to Anthropic, and unrelated hosts are tunnelled subscription login keeps working. Existing OpenCodex features — `modelMap`, aliases, native passthrough, sidecars, auto-context — apply the same way they do for `ocx claude`. -**Gateway** is the previous third-party mode: the profile described in the next section switches -the whole app to OpenCodex as its inference gateway. Chat runs locally through OpenCodex and the -claude.ai-only features are unavailable. Select it explicitly (`--gateway`, the dashboard -selector, or the legacy `--static` / `--hybrid` / `--discovery-only` flags, which imply it). - -Mode is persisted as `claudeCode.desktopMode`. Installs that already applied a gateway profile -keep gateway after updating; nothing is switched silently. Switching first applies the replacement, +Mode is persisted as `claudeCode.desktopMode`. Installs that already applied either mode retain it, +including first-party installs from before the mode was persisted. An explicit mode takes priority; +otherwise an owned selected gateway row, an applied gateway fingerprint, or owned first-party +settings in `~/.claude/settings.json` determine the existing mode before the gateway default. +A catalog sync or roster update never writes a gateway profile over a resolved first-party install. +Switching first applies the replacement, then removes the other mode's configuration (only values OpenCodex wrote — a foreign `HTTPS_PROXY` or `NODE_EXTRA_CA_CERTS`, for example a corporate proxy, is never overwritten and the apply is refused instead). A failed replacement preserves the previous connection. If retiring the old @@ -193,9 +207,27 @@ resolve that error before restarting Desktop. A committed gateway keeps its save marker even when first-party settings cleanup fails. Fully quit and reopen Desktop after a successful switch. `ocx ensure` refreshes a stale first-party env when the integration is ON and removes it when OFF. Set `claudeCode.intercept.enabled: false` to disable the proxy entirely; first-party then cannot be -applied and an implicit apply falls back to gateway. On a connected client the proxy runs on the +applied. An apply for an existing first-party install is refused when the intercept is disabled; +new installs apply gateway. On a connected client the proxy runs on the hub, so `ocx claude desktop apply` there uses the gateway profile. +### Picker mode: opencodex models in the first-party Code-tab picker + +Picker mode is part of first-party mode. On macOS it is on by default when first-party is selected, +unless `claudeCode.intercept.picker: false` is set. It changes the first-party Desktop Code-tab picker +so it lists available opencodex models by name. The first time it is enabled, macOS may ask you to +trust a local certificate authority in the login keychain. That authority is constrained to `claude.ai` +and its subdomains; the prompt is a one-time trust step for this local CA. + +While picker mode is on, Claude Desktop reaches the network through OpenCodex. If OpenCodex stops, +Desktop is offline until you fully restart it or turn picker mode off. Check the state with +`ocx claude desktop picker status`; use `ocx claude desktop picker trust` to repeat the trust step, +or turn it off with `ocx claude desktop picker off`. The dashboard has the same picker toggle under +**Claude → Desktop**. After the picker profile is selected, fully quit and reopen Claude Desktop. + +Picker mode is part of first-party mode, so the [first-party account risk](#first-party-opt-in) +applies to it as well. + ### Use opencodex models from the Desktop Code tab (first-party bindings) In first-party mode the Code tab's model picker belongs to claude.ai: its rows (Opus 5.5, @@ -757,7 +789,7 @@ Claude debug immediately clears the ring. The dashboard sidebar has a dedicated **Claude** page (below API) and a **Claude ON** toggle (label intentionally identical in every language). The page shows: -- Desktop tab: **Connection mode** selector — first-party (default) or gateway — with the +- Desktop tab: **Connection mode** selector — gateway (default) or first-party — with the running proxy port in first-party mode. Only **Save & apply** switches modes; **Save** alone stores the gateway profile lanes for a later gateway apply and leaves the current mode as is - Inbound kill switch (enabled toggle) diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 828a5ea948b..2b869665203 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -97,28 +97,57 @@ hook を削除します。Claude Desktop は独立した profile を使用し、 `claudeCode.nativePassthrough: false` でオフにでき、`claudeCode.anthropicBaseUrl` で別のアドレスを 指定できます。 -## Claude Desktop のモード: 1P(デフォルト)とゲートウェイ - -Claude Desktop は排他的な 2 つのモードのどちらかで OpenCodex を使います。ダッシュボードの -**Claude → Desktop → 接続モード**、または `ocx claude desktop apply --first-party|--gateway` で選びます。 - -- **1P(ファーストパーティ、デフォルト)**: Desktop 本体は変更しません。claude.ai のログイン、 - チャットタブ、コネクタ、リモート操作はそのまま動きます。OpenCodex は `~/.claude/settings.json` の - `env` に `HTTPS_PROXY=http://127.0.0.1:<公開ポート+100>` と - `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem` の 2 つだけを書きます。Desktop が - Code タブ用に起動する Claude Code(サブエージェント含む)とターミナルの `claude` CLI だけがこれを読み、 - ローカルのインターセプトプロキシを通ります。`POST /v1/messages` と `count_tokens` のみ OpenCodex が - 処理し、他の `api.anthropic.com` パスはそのまま Anthropic に中継されます。CA は OS の信頼ストアには - インストールされません。 -- **ゲートウェイ(3P)**: 従来の方式で、下記のプロファイルによりアプリ全体が OpenCodex を - ゲートウェイとして使います。`--gateway`(または従来の `--static`/`--hybrid`/`--discovery-only`)で - 明示的に選びます。 - -モードは `claudeCode.desktopMode` に保存されます。すでにゲートウェイプロファイルを適用済みの環境は -更新後もゲートウェイのままで、新規インストールだけが 1P になります。切り替えると他方のモードの設定 -(OpenCodex が書いた値のみ)が削除され、社内プロキシなど外部の `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` -は上書きせず適用を拒否します。切り替え後は Desktop を完全に終了して再起動してください。詳細と -Claude Code CLI 互換性は英語版ドキュメントを参照してください。 +## Claude Desktop のモード: ゲートウェイ(デフォルト)と 1P + +ダッシュボードの **Claude → Desktop → 接続モード**、または +`ocx claude desktop apply --first-party|--gateway` で排他的なモードを選びます。 + +### ゲートウェイ(デフォルト) + +新規インストールではゲートウェイプロファイルが適用され、Chat タブを含むアプリ全体が +OpenCodex を使います。claude.ai 専用の機能は利用できません。従来の `--static`、`--hybrid`、 +`--discovery-only` もゲートウェイを選びます。 + +### 1P(オプトイン) + +:::caution[アカウントのリスク] +1P モードでは Claude サブスクリプションの通信がローカルのインターセプトプロキシを通ります。 +Anthropic がこれを利用規約違反とみなし、アカウントを停止する可能性があります。デフォルトは +ゲートウェイです。このリスクを受け入れる場合にのみ 1P を選んでください。 +::: + +Desktop 本体は claude.ai に接続したままで、Chat、コネクタ、リモート操作も使えます。 +OpenCodex が書くのは `~/.claude/settings.json`(`CLAUDE_CONFIG_DIR` に対応)の `env` にある +`HTTPS_PROXY` と `NODE_EXTRA_CA_CERTS` だけです。Code タブが起動する Claude Code、 +サブエージェント、ターミナルの `claude` CLI がローカルプロキシを通ります。その他の +`api.anthropic.com` パスは Anthropic に中継されます。CA は OS の信頼ストアに入れず、 +`NODE_EXTRA_CA_CERTS` を読む Node プロセスだけが信頼します。 + +モードは `claudeCode.desktopMode` に保存されます。明示的に、またはこの更新以前に 1P を +適用した環境は 1P を維持し、既存のゲートウェイも維持します。明示設定がない場合は、 +OpenCodex 所有の選択済みゲートウェイ行、保存済みのゲートウェイ指紋、所有する +`settings.json` の 1P 設定の順に判定し、証拠がなければゲートウェイです。 +カタログ同期やモデル一覧の更新が 1P 環境にゲートウェイプロファイルを書くことはありません。 +`claudeCode.intercept.enabled: false` なら既存の 1P 環境での適用は +`intercept_disabled` で拒否され、新規環境はゲートウェイを適用します。外部の企業プロキシ +設定は上書きしません。モード変更後は Desktop を完全に終了して開き直してください。 + +### Picker モード: 1P の Code タブで opencodex モデルを表示する + +Picker モードは 1P モードの一部です。macOS で 1P を選ぶとデフォルトで有効になりますが、 +`claudeCode.intercept.picker: false` を設定した場合は無効です。1P の Desktop の Code タブにある +モデルピッカーを書き換え、利用できる opencodex モデルを名前付きで表示します。初回の有効化時は、 +macOS がログインキーチェーン内のローカル証明書認証局を信頼するよう求めることがあります。この認証局の +制約は `claude.ai` とそのサブドメインに限られ、このダイアログはこのローカル CA に対する一度だけの +信頼操作です。 + +Picker モードが有効な間、Claude Desktop のネットワークは OpenCodex を経由します。OpenCodex が停止 +すると、Picker モードをオフにするか Desktop を完全に再起動するまで Desktop はオフラインになります。 +状態は `ocx claude desktop picker status`、信頼操作は `ocx claude desktop picker trust` で確認・実行できます。 +`ocx claude desktop picker off` またはダッシュボードの **Claude → Desktop** の切り替えでオフにできます。 +Picker プロファイルを選択した後は、Claude Desktop を完全に終了して開き直してください。 + +Picker モードは 1P の一部なので、[1P のアカウントリスク](#1pオプトイン)も同じように適用されます。 ### Code タブで opencodex モデルを使う(1P バインディング) diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index b15f4708d8d..5fb668581c4 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -120,26 +120,56 @@ hook을 제거해요. Claude Desktop은 별도 profile을 사용하며 shell hoo `claudeCode.nativePassthrough: false`로 끌 수 있고, `claudeCode.anthropicBaseUrl`로 다른 주소를 지정할 수 있어요. -## Claude Desktop 모드: 1P(기본값)와 게이트웨이 - -Claude Desktop은 서로 배타적인 두 모드 중 하나로 OpenCodex를 사용해요. 대시보드의 -**Claude → Desktop → 연결 모드** 또는 `ocx claude desktop apply --first-party|--gateway`로 선택합니다. - -- **1P(퍼스트파티, 기본값)**: Desktop 자체는 건드리지 않아요. claude.ai 로그인, 채팅 탭, 커넥터, - 원격 제어가 그대로 유지됩니다. OpenCodex는 `~/.claude/settings.json`의 `env`에 - `HTTPS_PROXY=http://127.0.0.1:<공개 포트+100>`과 `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem` - 두 값만 씁니다. Desktop이 Code 탭용으로 실행하는 Claude Code(서브에이전트 포함)와 터미널의 - `claude` CLI만 이 값을 읽어 로컬 인터셉트 프록시를 거치고, `POST /v1/messages`·`count_tokens`만 - OpenCodex가 처리하며 나머지 `api.anthropic.com` 경로는 그대로 Anthropic으로 전달돼요. CA는 OS - 신뢰 저장소에 설치되지 않습니다. -- **게이트웨이(3P)**: 기존 방식으로, 아래 프로필을 써서 앱 전체가 OpenCodex를 게이트웨이로 - 사용해요. `--gateway`(또는 기존 `--static`/`--hybrid`/`--discovery-only`)로 명시적으로 선택합니다. - -모드는 `claudeCode.desktopMode`에 저장돼요. 이미 게이트웨이 프로필을 적용한 설치는 업데이트 후에도 -게이트웨이를 유지하고, 새 설치만 1P가 기본이에요. 모드를 바꾸면 다른 모드의 설정(OpenCodex가 쓴 -값만)이 제거되며, 회사 프록시 같은 외부 `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` 값은 덮어쓰지 않고 적용을 -거부해요. 전환 후에는 Desktop을 완전히 종료하고 다시 열어 주세요. 자세한 내용과 Claude Code CLI -호환성은 영어 문서를 참고하세요. +## Claude Desktop 모드: 게이트웨이(기본값)와 1P + +대시보드의 **Claude → Desktop → 연결 모드** 또는 +`ocx claude desktop apply --first-party|--gateway`로 서로 배타적인 두 모드 중 하나를 선택해요. + +### 게이트웨이(기본값) + +새 설치는 게이트웨이 프로필을 적용해요. 채팅 탭을 포함한 앱 전체가 OpenCodex를 사용하며, +claude.ai 전용 기능은 사용할 수 없어요. 기존 `--static`, `--hybrid`, `--discovery-only` 옵션도 +게이트웨이를 선택해요. + +### 1P(직접 선택) + +:::caution[계정 위험] +1P 모드에서는 Claude 구독 트래픽이 로컬 인터셉트 프록시를 거쳐요. +Anthropic이 이를 약관 위반으로 판단해 계정을 정지할 수 있어요. 기본값은 게이트웨이예요. +이 위험을 받아들일 때만 1P를 선택하세요. +::: + +Desktop은 claude.ai에 로그인된 채로 남아 채팅, 커넥터, 원격 제어를 계속 사용할 수 있어요. +OpenCodex는 `~/.claude/settings.json`(`CLAUDE_CONFIG_DIR` 지원)의 `env`에 +`HTTPS_PROXY`와 `NODE_EXTRA_CA_CERTS`만 써요. Code 탭이 실행한 Claude Code와 그 +서브에이전트, 터미널의 `claude` CLI만 로컬 프록시를 거쳐요. 그 밖의 `api.anthropic.com` +경로는 Anthropic으로 전달돼요. CA는 OS 신뢰 저장소에 설치하지 않고, +`NODE_EXTRA_CA_CERTS`를 읽는 Node 프로세스만 신뢰해요. + +모드는 `claudeCode.desktopMode`에 저장돼요. 명시적으로 1P를 적용했거나 이번 업데이트 전에 +적용한 설치는 1P를 유지하고, 기존 게이트웨이 설치도 그대로 유지해요. 명시 설정이 없다면 +OpenCodex 소유의 선택된 게이트웨이 항목, 저장된 게이트웨이 지문, `settings.json`의 소유된 +1P 설정 순으로 확인하고, 아무 증거도 없으면 게이트웨이를 선택해요. 카탈로그 동기화와 모델 +목록 업데이트는 1P 설치 위에 게이트웨이 프로필을 쓰지 않아요. +`claudeCode.intercept.enabled: false`라면 기존 1P 설치의 apply는 `intercept_disabled`로 +거절되고, 새 설치는 게이트웨이를 적용해요. 회사 프록시 같은 외부 설정은 덮어쓰지 않아요. +모드 전환 후에는 Desktop을 완전히 종료하고 다시 열어 주세요. + +### Picker 모드: 1P Code 탭에 opencodex 모델 표시하기 + +Picker 모드는 1P 모드의 일부예요. macOS에서 1P를 선택하면 기본으로 켜지지만, +`claudeCode.intercept.picker: false`를 설정하면 꺼져요. 1P Desktop의 Code 탭 모델 선택기를 바꿔서 +사용 가능한 opencodex 모델을 이름으로 보여줘요. 처음 켤 때 macOS 로그인 키체인에서 로컬 인증 기관을 +신뢰하라는 메시지가 표시될 수 있어요. 이 인증 기관은 `claude.ai`와 그 하위 도메인으로 제한되며, +이 메시지는 이 로컬 CA를 한 번 신뢰하기 위한 절차예요. + +Picker 모드가 켜져 있는 동안 Claude Desktop의 네트워크는 OpenCodex를 거쳐요. OpenCodex가 중단되면 +Picker 모드를 끄거나 Desktop을 완전히 다시 시작할 때까지 Desktop은 오프라인이에요. +`ocx claude desktop picker status`로 상태를 보고, `ocx claude desktop picker trust`로 신뢰 절차를 +다시 실행할 수 있어요. `ocx claude desktop picker off` 또는 대시보드 **Claude → Desktop**의 토글로 +끌 수 있어요. Picker 프로필을 선택한 뒤에는 Claude Desktop을 완전히 종료하고 다시 열어야 해요. + +Picker 모드는 1P의 일부이므로 [1P 계정 위험](#1p직접-선택)도 그대로 적용돼요. ### Code 탭에서 opencodex 모델 쓰기 (1P 바인딩) diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 11e6562fe60..be4dc7e37dd 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -104,6 +104,58 @@ Proxy admission secret в любом provider-заголовке удаляет Отключается параметром `claudeCode.nativePassthrough: false`; другой адрес задаётся через `claudeCode.anthropicBaseUrl`. +## Режимы Claude Desktop: шлюз (по умолчанию) и first-party + +Выберите один из взаимоисключающих режимов в **Claude → Desktop → Режим подключения** +или командой `ocx claude desktop apply --first-party|--gateway`. + +### Шлюз (по умолчанию) + +При новой установке применяется профиль шлюза: всё приложение, включая вкладку Chat, +работает через OpenCodex. Функции, доступные только через claude.ai, при этом недоступны. +Старые флаги `--static`, `--hybrid` и `--discovery-only` также выбирают шлюз. + +### First-party (по выбору) + +:::caution[Риск для аккаунта] +В режиме first-party трафик вашей подписки Claude проходит через локальный перехватывающий прокси. +Anthropic может счесть это нарушением условий и приостановить действие аккаунта. По умолчанию +используется шлюз; выбирайте first-party, только если принимаете этот риск. +::: + +Desktop остаётся подключён к claude.ai: Chat, коннекторы и удалённое управление продолжают работать. +OpenCodex записывает только `HTTPS_PROXY` и `NODE_EXTRA_CA_CERTS` в блок `env` файла +`~/.claude/settings.json` (с учётом `CLAUDE_CONFIG_DIR`). Claude Code во вкладке Code, его +субагенты и отдельная CLI `claude` проходят через локальный прокси; остальные пути +`api.anthropic.com` пересылаются Anthropic. CA не устанавливается в системное хранилище доверия: +ему доверяют только процессы Node, читающие `NODE_EXTRA_CA_CERTS`. + +Режим сохраняется в `claudeCode.desktopMode`. Установки, ранее применившие first-party явно или +до этого выпуска, сохраняют его; существующий шлюз также остаётся шлюзом. Без явного выбора +приоритет имеют выбранная строка шлюза OpenCodex, сохранённый отпечаток шлюза, затем принадлежащие +OpenCodex настройки first-party в `settings.json`; при отсутствии этих признаков выбирается шлюз. +Синхронизация каталога и обновление списка моделей никогда не записывают профиль шлюза поверх +установки first-party. При `claudeCode.intercept.enabled: false` применение на такой установке +отклоняется с `intercept_disabled`, а новая установка применяет шлюз. Чужие настройки прокси +не перезаписываются. После переключения полностью закройте и снова откройте Desktop. + +### Режим picker: модели opencodex в селекторе Code first-party + +Режим picker входит в режим first-party. В macOS он включён по умолчанию при выборе first-party, +если не задано `claudeCode.intercept.picker: false`. Он изменяет селектор моделей во вкладке Code +first-party Desktop и показывает доступные модели opencodex по именам. При первом включении macOS может +попросить доверить локальному центру сертификации в связке ключей для входа. Этот центр ограничен +`claude.ai` и его поддоменами; запрос появляется один раз для доверия этому локальному центру. + +Пока режим picker включён, Claude Desktop выходит в сеть через OpenCodex. Если OpenCodex остановится, +Desktop будет офлайн, пока вы полностью не перезапустите его или не отключите режим picker. +Проверьте состояние командой `ocx claude desktop picker status`, повторите доверие командой +`ocx claude desktop picker trust`, а для отключения используйте `ocx claude desktop picker off`. +В панели управления есть такой же переключатель в **Claude → Desktop**. После выбора профиля picker +полностью закройте и снова откройте Claude Desktop. + +Режим picker входит в first-party, поэтому к нему также применяется [риск для аккаунта first-party](#first-party-по-выбору). + ## Claude Desktop через удалённый хаб На подключённой машине `ocx claude desktop apply` или `ocx claude desktop` получает снимок diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index bfe583acb4a..a17f67e86d8 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -145,8 +145,63 @@ görüntüdür; `ocx claude` ise her zaman canlı olarak çözümler. ## Sistem ortamı entegrasyonu (macOS) +## Claude Desktop modları: ağ geçidi (varsayılan) ve first-party + +Birbirini dışlayan modlardan birini **Claude → Desktop → Bağlantı modu** bölümünden veya +`ocx claude desktop apply --first-party|--gateway` komutuyla seçin. + +### Ağ geçidi (varsayılan) + +Yeni kurulumlarda ağ geçidi profili uygulanır; Chat sekmesi dâhil tüm uygulama OpenCodex'i +kullanır. Yalnızca claude.ai üzerinden sunulan özellikler kullanılamaz. Eski `--static`, +`--hybrid` ve `--discovery-only` bayrakları da ağ geçidini seçer. + +### First-party (isteğe bağlı) + +:::caution[Hesap riski] +First-party modunda Claude aboneliğinizin trafiği yerel bir müdahale vekilinden geçer. +Anthropic bunu kullanım koşullarının ihlali sayıp hesabınızı askıya alabilir. Varsayılan mod +ağ geçididir; bu riski kabul ediyorsanız first-party modunu seçin. +::: + +Desktop claude.ai oturumunu korur; Chat, bağlayıcılar ve uzaktan kontrol çalışmaya devam eder. +OpenCodex yalnızca `~/.claude/settings.json` dosyasındaki (`CLAUDE_CONFIG_DIR` desteklenir) +`env` alanına `HTTPS_PROXY` ve `NODE_EXTRA_CA_CERTS` yazar. Code sekmesinin başlattığı +Claude Code, alt ajanları ve bağımsız `claude` CLI yerel vekilden geçer; diğer +`api.anthropic.com` yolları Anthropic'e iletilir. CA, işletim sisteminin güven deposuna +kurulmaz; yalnızca `NODE_EXTRA_CA_CERTS` okuyan Node süreçleri ona güvenir. + +Mod `claudeCode.desktopMode` içinde saklanır. First-party modunu açıkça veya bu sürümden önce +uygulayan kurulumlar bu modu korur; mevcut ağ geçidi de korunur. Açık mod yoksa önce OpenCodex'e +ait seçili ağ geçidi satırı, sonra kayıtlı ağ geçidi parmak izi, ardından `settings.json` +içindeki OpenCodex'e ait first-party ayarları değerlendirilir; hiçbiri yoksa ağ geçidi seçilir. +Katalog eşitlemesi ve model listesi güncellemesi, first-party kurulumunun üstüne ağ geçidi +profili yazmaz. `claudeCode.intercept.enabled: false` olduğunda mevcut first-party kurulumunda +apply işlemi `intercept_disabled` ile reddedilir; yeni kurulum ağ geçidini uygular. Başka bir +vekilin ayarları üzerine yazılmaz. Mod değiştirince Desktop'ı tamamen kapatıp yeniden açın. + +### Picker modu: first-party Code sekmesinde opencodex modelleri + +Picker modu first-party modunun bir parçasıdır. macOS'ta first-party seçildiğinde varsayılan olarak +açıktır; `claudeCode.intercept.picker: false` ayarlanırsa kapalı kalır. First-party Desktop'ın Code +sekmesindeki model seçiciyi değiştirerek kullanılabilir opencodex modellerini adlarıyla listeler. +İlk etkinleştirmede macOS, giriş anahtar zincirinde yerel bir sertifika yetkilisine güvenmenizi isteyebilir. +Bu yetkili `claude.ai` ve alt alan adlarıyla sınırlıdır; iletişim kutusu bu yerel CA için tek seferlik güven +adımıdır. + +Picker modu açıkken Claude Desktop ağa OpenCodex üzerinden çıkar. OpenCodex durursa Desktop, tamamen yeniden +başlatılana veya picker modu kapatılana kadar çevrimdışı kalır. Durumu `ocx claude desktop picker status` +ile görün, güven adımını `ocx claude desktop picker trust` ile tekrarlayın veya `ocx claude desktop picker off` +ile kapatın. Aynı açma-kapama denetimi **Claude → Desktop** kontrol panelinde de bulunur. Picker profili +seçildikten sonra Claude Desktop'ı tamamen kapatıp yeniden açın. + +Picker modu first-party'nin parçasıdır; bu nedenle [first-party hesap riski](#first-party-isteğe-bağlı) +aynı şekilde geçerlidir. + ## Claude Desktop profili +Bu profil yalnızca ağ geçidi modunda Desktop'a yazılır. + Claude Desktop, Claude Code'dan ayrı bir profil kullanır. Mevcut her rotayı dört aileden birine (Opus, Fable, Sonnet veya Haiku) yerleştirmek için kontrol panelinde **Claude → Desktop** sayfasını açın. Tüm rotalar yeni bir profilde @@ -173,7 +228,8 @@ ocx claude desktop export ocx claude desktop import [--apply] ``` -`ocx claude desktop` ve `apply`, geçerli profili Claude Desktop'a yazar. `show` +`ocx claude desktop` ve `apply` seçili modu uygular: first-party Claude Code vekilinin +ortam ayarlarını, ağ geçidi ise Desktop profilini yazar. `show` okunabilir bir özet sunar; betikler için `--json` ekleyin. `export -`, standart çıktıya sürümlenmiş JSON yazar. İçe aktarma, kaydetmeden önce dosyanın tamamını doğrular, böylece geçersiz bir dosya geçerli profili değiştirmeden bırakır. diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index b8174204253..2d051289bd4 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -91,6 +91,53 @@ Anthropic。若任一提供方请求头包含代理准入密钥,该密钥会 可以设置 `claudeCode.nativePassthrough: false` 来禁用;也可以通过 `claudeCode.anthropicBaseUrl` 指向其他位置。 +## Claude Desktop 模式:网关(默认)与第一方 + +在控制台的 **Claude → Desktop → 连接模式** 中,或使用 +`ocx claude desktop apply --first-party|--gateway` 选择互斥的模式。 + +### 网关(默认) + +新安装默认应用网关配置档案:包括聊天标签页在内的整个应用都通过 OpenCodex。 +仅限 claude.ai 的功能此时不可用。旧版 `--static`、`--hybrid` 和 `--discovery-only` +选项也会选择网关。 + +### 第一方(主动选择) + +:::caution[账户风险] +第一方模式会让你的 Claude 订阅流量经过本地拦截代理。 +Anthropic 可能认为这违反其条款并暂停你的账户。默认模式是网关; +只有接受这一风险时才选择第一方模式。 +::: + +Desktop 保持登录 claude.ai,聊天、连接器和远程控制仍可使用。OpenCodex 只在 +`~/.claude/settings.json`(支持 `CLAUDE_CONFIG_DIR`)的 `env` 中写入 `HTTPS_PROXY` +和 `NODE_EXTRA_CA_CERTS`。Code 标签页启动的 Claude Code、子代理及独立的 `claude` CLI +经过本地代理;其他 `api.anthropic.com` 路径会转发给 Anthropic。CA 不会安装到操作系统 +信任存储中,只有读取 `NODE_EXTRA_CA_CERTS` 的 Node 进程会信任它。 + +模式保存在 `claudeCode.desktopMode`。此前明确应用第一方模式或在本版本之前应用过 +第一方模式的安装会保留该模式;现有网关安装也保持不变。没有明确设置时,依次检查 +OpenCodex 拥有的已选网关条目、保存的网关指纹、`settings.json` 中属于 OpenCodex 的 +第一方设置;都没有时使用网关。目录同步和模型列表更新绝不会在已解析为第一方模式的 +安装上写入网关配置档案。若 `claudeCode.intercept.enabled: false`,现有第一方安装的 +应用操作会以 `intercept_disabled` 拒绝,新安装则应用网关。不会覆盖其他代理的设置。 +切换模式后请完全退出并重新打开 Desktop。 + +### Picker 模式:在第一方 Code 标签页中显示 opencodex 模型 + +Picker 模式是第一方模式的一部分。在 macOS 上选择第一方时默认开启;设置 +`claudeCode.intercept.picker: false` 后会保持关闭。它会修改第一方 Desktop 的 Code 标签页模型选择器, +按名称列出可用的 opencodex 模型。首次开启时,macOS 可能会要求你在登录钥匙串中信任本地证书颁发机构。 +该颁发机构限制为 `claude.ai` 及其子域名;这个提示是对该本地 CA 的一次性信任步骤。 + +Picker 模式开启期间,Claude Desktop 通过 OpenCodex 访问网络。如果 OpenCodex 停止,Desktop 会处于离线状态, +直到你完全重启 Desktop 或关闭 Picker 模式。使用 `ocx claude desktop picker status` 查看状态,使用 +`ocx claude desktop picker trust` 重复信任步骤,或使用 `ocx claude desktop picker off` 关闭。 +控制台 **Claude → Desktop** 中也有同样的开关。选择 Picker 配置档案后,请完全退出并重新打开 Claude Desktop。 + +Picker 模式属于第一方模式,因此[第一方账户风险](#第一方主动选择)同样适用。 + ## 连接远程 hub 的 Claude Desktop 已连接的机器运行 `ocx claude desktop apply` 或 `ocx claude desktop` 时,会读取 hub 的 diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 6ae36890b4b..9d329eaf525 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -101,8 +101,57 @@ Claude Code 需要在 `ANTHROPIC_AUTH_TOKEN` 中有 token 才能與閘道器通 在 macOS 上,自動連線(`claudeCode.systemEnv`)也遵循相同解析邏輯,因此在 `ocx` 之外直接啟動的 `claude` 行為一致。該檔案是代理啟動或你儲存設定時重新整理的快照,而 `ocx claude` 則一律即時解析。 +## Claude Desktop 模式:閘道(預設)與第一方 + +在儀表板的 **Claude → Desktop → 連線模式**,或透過 +`ocx claude desktop apply --first-party|--gateway` 選擇互斥的模式。 + +### 閘道(預設) + +新安裝預設套用閘道設定檔:包含聊天分頁在內的整個應用程式都使用 OpenCodex。 +僅限 claude.ai 的功能無法使用。舊版 `--static`、`--hybrid` 和 `--discovery-only` +選項也會選擇閘道。 + +### 第一方(自行選擇) + +:::caution[帳號風險] +第一方模式會讓你的 Claude 訂閱流量經過本機攔截代理。 +Anthropic 可能認為這違反其條款並停用你的帳號。預設模式是閘道; +只有接受這項風險時才選擇第一方模式。 +::: + +Desktop 維持 claude.ai 登入,聊天、連接器和遠端控制仍可使用。OpenCodex 只在 +`~/.claude/settings.json`(支援 `CLAUDE_CONFIG_DIR`)的 `env` 中寫入 `HTTPS_PROXY` +與 `NODE_EXTRA_CA_CERTS`。Code 分頁啟動的 Claude Code、子代理及獨立的 `claude` CLI +經過本機代理;其他 `api.anthropic.com` 路徑會轉送給 Anthropic。CA 不會安裝到作業系統 +信任儲存區,只有讀取 `NODE_EXTRA_CA_CERTS` 的 Node 程序會信任它。 + +模式儲存在 `claudeCode.desktopMode`。先前明確套用第一方模式或在此版本之前套用的安裝 +會保留第一方模式;現有閘道安裝也維持不變。沒有明確設定時,依序檢查 OpenCodex 擁有的 +已選閘道項目、儲存的閘道指紋、`settings.json` 中屬於 OpenCodex 的第一方設定; +都沒有時採用閘道。目錄同步和模型清單更新絕不會在已解析為第一方模式的安裝上 +寫入閘道設定檔。若 `claudeCode.intercept.enabled: false`,現有第一方安裝的套用操作 +會以 `intercept_disabled` 拒絕,新安裝則套用閘道。不會覆寫其他代理的設定。 +切換模式後請完全結束並重新開啟 Desktop。 + +### Picker 模式:在第一方 Code 分頁顯示 opencodex 模型 + +Picker 模式是第一方模式的一部分。在 macOS 上選擇第一方時預設開啟;設定 +`claudeCode.intercept.picker: false` 後會保持關閉。它會修改第一方 Desktop 的 Code 分頁模型選擇器, +依名稱列出可用的 opencodex 模型。首次開啟時,macOS 可能會要求你在登入鑰匙圈中信任本機憑證授權單位。 +該授權單位限制為 `claude.ai` 及其子網域;這個提示是對該本機 CA 的一次性信任步驟。 + +Picker 模式開啟期間,Claude Desktop 會透過 OpenCodex 存取網路。如果 OpenCodex 停止,Desktop 會離線, +直到你完全重新啟動 Desktop 或關閉 Picker 模式。使用 `ocx claude desktop picker status` 查看狀態, +使用 `ocx claude desktop picker trust` 重複信任步驟,或使用 `ocx claude desktop picker off` 關閉。 +儀表板的 **Claude → Desktop** 也有相同的切換開關。選取 Picker 設定檔後,請完全結束並重新開啟 Claude Desktop。 + +Picker 模式屬於第一方模式,因此[第一方帳號風險](#第一方自行選擇)同樣適用。 + ## Claude Desktop 設定檔 +只有閘道模式會將以下設定檔寫入 Desktop。 + Claude Desktop 使用與 Claude Code 分開的設定檔。在儀表板開啟 **Claude → Desktop**,可把每條 可用路由放到四個系列之一:Opus、Fable、Sonnet 或 Haiku。新設定檔中所有路由一開始都在 Opus。 第一個 Opus 路由會成為整體初始預設,且每個非空系列都一定會有一個系列預設。 @@ -125,7 +174,8 @@ ocx claude desktop export ocx claude desktop import [--apply] ``` -`ocx claude desktop` 與 `apply` 都會把目前設定檔寫入 Claude Desktop。`show` 提供可讀摘要;加上 +`ocx claude desktop` 與 `apply` 會套用選定模式:第一方寫入 Claude Code 代理環境變數, +閘道則寫入 Desktop 設定檔。`show` 提供可讀摘要;加上 `--json` 方便腳本使用。`export -` 會把帶版本的 JSON 寫到標準輸出。Import 會在儲存前驗證完整 檔案,因此無效檔案不會改動目前設定檔。加上 `--apply` 可在匯入有效設定檔後立即寫入 Desktop。 `none` 僅適用於空系列;每個非空系列都必須保留一個預設。 diff --git a/gui/src/components/ClaudeDesktopPicker.tsx b/gui/src/components/ClaudeDesktopPicker.tsx new file mode 100644 index 00000000000..f8147b88927 --- /dev/null +++ b/gui/src/components/ClaudeDesktopPicker.tsx @@ -0,0 +1,157 @@ +import { useState } from "react"; +import { useI18n, type TKey } from "../i18n/shared"; +import { readJsonOrThrow } from "../fetch-json"; +import { Notice } from "../ui"; + +export type DesktopPickerReason = + | "active" + | "restart_required" + | "unsupported_platform" + | "not_first_party" + | "integration_off" + | "disabled" + | "proxy_unavailable" + | "mode_not_committed" + | "trust_pending" + | "trust_declined" + | "profile_failed"; + +export type DesktopPickerTrust = "trusted" | "untrusted" | "unsupported" | "unknown"; +export type DesktopPickerProfile = "absent" | "applied" | "not_selected" | "unsafe"; + +export interface DesktopPickerStatus { + desired: boolean; + supported: boolean; + trust: DesktopPickerTrust; + profile: DesktopPickerProfile; + listenerReady: boolean; + effective: boolean; + reason: DesktopPickerReason; + models: number; + snapshotAt: number | null; + lastBootstrapAt: number | null; + hint?: string; + residual?: string[]; +} + +function isDesktopPickerStatus(value: unknown): value is DesktopPickerStatus { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return typeof v.desired === "boolean" + && typeof v.supported === "boolean" + && ["trusted", "untrusted", "unsupported", "unknown"].includes(v.trust as string) + && ["absent", "applied", "not_selected", "unsafe"].includes(v.profile as string) + && typeof v.listenerReady === "boolean" + && typeof v.effective === "boolean" + && ["active", "restart_required", "unsupported_platform", "not_first_party", "integration_off", "disabled", "proxy_unavailable", "mode_not_committed", "trust_pending", "trust_declined", "profile_failed"].includes(v.reason as string) + && typeof v.models === "number" && Number.isFinite(v.models) && v.models >= 0 + && (v.snapshotAt === null || typeof v.snapshotAt === "number") + && (v.lastBootstrapAt === null || typeof v.lastBootstrapAt === "number") + && (v.hint === undefined || typeof v.hint === "string") + && (v.residual === undefined || (Array.isArray(v.residual) && v.residual.every(item => typeof item === "string"))); +} + +function stateKey(reason: DesktopPickerReason): TKey { + switch (reason) { + case "active": return "claudeDesktop.picker.state.active"; + case "restart_required": return "claudeDesktop.picker.state.restart"; + case "trust_pending": return "claudeDesktop.picker.state.trustPending"; + case "trust_declined": return "claudeDesktop.picker.state.trustDeclined"; + case "proxy_unavailable": return "claudeDesktop.picker.state.proxyUnavailable"; + case "unsupported_platform": return "claudeDesktop.picker.state.unsupported"; + case "profile_failed": return "claudeDesktop.picker.state.profileFailed"; + case "not_first_party": + case "integration_off": + case "disabled": + case "mode_not_committed": + return "claudeDesktop.picker.state.notFirstParty"; + } +} + +export default function ClaudeDesktopPicker({ + apiBase, + picker, + onUpdated, +}: { + apiBase: string; + picker: DesktopPickerStatus; + onUpdated?: (picker: DesktopPickerStatus) => void; +}) { + const { t } = useI18n(); + const [localPicker, setLocalPicker] = useState(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const current = localPicker ?? picker; + + const toggle = async () => { + if (pending) return; + setPending(true); + setError(null); + try { + const response = await fetch(`${apiBase}/api/claude-desktop/picker`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !current.desired, persist: true }), + }); + let payload: { ok?: boolean; picker?: DesktopPickerStatus } | undefined; + if (response.ok) { + payload = await readJsonOrThrow<{ ok?: boolean; picker?: DesktopPickerStatus }>( + response, + t("claudeDesktop.updateFailed"), + ); + } else { + // A refused enable is a useful status response (not a transport failure); 503 + // carries the controller's reason so the card can explain the missing proxy. + try { + payload = await response.json() as { ok?: boolean; picker?: DesktopPickerStatus }; + } catch { + throw new Error(t("claudeDesktop.updateFailed")); + } + if (!payload.picker) throw new Error(t("claudeDesktop.updateFailed")); + } + if (!payload || payload.ok !== true && !payload.picker || !payload.picker || !isDesktopPickerStatus(payload.picker)) { + throw new Error(t("claudeDesktop.updateFailed")); + } + setLocalPicker(payload.picker); + onUpdated?.(payload.picker); + } catch (cause) { + setError(cause instanceof Error ? cause.message : t("claudeDesktop.updateFailed")); + } finally { + setPending(false); + } + }; + + return ( +
+
+
+

{t("claudeDesktop.picker.title")}

+

{t("claudeDesktop.picker.hint")}

+
+ +
+ + {error && {error}} + +

+

+

+ {t("claudeDesktop.picker.models", { count: current.models })} +

+

{t("claudeDesktop.picker.offlineNote")}

+
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 0cf5717d09a..0568c02bcd4 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2832,11 +2832,25 @@ export const de: Record = { "claudeDesktop.mode.defaultBadge": "Standard", "claudeDesktop.mode.current": "aktuell", "claudeDesktop.mode.firstPartyHint": "Desktop bleibt bei claude.ai angemeldet (Chat, Connectors, Remote Control). Nur der Code-Tab, seine Subagenten und die Claude Code CLI laufen über OpenCodex.", + "claudeDesktop.mode.firstPartyRisk": "Kontorisiko: Im First-Party-Modus wird dein Claude-Abonnementverkehr über einen lokalen Abfangproxy geleitet. Anthropic kann dies als Verstoß gegen die Nutzungsbedingungen werten und das Konto sperren. Gateway ist der Standard.", "claudeDesktop.mode.gatewayHint": "Die ganze App wechselt auf OpenCodex als Gateway. Chat läuft lokal; claude.ai-Funktionen sind nicht verfügbar.", "claudeDesktop.mode.switchNote": "Der Wechsel ersetzt die Konfiguration des anderen Modus. Desktop danach vollständig beenden und neu öffnen.", "claudeDesktop.firstParty.proxyRunning": "Lokaler Proxy auf 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "Lokaler Proxy 127.0.0.1:{port} läuft nicht — OpenCodex neu starten", "claudeDesktop.firstParty.interceptDisabled": "Intercept-Proxy ist in der Konfiguration deaktiviert (claudeCode.intercept.enabled)", + "claudeDesktop.picker.title": "Claude-Desktop-Auswahl", + "claudeDesktop.picker.hint": "OpenCodex-Modelle über den dedizierten lokalen Proxy in der Code-Tab-Auswahl von Desktop anzeigen.", + "claudeDesktop.picker.toggle": "Claude-Desktop-Auswahl aktivieren", + "claudeDesktop.picker.state.active": "Auswahl ist aktiv", + "claudeDesktop.picker.state.restart": "Claude Desktop vollständig beenden und erneut öffnen, um das Auswahlprofil anzuwenden.", + "claudeDesktop.picker.state.trustPending": "Warten auf den Schlüsselbundschritt", + "claudeDesktop.picker.state.trustDeclined": "Schlüsselbundvertrauen wurde abgelehnt", + "claudeDesktop.picker.state.proxyUnavailable": "Auswahlproxy läuft nicht", + "claudeDesktop.picker.state.unsupported": "Auswahlmodus wird auf diesem Betriebssystem nicht unterstützt", + "claudeDesktop.picker.state.notFirstParty": "Die Auswahl ist nur im First-Party-Modus verfügbar", + "claudeDesktop.picker.state.profileFailed": "Das Auswahlprofil konnte nicht angewendet werden", + "claudeDesktop.picker.models": "{count} Modelle in der Auswahl verfügbar", + "claudeDesktop.picker.offlineNote": "Bei aktiviertem Auswahlmodus erreicht Claude Desktop das Netzwerk über OpenCodex. Wenn OpenCodex stoppt, ist Desktop offline, bis es neu gestartet oder der Auswahlmodus deaktiviert wird.", "claudeDesktop.firstParty.bindings.title": "Modellbindungen des Code-Tabs", "claudeDesktop.firstParty.bindings.hint": "Claude Desktop behält Anthropics Namen in der Auswahl des Code-Tabs bei. Binden Sie ein Auswahlmodell an ein OpenCodex-Modell, und Anfragen für diesen Eintrag werden vom gebundenen Modell bedient. Nur Claude-Code-Verkehr über den lokalen Proxy (Desktop-Code-Tab und die claude-CLI) nutzt diese Bindungen; ocx claude bleibt unberührt. Bindungen gelten ab der nächsten Anfrage — ein vollständiger Neustart ist nicht nötig.", "claudeDesktop.firstParty.bindings.empty": "Noch keine Bindungen — Auswahlmodelle folgen ihrer Standardroute.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d6a645fcbf9..ef4f35ea59e 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2932,11 +2932,25 @@ export const en = { "claudeDesktop.mode.defaultBadge": "default", "claudeDesktop.mode.current": "current", "claudeDesktop.mode.firstPartyHint": "Desktop stays signed in to claude.ai (chat, connectors, remote control). Only the Code tab, its subagents and the Claude Code CLI go through OpenCodex.", + "claudeDesktop.mode.firstPartyRisk": "Account risk: first-party sends your Claude subscription traffic through a local interception proxy. Anthropic may treat this as a terms violation and suspend the account. Gateway is the default.", "claudeDesktop.mode.gatewayHint": "The whole app switches to OpenCodex as its gateway. Chat runs locally; claude.ai features are unavailable.", "claudeDesktop.mode.switchNote": "Switching replaces the other mode's configuration. Fully quit and reopen Desktop afterwards.", "claudeDesktop.firstParty.proxyRunning": "Local proxy on 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "Local proxy 127.0.0.1:{port} is not running — restart OpenCodex", "claudeDesktop.firstParty.interceptDisabled": "Intercept proxy is disabled in config (claudeCode.intercept.enabled)", + "claudeDesktop.picker.title": "Claude Desktop picker", + "claudeDesktop.picker.hint": "Show OpenCodex models in Desktop's Code tab picker through the dedicated local proxy.", + "claudeDesktop.picker.toggle": "Enable Claude Desktop picker", + "claudeDesktop.picker.state.active": "Picker is active", + "claudeDesktop.picker.state.restart": "Fully quit and reopen Claude Desktop to finish applying the picker profile.", + "claudeDesktop.picker.state.trustPending": "Waiting for the keychain step", + "claudeDesktop.picker.state.trustDeclined": "Keychain trust was declined", + "claudeDesktop.picker.state.proxyUnavailable": "Picker proxy is not running", + "claudeDesktop.picker.state.unsupported": "Picker mode is unsupported on this OS", + "claudeDesktop.picker.state.notFirstParty": "Picker is available only in first-party mode", + "claudeDesktop.picker.state.profileFailed": "The picker profile could not be applied", + "claudeDesktop.picker.models": "{count} models available in the picker", + "claudeDesktop.picker.offlineNote": "While picker mode is on, Claude Desktop reaches the network through OpenCodex. If OpenCodex stops, Desktop is offline until it restarts or picker mode is turned off.", "claudeDesktop.firstParty.bindings.title": "Code tab model bindings", "claudeDesktop.firstParty.bindings.hint": "Claude Desktop keeps Anthropic's names in the Code tab picker. Bind a picker model to an OpenCodex model and requests for that picker entry are served by the bound model. Only Claude Code traffic through the local proxy (the Desktop Code tab and the claude CLI) uses these bindings; ocx claude is unaffected. Bindings apply on the next request — no need to fully quit and reopen.", "claudeDesktop.firstParty.bindings.empty": "No bindings yet — picker models follow their default route.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 9cfb544e72b..e0b737bf887 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2850,11 +2850,25 @@ export const fr: Record = { "claudeDesktop.mode.defaultBadge": "par défaut", "claudeDesktop.mode.current": "actuel", "claudeDesktop.mode.firstPartyHint": "Desktop reste connecté à claude.ai (chat, connecteurs, contrôle à distance). Seuls l'onglet Code, ses sous-agents et la CLI Claude Code passent par OpenCodex.", + "claudeDesktop.mode.firstPartyRisk": "Risque pour le compte : le mode Première partie (first-party) fait passer le trafic de votre abonnement Claude par un proxy d'interception local. Anthropic peut considérer cela comme une violation de ses conditions d'utilisation et suspendre le compte. La Passerelle est le mode par défaut.", "claudeDesktop.mode.gatewayHint": "Toute l'application bascule sur OpenCodex comme passerelle. Le chat tourne en local ; les fonctions claude.ai sont indisponibles.", "claudeDesktop.mode.switchNote": "Le changement remplace la configuration de l'autre mode. Quittez complètement Desktop puis rouvrez-le.", "claudeDesktop.firstParty.proxyRunning": "Proxy local sur 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "Le proxy local 127.0.0.1:{port} ne tourne pas — redémarrez OpenCodex", "claudeDesktop.firstParty.interceptDisabled": "Le proxy d'interception est désactivé dans la config (claudeCode.intercept.enabled)", + "claudeDesktop.picker.title": "Sélecteur Claude Desktop", + "claudeDesktop.picker.hint": "Afficher les modèles OpenCodex dans le sélecteur de l’onglet Code de Desktop via le proxy local dédié.", + "claudeDesktop.picker.toggle": "Activer le sélecteur Claude Desktop", + "claudeDesktop.picker.state.active": "Sélecteur actif", + "claudeDesktop.picker.state.restart": "Quittez complètement Claude Desktop, puis rouvrez-le pour appliquer le profil du sélecteur.", + "claudeDesktop.picker.state.trustPending": "En attente de l’étape du trousseau", + "claudeDesktop.picker.state.trustDeclined": "La confiance du trousseau a été refusée", + "claudeDesktop.picker.state.proxyUnavailable": "Le proxy du sélecteur n’est pas démarré", + "claudeDesktop.picker.state.unsupported": "Le mode sélecteur n’est pas pris en charge sur ce système", + "claudeDesktop.picker.state.notFirstParty": "Le sélecteur est disponible uniquement en mode first-party", + "claudeDesktop.picker.state.profileFailed": "Le profil du sélecteur n’a pas pu être appliqué", + "claudeDesktop.picker.models": "{count} modèles disponibles dans le sélecteur", + "claudeDesktop.picker.offlineNote": "Lorsque le sélecteur est activé, Claude Desktop accède au réseau via OpenCodex. Si OpenCodex s’arrête, Desktop reste hors ligne jusqu’à son redémarrage ou la désactivation du sélecteur.", "claudeDesktop.firstParty.bindings.title": "Associations de modèles de l'onglet Code", "claudeDesktop.firstParty.bindings.hint": "Claude Desktop conserve les noms d'Anthropic dans le sélecteur de l'onglet Code. Associez un modèle du sélecteur à un modèle OpenCodex et les requêtes de cette entrée seront servies par le modèle associé. Seul le trafic Claude Code passant par le proxy local (l'onglet Code de Desktop et la CLI claude) utilise ces associations ; ocx claude n'est pas affecté. Elles s'appliquent dès la requête suivante — inutile de quitter et rouvrir l'app.", "claudeDesktop.firstParty.bindings.empty": "Aucune association pour l'instant — les modèles du sélecteur suivent leur route par défaut.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0bd35425fdd..a71d06d480b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2654,11 +2654,25 @@ export const ja: Record = { "claudeDesktop.mode.defaultBadge": "デフォルト", "claudeDesktop.mode.current": "現在", "claudeDesktop.mode.firstPartyHint": "Desktop は claude.ai にログインしたまま(チャット・コネクタ・リモート操作)。Code タブとそのサブエージェント、Claude Code CLI だけが OpenCodex を通ります。", + "claudeDesktop.mode.firstPartyRisk": "アカウントのリスク:1P(ファーストパーティ)モードでは、Claude のサブスクリプション通信がローカルの傍受プロキシを通ります。Anthropic がこれを利用規約違反とみなし、アカウントを停止する可能性があります。ゲートウェイがデフォルトです。", "claudeDesktop.mode.gatewayHint": "アプリ全体が OpenCodex をゲートウェイとして使います。チャットはローカル実行になり、claude.ai の機能は使えません。", "claudeDesktop.mode.switchNote": "切り替えると他方のモードの設定は削除されます。適用後は Desktop を完全に終了して再起動してください。", "claudeDesktop.firstParty.proxyRunning": "ローカルプロキシ 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "ローカルプロキシ 127.0.0.1:{port} が停止中 — OpenCodex を再起動", "claudeDesktop.firstParty.interceptDisabled": "設定でインターセプトプロキシが無効です(claudeCode.intercept.enabled)", + "claudeDesktop.picker.title": "Claude Desktop ピッカー", + "claudeDesktop.picker.hint": "専用ローカルプロキシ経由で、Desktop の Code タブのピッカーに OpenCodex モデルを表示します。", + "claudeDesktop.picker.toggle": "Claude Desktop ピッカーを有効化", + "claudeDesktop.picker.state.active": "ピッカーは有効です", + "claudeDesktop.picker.state.restart": "ピッカープロファイルの適用を完了するには、Claude Desktop を完全に終了して再起動してください。", + "claudeDesktop.picker.state.trustPending": "キーチェーンの手順を待っています", + "claudeDesktop.picker.state.trustDeclined": "キーチェーンの信頼が拒否されました", + "claudeDesktop.picker.state.proxyUnavailable": "ピッカープロキシが起動していません", + "claudeDesktop.picker.state.unsupported": "この OS ではピッカーモードを利用できません", + "claudeDesktop.picker.state.notFirstParty": "ピッカーはファーストパーティーモードでのみ利用できます", + "claudeDesktop.picker.state.profileFailed": "ピッカープロファイルを適用できませんでした", + "claudeDesktop.picker.models": "ピッカーで {count} モデルを利用できます", + "claudeDesktop.picker.offlineNote": "ピッカーモードが有効な間、Claude Desktop は OpenCodex 経由でネットワークに接続します。OpenCodex が停止すると、Desktop を再起動するかピッカーモードをオフにするまでオフラインになります。", "claudeDesktop.firstParty.bindings.title": "Code タブのモデルバインディング", "claudeDesktop.firstParty.bindings.hint": "Claude Desktop は Code タブのピッカーに Anthropic の名前をそのまま表示します。ピッカーのモデルを OpenCodex のモデルにバインドすると、そのピッカー項目へのリクエストはバインド先のモデルで処理されます。ローカルプロキシ経由の Claude Code トラフィック(Desktop の Code タブと claude CLI)のみがこのバインディングを使用し、ocx claude には影響しません。バインディングは次のリクエストから適用されるため、完全に終了して開き直す必要はありません。", "claudeDesktop.firstParty.bindings.empty": "バインディングはまだありません — ピッカーのモデルはデフォルトのルートを使います。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 4dbd9be2e45..4680d1be92c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2871,11 +2871,25 @@ export const ko: Record = { "claudeDesktop.mode.defaultBadge": "기본값", "claudeDesktop.mode.current": "현재", "claudeDesktop.mode.firstPartyHint": "Desktop은 claude.ai에 로그인된 상태를 유지합니다(채팅·커넥터·원격 제어). Code 탭과 서브에이전트, Claude Code CLI만 OpenCodex를 거칩니다.", + "claudeDesktop.mode.firstPartyRisk": "계정 위험: 1P (퍼스트파티) 모드는 Claude 구독 트래픽을 로컬 가로채기 프록시로 전송합니다. Anthropic이 이를 이용약관 위반으로 판단해 계정을 정지할 수 있습니다. 게이트웨이가 기본값입니다.", "claudeDesktop.mode.gatewayHint": "앱 전체가 OpenCodex를 게이트웨이로 사용합니다. 채팅은 로컬로 실행되고 claude.ai 기능은 사용할 수 없습니다.", "claudeDesktop.mode.switchNote": "전환하면 다른 모드의 설정은 제거됩니다. 적용 후 Desktop을 완전히 종료하고 다시 열어주세요.", "claudeDesktop.firstParty.proxyRunning": "로컬 프록시 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "로컬 프록시 127.0.0.1:{port}가 실행 중이 아님 — OpenCodex 재시작", "claudeDesktop.firstParty.interceptDisabled": "설정에서 인터셉트 프록시가 비활성화됨 (claudeCode.intercept.enabled)", + "claudeDesktop.picker.title": "Claude Desktop 선택기", + "claudeDesktop.picker.hint": "전용 로컬 프록시를 통해 Desktop Code 탭 선택기에 OpenCodex 모델을 표시합니다.", + "claudeDesktop.picker.toggle": "Claude Desktop 선택기 켜기", + "claudeDesktop.picker.state.active": "선택기가 활성화됨", + "claudeDesktop.picker.state.restart": "선택기 프로필 적용을 마치려면 Claude Desktop을 완전히 종료한 뒤 다시 열어주세요.", + "claudeDesktop.picker.state.trustPending": "키체인 단계 대기 중", + "claudeDesktop.picker.state.trustDeclined": "키체인 신뢰가 거부됨", + "claudeDesktop.picker.state.proxyUnavailable": "선택기 프록시가 실행 중이 아님", + "claudeDesktop.picker.state.unsupported": "이 운영체제에서는 선택기 모드를 지원하지 않음", + "claudeDesktop.picker.state.notFirstParty": "선택기는 퍼스트파티 모드에서만 사용할 수 있음", + "claudeDesktop.picker.state.profileFailed": "선택기 프로필을 적용하지 못함", + "claudeDesktop.picker.models": "선택기에서 {count}개 모델 사용 가능", + "claudeDesktop.picker.offlineNote": "선택기 모드가 켜져 있는 동안 Claude Desktop은 OpenCodex를 통해 네트워크에 연결됩니다. OpenCodex가 중지되면 Desktop을 다시 시작하거나 선택기 모드를 끌 때까지 오프라인 상태가 됩니다.", "claudeDesktop.firstParty.bindings.title": "Code 탭 모델 바인딩", "claudeDesktop.firstParty.bindings.hint": "Claude Desktop은 Code 탭 선택기에 Anthropic 이름을 그대로 유지합니다. 선택기 모델을 OpenCodex 모델에 바인딩하면 해당 선택 항목의 요청이 바인딩된 모델로 처리됩니다. 로컬 프록시를 통과하는 Claude Code 트래픽(Desktop Code 탭과 claude CLI)만 이 바인딩을 사용하며 ocx claude에는 영향이 없습니다. 바인딩은 다음 요청부터 적용되므로 완전히 종료 후 다시 열 필요가 없습니다.", "claudeDesktop.firstParty.bindings.empty": "아직 바인딩이 없습니다 — 선택기 모델은 기본 경로를 따릅니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 93da56494c8..8f6ea78d16b 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2725,11 +2725,25 @@ export const ru: Record = { "claudeDesktop.mode.defaultBadge": "по умолчанию", "claudeDesktop.mode.current": "текущий", "claudeDesktop.mode.firstPartyHint": "Desktop остаётся в claude.ai (чат, коннекторы, удалённое управление). Через OpenCodex идут только вкладка Code, её субагенты и CLI Claude Code.", + "claudeDesktop.mode.firstPartyRisk": "Риск для учётной записи: в режиме First-party трафик подписки Claude проходит через локальный перехватывающий прокси. Anthropic может счесть это нарушением условий использования и приостановить действие учётной записи. Шлюз — режим по умолчанию.", "claudeDesktop.mode.gatewayHint": "Всё приложение переключается на OpenCodex как шлюз. Чат работает локально; функции claude.ai недоступны.", "claudeDesktop.mode.switchNote": "Переключение удаляет настройки другого режима. После применения полностью закройте и заново откройте Desktop.", "claudeDesktop.firstParty.proxyRunning": "Локальный прокси на 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "Локальный прокси 127.0.0.1:{port} не запущен — перезапустите OpenCodex", "claudeDesktop.firstParty.interceptDisabled": "Перехватывающий прокси отключён в конфигурации (claudeCode.intercept.enabled)", + "claudeDesktop.picker.title": "Выбор моделей Claude Desktop", + "claudeDesktop.picker.hint": "Показывать модели OpenCodex в выборе вкладки Code в Desktop через выделенный локальный прокси.", + "claudeDesktop.picker.toggle": "Включить выбор Claude Desktop", + "claudeDesktop.picker.state.active": "Выбор активен", + "claudeDesktop.picker.state.restart": "Полностью закройте и снова откройте Claude Desktop, чтобы применить профиль выбора.", + "claudeDesktop.picker.state.trustPending": "Ожидание шага связки ключей", + "claudeDesktop.picker.state.trustDeclined": "Доверие связке ключей отклонено", + "claudeDesktop.picker.state.proxyUnavailable": "Прокси выбора не запущен", + "claudeDesktop.picker.state.unsupported": "Режим выбора не поддерживается в этой ОС", + "claudeDesktop.picker.state.notFirstParty": "Выбор доступен только в режиме first-party", + "claudeDesktop.picker.state.profileFailed": "Не удалось применить профиль выбора", + "claudeDesktop.picker.models": "В выборе доступно моделей: {count}", + "claudeDesktop.picker.offlineNote": "При включенном режиме выбора Claude Desktop подключается к сети через OpenCodex. Если OpenCodex остановится, Desktop останется офлайн до перезапуска или отключения режима выбора.", "claudeDesktop.firstParty.bindings.title": "Привязки моделей вкладки Code", "claudeDesktop.firstParty.bindings.hint": "Claude Desktop сохраняет имена Anthropic в селекторе вкладки Code. Привяжите модель селектора к модели OpenCodex, и запросы к этой записи будет обслуживать привязанная модель. Эти привязки использует только трафик Claude Code через локальный прокси (вкладка Code в Desktop и CLI claude); на ocx claude они не влияют. Привязки действуют со следующего запроса — полный перезапуск не нужен.", "claudeDesktop.firstParty.bindings.empty": "Привязок пока нет — модели селектора используют маршрут по умолчанию.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 12ee9d37008..0f61ebd8836 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2874,11 +2874,25 @@ export const tr: Record = { "claudeDesktop.mode.defaultBadge": "varsayılan", "claudeDesktop.mode.current": "geçerli", "claudeDesktop.mode.firstPartyHint": "Desktop claude.ai'de oturum açık kalır (sohbet, bağlayıcılar, uzaktan kontrol). Yalnızca Code sekmesi, alt ajanları ve Claude Code CLI OpenCodex üzerinden geçer.", + "claudeDesktop.mode.firstPartyRisk": "Hesap riski: First-party modu, Claude abonelik trafiğinizi yerel bir trafik yakalama proxy'si üzerinden geçirir. Anthropic bunu kullanım koşullarının ihlali sayıp hesabı askıya alabilir. Ağ geçidi varsayılan moddur.", "claudeDesktop.mode.gatewayHint": "Uygulamanın tamamı OpenCodex'i ağ geçidi olarak kullanır. Sohbet yerelde çalışır; claude.ai özellikleri kullanılamaz.", "claudeDesktop.mode.switchNote": "Geçiş diğer modun yapılandırmasını kaldırır. Ardından Desktop'ı tamamen kapatıp yeniden açın.", "claudeDesktop.firstParty.proxyRunning": "Yerel proxy 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "Yerel proxy 127.0.0.1:{port} çalışmıyor — OpenCodex'i yeniden başlatın", "claudeDesktop.firstParty.interceptDisabled": "Yakalama proxy'si yapılandırmada kapalı (claudeCode.intercept.enabled)", + "claudeDesktop.picker.title": "Claude Desktop seçici", + "claudeDesktop.picker.hint": "OpenCodex modellerini özel yerel proxy üzerinden Desktop Code sekmesi seçicisinde gösterin.", + "claudeDesktop.picker.toggle": "Claude Desktop seçiciyi etkinleştir", + "claudeDesktop.picker.state.active": "Seçici etkin", + "claudeDesktop.picker.state.restart": "Seçici profilini uygulamak için Claude Desktop’tan tamamen çıkıp yeniden açın.", + "claudeDesktop.picker.state.trustPending": "Anahtar zinciri adımı bekleniyor", + "claudeDesktop.picker.state.trustDeclined": "Anahtar zinciri güveni reddedildi", + "claudeDesktop.picker.state.proxyUnavailable": "Seçici proxy’si çalışmıyor", + "claudeDesktop.picker.state.unsupported": "Seçici modu bu işletim sisteminde desteklenmiyor", + "claudeDesktop.picker.state.notFirstParty": "Seçici yalnızca first-party modunda kullanılabilir", + "claudeDesktop.picker.state.profileFailed": "Seçici profili uygulanamadı", + "claudeDesktop.picker.models": "Seçicide {count} model kullanılabilir", + "claudeDesktop.picker.offlineNote": "Seçici açıkken Claude Desktop ağa OpenCodex üzerinden erişir. OpenCodex durursa Desktop yeniden başlatılana veya seçici kapatılana kadar çevrimdışı kalır.", "claudeDesktop.firstParty.bindings.title": "Code sekmesi model bağlantıları", "claudeDesktop.firstParty.bindings.hint": "Claude Desktop, Code sekmesi seçicisinde Anthropic'in adlarını korur. Bir seçici modelini OpenCodex modeline bağladığınızda o seçici girdisine gelen istekler bağlı model tarafından sunulur. Bu bağlantıları yalnızca yerel proxy üzerinden geçen Claude Code trafiği (Desktop Code sekmesi ve claude CLI'si) kullanır; ocx claude etkilenmez. Bağlantılar sonraki istekte geçerli olur — tamamen kapatıp yeniden açmak gerekmez.", "claudeDesktop.firstParty.bindings.empty": "Henüz bağlantı yok — seçici modelleri varsayılan rotayı izler.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 9b8410ae1c5..3b9d7f3d6fd 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2863,11 +2863,25 @@ export const vi: Record = { "claudeDesktop.mode.defaultBadge": "mặc định", "claudeDesktop.mode.current": "hiện tại", "claudeDesktop.mode.firstPartyHint": "Desktop vẫn đăng nhập claude.ai (chat, connector, điều khiển từ xa). Chỉ tab Code, các subagent và Claude Code CLI đi qua OpenCodex.", + "claudeDesktop.mode.firstPartyRisk": "Rủi ro tài khoản: chế độ First-party chuyển lưu lượng gói thuê bao Claude của bạn qua proxy chặn bắt cục bộ. Anthropic có thể xem đây là hành vi vi phạm điều khoản sử dụng và đình chỉ tài khoản. Gateway là chế độ mặc định.", "claudeDesktop.mode.gatewayHint": "Toàn bộ ứng dụng chuyển sang dùng OpenCodex làm gateway. Chat chạy cục bộ; các tính năng claude.ai không khả dụng.", "claudeDesktop.mode.switchNote": "Chuyển chế độ sẽ xóa cấu hình của chế độ kia. Sau đó hãy thoát hẳn Desktop rồi mở lại.", "claudeDesktop.firstParty.proxyRunning": "Proxy cục bộ tại 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "Proxy cục bộ 127.0.0.1:{port} không chạy — khởi động lại OpenCodex", "claudeDesktop.firstParty.interceptDisabled": "Proxy chặn bắt đã bị tắt trong cấu hình (claudeCode.intercept.enabled)", + "claudeDesktop.picker.title": "Bộ chọn Claude Desktop", + "claudeDesktop.picker.hint": "Hiển thị các model OpenCodex trong bộ chọn ở tab Code của Desktop qua proxy cục bộ chuyên dụng.", + "claudeDesktop.picker.toggle": "Bật bộ chọn Claude Desktop", + "claudeDesktop.picker.state.active": "Bộ chọn đang hoạt động", + "claudeDesktop.picker.state.restart": "Thoát hoàn toàn rồi mở lại Claude Desktop để hoàn tất áp dụng hồ sơ bộ chọn.", + "claudeDesktop.picker.state.trustPending": "Đang chờ bước trong chuỗi khóa", + "claudeDesktop.picker.state.trustDeclined": "Đã từ chối tin cậy chuỗi khóa", + "claudeDesktop.picker.state.proxyUnavailable": "Proxy bộ chọn chưa chạy", + "claudeDesktop.picker.state.unsupported": "Hệ điều hành này không hỗ trợ chế độ bộ chọn", + "claudeDesktop.picker.state.notFirstParty": "Bộ chọn chỉ khả dụng trong chế độ first-party", + "claudeDesktop.picker.state.profileFailed": "Không thể áp dụng hồ sơ bộ chọn", + "claudeDesktop.picker.models": "{count} model khả dụng trong bộ chọn", + "claudeDesktop.picker.offlineNote": "Khi bật chế độ bộ chọn, Claude Desktop truy cập mạng qua OpenCodex. Nếu OpenCodex dừng, Desktop sẽ ngoại tuyến cho đến khi được khởi động lại hoặc tắt chế độ bộ chọn.", "claudeDesktop.firstParty.bindings.title": "Liên kết mô hình cho tab Code", "claudeDesktop.firstParty.bindings.hint": "Claude Desktop giữ nguyên tên của Anthropic trong bộ chọn của tab Code. Liên kết một mô hình trong bộ chọn với mô hình OpenCodex và các yêu cầu tới mục đó sẽ do mô hình được liên kết phục vụ. Chỉ lưu lượng Claude Code đi qua proxy cục bộ (tab Code của Desktop và CLI claude) dùng các liên kết này; ocx claude không bị ảnh hưởng. Liên kết có hiệu lực từ yêu cầu tiếp theo — không cần thoát hẳn rồi mở lại.", "claudeDesktop.firstParty.bindings.empty": "Chưa có liên kết nào — mô hình trong bộ chọn dùng tuyến mặc định.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 4e35f1be38c..7089aa98c14 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2841,11 +2841,25 @@ export const zhTW: Record = { "claudeDesktop.mode.defaultBadge": "預設", "claudeDesktop.mode.current": "目前", "claudeDesktop.mode.firstPartyHint": "Desktop 維持登入 claude.ai(聊天、連接器、遠端控制)。僅 Code 分頁、其子代理與 Claude Code CLI 經由 OpenCodex。", + "claudeDesktop.mode.firstPartyRisk": "帳號風險:第一方模式會讓您的 Claude 訂閱流量經過本機攔截代理。Anthropic 可能將此視為違反服務條款並暫停帳號。閘道是預設模式。", "claudeDesktop.mode.gatewayHint": "整個應用程式改用 OpenCodex 作為閘道。聊天在本機執行;claude.ai 功能無法使用。", "claudeDesktop.mode.switchNote": "切換會移除另一模式的設定。套用後請完全結束並重新開啟 Desktop。", "claudeDesktop.firstParty.proxyRunning": "本機代理 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "本機代理 127.0.0.1:{port} 未執行 — 請重新啟動 OpenCodex", "claudeDesktop.firstParty.interceptDisabled": "設定中已停用攔截代理(claudeCode.intercept.enabled)", + "claudeDesktop.picker.title": "Claude Desktop 模型選擇器", + "claudeDesktop.picker.hint": "透過專用本機代理伺服器,將 OpenCodex 模型顯示在 Desktop 的 Code 分頁選擇器中。", + "claudeDesktop.picker.toggle": "啟用 Claude Desktop 選擇器", + "claudeDesktop.picker.state.active": "選擇器已啟用", + "claudeDesktop.picker.state.restart": "請完整結束並重新開啟 Claude Desktop,完成選擇器設定套用。", + "claudeDesktop.picker.state.trustPending": "正在等待鑰匙圈步驟", + "claudeDesktop.picker.state.trustDeclined": "鑰匙圈信任遭拒", + "claudeDesktop.picker.state.proxyUnavailable": "選擇器代理伺服器未執行", + "claudeDesktop.picker.state.unsupported": "此作業系統不支援選擇器模式", + "claudeDesktop.picker.state.notFirstParty": "選擇器僅適用於第一方模式", + "claudeDesktop.picker.state.profileFailed": "無法套用選擇器設定", + "claudeDesktop.picker.models": "選擇器中有 {count} 個可用模型", + "claudeDesktop.picker.offlineNote": "啟用選擇器模式時,Claude Desktop 會透過 OpenCodex 存取網路。如果 OpenCodex 停止,Desktop 將保持離線,直到重新啟動或關閉選擇器模式。", "claudeDesktop.firstParty.bindings.title": "Code 分頁模型綁定", "claudeDesktop.firstParty.bindings.hint": "Claude Desktop 在 Code 分頁選擇器中保留 Anthropic 的名稱。將選擇器模型綁定到 OpenCodex 模型後,該選擇器項目的請求將由綁定的模型處理。只有經由本機代理的 Claude Code 流量(Desktop Code 分頁與 claude CLI)使用這些綁定;ocx claude 不受影響。綁定於下一個請求生效,無需完全結束並重新開啟。", "claudeDesktop.firstParty.bindings.empty": "尚無綁定 — 選擇器模型將使用預設路由。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 895a7bd9642..651ce5be2d9 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2852,11 +2852,25 @@ export const zh: Record = { "claudeDesktop.mode.defaultBadge": "默认", "claudeDesktop.mode.current": "当前", "claudeDesktop.mode.firstPartyHint": "Desktop 保持登录 claude.ai(聊天、连接器、远程控制)。仅 Code 标签页、其子代理和 Claude Code CLI 经由 OpenCodex。", + "claudeDesktop.mode.firstPartyRisk": "账户风险:第一方模式会让您的 Claude 订阅流量经过本地拦截代理。Anthropic 可能将此视为违反服务条款并暂停账户。网关是默认模式。", "claudeDesktop.mode.gatewayHint": "整个应用改用 OpenCodex 作为网关。聊天在本地运行;claude.ai 功能不可用。", "claudeDesktop.mode.switchNote": "切换会移除另一模式的配置。应用后请完全退出并重新打开 Desktop。", "claudeDesktop.firstParty.proxyRunning": "本地代理 127.0.0.1:{port}", "claudeDesktop.firstParty.proxyStopped": "本地代理 127.0.0.1:{port} 未运行 — 请重启 OpenCodex", "claudeDesktop.firstParty.interceptDisabled": "配置中已禁用拦截代理(claudeCode.intercept.enabled)", + "claudeDesktop.picker.title": "Claude Desktop 模型选择器", + "claudeDesktop.picker.hint": "通过专用本地代理,将 OpenCodex 模型显示在 Desktop 的 Code 标签选择器中。", + "claudeDesktop.picker.toggle": "启用 Claude Desktop 选择器", + "claudeDesktop.picker.state.active": "选择器已启用", + "claudeDesktop.picker.state.restart": "请完全退出并重新打开 Claude Desktop,以完成选择器配置的应用。", + "claudeDesktop.picker.state.trustPending": "正在等待钥匙串步骤", + "claudeDesktop.picker.state.trustDeclined": "钥匙串信任已被拒绝", + "claudeDesktop.picker.state.proxyUnavailable": "选择器代理未运行", + "claudeDesktop.picker.state.unsupported": "此操作系统不支持选择器模式", + "claudeDesktop.picker.state.notFirstParty": "选择器仅在第一方模式下可用", + "claudeDesktop.picker.state.profileFailed": "无法应用选择器配置", + "claudeDesktop.picker.models": "选择器中有 {count} 个可用模型", + "claudeDesktop.picker.offlineNote": "启用选择器模式后,Claude Desktop 会通过 OpenCodex 访问网络。如果 OpenCodex 停止,Desktop 将保持离线,直到重启或关闭选择器模式。", "claudeDesktop.firstParty.bindings.title": "Code 标签页模型绑定", "claudeDesktop.firstParty.bindings.hint": "Claude Desktop 在 Code 标签页选择器中保留 Anthropic 的名称。将选择器模型绑定到 OpenCodex 模型后,该选择器条目的请求将由绑定的模型处理。只有经由本地代理的 Claude Code 流量(Desktop Code 标签页和 claude CLI)使用这些绑定;ocx claude 不受影响。绑定在下一个请求时生效,无需完全退出并重新打开。", "claudeDesktop.firstParty.bindings.empty": "暂无绑定 — 选择器模型将使用默认路由。", diff --git a/gui/src/main.tsx b/gui/src/main.tsx index 4d551be7097..f65420bb4cb 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -15,6 +15,7 @@ import "./styles/sidebar-brand.css"; import "./styles/fast-rows-setting.css"; import "./styles/claude-desktop-mode-picker.css"; import "./styles/claude-first-party-bindings.css"; +import "./styles/claude-desktop-picker.css"; import "./styles/anthropic-reset-grants.css"; import "./pages/tray.css"; diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index 2cb478a5b9b..e24aab1dfe2 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -9,6 +9,7 @@ import { readSessionListCacheEntry, writeSessionListCacheEntry } from "../sessio import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import ClaudeFirstPartyBindings from "../components/ClaudeFirstPartyBindings"; +import ClaudeDesktopPicker, { type DesktopPickerStatus } from "../components/ClaudeDesktopPicker"; const FAMILIES = ["opus", "fable", "sonnet", "haiku"] as const; type Family = typeof FAMILIES[number]; @@ -46,6 +47,23 @@ interface DesktopModel { type DesktopMode = "first-party" | "gateway"; const DESKTOP_MODES: readonly DesktopMode[] = ["first-party", "gateway"]; +function isDesktopPickerStatus(value: unknown): value is DesktopPickerStatus { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return typeof v.desired === "boolean" + && typeof v.supported === "boolean" + && ["trusted", "untrusted", "unsupported", "unknown"].includes(v.trust as string) + && ["absent", "applied", "not_selected", "unsafe"].includes(v.profile as string) + && typeof v.listenerReady === "boolean" + && typeof v.effective === "boolean" + && ["active", "restart_required", "unsupported_platform", "not_first_party", "integration_off", "disabled", "proxy_unavailable", "mode_not_committed", "trust_pending", "trust_declined", "profile_failed"].includes(v.reason as string) + && typeof v.models === "number" && Number.isFinite(v.models) && v.models >= 0 + && (v.snapshotAt === null || typeof v.snapshotAt === "number") + && (v.lastBootstrapAt === null || typeof v.lastBootstrapAt === "number") + && (v.hint === undefined || typeof v.hint === "string") + && (v.residual === undefined || (Array.isArray(v.residual) && v.residual.every(item => typeof item === "string"))); +} + interface DesktopFirstPartyStatus { applied: boolean; stale: boolean; @@ -53,6 +71,7 @@ interface DesktopFirstPartyStatus { interceptRunning: boolean; proxyPort: number; caCertPath: string; + picker?: DesktopPickerStatus; /** Desktop picker model id → OpenCodex route. Absent on servers that predate bindings. */ modelBindings?: Record; /** Common Desktop picker ids offered as add-row suggestions. */ @@ -63,6 +82,7 @@ interface DesktopStatus { desiredEnabled: boolean; /** Effective mode: a gateway profile on disk wins; otherwise the saved/default mode. */ mode?: DesktopMode; + riskWarning?: { code: string; message: string } | null; firstParty?: DesktopFirstPartyStatus; applied: boolean; appliedAt: string | null; @@ -86,12 +106,17 @@ function isDesktopStatus(value: unknown): value is DesktopStatus { const v = value as Record; const health = v.health as Record | null | undefined; if (v.mode !== undefined && !DESKTOP_MODES.includes(v.mode as DesktopMode)) return false; + if (v.riskWarning !== undefined && v.riskWarning !== null) { + const warning = v.riskWarning as Record; + if (typeof warning !== "object" || typeof warning.code !== "string" || typeof warning.message !== "string") return false; + } if (v.firstParty !== undefined) { const fp = v.firstParty as Record | null; if (typeof fp !== "object" || fp === null || typeof fp.applied !== "boolean" || typeof fp.stale !== "boolean" || typeof fp.interceptEnabled !== "boolean" || typeof fp.interceptRunning !== "boolean" || typeof fp.proxyPort !== "number" || typeof fp.caCertPath !== "string") return false; + if (fp.picker !== undefined && !isDesktopPickerStatus(fp.picker)) return false; if (fp.modelBindings !== undefined) { const mb = fp.modelBindings; if (typeof mb !== "object" || mb === null || Array.isArray(mb) @@ -237,7 +262,7 @@ export default function ClaudeDesktop({ // is not, and restoring five open rows on reload would rebuild the wall this removes. const [openRows, setOpenRows] = useState>({}); // The mode the user wants the next apply to use. null = follow whatever /status reports - // (first-party by default), so a page load never silently changes an existing install. + // (gateway by default), so a page load never silently changes an existing install. const [chosenMode, setChosenMode] = useState(null); const importRef = useRef(null); const fetchDesktop = useCallback(async (signal: AbortSignal): Promise => { @@ -348,12 +373,12 @@ export default function ClaudeDesktop({ const status = statusState.data ?? cachedStatus; const statusFailed = statusState.showError; // Until /status answers, the effective mode is unknown: the picker renders unchecked and - // disabled instead of flashing the first-party default at a gateway install. A confirmed + // disabled instead of flashing the gateway default at a first-party install. A confirmed // /status failure unlocks it (the status bar already shows the error) so an apply can still // be attempted, but no "current" badge is claimed. const modeKnown = status !== null; const modePickable = modeKnown || statusFailed; - const effectiveMode: DesktopMode = status?.mode ?? "first-party"; + const effectiveMode: DesktopMode = status?.mode ?? "gateway"; const selectedMode: DesktopMode = chosenMode ?? effectiveMode; const modeDirty = modeKnown && selectedMode !== effectiveMode; @@ -524,7 +549,7 @@ export default function ClaudeDesktop({ /> {mode === "first-party" ? t("claudeDesktop.mode.firstParty") : t("claudeDesktop.mode.gateway")} - {mode === "first-party" && {t("claudeDesktop.mode.defaultBadge")}} + {mode === "gateway" && {t("claudeDesktop.mode.defaultBadge")}} {modeKnown && effectiveMode === mode && {t("claudeDesktop.mode.current")}} @@ -532,6 +557,7 @@ export default function ClaudeDesktop({ ))} + {selectedMode === "first-party" &&

{t("claudeDesktop.mode.firstPartyRisk")}

} {modeDirty && {t("claudeDesktop.mode.switchNote")}} @@ -595,12 +621,24 @@ export default function ClaudeDesktop({ )} + {status?.riskWarning && selectedMode !== "first-party" &&

{t("claudeDesktop.mode.firstPartyRisk")}

}
{announcement}
{message && {message.text}} {loadState.showError && {t("claudeDesktop.loadFail")}} {statusFailed && status && {t("claudeDesktop.loadFail")}} + {effectiveMode === "first-party" && ( + status?.firstParty?.picker && ( + void statusResource.refresh()} + /> + ) + )} + {effectiveMode === "first-party" && ( { await new Promise(r => setTimeout(r, 200)); }); expect((container.querySelector(".claude-mode-picker") as HTMLFieldSetElement).disabled).toBe(false); - expect(radio("first-party").checked).toBe(true); - expect(radio("gateway").checked).toBe(false); + expect(radio("gateway").checked).toBe(true); + expect(radio("first-party").checked).toBe(false); + expect(radio("gateway").closest("label")?.querySelector(".claude-mode-default")).not.toBeNull(); expect(container.querySelector(".claude-mode-current")).toBeNull(); expect(container.querySelector(".claude-status-bar")?.textContent ?? "").toContain("Failed to load"); }); @@ -164,9 +165,11 @@ test("the picker follows the effective mode reported by /status and shows the pr expect(radio("first-party").checked).toBe(true); expect(radio("gateway").checked).toBe(false); const firstPartyOption = radio("first-party").closest("label")!; - expect(firstPartyOption.querySelector(".claude-mode-default")).not.toBeNull(); + expect(firstPartyOption.querySelector(".claude-mode-default")).toBeNull(); + expect(radio("gateway").closest("label")?.querySelector(".claude-mode-default")).not.toBeNull(); expect(firstPartyOption.querySelector(".claude-mode-current")).not.toBeNull(); expect(container.querySelector(".claude-mode-switch-note")).toBeNull(); + expect(container.querySelector(".claude-mode-picker .claude-mode-risk")?.textContent).toContain("suspend the account"); const bar = container.querySelector(".claude-status-bar")!; expect(bar.className).toContain("applied"); @@ -183,6 +186,16 @@ test("a stopped intercept proxy is surfaced in first-party mode", async () => { expect(container.querySelector(".claude-status-bar")?.textContent ?? "").toContain("is not running"); }); +test("an applied first-party warning stays visible below status while gateway is selected", async () => { + installFetch(statusPayload({ riskWarning: { code: "first_party_account_suspension_risk", message: "risk" } })); + await mount(); + await act(async () => { radio("gateway").click(); }); + expect(container.querySelector(".claude-mode-picker .claude-mode-risk")).toBeNull(); + const bar = container.querySelector(".claude-status-bar")!; + expect(bar.nextElementSibling?.classList.contains("claude-mode-risk")).toBe(true); + expect(bar.nextElementSibling?.textContent).toContain("suspend the account"); +}); + test("selecting the other mode flips the apply label and sends that mode in the POST body", async () => { await mount(); await act(async () => { @@ -227,7 +240,7 @@ test("activeProfile=false only demotes the status bar in gateway mode", async () }); test("no radio is checked and the picker is disabled until /status answers", async () => { - // A gateway install must never see the first-party default flash while /status is in flight. + // The picker must not claim a gateway selection before /status answers. let releaseStatus: () => void = () => {}; const gate = new Promise(resolve => { releaseStatus = resolve; }); const gatewayStatus = statusPayload({ mode: "gateway", activeProfile: true, firstParty: undefined }); diff --git a/gui/tests/claude-desktop-picker.test.tsx b/gui/tests/claude-desktop-picker.test.tsx new file mode 100644 index 00000000000..76cbfc5bbfc --- /dev/null +++ b/gui/tests/claude-desktop-picker.test.tsx @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import ClaudeDesktopPicker, { type DesktopPickerStatus } from "../src/components/ClaudeDesktopPicker"; +import { LanguageProvider } from "../src/i18n/provider"; + +const globals = ["document", "window", "navigator", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; +let requests: { url: string; init?: RequestInit }[] = []; + +const basePicker: DesktopPickerStatus = { + desired: true, + supported: true, + trust: "trusted", + profile: "applied", + listenerReady: true, + effective: true, + reason: "active", + models: 4, + snapshotAt: null, + lastBootstrapAt: null, +}; + +function installFetch(response: (request: { url: string; init?: RequestInit }) => { status: number; body: unknown }) { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string, init?: RequestInit) => { + const request = { url: String(url), init }; + requests.push(request); + const next = response(request); + return { + ok: next.status >= 200 && next.status < 300, + status: next.status, + json: async () => next.body, + text: async () => JSON.stringify(next.body), + } as unknown as Response; + }, + }); +} + +beforeEach(() => { + requests = []; + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + installFetch(() => ({ status: 200, body: { ok: true, picker: { ...basePicker, desired: false, effective: false, reason: "proxy_unavailable" } } })); + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container 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: previousGlobals[key] }); +}); + +async function mount(picker = basePicker) { + await act(async () => { + root = createRoot(container); + root.render(); + }); +} + +test("renders active state, model count, and the fixed offline note", async () => { + await mount(); + expect(container.querySelector(".claude-picker-title")?.textContent).toBe("Claude Desktop picker"); + expect(container.querySelector(".claude-picker-state")?.textContent).toContain("Picker is active"); + expect(container.querySelector(".claude-picker-models")?.textContent).toContain("4 models"); + expect(container.querySelector(".claude-picker-offline-note")?.textContent).toContain("Claude Desktop reaches the network through OpenCodex"); + expect(container.querySelector("[role=switch]")?.getAttribute("aria-checked")).toBe("true"); +}); + +test("sends the persisted toggle and renders a reported proxy refusal", async () => { + let response: { status: number; body: unknown } = { + status: 503, + body: { ok: false, code: "picker_proxy_unavailable", picker: { ...basePicker, effective: false, reason: "proxy_unavailable" } }, + }; + installFetch(() => response); + await mount(); + await act(async () => { (container.querySelector("[role=switch]") as HTMLButtonElement).click(); }); + + expect(requests[0]?.url).toBe("/api/claude-desktop/picker"); + expect(JSON.parse(String(requests[0]?.init?.body))).toEqual({ enabled: false, persist: true }); + expect(container.querySelector(".claude-picker-state")?.textContent).toContain("Picker proxy is not running"); + expect(container.querySelector(".notice-err")).toBeNull(); + response = { status: 200, body: { ok: true, picker: { ...basePicker, reason: "trust_pending", hint: "ocx claude desktop picker trust" } } }; + await act(async () => { (container.querySelector("[role=switch]") as HTMLButtonElement).click(); }); + expect(container.querySelector(".claude-picker-state")?.textContent).toContain("Waiting for the keychain step"); + expect(container.querySelector(".claude-picker-state code")?.textContent).toBe("ocx claude desktop picker trust"); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 34ca5d0a67e..6df6f6de8c6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -363,9 +363,19 @@ "claude-desktop-config-path.test.ts": "claude-integration", "claude-desktop-discovery.test.ts": "claude-integration", "claude-desktop-first-party.test.ts": "claude-integration", + "claude-desktop-first-party-guards.test.ts": "claude-integration", "claude-desktop-mode-explanation.test.ts": "claude-integration", + "claude-desktop-picker.test.ts": "claude-integration", + "claude-desktop-picker-profile.test.ts": "claude-integration", + "claude-desktop-picker-routes.test.ts": "claude-integration", "claude-desktop-native-context.test.ts": "claude-integration", "claude-desktop-policy.test.ts": "claude-integration", + "claude-picker-bootstrap.test.ts": "claude-integration", + "claude-picker-ca.test.ts": "claude-integration", + "claude-picker-listener.test.ts": "claude-integration", + "claude-picker-models.test.ts": "claude-integration", + "claude-picker-runtime.test.ts": "claude-integration", + "claude-picker-trust.test.ts": "claude-integration", "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index ecc6a201cb9..a2d31a8c7f0 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -437,6 +437,18 @@ JSON mode: `payload`. - Distinct from `claude desktop show`, which reports what this machine WOULD write; this reports what is actually in effect, which only the running proxy knows. +### `ocx claude desktop picker status` + +First-party picker mode: whether Claude Desktop's Code tab lists opencodex models, and what is missing if not. + +| Method | Route | +|---|---| +| GET | `/api/claude-desktop/picker` | + +JSON mode: `none`. + +- Reports desired, effective, keychain trust, the Desktop egress profile, the model count and a reason with the next command to run. + ## State-changing capabilities Each of these writes. Check the flags column before running one unattended. @@ -869,6 +881,43 @@ JSON mode: `none`. - Removing an id that is not bound is a no-op; the remaining bindings are printed. +### `ocx claude desktop picker on` + +Turn first-party picker mode on and remember the choice. + +| Method | Route | +|---|---| +| PUT | `/api/claude-desktop/picker` | + +JSON mode: `none`. + +- Needs a running proxy, first-party mode and macOS. The first time, macOS asks to trust a local certificate authority limited to claude.ai; when the server cannot show that prompt the command runs the trust step in this terminal. +- Claude Desktop then reaches the network through opencodex; fully quit and reopen Desktop afterwards. + +### `ocx claude desktop picker off` + +Turn first-party picker mode off, remove its Desktop egress profile and certificate trust, and remember the choice. + +| Method | Route | +|---|---| +| PUT | `/api/claude-desktop/picker` | + +JSON mode: `none`. + +- Works without a running proxy: the preference is saved and the picker profile and trust are removed locally. + +### `ocx claude desktop picker trust` + +Run the macOS keychain step for picker mode in this terminal, then ask the server to finish enabling it. + +| Method | Route | +|---|---| +| PUT | `/api/claude-desktop/picker` | + +JSON mode: `none`. + +- The server removes trust this command added if the enable is refused; if the request is lost, trust is left alone and picker status tells what happened. + ### `ocx integration native` Show or toggle the native Claude, Claude Desktop, Codex, and Grok integrations, and read the Cursor status (which builds are installed, gateway values, last request seen). @@ -956,6 +1005,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 52 -- of those, state-changing: 27 +- declared capabilities: 56 +- of those, state-changing: 30 - head-resolved invocations: 2 diff --git a/src/claude/desktop-3p-library.ts b/src/claude/desktop-3p-library.ts index 4deccbbc668..392bf025441 100644 --- a/src/claude/desktop-3p-library.ts +++ b/src/claude/desktop-3p-library.ts @@ -42,6 +42,8 @@ export interface Desktop3pMetadata { [key: string]: unknown; } +export const DESKTOP_PICKER_ENTRY_NAME = "opencodex-picker"; + export function parseMetadata(path: string): Desktop3pMetadata { if (!existsSync(path)) return { entries: [] }; const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; @@ -56,7 +58,7 @@ export function isRecord(value: unknown): value is Record { } export function isOwnedDesktopEntry(entry: Desktop3pMetadataEntry | undefined): boolean { - return entry?.name === "opencodex" || entry?.name === "opencodex-standard"; + return entry?.name === "opencodex" || entry?.name === "opencodex-standard" || entry?.name === DESKTOP_PICKER_ENTRY_NAME; } /** A gateway row is removable; the selected standard row must always remain. */ @@ -86,4 +88,3 @@ export function readDesktopProfileForeignKeys(path: string): Record !OPENCODEX_DESKTOP_PROFILE_KEYS.has(key)), ); } - diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index e7ffcfb72ba..b07b2379f98 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -146,7 +146,7 @@ export function legacyDesktop3pAlias(provider: string, modelId: string): string return `claude-opus-4-${deriveDesktop3pCode(`${provider}/${modelId}`)}`; } -function displayModelId(modelId: string): string { +export function displayModelId(modelId: string): string { return modelId // Capability markers like [1m] are not name text: strip the brackets so the label // reads "K3 1M", never "K3[1m]". diff --git a/src/claude/desktop-first-party.ts b/src/claude/desktop-first-party.ts index e010bb2f1e2..39ef7e41d91 100644 --- a/src/claude/desktop-first-party.ts +++ b/src/claude/desktop-first-party.ts @@ -3,21 +3,25 @@ * * Desktop has two ways to reach opencodex: * - * - `first-party` (default): the app keeps its ordinary claude.ai login, Chat tab, connectors - * and remote control. Only the Claude Code process it spawns for the Code tab (and that - * process's subagents) is redirected, through the `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` env - * in `~/.claude/settings.json` (src/claude/intercept/settings.ts) and the server's intercept - * pair (src/claude/intercept/runtime.ts). Nothing is written under Desktop's config library. - * - `gateway`: the historical third-party deployment profile (src/claude/desktop-3p.ts). The + * - `gateway` (default): the third-party deployment profile (src/claude/desktop-3p.ts). The * whole app is switched to a gateway build; picker entries are opencodex aliases. + * - `first-party`: the app keeps its ordinary claude.ai login, Chat tab, connectors and remote + * control. Only the Claude Code process it spawns for the Code tab (and that process's + * subagents) is redirected, through the `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` env in + * `~/.claude/settings.json` (src/claude/intercept/settings.ts) and the server's intercept pair + * (src/claude/intercept/runtime.ts). It sends Claude subscription traffic through a local + * interception proxy, so every first-party surface carries the account-risk notice in + * src/claude/desktop-risk.ts. * * The two are mutually exclusive on disk: applying one removes the other. The mode is persisted - * in `claudeCode.desktopMode`; installs that predate the field but already carry an applied - * gateway profile keep `gateway` until they explicitly re-apply, so an update never flips a - * working Desktop under the operator. + * in `claudeCode.desktopMode`. Installs that predate the field keep what they run: a selected + * gateway row or a gateway apply marker keeps `gateway`, and first-party env that opencodex wrote + * into Claude Code's settings keeps `first-party` (observeClaudeDesktopMode), so moving the default + * to gateway never flips a working Desktop under the operator. */ import { getConfigDir } from "../config/paths"; import type { OcxConfig } from "../types"; +import { inspectDesktop3pConfigLibrary } from "./desktop-3p"; import { claudeInterceptCaCertPath, ensureLocalInterceptCa } from "./intercept/local-ca"; import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/runtime"; import { @@ -33,7 +37,7 @@ import { export const CLAUDE_DESKTOP_MODES = ["first-party", "gateway"] as const; export type ClaudeDesktopMode = typeof CLAUDE_DESKTOP_MODES[number]; -export const DEFAULT_CLAUDE_DESKTOP_MODE: ClaudeDesktopMode = "first-party"; +export const DEFAULT_CLAUDE_DESKTOP_MODE: ClaudeDesktopMode = "gateway"; export function isClaudeDesktopMode(value: unknown): value is ClaudeDesktopMode { return typeof value === "string" && (CLAUDE_DESKTOP_MODES as readonly string[]).includes(value); @@ -41,15 +45,30 @@ export function isClaudeDesktopMode(value: unknown): value is ClaudeDesktopMode type DesktopModeConfig = Pick; +/** What the resolver may learn from disk. Only rows and settings opencodex owns count. */ +export interface ClaudeDesktopModeObservation { + /** Desktop's selected config-library row is our gateway (current or drifted). */ + ownedGatewaySelected?: boolean; + /** Claude Code's settings carry first-party env that opencodex wrote (applied or stale). */ + ownedFirstPartySettings?: boolean; +} + /** - * Effective Desktop mode. An explicit `claudeCode.desktopMode` wins; otherwise a persisted - * gateway apply marker (`desktopProfile.appliedFingerprint`) means a pre-existing gateway - * install and keeps `gateway`; everything else is the first-party default. + * Effective Desktop mode. An explicit `claudeCode.desktopMode` wins. Without one, what is on disk + * decides for installs that predate the field: a selected owned gateway row or a persisted gateway + * apply marker keeps `gateway`, owned first-party settings keep `first-party`. Everything else is + * the gateway default. Pure: callers that decide an apply or a write pass + * `observeClaudeDesktopMode(config)`. */ -export function resolveClaudeDesktopMode(config: DesktopModeConfig): ClaudeDesktopMode { +export function resolveClaudeDesktopMode( + config: DesktopModeConfig, + observed: ClaudeDesktopModeObservation = {}, +): ClaudeDesktopMode { const explicit = config.claudeCode?.desktopMode; if (isClaudeDesktopMode(explicit)) return explicit; + if (observed.ownedGatewaySelected) return "gateway"; if (config.claudeCode?.desktopProfile?.appliedFingerprint) return "gateway"; + if (observed.ownedFirstPartySettings) return "first-party"; return DEFAULT_CLAUDE_DESKTOP_MODE; } @@ -78,18 +97,16 @@ export function recordClaudeDesktopMode( } /** - * Mode an *apply* without an explicit choice should use. The first-party default only holds - * where the intercept proxy actually runs; with it disabled (or on a client role) an implied - * first-party apply would point Claude Code at a proxy that never starts, so fall back to the - * gateway profile. An explicit `desktopMode: "first-party"` is still honoured (and refused - * later with `intercept_disabled`, which names the fix). + * Mode an *apply* without an explicit choice should use. With gateway as the default, first-party + * only comes from an explicit choice or an observed first-party install; both are honoured even + * when the intercept is disabled, and the apply is then refused with `intercept_disabled`, which + * names the fix, instead of silently replacing the operator's mode. */ export function resolveClaudeDesktopApplyMode( config: Pick, + observed: ClaudeDesktopModeObservation = {}, ): ClaudeDesktopMode { - const resolved = resolveClaudeDesktopMode(config); - if (resolved === "gateway" || isClaudeDesktopMode(config.claudeCode?.desktopMode)) return resolved; - return claudeInterceptEnabled(config) ? "first-party" : "gateway"; + return resolveClaudeDesktopMode(config, observed); } export interface DesktopFirstPartyTarget { @@ -151,6 +168,30 @@ export function inspectDesktopFirstParty( }; } +/** + * Observe what Desktop runs today for the resolver. Never throws: an unreadable library or + * settings file is no evidence, and a foreign proxy env is never mistaken for ours. + */ +export function observeClaudeDesktopMode( + config: Pick, + options: DesktopFirstPartyOptions = {}, +): ClaudeDesktopModeObservation { + const observed: ClaudeDesktopModeObservation = {}; + try { + const library = inspectDesktop3pConfigLibrary({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null }); + observed.ownedGatewaySelected = library.kind === "gateway_ours" || library.kind === "gateway_drifted"; + } catch { // no-excuse-ok: catch -- an unreadable library is no gateway evidence. + observed.ownedGatewaySelected = false; + } + try { + const kind = inspectDesktopFirstParty(config, options).settings.kind; + observed.ownedFirstPartySettings = kind === "applied" || kind === "stale"; + } catch { // no-excuse-ok: catch -- unreadable settings are no first-party evidence. + observed.ownedFirstPartySettings = false; + } + return observed; +} + export type DesktopFirstPartyApplyResult = | { ok: true; changed: boolean; path: string; env: ClaudeInterceptEnv; proxyPort: number } | { ok: false; reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env"; path: string }; diff --git a/src/claude/desktop-picker-profile.ts b/src/claude/desktop-picker-profile.ts new file mode 100644 index 00000000000..a439d0a7fe5 --- /dev/null +++ b/src/claude/desktop-picker-profile.ts @@ -0,0 +1,302 @@ +/** + * Claude Desktop picker mode: the owned egress profile in Desktop's config library. + * + * The profile is an owned standard row named `opencodex-picker` whose file holds only + * `egressProxyUrl`, pointing Desktop at the dedicated picker CONNECT proxy + * (`ClaudeInterceptState.pickerProxyPort`). The previous selection is kept in opencodex state + * (`/claude-picker/profile-state.json`), never in Desktop's `_meta.json`. + */ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { withClientLifecycleSync } from "../client/lifecycle-lock"; +import { atomicWriteFile, getConfigDir, withConfigMutationLockSync } from "../config"; +import { + DESKTOP_PICKER_ENTRY_NAME, + isOwnedDesktopGatewayEntry, + parseMetadata, + profilePath, + resolveDesktop3pConfigLibraryPath, + SAFE_DESKTOP_PROFILE_ID, + type Desktop3pConfigLibraryOptions, + type Desktop3pMetadata, + type Desktop3pMetadataEntry, +} from "./desktop-3p-library"; + +// Keep a module-local rollback writer. Tests and callers may replace the public config writer to +// inject a forward-write failure; rollback must still be able to restore the prior bytes. +const rollbackAtomicWriteFile = atomicWriteFile; + +export interface DesktopPickerProfileState { entryId: string; previousAppliedId: string | null } +export type DesktopPickerProfileInspection = + | { kind: "absent" } + | { kind: "applied"; entryId: string; proxyUrl: string } + | { kind: "not_selected"; entryId: string } + | { kind: "unsafe"; reason: string }; + +export type DesktopPickerProfileOptions = Desktop3pConfigLibraryOptions & { configDir?: string }; + +const PICKER_DIRECTORY = "claude-picker"; + +function pickerStatePath(configDir: string): string { + return join(configDir, PICKER_DIRECTORY, "profile-state.json"); +} + +function metadataPath(libraryPath: string): string { + return join(libraryPath, "_meta.json"); +} + +function metadataJson(metadata: Desktop3pMetadata): string { + return JSON.stringify(metadata, null, 2) + "\n"; +} + +function isValidMetadata(metadata: Desktop3pMetadata): boolean { + return metadata.entries.every(entry => + entry !== null && typeof entry === "object" && typeof entry.id === "string" && typeof entry.name === "string"); +} + +function readPickerState(path: string): DesktopPickerProfileState | null { + if (!existsSync(path)) return null; + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + if (typeof parsed.entryId !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(parsed.entryId)) { + throw new Error("picker_profile_state_unreadable"); + } + if (parsed.previousAppliedId !== null + && parsed.previousAppliedId !== undefined + && (typeof parsed.previousAppliedId !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(parsed.previousAppliedId))) { + throw new Error("picker_profile_state_unreadable"); + } + return { entryId: parsed.entryId, previousAppliedId: parsed.previousAppliedId ?? null }; +} + +function readFileSnapshot(path: string): { exists: boolean; content?: string } { + return existsSync(path) ? { exists: true, content: readFileSync(path, "utf8") } : { exists: false }; +} + +function restoreFile(path: string, snapshot: { exists: boolean; content?: string }): void { + if (snapshot.exists) { + rollbackAtomicWriteFile(path, snapshot.content ?? ""); + } else if (existsSync(path)) { + unlinkSync(path); + } +} + +function unlinkIfPresent(path: string): void { + if (existsSync(path)) unlinkSync(path); +} + +function pickerEntries(metadata: Desktop3pMetadata): Desktop3pMetadataEntry[] { + return metadata.entries.filter(entry => entry.name === DESKTOP_PICKER_ENTRY_NAME); +} + +function pickerEntry(metadata: Desktop3pMetadata): Desktop3pMetadataEntry | undefined { + const entries = pickerEntries(metadata); + if (entries.length > 1) throw new Error("duplicate_picker_entries"); + const entry = entries[0]; + if (entry && !SAFE_DESKTOP_PROFILE_ID.test(entry.id)) throw new Error("unsafe_picker_id"); + return entry; +} + +function profileObject(path: string): Record { + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("picker_profile_unreadable"); + } + return parsed as Record; +} + +function validPickerProfile(profile: Record): profile is { egressProxyUrl: string } { + const keys = Object.keys(profile); + const proxyUrl = profile.egressProxyUrl; + const port = typeof proxyUrl === "string" ? /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/.exec(proxyUrl)?.[1] : undefined; + return keys.length === 1 + && keys[0] === "egressProxyUrl" + && typeof proxyUrl === "string" + && port !== undefined + && Number(port) <= 65535; +} + +export function pickerEgressUrl(proxyPort: number): string { + return `http://127.0.0.1:${proxyPort}`; +} + +type ApplyPickerProfileResult = + | { ok: true; changed: boolean; path: string } + | { ok: false; reason: "gateway_selected" | "foreign_unreadable" | "write_failed" }; +type RemovePickerProfileResult = { ok: true; changed: boolean } | { ok: false; reason: string; residualPaths?: string[] }; + +/** + * Select the picker profile. Runs under the client lifecycle lock and the config mutation lock, + * the same boundary as the gateway writer (src/claude/desktop-3p.ts), so another process cannot + * interleave a gateway or catalog write with the selection. + */ +export function applyDesktopPickerProfile(options: { proxyPort: number } & DesktopPickerProfileOptions): ApplyPickerProfileResult { + try { + return withClientLifecycleSync(() => withConfigMutationLockSync(() => applyDesktopPickerProfileLocked(options))); + } catch { + return { ok: false, reason: "write_failed" }; + } +} + +/** Remove the picker profile under the same locks as `applyDesktopPickerProfile`. */ +export function removeDesktopPickerProfile(options: DesktopPickerProfileOptions = {}): RemovePickerProfileResult { + try { + return withClientLifecycleSync(() => withConfigMutationLockSync(() => removeDesktopPickerProfileLocked(options))); + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : "lock_unavailable" }; + } +} + +function applyDesktopPickerProfileLocked(options: { proxyPort: number } & DesktopPickerProfileOptions): ApplyPickerProfileResult { + const libraryPath = resolveDesktop3pConfigLibraryPath(options); + const configDir = options.configDir ?? getConfigDir(); + const metaPath = metadataPath(libraryPath); + let profile = join(libraryPath, "picker.json"); + try { + if (!Number.isInteger(options.proxyPort) || options.proxyPort < 1 || options.proxyPort > 65535) { + return { ok: false, reason: "write_failed" }; + } + mkdirSync(libraryPath, { recursive: true, mode: 0o700 }); + const metadata = parseMetadata(metaPath); + if (!isValidMetadata(metadata)) return { ok: false, reason: "foreign_unreadable" }; + if (metadata.appliedId !== undefined + && (typeof metadata.appliedId !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(metadata.appliedId))) { + return { ok: false, reason: "foreign_unreadable" }; + } + const selectedId = typeof metadata.appliedId === "string" ? metadata.appliedId : null; + const selected = selectedId === null ? undefined : metadata.entries.find(entry => entry.id === selectedId); + if (selected && isOwnedDesktopGatewayEntry(selected)) return { ok: false, reason: "gateway_selected" }; + + const existing = pickerEntry(metadata); + const entryId = existing?.id ?? randomUUID(); + profile = profilePath(libraryPath, entryId); + const target = JSON.stringify({ egressProxyUrl: pickerEgressUrl(options.proxyPort) }) + "\n"; + const previousStatePath = pickerStatePath(configDir); + const alreadySelected = selectedId === entryId; + const priorState = readPickerState(previousStatePath); + const previousAppliedId = alreadySelected ? priorState?.previousAppliedId ?? null : selectedId; + const oldMeta = readFileSnapshot(metaPath); + const oldProfile = readFileSnapshot(profile); + const backupPath = `${profile}.bak`; + const oldBackup = readFileSnapshot(backupPath); + const oldState = readFileSnapshot(previousStatePath); + const changed = !alreadySelected || !oldProfile.exists || oldProfile.content !== target; + if (!changed) return { ok: true, changed: false, path: profile }; + + mkdirSync(join(configDir, PICKER_DIRECTORY), { recursive: true, mode: 0o700 }); + try { + // Keep the same backup convention as the existing Desktop writer. It is removed with the + // picker row and also gives this transaction a private rollback source. + if (oldProfile.exists) atomicWriteFile(backupPath, oldProfile.content ?? ""); + atomicWriteFile(profile, target); + if (!alreadySelected) { + atomicWriteFile(previousStatePath, JSON.stringify({ entryId, previousAppliedId }) + "\n"); + } + const entries = existing + ? metadata.entries.map(entry => entry.id === entryId ? { ...entry, name: DESKTOP_PICKER_ENTRY_NAME } : entry) + : [...metadata.entries, { id: entryId, name: DESKTOP_PICKER_ENTRY_NAME }]; + atomicWriteFile(metaPath, metadataJson({ ...metadata, appliedId: entryId, entries })); + } catch { + try { + restoreFile(metaPath, oldMeta); + restoreFile(profile, oldProfile); + restoreFile(backupPath, oldBackup); + restoreFile(previousStatePath, oldState); + } catch { + // The public result remains deliberately opaque; callers can inspect the library. + } + return { ok: false, reason: "write_failed" }; + } + return { ok: true, changed: true, path: profile }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + return { ok: false, reason: reason === "picker_profile_state_unreadable" || reason === "duplicate_picker_entries" || reason === "unsafe_picker_id" || reason === "picker_profile_unreadable" + ? "foreign_unreadable" : "write_failed" }; + } +} + +function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions): RemovePickerProfileResult { + const libraryPath = resolveDesktop3pConfigLibraryPath(options); + const configDir = options.configDir ?? getConfigDir(); + const statePath = pickerStatePath(configDir); + const metaPath = metadataPath(libraryPath); + try { + if (!existsSync(metaPath)) { + const hadState = existsSync(statePath); + unlinkIfPresent(statePath); + return { ok: true, changed: hadState }; + } + const metadata = parseMetadata(metaPath); + if (!isValidMetadata(metadata)) return { ok: false, reason: "metadata_unreadable" }; + const picker = pickerEntry(metadata); + if (!picker) { + const hadState = existsSync(statePath); + unlinkIfPresent(statePath); + return { ok: true, changed: hadState }; + } + const selected = metadata.appliedId === picker.id; + let state: DesktopPickerProfileState | null = null; + try { state = readPickerState(statePath); } catch { return { ok: false, reason: "profile_state_unreadable" }; } + + let metadataAfterPivot = metadata; + if (selected) { + const previous = state?.entryId === picker.id ? state.previousAppliedId : null; + const previousExists = previous !== null && previous !== picker.id && metadata.entries.some(entry => entry.id === previous); + if (previousExists) { + metadataAfterPivot = { ...metadata, appliedId: previous }; + } else { + const standardId = randomUUID(); + atomicWriteFile(profilePath(libraryPath, standardId), "{}\n"); + metadataAfterPivot = { + ...metadata, + appliedId: standardId, + entries: [...metadata.entries, { id: standardId, name: "opencodex-standard" }], + }; + } + atomicWriteFile(metaPath, metadataJson(metadataAfterPivot)); + } + + const residualPaths: string[] = []; + for (const path of [profilePath(libraryPath, picker.id), `${profilePath(libraryPath, picker.id)}.bak`]) { + try { unlinkIfPresent(path); } catch { /* report the path without exposing file contents */ } + if (existsSync(path)) residualPaths.push(path); + } + if (residualPaths.length > 0) { + return { ok: false, reason: "cleanup_incomplete", residualPaths }; + } + + const entries = metadataAfterPivot.entries.filter(entry => entry.id !== picker.id); + try { + atomicWriteFile(metaPath, metadataJson({ ...metadataAfterPivot, entries })); + try { unlinkIfPresent(statePath); } catch { /* residual is reported below */ } + if (existsSync(statePath)) return { ok: false, reason: "cleanup_incomplete", residualPaths: [statePath] }; + } catch { + return { ok: false, reason: "write_failed", residualPaths: [metaPath] }; + } + return { ok: true, changed: true }; + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : "cleanup_failed" }; + } +} + +export function inspectDesktopPickerProfile(options: DesktopPickerProfileOptions = {}): DesktopPickerProfileInspection { + const libraryPath = resolveDesktop3pConfigLibraryPath(options); + const metaPath = metadataPath(libraryPath); + if (!existsSync(metaPath)) return { kind: "absent" }; + try { + const metadata = parseMetadata(metaPath); + if (!isValidMetadata(metadata)) return { kind: "unsafe", reason: "metadata_unreadable" }; + const picker = pickerEntry(metadata); + if (!picker) return { kind: "absent" }; + const path = profilePath(libraryPath, picker.id); + if (!existsSync(path)) return { kind: "unsafe", reason: "profile_missing" }; + const profile = profileObject(path); + if (!validPickerProfile(profile)) return { kind: "unsafe", reason: "invalid_profile" }; + return metadata.appliedId === picker.id + ? { kind: "applied", entryId: picker.id, proxyUrl: profile.egressProxyUrl } + : { kind: "not_selected", entryId: picker.id }; + } catch (error) { + const reason = error instanceof Error ? error.message : "metadata_unreadable"; + return { kind: "unsafe", reason }; + } +} diff --git a/src/claude/desktop-picker.ts b/src/claude/desktop-picker.ts new file mode 100644 index 00000000000..b2e9bfed6c5 --- /dev/null +++ b/src/claude/desktop-picker.ts @@ -0,0 +1,365 @@ +/** + * Claude Desktop picker mode: the one server-side controller for every picker mutation. + * + * While a server runs, enable, disable and Desktop mode transitions run here, serialized by one + * lock (devlog/_plan/260924_claude_desktop_picker_mode/030_wp4_picker_activation.md, D11). + */ +import type { OcxConfig } from "../types"; +import { claudeDesktopIntegrationEnabled } from "../codex/desired-state"; +import { getConfigDir } from "../config/paths"; +import { readFileSync, existsSync } from "node:fs"; +import { pickerCaCertPath, pickerCaFingerprints, ensurePickerCa, issuePickerLeaf, pickerLeafCertPath } from "./intercept/picker-ca"; +import { inspectPickerTrust, trustPickerCa, untrustPickerCa } from "./intercept/picker-trust"; +import type { PickerRuntime } from "./intercept/picker-runtime"; +import type { PickerTrustState, SecurityRunner } from "./intercept/picker-trust"; +import { + applyDesktopPickerProfile, + inspectDesktopPickerProfile, + removeDesktopPickerProfile, + pickerEgressUrl, + type DesktopPickerProfileInspection, + type DesktopPickerProfileOptions, +} from "./desktop-picker-profile"; +import { resolveClaudeDesktopMode, observeClaudeDesktopMode } from "./desktop-first-party"; + +/** Desktop no longer selects the picker profile, so removing the CA's trust cannot strand it. */ +function profileReleased(profile: DesktopPickerProfileInspection): boolean { + return profile.kind === "absent" || profile.kind === "not_selected"; +} + +export type DesktopPickerReason = "active" | "restart_required" | "unsupported_platform" | "not_first_party" + | "integration_off" | "disabled" | "proxy_unavailable" | "mode_not_committed" | "trust_pending" + | "trust_declined" | "profile_failed"; + +export interface DesktopPickerStatus { + desired: boolean; + supported: boolean; + trust: PickerTrustState; + profile: DesktopPickerProfileInspection["kind"]; + listenerReady: boolean; + effective: boolean; + reason: DesktopPickerReason; + models: number; + snapshotAt: number | null; + lastBootstrapAt: number | null; + hint?: string; + residual?: string[]; +} + +export interface DesktopPickerEnableOptions { + persist: boolean; + context: "cli-trusted" | "server"; + callerAddedTrust?: boolean; +} + +export interface DesktopPickerOps { + disableLocked(options: { persist: boolean }): Promise; + enableLocked(options: DesktopPickerEnableOptions): Promise; +} + +export interface DesktopPickerController { + enable(options: DesktopPickerEnableOptions): Promise; + disable(options: { persist: boolean }): Promise; + transition(fn: (ops: DesktopPickerOps) => Promise): Promise; + status(): Promise; + busy(): boolean; +} + +export interface DesktopPickerControllerDeps { + runtime: PickerRuntime; + readConfig: () => OcxConfig; + persistPreference: (value: boolean) => boolean; + /** Bound picker CONNECT proxy port (Desktop's egress), or null when it is not running. */ + proxyPort: () => number | null; + configDir: string; + security?: SecurityRunner; + platform?: NodeJS.Platform; + applyProfile?: typeof applyDesktopPickerProfile; + removeProfile?: typeof removeDesktopPickerProfile; + inspectProfile?: typeof inspectDesktopPickerProfile; +} + +function profileOptions(deps: DesktopPickerControllerDeps): DesktopPickerProfileOptions { + return { configDir: deps.configDir, ...(deps.platform ? { platform: deps.platform } : {}) }; +} + +function statusReasonForConfig(config: OcxConfig, platform: NodeJS.Platform, proxyBound: boolean): DesktopPickerReason { + if (platform !== "darwin") return "unsupported_platform"; + if (resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party") return "not_first_party"; + if (!claudeDesktopIntegrationEnabled(config)) return "integration_off"; + if (config.claudeCode?.intercept?.picker === false) return "disabled"; + if (!proxyBound) return "proxy_unavailable"; + return "restart_required"; +} + +function emptyStatus(config: OcxConfig, platform: NodeJS.Platform, reason: DesktopPickerReason): DesktopPickerStatus { + return { + desired: platform === "darwin" + && claudeDesktopIntegrationEnabled(config) + && resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) === "first-party" + && config.claudeCode?.intercept?.picker !== false, + supported: platform === "darwin", + trust: "unknown", + profile: "absent", + listenerReady: false, + effective: false, + reason, + models: 0, + snapshotAt: null, + lastBootstrapAt: null, + }; +} + +export function createDesktopPickerController(deps: DesktopPickerControllerDeps): DesktopPickerController { + const platform = deps.platform ?? process.platform; + const applyProfile = deps.applyProfile ?? applyDesktopPickerProfile; + const removeProfile = deps.removeProfile ?? removeDesktopPickerProfile; + const inspectProfile = deps.inspectProfile ?? inspectDesktopPickerProfile; + let pending = 0; + // When this process last changed Desktop's egress profile. Desktop reads that profile only at + // launch, so a restart is needed only until it has fetched a bootstrap since that change; a plain + // opencodex restart changes nothing Desktop reads and never asks for one. + let profileChangedAt: number | null = null; + let tail = Promise.resolve(); + + function inspect(): DesktopPickerProfileInspection { + try { return inspectProfile(profileOptions(deps)); } + catch { return { kind: "unsafe", reason: "inspection_failed" }; } + } + + function runtimeStatus(trustOverride?: PickerTrustState): DesktopPickerStatus { + const runtime = deps.runtime.status(); + const profile = inspect(); + const profileCurrent = profile.kind === "applied" && profile.proxyUrl === pickerEgressUrl(deps.proxyPort() ?? 0); + const trust = trustOverride ?? runtime.trust; + const effective = runtime.effective && profileCurrent && trust === "trusted"; + let reason: DesktopPickerReason; + if (platform !== "darwin") reason = "unsupported_platform"; + else if (!runtime.desired) reason = statusReasonForConfig(deps.readConfig(), platform, deps.proxyPort() !== null); + else if (deps.proxyPort() === null) reason = "proxy_unavailable"; + else if (trust !== "trusted") reason = "trust_pending"; + else if (!profileCurrent) reason = "profile_failed"; + else if (!runtime.listenerReady || !runtime.effective) reason = "restart_required"; + else reason = profileChangedAt !== null && (runtime.lastBootstrapAt === null || runtime.lastBootstrapAt < profileChangedAt) + ? "restart_required" + : "active"; + return { + desired: runtime.desired, + supported: runtime.supported, + trust, + profile: profile.kind, + listenerReady: runtime.listenerReady, + effective, + reason, + models: runtime.models, + snapshotAt: runtime.snapshotAt, + lastBootstrapAt: runtime.lastBootstrapAt, + }; + } + + async function withLock(fn: () => Promise): Promise { + pending += 1; + let release!: () => void; + const previous = tail; + tail = new Promise(resolve => { release = resolve; }); + await previous; + try { return await fn(); } + finally { pending -= 1; release(); } + } + + async function untrustCurrentCa(): Promise { + const caPath = pickerCaCertPath(deps.configDir); + if (platform !== "darwin" || !existsSync(caPath)) return true; + try { + const sha1 = pickerCaFingerprints(readFileSync(caPath, "utf8")).sha1; + return (await untrustPickerCa(caPath, sha1, deps.security, platform)).ok; + } catch { return false; } + } + + async function compensateTrust(shouldCompensate: boolean): Promise { + if (!shouldCompensate) return true; + // An already selected owned profile proves that another successful enable still relies on + // this CA. Keep its trust even when the current request is refused. + if (inspect().kind === "applied") return true; + try { + const ca = ensurePickerCa(deps.configDir); + const result = await untrustPickerCa( + pickerCaCertPath(deps.configDir), pickerCaFingerprints(ca.certPem).sha1, deps.security, platform, + ); + return result.ok; + } catch { return false; } + } + + function withTrustFailure(status: DesktopPickerStatus, trustFailed: boolean): DesktopPickerStatus { + if (!trustFailed) return status; + return { ...status, effective: false, hint: "ocx claude desktop picker off", residual: [...new Set([...(status.residual ?? []), "trust"])] }; + } + + async function enableLocked(options: DesktopPickerEnableOptions): Promise { + let trustedByAttempt = false; + let observedTrust: PickerTrustState | undefined; + const refuse = async (reason: DesktopPickerReason): Promise => { + const trustFailed = options.callerAddedTrust === true && !(await compensateTrust(true)); + return withTrustFailure({ ...runtimeStatus(observedTrust), reason }, trustFailed); + }; + const check = (config: OcxConfig, includePreference: boolean): DesktopPickerReason | null => { + if (platform !== "darwin") return "unsupported_platform"; + if (resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party") return "mode_not_committed"; + if (!claudeDesktopIntegrationEnabled(config)) return "integration_off"; + if (includePreference && config.claudeCode?.intercept?.picker === false) return "disabled"; + if (deps.proxyPort() === null) return "proxy_unavailable"; + return null; + }; + + const first = check(deps.readConfig(), false); + if (first) return refuse(first); + if (options.persist) { + try { + if (!deps.persistPreference(true)) return refuse("mode_not_committed"); + } catch { return refuse("mode_not_committed"); } + } + const second = check(deps.readConfig(), true); + if (second) return refuse(second); + + let caPath: string; + let caSha1: string; + try { + const ca = ensurePickerCa(deps.configDir); + issuePickerLeaf(ca, deps.configDir); + caPath = pickerCaCertPath(deps.configDir); + caSha1 = pickerCaFingerprints(ca.certPem).sha1; + let trust = await inspectPickerTrust(pickerLeafCertPath(deps.configDir), caSha1, deps.security, platform); + observedTrust = trust; + if (trust !== "trusted" && options.context === "server") { + const added = await trustPickerCa(caPath, deps.security, platform); + trustedByAttempt = added.ok; + trust = await inspectPickerTrust(pickerLeafCertPath(deps.configDir), caSha1, deps.security, platform); + observedTrust = trust; + } + if (trust !== "trusted") { + const compensationFailed = (trustedByAttempt || options.callerAddedTrust === true) && !(await compensateTrust(true)); + const result = withTrustFailure({ ...runtimeStatus(trust), reason: options.context === "cli-trusted" ? "trust_declined" : "trust_pending", hint: options.context === "cli-trusted" ? undefined : "ocx claude desktop picker trust" }, compensationFailed); + return result; + } + const afterTrust = check(deps.readConfig(), true); + if (afterTrust) { + const compensationFailed = (trustedByAttempt || options.callerAddedTrust === true) && !(await compensateTrust(true)); + return withTrustFailure({ ...runtimeStatus(trust), reason: afterTrust }, compensationFailed); + } + const proxyPort = deps.proxyPort(); + if (proxyPort === null) { + const compensationFailed = (trustedByAttempt || options.callerAddedTrust === true) && !(await compensateTrust(true)); + return withTrustFailure({ ...runtimeStatus(trust), reason: "proxy_unavailable" }, compensationFailed); + } + const applied = applyProfile({ ...profileOptions(deps), proxyPort }); + if (!applied.ok) { + const compensationFailed = (trustedByAttempt || options.callerAddedTrust === true) && !(await compensateTrust(true)); + return withTrustFailure({ ...runtimeStatus(trust), reason: "profile_failed", residual: ["profile"] }, compensationFailed); + } + if (applied.changed) profileChangedAt = Date.now(); + await deps.runtime.rearm(); + const result = runtimeStatus(); + if (result.reason === "active") return result; + return { ...result, reason: "restart_required" }; + } catch { + const compensationFailed = (trustedByAttempt || options.callerAddedTrust === true) && !(await compensateTrust(true)); + return withTrustFailure({ ...runtimeStatus(observedTrust), reason: "profile_failed" }, compensationFailed); + } + } + + async function disableLocked(options: { persist: boolean }): Promise { + const residual: string[] = []; + try { deps.runtime.disarm(); } catch { residual.push("runtime"); } + if (options.persist) { + try { if (!deps.persistPreference(false)) residual.push("preference"); } + catch { residual.push("preference"); } + } + try { + const removed = removeProfile(profileOptions(deps)); + if (!removed.ok) residual.push("profile"); + } catch { residual.push("profile"); } + // Trust goes only once Desktop no longer selects the picker profile: a selected profile without + // trust pins Desktop to a proxy whose certificate it rejects. Disarmed, the proxy only tunnels. + let trustRemoved = false; + if (profileReleased(inspect())) { + trustRemoved = await untrustCurrentCa(); + if (!trustRemoved) residual.push("trust"); + } else if (!residual.includes("profile")) { + residual.push("profile"); + } + let trustAfter = deps.runtime.status().trust; + try { trustAfter = await deps.runtime.refreshTrust(); } catch { /* status below retains the runtime's cached trust */ } + const result = runtimeStatus(trustRemoved ? trustAfter === "trusted" ? "trusted" : "untrusted" : trustAfter); + const profile = inspect(); + const trust = result.trust; + const effective = result.effective && profile.kind === "applied" && trust === "trusted"; + const reason = residual.length > 0 + ? (residual.includes("trust") ? "trust_pending" : "profile_failed") + : configPickerDisabled(deps.readConfig()) ? "disabled" : profile.kind === "applied" ? "profile_failed" : "restart_required"; + return { ...result, effective, reason, ...(residual.length > 0 ? { residual } : {}) }; + } + + function configPickerDisabled(config: OcxConfig): boolean { return config.claudeCode?.intercept?.picker === false; } + const ops: DesktopPickerOps = { disableLocked, enableLocked }; + return { + enable: options => withLock(() => enableLocked(options)), + disable: options => withLock(() => disableLocked(options)), + transition: fn => withLock(() => fn(ops)), + status: async () => runtimeStatus(), + busy: () => pending > 0, + }; +} + +export async function removeDesktopPickerArtifacts(options: { configDir?: string; security?: SecurityRunner; platform?: NodeJS.Platform } = {}): Promise<{ ok: boolean; residual?: string[] }> { + const configDir = options.configDir ?? getConfigDir(); + const platform = options.platform ?? process.platform; + const residual: string[] = []; + try { + const removed = removeDesktopPickerProfile({ configDir, ...(platform ? { platform } : {}) }); + if (!removed.ok) residual.push("profile"); + } catch { residual.push("profile"); } + let released = false; + try { released = profileReleased(inspectDesktopPickerProfile({ configDir, ...(platform ? { platform } : {}) })); } catch { /* unknown: keep trust */ } + if (!released) { + // Still selected (or unknown): keep trust so Desktop is not pinned to a CA it rejects. + if (!residual.includes("profile")) residual.push("profile"); + return { ok: false, residual }; + } + const caPath = pickerCaCertPath(configDir); + if (platform === "darwin" && existsSync(caPath)) { + try { + const sha1 = pickerCaFingerprints(readFileSync(caPath, "utf8")).sha1; + if (!(await untrustPickerCa(caPath, sha1, options.security, platform)).ok) residual.push("trust"); + } catch { residual.push("trust"); } + } + return residual.length ? { ok: false, residual } : { ok: true }; +} + +/** Static status when no controller runs in this process (intercept disabled, client role, bind failure). */ +export function offlinePickerStatus(config: OcxConfig, platform: NodeJS.Platform = process.platform): DesktopPickerStatus { + const reason = statusReasonForConfig(config, platform, false); + return emptyStatus(config, platform, reason); +} + +/** + * Run a Desktop mode transition under the controller lock, or, with no controller, with offline + * ops: disableLocked removes leftover picker artifacts; enableLocked reports proxy_unavailable. + */ +export function runDesktopTransition( + controller: DesktopPickerController | null, + fn: (ops: DesktopPickerOps) => Promise, + offline?: { configDir?: string; config: OcxConfig; security?: SecurityRunner; platform?: NodeJS.Platform }, +): Promise { + if (controller) return controller.transition(fn); + const fallback = offline ?? { config: {} as OcxConfig }; + const platform = fallback.platform ?? process.platform; + const ops: DesktopPickerOps = { + disableLocked: async () => { + const cleanup = await removeDesktopPickerArtifacts({ configDir: fallback.configDir, security: fallback.security, platform }); + const status = offlinePickerStatus(fallback.config, platform); + return cleanup.ok ? status : { ...status, effective: false, reason: "profile_failed", residual: cleanup.residual }; + }, + enableLocked: async () => ({ ...offlinePickerStatus(fallback.config, platform), reason: "proxy_unavailable" }), + }; + return fn(ops); +} diff --git a/src/claude/desktop-risk.ts b/src/claude/desktop-risk.ts new file mode 100644 index 00000000000..50c630d6633 --- /dev/null +++ b/src/claude/desktop-risk.ts @@ -0,0 +1,20 @@ +/** + * Account-risk notice for Claude Desktop first-party mode. + * + * First-party mode sends the Claude subscription's Claude Code traffic through the local + * interception proxy. Every surface that offers, applies or reports first-party shows this text + * (CLI, management status, native toggle, dashboard, docs), so it has one owner and cannot drift. + */ +export const FIRST_PARTY_ACCOUNT_RISK = { + code: "first_party_account_suspension_risk", + message: "First-party mode sends Claude subscription traffic through a local interception proxy. " + + "Anthropic may treat this as a violation of its terms and suspend the account. " + + "Use it at your own risk; gateway mode is the default.", +} as const; + +export type FirstPartyAccountRisk = { code: typeof FIRST_PARTY_ACCOUNT_RISK.code; message: string }; + +/** A fresh copy for JSON payloads, so no caller can mutate the shared constant. */ +export function firstPartyAccountRisk(): FirstPartyAccountRisk { + return { code: FIRST_PARTY_ACCOUNT_RISK.code, message: FIRST_PARTY_ACCOUNT_RISK.message }; +} diff --git a/src/claude/intercept/connect-proxy.ts b/src/claude/intercept/connect-proxy.ts index 86c9375513f..5599597d16f 100644 --- a/src/claude/intercept/connect-proxy.ts +++ b/src/claude/intercept/connect-proxy.ts @@ -6,7 +6,8 @@ import { BlockList, createServer, connect, isIP, type Server, type Socket } from * Claude Code honours `HTTPS_PROXY` and opens `CONNECT :443` for every upstream. This * proxy splices tunnels for the intercepted hosts onto the local TLS listener (which holds a * leaf certificate for them) and blindly relays every other tunnel to its real destination, - * so telemetry, OAuth refresh and claude.ai traffic stay native and opaque to opencodex. + * so telemetry and OAuth refresh stay native and opaque to opencodex. Picker mode may + * terminate only claude.ai tunnels, selected independently for each connection. * * Only CONNECT is served. Plain proxied HTTP requests are refused: Claude Code never sends * them, and answering them would turn this socket into a generic forward proxy. @@ -15,6 +16,7 @@ import { BlockList, createServer, connect, isIP, type Server, type Socket } from export const CLAUDE_INTERCEPT_HOSTS = ["api.anthropic.com"] as const; const MAX_HEAD_BYTES = 8 * 1024; +const MAX_PENDING_BYTES = 16 * 1024 * 1024; const HEAD_TIMEOUT_MS = 10_000; const UPSTREAM_CONNECT_TIMEOUT_MS = 15_000; @@ -23,10 +25,43 @@ export interface ConnectProxyOptions { interceptPort: number; /** Hostnames (lowercase) whose 443 tunnels are spliced onto `interceptPort`. */ interceptHosts?: readonly string[]; + /** Per-connection override, consulted before interceptHosts; null keeps the default. */ + selectTunnel?: (host: string, port: number, request: ConnectRequestInfo) => TunnelDecision | null | Promise; /** Test seam: dial the real destination for a blind tunnel. */ dialUpstream?: (host: string, port: number) => Socket; } +export type TunnelDecision = { kind: "intercept"; port: number } | { kind: "blind" }; + +/** What the CONNECT head says about its client, for tunnel choice only. Never logged. */ +export interface ConnectRequestInfo { + /** The CONNECT request's User-Agent header, or null when it sent none. */ + userAgent: string | null; +} + +/** + * Chromium (Claude Desktop's app) sends its browser User-Agent on CONNECT; the Claude Code + * processes Desktop spawns send none. The two trust different CAs, so the tunnel choice uses it. + * + * This is a routing hint, not a trust boundary: a local client can send any User-Agent. Neither + * answer grants anything a local process lacks already. A non-browser tunnel reaches the + * api.anthropic.com intercept, which the Claude Code proxy offers every local process; a browser + * tunnel reaches the claude.ai relay, which verifies upstream and injects no credential. A client + * that lies only breaks its own TLS, because each terminator presents a certificate only its + * intended client trusts. + */ +export function isBrowserConnect(request: ConnectRequestInfo): boolean { + return request.userAgent !== null && /^Mozilla\//.test(request.userAgent); +} + +function connectRequestInfo(head: string): ConnectRequestInfo { + const match = /\r\nuser-agent:[ \t]*([^\r\n]*)/i.exec(head); + return { userAgent: match ? match[1]!.trim() : null }; +} + +type ResolvedConnectProxyOptions = Required> + & Pick; + export interface ConnectProxyHandle { port: number; close(): Promise; @@ -90,7 +125,7 @@ function splice(client: Socket, upstream: Socket, pending: Uint8Array): void { upstream.pipe(client); } -function handleConnection(socket: Socket, options: Required>): void { +function handleConnection(socket: Socket, options: ResolvedConnectProxyOptions): void { let head: Buffer = Buffer.alloc(0); socket.on("error", () => socket.destroy()); socket.setTimeout(HEAD_TIMEOUT_MS, () => respond(socket, 408, "Request Timeout")); @@ -98,7 +133,8 @@ function handleConnection(socket: Socket, options: Required { head = head.length === 0 ? chunk : Buffer.concat([head, chunk]); const end = head.indexOf("\r\n\r\n"); - if (end === -1) { + // The cap holds however the head arrives: across reads or in one oversized read. + if (end === -1 || end + 4 > MAX_HEAD_BYTES) { if (head.length > MAX_HEAD_BYTES) { socket.off("data", onData); respond(socket, 431, "Request Header Fields Too Large"); @@ -109,7 +145,7 @@ function handleConnection(socket: Socket, options: Required { - if (!established) { - upstream.destroy(); - respond(socket, 504, "Gateway Timeout"); - } - }, UPSTREAM_CONNECT_TIMEOUT_MS); - upstream.once("error", () => { - clearTimeout(connectTimer); - if (!established) respond(socket, 502, "Bad Gateway"); - }); - upstream.once("connect", () => { - established = true; - clearTimeout(connectTimer); - if (socket.destroyed) { - upstream.destroy(); - return; + const dialFor = (selected: TunnelDecision | null): void => { + if (socket.destroyed) return; + const choice = selected ?? (target.port === 443 && options.interceptHosts.includes(target.host) + ? { kind: "intercept" as const, port: options.interceptPort } + : { kind: "blind" as const }); + const upstream = choice.kind === "intercept" + ? connect({ host: "127.0.0.1", port: choice.port }) + : options.dialUpstream(target.host, target.port); + let established = false; + const connectTimer = setTimeout(() => { + if (!established) { + upstream.destroy(); + respond(socket, 504, "Gateway Timeout"); + } + }, UPSTREAM_CONNECT_TIMEOUT_MS); + upstream.once("error", () => { + clearTimeout(connectTimer); + if (!established) respond(socket, 502, "Bad Gateway"); + }); + upstream.once("connect", () => { + established = true; + clearTimeout(connectTimer); + if (socket.destroyed) { + upstream.destroy(); + return; + } + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + splice(socket, upstream, pending); + socket.resume(); + }); + }; + if (!options.selectTunnel) { + dialFor(null); + return; + } + let decision: ReturnType>; + try { + decision = options.selectTunnel(target.host, target.port, connectRequestInfo(head.subarray(0, end).toString("latin1"))); + } catch { + dialFor({ kind: "blind" }); + return; + } + if (!decision || typeof (decision as Promise).then !== "function") { + dialFor(decision as TunnelDecision | null); + return; + } + // A paused socket does not notice a peer FIN until its readable side is drained. + // Hold later tunnel bytes here so a departing client cannot trigger a stale dial. + const onPendingReadable = () => { + let chunk: Buffer | null; + while ((chunk = socket.read() as Buffer | null) !== null) { + if (pending.length + chunk.length > MAX_PENDING_BYTES) { + socket.destroy(); + return; + } + pending = Buffer.concat([pending, chunk]); } - socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); - splice(socket, upstream, pending); - socket.resume(); + }; + const onPendingEnd = () => socket.destroy(); + socket.on("readable", onPendingReadable); + socket.once("end", onPendingEnd); + void Promise.resolve(decision).catch(() => ({ kind: "blind" as const })).then(choice => { + socket.off("readable", onPendingReadable); + socket.off("end", onPendingEnd); + dialFor(choice); }); }; socket.on("data", onData); @@ -150,9 +226,10 @@ function handleConnection(socket: Socket, options: Required { - const resolved = { + const resolved: ResolvedConnectProxyOptions = { interceptPort: options.interceptPort, interceptHosts: options.interceptHosts ?? CLAUDE_INTERCEPT_HOSTS, + selectTunnel: options.selectTunnel, dialUpstream: options.dialUpstream ?? ((host: string, targetPort: number) => connect({ host, port: targetPort })), }; return new Promise((resolve, reject) => { diff --git a/src/claude/intercept/local-ca.ts b/src/claude/intercept/local-ca.ts index 015c2a47014..772555dcc81 100644 --- a/src/claude/intercept/local-ca.ts +++ b/src/claude/intercept/local-ca.ts @@ -91,6 +91,7 @@ const OID = { organization: "2.5.4.10", ecdsaWithSha256: "1.2.840.10045.4.3.2", basicConstraints: "2.5.29.19", + nameConstraints: "2.5.29.30", keyUsage: "2.5.29.15", subjectAltName: "2.5.29.17", extendedKeyUsage: "2.5.29.37", @@ -112,6 +113,12 @@ function extension(oid: string, critical: boolean, value: Uint8Array): Uint8Arra : sequence(objectIdentifier(oid), octetString(value)); } +/** RFC 5280 permittedSubtrees: each GeneralSubtree has one dNSName base. */ +function nameConstraints(permitted: readonly string[]): Uint8Array { + const subtrees = permitted.map(name => sequence(contextTag(2, new TextEncoder().encode(name), false))); + return sequence(contextTag(0, concat(...subtrees))); +} + function subjectPublicKeyInfo(key: KeyObject): Uint8Array { return new Uint8Array(key.export({ type: "spki", format: "der" })); } @@ -173,9 +180,14 @@ export interface LocalInterceptCa extends PemKeyPair { privateKey: KeyObject; } -export function createLocalInterceptCa(): LocalInterceptCa { +export interface AuthorityOptions { + commonName: string; + permittedDnsNames?: readonly string[]; +} + +export function createCertificateAuthority(options: AuthorityOptions): LocalInterceptCa { const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); - const name = distinguishedName(CLAUDE_INTERCEPT_CA_COMMON_NAME); + const name = distinguishedName(options.commonName); const der = issueCertificate({ subject: name, issuer: name, @@ -187,6 +199,9 @@ export function createLocalInterceptCa(): LocalInterceptCa { // keyCertSign | cRLSign extension(OID.keyUsage, true, bitString(Uint8Array.of(0x06), 1)), extension(OID.subjectKeyIdentifier, false, octetString(keyIdentifier(publicKey))), + ...(options.permittedDnsNames?.length + ? [extension(OID.nameConstraints, true, nameConstraints(options.permittedDnsNames))] + : []), ], }); return { @@ -197,13 +212,17 @@ export function createLocalInterceptCa(): LocalInterceptCa { }; } +export function createLocalInterceptCa(): LocalInterceptCa { + return createCertificateAuthority({ commonName: CLAUDE_INTERCEPT_CA_COMMON_NAME }); +} + /** Issue a serverAuth leaf for `hosts` (first entry becomes the CN; all become SAN dNSNames). */ -export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly string[]): PemKeyPair { +export function issueServerLeaf(ca: LocalInterceptCa, issuerCommonName: string, hosts: readonly string[]): PemKeyPair { if (hosts.length === 0) throw new Error("intercept leaf requires at least one host"); const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); const der = issueCertificate({ subject: distinguishedName(hosts[0]!), - issuer: distinguishedName(CLAUDE_INTERCEPT_CA_COMMON_NAME), + issuer: distinguishedName(issuerCommonName), subjectKey: publicKey, signingKey: ca.privateKey, validityDays: LEAF_VALIDITY_DAYS, @@ -224,6 +243,10 @@ export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly st }; } +export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly string[]): PemKeyPair { + return issueServerLeaf(ca, CLAUDE_INTERCEPT_CA_COMMON_NAME, hosts); +} + // ── Persistence ───────────────────────────────────────────────────────────────── export const CLAUDE_INTERCEPT_STATE_DIR = "claude-intercept"; @@ -246,7 +269,7 @@ function writeFileAtomic(path: string, contents: string, mode: number): void { renameSync(tmp, path); } -function loadPersistedCa(dir: string): LocalInterceptCa | null { +function loadPersistedCa(dir: string, accept?: (cert: X509Certificate) => boolean): LocalInterceptCa | null { const certPath = join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE); const keyPath = join(dir, CA_KEY_FILE); if (!existsSync(certPath) || !existsSync(keyPath)) return null; @@ -256,32 +279,41 @@ function loadPersistedCa(dir: string): LocalInterceptCa | null { const privateKey = createPrivateKey(keyPem); const publicKey = createPublicKey(keyPem); const certificate = new X509Certificate(certPem); - if (!certificate.ca || !certificate.checkPrivateKey(privateKey) || !certificate.verify(publicKey)) return null; + if (!certificate.ca || !certificate.checkPrivateKey(privateKey) || !certificate.verify(publicKey) + || (accept && !accept(certificate))) return null; return { certPem, keyPem, publicKey, privateKey }; } catch { // no-excuse-ok: catch -- an unreadable or corrupt authority is regenerated below. return null; } } -/** - * Load the persisted authority under `/claude-intercept/`, minting one when absent - * or unreadable. The private key is written 0600; the certificate is world-readable because - * `NODE_EXTRA_CA_CERTS` only needs the public half. - */ -export function ensureLocalInterceptCa(configDir: string): LocalInterceptCa { - const dir = claudeInterceptStateDir(configDir); +/** Persist an authority under its own lease, replacing unreadable or rejected pairs. */ +export function ensurePersistedAuthority( + dir: string, + options: AuthorityOptions, + lockName = "ca-publication.sqlite", + accept?: (cert: X509Certificate) => boolean, +): LocalInterceptCa { mkdirSync(dir, { recursive: true, mode: 0o700 }); // A separate SQLite namespace binds exclusion to the explicit CA directory. // The OS releases it on crash; a contending caller fails before touching either // PEM. Readers also take the lease so they cannot observe half a publication. return withClientLifecycleSync(() => { - const existing = loadPersistedCa(dir); + const existing = loadPersistedCa(dir, accept); if (existing) return existing; - const ca = createLocalInterceptCa(); + const ca = createCertificateAuthority(options); writeFileAtomic(join(dir, CA_KEY_FILE), ca.keyPem, 0o600); writeFileAtomic(join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE), ca.certPem, 0o644); return ca; - }, { lockPath: join(dir, "ca-publication.sqlite") }); + }, { lockPath: join(dir, lockName) }); +} + +/** Preserve the original intercept CA path, name, permissions and extension set. */ +export function ensureLocalInterceptCa(configDir: string): LocalInterceptCa { + return ensurePersistedAuthority( + claudeInterceptStateDir(configDir), + { commonName: CLAUDE_INTERCEPT_CA_COMMON_NAME }, + ); } /** Startup may race a settings apply publishing the same CA. Retry only lease diff --git a/src/claude/intercept/picker-bootstrap.ts b/src/claude/intercept/picker-bootstrap.ts new file mode 100644 index 00000000000..b3fd52948c2 --- /dev/null +++ b/src/claude/intercept/picker-bootstrap.ts @@ -0,0 +1,141 @@ +/** + * Claude Desktop picker mode: narrowly rewrite the Code bootstrap catalog. + * A failed or inapplicable transform leaves the upstream bytes untouched. + */ +import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib"; + +/** One opencodex model offered in Desktop's Code-tab picker. */ +export interface PickerModelEntry { + id: string; + name: string; + contextWindow?: number; +} + +export const BOOTSTRAP_MAX_ENCODED_BYTES = 4 * 1024 * 1024; +export const BOOTSTRAP_MAX_DECODED_BYTES = 16 * 1024 * 1024; +const BOOTSTRAP_PATH = /^\/(?:edge-api|api)\/bootstrap(?:\/[A-Za-z0-9-]+\/app_start)?\/?$/; +const REWRITE_REMOVED_HEADERS = new Set([ + "content-encoding", "content-length", "etag", "digest", "content-md5", "transfer-encoding", +]); + +export function isPickerBootstrapRequest(method: string, pathname: string): boolean { + return method === "GET" && BOOTSTRAP_PATH.test(pathname); +} + +export function narrowBootstrapAcceptEncoding(): string { + return "gzip, deflate, br"; +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record : null; +} + +/** Why a bootstrap was left unchanged, for the metadata-only picker log. Never carries values. */ +export type PickerInjectionOutcome = + | { kind: "rewritten"; added: number } + | { kind: "unchanged"; reason: string }; + +/** + * Surfaces whose picker the local Desktop Code tab can show. Desktop reads "ccd" and falls back to + * "code" only when "ccd" carries no catalog; "ccr" (remote sessions) is left alone because a + * remote session never reaches this machine's proxy, so an opencodex route could not run there. + */ +export const PICKER_SURFACE_IDS = ["ccd", "code"] as const; +/** Surface names the log may print; anything else from the body is counted, never echoed. */ +const KNOWN_SURFACE_IDS = new Set(["ccd", "code", "cc", "ccr", "cowork", "chat", "design"]); + +function injectIntoSurface(surface: Record, models: readonly PickerModelEntry[]): number | string { + if (!Array.isArray(surface.models)) return "no_models"; + const entries = surface.models as unknown[]; + const template = entries.map(record).find(entry => + typeof entry?.id === "string" && entry.id.startsWith("claude-") + && !entry.disabled && !entry.disabled_reason && entry.section !== "deprecated"); + if (!template) return `no_template(models=${entries.length})`; + const existing = new Set(entries.map(record).map(entry => entry?.id)); + let added = 0; + for (const model of models) { + if (existing.has(model.id)) continue; + const copy = structuredClone(template); + copy.id = model.id; + copy.name = model.name; + copy.section = "main"; + if (model.contextWindow === undefined) delete copy.context_window; + else copy.context_window = model.contextWindow; + for (const key of Object.keys(copy)) { + if (["disabled", "disabled_reason", "badge", "tooltip", "description", "fast_mode"].includes(key) + || /version/i.test(key)) delete copy[key]; + } + entries.push(copy); + existing.add(model.id); + added++; + } + return added; +} + +export function injectPickerModels( + bootstrap: unknown, + models: readonly PickerModelEntry[], + explain?: (outcome: PickerInjectionOutcome) => void, +): number { + const unchanged = (reason: string): number => { explain?.({ kind: "unchanged", reason }); return 0; }; + const surfaces = record(bootstrap)?.model_selector_config; + if (!Array.isArray(surfaces)) return unchanged("no_model_selector_config"); + const rows = surfaces.map(record); + const targets = rows.filter((entry): entry is Record => + entry !== null && (PICKER_SURFACE_IDS as readonly unknown[]).includes(entry.id)); + if (targets.length === 0) { + // Only known surface names reach the log; any other body-derived value is counted. + const known = rows.flatMap(entry => typeof entry?.id === "string" && KNOWN_SURFACE_IDS.has(entry.id) ? [entry.id] : []); + const other = rows.length - known.length; + return unchanged(`no_code_surface(${[...known, ...(other > 0 ? [`other:${other}`] : [])].join(",")})`); + } + let added = 0; + const skipped: string[] = []; + for (const surface of targets) { + const result = injectIntoSurface(surface, models); + if (typeof result === "number") added += result; + else skipped.push(`${String(surface.id)}:${result}`); + } + if (added === 0) return unchanged(skipped.length > 0 ? skipped.join(";") : models.length === 0 ? "no_routes" : "all_present"); + explain?.({ kind: "rewritten", added }); + return added; +} + +export function rewriteBootstrapBody( + encoded: Buffer, + contentEncoding: string | undefined, + models: readonly PickerModelEntry[], + explain?: (outcome: PickerInjectionOutcome) => void, +): Buffer | null { + const unchanged = (reason: string): null => { explain?.({ kind: "unchanged", reason }); return null; }; + if (encoded.length > BOOTSTRAP_MAX_ENCODED_BYTES) return unchanged("encoded_cap"); + const encoding = contentEncoding?.trim().toLowerCase() || "identity"; + let decoded: Buffer; + try { + switch (encoding) { + case "identity": decoded = encoded; break; + case "gzip": + case "x-gzip": decoded = gunzipSync(encoded, { maxOutputLength: BOOTSTRAP_MAX_DECODED_BYTES }); break; + case "deflate": decoded = inflateSync(encoded, { maxOutputLength: BOOTSTRAP_MAX_DECODED_BYTES }); break; + case "br": decoded = brotliDecompressSync(encoded, { maxOutputLength: BOOTSTRAP_MAX_DECODED_BYTES }); break; + default: return unchanged("unsupported_encoding"); + } + if (decoded.length > BOOTSTRAP_MAX_DECODED_BYTES) return unchanged("decoded_cap"); + const parsed: unknown = JSON.parse(decoded.toString("utf8")); + if (injectPickerModels(parsed, models, explain) === 0) return null; + return Buffer.from(JSON.stringify(parsed), "utf8"); + } catch { + return unchanged("decode_or_parse_failed"); + } +} + +/** Rewritten payloads are identity encoded, so stale validators and sizes must go. */ +export function rewrittenHeaders(raw: readonly string[], bodyLength: number): string[] { + const result: string[] = []; + for (let i = 0; i + 1 < raw.length; i += 2) { + if (!REWRITE_REMOVED_HEADERS.has(raw[i]!.toLowerCase())) result.push(raw[i]!, raw[i + 1]!); + } + result.push("Content-Length", String(bodyLength)); + return result; +} diff --git a/src/claude/intercept/picker-ca.ts b/src/claude/intercept/picker-ca.ts new file mode 100644 index 00000000000..77ddfd8c8a6 --- /dev/null +++ b/src/claude/intercept/picker-ca.ts @@ -0,0 +1,115 @@ +import { createHash, X509Certificate } from "node:crypto"; +import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + ensurePersistedAuthority, + issueServerLeaf, + type LocalInterceptCa, + type PemKeyPair, +} from "./local-ca"; + +/** Separate root for Desktop traffic: its critical DNS constraint is checked on every reload. */ +export const PICKER_HOST = "claude.ai"; +export const PICKER_CA_COMMON_NAME = "opencodex Claude Desktop Picker CA"; +export const PICKER_STATE_DIR = "claude-picker"; + +export interface PickerCa extends LocalInterceptCa { fingerprint: string } + +export function pickerStateDir(configDir: string): string { return join(configDir, PICKER_STATE_DIR); } +export function pickerCaCertPath(configDir: string): string { return join(pickerStateDir(configDir), "ca.pem"); } +export function pickerLeafCertPath(configDir: string): string { return join(pickerStateDir(configDir), "leaf.pem"); } + +export function pickerCaFingerprints(certPem: string): { sha1: string; sha256: string } { + const der = new X509Certificate(certPem).raw; + return { + sha1: createHash("sha1").update(der).digest("hex").toUpperCase(), + sha256: createHash("sha256").update(der).digest("hex").toUpperCase(), + }; +} + +interface DerItem { tag: number; body: Buffer; next: number } + +function readDer(bytes: Buffer, at: number): DerItem | null { + if (at + 2 > bytes.length) return null; + const tag = bytes[at]!; + let length = bytes[at + 1]!; + let cursor = at + 2; + if (length & 0x80) { + const width = length & 0x7f; + if (width === 0 || width > 4 || cursor + width > bytes.length) return null; + length = 0; + for (let i = 0; i < width; i++) length = length * 256 + bytes[cursor++]!; + } + if (cursor + length > bytes.length) return null; + return { tag, body: bytes.subarray(cursor, cursor + length), next: cursor + length }; +} + +function children(bytes: Buffer): DerItem[] | null { + const out: DerItem[] = []; + for (let cursor = 0; cursor < bytes.length;) { + const item = readDer(bytes, cursor); + if (!item) return null; + out.push(item); + cursor = item.next; + } + return out; +} + +function constrainedToPickerHost(value: Buffer): boolean { + const root = readDer(value, 0); + if (!root || root.tag !== 0x30 || root.next !== value.length) return false; + const fields = children(root.body); + if (!fields || fields.length !== 1 || fields[0]!.tag !== 0xa0) return false; + const subtrees = children(fields[0]!.body); + if (!subtrees || subtrees.length !== 1 || subtrees[0]!.tag !== 0x30) return false; + const subtree = children(subtrees[0]!.body); + return !!subtree && subtree.length === 1 && subtree[0]!.tag === 0x82 + && subtree[0]!.body.equals(Buffer.from(PICKER_HOST, "ascii")); +} + +/** Inspect the extension itself; X509Certificate does not expose nameConstraints. */ +function acceptsPickerAuthority(cert: X509Certificate): boolean { + if (!cert.subject.split("\n").includes(`CN=${PICKER_CA_COMMON_NAME}`)) return false; + const root = readDer(cert.raw, 0); + if (!root || root.tag !== 0x30 || root.next !== cert.raw.length) return false; + const certificate = children(root.body); + if (!certificate || certificate[0]?.tag !== 0x30) return false; + const tbs = children(certificate[0].body); + const extensionField = tbs?.find(item => item.tag === 0xa3); + const wrapped = extensionField && readDer(extensionField.body, 0); + if (!wrapped || wrapped.tag !== 0x30 || wrapped.next !== extensionField!.body.length) return false; + const extensions = children(wrapped.body); + if (!extensions) return false; + const matches = extensions.filter(item => { + if (item.tag !== 0x30) return false; + const fields = children(item.body); + return fields?.[0]?.tag === 0x06 && fields[0].body.equals(Buffer.from([0x55, 0x1d, 0x1e])); + }); + if (matches.length !== 1) return false; + const fields = children(matches[0]!.body); + return !!fields && fields.length === 3 + && fields[1]!.tag === 0x01 && fields[1]!.body.equals(Buffer.from([0xff])) + && fields[2]!.tag === 0x04 && constrainedToPickerHost(fields[2]!.body); +} + +export function ensurePickerCa(configDir: string): PickerCa { + const ca = ensurePersistedAuthority( + pickerStateDir(configDir), + { commonName: PICKER_CA_COMMON_NAME, permittedDnsNames: [PICKER_HOST] }, + "ca-publication.sqlite", + acceptsPickerAuthority, + ); + return { ...ca, fingerprint: pickerCaFingerprints(ca.certPem).sha256 }; +} + +/** Persist only the public leaf, so trust inspection verifies this exact local issuer. */ +export function issuePickerLeaf(ca: PickerCa, configDir: string): PemKeyPair { + const leaf = issueServerLeaf(ca, PICKER_CA_COMMON_NAME, [PICKER_HOST]); + const path = pickerLeafCertPath(configDir); + mkdirSync(pickerStateDir(configDir), { recursive: true, mode: 0o700 }); + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, leaf.certPem, { mode: 0o644 }); + try { chmodSync(tmp, 0o644); } catch { /* best-effort on platforms without POSIX modes */ } + renameSync(tmp, path); + return leaf; +} diff --git a/src/claude/intercept/picker-listener.ts b/src/claude/intercept/picker-listener.ts new file mode 100644 index 00000000000..84ba8737e57 --- /dev/null +++ b/src/claude/intercept/picker-listener.ts @@ -0,0 +1,213 @@ +/** + * HTTP/1.1 TLS terminator for claude.ai. Only the bounded bootstrap response is + * held; all other HTTP bodies and upgraded sockets relay as streams. + */ +import { createServer, request as httpsRequest } from "node:https"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { connect as tlsConnect } from "node:tls"; +import type { Duplex } from "node:stream"; +import type { PemKeyPair } from "./local-ca"; +import { + BOOTSTRAP_MAX_ENCODED_BYTES, isPickerBootstrapRequest, narrowBootstrapAcceptEncoding, + rewriteBootstrapBody, rewrittenHeaders, +} from "./picker-bootstrap"; +import type { PickerModelEntry } from "./picker-bootstrap"; + +export interface PickerListenerOptions { + leaf: PemKeyPair; + models: () => readonly PickerModelEntry[]; + upstream?: { host: string; port: number; servername: string; ca?: string }; + /** Test seam: encoded bootstrap cap; production uses BOOTSTRAP_MAX_ENCODED_BYTES. */ + maxEncodedBytes?: number; + log?: (line: string) => void; +} +export interface PickerListenerHandle { port: number; close(): Promise } + +const HOP_HEADERS = new Set([ + "connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", + "te", "trailer", "transfer-encoding", "upgrade", +]); + +function filteredHeaders(raw: readonly string[], omit: ReadonlySet = new Set()): string[] { + const named = new Set(); + for (let i = 0; i + 1 < raw.length; i += 2) { + if (raw[i]!.toLowerCase() === "connection") { + for (const name of raw[i + 1]!.split(",")) named.add(name.trim().toLowerCase()); + } + } + const result: string[] = []; + for (let i = 0; i + 1 < raw.length; i += 2) { + const name = raw[i]!.toLowerCase(); + if (!HOP_HEADERS.has(name) && !named.has(name) && !omit.has(name)) { + result.push(raw[i]!, raw[i + 1]!); + } + } + return result; +} + +function hasJsonContentType(raw: readonly string[]): boolean { + for (let i = 0; i + 1 < raw.length; i += 2) { + if (raw[i]!.toLowerCase() === "content-type") { + return /^(?:application\/json|[^;\s]+\+json)(?:\s*;|\s*$)/i.test(raw[i + 1]!); + } + } + return false; +} + +function contentEncoding(raw: readonly string[]): string | undefined { + for (let i = 0; i + 1 < raw.length; i += 2) { + if (raw[i]!.toLowerCase() === "content-encoding") return raw[i + 1]!; + } + return undefined; +} + +export async function startPickerListener(options: PickerListenerOptions): Promise { + const upstream = options.upstream ?? { host: "claude.ai", port: 443, servername: "claude.ai" }; + const cap = options.maxEncodedBytes ?? BOOTSTRAP_MAX_ENCODED_BYTES; + const upgrades = new Set(); + const server = createServer({ cert: options.leaf.certPem, key: options.leaf.keyPem, ALPNProtocols: ["http/1.1"] }); + + server.on("request", (req: IncomingMessage, res: ServerResponse) => { + const method = req.method ?? "GET"; + const pathname = new URL(req.url ?? "/", "https://claude.ai").pathname; + const bootstrap = isPickerBootstrapRequest(method, pathname); + const category = bootstrap ? "bootstrap" : "other"; + let logged = false; + const log = (status: number) => { + if (!logged) options.log?.(`picker ${method} ${category} ${status}`); + logged = true; + }; + const fail = () => { + if (res.headersSent) res.destroy(); + else { res.writeHead(502, { "Content-Length": "0" }); res.end(); log(502); } + }; + const omit = bootstrap ? new Set(["accept-encoding"]) : new Set(); + const headers = filteredHeaders(req.rawHeaders, omit); + if (bootstrap) headers.push("Accept-Encoding", narrowBootstrapAcceptEncoding()); + const upReq = httpsRequest({ + host: upstream.host, port: upstream.port, servername: upstream.servername, + ca: upstream.ca, rejectUnauthorized: true, agent: false, + method, path: req.url, headers, + }, upRes => { + const status = upRes.statusCode ?? 502; + const originalHeaders = filteredHeaders(upRes.rawHeaders); + const sendHead = (raw: string[]) => { + if (res.headersSent) return; + res.writeHead(status, upRes.statusMessage, raw); + log(status); + }; + upRes.on("error", fail); + if (!bootstrap || status !== 200 || !hasJsonContentType(upRes.rawHeaders)) { + sendHead(originalHeaders); + upRes.pipe(res); + return; + } + const held: Buffer[] = []; + let size = 0; + let handedOff = false; + const onData = (chunk: Buffer) => { + if (size + chunk.length > cap) { + upRes.pause(); + upRes.off("data", onData); + handedOff = true; + sendHead(originalHeaders); + for (const part of held) res.write(part); + res.write(chunk); + upRes.pipe(res); + return; + } + held.push(chunk); + size += chunk.length; + }; + upRes.on("data", onData); + upRes.once("end", () => { + if (handedOff) return; + const original = Buffer.concat(held, size); + let outcome = "unchanged"; + const rewritten = rewriteBootstrapBody(original, contentEncoding(upRes.rawHeaders), options.models(), result => { + outcome = result.kind === "rewritten" ? `rewritten(+${result.added})` : `unchanged:${result.reason}`; + }); + sendHead(rewritten === null ? originalHeaders : rewrittenHeaders(originalHeaders, rewritten.length)); + options.log?.(`picker ${method} bootstrap ${outcome}`); + res.end(rewritten ?? original); + }); + }); + upReq.on("error", fail); + req.on("error", () => upReq.destroy()); + res.on("close", () => { if (!res.writableEnded) upReq.destroy(); }); + req.pipe(upReq); + }); + + server.on("upgrade", (req, client, head) => { + const method = req.method ?? "GET"; + const target = tlsConnect({ + host: upstream.host, port: upstream.port, servername: upstream.servername, + ca: upstream.ca, rejectUnauthorized: true, ALPNProtocols: ["http/1.1"], + }); + upgrades.add(client); + upgrades.add(target); + let established = false; + let upgradeLogged = false; + let responseStart = ""; + const logUpgrade = (status: number) => { + if (upgradeLogged) return; + upgradeLogged = true; + options.log?.(`picker ${method} other ${status}`); + }; + const onResponseData = (chunk: Buffer) => { + responseStart += chunk.toString("latin1"); + const end = responseStart.indexOf("\r\n"); + if (end >= 0) { + target.off("data", onResponseData); + const status = /^HTTP\/1\.[01] (\d{3})(?: |$)/.exec(responseStart.slice(0, end)); + logUpgrade(status ? Number(status[1]) : 502); + } else if (responseStart.length > 128) { + target.off("data", onResponseData); + logUpgrade(502); + } + }; + const fail = () => { + if (!established && !client.destroyed) client.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + else client.destroy(); + target.destroy(); + logUpgrade(502); + }; + target.on("data", onResponseData); + target.once("secureConnect", () => { + established = true; + target.write(`${method} ${req.url ?? "/"} HTTP/1.1\r\n`); + for (let i = 0; i + 1 < req.rawHeaders.length; i += 2) { + if (/^proxy-(?:authorization|connection)$/i.test(req.rawHeaders[i]!)) continue; + target.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`); + } + target.write("\r\n"); + if (head.length) target.write(head); + client.pipe(target).pipe(client); + }); + target.once("error", fail); + client.once("error", () => target.destroy()); + target.once("close", () => { upgrades.delete(target); if (!upgradeLogged) logUpgrade(502); client.destroy(); }); + client.once("close", () => { upgrades.delete(client); target.destroy(); }); + }); + + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); + }); + } catch (error) { + server.close(); + throw error; + } + const address = server.address(); + if (!address || typeof address === "string") throw new Error("picker listener has no port"); + return { + port: address.port, + async close() { + for (const socket of upgrades) socket.destroy(); + const closed = new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + server.closeAllConnections(); + await closed; + }, + }; +} diff --git a/src/claude/intercept/picker-models.ts b/src/claude/intercept/picker-models.ts new file mode 100644 index 00000000000..3bc01e22c2f --- /dev/null +++ b/src/claude/intercept/picker-models.ts @@ -0,0 +1,101 @@ +/** + * Claude Desktop picker candidates use the gateway's profile order and labels, while + * publishing aliases that the first-party Messages ingress can resolve. + */ +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../../codex/catalog"; +import type { OcxClaudeDesktopProfile } from "../../types"; +import { aliasForRoute, claudeCodeNativeAlias } from "../alias"; +import { displayModelId, type Desktop3pRoutedModel } from "../desktop-3p"; +import { reconcileDesktopProfile, renderDesktopProfile, type DesktopProfileModel } from "../desktop-profile"; +import type { PickerModelEntry } from "./picker-bootstrap"; + +export interface PickerRouteInput { + nativeSlugs: string[]; + routedModels: Desktop3pRoutedModel[]; + profile?: OcxClaudeDesktopProfile; + nativeContextCap?: NativeContextLimitsInput; +} + +export function buildPickerModels(input: PickerRouteInput): PickerModelEntry[] { + const candidates: DesktopProfileModel[] = [ + ...input.nativeSlugs.map(id => { + const contextWindow = nativeOpenAiContextWindow(id, input.nativeContextCap); + return { + route: `native/${id}`, + label: `${displayModelId(id)} (native)`, + ...(contextWindow === undefined ? {} : { contextWindow }), + }; + }), + ...input.routedModels.map(({ provider, id, contextWindow }) => ({ + route: `${provider}/${id}`, + label: `${displayModelId(id)} (${provider})`, + ...(contextWindow === undefined ? {} : { contextWindow }), + })), + ]; + const rendered = input.profile + ? renderDesktopProfile(reconcileDesktopProfile(input.profile, candidates), candidates) + : candidates; + const out: PickerModelEntry[] = []; + const seen = new Set(); + for (const model of rendered) { + const slash = model.route.indexOf("/"); + const provider = model.route.slice(0, slash); + const id = model.route.slice(slash + 1); + if (provider === "anthropic" && id.startsWith("claude-")) continue; + const alias = provider === "native" ? claudeCodeNativeAlias(id) : aliasForRoute(provider, id); + if (!alias || seen.has(alias)) continue; + seen.add(alias); + out.push({ id: alias, name: model.label, + ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }) }); + } + return out; +} + +export interface PickerModelSnapshot { + current(): { models: PickerModelEntry[]; builtAt: number } | null; + refresh(): Promise; + refreshIfStale(maxAgeMs: number): void; +} + +function parseSnapshot(value: unknown): { models: PickerModelEntry[]; builtAt: number } | null { + if (!value || typeof value !== "object") return null; + const candidate = value as { models?: unknown; builtAt?: unknown }; + if (typeof candidate.builtAt !== "number" || !Number.isFinite(candidate.builtAt) || !Array.isArray(candidate.models)) return null; + if (!candidate.models.every(model => model && typeof model === "object" + && typeof model.id === "string" && typeof model.name === "string" + && (model.contextWindow === undefined || typeof model.contextWindow === "number"))) return null; + return { models: candidate.models as PickerModelEntry[], builtAt: candidate.builtAt }; +} + +export function createPickerModelSnapshot(load: () => Promise, persistPath?: string): PickerModelSnapshot { + let snapshot: { models: PickerModelEntry[]; builtAt: number } | null = null; + if (persistPath) { + try { snapshot = parseSnapshot(JSON.parse(readFileSync(persistPath, "utf8")) as unknown); } catch { /* No usable prior snapshot. */ } + } + let pending: Promise | null = null; + const refresh = (): Promise => { + if (pending) return pending; + pending = (async () => { + try { + const models = buildPickerModels(await load()); + const next = { models, builtAt: Date.now() }; + if (persistPath) { + mkdirSync(dirname(persistPath), { recursive: true, mode: 0o700 }); + writeFileSync(persistPath, JSON.stringify(next), { encoding: "utf8", mode: 0o600 }); + chmodSync(persistPath, 0o600); + } + snapshot = next; + } catch { /* Keep the last good snapshot when discovery or persistence fails. */ } + })().finally(() => { pending = null; }); + return pending; + }; + return { + current: () => snapshot, + refresh, + refreshIfStale(maxAgeMs) { + if (!pending && (!snapshot || Date.now() - snapshot.builtAt >= maxAgeMs)) void refresh(); + }, + }; +} diff --git a/src/claude/intercept/picker-runtime.ts b/src/claude/intercept/picker-runtime.ts new file mode 100644 index 00000000000..0d488ddfbe0 --- /dev/null +++ b/src/claude/intercept/picker-runtime.ts @@ -0,0 +1,330 @@ +/** + * Claude Desktop picker mode: when to terminate claude.ai, and the pieces that do it. + * + * Picker mode lists opencodex models by name in Desktop's Code-tab picker while Desktop stays + * first-party. Desktop's own egress profile (wp4) points the app at the intercept CONNECT proxy; + * for every tunnel the proxy asks `selectTunnel`, and only `claude.ai:443` can ever come back as + * `intercept`. Everything else, and claude.ai whenever any condition fails, is a blind tunnel. + * + * The decision is cached by `refresh()` and only read by `selectTunnel`, so a CONNECT never + * waits on the keychain or the catalog. Arming needs all of: macOS, the persisted resolved mode is + * first-party (observation-aware, so a pre-field first-party install counts), Desktop intent on, + * `claudeCode.intercept.picker !== false`, the disarm latch clear, the picker listener up and the + * current picker CA trusted in the login keychain. `disarm()` stops terminating immediately and + * latches; only the picker controller's owner-only `rearm()` clears the latch. + */ +import { join } from "node:path"; +import type { OcxConfig } from "../../types"; +import type { ClaudeDesktopMode } from "../desktop-first-party"; +import type { TunnelDecision } from "./connect-proxy"; +import type { PemKeyPair } from "./local-ca"; +import { PICKER_HOST, ensurePickerCa, issuePickerLeaf, pickerCaFingerprints, pickerLeafCertPath, pickerStateDir, type PickerCa } from "./picker-ca"; +import { startPickerListener, type PickerListenerHandle } from "./picker-listener"; +import { createPickerModelSnapshot, type PickerModelSnapshot, type PickerRouteInput } from "./picker-models"; +import { inspectPickerTrust, type PickerTrustState, type SecurityRunner } from "./picker-trust"; + +export type TunnelChoice = TunnelDecision; + +/** A claude.ai CONNECT that arrives before the first refresh waits at most this long. */ +export const PICKER_STARTUP_WAIT_MS = 3_000; +export const PICKER_REFRESH_INTERVAL_MS = 60_000; +export const PICKER_TRUST_TTL_MS = 30_000; +/** Discovery for the picker list is refreshed in the background once the snapshot is this old. */ +export const PICKER_MODELS_MAX_AGE_MS = 5 * 60_000; +export const PICKER_MODELS_FILE = "models.json"; + +export type PickerRuntimeReason = + | "active" + | "starting" + | "unsupported_platform" + | "not_desired" + | "disarmed" + | "busy" + | "trust_untrusted" + | "trust_unknown" + | "listener_failed" + | "refresh_failed" + | "stopped"; + +export interface PickerRuntimeStatus { + desired: boolean; + supported: boolean; + trust: PickerTrustState; + listenerReady: boolean; + effective: boolean; + latched: boolean; + reason: PickerRuntimeReason; + models: number; + snapshotAt: number | null; + /** Last time Desktop fetched a bootstrap through the picker listener (null = not since start). */ + lastBootstrapAt: number | null; +} + +export interface PickerRuntime { + selectTunnel(host: string, port: number): TunnelChoice | null | Promise; + refreshTrust(): Promise; + refresh(): Promise; + disarm(): void; + rearm(): Promise; + ensureStarted(): Promise; + start(): Promise; + readonly ready: Promise; + status(): PickerRuntimeStatus; + stop(): Promise; +} + +export interface CreatePickerRuntimeOptions { + config: OcxConfig; + /** Persisted config for every decision; defaults to `loadConfig`. */ + readConfig?: () => OcxConfig; + /** True while the picker controller holds its lock; refresh() then never arms. */ + isBusy?: () => boolean; + configDir: string; + loadRoutes: () => Promise; + security?: SecurityRunner; + platform?: NodeJS.Platform; + now?: () => number; + trustTtlMs?: number; + startupWaitMs?: number; + refreshIntervalMs?: number; + /** Test seam: the observation-aware resolved Desktop mode for a config. */ + resolveMode?: (config: OcxConfig) => ClaudeDesktopMode | Promise; + /** Test seam: the claude.ai terminator. */ + startListener?: typeof startPickerListener; + log?: (line: string) => void; +} + +/** + * Whether picker mode should run for this persisted config and resolved mode. Desktop intent uses + * the durable-intent rule of `claudeDesktopIntegrationEnabled` (src/codex/desired-state.ts): only an + * explicit `false` turns it off. The picker preference is the same: on unless explicitly false. + */ +export function pickerDesired( + config: Pick, + mode: ClaudeDesktopMode, + platform: NodeJS.Platform = process.platform, +): boolean { + return platform === "darwin" + && config.clientIntegrations?.["claude-desktop"] !== false + && mode === "first-party" + && config.claudeCode?.intercept?.picker !== false; +} + +async function defaultResolveMode(config: OcxConfig): Promise { + // Dynamic: desktop-first-party imports intercept/runtime, which imports this module. + const { observeClaudeDesktopMode, resolveClaudeDesktopMode } = await import("../desktop-first-party"); + return resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)); +} + +async function defaultReadConfig(): Promise<() => OcxConfig> { + const { loadConfig } = await import("../../config"); + return loadConfig; +} + +export function createPickerRuntime(options: CreatePickerRuntimeOptions): PickerRuntime { + const platform = options.platform ?? process.platform; + const now = options.now ?? Date.now; + const trustTtlMs = options.trustTtlMs ?? PICKER_TRUST_TTL_MS; + const startupWaitMs = options.startupWaitMs ?? PICKER_STARTUP_WAIT_MS; + const refreshIntervalMs = options.refreshIntervalMs ?? PICKER_REFRESH_INTERVAL_MS; + const resolveMode = options.resolveMode ?? defaultResolveMode; + const startListener = options.startListener ?? startPickerListener; + const log = options.log ?? (() => {}); + + let desired = false; + let armed = false; + let latched = false; + let stopped = false; + let started = false; + let firstRefreshDone = false; + let generation = 0; + let reason: PickerRuntimeReason = "starting"; + let trust: PickerTrustState = "unknown"; + let trustCheckedAt = -Infinity; + let trustFor: string | null = null; + let ca: PickerCa | null = null; + let caSha1: string | null = null; + let leaf: PemKeyPair | null = null; + let snapshot: PickerModelSnapshot | null = null; + let listener: PickerListenerHandle | null = null; + let listenerPromise: Promise | null = null; + let lastBootstrapAt: number | null = null; + let interval: ReturnType | null = null; + let readConfig: (() => OcxConfig) | null = options.readConfig ?? null; + let resolveReady!: () => void; + const ready = new Promise(resolve => { resolveReady = resolve; }); + + function closeListener(): void { + const pending = listenerPromise; + listenerPromise = null; + listener = null; + if (pending) void pending.then(handle => handle.close()).catch(() => {}); + } + + function ensureMaterial(): { ca: PickerCa; leaf: PemKeyPair; sha1: string } { + if (!ca || !leaf || !caSha1) { + ca = ensurePickerCa(options.configDir); + leaf = issuePickerLeaf(ca, options.configDir); + caSha1 = pickerCaFingerprints(ca.certPem).sha1; + } + return { ca, leaf, sha1: caSha1 }; + } + + function ensureSnapshot(): PickerModelSnapshot { + snapshot ??= createPickerModelSnapshot(options.loadRoutes, join(pickerStateDir(options.configDir), PICKER_MODELS_FILE)); + return snapshot; + } + + async function inspectTrust(force: boolean): Promise { + const { sha1 } = ensureMaterial(); + if (!force && trustFor === sha1 && now() - trustCheckedAt < trustTtlMs) return trust; + trust = await inspectPickerTrust(pickerLeafCertPath(options.configDir), sha1, options.security, platform); + trustFor = sha1; + trustCheckedAt = now(); + return trust; + } + + /** CA, leaf, snapshot and listener. Resolves false when a disarm overtook it. */ + async function startPieces(gen: number): Promise { + const material = ensureMaterial(); + ensureSnapshot().refreshIfStale(PICKER_MODELS_MAX_AGE_MS); + if (!listenerPromise) { + const current = startListener({ + leaf: material.leaf, + models: () => { + lastBootstrapAt = now(); + return ensureSnapshot().current()?.models ?? []; + }, + log, + }); + listenerPromise = current; + current.catch(() => { if (listenerPromise === current) listenerPromise = null; }); + } + const pending = listenerPromise; + const handle = await pending; + if (gen !== generation || latched || stopped || listenerPromise !== pending) return false; + listener = handle; + return true; + } + + async function evaluate(bypassBusy: boolean, forceTrust: boolean): Promise { + const gen = generation; + if (stopped) return; + try { + readConfig ??= await defaultReadConfig(); + const fresh = readConfig(); + const mode = await resolveMode(fresh); + if (gen !== generation || stopped) return; + desired = pickerDesired(fresh, mode, platform); + if (!desired || latched) { + armed = false; + reason = platform !== "darwin" ? "unsupported_platform" : latched ? "disarmed" : "not_desired"; + closeListener(); + return; + } + if (!bypassBusy && options.isBusy?.()) { + reason = armed ? reason : "busy"; + return; + } + let up: boolean; + try { + up = await startPieces(gen); + } catch { + armed = false; + reason = "listener_failed"; + return; + } + if (!up) return; + const state = await inspectTrust(forceTrust); + if (gen !== generation || latched || stopped) return; + armed = state === "trusted" && listener !== null; + reason = armed ? "active" : state === "unknown" ? "trust_unknown" : state === "unsupported" ? "unsupported_platform" : "trust_untrusted"; + } catch (error) { + if (gen !== generation) return; + armed = false; + reason = "refresh_failed"; + log(`picker refresh failed (${error instanceof Error ? error.name : "error"})`); + } + } + + function currentChoice(): TunnelChoice { + return armed && listener && !latched && !stopped + ? { kind: "intercept", port: listener.port } + : { kind: "blind" }; + } + + return { + ready, + selectTunnel(host, port) { + if (host.toLowerCase() !== PICKER_HOST || port !== 443) return null; + if (!started || firstRefreshDone || stopped) return currentChoice(); + let timer: ReturnType | undefined; + const timeout = new Promise<"timeout">(resolve => { timer = setTimeout(() => resolve("timeout"), startupWaitMs); }); + return Promise.race([ready.then(() => "ready" as const), timeout]).then(outcome => { + clearTimeout(timer); + return outcome === "ready" ? currentChoice() : { kind: "blind" as const }; + }); + }, + async refreshTrust() { + return inspectTrust(true); + }, + async refresh() { + await evaluate(false, false); + }, + disarm() { + latched = true; + armed = false; + generation += 1; + reason = "disarmed"; + closeListener(); + }, + async rearm() { + if (stopped) return; + latched = false; + await evaluate(true, true); + }, + async ensureStarted() { + if (latched || stopped) return; + await startPieces(generation); + }, + start() { + if (started) return ready; + started = true; + void evaluate(false, false).finally(() => { + firstRefreshDone = true; + resolveReady(); + }); + interval = setInterval(() => { void evaluate(false, false); }, refreshIntervalMs); + (interval as { unref?: () => void }).unref?.(); + return ready; + }, + status() { + const current = snapshot?.current() ?? null; + return { + desired, + supported: platform === "darwin", + trust, + listenerReady: listener !== null, + effective: armed && listener !== null && !latched && !stopped, + latched, + reason: stopped ? "stopped" : reason, + models: current?.models.length ?? 0, + snapshotAt: current?.builtAt ?? null, + lastBootstrapAt, + }; + }, + async stop() { + stopped = true; + armed = false; + generation += 1; + if (interval) clearInterval(interval); + interval = null; + firstRefreshDone = true; + resolveReady(); + const pending = listenerPromise; + listenerPromise = null; + listener = null; + if (pending) await pending.then(handle => handle.close(), () => {}); + }, + }; +} diff --git a/src/claude/intercept/picker-trust.ts b/src/claude/intercept/picker-trust.ts new file mode 100644 index 00000000000..3ad0343d657 --- /dev/null +++ b/src/claude/intercept/picker-trust.ts @@ -0,0 +1,91 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { PICKER_CA_COMMON_NAME, PICKER_HOST } from "./picker-ca"; + +/** Trust is scoped to the current root fingerprint and a verified persisted leaf. */ +export type PickerTrustState = "trusted" | "untrusted" | "unsupported" | "unknown"; +export interface SecurityResult { code: number | null; stdout: string; stderr: string } +export type SecurityRunner = (args: readonly string[]) => Promise; + +export const defaultSecurityRunner: SecurityRunner = async args => { + const child = Bun.spawn(["/usr/bin/security", ...args], { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, + ]); + return { code, stdout, stderr }; +}; + +export function loginKeychainPath(home = homedir()): string { + return join(home, "Library", "Keychains", "login.keychain-db"); +} + +function hasFingerprint(output: string, expected: string): boolean { + const normalized = expected.replace(/:/g, "").toUpperCase(); + if (!/^[0-9A-F]{40}$/.test(normalized)) return false; + return output.split(/\r?\n/).some(line => { + const match = /^SHA-1 hash:\s*([0-9a-fA-F:]{40,59})\s*$/.exec(line.trim()); + return !!match && match[1]!.replace(/:/g, "").toUpperCase() === normalized; + }); +} + +export async function inspectPickerTrust( + leafPath: string, + caSha1: string, + run: SecurityRunner = defaultSecurityRunner, + platform: NodeJS.Platform = process.platform, +): Promise { + if (platform !== "darwin") return "unsupported"; + const keychain = loginKeychainPath(); + try { + const found = await run(["find-certificate", "-a", "-Z", "-c", PICKER_CA_COMMON_NAME, keychain]); + if (found.code === 1) return "untrusted"; + if (found.code !== 0) return "unknown"; + if (!hasFingerprint(found.stdout, caSha1)) return "untrusted"; + const verified = await run(["verify-cert", "-q", "-L", "-c", leafPath, + "-p", "ssl", "-n", PICKER_HOST, "-k", keychain]); + return verified.code === 0 ? "trusted" : verified.code === 1 ? "untrusted" : "unknown"; + } catch { // no-excuse-ok: catch -- OS command unavailable or denied; never claim trust. + return "unknown"; + } +} + +export async function trustPickerCa( + caPath: string, + run: SecurityRunner = defaultSecurityRunner, + platform: NodeJS.Platform = process.platform, +): Promise<{ ok: boolean; reason?: "unsupported" | "declined_or_failed" }> { + if (platform !== "darwin") return { ok: false, reason: "unsupported" }; + try { + const result = await run(["add-trusted-cert", "-r", "trustRoot", "-p", "ssl", + "-s", PICKER_HOST, "-k", loginKeychainPath(), caPath]); + return result.code === 0 ? { ok: true } : { ok: false, reason: "declined_or_failed" }; + } catch { // no-excuse-ok: catch -- user decline and command failure share a safe result. + return { ok: false, reason: "declined_or_failed" }; + } +} + +export async function untrustPickerCa( + caPath: string, + fingerprintSha1: string, + run: SecurityRunner = defaultSecurityRunner, + platform: NodeJS.Platform = process.platform, +): Promise<{ ok: boolean }> { + if (platform !== "darwin") return { ok: false }; + const keychain = loginKeychainPath(); + // Listed means the current picker CA is in the login keychain; unlisted means nothing to remove, + // which is success, so a machine that never trusted it never sees a keychain prompt for it. + const listed = async (): Promise => { + const found = await run(["find-certificate", "-a", "-Z", "-c", PICKER_CA_COMMON_NAME, keychain]); + if (found.code === 1) return false; + if (found.code !== 0) throw new Error("find-certificate failed"); + return hasFingerprint(found.stdout, fingerprintSha1); + }; + try { + if (!(await listed())) return { ok: true }; + const removed = await run(["remove-trusted-cert", caPath]); + const deleted = await run(["delete-certificate", "-Z", fingerprintSha1, keychain]); + return { ok: removed.code === 0 && deleted.code === 0 && !(await listed()) }; + } catch { // no-excuse-ok: catch -- failed removal must be visible to the caller. + return { ok: false }; + } +} diff --git a/src/claude/intercept/runtime.ts b/src/claude/intercept/runtime.ts index 5f55aa4724b..0e5fe0d463b 100644 --- a/src/claude/intercept/runtime.ts +++ b/src/claude/intercept/runtime.ts @@ -1,9 +1,13 @@ import type { Server } from "bun"; import type { OcxConfig } from "../../types"; import { getConfigDir } from "../../config/paths"; -import { CLAUDE_INTERCEPT_HOSTS, startConnectProxy, type ConnectProxyHandle } from "./connect-proxy"; +import type { DesktopPickerController } from "../desktop-picker"; +import { CLAUDE_INTERCEPT_HOSTS, isBrowserConnect, startConnectProxy, type ConnectProxyHandle } from "./connect-proxy"; import { startClaudeInterceptListener } from "./listener"; import { claudeInterceptCaCertPath, ensureLocalInterceptCaForStartup, issueLocalInterceptLeaf } from "./local-ca"; +import type { PickerRouteInput } from "./picker-models"; +import { createPickerRuntime, type CreatePickerRuntimeOptions, type PickerRuntime } from "./picker-runtime"; +import type { SecurityRunner } from "./picker-trust"; /** * Lifecycle for the Claude intercept pair (CONNECT proxy + TLS listener). @@ -11,6 +15,13 @@ import { claudeInterceptCaCertPath, ensureLocalInterceptCaForStartup, issueLocal * Started next to the public listener, torn down with it. The proxy port is derived from the * public port unless configured, because Claude Code's `settings.json` must name a port that * survives restarts; the TLS listener is ephemeral and only ever reached through the proxy. + * + * Picker mode adds a second CONNECT proxy on the next port, used as Claude Desktop's pinned egress + * proxy. Desktop also hands that proxy to the Claude Code processes it spawns, so the choice is per + * client: Claude Code (no User-Agent on CONNECT) trusts only the intercept CA and gets the + * api.anthropic.com intercept and nothing else; the app itself (a browser User-Agent) trusts only + * the login keychain and never meets the api.anthropic.com intercept, and only its claude.ai + * tunnels may be terminated by the picker runtime (src/claude/intercept/picker-runtime.ts). */ export const CLAUDE_INTERCEPT_PORT_OFFSET = 100; @@ -27,9 +38,17 @@ export function claudeInterceptProxyPort(config: Pick, return publicPort + CLAUDE_INTERCEPT_PORT_OFFSET; } +/** Desktop's egress proxy for picker mode: the port after the intercept proxy (before it at 65535). */ +export function claudePickerProxyPort(config: Pick, publicPort: number): number { + const interceptPort = claudeInterceptProxyPort(config, publicPort); + return interceptPort < 65535 ? interceptPort + 1 : interceptPort - 1; +} + export interface ClaudeInterceptState { proxyPort: number; caCertPath: string; + /** Desktop egress proxy for picker mode; null when the picker is not wired or could not bind. */ + pickerProxyPort: number | null; } export interface ClaudeInterceptHandle extends ClaudeInterceptState { @@ -38,12 +57,44 @@ export interface ClaudeInterceptHandle extends ClaudeInterceptSta } let activeState: ClaudeInterceptState | null = null; +let activePicker: PickerRuntime | null = null; +let activeController: DesktopPickerController | null = null; /** Live intercept endpoints, or `null` when the pair is not running in this process. */ export function getClaudeInterceptState(): ClaudeInterceptState | null { return activeState; } +/** The running picker runtime, or `null` when picker mode is not wired in this process. */ +export function getClaudePickerRuntime(): PickerRuntime | null { + return activePicker; +} + +/** The picker controller that owns every picker mutation while this server runs, or `null`. */ +export function getClaudePickerController(): DesktopPickerController | null { + return activeController; +} + +/** + * Persist `claudeCode.intercept.picker` through the field-scoped writer and adopt the committed + * subtree into the live config, so a later whole-config save neither reverts nor re-applies it. + */ +export async function createPickerPreferenceWriter(live: OcxConfig): Promise<(value: boolean) => boolean> { + const { adoptPersistedClaudeCode, mutatePersistedConfig } = await import("../../config"); + return value => { + const outcome = mutatePersistedConfig(persisted => { + const claudeCode = persisted.claudeCode ?? {}; + const intercept = claudeCode.intercept ?? {}; + if (intercept.picker === value) return { changed: false, value: structuredClone(persisted.claudeCode) }; + persisted.claudeCode = { ...claudeCode, intercept: { ...intercept, picker: value } }; + return { changed: true, value: structuredClone(persisted.claudeCode) }; + }); + if (outcome.status === "unavailable") return false; + adoptPersistedClaudeCode(live, outcome.value); + return true; + }; +} + export interface StartClaudeInterceptOptions { config: OcxConfig; /** Bound public port; the derived proxy port is offset from it. */ @@ -56,6 +107,13 @@ export interface StartClaudeInterceptOptions { dispatch: (req: Request, server: Server) => Promise; maxRequestBodySize?: number; configDir?: string; + /** Routes for Desktop's Code-tab picker. Picker mode is wired only when this is given. */ + loadPickerRoutes?: () => Promise; + /** Test seam: builds the picker runtime. */ + createPicker?: (options: CreatePickerRuntimeOptions) => PickerRuntime; + /** Test seams: the macOS `security` runner and platform for the picker runtime and controller. */ + pickerSecurity?: SecurityRunner; + pickerPlatform?: NodeJS.Platform; } /** @@ -84,13 +142,102 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio await listener.stop(true); throw error; } - const state: ClaudeInterceptState = { proxyPort: proxy.port, caCertPath: claudeInterceptCaCertPath(configDir) }; + // Widened on purpose: assignments happen in nested awaits the catch below must still see. + let picker = null as PickerRuntime | null; + let pickerProxy = null as ConnectProxyHandle | null; + let controller = null as DesktopPickerController | null; + let pickerProxyLive = false; + try { + if (options.loadPickerRoutes) { + picker = (options.createPicker ?? createPickerRuntime)({ + config: options.config, + configDir, + loadRoutes: options.loadPickerRoutes, + // The controller's lock: while it is held, periodic refreshes never arm. + isBusy: () => controller?.busy() ?? false, + // Metadata only: method, bootstrap or other, status, and the rewrite outcome. + log: line => console.log(`[claude-picker] ${line}`), + ...(options.pickerSecurity ? { security: options.pickerSecurity } : {}), + ...(options.pickerPlatform ? { platform: options.pickerPlatform } : {}), + }); + const runtime = picker; + const interceptPort = listener.port!; + try { + pickerProxy = await startConnectProxy(claudePickerProxyPort(options.config, options.publicPort), { + interceptPort, + // No host list here: the choice below depends on which client opened the tunnel. + interceptHosts: [], + selectTunnel: (host, port, request) => { + // Desktop hands its pinned egress proxy to the Claude Code processes it spawns, so their + // api.anthropic.com traffic arrives here too and gets the same intercept as on the Claude + // Code proxy. Those processes trust only the intercept CA, so the picker never terminates + // their claude.ai tunnels; only the app's own (browser) CONNECTs reach the picker. + if (!isBrowserConnect(request)) { + return port === 443 && (CLAUDE_INTERCEPT_HOSTS as readonly string[]).includes(host.toLowerCase()) + ? { kind: "intercept", port: interceptPort } + : { kind: "blind" }; + } + return runtime.selectTunnel(host, port); + }, + }); + pickerProxyLive = true; + } catch (error) { + // Picker mode is optional: a busy port leaves the intercept pair running without it. + console.warn(`⚠ Claude Desktop picker proxy could not start: ${error instanceof Error ? error.message : String(error)}`); + await runtime.stop(); + picker = null; + } + if (picker) { + // Dynamic: the controller reaches desktop-first-party, which imports this module. + const [{ createDesktopPickerController }, { loadConfig }, persistPreference] = await Promise.all([ + import("../desktop-picker"), + import("../../config"), + createPickerPreferenceWriter(options.config), + ]); + const boundProxy = pickerProxy; + controller = createDesktopPickerController({ + runtime: picker, + readConfig: loadConfig, + persistPreference, + proxyPort: () => (pickerProxyLive && boundProxy ? boundProxy.port : null), + configDir, + ...(options.pickerSecurity ? { security: options.pickerSecurity } : {}), + ...(options.pickerPlatform ? { platform: options.pickerPlatform } : {}), + }); + await picker.start(); + } + } + } catch (error) { + // Construction or start failed after the CONNECT proxy bound: release every socket first, + // so the lifecycle's catch never leaves a bound port without a handle. + pickerProxyLive = false; + controller = null; + await picker?.stop(); + await pickerProxy?.close(); + await proxy.close(); + await listener.stop(true); + throw error; + } + const state: ClaudeInterceptState = { + proxyPort: proxy.port, + caCertPath: claudeInterceptCaCertPath(configDir), + pickerProxyPort: picker && pickerProxy ? pickerProxy.port : null, + }; activeState = state; + activePicker = picker; + activeController = controller; + const ownPicker = picker; + const ownController = controller; return { ...state, listener, stop: async () => { if (activeState === state) activeState = null; + if (activePicker === ownPicker) activePicker = null; + if (activeController === ownController) activeController = null; + pickerProxyLive = false; + await ownPicker?.stop(); + await pickerProxy?.close(); await proxy.close(); await listener.stop(true); }, diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index ff549536d2e..b915aa5bee7 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -830,6 +830,51 @@ export const CAPABILITIES: readonly Capability[] = [ "Removing an id that is not bound is a no-op; the remaining bindings are printed.", ], }, + { + command: ["claude", "desktop", "picker", "status"], + summary: "First-party picker mode: whether Claude Desktop's Code tab lists opencodex models, and what is missing if not.", + routes: [{ method: "GET", path: "/api/claude-desktop/picker" }], + flags: [], + mutates: false, + json: "none", + details: [ + "Reports desired, effective, keychain trust, the Desktop egress profile, the model count and a reason with the next command to run.", + ], + }, + { + command: ["claude", "desktop", "picker", "on"], + summary: "Turn first-party picker mode on and remember the choice.", + routes: [{ method: "PUT", path: "/api/claude-desktop/picker" }], + flags: [], + mutates: true, + json: "none", + details: [ + "Needs a running proxy, first-party mode and macOS. The first time, macOS asks to trust a local certificate authority limited to claude.ai; when the server cannot show that prompt the command runs the trust step in this terminal.", + "Claude Desktop then reaches the network through opencodex; fully quit and reopen Desktop afterwards.", + ], + }, + { + command: ["claude", "desktop", "picker", "off"], + summary: "Turn first-party picker mode off, remove its Desktop egress profile and certificate trust, and remember the choice.", + routes: [{ method: "PUT", path: "/api/claude-desktop/picker" }], + flags: [], + mutates: true, + json: "none", + details: [ + "Works without a running proxy: the preference is saved and the picker profile and trust are removed locally.", + ], + }, + { + command: ["claude", "desktop", "picker", "trust"], + summary: "Run the macOS keychain step for picker mode in this terminal, then ask the server to finish enabling it.", + routes: [{ method: "PUT", path: "/api/claude-desktop/picker" }], + flags: [], + mutates: true, + json: "none", + details: [ + "The server removes trust this command added if the enable is refused; if the request is lost, trust is left alone and picker status tells what happened.", + ], + }, { command: ["integration", "native"], summary: "Show or toggle the native Claude, Claude Desktop, Codex, and Grok integrations, and read the Cursor status (which builds are installed, gateway values, last request seen).", diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 3a526e8504a..a75070468c1 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -1,6 +1,7 @@ import { recordCommittedDesktopGateway } from "../claude/desktop-gateway-state"; import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; +import { getConfigDir } from "../config/paths"; import { loadConfig, mutatePersistedConfig, withConfigMutationLockSync } from "../config"; import { claudeDesktopIntegrationEnabledNow, setIntegrationEnabled } from "../codex/desired-state"; import { readClientConnectionState, assertClientConnectionUnchanged, assertNoClientDisconnectPending, type ClientConnectionState } from "../client/state"; @@ -21,17 +22,24 @@ import { applyDesktopFirstParty, captureDesktopFirstPartyRollback, isClaudeDesktopMode, + observeClaudeDesktopMode, recordClaudeDesktopMode, removeDesktopFirstParty, resolveClaudeDesktopMode, resolveClaudeDesktopApplyMode, + type ClaudeDesktopModeObservation, type ClaudeDesktopMode, } from "../claude/desktop-first-party"; +import { FIRST_PARTY_ACCOUNT_RISK } from "../claude/desktop-risk"; +import { claudeInterceptEnabled } from "../claude/intercept/runtime"; +import { ensurePickerCa, pickerCaCertPath, pickerCaFingerprints, pickerLeafCertPath } from "../claude/intercept/picker-ca"; +import { inspectPickerTrust, trustPickerCa, untrustPickerCa, type SecurityRunner } from "../claude/intercept/picker-trust"; +import { offlinePickerStatus, removeDesktopPickerArtifacts, type DesktopPickerStatus } from "../claude/desktop-picker"; import { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } from "../claude/desktop-policy"; import { filterCatalogVisibleModels, desktopVisibleNativeSlugs, nativeContextLimits } from "../codex/catalog"; import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api"; import { findLiveProxy } from "../server/proxy-liveness"; -import { CliUsageError, runtimeRequest, takeJsonFlag } from "./runtime-api"; +import { CliUsageError, RuntimeApiError, runtimeRequest, takeJsonFlag, type RuntimeApiDeps } from "./runtime-api"; import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import type { OcxConfig } from "../types"; @@ -44,11 +52,14 @@ function isFamily(value: string | undefined): value is DesktopFamily { function printDesktopHelp(): void { console.log(`Usage: ocx claude desktop [apply] [--first-party | --gateway [--static|--hybrid|--discovery-only]] - --first-party (default) keep Desktop on claude.ai; route only the Code tab's Claude Code - through the local intercept proxy via ~/.claude/settings.json env - --gateway install the third-party gateway profile for the whole app + --gateway (default) install the third-party gateway profile for the whole app + --first-party keep Desktop on claude.ai; route only the Code tab's Claude Code through the + local intercept proxy via ~/.claude/settings.json env. Account risk: this sends + Claude subscription traffic through a local interception proxy, and Anthropic + may suspend the account. ocx claude desktop show [--json] ocx claude desktop status [--json] + ocx claude desktop picker on|off|status|trust ocx claude desktop bind first-party: serve a Code tab picker model (e.g. claude-sonnet-4-6) with an opencodex model; the picker keeps Anthropic's label, and only Claude Code traffic through the local proxy uses it @@ -67,10 +78,111 @@ export interface ApplyProfileDeps { postApplyImpl?: ( mode: Desktop3pConfigMode, profile: DesktopProfile, - ) => Promise<{ ok?: boolean; path?: string; error?: string; warning?: string }>; + ) => Promise<{ ok?: boolean; path?: string; error?: string; warning?: string; picker?: DesktopPickerStatus }>; + runtimeRequestImpl?: typeof runtimeRequest; + ensurePickerCaImpl?: typeof ensurePickerCa; + inspectPickerTrustImpl?: typeof inspectPickerTrust; + trustPickerCaImpl?: typeof trustPickerCa; + untrustPickerCaImpl?: typeof untrustPickerCa; + removeDesktopPickerArtifacts?: typeof removeDesktopPickerArtifacts; + security?: SecurityRunner; + platform?: NodeJS.Platform; probeClaudeDesktopPolicy?: typeof import("../claude/desktop-policy").probeClaudeDesktopPolicy; } +type DesktopApplyResult = { + ok: boolean; + path: string; + reason?: string; + warning?: string; + picker?: DesktopPickerStatus; + delegated?: boolean; +}; + +type PickerRouteResponse = { + ok?: boolean; + code?: string; + reason?: string; + hint?: string; + picker?: DesktopPickerStatus; +}; + +function pickerRuntimeRequest( + path: string, + init: RequestInit, + deps: ApplyProfileDeps, +): Promise { + const request = deps.runtimeRequestImpl ?? runtimeRequest; + const requestDeps: RuntimeApiDeps = deps.findLiveProxyImpl + ? { findLiveProxy: deps.findLiveProxyImpl } + : {}; + return request(path, init, requestDeps); +} + +async function liveDesktopProxy(deps: ApplyProfileDeps): Promise { + return !!await (deps.findLiveProxyImpl ?? findLiveProxy)(); +} + +function persistPickerPreference(value: boolean): boolean { + const outcome = mutatePersistedConfig(current => { + const claudeCode = current.claudeCode ?? {}; + const intercept = claudeCode.intercept ?? {}; + if (intercept.picker === value) return { changed: false, value: structuredClone(current.claudeCode) }; + current.claudeCode = { ...claudeCode, intercept: { ...intercept, picker: value } }; + return { changed: true, value: structuredClone(current.claudeCode) }; + }); + return outcome.status !== "unavailable"; +} + +function pickerTrustPaths(deps: ApplyProfileDeps, configDir = getConfigDir()): { caPath: string; leafPath: string; sha1: string } { + const ca = (deps.ensurePickerCaImpl ?? ensurePickerCa)(configDir); + return { + caPath: pickerCaCertPath(configDir), + leafPath: pickerLeafCertPath(configDir), + sha1: pickerCaFingerprints(ca.certPem).sha1, + }; +} + +async function trustPickerLocally(deps: ApplyProfileDeps): Promise<{ ok: true; callerAddedTrust: boolean; caPath: string; sha1: string } | { ok: false; reason: string }> { + try { + const configDir = getConfigDir(); + const paths = pickerTrustPaths(deps, configDir); + const inspect = deps.inspectPickerTrustImpl ?? inspectPickerTrust; + const before = await inspect(paths.leafPath, paths.sha1, deps.security, deps.platform); + if (before === "trusted") return { ok: true, callerAddedTrust: false, caPath: paths.caPath, sha1: paths.sha1 }; + const trust = await (deps.trustPickerCaImpl ?? trustPickerCa)(paths.caPath, deps.security, deps.platform); + if (!trust.ok) return { ok: false, reason: trust.reason ?? "trust_declined" }; + return { ok: true, callerAddedTrust: true, caPath: paths.caPath, sha1: paths.sha1 }; + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : "trust_failed" }; + } +} + +async function compensateLocalPickerTrust( + trust: { callerAddedTrust: boolean; caPath: string; sha1: string }, + deps: ApplyProfileDeps, +): Promise { + if (!trust.callerAddedTrust) return; + await (deps.untrustPickerCaImpl ?? untrustPickerCa)(trust.caPath, trust.sha1, deps.security, deps.platform); +} + +function printPickerStatus(status: DesktopPickerStatus | undefined, json = false): void { + if (!status) return; + if (json) console.log(JSON.stringify(status, null, 2)); + else console.log(`picker: ${JSON.stringify(status)}`); +} + +function isAmbiguousPickerTransport(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /timeout|timed out|abort|aborted|lost response|socket hang up|reset/i.test(message); +} + +function isAnsweredPickerRefusal(error: unknown): boolean { + if (!(error instanceof RuntimeApiError)) return false; + const body = error.body; + return !!body && typeof body === "object" && ("picker" in body || (body as Record).code === "picker_proxy_unavailable"); +} + /** Persist only the requested local profile, never an await-old whole configuration. */ function saveLocalDesktopProfile( profile: DesktopProfile, @@ -179,22 +291,24 @@ export type DesktopApplyTarget = /** Parse `ocx claude desktop apply` flags into a target; legacy gateway shape flags imply --gateway. */ /** - * Default mode when no flag is given: first-party wherever the local intercept proxy can - * run; a connected client (proxy lives on the hub) or a disabled intercept falls back to - * the gateway profile rather than pointing Claude Code at a proxy that does not exist. + * Default mode when no flag is given: gateway, unless this install is already first-party (saved + * mode or observed first-party settings). A connected client still uses gateway, because the + * intercept proxy lives on the hub. */ export function defaultDesktopApplyMode( - config: Pick, + config: Pick, connection: ClientConnectionState = readClientConnectionState(), + observed: ClaudeDesktopModeObservation = observeClaudeDesktopMode(config), ): ClaudeDesktopMode { - const resolved = resolveClaudeDesktopApplyMode(config); + const resolved = resolveClaudeDesktopApplyMode(config, observed); if (resolved === "gateway") return resolved; return connection.kind === "connected" ? "gateway" : "first-party"; } export function parseDesktopApplyArgs( flags: string[], - config: Pick, + config: Pick, + observed?: ClaudeDesktopModeObservation, ): { target: DesktopApplyTarget } | { error: string } { const shapeFlags = flags.filter(arg => ["--static", "--hybrid", "--discovery-only"].includes(arg)); const wantsFirstParty = flags.includes("--first-party"); @@ -202,7 +316,9 @@ export function parseDesktopApplyArgs( if (wantsFirstParty && wantsGateway) return { error: "--first-party cannot be combined with --gateway or gateway shape flags." }; const unknown = flags.filter(arg => !["--first-party", "--gateway", "--static", "--hybrid", "--discovery-only"].includes(arg)); if (unknown.length > 0) return { error: `알 수 없는 인자: ${unknown.join(" ")}` }; - const kind: ClaudeDesktopMode = wantsFirstParty ? "first-party" : wantsGateway ? "gateway" : defaultDesktopApplyMode(config); + const kind: ClaudeDesktopMode = wantsFirstParty + ? "first-party" + : wantsGateway ? "gateway" : defaultDesktopApplyMode(config, readClientConnectionState(), observed ?? observeClaudeDesktopMode(config)); if (kind === "first-party") return { target: { kind } }; const parsedMode = parseDesktop3pModeArgs(shapeFlags); if ("error" in parsedMode) return parsedMode; @@ -210,18 +326,13 @@ export function parseDesktopApplyArgs( } /** - * Why a gateway apply happened when the help text calls first-party the default. - * - * `resolveClaudeDesktopMode` keeps an existing install where it is: an explicit - * `claudeCode.desktopMode` wins, and a stored gateway apply marker keeps gateway. Both rules are - * right — a working Desktop install must not flip underneath its user because a default moved. - * Together they mean an existing gateway user never arrives at first-party without discovering - * `--first-party` unaided, while `ocx claude desktop --help` tells them first-party is "(default)". + * What an apply without a flag says after it lands on gateway, the default. * - * The fix is not to change the resolution. It is to say, at the moment of the apply, that the - * other mode exists and what selects it. Returns null when the user asked for gateway explicitly, - * because they already know, and when first-party is simply unavailable here — a connected client - * or a disabled intercept cannot run it, so offering it would be advice that fails. + * It names why gateway was chosen (the default, a saved gateway mode, or a previous gateway apply), + * that first-party exists and which command selects it, and the account risk that comes with it, + * so nobody switches without reading it. Returns nothing when the user asked for gateway + * explicitly, because they already chose, and when first-party cannot run here — a connected client + * or a disabled intercept — because offering it would be advice that fails. */ export function gatewayModeExplanation(input: { requestedExplicitly: boolean; @@ -231,19 +342,20 @@ export function gatewayModeExplanation(input: { if (input.requestedExplicitly) return []; const connection = input.connection ?? readClientConnectionState(); if (connection.kind === "connected") return []; - // Only a stored preference is worth explaining. Without one, gateway was chosen because - // first-party cannot run here, and naming an unavailable alternative is advice that fails. + if (!claudeInterceptEnabled(input.config)) return []; const savedMode = input.config.claudeCode?.desktopMode; const hasSavedGateway = isClaudeDesktopMode(savedMode) && savedMode === "gateway"; const hasApplyMarker = input.config.claudeCode?.desktopProfile?.appliedFingerprint !== undefined; - if (!hasSavedGateway && !hasApplyMarker) return []; const reason = hasSavedGateway - ? "this machine has claudeCode.desktopMode saved as gateway" - : "this machine carries a previous gateway apply"; + ? "because this machine has claudeCode.desktopMode saved as gateway; an existing install is never switched for you" + : hasApplyMarker + ? "because this machine carries a previous gateway apply; an existing install is never switched for you" + : "because gateway is the default for Claude Desktop"; return [ - `Applied the gateway profile because ${reason}; an existing install is never switched for you.`, + `Applied the gateway profile ${reason}.`, "First-party keeps Desktop on your claude.ai account and routes only the Code tab through the local proxy:", " ocx claude desktop apply --first-party", + `Account risk: ${FIRST_PARTY_ACCOUNT_RISK.message}`, ]; } @@ -254,11 +366,23 @@ export function gatewayModeExplanation(input: { */ async function applyFirstPartyDesktop( deps: ApplyProfileDeps, -): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { +): Promise { try { assertNoClientDisconnectPending(); } catch { return { ok: false, path: "", reason: "client_disconnect_pending" }; } const connection = readClientConnectionState(); if (connection.kind === "connected") return { ok: false, path: "", reason: "first_party_requires_local_hub" }; if (connection.kind !== "disconnected") return { ok: false, path: "", reason: "client_connection_invalid" }; + if (await liveDesktopProxy(deps)) { + try { + const applied = await pickerRuntimeRequest( + "/api/claude-desktop/apply", + { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ mode: "first-party" }) }, + deps, + ); + return { ...applied, path: applied.path ?? "", delegated: true }; + } catch (error) { + return { ok: false, path: "", reason: error instanceof Error ? error.message : "daemon apply failed" }; + } + } const config = loadConfig(); const desired = setIntegrationEnabled("claude-desktop", true); if (!desired.ok) return { ok: false, path: "", reason: desired.message }; @@ -284,6 +408,7 @@ async function applyFirstPartyDesktop( return { ok: true, path: applied.path, + picker: offlinePickerStatus(loadConfig(), deps.platform), ...(saved ? {} : { warning: "desktop mode marker was not saved" }), }; } @@ -292,10 +417,11 @@ export async function applyDesktop( profile: DesktopProfile | undefined, target: DesktopApplyTarget, deps: ApplyProfileDeps = {}, -): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { +): Promise { if (target.kind === "first-party") return applyFirstPartyDesktop(deps); const result = await applyProfile(profile, target.mode, deps); if (!result.ok) return result; + if (result.delegated) return result; const modeSaved = saveDesktopMode("gateway", deps); const warning = [result.warning, modeSaved ? "" : "desktop mode marker was not saved"].filter(Boolean).join(" "); // The gateway mode is committed before retiring first-party settings. @@ -310,7 +436,7 @@ export async function applyProfile( profile: DesktopProfile | undefined, mode: Desktop3pConfigMode, deps: ApplyProfileDeps = {}, -): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { +): Promise { try { assertNoClientDisconnectPending(); } catch { return { ok: false, path: "", reason: "client_disconnect_pending" }; } const connection = readClientConnectionState(); if (connection.kind === "connected") return applyConnectedDesktopProfile(mode, connection, deps); @@ -330,7 +456,7 @@ export async function applyProfile( // serving process installs the map there; a local-only write leaves the // daemon unable to decode aliases, and the provider rejects them (400). const post = deps.postApplyImpl ?? (async (m: Desktop3pConfigMode, p: DesktopProfile) => - runtimeRequest<{ ok?: boolean; path?: string; error?: string; saved?: boolean; warning?: string }>( + runtimeRequest<{ ok?: boolean; path?: string; error?: string; saved?: boolean; warning?: string; picker?: DesktopPickerStatus }>( "/api/claude-desktop/apply", // The daemon's config may be older than what we just saved, so the // profile travels with the request instead of being re-read there. @@ -346,6 +472,8 @@ export async function applyProfile( return { ok: true, path: applied.path ?? "", + picker: applied.picker, + delegated: true, ...(warning ? { warning } : partial ? { warning: "applied marker was not saved" } : {}), }; } catch (error) { @@ -391,6 +519,130 @@ export async function applyProfile( }; } +async function handleClaudeDesktopPickerCommand( + argv: string[], + config: OcxConfig, + deps: ApplyProfileDeps, +): Promise { + const action = argv[1]; + const rest = argv.slice(2); + const usage = "Usage: ocx claude desktop picker on|off|status|trust [--json]"; + if (!action || !["on", "off", "status", "trust"].includes(action)) throw new CliUsageError(usage); + const wantsJson = takeJsonFlag(rest); + if (rest.length > 0 || (action !== "status" && wantsJson)) throw new CliUsageError(usage); + + const requestPicker = (body: Record) => pickerRuntimeRequest( + "/api/claude-desktop/picker", + { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }, + deps, + ); + + if (action === "status") { + if (await liveDesktopProxy(deps)) { + try { + const response = await pickerRuntimeRequest<{ ok?: boolean; picker?: DesktopPickerStatus }>("/api/claude-desktop/picker", {}, deps); + printPickerStatus(response.picker, wantsJson); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } + } else { + printPickerStatus(offlinePickerStatus(config, deps.platform), wantsJson); + } + return 0; + } + + if (action === "off") { + if (await liveDesktopProxy(deps)) { + try { + const response = await requestPicker({ enabled: false, persist: true }); + printPickerStatus(response.picker, false); + } catch (error) { + if (isAnsweredPickerRefusal(error)) { + const body = (error as RuntimeApiError).body as PickerRouteResponse; + printPickerStatus(body.picker, false); + } + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } + } else { + if (!persistPickerPreference(false)) { + console.error("Could not persist claudeCode.intercept.picker=false"); + return 1; + } + const removed = await (deps.removeDesktopPickerArtifacts ?? removeDesktopPickerArtifacts)({ configDir: getConfigDir(), security: deps.security, platform: deps.platform }); + if (!removed.ok) console.error(`picker cleanup incomplete${removed.residual?.length ? `: ${removed.residual.join(", ")}` : ""}`); + printPickerStatus(offlinePickerStatus(loadConfig(), deps.platform), false); + if (!removed.ok) return 1; + } + console.log("Fully quit and reopen Claude Desktop"); + return 0; + } + + if (action === "on" && !(await liveDesktopProxy(deps))) { + console.error("proxy_unavailable"); + return 1; + } + + let localTrust: { callerAddedTrust: boolean; caPath: string; sha1: string } | undefined; + const sendEnable = async (trustedLocally = false): Promise => requestPicker({ + enabled: true, + persist: action === "on", + ...(trustedLocally ? { trustedLocally: true, callerAddedTrust: localTrust?.callerAddedTrust ?? false } : {}), + }); + + if (action === "trust") { + const trusted = await trustPickerLocally(deps); + if (!trusted.ok) { + console.error(trusted.reason); + return 1; + } + localTrust = trusted; + if (!(await liveDesktopProxy(deps))) { + await compensateLocalPickerTrust(localTrust, deps); + console.error("proxy_unavailable"); + return 1; + } + } + + let response: PickerRouteResponse; + try { + response = await sendEnable(action === "trust"); + if (response.ok === false) { + printPickerStatus(response.picker, false); + console.error(response.reason ?? response.code ?? "picker_enable_refused"); + return 1; + } + if (action === "on" && response.picker?.reason === "trust_pending") { + const trusted = await trustPickerLocally(deps); + if (!trusted.ok) { + console.error(trusted.reason); + return 1; + } + localTrust = trusted; + response = await sendEnable(true); + } + printPickerStatus(response.picker, false); + if (response.picker?.reason === "restart_required") console.log("Fully quit and reopen Claude Desktop"); + const reason = response.picker?.reason; + return response.ok === false || (reason !== "active" && reason !== "restart_required") ? 1 : 0; + } catch (error) { + if (isAnsweredPickerRefusal(error)) { + const body = (error as RuntimeApiError).body as PickerRouteResponse; + printPickerStatus(body.picker, false); + console.error(body.reason ?? body.code ?? (error instanceof Error ? error.message : String(error))); + return 1; + } + if (isAmbiguousPickerTransport(error)) { + console.error("state unknown - run ocx claude desktop picker status"); + return 1; + } + if (localTrust) await compensateLocalPickerTrust(localTrust, deps); + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} + export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProfileDeps = {}): Promise { const command = argv[0]; if (command === "help" || command === "--help" || command === "-h") { @@ -426,6 +678,8 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf if (target.kind === "first-party") { console.log(`Claude Desktop first-party 설정을 적용했습니다: ${result.path}`); console.log("Desktop 앱 설정은 그대로이며, Code 탭의 Claude Code만 로컬 프록시를 거칩니다."); + console.warn(`⚠️ ${FIRST_PARTY_ACCOUNT_RISK.message}`); + printPickerStatus(result.picker, false); } else { console.log(`Claude Desktop gateway 설정을 적용했습니다: ${result.path}`); for (const line of gatewayModeExplanation({ @@ -459,6 +713,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf console.warn("Local client profile only; connected Desktop apply uses the hub profile."); } const config = loadConfig(); + if (command === "picker") return await handleClaudeDesktopPickerCommand(argv, config, deps); // `status` is API-backed and must NOT build local state first: the whole point of the // route the GUI polls (/api/claude-desktop/status) is the applied-vs-desired comparison, // including staleness, drift and health, which only the running proxy knows. `show` @@ -497,7 +752,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf const ids = Object.keys(bindings).sort(); if (ids.length === 0) console.log("현재 연결된 피커 모델이 없습니다."); for (const id of ids) console.log(` ${id} -> ${bindings[id]}`); - if (resolveClaudeDesktopMode(config) === "gateway") { + if (resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) === "gateway") { console.warn("⚠️ Desktop이 gateway 모드입니다. 바인딩은 first-party 모드(ocx claude desktop apply --first-party)의 Code 탭과 claude CLI에만 적용됩니다."); } return 0; diff --git a/src/cli/ensure-desired-integrations.ts b/src/cli/ensure-desired-integrations.ts index 6a23b5a00c5..eecd1876b03 100644 --- a/src/cli/ensure-desired-integrations.ts +++ b/src/cli/ensure-desired-integrations.ts @@ -9,11 +9,15 @@ * each external-file mutation, and use that current config for sync inputs. */ import { loadConfig } from "../config"; +import { removeDesktopPickerArtifacts } from "../claude/desktop-picker"; +import { findLiveProxy } from "../server/proxy-liveness"; +import { runtimeRequest } from "./runtime-api"; import { stripGrokConfig, type GrokInjectResult } from "../grok/inject"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } from "../claude/desktop-3p"; import { applyDesktopFirstParty, inspectDesktopFirstParty, + observeClaudeDesktopMode, removeDesktopFirstParty, resolveClaudeDesktopMode, } from "../claude/desktop-first-party"; @@ -44,7 +48,11 @@ export interface EnsureDesiredIntegrationsDeps { removeDesktopFirstParty?: typeof removeDesktopFirstParty; applyDesktopFirstParty?: typeof applyDesktopFirstParty; inspectDesktopFirstParty?: typeof inspectDesktopFirstParty; + observeClaudeDesktopMode?: typeof observeClaudeDesktopMode; inspectDesktop3pConfigLibrary?: typeof inspectDesktop3pConfigLibrary; + findLiveProxyImpl?: typeof findLiveProxy; + runtimeRequestImpl?: typeof runtimeRequest; + removeDesktopPickerArtifacts?: typeof removeDesktopPickerArtifacts; log?: (message: string) => void; error?: (message: string) => void; } @@ -130,13 +138,13 @@ export async function ensureGrokFenceMatchesDesired( * When it is ON in first-party mode, refresh a stale env (the intercept port follows the * public port, so a port change would otherwise leave Claude Code pointed at a dead proxy). */ -export function ensureClaudeDesktopMatchesDesired( +export async function ensureClaudeDesktopMatchesDesired( deps: EnsureDesiredIntegrationsDeps = productionDeps, -): void { +): Promise { const config = deps.loadConfig(); const { log, error } = io(deps); if (claudeDesktopIntegrationEnabled(config)) { - if (resolveClaudeDesktopMode(config) !== "first-party") return; + if (resolveClaudeDesktopMode(config, (deps.observeClaudeDesktopMode ?? observeClaudeDesktopMode)(config)) !== "first-party") return; const library = (deps.inspectDesktop3pConfigLibrary ?? inspectDesktop3pConfigLibrary)({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, }); @@ -153,6 +161,23 @@ export function ensureClaudeDesktopMatchesDesired( else if (!applied.ok) error(`⚠️ Claude Desktop first-party env refresh skipped: ${applied.reason}.`); return; } + try { + const live = await (deps.findLiveProxyImpl ?? findLiveProxy)(); + if (live) { + const request = deps.runtimeRequestImpl ?? runtimeRequest; + await request( + "/api/claude-desktop/picker", + { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: false, persist: false }) }, + deps.findLiveProxyImpl ? { findLiveProxy: deps.findLiveProxyImpl } : {}, + ); + } else { + const removed = await (deps.removeDesktopPickerArtifacts ?? removeDesktopPickerArtifacts)({}); + if (!removed.ok) error(`⚠️ Claude Desktop picker cleanup skipped${removed.residual?.length ? `: ${removed.residual.join(", ")}` : ""}.`); + } + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + error(`⚠️ Claude Desktop picker cleanup skipped: ${detail}.`); + } try { const env = (deps.removeDesktopFirstParty ?? removeDesktopFirstParty)(); if (env.ok && env.changed) log(" ↩️ Claude Desktop first-party env removed."); @@ -196,5 +221,5 @@ export async function reconcileEnsureDesiredIntegrations( liveHost ? { hostname: liveHost } : {}, deps, ); - ensureClaudeDesktopMatchesDesired(deps); + await ensureClaudeDesktopMatchesDesired(deps); } diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index c215f32e6c4..eb2c2c9182a 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -285,10 +285,13 @@ export const configSchema = z.object({ if (!intercept || typeof intercept !== "object" || Array.isArray(intercept)) { ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept"], message: "intercept must be an object" }); } else { - const { enabled, port, modelMap } = intercept as { enabled?: unknown; port?: unknown; modelMap?: unknown }; + const { enabled, port, picker, modelMap } = intercept as { enabled?: unknown; port?: unknown; picker?: unknown; modelMap?: unknown }; if (enabled !== undefined && typeof enabled !== "boolean") { ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "enabled"], message: "intercept.enabled must be a boolean" }); } + if (picker !== undefined && typeof picker !== "boolean") { + ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "picker"], message: "intercept.picker must be a boolean" }); + } if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) { ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "port"], message: "intercept.port must be an integer between 1 and 65535" }); } diff --git a/src/server/index/claude-intercept-lifecycle.ts b/src/server/index/claude-intercept-lifecycle.ts index f49bc49b988..b55f794dc06 100644 --- a/src/server/index/claude-intercept-lifecycle.ts +++ b/src/server/index/claude-intercept-lifecycle.ts @@ -1,4 +1,5 @@ import type { Server } from "bun"; +import type { PickerRouteInput } from "../../claude/intercept/picker-models"; import { startClaudeIntercept, type ClaudeInterceptHandle, @@ -18,6 +19,28 @@ export interface ClaudeInterceptLifecycle { stop(): Promise; } +/** + * Routes for Desktop's Code-tab picker: the same inputs `/api/sync` gives the gateway profile + * (src/server/management/config-routes.ts), read from the persisted config at call time. Dynamic + * imports keep the catalog and discovery off the synchronous startup path. + */ +export async function loadPickerRoutesFromCatalog(): Promise { + const [{ loadConfig }, { fetchAllModels }, catalog] = await Promise.all([ + import("../../config"), + import("../management-api"), + import("../../codex/catalog"), + ]); + const config = loadConfig(); + const models = await fetchAllModels(config); + return { + nativeSlugs: [...catalog.desktopVisibleNativeSlugs(config)], + routedModels: catalog.filterCatalogVisibleModels(models, config) + .map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow })), + ...(config.claudeCode?.desktopProfile ? { profile: config.claudeCode.desktopProfile } : {}), + nativeContextCap: catalog.nativeContextLimits(config), + }; +} + export function createClaudeInterceptLifecycle(): ClaudeInterceptLifecycle { let listener: Server | null = null; let pending: Promise | null> = Promise.resolve(null); @@ -27,6 +50,7 @@ export function createClaudeInterceptLifecycle(): ClaudeInterceptLifecycle const dispatch = options.dispatch; pending = startClaudeIntercept({ ...options, + loadPickerRoutes: options.loadPickerRoutes ?? loadPickerRoutesFromCatalog, dispatch: (req, requestServer) => { listener ??= requestServer; return dispatch(req, requestServer); diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 2ef241536d9..3881718b2d6 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -76,6 +76,7 @@ import { handleCompanionRoutes } from "./management/companion-routes"; import { handleCodexPromptRoutes } from "./management/codex-prompt-routes"; import { handleIntegrationRoutes } from "./management/integration-routes"; import { handleNativeIntegrationRoutes } from "./management/native-integration-routes"; +import { handleClaudeDesktopPickerRoutes } from "./management/claude-desktop-picker-routes"; import { handleCursorIntegrationRoutes } from "./management/cursor-integration-routes"; import type { ManagementContext } from "./management/context"; import type { ManagementPrincipal, ManagementSessionControl } from "./management-auth"; @@ -293,6 +294,7 @@ export async function handleManagementAPI( ?? (await handleIntegrationRoutes(ctx)) ?? (await handleNativeIntegrationRoutes(ctx)) ?? (await handleCursorIntegrationRoutes(ctx)) + ?? (await handleClaudeDesktopPickerRoutes(ctx)) ?? (await handleAgentSettingsRoutes(ctx)) ?? (await handleCodexPromptRoutes(ctx)) ?? (await handleOauthAccountRoutes(ctx)) diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index ca84dde8e7f..5e976740fd7 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -214,6 +214,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const { claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); const admitted = loadConfig(); if (!claudeDesktopIntegrationEnabled(admitted)) return; + // A first-party Desktop must never get a gateway profile written and selected behind it. + const { observeClaudeDesktopMode, resolveClaudeDesktopMode } = await import("../../claude/desktop-first-party"); + if (resolveClaudeDesktopMode(admitted, observeClaudeDesktopMode(admitted)) === "first-party") return; if (admitted.claudeCode?.desktopAutoApply === false) return; if (!admitted.claudeCode?.desktopProfile) return; const { inspectDesktop3pConfigLibrary, writeDesktop3pConfig } = await import("../../claude/desktop-3p"); @@ -223,32 +226,38 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (["not_installed", "no_owned_state", "foreign", "unsafe", "broken"].includes(beforeKind)) return; const { filterCatalogVisibleModels, desktopVisibleNativeSlugs } = await import("../../codex/catalog"); const allModels = await (deps.fetchAllModels ?? fetchAllModels)(admitted); - const current = loadConfig(); - // This is the real guard: the catalog await admits a concurrent explicit OFF. - if (!claudeDesktopIntegrationEnabled(current)) return; - if (current.claudeCode?.desktopAutoApply === false || !current.claudeCode?.desktopProfile) return; - const afterKind = inspectDesktop3pConfigLibrary({ - appliedFingerprint: current.claudeCode.desktopProfile.appliedFingerprint ?? null, - }).kind; - if (["not_installed", "no_owned_state", "foreign", "unsafe", "broken"].includes(afterKind)) return; - const routed = filterCatalogVisibleModels(allModels, current).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })); - const writtenProfile = current.claudeCode.desktopProfile; - const markerBaseline = captureDesktopAppliedMarker(writtenProfile); - const result = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( - current.port ?? 10100, - [...desktopVisibleNativeSlugs(current)], - routed, - current.apiKeys?.[0]?.key, - "static", - writtenProfile, - nativeContextLimits(current), - ); - if (result.written && result.fingerprint) { - const marked = commitDesktopAppliedMarker(markerBaseline, result.fingerprint); - if (marked.status === "unavailable" || marked.value === false) { - console.warn("[claude-desktop] provider-change applied marker skipped"); + // Serialized with Desktop mode transitions: a first-party switch cannot interleave with this write. + const { runPickerTransition } = await import("./claude-desktop-picker-routes"); + await runPickerTransition(admitted, async () => { + const current = loadConfig(); + // This is the real guard: the catalog await admits a concurrent explicit OFF. + if (!claudeDesktopIntegrationEnabled(current)) return undefined; + // …and a concurrent switch to first-party. + if (resolveClaudeDesktopMode(current, observeClaudeDesktopMode(current)) === "first-party") return undefined; + if (current.claudeCode?.desktopAutoApply === false || !current.claudeCode?.desktopProfile) return undefined; + const afterKind = inspectDesktop3pConfigLibrary({ + appliedFingerprint: current.claudeCode.desktopProfile.appliedFingerprint ?? null, + }).kind; + if (["not_installed", "no_owned_state", "foreign", "unsafe", "broken"].includes(afterKind)) return undefined; + const routed = filterCatalogVisibleModels(allModels, current).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })); + const writtenProfile = current.claudeCode.desktopProfile; + const markerBaseline = captureDesktopAppliedMarker(writtenProfile); + const result = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( + current.port ?? 10100, + [...desktopVisibleNativeSlugs(current)], + routed, + current.apiKeys?.[0]?.key, + "static", + writtenProfile, + nativeContextLimits(current), + ); + if (result.written && result.fingerprint) { + const marked = commitDesktopAppliedMarker(markerBaseline, result.fingerprint); + if (marked.status === "unavailable" || marked.value === false) { + console.warn("[claude-desktop] provider-change applied marker skipped"); + } } - } + }); } catch { /* best-effort */ } } @@ -1068,9 +1077,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } - const { resolveClaudeDesktopApplyMode, applyDesktopFirstParty, captureDesktopFirstPartyRollback, removeDesktopFirstParty } = await import("../../claude/desktop-first-party"); + const { resolveClaudeDesktopApplyMode, observeClaudeDesktopMode, applyDesktopFirstParty, captureDesktopFirstPartyRollback, removeDesktopFirstParty } = await import("../../claude/desktop-first-party"); const requested = (parsed as { mode?: unknown } | null)?.mode; - let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config); + let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config, observeClaudeDesktopMode(config)); if (requested !== undefined) { if (requested === "static" || requested === "hybrid" || requested === "discovery") { mode = requested; @@ -1099,53 +1108,64 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const desired = setIntegrationEnabled("claude-desktop", true); if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500); mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true); - const { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } = await import("../../claude/desktop-3p"); - const rollback = captureDesktopFirstPartyRollback(config); - const applied = applyDesktopFirstParty(config); - if (!applied.ok) { - const { firstPartyRefusalMessage } = await import("./native-integration-routes"); - return jsonResponse({ - error: firstPartyRefusalMessage(applied.reason, applied.path), - code: "claude_desktop_first_party_refused", - reason: applied.reason, - path: applied.path, - }, applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? 409 : 500); - } - // Remove the gateway only after the replacement env was written. - const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; - const library = inspectDesktop3pConfigLibrary({ appliedFingerprint }); - let gatewayRemoved = false; - if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { - const removed = (deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint, replaceWhileEnabled: true }); - if (!removed.ok) { - const restored = removed.changed || !applied.changed || rollback(); - const modeSaved = !removed.changed || (await persistDesktopModeField(config, "first-party")).ok; - const warning = [restored ? "" : "first-party settings rollback did not complete", - modeSaved ? "" : "first-party is active but its mode marker was not saved"].filter(Boolean).join("; "); + const { pickerPreferenceOn, pickerStatusFor, runPickerTransition } = await import("./claude-desktop-picker-routes"); + // The whole switch runs under the picker lock, in today's order: env first, then gateway cleanup. + return await runPickerTransition(config, async ops => { + const { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } = await import("../../claude/desktop-3p"); + const rollback = captureDesktopFirstPartyRollback(config); + const applied = applyDesktopFirstParty(config); + if (!applied.ok) { + const { firstPartyRefusalMessage } = await import("./native-integration-routes"); return jsonResponse({ - error: removed.kind === "cleanup_incomplete" - ? "Claude Desktop now points at standard mode, but gateway credential cleanup is incomplete; the first-party connection remains active." - : "The gateway profile could not be removed safely, so first-party mode was not applied.", - code: "claude_desktop_gateway_removal_failed", - ...(warning ? { warning } : {}), - reason: removed.reason ?? removed.kind, - ...(removed.residualPaths ? { residualPaths: removed.residualPaths } : {}), - }, removed.kind === "cleanup_incomplete" ? 500 : 409); + error: firstPartyRefusalMessage(applied.reason, applied.path), + code: "claude_desktop_first_party_refused", + reason: applied.reason, + path: applied.path, + }, applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? 409 : 500); } - gatewayRemoved = removed.changed; - } - const modeSaved = await persistDesktopModeField(config, "first-party"); - return jsonResponse({ - ok: true, - mode: "first-party", - saved: modeSaved.ok, - applied: true, - changed: applied.changed || gatewayRemoved, - gatewayRemoved, - path: applied.path, - proxyPort: applied.proxyPort, - caCertPath: applied.env.NODE_EXTRA_CA_CERTS, - ...(modeSaved.ok ? {} : { warning: `First-party env was applied, but the mode marker was not saved (${modeSaved.reason}).` }), + // Remove the gateway only after the replacement env was written. + const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; + const library = inspectDesktop3pConfigLibrary({ appliedFingerprint }); + let gatewayRemoved = false; + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + const removed = (deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint, replaceWhileEnabled: true }); + if (!removed.ok) { + const restored = removed.changed || !applied.changed || rollback(); + const modeSaved = !removed.changed || (await persistDesktopModeField(config, "first-party")).ok; + const warning = [restored ? "" : "first-party settings rollback did not complete", + modeSaved ? "" : "first-party is active but its mode marker was not saved"].filter(Boolean).join("; "); + return jsonResponse({ + error: removed.kind === "cleanup_incomplete" + ? "Claude Desktop now points at standard mode, but gateway credential cleanup is incomplete; the first-party connection remains active." + : "The gateway profile could not be removed safely, so first-party mode was not applied.", + code: "claude_desktop_gateway_removal_failed", + ...(warning ? { warning } : {}), + reason: removed.reason ?? removed.kind, + ...(removed.residualPaths ? { residualPaths: removed.residualPaths } : {}), + }, removed.kind === "cleanup_incomplete" ? 500 : 409); + } + gatewayRemoved = removed.changed; + } + const modeSaved = await persistDesktopModeField(config, "first-party"); + const { firstPartyAccountRisk } = await import("../../claude/desktop-risk"); + // Picker mode is on by default in first-party; only a committed mode turns it on. + const picker = modeSaved.ok && pickerPreferenceOn(loadConfig()) + ? await ops.enableLocked({ persist: false, context: "server" }) + : await pickerStatusFor(loadConfig()); + return jsonResponse({ + ok: true, + mode: "first-party", + saved: modeSaved.ok, + applied: true, + changed: applied.changed || gatewayRemoved, + gatewayRemoved, + path: applied.path, + proxyPort: applied.proxyPort, + caCertPath: applied.env.NODE_EXTRA_CA_CERTS, + riskWarning: firstPartyAccountRisk(), + picker, + ...(modeSaved.ok ? {} : { warning: `First-party env was applied, but the mode marker was not saved (${modeSaved.reason}).` }), + }); }); } const { setIntegrationEnabled, claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); @@ -1176,47 +1196,58 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const slash = model.route.indexOf("/"); return { provider: model.route.slice(0, slash), id: model.route.slice(slash + 1), contextWindow: model.contextWindow }; }); - // State construction can await catalog work; never write from the stale - // config captured before that await if another request turned Desktop off. - const latest = loadConfig(); - if (!claudeDesktopIntegrationEnabled(latest)) { - return jsonResponse({ - error: "Claude Desktop apply was cancelled because the desired state changed to off.", - code: "claude_desktop_apply_skipped", - reason: "desired_state_changed", - desiredEnabled: false, - saved: true, - applied: false, - }, 409); - } - const result = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( - Number(url.port) || latest.port, - [...desktopVisibleNativeSlugs(latest)], - routed, - latest.apiKeys?.[0]?.key, - mode, - state.profile, - nativeContextLimits(latest), - ); - if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500); - const committed = persistCommittedDesktopGateway(config, state.profile, result.fingerprint); - const modeWarning = committed.ok ? undefined : `Gateway applied, but its mode/profile state was not saved (${committed.reason}).`; - // The durable marker describes the committed gateway even if old-mode cleanup fails. - const firstPartyRemoved = removeDesktopFirstParty(); - if (!firstPartyRemoved.ok) { - return jsonResponse({ - error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); first-party cleanup remains incomplete after gateway apply.`, - code: "claude_desktop_first_party_refused", reason: firstPartyRemoved.reason, - applied: true, saved: committed.ok, mode: "gateway", path: result.path, - ...(modeWarning ? { warning: modeWarning } : {}), - }, 500); - } + const { runPickerTransition } = await import("./claude-desktop-picker-routes"); + const outcome = await runPickerTransition(config, async ops => { + // State construction can await catalog work; never write from the stale + // config captured before that await if another request turned Desktop off. + const latest = loadConfig(); + if (!claudeDesktopIntegrationEnabled(latest)) { + return { response: jsonResponse({ + error: "Claude Desktop apply was cancelled because the desired state changed to off.", + code: "claude_desktop_apply_skipped", + reason: "desired_state_changed", + desiredEnabled: false, + saved: true, + applied: false, + }, 409) }; + } + // Picker mode belongs to first-party: stop terminating claude.ai before the gateway lands. + const pickerOff = await ops.disableLocked({ persist: false }); + const result = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( + Number(url.port) || latest.port, + [...desktopVisibleNativeSlugs(latest)], + routed, + latest.apiKeys?.[0]?.key, + mode, + state.profile, + nativeContextLimits(latest), + ); + if (!result.written) return { response: jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500) }; + const committed = persistCommittedDesktopGateway(config, state.profile, result.fingerprint); + const modeWarning = committed.ok ? undefined : `Gateway applied, but its mode/profile state was not saved (${committed.reason}).`; + // The durable marker describes the committed gateway even if old-mode cleanup fails. + const firstPartyRemoved = removeDesktopFirstParty(); + if (!firstPartyRemoved.ok) { + return { response: jsonResponse({ + error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); first-party cleanup remains incomplete after gateway apply.`, + code: "claude_desktop_first_party_refused", reason: firstPartyRemoved.reason, + applied: true, saved: committed.ok, mode: "gateway", path: result.path, + ...(modeWarning ? { warning: modeWarning } : {}), + }, 500) }; + } + return { result, committed, modeWarning, pickerOff }; + }); + if (outcome.response) return outcome.response; + const { result, committed, modeWarning, pickerOff } = outcome as Exclude; const { claudeDesktopPolicyWarning, getCachedClaudeDesktopPolicy } = await import("../../claude/desktop-policy"); const policyState = deps.probeClaudeDesktopPolicy ? await deps.probeClaudeDesktopPolicy({ platform: deps.platform ?? process.platform }) : await getCachedClaudeDesktopPolicy({ platform: deps.platform ?? process.platform }); const policyWarning = claudeDesktopPolicyWarning(policyState); - const warning = [modeWarning, policyWarning].filter(Boolean).join(" "); + const pickerWarning = pickerOff.residual?.length + ? `Picker mode cleanup is incomplete (${pickerOff.residual.join(", ")}); run ocx claude desktop picker off.` + : undefined; + const warning = [modeWarning, policyWarning, pickerWarning].filter(Boolean).join(" "); return jsonResponse({ ok: true, mode: "gateway", @@ -1238,7 +1269,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise try { const { claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); const { inspectDesktop3pConfigLibrary } = await import("../../claude/desktop-3p"); - const { resolveClaudeDesktopApplyMode, inspectDesktopFirstParty } = await import("../../claude/desktop-first-party"); + const { resolveClaudeDesktopApplyMode, inspectDesktopFirstParty, observeClaudeDesktopMode } = await import("../../claude/desktop-first-party"); const { getClaudeInterceptState } = await import("../../claude/intercept/runtime"); const { DESKTOP_PICKER_ID_SUGGESTIONS, readInterceptBindings } = await import("../../claude/intercept/model-bindings"); const persisted = loadConfig(); @@ -1247,7 +1278,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const desiredEnabled = claudeDesktopIntegrationEnabled(persisted); const gatewayApplied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted"; // A gateway profile on disk is what Desktop actually runs, whatever the saved mode says. - const mode = gatewayApplied ? "gateway" : resolveClaudeDesktopApplyMode(persisted); + const mode = gatewayApplied ? "gateway" : resolveClaudeDesktopApplyMode(persisted, observeClaudeDesktopMode(persisted)); const firstPartySeen = inspectDesktopFirstParty(persisted); const intercept = getClaudeInterceptState(); const firstParty = { @@ -1261,6 +1292,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // What the running proxy routes with right now (live config), not the file on disk. modelBindings: readInterceptBindings(config.claudeCode), pickerSuggestions: [...DESKTOP_PICKER_ID_SUGGESTIONS], + // Picker mode: the controller's view while it runs, else the static offline status. + picker: await (await import("./claude-desktop-picker-routes")).pickerStatusFor(persisted), }; const applied = mode === "first-party" ? firstPartySeen.applied : gatewayApplied; // "Needs update" is only meaningful while the integration is wanted. When the @@ -1294,6 +1327,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return jsonResponse({ desiredEnabled, mode, + // Shown wherever first-party is selected or its env is still applied (account-risk notice). + riskWarning: mode === "first-party" || firstPartySeen.applied || firstPartySeen.stale + ? (await import("../../claude/desktop-risk")).firstPartyAccountRisk() + : null, firstParty, installed: observed.kind !== "not_installed", observedKind: observed.kind, diff --git a/src/server/management/claude-desktop-picker-routes.ts b/src/server/management/claude-desktop-picker-routes.ts new file mode 100644 index 00000000000..bca4e6e0e8f --- /dev/null +++ b/src/server/management/claude-desktop-picker-routes.ts @@ -0,0 +1,128 @@ +/** + * Claude Desktop picker mode over the management API, and the transition helpers every Desktop + * mode change uses. + * + * While this server runs a picker controller (src/claude/desktop-picker.ts), every picker + * mutation goes through it, serialized by its lock. With no controller (intercept disabled, client + * role, a failed bind), `runPickerTransition` runs the same code with offline ops that only remove + * leftover picker artifacts, and enabling reports `proxy_unavailable`. + */ +import type { OcxConfig } from "../../types"; +import type { DesktopPickerController, DesktopPickerOps, DesktopPickerStatus } from "../../claude/desktop-picker"; +import { loadConfig } from "../../config"; +import { jsonResponse } from "../auth-cors"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import type { ManagementContext } from "./context"; +import { isPlainRecord } from "./shared"; + +export async function currentPickerController(): Promise { + const { getClaudePickerController } = await import("../../claude/intercept/runtime"); + return getClaudePickerController(); +} + +/** Picker status for status payloads: the controller's, or the static offline status. */ +export async function pickerStatusFor(config: OcxConfig): Promise { + const controller = await currentPickerController(); + if (controller) return controller.status(); + const { offlinePickerStatus } = await import("../../claude/desktop-picker"); + return offlinePickerStatus(config); +} + +/** Run a Desktop mode change under the picker lock (or with offline ops when no controller runs). */ +export async function runPickerTransition(config: OcxConfig, fn: (ops: DesktopPickerOps) => Promise): Promise { + const [controller, { runDesktopTransition }] = await Promise.all([ + currentPickerController(), + import("../../claude/desktop-picker"), + ]); + return runDesktopTransition(controller, fn, { config }); +} + +/** First-party turns the picker on unless the operator said `claudeCode.intercept.picker: false`. */ +export function pickerPreferenceOn(config: Pick): boolean { + return config.claudeCode?.intercept?.picker !== false; +} + +const PICKER_BODY_KEYS = new Set(["enabled", "persist", "trustedLocally", "callerAddedTrust"]); +/** Enable outcomes that are progress, not refusal: on, waiting for a Desktop restart, or for the keychain step. */ +const ENABLE_ACCEPTED = new Set(["active", "restart_required", "trust_pending"]); + +interface PickerRequestBody { enabled: boolean; persist: boolean; trustedLocally?: boolean; callerAddedTrust?: boolean } + +function parsePickerBody(raw: unknown): PickerRequestBody | string { + if (!isPlainRecord(raw)) return "JSON body must be an object"; + for (const key of Object.keys(raw)) if (!PICKER_BODY_KEYS.has(key)) return `unknown field: ${key}`; + if (typeof raw.enabled !== "boolean") return "enabled must be a boolean"; + if (typeof raw.persist !== "boolean") return "persist must be a boolean"; + for (const key of ["trustedLocally", "callerAddedTrust"] as const) { + if (raw[key] !== undefined && typeof raw[key] !== "boolean") return `${key} must be a boolean`; + } + return raw as unknown as PickerRequestBody; +} + +export async function handleClaudeDesktopPickerRoutes(ctx: ManagementContext): Promise { + const { req, url } = ctx; + + if (url.pathname === "/api/claude-desktop/picker" && req.method === "GET") { + return jsonResponse({ ok: true, picker: await pickerStatusFor(loadConfig()) }); + } + + if (url.pathname === "/api/claude-desktop/picker" && req.method === "PUT") { + let raw: unknown; + try { + raw = await readManagementJsonBody(req); + } catch (error) { + rethrowManagementBodyTooLarge(error); + return jsonResponse({ error: "invalid JSON body" }, 400); + } + const body = parsePickerBody(raw); + if (typeof body === "string") return jsonResponse({ error: body }, 400); + const controller = await currentPickerController(); + if (body.enabled) { + if (!controller) { + const { offlinePickerStatus } = await import("../../claude/desktop-picker"); + return jsonResponse({ + ok: false, + code: "picker_proxy_unavailable", + error: "Picker mode needs the Claude Desktop picker proxy, which is not running in this server.", + picker: offlinePickerStatus(loadConfig()), + }, 503); + } + const picker = await controller.enable({ + persist: body.persist, + context: body.trustedLocally ? "cli-trusted" : "server", + ...(body.callerAddedTrust !== undefined ? { callerAddedTrust: body.callerAddedTrust } : {}), + }); + if (ENABLE_ACCEPTED.has(picker.reason) && !picker.residual?.length) return jsonResponse({ ok: true, picker }); + return jsonResponse({ + ok: false, + code: "picker_enable_refused", + reason: picker.reason, + error: `Picker mode was not enabled (${picker.reason}).`, + picker, + }, 409); + } + if (controller) { + const picker = await controller.disable({ persist: body.persist }); + if (!picker.residual?.length) return jsonResponse({ ok: true, picker }); + return jsonResponse({ + ok: false, + code: "picker_disable_incomplete", + error: `Picker mode cleanup is incomplete (${picker.residual.join(", ")}).`, + picker, + }, 500); + } + // No controller: nothing can terminate claude.ai here, so cleanup is local. + if (body.persist) { + const { createPickerPreferenceWriter } = await import("../../claude/intercept/runtime"); + if (!(await createPickerPreferenceWriter(ctx.config))(false)) { + return jsonResponse({ ok: false, error: "The picker preference could not be saved; nothing was removed." }, 500); + } + } + const { offlinePickerStatus, removeDesktopPickerArtifacts } = await import("../../claude/desktop-picker"); + const removed = await removeDesktopPickerArtifacts({}); + const picker = { ...offlinePickerStatus(loadConfig()), ...(removed.residual?.length ? { residual: removed.residual } : {}) }; + return jsonResponse({ ok: removed.ok, picker }, removed.ok ? 200 : 500); + } + + return null; +} diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 6bf49819aab..b76e9af4609 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -208,40 +208,48 @@ export async function syncEnabledClientIntegrations( } } - if (claudeDesktopIntegrationEnabled(config)) { + const { observeClaudeDesktopMode, resolveClaudeDesktopMode } = await import("../../claude/desktop-first-party"); + // A first-party Desktop must never get a gateway profile written and selected by a sync. + if (claudeDesktopIntegrationEnabled(config) && resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party") { try { const { writeDesktop3pConfig } = await import("../../claude/desktop-3p"); const { desktopVisibleNativeSlugs, filterCatalogVisibleModels } = await import("../../codex/catalog"); const { fetchAllModels } = await import("../management-api"); const models = await (deps.fetchAllModels ?? fetchAllModels)(config); - // Discovery admits a concurrent OFF or settings edit. Re-read outside C: - // the writer facade owns L and its final desired-state check under L→C. - const latest = loadConfig(); - if (claudeDesktopIntegrationEnabled(latest)) { - const routed = filterCatalogVisibleModels(models, latest) - .map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow })); - const writtenProfile = latest.claudeCode?.desktopProfile; - const markerBaseline = captureDesktopAppliedMarker(writtenProfile); - const r = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( - port, - [...desktopVisibleNativeSlugs(latest)], - routed, - latest.apiKeys?.[0]?.key, - "static", - writtenProfile, - nativeContextLimits(latest), - ); - if (!r.written || !r.fingerprint) { - out.push({ client: "claude-desktop", ok: false, reason: r.reason ?? "Claude Desktop write failed" }); - } else { - const marked = commitDesktopAppliedMarker(markerBaseline, r.fingerprint); - out.push(marked.status === "unavailable" - ? { client: "claude-desktop", ok: false, reason: "Claude Desktop applied marker was not saved (" + marked.reason + ")" } - : marked.value === false - ? { client: "claude-desktop", ok: false, reason: "Claude Desktop desired profile changed during sync; applied marker skipped" } - : { client: "claude-desktop", ok: true, changed: true }); + // Serialized with Desktop mode transitions (picker lock): a first-party switch cannot interleave. + const { runPickerTransition } = await import("./claude-desktop-picker-routes"); + await runPickerTransition(config, async () => { + // Discovery admits a concurrent OFF or settings edit. Re-read outside C: + // the writer facade owns L and its final desired-state check under L→C. + const latest = loadConfig(); + // Discovery awaited: the mode may have changed meanwhile. Re-resolve on the fresh read, + // immediately before the writer, so a first-party switch during fetchAllModels still wins. + if (claudeDesktopIntegrationEnabled(latest) && resolveClaudeDesktopMode(latest, observeClaudeDesktopMode(latest)) !== "first-party") { + const routed = filterCatalogVisibleModels(models, latest) + .map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow })); + const writtenProfile = latest.claudeCode?.desktopProfile; + const markerBaseline = captureDesktopAppliedMarker(writtenProfile); + const r = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( + port, + [...desktopVisibleNativeSlugs(latest)], + routed, + latest.apiKeys?.[0]?.key, + "static", + writtenProfile, + nativeContextLimits(latest), + ); + if (!r.written || !r.fingerprint) { + out.push({ client: "claude-desktop", ok: false, reason: r.reason ?? "Claude Desktop write failed" }); + } else { + const marked = commitDesktopAppliedMarker(markerBaseline, r.fingerprint); + out.push(marked.status === "unavailable" + ? { client: "claude-desktop", ok: false, reason: "Claude Desktop applied marker was not saved (" + marked.reason + ")" } + : marked.value === false + ? { client: "claude-desktop", ok: false, reason: "Claude Desktop desired profile changed during sync; applied marker skipped" } + : { client: "claude-desktop", ok: true, changed: true }); + } } - } + }); } catch (error) { out.push({ client: "claude-desktop", ok: false, reason: error instanceof Error ? error.message : String(error) }); } diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index aa049e9d566..d47a6dcc2b3 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -31,11 +31,15 @@ import { applyDesktopFirstParty, captureDesktopFirstPartyRollback, inspectDesktopFirstParty, + observeClaudeDesktopMode, recordClaudeDesktopMode, removeDesktopFirstParty, resolveClaudeDesktopApplyMode, type ClaudeDesktopMode, } from "../../claude/desktop-first-party"; +import { FIRST_PARTY_ACCOUNT_RISK } from "../../claude/desktop-risk"; +import type { DesktopPickerStatus } from "../../claude/desktop-picker"; +import { pickerPreferenceOn, runPickerTransition } from "./claude-desktop-picker-routes"; import { projectGrokCatalog } from "../../grok/catalog"; import { injectGrokConfig, stripGrokConfig } from "../../grok/inject"; import { inspectGrokConfig } from "../../grok/inspect"; @@ -150,7 +154,7 @@ function desktopStatus(config: ManagementContext["config"]): NativeStatus { : null; // First-party mode lives in Claude Code's settings.json, not in Desktop's library. A leftover // gateway profile still counts as current (it is what Desktop is actually running). - const firstParty = resolveClaudeDesktopApplyMode(config) === "first-party" && gatewayState === "absent" + const firstParty = resolveClaudeDesktopApplyMode(config, observeClaudeDesktopMode(config)) === "first-party" && gatewayState === "absent" ? inspectDesktopFirstParty(config) : null; const state: NativeStatus["state"] = firstParty @@ -652,6 +656,24 @@ export function firstPartyRefusalMessage( } } +/** One line for a toggle message about picker mode after a first-party enable. */ +function pickerStateNote(picker: DesktopPickerStatus): string { + if (picker.reason === "active" || picker.reason === "restart_required") { + return "Picker mode is on: fully quit and reopen Claude Desktop to see OpenCodex models in the Code tab."; + } + if (picker.reason === "trust_pending" || picker.reason === "trust_declined") { + return `Picker mode is waiting for the keychain step: run ${picker.hint ?? "ocx claude desktop picker trust"}.`; + } + return `Picker mode is off (${picker.reason}).`; +} + +/** Empty unless turning picker mode off left something behind. */ +function pickerCleanupNote(picker: DesktopPickerStatus): string { + return picker.residual?.length + ? `Picker mode cleanup is incomplete (${picker.residual.join(", ")}); run ocx claude desktop picker off.` + : ""; +} + /** Record which Desktop mode is applied; `false` when the config file could not be updated. */ function persistDesktopModeMarker(desktopMode: ClaudeDesktopMode): boolean { const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode)); @@ -685,104 +707,125 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { + const pickerOff = await ops.disableLocked({ persist: false }); + const firstPartyRemoved = removeDesktopFirstParty(); + if (!firstPartyRemoved.ok) { + return postCommitRefusal(409, "claude-desktop", "write_failed", + `Claude Code settings could not be read (${firstPartyRemoved.path}); the first-party proxy env was left in place.`, { desiredEnabled }); } - const partialModeWarning = !removed.ok && removed.changed && !persistDesktopModeMarker("first-party") - ? " First-party is active but its mode marker was not saved." : ""; + const removed = (ctx.deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint: fingerprint }); if (removed.kind === "cleanup_incomplete") { return postCommitRefusal(500, "claude-desktop", "cleanup_incomplete", - "Claude Desktop now points at standard mode, but gateway credential cleanup is incomplete; the first-party connection remains active." + partialModeWarning, + "Claude Desktop now points at standard mode, but credential cleanup is incomplete.", { desiredEnabled, residualPaths: removed.residualPaths ?? [] }); } if (!removed.ok) { return postCommitRefusal(409, "claude-desktop", removed.reason === "metadata_unreadable" ? "metadata_unreadable" : "write_failed", - "The gateway profile could not be removed safely; the mode switch is incomplete." + partialModeWarning, { desiredEnabled }); + "Claude Desktop configuration could not be changed safely.", { desiredEnabled }); } - gatewayRemoved = removed.changed; - } - const modeSaved = persistDesktopModeMarker("first-party"); - const changed = applied.changed || gatewayRemoved; - return jsonResponse({ - ok: true, clientId: "claude-desktop", changed, state: "current", desiredEnabled, - message: [ - changed - ? "Claude Desktop integration enabled (first-party). Fully quit and reopen Claude Desktop." - : "Claude Desktop integration is already on.", - modeSaved ? "" : "The first-party mode marker could not be saved to config; status may report the mode as unsaved.", - ].filter(Boolean).join(" "), - } satisfies NativeToggleEnvelope); + const changed = removed.changed || firstPartyRemoved.changed; + return jsonResponse({ + ok: true, clientId: "claude-desktop", changed, state: "absent", desiredEnabled, + message: [ + changed ? "Claude Desktop integration disabled." : "Claude Desktop integration is already off.", + pickerCleanupNote(pickerOff), + ].filter(Boolean).join(" "), + } satisfies NativeToggleEnvelope); + }); + } + + if (resolveClaudeDesktopApplyMode(current, observeClaudeDesktopMode(current)) === "first-party") { + // The whole switch runs under the picker lock, in today's order: env first, then gateway cleanup. + return await runPickerTransition(current, async ops => { + const rollback = captureDesktopFirstPartyRollback(current); + const applied = applyDesktopFirstParty(current); + if (!applied.ok) { + const reason = applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? applied.reason : "write_failed"; + return postCommitRefusal(applied.reason === "unreadable" || applied.reason === "ca_unavailable" ? 500 : 409, "claude-desktop", reason, + firstPartyRefusalMessage(applied.reason, applied.path), { desiredEnabled }); + } + const library = inspectDesktop3pConfigLibrary({ appliedFingerprint: fingerprint }); + let gatewayRemoved = false; + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + const removed = (ctx.deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint: fingerprint, replaceWhileEnabled: true }); + if (!removed.ok && !removed.changed && applied.changed && !rollback()) { + return postCommitRefusal(500, "claude-desktop", "write_failed", + "Gateway cleanup and first-party settings rollback did not complete.", { desiredEnabled }); + } + const partialModeWarning = !removed.ok && removed.changed && !persistDesktopModeMarker("first-party") + ? " First-party is active but its mode marker was not saved." : ""; + if (removed.kind === "cleanup_incomplete") { + return postCommitRefusal(500, "claude-desktop", "cleanup_incomplete", + "Claude Desktop now points at standard mode, but gateway credential cleanup is incomplete; the first-party connection remains active." + partialModeWarning, + { desiredEnabled, residualPaths: removed.residualPaths ?? [] }); + } + if (!removed.ok) { + return postCommitRefusal(409, "claude-desktop", removed.reason === "metadata_unreadable" ? "metadata_unreadable" : "write_failed", + "The gateway profile could not be removed safely; the mode switch is incomplete." + partialModeWarning, { desiredEnabled }); + } + gatewayRemoved = removed.changed; + } + const modeSaved = persistDesktopModeMarker("first-party"); + const changed = applied.changed || gatewayRemoved; + // Picker mode is on by default in first-party; only a committed mode turns it on. + const picker = modeSaved && pickerPreferenceOn(loadConfig()) + ? await ops.enableLocked({ persist: false, context: "server" }) + : null; + return jsonResponse({ + ok: true, clientId: "claude-desktop", changed, state: "current", desiredEnabled, + message: [ + changed + ? "Claude Desktop integration enabled (first-party). Fully quit and reopen Claude Desktop." + : "Claude Desktop integration is already on.", + FIRST_PARTY_ACCOUNT_RISK.message, + picker ? pickerStateNote(picker) : "", + modeSaved ? "" : "The first-party mode marker could not be saved to config; status may report the mode as unsaved.", + ].filter(Boolean).join(" "), + } satisfies NativeToggleEnvelope); + }); } const fetchModels = ctx.deps.fetchAllModels ?? defaultFetchAllModels; try { const fetched = await fetchModels(current); - const latest = loadConfig(); - const latestDesiredEnabled = latest.clientIntegrations?.["claude-desktop"] !== false; - if (!latestDesiredEnabled) { - return postCommitRefusal(409, "claude-desktop", "desired_state_changed", - "Claude Desktop enable was cancelled because the desired state changed to off.", { desiredEnabled: latestDesiredEnabled }); - } - const routed = filterCatalogVisibleModels(fetched, latest).map(model => ({ - provider: model.provider, id: model.id, contextWindow: model.contextWindow, - })); - const runtime = (ctx.deps.readRuntimePort ?? readRuntimePort)(process.pid); - const result = (ctx.deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( - runtime?.port ?? latest.port, - [...desktopVisibleNativeSlugs(latest)], - routed, - latest.apiKeys?.[0]?.key, - "static", - latest.claudeCode?.desktopProfile, - nativeContextLimits(latest), - ); - if (!result.written) return postCommitRefusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled: latestDesiredEnabled }); - const committed = persistCommittedDesktopGateway(ctx.config, latest.claudeCode?.desktopProfile, result.fingerprint); - const stateWarning = committed.ok ? "" : " The committed gateway mode/profile state was not saved."; - const removed = removeDesktopFirstParty(); - if (!removed.ok) return postCommitRefusal(500, "claude-desktop", "write_failed", "Gateway applied, but first-party settings cleanup did not complete." + stateWarning, { desiredEnabled: latestDesiredEnabled }); - return jsonResponse({ - ok: true, clientId: "claude-desktop", changed: true, state: "current", desiredEnabled: latestDesiredEnabled, - message: [ - "Claude Desktop integration enabled.", - stateWarning, - ].filter(Boolean).join(" "), - } satisfies NativeToggleEnvelope); + return await runPickerTransition(current, async ops => { + const latest = loadConfig(); + const latestDesiredEnabled = latest.clientIntegrations?.["claude-desktop"] !== false; + if (!latestDesiredEnabled) { + return postCommitRefusal(409, "claude-desktop", "desired_state_changed", + "Claude Desktop enable was cancelled because the desired state changed to off.", { desiredEnabled: latestDesiredEnabled }); + } + // Picker mode belongs to first-party: stop terminating claude.ai before the gateway lands. + const pickerOff = await ops.disableLocked({ persist: false }); + const routed = filterCatalogVisibleModels(fetched, latest).map(model => ({ + provider: model.provider, id: model.id, contextWindow: model.contextWindow, + })); + const runtime = (ctx.deps.readRuntimePort ?? readRuntimePort)(process.pid); + const result = (ctx.deps.writeDesktop3pConfig ?? writeDesktop3pConfig)( + runtime?.port ?? latest.port, + [...desktopVisibleNativeSlugs(latest)], + routed, + latest.apiKeys?.[0]?.key, + "static", + latest.claudeCode?.desktopProfile, + nativeContextLimits(latest), + ); + if (!result.written) return postCommitRefusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled: latestDesiredEnabled }); + const committed = persistCommittedDesktopGateway(ctx.config, latest.claudeCode?.desktopProfile, result.fingerprint); + const stateWarning = committed.ok ? "" : " The committed gateway mode/profile state was not saved."; + const removed = removeDesktopFirstParty(); + if (!removed.ok) return postCommitRefusal(500, "claude-desktop", "write_failed", "Gateway applied, but first-party settings cleanup did not complete." + stateWarning, { desiredEnabled: latestDesiredEnabled }); + return jsonResponse({ + ok: true, clientId: "claude-desktop", changed: true, state: "current", desiredEnabled: latestDesiredEnabled, + message: [ + "Claude Desktop integration enabled.", + stateWarning, + pickerCleanupNote(pickerOff), + ].filter(Boolean).join(" "), + } satisfies NativeToggleEnvelope); + }); } catch { return postCommitRefusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled }); } diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index bf3c8c7da9f..59484c787ca 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -153,6 +153,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "PUT", path: "/api/claude-code", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/claude-desktop", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/claude-desktop/first-party-bindings", module: "server/management/agent-settings-routes", mutates: true }, + // server/management/claude-desktop-picker-routes + { method: "GET", path: "/api/claude-desktop/picker", module: "server/management/claude-desktop-picker-routes", mutates: false }, + { method: "PUT", path: "/api/claude-desktop/picker", module: "server/management/claude-desktop-picker-routes", mutates: true }, { method: "PUT", path: "/api/codex-auth/features/default-mode-request-user-input", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/effort-caps", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/grok/selection", module: "server/management/agent-settings-routes", mutates: true }, diff --git a/src/types/config.ts b/src/types/config.ts index 6eb6e26f12a..693b3e20787 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -148,7 +148,13 @@ export interface OcxClaudeCodeConfig { * the Desktop route vocabulary (`provider/model`, or `native/`). Bindings apply only to * requests that arrive through the intercept pair, overlaid on the global `modelMap`. */ - intercept?: { enabled?: boolean; port?: number; modelMap?: Record }; + intercept?: { + enabled?: boolean; + port?: number; + /** First-party Desktop Code-tab picker injection; unset enables it when eligible. */ + picker?: boolean; + modelMap?: Record; + }; /** * Bundled-skill content elision for ROUTED (non-Anthropic) models (devlog 260712 * 060): Skill-tool results whose skill name matches an entry here are replaced @@ -180,10 +186,12 @@ export interface OcxClaudeCodeConfig { desktopProfile?: OcxClaudeDesktopProfile; /** * How Claude Desktop reaches opencodex (src/claude/desktop-first-party.ts). - * `first-party` (default) leaves the app on its normal claude.ai login and redirects only the - * Code tab's Claude Code process through the intercept pair via settings.json env. - * `gateway` installs the third-party deployment profile (desktop-3p) for the whole app. - * Unset on an install that already applied a gateway profile resolves to `gateway`. + * `gateway` (default) installs the third-party deployment profile (desktop-3p) for the whole app. + * `first-party` leaves the app on its normal claude.ai login and redirects only the Code tab's + * Claude Code process through the intercept pair via settings.json env; it sends Claude + * subscription traffic through a local interception proxy and carries an account-risk warning + * (src/claude/desktop-risk.ts). Unset: a gateway row or apply marker resolves to `gateway`, and + * first-party env that opencodex wrote resolves to `first-party` (observeClaudeDesktopMode). */ desktopMode?: "first-party" | "gateway"; /** Auto-reconcile Desktop 3P config when provider catalog changes. Default: enabled. */ diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 3e4a8c2d3f1..ab7443c76dc 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -29,29 +29,35 @@ Native OpenAI pool routing also accepts [Orca-linked accounts](../codex-home.md#orca-source-owned-account-import), whose source resolution belongs to the shared account store. The import CLI adds pool rows independently of Desktop profiles. -## Desktop modes: first-party and gateway +## Desktop modes: gateway and first-party `src/claude/desktop-first-party.ts` owns the Desktop mode contract. Two modes exist and are mutually exclusive on one machine: -- **first-party** (default): Claude Desktop itself is left on claude.ai — login, Chat tab, +- **first-party** (opt-in, with account risk): Claude Desktop itself is left on claude.ai — login, Chat tab, connectors and remote control are untouched and no config-library profile is written. The apply writes only `HTTPS_PROXY=http://127.0.0.1:` and `NODE_EXTRA_CA_CERTS=/claude-intercept/ca.pem` into the `env` block of Claude Code's `settings.json` (via `src/claude/intercept/settings.ts`), creating the local authority first. Only the Claude Code process Desktop spawns for the Code tab (and its subagents, and any standalone `claude` CLI) reads that env, so only their `api.anthropic.com` traffic reaches the [Claude intercept pair](../runtime.md#claude-intercept-pair). -- **gateway**: the existing third-party profile written by `src/claude/desktop-3p.ts`; the whole - app switches to the local gateway. It is selected explicitly (`--gateway`, dashboard, or the - legacy `--static|--hybrid|--discovery-only` shape flags, which imply it). - -`resolveClaudeDesktopMode` returns the explicit `claudeCode.desktopMode` when set; otherwise a -persisted `desktopProfile.appliedFingerprint` (an existing gateway install) keeps `gateway`, and a -fresh install resolves to `first-party`. Updates therefore never flip a working gateway install -silently, while new installs land on first-party. `resolveClaudeDesktopApplyMode` narrows an -*implied* first-party to gateway where the intercept pair cannot run (client role or -`claudeCode.intercept.enabled: false`); an explicit `first-party` is refused with -`intercept_disabled` instead of being rewritten. +- **gateway** (default for new installs): the existing third-party profile written by + `src/claude/desktop-3p.ts`; the whole app switches to the local gateway. The dashboard, + `--gateway`, and legacy `--static|--hybrid|--discovery-only` shape flags also select it. + +`resolveClaudeDesktopMode` uses observations from `observeClaudeDesktopMode` in this order: +explicit `claudeCode.desktopMode` → selected owned gateway row → persisted +`desktopProfile.appliedFingerprint` → owned first-party env in `~/.claude/settings.json` → +gateway. The owned env observation preserves first-party installs applied before mode persistence; +foreign proxy settings do not count. `resolveClaudeDesktopApplyMode` preserves the resolved mode. +An apply for a first-party install with `claudeCode.intercept.enabled: false` is refused with +`intercept_disabled` rather than switched to gateway. New installs apply gateway. +`src/claude/desktop-risk.ts` owns the account-suspension warning: first-party sends subscription +traffic through a local interception proxy, which Anthropic may treat as a terms violation. +`GET /api/claude-desktop/status` exposes it as `riskWarning` when first-party is resolved or its +owned settings are still observed; otherwise the field is `null`. +`/api/sync` and roster-update auto-apply never write a gateway profile while the resolved mode is +first-party; both re-resolve after model discovery before writing. Mode switches establish the replacement before removing the previous connection. A failed first-party apply (disabled intercept, CA failure, unreadable settings or foreign env) preserves @@ -70,7 +76,7 @@ re-applies a stale env (the proxy port follows the public port). Surfaces: `ocx claude desktop apply [--first-party|--gateway]` in `src/cli/claude-desktop.ts`; `POST /api/claude-desktop/apply` with `mode` ∈ `first-party|gateway|static|hybrid|discovery` and -`GET /api/claude-desktop/status` (`mode`, `firstParty.{applied,stale,interceptEnabled,interceptRunning,proxyPort,caCertPath}`) +`GET /api/claude-desktop/status` (`mode`, `riskWarning`, `firstParty.{applied,stale,interceptEnabled,interceptRunning,proxyPort,caCertPath}`) in `src/server/management/agent-settings-routes.ts`; the native toggle in `src/server/management/native-integration-routes.ts` applies the resolved mode on enable. Managed Windows policy health only applies in gateway mode, because first-party never touches Desktop's own @@ -109,6 +115,62 @@ settled state is cached for 30 seconds. Each registry query is bounded to two se timeouts and unreadable results report unknown policy state without blocking the server event loop. Injected probes may return a state or a promise, so isolated callers can exercise the same asynchronous boundary. +### Picker mode: the Desktop egress proxy + +When the lifecycle passes `loadPickerRoutes` (the server always does), `startClaudeIntercept` also +wires Claude Desktop picker mode: a second loopback CONNECT proxy on the dedicated picker proxy +port (`getClaudeInterceptState()?.pickerProxyPort`), used as Desktop's pinned egress proxy. Desktop +also hands that proxy to the Claude Code processes it spawns, and the two trust different CAs, so +the tunnel is chosen per client from the CONNECT head: a tunnel without a browser User-Agent (Claude +Code, trusting only the intercept CA) gets the `api.anthropic.com` intercept and every other target +blind, never the picker; a tunnel with Chromium's `Mozilla/` User-Agent (the app, trusting only the +login keychain) is asked of the picker runtime (`src/claude/intercept/picker-runtime.ts`), which +blind-tunnels every target except `claude.ai:443`. +The User-Agent is a routing hint, not a trust boundary: a client that fakes it reaches only what +any local process already reaches (the `api.anthropic.com` intercept is on the Claude Code proxy +too; the `claude.ai` relay verifies upstream and adds no credential) and breaks only its own TLS, +because each terminator presents a certificate only its intended client trusts. `claude.ai:443` is +terminated by a `node:https` HTTP/1.1 relay (`picker-listener.ts`) only while the runtime's cached +decision is armed: macOS, persisted resolved Desktop mode first-party, Desktop intent on, +`claudeCode.intercept.picker !== false`, no disarm latch, listener up, and the current picker CA +trusted in the login keychain (`picker-trust.ts`). The picker CA (`picker-ca.ts`, under +`/claude-picker/`, 0600 key) carries critical name constraints permitting only +`claude.ai` and is regenerated on reload when they are missing. The relay verifies the upstream +certificate, streams every body and upgrade unchanged, and rewrites only the bootstrap response's +local Code picker surfaces, `ccd` (what the Desktop Code tab reads) and its `code` fallback, never the +remote `ccr` (`picker-bootstrap.ts`), failing open to the original bytes; the model list +comes from a persisted snapshot (`picker-models.ts`), so a bootstrap never waits on discovery. A +CONNECT to claude.ai that arrives before the first refresh waits at most 3 s, then goes blind. A +picker proxy bind failure only disables picker mode; a picker construction or start failure closes +every socket the start had bound before rethrowing. Nothing is logged but method, bootstrap or +other, and status. + +`src/claude/desktop-picker.ts` owns every mutation while a server is running. One controller lock +serializes `enable`, `disable`, and `transition`; the latter wraps a whole Desktop mode change so +cleanup, mode/profile commit, and the optional picker enable cannot race. `runDesktopTransition` uses +that controller when one exists. With no controller (intercept disabled, client role, or a failed +picker-proxy bind), its offline operations remove leftover picker artifacts without creating a +terminator, and refuse enable with `proxy_unavailable`. + +The controller disarms the picker runtime before disable or cleanup. The disarm latch makes new +`claude.ai` CONNECTs blind immediately and is cleared only by a completed, checked enable. If an +enable attempt added trust and a later check or profile write fails, it removes that trust again; +an earlier successful picker profile keeps the trust it needs. The owned profile helpers in +`src/claude/desktop-picker-profile.ts` use the standard row `opencodex-picker`, whose file contains +only `egressProxyUrl`. The previous Desktop selection is stored in +`/claude-picker/profile-state.json`, never in Desktop's `_meta.json`. + +The local controls are `ocx claude desktop picker on|off|status|trust`. With a live server, `on`, +`off`, and transition cleanup use the controller; `trust` performs the operator's local keychain +step and then reports the result to the server. Without a server, `on` is refused and `off` removes +owned artifacts locally. The management surface accepts `GET /api/claude-desktop/picker` and +`PUT /api/claude-desktop/picker` with `{ enabled, persist, trustedLocally?, callerAddedTrust? }`; +unknown keys are rejected, a successful enable/disable or reported refusal returns `200 { ok: true, +picker }`, and enabling without a controller returns `503 { ok: false, code: "picker_proxy_unavailable", +picker }`. `GET /api/claude-desktop/status` and `POST /api/claude-desktop/apply` expose the same +`firstParty.picker` status; first-party apply includes `picker` in its response. Selecting the +profile requires a full Desktop quit and reopen. + ## Connected Claude Desktop profiles The connection's local Codex readiness check follows the [selected-runtime probe contract](../runtime.md#remote-hub-hardening-ownership); general status hands its resolved command to this check instead of probing the version twice. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 7fcc2ebbab0..f6c02ea1a76 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -179,7 +179,8 @@ this document owns is which module holds which area and what invariant that area | Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. | | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`. Visibility writes trigger catalog sync through the owning server path. | | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | -| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply` (`mode`: `first-party` default, `gateway`, or legacy shapes), `GET /api/claude-desktop/status` (`mode`, `firstParty`), `GET/PUT /api/claude-code`. Gateway apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)); first-party apply writes only the Claude Code proxy env, see [`clients/claude-desktop.md`](clients/claude-desktop.md#desktop-modes-first-party-and-gateway). `gui/src/pages/ClaudeDesktop.tsx` renders the mode selector and sends the chosen `mode` with apply. `PUT /api/claude-code` re-runs macOS system-env reconciliation (`src/server/system-env.ts`) whenever the body carries `systemEnv`, `authMode`, a model slot or a lever field: keys opencodex tracks as injected are refreshed or unset once the config stops producing them, and a launchd value the user set before injection is never touched. | +| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply` (`mode`: `gateway` default for new installs, `first-party` opt-in, or legacy shapes), `GET /api/claude-desktop/status` (`mode`, `riskWarning`, `firstParty`), `GET/PUT /api/claude-code`. Gateway apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)); first-party apply writes only the Claude Code proxy env, see [`clients/claude-desktop.md`](clients/claude-desktop.md#desktop-modes-gateway-and-first-party). `gui/src/pages/ClaudeDesktop.tsx` renders the mode selector and sends the chosen `mode` with apply. `PUT /api/claude-code` re-runs macOS system-env reconciliation (`src/server/system-env.ts`) whenever the body carries `systemEnv`, `authMode`, a model slot or a lever field: keys opencodex tracks as injected are refreshed or unset once the config stops producing them, and a launchd value the user set before injection is never touched. | + | File-integration plans | `src/server/management/integration-routes.ts` and `aside-profile-routes.ts` — `POST /api/client-integrations/preview`, `POST /api/client-integrations/restore/preview`, and `POST /api/client-integrations/aside/profiles/{profileId}/preview`. Management-authenticated, declared non-mutating, and they write nothing: no snapshot, no lock, no maintenance, no recovery. They answer `409 integration_preview_unavailable` rather than gathering a model roster, because discovery refreshes credentials and writes the provider cache. Responses carry only declared managed schema paths, closed change kinds and an opaque fingerprint; no value, filesystem location or selected member identity appears. Mutation routes accept `operation` and `planFingerprint` together or not at all, reject a half-bound request and an operation that disagrees with the change, and answer `409 integration_preview_stale` with a freshly computed plan. Binding is an optimistic token, never authorization. [The integration contract](clients/integrations.md) owns the ordering. | | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | | Claude reset grants | `src/server/management/anthropic-reset-grant-routes.ts` — `GET /api/anthropic/reset-grants`, `POST /api/anthropic/reset-grants/consume` (lazy-loaded). Wire and fail-closed parsing live in `src/providers/anthropic-reset-grants.ts` (the Claude Code 2.1.278 `cedar_ember` contract, sent with `CLAUDE_CLI_USER_AGENT` from `src/providers/claude-cli-identity.ts`); the journal is `src/providers/anthropic-reset-grant-ledger.ts`: a cross-process `BEGIN IMMEDIATE` lock around every synchronous read-modify-write, a 90 s lease, the operation id reused as the upstream `request_id`, same-id retry only inside the vendor's ten-minute window, no settlement inferred from a re-read, and a fail-closed `500 journal_write_failed` when an answer cannot be recorded. Spending requires the `gui-session` principal. The dashboard owner is `gui/src/hooks/useAnthropicResetGrants.ts` with `gui/src/components/provider-workspace/AnthropicResetGrants.tsx` on the Anthropic OAuth rows of `ProviderAuthPanel`; after an unknown outcome the dialog only retries the same id. Design and audit record: [`../devlog/_plan/260923_claude_reset_grants/010_plan.md`](../devlog/_plan/260923_claude_reset_grants/010_plan.md). | @@ -189,6 +190,20 @@ this document owns is which module holds which area and what invariant that area | Sidebar | `src/server/management/sidebar-routes.ts` — `GET/POST /api/github/star` and `GET /api/update/badge`. Sidebar state is cosmetic; a failed fetch 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. | +### Claude Desktop picker management + +`GET /api/claude-desktop/picker` returns `200 { ok: true, picker }`, where `picker` is the +`DesktopPickerStatus` shown under `firstParty.picker` by `GET /api/claude-desktop/status`. The +dashboard's first-party card is `gui/src/components/ClaudeDesktopPicker.tsx`; it renders the +status, model count, trust hint, restart requirement, and the offline note, and its toggle sends +`PUT /api/claude-desktop/picker` with `{ enabled: boolean, persist: boolean, trustedLocally?, callerAddedTrust? }`. +Unknown request keys are rejected with `400`. A successful mutation or a reported refusal returns +`200 { ok: true, picker }`; enabling without the controller returns `503` with +`{ ok: false, code: "picker_proxy_unavailable", picker }`. First-party +`POST /api/claude-desktop/apply` also returns `picker`, so the dashboard can render the committed +mode and picker state together. The card is mounted only for first-party mode and tells the operator +to fully quit and reopen Desktop after selecting the picker profile. + > Decision record: [ADR-0074](decisions/ADR-0074-api-ownership.md) Provider writes must not round-trip masked API keys as real secrets. Dashboard actions that change diff --git a/structure/runtime.md b/structure/runtime.md index 23ae1f37ea6..3927b555fe7 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -253,7 +253,7 @@ configured Anthropic upstream. The pair is on by default on a hub (`claudeCode.i its proxy port defaults to the public port + 100 (`claudeCode.intercept.port`), and a bind failure degrades to a startup warning rather than a startup failure; stop joins both sockets. A server asked for an ephemeral public port (`startServer(0)`, the shape every in-process test fixture uses) has no -stable port to derive from, so the pair stays off unless `claudeCode.intercept.port` is explicit. Requests on this ingress also honour first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). +stable port to derive from, so the pair stays off unless `claudeCode.intercept.port` is explicit. Requests on this ingress also honour first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). Picker mode adds a second, Desktop-only CONNECT proxy on the next port; see [Claude Desktop](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). Auxiliary listener bind failures carry the listener key and effective address through `AuxiliaryListenerBindError` in `src/server/ports.ts`. `src/cli/index.ts` reports them without retrying the public port. Startup still rolls back every earlier socket synchronously. diff --git a/tests/claude-integration/claude-desktop-cli.test.ts b/tests/claude-integration/claude-desktop-cli.test.ts index 77d74d6eca5..f658aafcada 100644 --- a/tests/claude-integration/claude-desktop-cli.test.ts +++ b/tests/claude-integration/claude-desktop-cli.test.ts @@ -11,6 +11,9 @@ import { applyRemoteDesktopStore, restoreRemoteDesktopStore, writeDesktopDisconn import * as lifecycleLock from "../../src/client/lifecycle-lock"; import { readClientConnectionState, clearClientConnection } from "../../src/client/state"; import { HubClientError } from "../../src/client/hub-client"; +import { RuntimeApiError } from "../../src/cli/runtime-api"; +import type { DesktopPickerStatus } from "../../src/claude/desktop-picker"; +import { ensurePickerCa } from "../../src/claude/intercept/picker-ca"; import { claudeDesktopIntegrationEnabledNow, setIntegrationEnabled } from "../../src/codex/desired-state"; import { resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; import { resetBundledCatalogCacheForTests, setBundledCatalogCacheForTests } from "../../src/codex/catalog/bundled"; @@ -560,8 +563,151 @@ test("usage errors on desktop verbs exit 2, not 1", async () => { expect(await handleClaudeDesktopCommand(["move"])).toBe(2); expect(await handleClaudeDesktopCommand(["nope"])).toBe(2); expect(await handleClaudeDesktopCommand(["apply", "--wat"])).toBe(2); + expect(await handleClaudeDesktopCommand(["picker"])).toBe(2); + expect(await handleClaudeDesktopCommand(["picker", "wat"])).toBe(2); + expect(await handleClaudeDesktopCommand(["picker", "on", "extra"])).toBe(2); } finally { log.mockRestore(); error.mockRestore(); } }); + +function pickerStatus(reason: DesktopPickerStatus["reason"] = "restart_required"): DesktopPickerStatus { + return { + desired: true, + supported: true, + trust: "trusted", + profile: "applied", + listenerReady: true, + effective: false, + reason, + models: 1, + snapshotAt: 1, + lastBootstrapAt: null, + }; +} + +test("picker on refuses without a live proxy", async () => { + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleClaudeDesktopCommand(["picker", "on"], { findLiveProxyImpl: async () => null })).toBe(1); + expect(error.mock.calls.flat().join(" ")).toContain("proxy_unavailable"); + } finally { error.mockRestore(); } +}); + +test("picker status uses the live management route", async () => { + const calls: string[] = []; + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleClaudeDesktopCommand(["picker", "status"], { + findLiveProxyImpl: async () => ({ pid: 42, port: 10100, hostname: "127.0.0.1", source: "runtime" }), + runtimeRequestImpl: async path => { calls.push(path); return { ok: true, picker: pickerStatus("active") }; }, + })).toBe(0); + expect(calls).toEqual(["/api/claude-desktop/picker"]); + expect(log.mock.calls.flat().join(" ")).toContain('"reason":"active"'); + } finally { log.mockRestore(); } +}); + +test("picker on answers trust_pending by trusting locally and repeating the PUT", async () => { + const bodies: Record[] = []; + const log = spyOn(console, "log").mockImplementation(() => {}); + const ca = ensurePickerCa(join(process.env.OPENCODEX_HOME!, "picker-pending-test")); + try { + expect(await handleClaudeDesktopCommand(["picker", "on"], { + findLiveProxyImpl: async () => ({ pid: 42, port: 10100, hostname: "127.0.0.1", source: "runtime" }), + ensurePickerCaImpl: () => ca, + inspectPickerTrustImpl: async () => "untrusted", + trustPickerCaImpl: async () => ({ ok: true }), + runtimeRequestImpl: async (_path, init) => { + bodies.push(JSON.parse(String(init.body)) as Record); + return bodies.length === 1 + ? { ok: true, picker: pickerStatus("trust_pending") } + : { ok: true, picker: pickerStatus("restart_required") }; + }, + })).toBe(0); + expect(bodies).toEqual([ + { enabled: true, persist: true }, + { enabled: true, persist: true, trustedLocally: true, callerAddedTrust: true }, + ]); + } finally { log.mockRestore(); } +}); + +test("picker off offline persists the preference and removes local artifacts", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + const removed: string[] = []; + try { + const result = await handleClaudeDesktopCommand(["picker", "off"], { + findLiveProxyImpl: async () => null, + removeDesktopPickerArtifacts: async () => { removed.push("cleanup"); return { ok: true }; }, + }); + expect(result).toBe(0); + expect(loadConfig().claudeCode?.intercept?.picker).toBe(false); + expect(removed).toEqual(["cleanup"]); + expect(log.mock.calls.flat().join(" ")).toContain("picker:"); + } finally { log.mockRestore(); } +}); + +test("picker trust forwards whether this run added trust", async () => { + const calls: Array<{ path: string; body: Record }> = []; + const log = spyOn(console, "log").mockImplementation(() => {}); + const ca = ensurePickerCa(join(process.env.OPENCODEX_HOME!, "picker-test")); + try { + const result = await handleClaudeDesktopCommand(["picker", "trust"], { + findLiveProxyImpl: async () => ({ pid: 42, port: 10100, hostname: "127.0.0.1", source: "runtime" }), + ensurePickerCaImpl: () => ca, + inspectPickerTrustImpl: async () => "untrusted", + trustPickerCaImpl: async () => ({ ok: true }), + runtimeRequestImpl: async (path, init) => { + calls.push({ path, body: JSON.parse(String(init.body)) as Record }); + return { ok: true, picker: pickerStatus("restart_required") }; + }, + }); + expect(result).toBe(0); + expect(calls).toHaveLength(1); + expect(calls[0]!.body).toMatchObject({ enabled: true, persist: false, trustedLocally: true, callerAddedTrust: true }); + } finally { log.mockRestore(); } +}); + +test("picker trust compensates only a connection refusal, while timeout leaves trust unknown", async () => { + const ca = ensurePickerCa(join(process.env.OPENCODEX_HOME!, "picker-test")); + const untrusted: string[] = []; + const baseDeps: ApplyProfileDeps = { + findLiveProxyImpl: async () => ({ pid: 42, port: 10100, hostname: "127.0.0.1", source: "runtime" }), + ensurePickerCaImpl: () => ca, + inspectPickerTrustImpl: async () => "untrusted", + trustPickerCaImpl: async () => ({ ok: true }), + untrustPickerCaImpl: async () => { untrusted.push("untrust"); return { ok: true }; }, + }; + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleClaudeDesktopCommand(["picker", "trust"], { + ...baseDeps, + runtimeRequestImpl: async () => { throw new Error("ECONNREFUSED"); }, + })).toBe(1); + expect(untrusted).toEqual(["untrust"]); + + untrusted.length = 0; + expect(await handleClaudeDesktopCommand(["picker", "trust"], { + ...baseDeps, + runtimeRequestImpl: async () => { throw new RuntimeApiError("request timed out", 503, null); }, + })).toBe(1); + expect(untrusted).toEqual([]); + expect(error.mock.calls.flat().join(" ")).toContain("state unknown - run ocx claude desktop picker status"); + } finally { error.mockRestore(); } +}); + +test("first-party apply delegates to the live local hub and prints its picker state", async () => { + const posted: Record[] = []; + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleClaudeDesktopCommand(["apply", "--first-party"], { + findLiveProxyImpl: async () => ({ pid: 42, port: 10100, hostname: "127.0.0.1", source: "runtime" }), + runtimeRequestImpl: async (_path, init) => { + posted.push(JSON.parse(String(init.body)) as Record); + return { ok: true, path: "/daemon", picker: pickerStatus("restart_required") }; + }, + })).toBe(0); + expect(posted).toEqual([{ mode: "first-party" }]); + expect(log.mock.calls.flat().join(" ")).toContain('"reason":"restart_required"'); + } finally { log.mockRestore(); } +}); diff --git a/tests/claude-integration/claude-desktop-first-party-guards.test.ts b/tests/claude-integration/claude-desktop-first-party-guards.test.ts new file mode 100644 index 00000000000..e348cb4ec48 --- /dev/null +++ b/tests/claude-integration/claude-desktop-first-party-guards.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyDesktopFirstParty, + observeClaudeDesktopMode, + resolveClaudeDesktopMode, +} from "../../src/claude/desktop-first-party"; +import type { writeDesktop3pConfig } from "../../src/claude/desktop-3p"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { syncEnabledClientIntegrations } from "../../src/server/management/config-routes"; +import type { CatalogModel } from "../../src/codex/catalog"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// Gateway is the default Desktop mode. These guards keep that default from ever writing a +// gateway profile behind a Desktop that runs first-party, and keep foreign proxy env from being +// read as a first-party install. + +let root = ""; +let library = ""; +let claudeDir = ""; +const previous: Record = {}; +const ENV_KEYS = ["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "CLAUDE_CONFIG_DIR"] as const; + +function config(extra: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "mock", + clientIntegrations: { grok: false }, + providers: { mock: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: ["keep"] } }, + ...extra, + } as OcxConfig; +} + +function persisted(): OcxConfig { + return JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; +} + +async function dispatch(path: string, init: RequestInit, inputConfig: OcxConfig, deps: Parameters[3] = {}) { + const url = new URL(`http://127.0.0.1:10100${path}`); + const response = await handleManagementAPI(new Request(url, { + ...init, + headers: { Host: url.host, "Content-Type": "application/json", ...(init.headers ?? {}) }, + }), url, inputConfig, deps); + return { status: response!.status, body: await response!.json() as Record }; +} + +/** Apply the real gateway profile so Desktop's library holds our selected row. */ +async function applyGateway(): Promise { + const applied = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }, config(), { + fetchAllModels: async () => [], + }); + expect(applied.status).toBe(200); + return persisted(); +} + +function gatedDiscovery(models: CatalogModel[]) { + let release!: () => void; + let announce!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { announce = resolve; }); + let calls = 0; + return { + started, + release: () => release(), + get calls() { return calls; }, + fetchAllModels: async () => { + calls += 1; + announce(); + await gate; + return models; + }, + }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-desktop-1p-guards-")); + library = join(root, "desktop-library"); + claudeDir = join(root, "claude"); + for (const key of ENV_KEYS) previous[key] = process.env[key]; + process.env.OPENCODEX_HOME = root; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + process.env.CLAUDE_CONFIG_DIR = claudeDir; + writeFileSync(join(root, "config.json"), JSON.stringify(config())); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + removeTreeWithRetry(root); +}); + +test("a foreign HTTPS_PROXY in Claude Code settings is not first-party evidence", () => { + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://corp-proxy.example:3128" } })); + const observed = observeClaudeDesktopMode(config()); + expect(observed).toEqual({ ownedGatewaySelected: false, ownedFirstPartySettings: false }); + expect(resolveClaudeDesktopMode(config(), observed)).toBe("gateway"); +}); + +test("owned first-party env keeps a pre-field install first-party, and a selected owned gateway row outranks it", async () => { + expect(applyDesktopFirstParty(config()).ok).toBe(true); + const firstPartyOnly = observeClaudeDesktopMode(config()); + expect(firstPartyOnly).toEqual({ ownedGatewaySelected: false, ownedFirstPartySettings: true }); + expect(resolveClaudeDesktopMode(config(), firstPartyOnly)).toBe("first-party"); + + const gateway = await applyGateway(); + // The gateway apply removes the first-party env; put it back to model a hand-restored leftover. + expect(applyDesktopFirstParty(config()).ok).toBe(true); + const unmarked = { ...gateway, claudeCode: { ...gateway.claudeCode, desktopMode: undefined } }; + const both = observeClaudeDesktopMode(unmarked); + expect(both).toEqual({ ownedGatewaySelected: true, ownedFirstPartySettings: true }); + expect(resolveClaudeDesktopMode(unmarked, both)).toBe("gateway"); +}); + +for (const switchDuringDiscovery of [false, true]) { + test(`/api/sync Desktop writer ${switchDuringDiscovery ? "skips a switch to first-party during discovery" : "still writes while the mode stays gateway"}`, async () => { + writeFileSync(join(root, "config.json"), JSON.stringify(config())); + const discovery = gatedDiscovery([{ provider: "mock", id: "keep", contextWindow: 123_000 }]); + const writes: Parameters[] = []; + const sync = syncEnabledClientIntegrations(10100, config(), { + fetchAllModels: discovery.fetchAllModels, + refreshOwnedCatalogIntegrations: async () => [], + writeDesktop3pConfig: (...args) => { + writes.push(args); + return { written: true, path: "fixture", fingerprint: "0123456789abcdef" }; + }, + }); + try { + await Promise.race([ + discovery.started, + sync.then(() => { throw new Error("sync ended without entering Desktop discovery"); }), + ]); + if (switchDuringDiscovery) writeFileSync(join(root, "config.json"), JSON.stringify(config({ claudeCode: { desktopMode: "first-party" } }))); + } finally { + discovery.release(); + } + const outcomes = await sync; + expect(writes.length).toBe(switchDuringDiscovery ? 0 : 1); + expect(outcomes.some(outcome => outcome.client === "claude-desktop")).toBe(!switchDuringDiscovery); + }); +} + +test("/api/sync never enters Desktop discovery for a first-party install", async () => { + const chosen = config({ claudeCode: { desktopMode: "first-party" } }); + writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); + let discovered = 0; + const writes: unknown[] = []; + const outcomes = await syncEnabledClientIntegrations(10100, chosen, { + fetchAllModels: async () => { discovered += 1; return []; }, + refreshOwnedCatalogIntegrations: async () => [], + writeDesktop3pConfig: (...args) => { writes.push(args); return { written: true, path: "fixture", fingerprint: "0123456789abcdef" }; }, + }); + expect(discovered).toBe(0); + expect(writes).toEqual([]); + expect(outcomes.some(outcome => outcome.client === "claude-desktop")).toBe(false); +}); + +for (const switchDuringDiscovery of [false, true]) { + test(`roster update auto-apply ${switchDuringDiscovery ? "skips a switch to first-party during discovery" : "still rewrites the owned gateway profile"}`, async () => { + const gateway = await applyGateway(); + // Keep the saved profile but drop the explicit marker, so only the switch below can make it first-party. + const live = { ...gateway, claudeCode: { ...gateway.claudeCode, desktopMode: undefined } } as OcxConfig; + writeFileSync(join(root, "config.json"), JSON.stringify(live)); + const discovery = gatedDiscovery([{ provider: "mock", id: "keep", contextWindow: 123_000 }]); + const writes: unknown[] = []; + const request = dispatch("/api/subagent-models", { method: "PUT", body: JSON.stringify({ models: ["mock/keep"] }) }, live, { + fetchAllModels: discovery.fetchAllModels, + writeDesktop3pConfig: (...args) => { + writes.push(args); + return { written: true, path: "fixture", fingerprint: "fedcba9876543210" }; + }, + }); + try { + await Promise.race([ + discovery.started, + request.then(() => { throw new Error("roster update ended without entering Desktop discovery"); }), + ]); + if (switchDuringDiscovery) { + const current = persisted(); + writeFileSync(join(root, "config.json"), JSON.stringify({ ...current, claudeCode: { ...current.claudeCode, desktopMode: "first-party" } })); + } + } finally { + discovery.release(); + } + const reply = await request; + expect(reply.status).toBe(200); + expect(writes.length).toBe(switchDuringDiscovery ? 0 : 1); + }); +} + +test("roster update auto-apply never writes behind an explicit first-party marker, even over an owned gateway row", async () => { + const gateway = await applyGateway(); + const chosen = { ...gateway, claudeCode: { ...gateway.claudeCode, desktopMode: "first-party" as const } }; + writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); + let discovered = 0; + const writes: unknown[] = []; + const reply = await dispatch("/api/subagent-models", { method: "PUT", body: JSON.stringify({ models: ["mock/keep"] }) }, chosen, { + fetchAllModels: async () => { discovered += 1; return []; }, + writeDesktop3pConfig: (...args) => { writes.push(args); return { written: true, path: "fixture", fingerprint: "fedcba9876543210" }; }, + }); + expect(reply.status).toBe(200); + expect(discovered).toBe(0); + expect(writes).toEqual([]); +}); diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index 8786be63412..f65e899c31b 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -61,9 +61,15 @@ afterEach(() => { removeTreeWithRetry(root); }); -test("mode resolution: explicit wins, applied gateway fingerprint keeps gateway, otherwise first-party", () => { - expect(resolveClaudeDesktopMode(config())).toBe("first-party"); +test("mode resolution: explicit wins, owned disk state keeps what runs, otherwise gateway", () => { + expect(resolveClaudeDesktopMode(config())).toBe("gateway"); + expect(resolveClaudeDesktopMode(config({ claudeCode: { desktopMode: "first-party" } }))).toBe("first-party"); expect(resolveClaudeDesktopMode(config({ claudeCode: { desktopMode: "gateway" } }))).toBe("gateway"); + // An install that predates the field keeps the first-party env opencodex wrote for it... + expect(resolveClaudeDesktopMode(config(), { ownedFirstPartySettings: true })).toBe("first-party"); + // ...unless Desktop has our gateway row selected, which is what the app actually runs. + expect(resolveClaudeDesktopMode(config(), { ownedGatewaySelected: true, ownedFirstPartySettings: true })).toBe("gateway"); + expect(resolveClaudeDesktopMode(config({ claudeCode: { desktopMode: "gateway" } }), { ownedFirstPartySettings: true })).toBe("gateway"); expect(resolveClaudeDesktopMode(config({ claudeCode: { desktopProfile: { version: 1, assignments: {}, defaults: { opus: null, fable: null, sonnet: null, haiku: null }, appliedFingerprint: "abc" } }, }))).toBe("gateway"); @@ -75,16 +81,19 @@ test("mode resolution: explicit wins, applied gateway fingerprint keeps gateway, }))).toBe("first-party"); }); -test("implied apply mode falls back to gateway where the intercept proxy cannot run", () => { - expect(resolveClaudeDesktopApplyMode(config())).toBe("first-party"); +test("implied apply mode is gateway and never rewrites an explicit or observed first-party choice", () => { + expect(resolveClaudeDesktopApplyMode(config())).toBe("gateway"); expect(resolveClaudeDesktopApplyMode(config({ runtimeRole: "client" }))).toBe("gateway"); expect(resolveClaudeDesktopApplyMode(config({ claudeCode: { intercept: { enabled: false } } }))).toBe("gateway"); // An explicit choice is never silently rewritten. expect(resolveClaudeDesktopApplyMode(config({ runtimeRole: "client", claudeCode: { desktopMode: "first-party" } }))).toBe("first-party"); + expect(resolveClaudeDesktopApplyMode(config({ claudeCode: { intercept: { enabled: false } } }), { ownedFirstPartySettings: true })).toBe("first-party"); }); -test("CLI apply flags: default first-party, legacy shape flags imply gateway, conflicts rejected", () => { - expect(parseDesktopApplyArgs([], config())).toEqual({ target: { kind: "first-party" } }); +test("CLI apply flags: default gateway, legacy shape flags imply gateway, conflicts rejected", () => { + expect(parseDesktopApplyArgs([], config())).toEqual({ target: { kind: "gateway", mode: "static" } }); + expect(parseDesktopApplyArgs([], config({ claudeCode: { desktopMode: "first-party" } }))).toEqual({ target: { kind: "first-party" } }); + expect(parseDesktopApplyArgs([], config(), { ownedFirstPartySettings: true })).toEqual({ target: { kind: "first-party" } }); expect(parseDesktopApplyArgs(["--first-party"], config())).toEqual({ target: { kind: "first-party" } }); expect(parseDesktopApplyArgs(["--gateway"], config())).toEqual({ target: { kind: "gateway", mode: "static" } }); expect(parseDesktopApplyArgs(["--hybrid"], config())).toEqual({ target: { kind: "gateway", mode: "hybrid" } }); @@ -135,26 +144,47 @@ test("first-party apply refuses foreign proxy env and disabled intercept", () => expect(applyDesktopFirstParty(config({ runtimeRole: "client" }))).toMatchObject({ ok: false, reason: "intercept_disabled" }); }); -test("POST /api/claude-desktop/apply defaults to first-party and gateway mode replaces it", async () => { - const first = await dispatch("/api/claude-desktop/apply", { method: "POST" }); +test("POST /api/claude-desktop/apply defaults to gateway; first-party is explicit and carries the account-risk notice", async () => { + const byDefault = await dispatch("/api/claude-desktop/apply", { method: "POST" }); + expect(byDefault.status).toBe(200); + expect(byDefault.body.riskWarning).toBeUndefined(); + expect(existsSync(join(claudeDir, "settings.json"))).toBe(false); + const afterDefault = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(afterDefault.claudeCode?.desktopMode).toBe("gateway"); + expect(afterDefault.claudeCode?.desktopProfile?.appliedFingerprint).toBeTruthy(); + const gatewayStatus = await dispatch("/api/claude-desktop/status", {}, afterDefault); + expect(gatewayStatus.body.mode).toBe("gateway"); + expect(gatewayStatus.body.riskWarning).toBeNull(); + + const first = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, afterDefault); expect(first.status).toBe(200); - expect(first.body).toMatchObject({ ok: true, mode: "first-party", applied: true, changed: true, proxyPort: 10200 }); + expect(first.body).toMatchObject({ + ok: true, + mode: "first-party", + applied: true, + changed: true, + proxyPort: 10200, + gatewayRemoved: true, + riskWarning: { code: "first_party_account_suspension_risk" }, + }); + expect(first.body.riskWarning.message).toContain("suspend the account"); expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; expect(saved.claudeCode?.desktopMode).toBe("first-party"); expect(saved.clientIntegrations?.["claude-desktop"]).not.toBe(false); - const status = await dispatch("/api/claude-desktop/status"); + const status = await dispatch("/api/claude-desktop/status", {}, saved); expect(status.body).toMatchObject({ mode: "first-party", applied: true, stale: false, drift: false, firstParty: { applied: true, interceptEnabled: true, proxyPort: 10200 }, + riskWarning: { code: "first_party_account_suspension_risk" }, }); expect(status.body.health.ok).toBe(true); - const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }); + const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }, saved); expect(gateway.status).toBe(200); expect(settings().env?.HTTPS_PROXY).toBeUndefined(); expect(settings().env?.NODE_EXTRA_CA_CERTS).toBeUndefined(); @@ -176,20 +206,35 @@ test("POST /api/claude-desktop/apply defaults to first-party and gateway mode re expect(savedBack.claudeCode?.desktopProfile?.appliedFingerprint).toBeUndefined(); expect(savedBack.claudeCode?.desktopProfile?.appliedAt).toBeUndefined(); expect(savedBack.claudeCode?.desktopProfile?.assignments).toBeDefined(); - expect(resolveClaudeDesktopMode({ claudeCode: { ...savedBack.claudeCode, desktopMode: undefined } })).toBe("first-party"); + // With the gateway default, only the owned first-party env on disk keeps it first-party. + expect(resolveClaudeDesktopMode({ claudeCode: { ...savedBack.claudeCode, desktopMode: undefined } })).toBe("gateway"); + expect(resolveClaudeDesktopMode({ claudeCode: { ...savedBack.claudeCode, desktopMode: undefined } }, { ownedFirstPartySettings: true })).toBe("first-party"); }); -test("native toggle: enable applies first-party by default and disable removes the env", async () => { +test("native toggle: enable applies gateway by default and never writes first-party env", async () => { const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }); expect(enabled.status).toBe(200); + expect(enabled.body).toMatchObject({ ok: true, state: "current", desiredEnabled: true }); + expect(enabled.body.message).not.toContain("suspend"); + expect(existsSync(join(claudeDir, "settings.json"))).toBe(false); + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode?.desktopMode).toBe("gateway"); +}); + +test("native toggle: explicit first-party enable warns about the account risk and disable removes the env", async () => { + const chosen = config({ claudeCode: { desktopMode: "first-party" } }); + writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); + const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, chosen); + expect(enabled.status).toBe(200); expect(enabled.body).toMatchObject({ ok: true, changed: true, state: "current", desiredEnabled: true }); + expect(enabled.body.message).toContain("suspend the account"); expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); - const list = await dispatch("/api/native-integrations"); + const list = await dispatch("/api/native-integrations", {}, chosen); const desktop = (list.body.clients as Array<{ clientId: string; state: string }>).find(client => client.clientId === "claude-desktop"); expect(desktop?.state).toBe("current"); - const disabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: false }) }); + const disabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: false }) }, chosen); expect(disabled.status).toBe(200); expect(disabled.body).toMatchObject({ ok: true, changed: true, state: "absent", desiredEnabled: false }); expect(settings().env?.HTTPS_PROXY).toBeUndefined(); @@ -217,8 +262,8 @@ test("native toggle: enabling into explicit first-party pivots an applied gatewa }); test("native toggle: enabling into gateway saves the gateway mode marker like the apply route", async () => { - // No explicit mode: the disabled intercept is what implies gateway, so the saved marker - // can only come from the toggle itself. + // No explicit mode: gateway is the default, so the saved marker can only come from the + // toggle itself. const chosen = config({ claudeCode: { intercept: { enabled: false } } }); writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, chosen); @@ -230,7 +275,7 @@ test("native toggle: enabling into gateway saves the gateway mode marker like th expect(resolveClaudeDesktopMode(saved)).toBe("gateway"); }); -test("ensure warns instead of touching a gateway profile that contradicts an explicit first-party marker", () => { +test("ensure warns instead of touching a gateway profile that contradicts an explicit first-party marker", async () => { const logs: string[] = []; const deps = { loadConfig: () => config({ claudeCode: { desktopMode: "first-party" } }), @@ -242,7 +287,7 @@ test("ensure warns instead of touching a gateway profile that contradicts an exp log: (message: string) => { logs.push(message); }, error: (message: string) => { logs.push(message); }, }; - ensureClaudeDesktopMatchesDesired(deps as unknown as Parameters[0]); + await ensureClaudeDesktopMatchesDesired(deps as unknown as Parameters[0]); expect(logs.some(line => line.includes("gateway profile is still applied"))).toBe(true); }); @@ -254,7 +299,7 @@ test("first-party apply rebases the Claude hand-edit guard after its scoped mode writeFileSync(join(root, "config.json"), JSON.stringify(snapshot)); armClaudeCodeBaseline(snapshot); - const applied = await dispatch("/api/claude-desktop/apply", { method: "POST" }, snapshot); + const applied = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, snapshot); expect(applied.status).toBe(200); expect(applied.body).toMatchObject({ mode: "first-party", saved: true }); @@ -388,7 +433,7 @@ test("failed committed gateway persistence does not adopt into the live snapshot expect(snapshot).toEqual(before); }); -test("ensure reconciles first-party env: refreshes when ON and stale, removes when OFF", () => { +test("ensure reconciles first-party env: refreshes when ON and stale, removes when OFF", async () => { const applied = applyDesktopFirstParty(config({ port: 10300 })); expect(applied.ok).toBe(true); expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10400"); @@ -402,16 +447,33 @@ test("ensure reconciles first-party env: refreshes when ON and stale, removes wh log: (message: string) => { logs.push(message); }, error: (message: string) => { logs.push(message); }, }; - ensureClaudeDesktopMatchesDesired(deps); + await ensureClaudeDesktopMatchesDesired(deps); expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); expect(logs.some(line => line.includes("first-party env refreshed"))).toBe(true); expect(setIntegrationEnabled("claude-desktop", false).ok).toBe(true); - ensureClaudeDesktopMatchesDesired({ ...deps, loadConfig: () => config({ clientIntegrations: { "claude-desktop": false } }) }); + await ensureClaudeDesktopMatchesDesired({ ...deps, loadConfig: () => config({ clientIntegrations: { "claude-desktop": false } }) }); expect(settings().env?.HTTPS_PROXY).toBeUndefined(); expect(settings().env?.NODE_EXTRA_CA_CERTS).toBeUndefined(); }); +test("ensure durable OFF disables the picker through the live proxy", async () => { + const requests: Array<{ path: string; body: unknown }> = []; + await ensureClaudeDesktopMatchesDesired({ + loadConfig: () => config({ clientIntegrations: { "claude-desktop": false } }), + stripGrokConfig: () => ({ ok: true, changed: false, message: "" }), + syncGrokConfig: async () => ({ ok: true, changed: false, message: "" }), + removeDesktop3pStandardPivot: () => ({ ok: true as const, changed: false, kind: "noop" as const, libraryPath: library }), + findLiveProxyImpl: async () => ({ pid: 42, port: 10100, hostname: "127.0.0.1", source: "runtime" }), + runtimeRequestImpl: async (path, init) => { + requests.push({ path, body: JSON.parse(String(init.body)) }); + return { ok: true }; + }, + removeDesktopFirstParty: () => ({ ok: true, changed: false, path: "" }), + }); + expect(requests).toEqual([{ path: "/api/claude-desktop/picker", body: { enabled: false, persist: false } }]); +}); + for (const surface of ["cli", "api"] as const) { for (const failure of ["intercept_disabled", "foreign_env", "unreadable", "ca_unavailable"] as const) { test(`${surface} failed first-party ${failure} leaves the gateway profile active`, async () => { @@ -432,7 +494,7 @@ for (const surface of ["cli", "api"] as const) { ? "{broken" : JSON.stringify({ env: { HTTPS_PROXY: "http://corporate.example:3128" } })); } if (surface === "cli") { - expect(await applyDesktop(undefined, { kind: "first-party" })).toMatchObject({ ok: false, reason: failure }); + expect(await applyDesktop(undefined, { kind: "first-party" }, { findLiveProxyImpl: async () => null })).toMatchObject({ ok: false, reason: failure }); } else { const reply = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, saved); expect(reply.body.reason).toBe(failure); diff --git a/tests/claude-integration/claude-desktop-mode-explanation.test.ts b/tests/claude-integration/claude-desktop-mode-explanation.test.ts index a2b6e80529e..616fd6a103d 100644 --- a/tests/claude-integration/claude-desktop-mode-explanation.test.ts +++ b/tests/claude-integration/claude-desktop-mode-explanation.test.ts @@ -43,11 +43,32 @@ describe("gateway apply explains the first-party alternative", () => { })).toEqual([]); }); - test("a fresh machine that fell back to gateway is not told to switch to something unavailable", () => { - expect(gatewayModeExplanation({ + test("a fresh machine names gateway as the default and pairs the first-party command with the account risk", () => { + const text = gatewayModeExplanation({ requestedExplicitly: false, config: {}, connection: disconnected, + }).join("\n"); + + expect(text).toContain("gateway is the default"); + expect(text).toContain("ocx claude desktop apply --first-party"); + expect(text).toContain("Account risk:"); + expect(text).toContain("suspend the account"); + }); + + test("every explanation that offers first-party also carries the account risk", () => { + for (const config of [{}, { claudeCode: { desktopMode: "gateway" as const } }, { claudeCode: { desktopProfile: { appliedFingerprint: "abc123" } } }]) { + const text = gatewayModeExplanation({ requestedExplicitly: false, config, connection: disconnected }).join("\n"); + expect(text).toContain("--first-party"); + expect(text).toContain("Account risk:"); + } + }); + + test("a disabled intercept says nothing, because first-party cannot run there", () => { + expect(gatewayModeExplanation({ + requestedExplicitly: false, + config: { claudeCode: { intercept: { enabled: false } } }, + connection: disconnected, })).toEqual([]); }); }); diff --git a/tests/claude-integration/claude-desktop-picker-profile.test.ts b/tests/claude-integration/claude-desktop-picker-profile.test.ts new file mode 100644 index 00000000000..19479d99e67 --- /dev/null +++ b/tests/claude-integration/claude-desktop-picker-profile.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as configIO from "../../src/config"; +import { saveConfig } from "../../src/config"; +import { + inspectDesktopPickerProfile, + applyDesktopPickerProfile, + removeDesktopPickerProfile, +} from "../../src/claude/desktop-picker-profile"; +import { + inspectDesktop3pConfigLibrary, + removeDesktop3pStandardPivot, + writeDesktop3pConfig, +} from "../../src/claude/desktop-3p"; +import { isOwnedDesktopEntry, isOwnedDesktopGatewayEntry } from "../../src/claude/desktop-3p-library"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root: string; +let library: string; +let configDir: string; +let previousHome: string | undefined; +let previousLibrary: string | undefined; + +function env(): NodeJS.ProcessEnv { + return { ...process.env, OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: library }; +} + +function readJson(path: string): Record { + return JSON.parse(readFileSync(path, "utf8")) as Record; +} + +function writeMetadata(value: Record): void { + mkdirSync(library, { recursive: true }); + writeFileSync(join(library, "_meta.json"), JSON.stringify(value, null, 2) + "\n"); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousLibrary = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + root = mkdtempSync(join(tmpdir(), "ocx-desktop-picker-profile-")); + library = join(root, "desktop"); + configDir = join(root, "ocx"); + mkdirSync(library, { recursive: true }); + process.env.OPENCODEX_HOME = configDir; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + saveConfig({ + port: 10100, + defaultProvider: "test", + providers: { test: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", allowPrivateNetwork: true, liveModels: false, models: ["fixture"] } }, + clientIntegrations: { "claude-desktop": false }, + } as any); +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousLibrary === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousLibrary; + removeTreeWithRetry(root); +}); + +describe("Claude Desktop picker profile", () => { + test("creates an egress-only row, records the prior selection, and is idempotent", () => { + const previous = "foreign-selected"; + writeFileSync(join(library, `${previous}.json`), "{\"foreign\":true}\n"); + writeMetadata({ appliedId: previous, foreignMeta: true, entries: [{ id: previous, name: "Personal" }] }); + + const first = applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir }); + expect(first).toMatchObject({ ok: true, changed: true }); + if (!first.ok) throw new Error(first.reason); + expect(readFileSync(first.path, "utf8")).toBe('{"egressProxyUrl":"http://127.0.0.1:41234"}\n'); + expect(statSync(first.path).mode & 0o777).toBe(0o600); + const metadata = readJson(join(library, "_meta.json")); + expect(metadata.foreignMeta).toBe(true); + expect(Object.keys(metadata).filter(key => key.toLowerCase().includes("opencodex"))).toEqual([]); + const picker = metadata.entries.find((entry: { name: string }) => entry.name === "opencodex-picker"); + expect(metadata.appliedId).toBe(picker.id); + const statePath = join(configDir, "claude-picker", "profile-state.json"); + expect(readJson(statePath)).toEqual({ entryId: picker.id, previousAppliedId: previous }); + expect(statSync(statePath).mode & 0o777).toBe(0o600); + expect(inspectDesktopPickerProfile({ env: env(), configDir })).toMatchObject({ kind: "applied", proxyUrl: "http://127.0.0.1:41234" }); + + expect(applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir })).toMatchObject({ ok: true, changed: false, path: first.path }); + expect(applyDesktopPickerProfile({ proxyPort: 41235, env: env(), configDir })).toMatchObject({ ok: true, changed: true }); + expect(readJson(join(configDir, "claude-picker", "profile-state.json")).previousAppliedId).toBe(previous); + }); + + test("refuses a selected gateway and preserves foreign rows when removing", () => { + const gateway = "gateway"; + writeFileSync(join(library, `${gateway}.json`), "{}\n"); + writeMetadata({ appliedId: gateway, entries: [{ id: gateway, name: "opencodex" }] }); + expect(applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir })).toEqual({ ok: false, reason: "gateway_selected" }); + + const foreign = "foreign"; + writeFileSync(join(library, `${foreign}.json`), "{\"foreign\":true}\n"); + writeMetadata({ appliedId: foreign, entries: [{ id: foreign, name: "Personal" }] }); + const applied = applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir }); + expect(applied.ok).toBe(true); + const removed = removeDesktopPickerProfile({ env: env(), configDir }); + expect(removed).toMatchObject({ ok: true, changed: true }); + expect(readJson(join(library, "_meta.json"))).toMatchObject({ appliedId: foreign, entries: [{ id: foreign, name: "Personal" }] }); + expect(readFileSync(join(library, `${foreign}.json`), "utf8")).toBe("{\"foreign\":true}\n"); + expect(existsSync(join(configDir, "claude-picker", "profile-state.json"))).toBe(false); + }); + + test("falls back to a standard row when the previous selection disappeared", () => { + const foreign = "foreign"; + writeFileSync(join(library, `${foreign}.json`), "{}\n"); + writeMetadata({ appliedId: foreign, entries: [{ id: foreign, name: "Personal" }] }); + expect(applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir }).ok).toBe(true); + unlinkSync(join(library, `${foreign}.json`)); + writeMetadata({ appliedId: readJson(join(library, "_meta.json")).appliedId, entries: readJson(join(library, "_meta.json")).entries.filter((entry: { id: string }) => entry.id !== foreign) }); + const removed = removeDesktopPickerProfile({ env: env(), configDir }); + expect(removed).toMatchObject({ ok: true, changed: true }); + const metadata = readJson(join(library, "_meta.json")); + expect(metadata.entries.some((entry: { name: string }) => entry.name === "opencodex-picker")).toBe(false); + expect(metadata.entries.find((entry: { id: string }) => entry.id === metadata.appliedId).name).toBe("opencodex-standard"); + expect(readFileSync(join(library, `${metadata.appliedId}.json`), "utf8")).toBe("{}\n"); + }); + + test("rolls back the profile and state when metadata publication fails", () => { + const previous = "foreign"; + writeFileSync(join(library, `${previous}.json`), "{\"foreign\":true}\n"); + writeMetadata({ appliedId: previous, foreignMeta: "keep", entries: [{ id: previous, name: "Personal" }] }); + const beforeMeta = readFileSync(join(library, "_meta.json"), "utf8"); + const realWrite = configIO.atomicWriteFile; + const failure = spyOn(configIO, "atomicWriteFile").mockImplementation((path, content, io, hooks) => { + if (path === join(library, "_meta.json")) throw new Error("metadata failure"); + return realWrite(path, content, io, hooks); + }); + try { + expect(applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir })).toEqual({ ok: false, reason: "write_failed" }); + } finally { + failure.mockRestore(); + } + expect(readFileSync(join(library, "_meta.json"), "utf8")).toBe(beforeMeta); + expect(readFileSync(join(library, `${previous}.json`), "utf8")).toBe("{\"foreign\":true}\n"); + expect(inspectDesktopPickerProfile({ env: env(), configDir })).toEqual({ kind: "absent" }); + expect(existsSync(join(configDir, "claude-picker", "profile-state.json"))).toBe(false); + }); + + test("gateway writes and removal leave the picker row alone", () => { + const foreign = "foreign"; + writeFileSync(join(library, `${foreign}.json`), "{}\n"); + writeMetadata({ appliedId: foreign, entries: [{ id: foreign, name: "Personal" }] }); + const applied = applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir }); + expect(applied.ok).toBe(true); + saveConfig({ + port: 10100, + defaultProvider: "test", + providers: { test: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", allowPrivateNetwork: true, liveModels: false, models: ["fixture"] } }, + clientIntegrations: { "claude-desktop": true }, + } as any); + expect(writeDesktop3pConfig(10100, [], [], undefined, "static", undefined, undefined, { lockPath: join(root, "locks", "desktop.sqlite") }).written).toBe(true); + const pickerId = readJson(join(configDir, "claude-picker", "profile-state.json")).entryId; + expect(existsSync(join(library, `${pickerId}.json`))).toBe(true); + expect(removeDesktop3pStandardPivot({ env: env(), replaceWhileEnabled: true })).toMatchObject({ ok: true }); + expect(existsSync(join(library, `${pickerId}.json`))).toBe(true); + expect(inspectDesktopPickerProfile({ env: env(), configDir })).toMatchObject({ kind: "not_selected", entryId: pickerId }); + }); + + test("the shared Desktop inspection treats a selected picker row as owned standard", () => { + const picker = "picker"; + writeFileSync(join(library, `${picker}.json`), '{"egressProxyUrl":"http://127.0.0.1:41234"}\n'); + writeMetadata({ appliedId: picker, entries: [{ id: picker, name: "opencodex-picker" }] }); + expect(isOwnedDesktopEntry({ id: picker, name: "opencodex-picker" })).toBe(true); + expect(isOwnedDesktopGatewayEntry({ id: picker, name: "opencodex-picker" })).toBe(false); + expect(inspectDesktop3pConfigLibrary({ env: env() })).toMatchObject({ kind: "standard", appliedId: picker }); + }); +}); diff --git a/tests/claude-integration/claude-desktop-picker-routes.test.ts b/tests/claude-integration/claude-desktop-picker-routes.test.ts new file mode 100644 index 00000000000..089783c15fd --- /dev/null +++ b/tests/claude-integration/claude-desktop-picker-routes.test.ts @@ -0,0 +1,247 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { applyDesktopPickerProfile, inspectDesktopPickerProfile } from "../../src/claude/desktop-picker-profile"; +import { pickerCaCertPath, pickerCaFingerprints } from "../../src/claude/intercept/picker-ca"; +import type { PickerListenerOptions } from "../../src/claude/intercept/picker-listener"; +import { createPickerRuntime } from "../../src/claude/intercept/picker-runtime"; +import type { SecurityRunner } from "../../src/claude/intercept/picker-trust"; +import { getClaudePickerRuntime, startClaudeIntercept, type ClaudeInterceptHandle } from "../../src/claude/intercept/runtime"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// End to end over the management routes: a real picker runtime and controller behind the +// dedicated egress proxy, with the keychain, the claude.ai listener and discovery faked. + +let root = ""; +let handle: ClaudeInterceptHandle | null = null; +const previous: Record = {}; +const ENV_KEYS = ["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "CLAUDE_CONFIG_DIR"] as const; +const LISTENER_PORT = 45_679; +const INTERCEPT = { kind: "intercept", port: LISTENER_PORT }; +const BLIND = { kind: "blind" }; + +function config(extra: Partial = {}): OcxConfig { + return { port: 10100, providers: {}, defaultProvider: "openai", ...extra } as OcxConfig; +} + +function persisted(): OcxConfig { + return JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; +} + +const keychain = { trusted: false, calls: [] as string[] }; +const security: SecurityRunner = async args => { + keychain.calls.push(args[0]!); + switch (args[0]) { + case "find-certificate": { + if (!keychain.trusted) return { code: 1, stdout: "", stderr: "" }; + const { sha1 } = pickerCaFingerprints(readFileSync(pickerCaCertPath(root), "utf8")); + return { code: 0, stdout: `SHA-1 hash: ${sha1}\n`, stderr: "" }; + } + case "verify-cert": return { code: keychain.trusted ? 0 : 1, stdout: "", stderr: "" }; + case "add-trusted-cert": keychain.trusted = true; return { code: 0, stdout: "", stderr: "" }; + case "remove-trusted-cert": keychain.trusted = false; return { code: 0, stdout: "", stderr: "" }; + default: return { code: 0, stdout: "", stderr: "" }; + } +}; + +async function canBind(port: number): Promise { + return new Promise(resolve => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.listen({ port, host: "127.0.0.1", exclusive: true }, () => server.close(() => resolve(true))); + }); +} + +async function freePortPair(): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + const port = 20_000 + Math.floor(Math.random() * 30_000); + if (await canBind(port) && await canBind(port + 1)) return port; + } + throw new Error("no free port pair"); +} + +/** Start the intercept pair with picker mode wired, as the server lifecycle does. */ +async function startPicker(saved: OcxConfig): Promise { + writeFileSync(join(root, "config.json"), JSON.stringify(saved)); + const port = await freePortPair(); + handle = await startClaudeIntercept({ + config: config({ claudeCode: { intercept: { port } } }), + publicPort: 10100, + configDir: root, + dispatch: async () => new Response("unused"), + loadPickerRoutes: async () => ({ nativeSlugs: [], routedModels: [{ provider: "xai", id: "grok-4.7", contextWindow: 256_000 }] }), + pickerSecurity: security, + pickerPlatform: "darwin", + createPicker: options => createPickerRuntime({ + ...options, + startListener: (async (_: PickerListenerOptions) => ({ port: LISTENER_PORT, close: async () => {} })) as never, + trustTtlMs: 0, + refreshIntervalMs: 3_600_000, + }), + }); + return port; +} + +async function dispatch(path: string, init: RequestInit = {}, deps: Parameters[3] = {}) { + const url = new URL(`http://127.0.0.1:10100${path}`); + const response = await handleManagementAPI(new Request(url, { + ...init, + headers: { Host: url.host, "Content-Type": "application/json", ...(init.headers ?? {}) }, + }), url, persisted(), deps); + return { status: response!.status, body: await response!.json() as Record }; +} + +const put = (body: unknown) => dispatch("/api/claude-desktop/picker", { method: "PUT", body: JSON.stringify(body) }); +const decision = () => getClaudePickerRuntime()!.selectTunnel("claude.ai", 443); + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-picker-routes-")); + for (const key of ENV_KEYS) previous[key] = process.env[key]; + process.env.OPENCODEX_HOME = root; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(root, "desktop-library"); + process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); + keychain.trusted = false; + keychain.calls = []; +}); + +afterEach(async () => { + await handle?.stop(); + handle = null; + for (const key of ENV_KEYS) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + removeTreeWithRetry(root); +}); + +describe("first-party turns picker mode on by default", () => { + test("management first-party apply from an explicit gateway marker arms claude.ai, and gateway apply disarms it", async () => { + const port = await startPicker(config({ claudeCode: { desktopMode: "gateway" } })); + expect(decision()).toEqual(BLIND); + + const applied = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }); + expect(applied.status).toBe(200); + expect(applied.body.picker).toMatchObject({ effective: true, reason: "restart_required", trust: "trusted", profile: "applied" }); + expect(decision()).toEqual(INTERCEPT); + expect(inspectDesktopPickerProfile()).toMatchObject({ kind: "applied", proxyUrl: `http://127.0.0.1:${port + 1}` }); + expect(keychain.trusted).toBe(true); + expect(persisted().claudeCode?.intercept?.picker).toBeUndefined(); + + const status = await dispatch("/api/claude-desktop/status"); + expect(status.body.firstParty.picker).toMatchObject({ effective: true, profile: "applied" }); + + const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }, { fetchAllModels: async () => [] }); + expect(gateway.status).toBe(200); + expect(decision()).toEqual(BLIND); + await getClaudePickerRuntime()!.refresh(); + expect(decision()).toEqual(BLIND); + expect(inspectDesktopPickerProfile().kind).toBe("absent"); + expect(keychain.trusted).toBe(false); + // Mode-transition cleanup never writes the preference: returning to first-party re-enables. + expect(persisted().claudeCode?.intercept?.picker).toBeUndefined(); + }); + + test("native OFF to ON on a server that started with the integration off arms claude.ai; native OFF disarms", async () => { + await startPicker(config({ clientIntegrations: { "claude-desktop": false }, claudeCode: { desktopMode: "first-party" } })); + expect(decision()).toEqual(BLIND); + const on = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }); + expect(on.status).toBe(200); + expect(on.body.message).toContain("Picker mode is on"); + expect(decision()).toEqual(INTERCEPT); + const off = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: false }) }); + expect(off.status).toBe(200); + expect(decision()).toEqual(BLIND); + expect(inspectDesktopPickerProfile().kind).toBe("absent"); + }); + + test("an explicit picker off is remembered, and picker on from it re-arms", async () => { + await startPicker(config({ claudeCode: { desktopMode: "first-party" } })); + expect((await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) })).status).toBe(200); + expect(decision()).toEqual(INTERCEPT); + + const off = await put({ enabled: false, persist: true }); + expect(off.status).toBe(200); + expect(off.body.picker.effective).toBe(false); + expect(decision()).toEqual(BLIND); + expect(persisted().claudeCode?.intercept?.picker).toBe(false); + await getClaudePickerRuntime()!.refresh(); + expect(decision()).toEqual(BLIND); + // A later first-party apply respects the remembered off. + const again = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }); + expect(again.body.picker.effective).toBe(false); + expect(decision()).toEqual(BLIND); + + const on = await put({ enabled: true, persist: true }); + expect(on.status).toBe(200); + expect(on.body.picker.effective).toBe(true); + expect(persisted().claudeCode?.intercept?.picker).toBe(true); + expect(decision()).toEqual(INTERCEPT); + expect((await dispatch("/api/claude-desktop/picker")).body.picker).toMatchObject({ effective: true }); + }); + + test("callerAddedTrust reaches the controller: a refused CLI enable has its trust removed under the lock", async () => { + await startPicker(config({ claudeCode: { desktopMode: "gateway" } })); + // The CLI's trust step already ran; the server refuses because Desktop is not first-party. + await dispatch("/api/claude-desktop/picker"); + keychain.trusted = true; + const refused = await put({ enabled: true, persist: false, trustedLocally: true, callerAddedTrust: true }); + expect(refused.status).toBe(409); + expect(refused.body).toMatchObject({ ok: false, code: "picker_enable_refused", reason: "mode_not_committed" }); + expect(refused.body.picker.effective).toBe(false); + expect(keychain.calls).toContain("remove-trusted-cert"); + expect(keychain.trusted).toBe(false); + }); +}); + +describe("without a running picker controller", () => { + test("status is offline, enabling is refused, and off persists and cleans up locally", async () => { + writeFileSync(join(root, "config.json"), JSON.stringify(config({ claudeCode: { desktopMode: "first-party" } }))); + const status = await dispatch("/api/claude-desktop/picker"); + // Off macOS the offline status says why first: picker mode is macOS-only. + expect(status.body.picker).toMatchObject({ + effective: false, + reason: process.platform === "darwin" ? "proxy_unavailable" : "unsupported_platform", + }); + const on = await put({ enabled: true, persist: true }); + expect(on.status).toBe(503); + expect(on.body.code).toBe("picker_proxy_unavailable"); + expect(persisted().claudeCode?.intercept?.picker).toBeUndefined(); + expect(applyDesktopPickerProfile({ proxyPort: 45_001 }).ok).toBe(true); + const off = await put({ enabled: false, persist: true }); + expect(off.status).toBe(200); + expect(persisted().claudeCode?.intercept?.picker).toBe(false); + expect(inspectDesktopPickerProfile().kind).toBe("absent"); + }); + + test("first-party apply still succeeds and reports the picker as unavailable", async () => { + writeFileSync(join(root, "config.json"), JSON.stringify(config())); + const applied = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }); + expect(applied.status).toBe(200); + expect(applied.body).toMatchObject({ mode: "first-party", applied: true, picker: { effective: false, reason: "proxy_unavailable" } }); + }); + + test("gateway apply and native disable remove a leftover picker row", async () => { + writeFileSync(join(root, "config.json"), JSON.stringify(config({ claudeCode: { intercept: { enabled: false } } }))); + expect(applyDesktopPickerProfile({ proxyPort: 45_001 }).ok).toBe(true); + const off = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: false }) }); + expect(off.status).toBe(200); + expect(inspectDesktopPickerProfile().kind).toBe("absent"); + + expect(applyDesktopPickerProfile({ proxyPort: 45_001 }).ok).toBe(true); + const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }, { fetchAllModels: async () => [] }); + expect(gateway.status).toBe(200); + expect(inspectDesktopPickerProfile().kind).toBe("absent"); + }); + + test("the picker route rejects unknown fields and non-boolean values", async () => { + writeFileSync(join(root, "config.json"), JSON.stringify(config())); + expect((await put({ enabled: true, persist: true, extra: 1 })).status).toBe(400); + expect((await put({ enabled: "yes", persist: true })).status).toBe(400); + expect((await put({ enabled: true })).status).toBe(400); + expect((await put({ enabled: false, persist: false, callerAddedTrust: "no" })).status).toBe(400); + }); +}); diff --git a/tests/claude-integration/claude-desktop-picker.test.ts b/tests/claude-integration/claude-desktop-picker.test.ts new file mode 100644 index 00000000000..55c9316969e --- /dev/null +++ b/tests/claude-integration/claude-desktop-picker.test.ts @@ -0,0 +1,291 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createDesktopPickerController, + offlinePickerStatus, + removeDesktopPickerArtifacts, + type DesktopPickerProfileInspection, +} from "../../src/claude/desktop-picker"; +import type { PickerRuntime, PickerRuntimeStatus } from "../../src/claude/intercept/picker-runtime"; +import type { SecurityResult, SecurityRunner } from "../../src/claude/intercept/picker-trust"; +import { pickerCaCertPath, pickerCaFingerprints } from "../../src/claude/intercept/picker-ca"; +import type { OcxConfig } from "../../src/types"; + +let root = ""; + +function config(extra: Partial = {}): OcxConfig { + return { + port: 10100, + providers: {}, + defaultProvider: "openai", + runtimeRole: "hub", + clientIntegrations: { "claude-desktop": true }, + claudeCode: { desktopMode: "first-party" }, + ...extra, + } as OcxConfig; +} + +function pickerSecurity(options: { trusted?: boolean; addTrust?: boolean; removeTrust?: boolean } = {}): { + run: SecurityRunner; + calls: string[][]; +} { + let trusted = options.trusted ?? false; + const calls: string[][] = []; + const ok: SecurityResult = { code: 0, stdout: "", stderr: "" }; + const run: SecurityRunner = async args => { + calls.push([...args]); + if (args[0] === "find-certificate") { + const certPath = pickerCaCertPath(root); + let sha1 = ""; + try { sha1 = pickerCaFingerprints(readFileSync(certPath, "utf8")).sha1; } catch { /* certificate is created lazily */ } + return trusted + ? { ...ok, stdout: `SHA-1 hash: ${sha1}` } + : { code: 1, stdout: "", stderr: "" }; + } + if (args[0] === "verify-cert") return trusted ? ok : { code: 1, stdout: "", stderr: "" }; + if (args[0] === "add-trusted-cert") { + trusted = options.addTrust ?? true; + return trusted ? ok : { code: 1, stdout: "", stderr: "" }; + } + if (args[0] === "remove-trusted-cert") { + trusted = options.removeTrust ?? false; + return trusted ? { code: 1, stdout: "", stderr: "" } : ok; + } + if (args[0] === "delete-certificate") return ok; + return ok; + }; + return { run, calls }; +} + +function fakeRuntime(events: string[] = []): { runtime: PickerRuntime; state: PickerRuntimeStatus } { + const state: PickerRuntimeStatus = { + desired: true, supported: true, trust: "trusted", listenerReady: true, effective: false, + latched: false, reason: "disarmed", models: 3, snapshotAt: 10, lastBootstrapAt: null, + }; + const runtime = { + selectTunnel: () => ({ kind: "blind" as const }), + refreshTrust: async () => state.trust, + refresh: async () => {}, + disarm: () => { events.push("disarm"); state.effective = false; state.latched = true; state.reason = "disarmed"; }, + rearm: async () => { events.push("rearm"); state.effective = true; state.latched = false; state.reason = "active"; }, + ensureStarted: async () => {}, + start: async () => {}, + ready: Promise.resolve(), + status: () => ({ ...state }), + stop: async () => {}, + } as unknown as PickerRuntime; + return { runtime, state }; +} + +function profileFake(events: string[] = []): { + inspect: () => DesktopPickerProfileInspection; + apply: (options: { proxyPort: number }) => { ok: true; changed: boolean; path: string }; + remove: () => { ok: true; changed: boolean }; + selected: () => boolean; +} { + let selected = false; + return { + inspect: () => selected ? { kind: "applied", entryId: "picker", proxyUrl: "http://127.0.0.1:10201" } : { kind: "absent" }, + apply: ({ proxyPort }) => { + events.push("profile"); + selected = true; + return { ok: true, changed: true, path: `picker-${proxyPort}.json` }; + }, + remove: () => { events.push("remove"); selected = false; return { ok: true, changed: true }; }, + selected: () => selected, + }; +} + +function controllerFor(options: { + config?: OcxConfig; + runtime?: PickerRuntime; + events?: string[]; + security?: SecurityRunner; + profile?: ReturnType; + proxy?: number | null; +} = {}) { + const current = options.config ?? config(); + const runtime = options.runtime ?? fakeRuntime(options.events).runtime; + const profile = options.profile ?? profileFake(options.events); + return createDesktopPickerController({ + runtime, + readConfig: () => current, + persistPreference: value => { + options.events?.push(`persist:${value}`); + current.claudeCode = { ...(current.claudeCode ?? {}), intercept: { ...(current.claudeCode?.intercept ?? {}), picker: value } }; + return true; + }, + proxyPort: () => options.proxy === undefined ? 10201 : options.proxy, + configDir: root, + platform: "darwin", + security: options.security, + applyProfile: profile.apply as never, + removeProfile: profile.remove as never, + inspectProfile: profile.inspect as never, + }); +} + +beforeEach(() => { root = mkdtempSync(join(tmpdir(), "ocx-picker-controller-")); }); +afterEach(() => { rmSync(root, { recursive: true, force: true }); }); + +test("enable orders trust, fresh recheck, profile, and rearm", async () => { + const events: string[] = []; + const trust = pickerSecurity({ trusted: false, addTrust: true }); + const profile = profileFake(events); + const controller = controllerFor({ events, security: trust.run, profile }); + const result = await controller.enable({ persist: true, context: "server" }); + expect(result).toMatchObject({ reason: "restart_required", profile: "applied", effective: true, models: 3 }); + expect(events).toEqual(["persist:true", "profile", "rearm"]); + expect(trust.calls.map(call => call[0])).toEqual(["find-certificate", "add-trusted-cert", "find-certificate", "verify-cert"]); +}); + +test("persisted false is allowed to become true on explicit enable", async () => { + const current = config({ claudeCode: { desktopMode: "first-party", intercept: { picker: false } } }); + const trust = pickerSecurity({ trusted: true }); + const controller = controllerFor({ config: current, security: trust.run, profile: profileFake() }); + const result = await controller.enable({ persist: true, context: "server" }); + expect(result.reason).toBe("restart_required"); + expect(current.claudeCode?.intercept?.picker).toBe(true); +}); + +test("independent preconditions refuse before writing the preference", async () => { + for (const [name, current, expected] of [ + ["mode", config({ claudeCode: { desktopMode: "gateway" } }), "mode_not_committed"], + ["intent", config({ clientIntegrations: { "claude-desktop": false } }), "integration_off"], + ["proxy", config(), "proxy_unavailable"], + ] as const) { + const trust = pickerSecurity({ trusted: true }); + const controller = controllerFor({ config: current, security: trust.run, proxy: name === "proxy" ? null : 10201 }); + const result = await controller.enable({ persist: true, context: "server" }); + expect(result.reason).toBe(expected); + expect(current.claudeCode?.intercept?.picker).not.toBe(true); + } + const unsupportedRuntime = fakeRuntime().runtime; + const unsupported = createDesktopPickerController({ + runtime: unsupportedRuntime, + readConfig: () => config(), + persistPreference: () => { throw new Error("must not persist"); }, + proxyPort: () => 10201, + configDir: root, + platform: "linux", + applyProfile: (() => ({ ok: true, changed: true, path: "" })) as never, + removeProfile: (() => ({ ok: true, changed: false })) as never, + inspectProfile: (() => ({ kind: "absent" })) as never, + }); + expect((await unsupported.enable({ persist: true, context: "server" })).reason).toBe("unsupported_platform"); +}); + +test("server trust refusal reports trust_pending and CLI trust refusal reports trust_declined", async () => { + const declined = pickerSecurity({ trusted: false, addTrust: false }); + const server = await controllerFor({ security: declined.run }).enable({ persist: false, context: "server" }); + expect(server).toMatchObject({ reason: "trust_pending", hint: "ocx claude desktop picker trust", profile: "absent" }); + const cli = await controllerFor({ security: pickerSecurity({ trusted: false }).run }).enable({ persist: false, context: "cli-trusted" }); + expect(cli.reason).toBe("trust_declined"); +}); + +test("trust added by the server is removed when profile activation fails", async () => { + const trust = pickerSecurity({ trusted: false, addTrust: true }); + const failedProfile = profileFake(); + failedProfile.apply = (() => ({ ok: false, reason: "write_failed" })) as never; + const controller = controllerFor({ security: trust.run, profile: failedProfile }); + const result = await controller.enable({ persist: false, context: "server" }); + expect(result).toMatchObject({ reason: "profile_failed", profile: "absent", effective: false }); + expect(trust.calls.some(call => call[0] === "remove-trusted-cert")).toBe(true); +}); + +test("disable disarms, optionally persists, removes the profile, and untrusts", async () => { + const events: string[] = []; + const trust = pickerSecurity({ trusted: true }); + const profile = profileFake(events); + const runtimeParts = fakeRuntime(events); + const controller = controllerFor({ events, security: trust.run, profile, runtime: runtimeParts.runtime }); + await controller.enable({ persist: false, context: "server" }); + events.length = 0; + const result = await controller.disable({ persist: true }); + expect(result.reason).toBe("disabled"); + expect(result.effective).toBe(false); + expect(events).toEqual(["disarm", "persist:false", "remove"]); + expect(trust.calls.slice(-4).map(call => call[0])).toEqual(["find-certificate", "remove-trusted-cert", "delete-certificate", "find-certificate"]); +}); + +test("disable keeps the CA trusted while Desktop still selects the picker profile", async () => { + const events: string[] = []; + const trust = pickerSecurity({ trusted: true }); + const base = profileFake(events); + let failRemove = false; + const profile = { + ...base, + remove: () => failRemove + ? ({ ok: false, reason: "write_failed" } as never) + : base.remove(), + }; + const runtimeParts = fakeRuntime(events); + const controller = controllerFor({ events, security: trust.run, profile, runtime: runtimeParts.runtime }); + await controller.enable({ persist: false, context: "server" }); + // The metadata write fails: the profile stays selected. + failRemove = true; + const callsBefore = trust.calls.length; + const result = await controller.disable({ persist: false }); + expect(profile.selected()).toBe(true); + expect(result.residual).toEqual(["profile"]); + expect(result.effective).toBe(false); + expect(trust.calls.slice(callsBefore).some(call => call[0] === "remove-trusted-cert")).toBe(false); +}); + +test("one lock serializes a pending enable and a queued disable", async () => { + const events: string[] = []; + const profile = profileFake(events); + const runtimeParts = fakeRuntime(events); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const originalRearm = runtimeParts.runtime.rearm; + runtimeParts.runtime.rearm = async () => { events.push("rearm-start"); await gate; await originalRearm(); }; + const controller = controllerFor({ events, security: pickerSecurity({ trusted: true }).run, profile, runtime: runtimeParts.runtime }); + const enable = controller.enable({ persist: false, context: "server" }); + while (!events.includes("rearm-start")) await Bun.sleep(0); + expect(controller.busy()).toBe(true); + const disable = controller.disable({ persist: false }); + release(); + await Promise.all([enable, disable]); + expect(controller.busy()).toBe(false); + expect(profile.selected()).toBe(false); +}); + +test("caller-added trust is compensated on an early refusal", async () => { + const trust = pickerSecurity({ trusted: true, removeTrust: true }); + const current = config({ clientIntegrations: { "claude-desktop": false } }); + const controller = controllerFor({ config: current, security: trust.run }); + const result = await controller.enable({ persist: false, context: "cli-trusted", callerAddedTrust: true }); + expect(result.reason).toBe("integration_off"); + expect(trust.calls.some(call => call[0] === "remove-trusted-cert")).toBe(true); +}); + +test("offline status explains why a picker cannot run", () => { + expect(offlinePickerStatus(config(), "linux")).toMatchObject({ reason: "unsupported_platform", effective: false, supported: false }); + expect(offlinePickerStatus(config({ claudeCode: { desktopMode: "gateway" } }), "darwin").reason).toBe("not_first_party"); +}); + +test("offline cleanup reports residual work", async () => { + const result = await removeDesktopPickerArtifacts({ configDir: root, platform: "linux", security: async () => ({ code: 1, stdout: "", stderr: "" }) }); + expect(result.ok).toBe(true); +}); + +test("a restart is asked for only after this process changed the profile, never after a plain opencodex restart", async () => { + const events: string[] = []; + const profile = profileFake(events); + const runtimeParts = fakeRuntime(events); + // The profile already points at this proxy, as after an opencodex restart. + profile.apply({ proxyPort: 10201 }); + const unchanged = { ...profile, apply: () => ({ ok: true as const, changed: false, path: "picker.json" }) }; + const controller = controllerFor({ events, security: pickerSecurity({ trusted: true }).run, profile: unchanged, runtime: runtimeParts.runtime }); + expect((await controller.enable({ persist: false, context: "server" })).reason).toBe("active"); + + const fresh = profileFake(events); + const second = fakeRuntime(events); + const changed = controllerFor({ events, security: pickerSecurity({ trusted: true }).run, profile: fresh, runtime: second.runtime }); + expect((await changed.enable({ persist: false, context: "server" })).reason).toBe("restart_required"); + second.state.lastBootstrapAt = Date.now() + 1; + expect((await changed.status()).reason).toBe("active"); +}); diff --git a/tests/claude-integration/claude-intercept-proxy.test.ts b/tests/claude-integration/claude-intercept-proxy.test.ts index c9b6db3fb33..80cb58448cd 100644 --- a/tests/claude-integration/claude-intercept-proxy.test.ts +++ b/tests/claude-integration/claude-intercept-proxy.test.ts @@ -1,5 +1,5 @@ import { afterAll, expect, test } from "bun:test"; -import { connect, createServer } from "node:net"; +import { connect, createServer, type Socket } from "node:net"; import { CLAUDE_INTERCEPT_HOSTS, isLoopbackTarget, parseConnectRequestLine, startConnectProxy, type ConnectProxyHandle } from "../../src/claude/intercept/connect-proxy"; import { startClaudeInterceptListener, rewriteInterceptedRequest } from "../../src/claude/intercept/listener"; import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; @@ -141,6 +141,97 @@ test("other CONNECT targets are relayed blind, including pipelined bytes after t expect(out).toContain("echo:hello"); }); +function tunnelPayload(port: number, host: string, payload = "hello"): Promise { + return new Promise((resolve, reject) => { + const socket = connect({ host: "127.0.0.1", port }, () => + socket.write(`CONNECT ${host}:443 HTTP/1.1\r\nHost: ${host}:443\r\n\r\n${payload}`)); + let out = ""; + socket.on("data", chunk => { + out += chunk.toString("latin1"); + if (out.includes(`echo:${payload}`)) { socket.end(); resolve(out); } + }); + socket.on("error", reject); + }); +} + +test("per-connection override chooses its own listener or blind tunnel", async () => { + const echo = await startEchoUpstream(); + cleanups.push(echo.close); + const selected: string[] = []; + const proxy = await startConnectProxy(0, { + interceptPort: 1, + selectTunnel: (host, port) => { + selected.push(`${host}:${port}`); + return host === "claude.ai" ? { kind: "intercept", port: echo.port } : { kind: "blind" }; + }, + dialUpstream: () => connect({ host: "127.0.0.1", port: echo.port }), + }); + cleanups.push(proxy.close); + expect(await tunnelPayload(proxy.port, "claude.ai")).toContain("echo:hello"); + expect(await tunnelPayload(proxy.port, "other.example")).toContain("echo:hello"); + expect(selected).toEqual(["claude.ai:443", "other.example:443"]); +}); + +test("pending async choice holds pipelined bytes, then connects; rejection falls back blind", async () => { + const echo = await startEchoUpstream(); + cleanups.push(echo.close); + let settle!: (choice: { kind: "intercept"; port: number }) => void; + let dialCount = 0; + const proxy = await startConnectProxy(0, { + interceptPort: 1, + selectTunnel: host => host === "claude.ai" + ? new Promise(resolve => { settle = resolve; }) + : Promise.reject(new Error("decision failed")), + dialUpstream: () => { dialCount += 1; return connect({ host: "127.0.0.1", port: echo.port }); }, + }); + cleanups.push(proxy.close); + const delayed = tunnelPayload(proxy.port, "claude.ai", "pending"); + await Bun.sleep(20); + expect(dialCount).toBe(0); + settle({ kind: "intercept", port: echo.port }); + expect(await delayed).toContain("echo:pending"); + expect(dialCount).toBe(0); + expect(await tunnelPayload(proxy.port, "failed.example", "blind")).toContain("echo:blind"); + expect(dialCount).toBe(1); +}); + +test("a closed client during an async decision causes no upstream dial", async () => { + let settle!: (choice: { kind: "blind" }) => void; + let dialCount = 0; + const proxy = await startConnectProxy(0, { + interceptPort: 1, + selectTunnel: () => new Promise(resolve => { settle = resolve; }), + dialUpstream: () => { dialCount += 1; return connect({ host: "127.0.0.1", port: 1 }); }, + }); + cleanups.push(proxy.close); + const client: Socket = connect({ host: "127.0.0.1", port: proxy.port }, () => + client.write("CONNECT claude.ai:443 HTTP/1.1\r\n\r\n")); + await new Promise(resolve => { + const check = setInterval(() => { + if (!settle) return; + clearInterval(check); + client.destroy(); + client.once("close", resolve); + }, 1); + }); + await Bun.sleep(20); + settle({ kind: "blind" }); + await Bun.sleep(0); + expect(dialCount).toBe(0); +}); + +test("invalid request and loopback are refused before consulting tunnel choice", async () => { + let consulted = 0; + const proxy = await startConnectProxy(0, { + interceptPort: 1, + selectTunnel: () => { consulted += 1; return { kind: "blind" }; }, + }); + cleanups.push(proxy.close); + expect(await rawRequest(proxy.port, "GET http://example.com/ HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 405"); + expect(await rawRequest(proxy.port, "CONNECT localhost:443 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); + expect(consulted).toBe(0); +}); + test("plain proxied HTTP, loopback targets and oversized heads are refused", async () => { const { proxy } = await startPair(); expect(await rawRequest(proxy.port, "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n")).toStartWith("HTTP/1.1 405"); @@ -148,6 +239,8 @@ test("plain proxied HTTP, loopback targets and oversized heads are refused", asy expect(await rawRequest(proxy.port, "CONNECT localhost:443 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); expect(await rawRequest(proxy.port, "CONNECT [::ffff:127.0.0.1]:22 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); expect(await rawRequest(proxy.port, `CONNECT a:443 HTTP/1.1\r\nX: ${"y".repeat(9000)}`)).toStartWith("HTTP/1.1 431"); + // One read that carries a complete but oversized head is refused the same way. + expect(await rawRequest(proxy.port, `CONNECT a:443 HTTP/1.1\r\nX: ${"y".repeat(9000)}\r\n\r\n`)).toStartWith("HTTP/1.1 431"); }); test("isLoopbackTarget covers mapped, unspecified and shorthand loopback literals", () => { diff --git a/tests/claude-integration/claude-picker-bootstrap.test.ts b/tests/claude-integration/claude-picker-bootstrap.test.ts new file mode 100644 index 00000000000..1e0d112b083 --- /dev/null +++ b/tests/claude-integration/claude-picker-bootstrap.test.ts @@ -0,0 +1,141 @@ +import { expect, test } from "bun:test"; +import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib"; +import { + BOOTSTRAP_MAX_DECODED_BYTES, injectPickerModels, isPickerBootstrapRequest, + narrowBootstrapAcceptEncoding, rewriteBootstrapBody, rewrittenHeaders, +} from "../../src/claude/intercept/picker-bootstrap"; + +const models = [{ id: "ocx-model", name: "Routed", contextWindow: 128_000 }]; +function fixture() { + return { + model_selector_config: [ + { id: "cowork", models: [{ id: "cowork-original" }] }, + { id: "code", models: [{ id: "claude-native", name: "Native", section: "main", + thinking: { enabled: true }, capabilities: ["image"], fast_mode: true, + min_version: "1", clientVersionGate: "2", badge: "old", tooltip: "old", + description: "old", context_window: 100 }] }, + ], + model_selector_state: { current: "claude-native" }, + }; +} + +test("request matcher accepts only GET bootstrap paths", () => { + for (const prefix of ["edge-api", "api"]) { + expect(isPickerBootstrapRequest("GET", `/${prefix}/bootstrap`)).toBe(true); + expect(isPickerBootstrapRequest("GET", `/${prefix}/bootstrap/org-123/app_start/`)).toBe(true); + expect(isPickerBootstrapRequest("POST", `/${prefix}/bootstrap`)).toBe(false); + expect(isPickerBootstrapRequest("HEAD", `/${prefix}/bootstrap`)).toBe(false); + expect(isPickerBootstrapRequest("GET", `/${prefix}/bootstrap/other`)).toBe(false); + } + expect(narrowBootstrapAcceptEncoding()).toBe("gzip, deflate, br"); +}); + +test("injection clones an eligible native row only into Code", () => { + const body = fixture(); + const before = structuredClone(body); + expect(injectPickerModels(body, [...models, models[0]!])).toBe(1); + const code = body.model_selector_config[1]!.models; + expect(code).toHaveLength(2); + const inserted = code[1] as unknown as Record; + expect(inserted.id).toBe("ocx-model"); + expect(inserted.name).toBe("Routed"); + expect(inserted.context_window).toBe(128_000); + expect(inserted.thinking).toEqual({ enabled: true }); + expect(inserted.capabilities).toEqual(["image"]); + for (const key of ["fast_mode", "min_version", "clientVersionGate", "badge", "tooltip", "description"]) { + expect(inserted).not.toHaveProperty(key); + } + expect(body.model_selector_config[0]).toEqual(before.model_selector_config[0]); + expect(body.model_selector_state).toEqual(before.model_selector_state); + expect(injectPickerModels(body, models)).toBe(0); +}); + +test("disabled or deprecated native rows cannot serve as templates", () => { + for (const property of [{ disabled: true }, { disabled_reason: "blocked" }, { section: "deprecated" }]) { + const body = fixture(); + Object.assign(body.model_selector_config[1]!.models[0]!, property); + expect(injectPickerModels(body, models)).toBe(0); + } +}); + +test("supported encodings become identity encoded JSON", () => { + const plain = Buffer.from(JSON.stringify(fixture())); + for (const [encoding, encoded] of [ + [undefined, plain], ["identity", plain], ["gzip", gzipSync(plain)], + ["x-gzip", gzipSync(plain)], ["deflate", deflateSync(plain)], ["br", brotliCompressSync(plain)], + ] as const) { + const output = rewriteBootstrapBody(encoded, encoding, models); + expect(output).not.toBeNull(); + expect(JSON.parse(output!.toString()).model_selector_config[1].models[1].id).toBe("ocx-model"); + } +}); + +test("invalid, oversized and inapplicable bodies stay untouched", () => { + const plain = Buffer.from(JSON.stringify(fixture())); + expect(rewriteBootstrapBody(Buffer.from("not-json"), undefined, models)).toBeNull(); + expect(rewriteBootstrapBody(plain, "zstd", models)).toBeNull(); + expect(rewriteBootstrapBody(Buffer.from("{}"), undefined, models)).toBeNull(); + expect(rewriteBootstrapBody(Buffer.from(" ".repeat(BOOTSTRAP_MAX_DECODED_BYTES + 1)), undefined, models)).toBeNull(); + const hugeCompressed = gzipSync(Buffer.from(" ".repeat(BOOTSTRAP_MAX_DECODED_BYTES + 1))); + expect(rewriteBootstrapBody(hugeCompressed, "gzip", models)).toBeNull(); +}); + +test("rewritten headers remove stale encoding, length, validators and transfer metadata", () => { + expect(rewrittenHeaders([ + "Content-Type", "application/json", "Content-Encoding", "gzip", "Content-Length", "19", + "ETag", "x", "Digest", "x", "Content-MD5", "x", "Transfer-Encoding", "chunked", + "Set-Cookie", "a=1", "Set-Cookie", "b=2", + ], 7)).toEqual(["Content-Type", "application/json", "Set-Cookie", "a=1", "Set-Cookie", "b=2", "Content-Length", "7"]); +}); + +test("the injection outcome names why a bootstrap stayed unchanged, never its values", () => { + const outcomes: string[] = []; + const explain = (outcome: { kind: string; reason?: string; added?: number }) => { + outcomes.push(outcome.kind === "rewritten" ? `rewritten:${outcome.added}` : `unchanged:${outcome.reason}`); + }; + expect(injectPickerModels({}, models, explain)).toBe(0); + expect(injectPickerModels({ model_selector_config: [{ id: "cowork", models: [] }, { id: "chat", models: [] }] }, models, explain)).toBe(0); + expect(injectPickerModels({ model_selector_config: [{ id: "code", models: [{ id: "not-claude" }] }] }, models, explain)).toBe(0); + expect(injectPickerModels(fixture(), [], explain)).toBe(0); + expect(injectPickerModels(fixture(), models, explain)).toBe(1); + expect(rewriteBootstrapBody(Buffer.from("{"), undefined, models, explain)).toBeNull(); + expect(outcomes).toEqual([ + "unchanged:no_model_selector_config", + "unchanged:no_code_surface(cowork,chat)", + "unchanged:code:no_template(models=1)", + "unchanged:no_routes", + "rewritten:1", + "unchanged:decode_or_parse_failed", + ]); +}); + +test("the Desktop Code surfaces ccd and code both gain the routes; remote ccr and cowork stay untouched", () => { + const native = { id: "claude-native", name: "Native", section: "main" }; + const body = { + model_selector_config: [ + { id: "ccd", models: [{ ...native }] }, + { id: "code", models: [{ ...native }] }, + { id: "ccr", models: [{ ...native }] }, + { id: "cowork", models: [{ ...native }] }, + ], + }; + expect(injectPickerModels(body, models)).toBe(2); + const ids = (surface: number) => body.model_selector_config[surface]!.models.map(row => row.id); + expect(ids(0)).toEqual(["claude-native", "ocx-model"]); + expect(ids(1)).toEqual(["claude-native", "ocx-model"]); + expect(ids(2)).toEqual(["claude-native"]); + expect(ids(3)).toEqual(["claude-native"]); + // Desktop falls back to "code" when "ccd" is absent. + const fallback = { model_selector_config: [{ id: "code", models: [{ ...native }] }, { id: "ccr", models: [{ ...native }] }] }; + expect(injectPickerModels(fallback, models)).toBe(1); + expect(fallback.model_selector_config[1]!.models).toHaveLength(1); +}); + +test("unknown surface ids from the body are counted in the outcome, never echoed", () => { + const outcomes: string[] = []; + const secretish = "body-derived-surface-text-7f3a"; + injectPickerModels({ model_selector_config: [{ id: "cowork", models: [] }, { id: secretish, models: [] }, { id: 7, models: [] }] }, models, + outcome => { outcomes.push(outcome.kind === "unchanged" ? outcome.reason : "rewritten"); }); + expect(outcomes).toEqual(["no_code_surface(cowork,other:2)"]); + expect(outcomes.join("")).not.toContain(secretish); +}); diff --git a/tests/claude-integration/claude-picker-ca.test.ts b/tests/claude-integration/claude-picker-ca.test.ts new file mode 100644 index 00000000000..04dafe83783 --- /dev/null +++ b/tests/claude-integration/claude-picker-ca.test.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; +import { X509Certificate } from "node:crypto"; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { connect, createServer } from "node:tls"; +import { createCertificateAuthority, createLocalInterceptCa, issueServerLeaf } from "../../src/claude/intercept/local-ca"; +import { + ensurePickerCa, issuePickerLeaf, pickerCaCertPath, pickerCaFingerprints, + pickerLeafCertPath, pickerStateDir, PICKER_CA_COMMON_NAME, PICKER_HOST, +} from "../../src/claude/intercept/picker-ca"; + +function tempDir(): string { return mkdtempSync(join(tmpdir(), "ocx-picker-ca-")); } + +function der(bytes: Buffer, offset: number): { tag: number; body: Buffer; next: number } { + const tag = bytes[offset]!; + let length = bytes[offset + 1]!; + let start = offset + 2; + if (length & 0x80) { + const width = length & 0x7f; + length = 0; + for (let i = 0; i < width; i++) length = length * 256 + bytes[start++]!; + } + return { tag, body: bytes.subarray(start, start + length), next: start + length }; +} + +function parts(bytes: Buffer): ReturnType[] { + const items = []; + for (let at = 0; at < bytes.length;) { + const item = der(bytes, at); + items.push(item); + at = item.next; + } + return items; +} + +function constraints(certPem: string): { critical: boolean; dnsNames: string[]; excluded: boolean } | null { + const root = der(new X509Certificate(certPem).raw, 0); + const tbs = parts(root.body)[0]!; + const wrapper = parts(tbs.body).find(item => item.tag === 0xa3)!; + const extensions = parts(der(wrapper.body, 0).body); + const matched = extensions.map(item => parts(item.body)).find(fields => + fields[0]?.tag === 0x06 && fields[0].body.equals(Buffer.from([0x55, 0x1d, 0x1e]))); + if (!matched) return null; + const nc = parts(der(matched.at(-1)!.body, 0).body); + const permitted = nc.find(field => field.tag === 0xa0); + return { + critical: matched[1]?.tag === 0x01 && matched[1].body.equals(Buffer.from([0xff])), + dnsNames: permitted ? parts(permitted.body).flatMap(subtree => + parts(subtree.body).filter(base => base.tag === 0x82).map(base => base.body.toString("ascii"))) : [], + excluded: nc.some(field => field.tag === 0xa1), + }; +} + +test("picker root has a critical claude.ai-only DNS constraint; intercept root remains unconstrained", () => { + const ca = ensurePickerCa(tempDir()); + expect(new X509Certificate(ca.certPem).subject).toContain(`CN=${PICKER_CA_COMMON_NAME}`); + expect(constraints(ca.certPem)).toEqual({ critical: true, dnsNames: [PICKER_HOST], excluded: false }); + expect(constraints(createLocalInterceptCa().certPem)).toBeNull(); + expect(ca.fingerprint).toBe(pickerCaFingerprints(ca.certPem).sha256); + expect(pickerCaFingerprints(ca.certPem).sha1).toMatch(/^[0-9A-F]{40}$/); +}); + +test("picker leaf SAN is exactly claude.ai and verifies under its issuer", () => { + const dir = tempDir(); + const ca = ensurePickerCa(dir); + const leaf = issuePickerLeaf(ca, dir); + const cert = new X509Certificate(leaf.certPem); + expect(cert.subjectAltName).toBe("DNS:claude.ai"); + expect(cert.verify(ca.publicKey)).toBe(true); + expect(cert.checkIssued(new X509Certificate(ca.certPem))).toBe(true); + expect(readFileSync(pickerLeafCertPath(dir), "utf8")).toBe(leaf.certPem); +}); + +async function handshake(caPem: string, pair: { certPem: string; keyPem: string }, host: string): Promise { + const server = createServer({ cert: pair.certPem, key: pair.keyPem }, socket => socket.end()); + 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 test listener"); + try { + return await new Promise(resolve => { + const socket = connect({ host: "127.0.0.1", port: address.port, servername: host, + ca: caPem, rejectUnauthorized: true }); + socket.once("secureConnect", () => { resolve(socket.authorized); socket.destroy(); }); + socket.once("error", () => { resolve(false); socket.destroy(); }); + }); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } +} + +test("TLS accepts claude.ai and rejects an off-host leaf issued by the picker root", async () => { + const dir = tempDir(); + const ca = ensurePickerCa(dir); + expect(await handshake(ca.certPem, issuePickerLeaf(ca, dir), PICKER_HOST)).toBe(true); + const offHost = issueServerLeaf(ca, PICKER_CA_COMMON_NAME, ["example.com"]); + expect(await handshake(ca.certPem, offHost, "example.com")).toBe(false); +}); + +test("picker authority persists private key at 0600 and regenerates a corrupt key", () => { + const dir = tempDir(); + const first = ensurePickerCa(dir); + expect(ensurePickerCa(dir).fingerprint).toBe(first.fingerprint); + if (process.platform !== "win32") expect(statSync(join(pickerStateDir(dir), "ca.key")).mode & 0o777).toBe(0o600); + writeFileSync(join(pickerStateDir(dir), "ca.key"), "corrupt\n"); + const repaired = ensurePickerCa(dir); + expect(repaired.fingerprint).not.toBe(first.fingerprint); + expect(constraints(readFileSync(pickerCaCertPath(dir), "utf8"))?.dnsNames).toEqual([PICKER_HOST]); +}); + +test("a valid key-matching but unconstrained persisted CA is rotated", () => { + const dir = tempDir(); + const first = ensurePickerCa(dir); + const unconstrained = createCertificateAuthority({ commonName: PICKER_CA_COMMON_NAME }); + writeFileSync(pickerCaCertPath(dir), unconstrained.certPem); + writeFileSync(join(pickerStateDir(dir), "ca.key"), unconstrained.keyPem); + const repaired = ensurePickerCa(dir); + expect(repaired.fingerprint).not.toBe(first.fingerprint); + expect(repaired.fingerprint).not.toBe(pickerCaFingerprints(unconstrained.certPem).sha256); + expect(constraints(repaired.certPem)?.dnsNames).toEqual([PICKER_HOST]); +}); + +test("a valid constrained CA with the wrong name or DNS scope is rotated", () => { + for (const options of [ + { commonName: "other local CA", permittedDnsNames: [PICKER_HOST] }, + { commonName: PICKER_CA_COMMON_NAME, permittedDnsNames: ["example.com"] }, + ]) { + const dir = tempDir(); + ensurePickerCa(dir); + const other = createCertificateAuthority(options); + writeFileSync(pickerCaCertPath(dir), other.certPem); + writeFileSync(join(pickerStateDir(dir), "ca.key"), other.keyPem); + const repaired = ensurePickerCa(dir); + expect(repaired.fingerprint).not.toBe(pickerCaFingerprints(other.certPem).sha256); + expect(constraints(repaired.certPem)?.dnsNames).toEqual([PICKER_HOST]); + } +}); diff --git a/tests/claude-integration/claude-picker-listener.test.ts b/tests/claude-integration/claude-picker-listener.test.ts new file mode 100644 index 00000000000..8cfccc1bf8d --- /dev/null +++ b/tests/claude-integration/claude-picker-listener.test.ts @@ -0,0 +1,212 @@ +import { expect, test } from "bun:test"; +import { createServer, request } from "node:https"; +import { connect as tlsConnect } from "node:tls"; +import { gzipSync } from "node:zlib"; +import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; +import { startPickerListener } from "../../src/claude/intercept/picker-listener"; +import type { PickerListenerHandle } from "../../src/claude/intercept/picker-listener"; +import type { Server as HttpsServer } from "node:https"; +import type { IncomingMessage } from "node:http"; + +const models = [{ id: "ocx-model", name: "Routed" }]; +const bootstrap = Buffer.from(JSON.stringify({ model_selector_config: [ + { id: "code", models: [{ id: "claude-native", name: "Native", section: "main" }] }, +] })); + +type ResponseData = { status: number; headers: IncomingMessage["headers"]; rawHeaders: string[]; body: Buffer }; + +async function fixture( + handler: Parameters[1], + options: { maxEncodedBytes?: number; untrusted?: boolean } = {}, +) { + const ca = createLocalInterceptCa(); + const upstreamCa = options.untrusted ? createLocalInterceptCa() : ca; + const leaf = issueLocalInterceptLeaf(ca, ["claude.ai"]); + const upstreamLeaf = issueLocalInterceptLeaf(upstreamCa, ["claude.ai"]); + const upstream = createServer({ cert: upstreamLeaf.certPem, key: upstreamLeaf.keyPem }, handler); + await new Promise(resolve => upstream.listen(0, "127.0.0.1", resolve)); + const address = upstream.address(); + if (!address || typeof address === "string") throw new Error("upstream port missing"); + const logs: string[] = []; + const relay = await startPickerListener({ + leaf, models: () => models, + upstream: { host: "127.0.0.1", port: address.port, servername: "claude.ai", ca: ca.certPem }, + maxEncodedBytes: options.maxEncodedBytes, + log: line => logs.push(line), + }); + const close = async () => { + await relay.close(); + await new Promise((resolve, reject) => upstream.close(error => error ? reject(error) : resolve())); + }; + const get = (path: string, method = "GET") => clientRequest(relay, ca.certPem, path, method); + return { upstream, relay, ca: ca.certPem, get, logs, close }; +} + +function clientRequest(relay: PickerListenerHandle, ca: string, path: string, method = "GET"): Promise { + return new Promise((resolve, reject) => { + const req = request({ host: "127.0.0.1", port: relay.port, servername: "claude.ai", ca, + rejectUnauthorized: true, path, method, headers: { Host: "claude.ai" }, agent: false }, res => { + const chunks: Buffer[] = []; + res.on("data", chunk => chunks.push(chunk)); + res.on("end", () => resolve({ status: res.statusCode!, headers: res.headers, + rawHeaders: res.rawHeaders, body: Buffer.concat(chunks) })); + res.on("error", reject); + }); + req.on("error", reject); + req.end(); + }); +} + +test("opaque gzip body and duplicate Set-Cookie headers survive", async () => { + const body = gzipSync(Buffer.from("opaque response")); + const f = await fixture((_req, res) => { + res.writeHead(200, ["Content-Encoding", "gzip", "Content-Length", String(body.length), + "Set-Cookie", "a=1; HttpOnly", "Set-Cookie", "b=2; Secure"]); + res.end(body); + }); + try { + const received = await f.get("/v1/other"); + expect(received.status).toBe(200); + expect(received.body.equals(body)).toBe(true); + expect(received.headers["content-encoding"]).toBe("gzip"); + expect(received.headers["set-cookie"]).toEqual(["a=1; HttpOnly", "b=2; Secure"]); + expect(f.logs).toEqual(["picker GET other 200"]); + } finally { await f.close(); } +}); + +test("SSE first chunk reaches the client before upstream end", async () => { + let finish!: () => void; + const f = await fixture((_req, res) => { + res.writeHead(200, { "Content-Type": "text/event-stream" }); + res.write("data: first\n\n"); + finish = () => res.end("data: second\n\n"); + }); + try { + const first = new Promise((resolve, reject) => { + const req = request({ host: "127.0.0.1", port: f.relay.port, servername: "claude.ai", ca: f.ca, + path: "/events", headers: { Host: "claude.ai" }, agent: false }, res => { + res.once("data", chunk => resolve(chunk.toString())); + }); + req.on("error", reject); + req.end(); + }); + expect(await first).toBe("data: first\n\n"); + finish(); + } finally { await f.close(); } +}); + +test("bootstrap JSON is injected and emitted with identity headers", async () => { + const gzipped = gzipSync(bootstrap); + const f = await fixture((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json", "Content-Encoding": "gzip", + "Content-Length": String(gzipped.length), ETag: "old" }); + res.end(gzipped); + }); + try { + const received = await f.get("/edge-api/bootstrap/org/app_start?cache_bust=1"); + expect(received.status).toBe(200); + expect(received.headers["content-encoding"]).toBeUndefined(); + expect(received.headers.etag).toBeUndefined(); + expect(Number(received.headers["content-length"])).toBe(received.body.length); + expect(JSON.parse(received.body.toString()).model_selector_config[0].models[1].id).toBe("ocx-model"); + expect(f.logs).toEqual(["picker GET bootstrap 200", "picker GET bootstrap rewritten(+1)"]); + } finally { await f.close(); } +}); + +test("mid-chunk encoded cap overflow passes original bytes exactly once", async () => { + const body = Buffer.concat([bootstrap, Buffer.from("tail")]); + const first = body.subarray(0, 10); + const second = body.subarray(10); + const f = await fixture((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json", "Content-Length": String(body.length), ETag: "same" }); + res.write(first); + setTimeout(() => res.end(second), 20); + }, { maxEncodedBytes: first.length + 3 }); + try { + const received = await f.get("/api/bootstrap"); + expect(received.status).toBe(200); + expect(received.body.equals(body)).toBe(true); + expect(received.headers.etag).toBe("same"); + expect(received.headers["content-length"]).toBe(String(body.length)); + } finally { await f.close(); } +}); + +test("malformed bootstrap JSON passes byte-identical with original headers", async () => { + const body = Buffer.from("{ malformed JSON"); + const f = await fixture((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json", "Content-Length": String(body.length), ETag: "same" }); + res.end(body); + }); + try { + const received = await f.get("/edge-api/bootstrap"); + expect(received.body.equals(body)).toBe(true); + expect(received.headers.etag).toBe("same"); + } finally { await f.close(); } +}); + +test("untrusted upstream certificate yields an empty 502", async () => { + const f = await fixture((_req, res) => res.end("should not arrive"), { untrusted: true }); + try { + const received = await f.get("/v1/other"); + expect(received.status).toBe(502); + expect(received.body.length).toBe(0); + expect(f.logs).toEqual(["picker GET other 502"]); + } finally { await f.close(); } +}); + +test("WebSocket upgrade carries bytes in both directions", async () => { + const f = await fixture((_req, res) => res.end("ordinary")); + f.upstream.on("upgrade", (_req, socket) => { + socket.write("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"); + socket.on("data", data => socket.write(data)); + }); + try { + const result = await new Promise((resolve, reject) => { + const socket = tlsConnect({ host: "127.0.0.1", port: f.relay.port, servername: "claude.ai", ca: f.ca, + rejectUnauthorized: true }, () => { + socket.write("GET /api/ws/test HTTP/1.1\r\nHost: claude.ai\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"); + }); + let text = ""; + socket.on("data", chunk => { + text += chunk.toString(); + if (text.includes("\r\n\r\n") && !text.includes("echo-me")) socket.write("echo-me"); + if (text.includes("echo-me")) { socket.destroy(); resolve(text); } + }); + socket.on("error", reject); + }); + expect(result).toContain("101 Switching Protocols"); + expect(result).toContain("echo-me"); + } finally { await f.close(); } +}); + +test("ordinary request method, body and headers relay upstream", async () => { + let observed: { method?: string; header?: string; body: string } | undefined; + const f = await fixture((req, res) => { + const chunks: Buffer[] = []; + req.on("data", chunk => chunks.push(chunk)); + req.on("end", () => { + observed = { method: req.method, header: req.headers["x-test"] as string, + body: Buffer.concat(chunks).toString() }; + res.writeHead(201, { "Content-Type": "text/plain" }); + res.end("ok"); + }); + }); + try { + const received = await new Promise((resolve, reject) => { + const req = request({ host: "127.0.0.1", port: f.relay.port, servername: "claude.ai", ca: f.ca, + rejectUnauthorized: true, path: "/submit", method: "POST", agent: false, + headers: { Host: "claude.ai", "X-Test": "retained", "Content-Length": "7" } }, res => { + const chunks: Buffer[] = []; + res.on("data", chunk => chunks.push(chunk)); + res.on("end", () => resolve({ status: res.statusCode!, headers: res.headers, + rawHeaders: res.rawHeaders, body: Buffer.concat(chunks) })); + }); + req.on("error", reject); + req.end("payload"); + }); + expect(received.status).toBe(201); + expect(received.body.toString()).toBe("ok"); + expect(observed).toEqual({ method: "POST", header: "retained", body: "payload" }); + expect(f.logs).toEqual(["picker POST other 201"]); + } finally { await f.close(); } +}); diff --git a/tests/claude-integration/claude-picker-models.test.ts b/tests/claude-integration/claude-picker-models.test.ts new file mode 100644 index 00000000000..4f6561b9ab1 --- /dev/null +++ b/tests/claude-integration/claude-picker-models.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { claudeCodeNativeAlias } from "../../src/claude/alias"; +import { displayModelId } from "../../src/claude/desktop-3p"; +import { emptyDesktopProfile, moveDesktopRoute, reconcileDesktopProfile, renderDesktopProfile } from "../../src/claude/desktop-profile"; +import { buildPickerModels, createPickerModelSnapshot, type PickerRouteInput } from "../../src/claude/intercept/picker-models"; + +const routes: PickerRouteInput = { + nativeSlugs: ["gpt-6-sol"], + routedModels: [ + { provider: "xai", id: "grok-4.7", contextWindow: 128_000 }, + { provider: "anthropic", id: "claude-sonnet-4-6", contextWindow: 200_000 }, + { provider: "bad--provider", id: "unused" }, + ], +}; + +test("picker uses routable aliases, candidate labels and context, excluding native Anthropic rows", () => { + const rows = buildPickerModels(routes); + expect(rows).toContainEqual({ id: "ocx-claude-xai--grok-4.7", name: "Grok 4.7 (xai)", contextWindow: 128_000 }); + expect(rows).toContainEqual(expect.objectContaining({ id: claudeCodeNativeAlias("gpt-6-sol"), name: "GPT 6 Sol (native)" })); + expect(rows).toHaveLength(2); +}); + +test("picker profile order and labels follow the gateway renderer", () => { + const candidates = [ + { route: "native/gpt-6-sol", label: `${displayModelId("gpt-6-sol")} (native)` }, + { route: "xai/grok-4.7", label: `${displayModelId("grok-4.7")} (xai)`, contextWindow: 128_000 }, + ]; + const initial = reconcileDesktopProfile(emptyDesktopProfile(), candidates); + const profile = moveDesktopRoute(initial, "native/gpt-6-sol", "haiku", true); + const rendered = renderDesktopProfile(reconcileDesktopProfile(profile, candidates), candidates); + const rows = buildPickerModels({ ...routes, routedModels: routes.routedModels.slice(0, 1), profile }); + expect(rows.map(row => row.name)).toEqual(rendered.map(row => row.label)); + expect(rows[0]?.id).toBe("ocx-claude-xai--grok-4.7"); +}); + +test("snapshot persists mode 0600, loads synchronously, and retains last good on loader failure", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-picker-models-")); + try { + const path = join(dir, "claude-picker", "models.json"); + let fail = false; + const load = async () => { + if (fail) throw new Error("discovery unavailable"); + return routes; + }; + const first = createPickerModelSnapshot(load, path); + expect(first.current()).toBeNull(); + await first.refresh(); + const saved = first.current(); + expect(saved?.models).toHaveLength(2); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual(saved); + const restarted = createPickerModelSnapshot(load, path); + expect(restarted.current()).toEqual(saved); + fail = true; + await restarted.refresh(); + expect(restarted.current()).toEqual(saved); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/claude-integration/claude-picker-runtime.test.ts b/tests/claude-integration/claude-picker-runtime.test.ts new file mode 100644 index 00000000000..b7048bda01f --- /dev/null +++ b/tests/claude-integration/claude-picker-runtime.test.ts @@ -0,0 +1,356 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { connect, createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { applyDesktopFirstParty } from "../../src/claude/desktop-first-party"; +import { claudeDesktopIntegrationEnabled } from "../../src/codex/desired-state"; +import { pickerCaCertPath, pickerCaFingerprints, pickerStateDir } from "../../src/claude/intercept/picker-ca"; +import type { PickerListenerOptions } from "../../src/claude/intercept/picker-listener"; +import { + createPickerRuntime, + pickerDesired, + PICKER_MODELS_FILE, + type CreatePickerRuntimeOptions, + type PickerRuntime, +} from "../../src/claude/intercept/picker-runtime"; +import type { SecurityRunner } from "../../src/claude/intercept/picker-trust"; +import { getClaudePickerRuntime, startClaudeIntercept } from "../../src/claude/intercept/runtime"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root = ""; +const previous: Record = {}; +const ENV_KEYS = ["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "CLAUDE_CONFIG_DIR"] as const; +const running: PickerRuntime[] = []; +const LISTENER_PORT = 45_678; + +function config(extra: Partial = {}): OcxConfig { + return { port: 10100, providers: {}, defaultProvider: "openai", ...extra } as OcxConfig; +} +const firstParty = (extra: Partial = {}) => config({ claudeCode: { desktopMode: "first-party" }, ...extra }); + +/** Fake `security`: find-certificate lists the current picker CA while trusted; verify-cert follows. */ +function keychain(state: { trusted: boolean }) { + const calls: string[][] = []; + const run: SecurityRunner = async args => { + calls.push([...args]); + if (args[0] === "find-certificate") { + if (!state.trusted) return { code: 1, stdout: "", stderr: "" }; + const { sha1 } = pickerCaFingerprints(readFileSync(pickerCaCertPath(root), "utf8")); + return { code: 0, stdout: `SHA-1 hash: ${sha1}\n`, stderr: "" }; + } + if (args[0] === "verify-cert") return { code: state.trusted ? 0 : 1, stdout: "", stderr: "" }; + return { code: 0, stdout: "", stderr: "" }; + }; + return { run, calls }; +} + +function fakeListener(hold?: Promise) { + const state = { started: 0, closed: 0, models: null as PickerListenerOptions["models"] | null }; + const start = async (options: PickerListenerOptions) => { + state.started += 1; + state.models = options.models; + if (hold) await hold; + return { port: LISTENER_PORT, close: async () => { state.closed += 1; } }; + }; + return { state, start: start as unknown as NonNullable }; +} + +function runtime(overrides: Partial & { current?: () => OcxConfig } = {}): PickerRuntime { + const { current, ...rest } = overrides; + const created = createPickerRuntime({ + config: firstParty(), + readConfig: current ?? (() => firstParty()), + configDir: root, + loadRoutes: async () => ({ nativeSlugs: [], routedModels: [{ provider: "xai", id: "grok-4.7", contextWindow: 256_000 }] }), + platform: "darwin", + resolveMode: fresh => fresh.claudeCode?.desktopMode ?? "gateway", + trustTtlMs: 0, + refreshIntervalMs: 3_600_000, + ...rest, + }); + running.push(created); + return created; +} + +const INTERCEPT = { kind: "intercept", port: LISTENER_PORT }; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-picker-runtime-")); + for (const key of ENV_KEYS) previous[key] = process.env[key]; + process.env.OPENCODEX_HOME = root; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(root, "desktop-library"); + process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); +}); + +afterEach(async () => { + for (const created of running.splice(0)) await created.stop(); + for (const key of ENV_KEYS) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + removeTreeWithRetry(root); +}); + +describe("pickerDesired", () => { + test("needs macOS, first-party, Desktop intent and no explicit picker off", () => { + expect(pickerDesired(firstParty(), "first-party", "darwin")).toBe(true); + expect(pickerDesired(firstParty(), "gateway", "darwin")).toBe(false); + expect(pickerDesired(firstParty(), "first-party", "linux")).toBe(false); + expect(pickerDesired(firstParty(), "first-party", "win32")).toBe(false); + expect(pickerDesired(firstParty({ claudeCode: { desktopMode: "first-party", intercept: { picker: false } } }), "first-party", "darwin")).toBe(false); + expect(pickerDesired(firstParty({ claudeCode: { desktopMode: "first-party", intercept: { picker: true } } }), "first-party", "darwin")).toBe(true); + }); + + test("Desktop intent follows claudeDesktopIntegrationEnabled exactly", () => { + for (const clientIntegrations of [undefined, {}, { "claude-desktop": true }, { "claude-desktop": false }] as const) { + const input = firstParty({ clientIntegrations: clientIntegrations as OcxConfig["clientIntegrations"] }); + expect(pickerDesired(input, "first-party", "darwin")).toBe(claudeDesktopIntegrationEnabled(input)); + } + }); +}); + +describe("tunnel decision", () => { + test("the first claude.ai CONNECT after start() resolves is intercepted with no timer tick", async () => { + const trust = keychain({ trusted: true }); + const listener = fakeListener(); + const picker = runtime({ security: trust.run, startListener: listener.start }); + await picker.start(); + expect(picker.selectTunnel("claude.ai", 443)).toEqual(INTERCEPT); + expect(picker.selectTunnel("CLAUDE.AI", 443)).toEqual(INTERCEPT); + expect(picker.selectTunnel("api.anthropic.com", 443)).toBeNull(); + expect(picker.selectTunnel("claude.ai", 80)).toBeNull(); + expect(picker.selectTunnel("a.claude.ai", 443)).toBeNull(); + expect(picker.status()).toMatchObject({ desired: true, trust: "trusted", listenerReady: true, effective: true, reason: "active" }); + }); + + test("a claude.ai CONNECT during the first refresh waits for it and is intercepted", async () => { + const trust = keychain({ trusted: true }); + let release!: () => void; + const held = new Promise<"first-party">(resolve => { release = () => resolve("first-party"); }); + const picker = runtime({ security: trust.run, startListener: fakeListener().start, resolveMode: () => held }); + void picker.start(); + const pending = picker.selectTunnel("claude.ai", 443); + expect(pending).toBeInstanceOf(Promise); + release(); + expect(await pending).toEqual(INTERCEPT); + }); + + test("a first refresh held past the startup bound leaves that CONNECT blind", async () => { + const trust = keychain({ trusted: true }); + const picker = runtime({ security: trust.run, startListener: fakeListener().start, resolveMode: () => new Promise(() => {}), startupWaitMs: 20 }); + void picker.start(); + expect(await picker.selectTunnel("claude.ai", 443)).toEqual({ kind: "blind" }); + }); + + test("claude.ai stays blind until trusted and goes blind again when trust is lost", async () => { + const state = { trusted: false }; + const trust = keychain(state); + const picker = runtime({ security: trust.run, startListener: fakeListener().start }); + await picker.start(); + expect(picker.selectTunnel("claude.ai", 443)).toEqual({ kind: "blind" }); + expect(picker.status().reason).toBe("trust_untrusted"); + state.trusted = true; + await picker.refresh(); + expect(picker.selectTunnel("claude.ai", 443)).toEqual(INTERCEPT); + state.trusted = false; + await picker.refresh(); + expect(picker.selectTunnel("claude.ai", 443)).toEqual({ kind: "blind" }); + }); + + test("off macOS, in gateway mode or with the preference off it never intercepts or touches the keychain", async () => { + for (const overrides of [ + { platform: "linux" as const }, + { current: () => config({ claudeCode: { desktopMode: "gateway" } }) }, + { current: () => firstParty({ claudeCode: { desktopMode: "first-party", intercept: { picker: false } } }) }, + ]) { + const trust = keychain({ trusted: true }); + const listener = fakeListener(); + const picker = runtime({ security: trust.run, startListener: listener.start, ...overrides }); + await picker.start(); + expect(picker.selectTunnel("claude.ai", 443)).toEqual({ kind: "blind" }); + expect(trust.calls).toEqual([]); + expect(listener.state.started).toBe(0); + } + }); +}); + +describe("disarm latch and controller lock", () => { + test("disarm goes blind at once and only the owner's rearm clears it", async () => { + const trust = keychain({ trusted: true }); + const listener = fakeListener(); + const picker = runtime({ security: trust.run, startListener: listener.start }); + await picker.start(); + expect(picker.selectTunnel("claude.ai", 443)).toEqual(INTERCEPT); + picker.disarm(); + expect(picker.selectTunnel("claude.ai", 443)).toEqual({ kind: "blind" }); + await picker.refresh(); + await picker.ensureStarted(); + expect(picker.selectTunnel("claude.ai", 443)).toEqual({ kind: "blind" }); + expect(picker.status()).toMatchObject({ latched: true, reason: "disarmed" }); + await Bun.sleep(0); + expect(listener.state.closed).toBe(1); + await picker.rearm(); + expect(picker.selectTunnel("claude.ai", 443)).toEqual(INTERCEPT); + }); + + test("refresh never arms while the controller holds its lock; rearm bypasses the check", async () => { + const trust = keychain({ trusted: true }); + const picker = runtime({ security: trust.run, startListener: fakeListener().start, isBusy: () => true }); + await picker.start(); + expect(picker.selectTunnel("claude.ai", 443)).toEqual({ kind: "blind" }); + expect(picker.status().reason).toBe("busy"); + await picker.rearm(); + expect(picker.selectTunnel("claude.ai", 443)).toEqual(INTERCEPT); + }); + + test("a start still in flight when disarm lands never arms and its listener is closed", async () => { + const trust = keychain({ trusted: true }); + let release!: () => void; + const listener = fakeListener(new Promise(resolve => { release = resolve; })); + const picker = runtime({ security: trust.run, startListener: listener.start }); + const started = picker.start(); + while (listener.state.started === 0) await Bun.sleep(1); + picker.disarm(); + release(); + await started; + await Bun.sleep(0); + expect(picker.selectTunnel("claude.ai", 443)).toEqual({ kind: "blind" }); + expect(picker.status().listenerReady).toBe(false); + expect(listener.state.closed).toBe(1); + }); +}); + +describe("upgrade and restart", () => { + test("a pre-field install with owned first-party env keeps picker mode on by default", async () => { + const legacy = config(); + expect(applyDesktopFirstParty(legacy).ok).toBe(true); + const trust = keychain({ trusted: true }); + const picker = runtime({ security: trust.run, startListener: fakeListener().start, current: () => legacy, resolveMode: undefined }); + await picker.start(); + expect(picker.status()).toMatchObject({ desired: true, effective: true }); + expect(picker.selectTunnel("claude.ai", 443)).toEqual(INTERCEPT); + }); + + test("a snapshot persisted by one run feeds the first bootstrap after a restart before discovery", async () => { + const trust = keychain({ trusted: true }); + const first = runtime({ security: trust.run, startListener: fakeListener().start }); + await first.start(); + const persisted = join(pickerStateDir(root), PICKER_MODELS_FILE); + for (let i = 0; i < 200 && !existsSync(persisted); i += 1) await Bun.sleep(5); + const models = first.status().models; + expect(models).toBeGreaterThan(0); + await first.stop(); + + const listener = fakeListener(); + const second = runtime({ security: trust.run, startListener: listener.start, loadRoutes: () => new Promise(() => {}) }); + await second.start(); + const served = listener.state.models?.() ?? []; + expect(served.length).toBe(models); + expect(served.some(model => model.id.startsWith("ocx-claude-xai--"))).toBe(true); + expect(second.status().lastBootstrapAt).not.toBeNull(); + }); +}); + +async function canBind(port: number): Promise { + return new Promise(resolve => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.listen({ port, host: "127.0.0.1", exclusive: true }, () => server.close(() => resolve(true))); + }); +} + +async function freePortPair(): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + const port = 20_000 + Math.floor(Math.random() * 30_000); + if (await canBind(port) && await canBind(port + 1)) return port; + } + throw new Error("no free port pair"); +} + +const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Claude/2.7032.0"; + +function connectStatusLine(port: number, host: string, userAgent?: string): Promise { + return new Promise(resolve => { + let buffered = ""; + const socket = connect({ host: "127.0.0.1", port }, () => { + const ua = userAgent ? `User-Agent: ${userAgent}\r\n` : ""; + socket.write(`CONNECT ${host}:443 HTTP/1.1\r\nHost: ${host}:443\r\n${ua}\r\n`); + }); + const done = () => { socket.destroy(); resolve(buffered.split("\r\n")[0] ?? ""); }; + socket.on("data", chunk => { buffered += chunk.toString("latin1"); if (buffered.includes("\r\n")) done(); }); + socket.on("error", done); + setTimeout(done, 10_000); + }); +} + +describe("startClaudeIntercept wiring", () => { + function interceptOptions(port: number, createPicker: Parameters[0]["createPicker"]) { + return { + config: config({ claudeCode: { intercept: { port } } }), + publicPort: 10100, + configDir: root, + dispatch: async () => new Response("unused"), + loadPickerRoutes: async () => ({ nativeSlugs: [], routedModels: [] }), + createPicker, + }; + } + + for (const failure of ["construction", "start"] as const) { + test(`a picker ${failure} failure rejects the start and releases every port`, async () => { + const port = await freePortPair(); + const createPicker = failure === "construction" + ? () => { throw new Error("picker construction failed"); } + : () => ({ + selectTunnel: () => null, + start: async () => { throw new Error("picker start failed"); }, + stop: async () => {}, + }) as unknown as PickerRuntime; + await expect(startClaudeIntercept(interceptOptions(port, createPicker))).rejects.toThrow(`picker ${failure} failed`); + expect(await canBind(port)).toBe(true); + expect(await canBind(port + 1)).toBe(true); + expect(getClaudePickerRuntime()).toBeNull(); + }); + } + + test("only the app's browser tunnels on Desktop's egress proxy consult the picker", async () => { + const port = await freePortPair(); + const asked: string[] = []; + const fake = { + selectTunnel: (host: string) => { asked.push(host); return null; }, + start: async () => {}, + stop: async () => {}, + } as unknown as PickerRuntime; + const handle = await startClaudeIntercept(interceptOptions(port, () => fake)); + try { + expect(handle?.proxyPort).toBe(port); + expect(handle?.pickerProxyPort).toBe(port + 1); + expect(getClaudePickerRuntime()).toBe(fake); + // The Claude Code proxy never consults the picker, whoever connects. + expect(await connectStatusLine(port, "picker-probe.invalid", BROWSER_UA)).toContain("502"); + expect(asked).toEqual([]); + // Claude Code processes Desktop spawns reach the egress proxy without a User-Agent: claude.ai + // stays blind for them, and api.anthropic.com gets the intercept (a local listener, so 200). + expect(await connectStatusLine(port + 1, "picker-probe.invalid")).toContain("502"); + expect(await connectStatusLine(port + 1, "api.anthropic.com")).toContain("200"); + expect(asked).toEqual([]); + // The app's own tunnels carry its browser User-Agent and are the only ones the picker sees. + expect(await connectStatusLine(port + 1, "picker-probe.invalid", BROWSER_UA)).toContain("502"); + expect(asked).toEqual(["picker-probe.invalid"]); + // The User-Agent is a routing hint. A client that fakes a browser one only reaches the picker + // decision (claude.ai relay, which needs the keychain-trusted CA); one that omits it only + // reaches the api.anthropic.com intercept, which the Claude Code proxy already offers any + // local process, and its api.anthropic.com tunnel is not asked of the picker. + expect(await connectStatusLine(port + 1, "api.anthropic.com", "curl/8.7.1")).toContain("200"); + // An empty User-Agent is not a browser: blind, not asked. A faked browser one is only asked. + expect(await connectStatusLine(port + 1, "empty-ua.invalid", "")).toContain("502"); + expect(await connectStatusLine(port + 1, "spoofed-ua.invalid", `${BROWSER_UA} spoofed`)).toContain("502"); + expect(asked).toEqual(["picker-probe.invalid", "spoofed-ua.invalid"]); + } finally { + await handle?.stop(); + } + expect(getClaudePickerRuntime()).toBeNull(); + expect(await canBind(port + 1)).toBe(true); + }); +}); diff --git a/tests/claude-integration/claude-picker-trust.test.ts b/tests/claude-integration/claude-picker-trust.test.ts new file mode 100644 index 00000000000..72a74b5b4a8 --- /dev/null +++ b/tests/claude-integration/claude-picker-trust.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import { + inspectPickerTrust, loginKeychainPath, trustPickerCa, untrustPickerCa, + type SecurityResult, type SecurityRunner, +} from "../../src/claude/intercept/picker-trust"; +import { PICKER_CA_COMMON_NAME } from "../../src/claude/intercept/picker-ca"; + +const sha1 = "A".repeat(40); +const ok: SecurityResult = { code: 0, stdout: "", stderr: "" }; + +function fake(...results: SecurityResult[]): { run: SecurityRunner; calls: readonly string[][] } { + const calls: string[][] = []; + return { calls, run: async args => { + calls.push([...args]); + return results.shift() ?? ok; + } }; +} + +test("inspection requires the current root fingerprint and verifies the persisted leaf", async () => { + const f = fake({ ...ok, stdout: `SHA-1 hash: ${sha1}\n` }, ok); + expect(await inspectPickerTrust("/leaf.pem", sha1, f.run, "darwin")).toBe("trusted"); + expect(f.calls).toEqual([ + ["find-certificate", "-a", "-Z", "-c", PICKER_CA_COMMON_NAME, loginKeychainPath()], + ["verify-cert", "-q", "-L", "-c", "/leaf.pem", "-p", "ssl", "-n", "claude.ai", "-k", loginKeychainPath()], + ]); +}); + +test("missing or stale root never reaches leaf verification", async () => { + for (const stdout of ["", `SHA-1 hash: ${"B".repeat(40)}\n`]) { + const f = fake({ ...ok, stdout }); + expect(await inspectPickerTrust("/leaf.pem", sha1, f.run, "darwin")).toBe("untrusted"); + expect(f.calls).toHaveLength(1); + } +}); + +test("exit 1 is untrusted; other command failures are unknown", async () => { + const matched = { ...ok, stdout: `SHA-1 hash: ${sha1}\n` }; + expect(await inspectPickerTrust("/leaf.pem", sha1, fake(matched, { ...ok, code: 1 }).run, "darwin")).toBe("untrusted"); + expect(await inspectPickerTrust("/leaf.pem", sha1, fake(matched, { ...ok, code: 2 }).run, "darwin")).toBe("unknown"); + expect(await inspectPickerTrust("/leaf.pem", sha1, fake({ ...ok, code: 1 }).run, "darwin")).toBe("untrusted"); + expect(await inspectPickerTrust("/leaf.pem", sha1, fake({ ...ok, code: null }).run, "darwin")).toBe("unknown"); + expect(await inspectPickerTrust("/leaf.pem", sha1, async () => { throw new Error("failed"); }, "darwin")).toBe("unknown"); +}); + +test("trust and untrust pass the exact security argv", async () => { + const listed = { ...ok, stdout: `SHA-1 hash: ${sha1}\n` }; + const find = ["find-certificate", "-a", "-Z", "-c", PICKER_CA_COMMON_NAME, loginKeychainPath()]; + const f = fake(ok, listed, ok, ok, { ...ok, code: 1 }); + expect(await trustPickerCa("/ca.pem", f.run, "darwin")).toEqual({ ok: true }); + expect(await untrustPickerCa("/ca.pem", sha1, f.run, "darwin")).toEqual({ ok: true }); + expect(f.calls).toEqual([ + ["add-trusted-cert", "-r", "trustRoot", "-p", "ssl", "-s", "claude.ai", "-k", loginKeychainPath(), "/ca.pem"], + find, + ["remove-trusted-cert", "/ca.pem"], + ["delete-certificate", "-Z", sha1, loginKeychainPath()], + find, + ]); + expect(await trustPickerCa("/ca.pem", fake({ ...ok, code: 1 }).run, "darwin")) + .toEqual({ ok: false, reason: "declined_or_failed" }); +}); + +test("untrust is a no-op success when the current CA is not in the login keychain", async () => { + for (const found of [{ ...ok, code: 1 }, { ...ok, stdout: `SHA-1 hash: ${"B".repeat(40)}\n` }]) { + const f = fake(found); + expect(await untrustPickerCa("/ca.pem", sha1, f.run, "darwin")).toEqual({ ok: true }); + expect(f.calls.map(call => call[0])).toEqual(["find-certificate"]); + } + // A removal that leaves the certificate listed is not success. + const listed = { ...ok, stdout: `SHA-1 hash: ${sha1}\n` }; + expect(await untrustPickerCa("/ca.pem", sha1, fake(listed, ok, ok, listed).run, "darwin")).toEqual({ ok: false }); +}); + +test("non-darwin never invokes the runner", async () => { + const run: SecurityRunner = async () => { throw new Error("runner must stay idle"); }; + expect(await inspectPickerTrust("/leaf.pem", sha1, run, "linux")).toBe("unsupported"); + expect(await trustPickerCa("/ca.pem", run, "linux")).toEqual({ ok: false, reason: "unsupported" }); + expect(await untrustPickerCa("/ca.pem", sha1, run, "linux")).toEqual({ ok: false }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 82739a30cf8..ec3226a281b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -195,9 +195,19 @@ "claude-desktop-config-path.test.ts": "claude-integration", "claude-desktop-discovery.test.ts": "claude-integration", "claude-desktop-first-party.test.ts": "claude-integration", + "claude-desktop-first-party-guards.test.ts": "claude-integration", "claude-desktop-mode-explanation.test.ts": "claude-integration", + "claude-desktop-picker.test.ts": "claude-integration", + "claude-desktop-picker-profile.test.ts": "claude-integration", + "claude-desktop-picker-routes.test.ts": "claude-integration", "claude-desktop-native-context.test.ts": "claude-integration", "claude-desktop-policy.test.ts": "claude-integration", + "claude-picker-bootstrap.test.ts": "claude-integration", + "claude-picker-ca.test.ts": "claude-integration", + "claude-picker-listener.test.ts": "claude-integration", + "claude-picker-models.test.ts": "claude-integration", + "claude-picker-runtime.test.ts": "claude-integration", + "claude-picker-trust.test.ts": "claude-integration", "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", diff --git a/tests/providers/xai/grok-lifecycle.test.ts b/tests/providers/xai/grok-lifecycle.test.ts index e6e42c80759..ba4557d9ba7 100644 --- a/tests/providers/xai/grok-lifecycle.test.ts +++ b/tests/providers/xai/grok-lifecycle.test.ts @@ -58,7 +58,7 @@ describe("Grok fence lifecycle wiring", () => { const helper = sliceFn( ENSURE_SOURCE, "export async function ensureGrokFenceMatchesDesired(", - "export function ensureClaudeDesktopMatchesDesired(", + "export async function ensureClaudeDesktopMatchesDesired(", ); const ensureFn = sliceFn(CLI_SOURCE, "async function handleEnsure(", "async function handleTrayProxyStart("); @@ -76,14 +76,14 @@ describe("Grok fence lifecycle wiring", () => { test("ensure clears Claude Desktop residue when the durable switch is OFF", () => { const helper = sliceFn( ENSURE_SOURCE, - "export function ensureClaudeDesktopMatchesDesired(", + "export async function ensureClaudeDesktopMatchesDesired(", "Claude Desktop cleanup failed", ); const ensureFn = sliceFn(CLI_SOURCE, "async function handleEnsure(", "async function handleTrayProxyStart("); expect(helper).toContain("claudeDesktopIntegrationEnabled(config)"); expect(helper).toContain("deps.removeDesktop3pStandardPivot("); expect(helper.indexOf("deps.loadConfig()")).toBeLessThan(helper.indexOf("claudeDesktopIntegrationEnabled(config)")); - expect(ENSURE_SOURCE).toContain("ensureClaudeDesktopMatchesDesired(deps)"); + expect(ENSURE_SOURCE).toContain("await ensureClaudeDesktopMatchesDesired(deps)"); }); test("both ensure branches re-read persisted config after the in-flight await window", () => { From 74cd423e156b421913f7b2ab5a5d8c2e6962b1f0 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 13:32:47 +0900 Subject: [PATCH 21/48] feat(anthropic): keep Claude fast mode off until the provider opts in (#5727) * feat(anthropic): keep Claude fast mode off until the provider opts in Anthropic fast mode spends usage credits at 2x price. The anthropic and anthropic-apikey registry entries now mark their Fast lane opt-in, and a new providers..fastEnabled switch turns it on. While off, the models publish no Fast toggle or --fast row and the proxy never sends speed. The dashboard Models page gains an Off/On row on opt-in provider cards. Cursor Fast and every other provider are unchanged. * fix(providers): keep the Fast switch across a full provider save The provider edit form never sends fastEnabled, so a POST overwrite dropped an Anthropic Fast opt-in set from the Models page. Carry the live value when the request omits it, like upstreamWebsocket. * fix(gui): keep the Fast row on the confirmed value after a save The row read its value only from the provider summary, so it showed the old state until the catalog reload landed and a click on the stale option was ignored. Hold the confirmed save until the summary moves. Also qualify the structure note: the switch stops proxy-generated speed, while native passthrough still forwards a caller's own speed. * fix(gui): drop the Fast row override once the server confirms Clear the saved value as soon as the provider summary moves off the value the save started from, so a later change by another client is shown as-is. Name the model-level Fast toggle in the structure note. --- .../260924_anthropic_fast_opt_in/010_plan.md | 68 +++++++ .../docs/reference/configuration/providers.md | 13 +- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/models-groups.ts | 2 + gui/src/pages/Models.tsx | 3 + gui/src/pages/models-fast-row.tsx | 60 +++++++ scripts/test-layout/layout.json | 1 + src/config/load-degrade.ts | 2 + src/config/schema/leaf-validators.ts | 1 + src/providers/fast-opt-in.ts | 31 ++++ src/providers/model-rename-fields.ts | 1 + src/providers/registry/entries-core.ts | 3 + src/providers/registry/model-ids.ts | 1 + src/providers/registry/types.ts | 5 + src/providers/resolved-model-policy.ts | 12 +- src/providers/service-tier.ts | 13 +- src/router.ts | 4 + src/server/auth-cors.ts | 1 + src/server/management/provider-routes.ts | 14 ++ src/types/provider.ts | 7 + structure/providers-and-adapters.md | 2 +- .../anthropic/anthropic-fast-opt-in.test.ts | 168 ++++++++++++++++++ .../anthropic/anthropic-fast-speed.test.ts | 2 + tests/fixtures/test-layout-expected.json | 1 + ...responses-anthropic-fast-downgrade.test.ts | 2 + .../management-client-config-route.test.ts | 2 + 35 files changed, 421 insertions(+), 8 deletions(-) create mode 100644 devlog/_plan/260924_anthropic_fast_opt_in/010_plan.md create mode 100644 gui/src/pages/models-fast-row.tsx create mode 100644 src/providers/fast-opt-in.ts create mode 100644 tests/adapters/anthropic/anthropic-fast-opt-in.test.ts diff --git a/devlog/_plan/260924_anthropic_fast_opt_in/010_plan.md b/devlog/_plan/260924_anthropic_fast_opt_in/010_plan.md new file mode 100644 index 00000000000..58a8a63a87e --- /dev/null +++ b/devlog/_plan/260924_anthropic_fast_opt_in/010_plan.md @@ -0,0 +1,68 @@ +# Anthropic Fast opt-in (default off) — plan (wp1) + +## Loop spec (HOTL wp1) + +- Request (2026-09-24): Anthropic fast mode spends usage credits, so keep it off by default; give the + Anthropic provider card on the dashboard Models page one row that turns it on and off; open a PR and + merge it. Push, PR, and merge are authorized for this change. +- Previous unit: devlog/_plan/260923_anthropic_fast_speed (#5604) made `claude-opus-5-5`, `claude-opus-5`, + `claude-opus-4-8` Fast-eligible on `anthropic` and `anthropic-apikey` with no switch other than the + global `fastMode`. Its residual already named the cost: every turn on an account without credits pays a + refused round trip, and an account with credits is billed 2x without a per-provider choice. +- Branch `codex/anthropic-fast-default-off` from `origin/dev` 6b7a91f575. + +## Design + +- D1 Registry: `ProviderRegistryEntry.fastOptIn?: boolean`. `true` means the provider's Fast lane is billed + beyond the plan and stays off until the operator enables it. Set on `anthropic` and `anthropic-apikey`. + The FastWire, model map, and tier description stay as they are, so enabling restores #5604 exactly. +- D2 Config: `OcxProviderConfig.fastEnabled?: boolean`. `false` turns Fast off for any provider; + `true` satisfies an opt-in registry entry; absent means "registry default" (off for opt-in entries, + unchanged elsewhere). Zod provider schema accepts a boolean; auth-cors field policy classifies it `editor`. +- D3 Policy: `buildFastPolicyAuthority` (src/providers/service-tier.ts) resolves the switch from the configured + provider, then the enriched provider, then `getProviderRegistryEntry(name)?.fastOptIn` (looked up by name + regardless of transport match, because it can only turn Fast off). An off switch sets the provider + capability to `false`, which `resolveFastPolicy` already treats as a global denial: eligibility becomes + `capability-unsupported`, catalog Fast toggles and `--fast` rows disappear, `decideTier` drops, and the + adapter never emits `speed`. No new policy branch in fastwire.ts. +- D4 Management API: PATCH /api/providers accepts `fastEnabled` (boolean, or null to clear). GET /api/providers + adds `fastOptIn: { enabled }` only for opt-in registry entries so the dashboard knows where to draw the row. +- D5 Dashboard: a small `ProviderFastRow` component (own file, Models.tsx is 8 lines under its ratchet cap) + with the same Off/On segmented control as the new-model policy row, label "Fast mode" and a hint that it + uses usage credits at 2x price. Rendered in the provider body for providers whose summary carries + `fastOptIn`. PATCH then reload. i18n keys in all ten locales. +- D6 Docs/SoT: docs-site providers reference (Anthropic Fast section + field table row), structure + providers-and-adapters note. + +## Tests + +- New `tests/providers/anthropic-fast-opt-in.test.ts`: registry default ineligible for both entries; + `fastEnabled: true` restores eligible; `fastEnabled: false` denies an ordinary service-tier provider; + `catalogFastRowEligible` false by default; PATCH round-trip sets/clears the field and GET exposes + `fastOptIn`. +- Rewrite fixtures that assumed default-on (responses-anthropic-fast-downgrade, fast pricing, fastwire roster + if affected) to set `fastEnabled: true` deliberately. + +## Acceptance + +- C1 default ineligible / opt-in eligible (c-1). C2 Models row renders and PATCHes (c-2, screenshot). +- C3 typecheck, focused tests, test:changed, ratchet/layout, structure:check, privacy:scan, lint:gui, build:gui. +- C4 PR to dev with template, exact-head CI green, merged (c-3). + +## Residuals + +- Claude Messages native passthrough forwards a caller's own `speed` field (Claude Code /fast); that is the + caller's explicit choice and stays outside this switch. +## Audit fold (A, reviewer NEAR-PASS) + +- B1 folded: one helper `providerFastSwitchOff(name, provider)` (src/providers/fast-opt-in.ts) is applied in + three places: the FastPolicy authority (service-tier.ts), `resolveModelPolicy` (static supportsServiceTier + and fastTierDescription), and router registry enrichment, which writes `supportsServiceTier: false` on the + resolved runtime provider so nameless `fastPolicyForModel` callers also see the denial. +- B2 folded by the same enrichment write. +- B3: PATCH already clears the provider model cache and runs `convergeCodexCatalog` for any non-pacing + field; the policy test covers the PATCH round trip. Claude listings compute per request. +- B4: flipped fixtures set `fastEnabled: true` deliberately; load-degrade's inherited-fastWire warning skips + providers whose switch is off. +- Residual accepted: native Claude Messages passthrough forwards the caller's own `speed`; documented. +- Scope confirmed by user: Cursor unchanged; only anthropic and anthropic-apikey default off. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 2ea195aded2..6cb260fa128 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -205,6 +205,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `allowEncryptedV2AgentTasks?` | `boolean` | Disabled by default. Trust a direct key-auth `openai-responses` provider to consume or relay opaque encrypted V2 sub-agent tasks unchanged. Eligible routes skip `agentTaskRecovery`; all other routes keep the existing recovery or fail-closed behavior. OpenCodex does not decrypt, translate, or recover tasks sent through this opt-in. | | `upstreamWebsocket?` | `boolean` | Opt-in upstream Responses WebSocket transport for `openai-responses` requests (default false). Honored only for the first-party `https://api.openai.com/v1` upstream; custom-provider endpoints always use bounded HTTP/SSE because Bun cannot enforce an inbound WebSocket message limit before allocating the complete message. The canonical ChatGPT transport is unaffected. Plain HTTP remains on SSE; non-Responses paths and `openai-chat` requests stay on HTTP. | | `supportsServiceTier?` | `boolean` | Tri-state canonical Fast capability fallback. `true` publishes Fast in the catalog, satisfies service-tier routing requirements, contributes a supported fingerprint, and lets fast mode inject the provider's canonical wire value on a compatible final adapter. `false` strips the field and never injects, and exact model declarations cannot reopen it. Absent leaves the provider unclassified: fast mode does not inject or normalize a canonical caller value, and caller values obey the final wire's forwarding permission (`chatServiceTier` on Chat; passthrough on Responses). The registry classifies canonical OpenAI (`true`), DeepSeek, and Volcengine Ark (`false`); set it explicitly only for custom gateways that genuinely support tiers. | +| `fastEnabled?` | `boolean` | Operator switch for the provider's Fast lane. `false` turns Fast off (no Fast toggle, no `--fast` row, no fast wire field) and overrides `supportsServiceTier`. `true` enables a lane the registry marks opt-in. Absent keeps the registry default: off for `anthropic` and `anthropic-apikey`, whose fast mode spends usage credits at 2x price, and unchanged for every other provider. The dashboard Models page shows an Off/On row for opt-in providers. | | `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. Exact `true` enables canonical Fast for that model; exact `false` narrows provider defaults. An explicit provider-level `supportsServiceTier: false` remains fail-closed and cannot be reopened. Exact `true` does not authorize foreign caller-tier forwarding on Chat. Undeclared models fall back to provider-wide behavior. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | | `chatServiceTier?` | `boolean` | Provider-wide Chat-wire opt-in for forwarding caller `service_tier` values. On a classified route it governs foreign values such as `flex`, not proxy-owned canonical Fast after capability validation; on an unclassified route it governs every caller value because no Fast capability has been validated. Exact model capability does not authorize foreign forwarding. Responses routes retain their capability-based caller forwarding behavior. | | `promptCacheKey?` | `boolean` | Provider-wide `openai-chat` opt-in for forwarding a `prompt_cache_key`. The adapter forwards the key it is given and never invents one, but the key is not always the caller's: Claude Messages translation derives one from `metadata.user_id`, or from a model/system/tools cohort when no metadata is sent. Default off. Enable only when the upstream documents support, because strict gateways may reject the unknown field with HTTP 400. | @@ -562,7 +563,17 @@ Explicit capability `false` and Responses caller-tier forwarding retain their ex The built-in `anthropic` (stored Claude OAuth) and `anthropic-apikey` entries advertise Fast only for `claude-opus-5-5`, `claude-opus-5`, and `claude-opus-4-8`. Other Claude models are unclassified; -the entries have no provider-wide Fast default. An eligible `--fast` or Fast selector sends +the entries have no provider-wide Fast default. + +Anthropic Fast is **off by default** because it spends usage credits (subscription) or fast-mode +access (API) at 2x price. Turn it on per provider with the Off/On row on the dashboard Models page, +or set `providers.anthropic.fastEnabled: true` (or the same field on `anthropic-apikey`). While it is +off, those models publish no Fast toggle or `--fast` row and the proxy never sends `speed`, even with +a global `fastMode: true`. Claude Code native passthrough still forwards a `speed` field the caller +sends itself (for example Claude Code `/fast`), because that request never reaches the proxy's Fast +policy. Cursor Fast and other providers are unaffected. + +When enabled, an eligible `--fast` or Fast selector sends `speed: "fast"` and adds `fast-mode-2026-02-01` to the existing `anthropic-beta` header. The proxy preserves OAuth beta values and sends one deduplicated header. `fastMode: false` disables this injection. diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 0568c02bcd4..b50da156f74 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2948,6 +2948,7 @@ export const de: Record = { "dash.codexClientCompaction": "Clientseitige Komprimierung verwenden", "dash.codexClientCompactionHint": "Standardmäßig aus; nur für authentifiziertes Loopback-Routing. Künftige Komprimierungen speichern portable Klartext-Zusammenfassungen, während das OpenCodeX-Provider-Routing und die V2-Subagent-Zustellung aktiv bleiben; der konfigurierte Anbieter kann sie verarbeiten und Kontingent verbrauchen. Vorhandene ocx1-Verläufe müssen weiterhin wiederhergestellt werden. Codex nach einer Änderung neu starten.", "models.newPolicyGlobal": "Neue Modelle zunächst deaktivieren", "models.newPolicyProvider": "Richtlinie für neue Modelle", + "models.fastProvider": "Fast-Modus", "models.fastProviderHint": "Verbraucht Nutzungsguthaben zum doppelten Preis", "models.fastEnabled": "Fast-Modus an", "models.fastDisabled": "Fast-Modus aus", "models.fastSaveFailed": "Fast-Modus konnte nicht gespeichert werden", "models.newPolicy_inherit": "Übernehmen", "models.newPolicy_off": "Aus", "models.newPolicy_on": "An", "models.newBadge": "NEU", "models.newCount": "{count} neu, aus", "models.aliases": "Aliase", "models.aliasesTable": "Alias-Tabelle", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ef4f35ea59e..199e6e9abb2 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3022,6 +3022,7 @@ export const en = { "lab.layer.task_effectiveness": "Task effectiveness", "models.newPolicyGlobal": "New models start disabled", "models.newPolicyProvider": "New model policy", + "models.fastProvider": "Fast mode", "models.fastProviderHint": "Uses usage credits at 2x price", "models.fastEnabled": "Fast mode on", "models.fastDisabled": "Fast mode off", "models.fastSaveFailed": "Could not save Fast mode", "models.newPolicy_inherit": "Inherit", "models.newPolicy_off": "Off", "models.newPolicy_on": "On", "models.newBadge": "NEW", "models.newCount": "{count} new, off", "models.aliases": "Aliases", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e0b737bf887..4b87fe14749 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2937,6 +2937,7 @@ export const fr: Record = { "lab.layer.live_route_compatibility": "Compatibilité des routes en direct", "lab.layer.task_effectiveness": "Efficacité des tâches", "models.newPolicyGlobal": "Désactiver les nouveaux modèles par défaut", "models.newPolicyProvider": "Politique des nouveaux modèles", + "models.fastProvider": "Mode Fast", "models.fastProviderHint": "Consomme des crédits d'utilisation au double du prix", "models.fastEnabled": "Mode Fast activé", "models.fastDisabled": "Mode Fast désactivé", "models.fastSaveFailed": "Impossible d'enregistrer le mode Fast", "models.newPolicy_inherit": "Hériter", "models.newPolicy_off": "Désactivé", "models.newPolicy_on": "Activé", "models.newBadge": "NOUVEAU", "models.newCount": "{count} nouveaux, désactivés", "models.aliases": "Alias", "models.aliasesTable": "Table des alias", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a71d06d480b..29a825d3326 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2970,6 +2970,7 @@ export const ja: Record = { "dash.codexClientCompaction": "クライアント側コンパクションを使用", "dash.codexClientCompactionHint": "既定ではオフで、認証済みループバックルーティング専用です。今後のコンパクションは、OpenCodeX と V2 プロバイダーのルーティングを維持したまま移植可能な平文要約を保存します。設定済みプロバイダーが要約を処理し、割り当てを消費する場合があります。既存の ocx1 履歴は別途復旧が必要です。変更後は Codex を再起動してください。", "models.newPolicyGlobal": "新しいモデルを無効で追加", "models.newPolicyProvider": "新しいモデルのポリシー", + "models.fastProvider": "Fast モード", "models.fastProviderHint": "使用クレジットを 2 倍の料金で消費します", "models.fastEnabled": "Fast モードをオンにしました", "models.fastDisabled": "Fast モードをオフにしました", "models.fastSaveFailed": "Fast モードを保存できませんでした", "models.newPolicy_inherit": "継承", "models.newPolicy_off": "オフ", "models.newPolicy_on": "オン", "models.newBadge": "新着", "models.newCount": "新着 {count} 件、オフ", "models.aliases": "エイリアス", "models.aliasesTable": "エイリアス一覧", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 4680d1be92c..2e87cd5b5ff 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2970,6 +2970,7 @@ export const ko: Record = { "dash.codexClientCompaction": "클라이언트 측 컴팩션 사용", "dash.codexClientCompactionHint": "기본값은 꺼짐이며 인증된 루프백 라우팅에만 적용됩니다. 향후 컴팩션은 OpenCodeX 및 V2 제공자 라우팅을 유지하면서 이식 가능한 평문 요약을 저장합니다. 설정된 제공자가 요약을 처리하고 할당량을 사용할 수 있습니다. 기록은 건드리지 않으며, 기존 스레드는 OpenCodeX가 관리하는 openai_base_url override를 통해 프록시 경로를 유지합니다. 그 줄을 직접 설정해 두셨다면 그대로 보존하므로 해당 스레드는 설정하신 목적지를 따릅니다. 기존 ocx1 기록은 그대로 복구할 수 있고, 네이티브 Codex에서 해당 스레드를 재개하기 전에만 별도로 복구하세요. 변경 후 Codex를 다시 시작하세요.", "models.newPolicyGlobal": "새 모델을 비활성화 상태로 추가", "models.newPolicyProvider": "새 모델 정책", + "models.fastProvider": "Fast 모드", "models.fastProviderHint": "사용 크레딧을 2배 가격으로 소모합니다", "models.fastEnabled": "Fast 모드를 켰습니다", "models.fastDisabled": "Fast 모드를 껐습니다", "models.fastSaveFailed": "Fast 모드를 저장하지 못했습니다", "models.newPolicy_inherit": "상속", "models.newPolicy_off": "끔", "models.newPolicy_on": "켬", "models.newBadge": "신규", "models.newCount": "신규 {count}개, 꺼짐", "models.aliases": "별칭", "models.aliasesTable": "별칭 표", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 8f6ea78d16b..1279c60b099 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2971,6 +2971,7 @@ export const ru: Record = { "dash.codexClientCompaction": "Использовать сжатие на стороне клиента", "dash.codexClientCompactionHint": "По умолчанию выключено; только для аутентифицированной loopback-маршрутизации. Будущие сжатия сохраняют переносимые текстовые сводки, а маршрутизация OpenCodeX и V2 остаётся активной; настроенный провайдер может обрабатывать сводки и расходовать квоту. Существующую историю ocx1 всё равно нужно восстановить. После изменения перезапустите Codex.", "models.newPolicyGlobal": "Добавлять новые модели выключенными", "models.newPolicyProvider": "Политика новых моделей", + "models.fastProvider": "Режим Fast", "models.fastProviderHint": "Расходует кредиты использования по двойной цене", "models.fastEnabled": "Режим Fast включён", "models.fastDisabled": "Режим Fast выключен", "models.fastSaveFailed": "Не удалось сохранить режим Fast", "models.newPolicy_inherit": "Наследовать", "models.newPolicy_off": "Выкл.", "models.newPolicy_on": "Вкл.", "models.newBadge": "НОВАЯ", "models.newCount": "Новых: {count}, выкл.", "models.aliases": "Псевдонимы", "models.aliasesTable": "Таблица псевдонимов", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 0f61ebd8836..cef6fa07031 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2971,6 +2971,7 @@ export const tr: Record = { "dash.codexClientCompaction": "İstemci tarafı sıkıştırmayı kullan", "dash.codexClientCompactionHint": "Varsayılan olarak kapalıdır ve yalnızca kimliği doğrulanmış geri döngü yönlendirmesinde geçerlidir. Gelecekteki sıkıştırmalar, OpenCodeX ve V2 sağlayıcı yönlendirmesi etkin kalırken taşınabilir düz metin özetleri kaydeder; yapılandırılmış sağlayıcı bunları işleyip kotasını tüketebilir. Mevcut ocx1 geçmişi yine ayrıca kurtarılmalıdır. Değişiklikten sonra Codex’i yeniden başlatın.", "models.newPolicyGlobal": "Yeni modeller devre dışı başlasın", "models.newPolicyProvider": "Yeni model ilkesi", + "models.fastProvider": "Fast modu", "models.fastProviderHint": "Kullanım kredilerini 2 kat fiyatla harcar", "models.fastEnabled": "Fast modu açık", "models.fastDisabled": "Fast modu kapalı", "models.fastSaveFailed": "Fast modu kaydedilemedi", "models.newPolicy_inherit": "Devral", "models.newPolicy_off": "Kapalı", "models.newPolicy_on": "Açık", "models.newBadge": "YENİ", "models.newCount": "{count} yeni, kapalı", "models.aliases": "Takma adlar", "models.aliasesTable": "Takma ad tablosu", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 3b9d7f3d6fd..099ce7cafb1 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3137,6 +3137,7 @@ export const vi: Record = { "remote.event.error": "Lỗi", "models.newPolicyGlobal": "Model mới mặc định bị tắt", "models.newPolicyProvider": "Chính sách model mới", + "models.fastProvider": "Chế độ Fast", "models.fastProviderHint": "Dùng tín dụng sử dụng với giá gấp 2", "models.fastEnabled": "Đã bật chế độ Fast", "models.fastDisabled": "Đã tắt chế độ Fast", "models.fastSaveFailed": "Không thể lưu chế độ Fast", "models.newPolicy_inherit": "Kế thừa", "models.newPolicy_off": "Tắt", "models.newPolicy_on": "Bật", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 7089aa98c14..1c8d62080b6 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2934,6 +2934,7 @@ export const zhTW: Record = { "dash.codexClientCompaction": "使用用戶端壓縮", "dash.codexClientCompactionHint": "預設關閉,僅適用於已驗證的 loopback 路由。未來壓縮會儲存可攜的純文字摘要,同時保留 OpenCodeX 與 V2 提供方路由;已設定的提供方可能處理摘要並消耗其額度。既有 ocx1 歷程仍須另行復原。變更後請重新啟動 Codex。", "models.newPolicyGlobal": "新模型預設停用", "models.newPolicyProvider": "新模型策略", + "models.fastProvider": "Fast 模式", "models.fastProviderHint": "以 2 倍價格消耗用量額度", "models.fastEnabled": "已開啟 Fast 模式", "models.fastDisabled": "已關閉 Fast 模式", "models.fastSaveFailed": "無法儲存 Fast 模式", "models.newPolicy_inherit": "繼承", "models.newPolicy_off": "關閉", "models.newPolicy_on": "開啟", "models.newBadge": "新增", "models.newCount": "{count} 個新增,已關閉", "models.aliases": "別名", "models.aliasesTable": "別名表", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 651ce5be2d9..6d05e3854a8 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2969,6 +2969,7 @@ export const zh: Record = { "dash.codexClientCompaction": "使用客户端压缩", "dash.codexClientCompactionHint": "默认关闭,仅适用于已认证的 loopback 路由。未来压缩会保存可移植的明文摘要,同时保留 OpenCodeX 与 V2 提供方路由;已配置的提供方可能处理摘要并消耗其额度。已有 ocx1 历史仍需单独恢复。更改后请重启 Codex。", "models.newPolicyGlobal": "新模型默认停用", "models.newPolicyProvider": "新模型策略", + "models.fastProvider": "Fast 模式", "models.fastProviderHint": "按 2 倍价格消耗用量额度", "models.fastEnabled": "已开启 Fast 模式", "models.fastDisabled": "已关闭 Fast 模式", "models.fastSaveFailed": "无法保存 Fast 模式", "models.newPolicy_inherit": "继承", "models.newPolicy_off": "关闭", "models.newPolicy_on": "开启", "models.newBadge": "新增", "models.newCount": "{count} 个新增,已关闭", "models.aliases": "别名", "models.aliasesTable": "别名表", diff --git a/gui/src/models-groups.ts b/gui/src/models-groups.ts index 3e24aaf4598..d5716c5bdc1 100644 --- a/gui/src/models-groups.ts +++ b/gui/src/models-groups.ts @@ -30,6 +30,8 @@ export interface ConfiguredProviderSummary { modelContextWindows?: Record; discovery?: ProviderDiscoverySummary; entitlement?: ProviderEntitlementSummary; + /** Present only for providers whose Fast lane is opt-in (Anthropic fast mode). */ + fastOptIn?: { enabled: boolean }; } export interface ProviderModelGroup { diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index e142c781bcb..21c1100c8e9 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -32,6 +32,7 @@ import Combos from "./Combos"; import RoutingProfiles from "./RoutingProfiles"; import CompatibilityMatrix from "./CompatibilityMatrix"; import { ModelsTabStrip } from "./models-tab-strip"; +import { ProviderFastRow } from "./models-fast-row"; import { modelsPanelDomId, modelsTabDomId, @@ -1677,6 +1678,8 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c )} + {!nativeProviderGroup && p.name === provider)} apiBase={apiBase} + onSaved={(saved, message) => { publishFeedback(saved, message); if (saved) void load(true); }} />} {rows.length === 0 && ( )} diff --git a/gui/src/pages/models-fast-row.tsx b/gui/src/pages/models-fast-row.tsx new file mode 100644 index 00000000000..12145832be9 --- /dev/null +++ b/gui/src/pages/models-fast-row.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { readJsonOrThrow } from "../fetch-json"; +import { useT } from "../i18n/shared"; +import type { ConfiguredProviderSummary } from "../models-groups"; + +/** + * Off/On switch for a provider's opt-in Fast lane (Anthropic fast mode spends usage credits at 2x + * price, so it ships off). Drawn only when the server reports `fastOptIn` for the provider. + */ +export function ProviderFastRow({ summary, apiBase, onSaved }: { + summary: ConfiguredProviderSummary | undefined; + apiBase: string; + onSaved: (ok: boolean, message: string) => void; +}) { + const t = useT(); + const [busy, setBusy] = useState(false); + // The confirmed save wins until the reloaded summary moves off the value it was saved from, so + // the control never falls back to a stale summary while (or if) the catalog reload is pending. + const [saved, setSaved] = useState<{ value: boolean; from: boolean } | null>(null); + const reported = summary?.fastOptIn?.enabled; + // Once the summary moves off the value the save started from, the server has spoken; drop the + // override so a later change by another client is shown as-is (render-time reset, no effect). + if (saved && reported !== saved.from) setSaved(null); + if (!summary?.fastOptIn) return null; + const serverEnabled = summary.fastOptIn.enabled; + const enabled = saved && saved.from === serverEnabled ? saved.value : serverEnabled; + const save = async (next: boolean) => { + if (busy || next === enabled) return; + setBusy(true); + try { + const response = await fetch(`${apiBase}/api/providers?name=${encodeURIComponent(summary.name)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fastEnabled: next }), + }); + await readJsonOrThrow(response, t("models.fastSaveFailed")); + setSaved({ value: next, from: serverEnabled }); + onSaved(true, t(next ? "models.fastEnabled" : "models.fastDisabled")); + } catch (error) { + onSaved(false, error instanceof Error ? error.message : t("models.fastSaveFailed")); + } finally { + setBusy(false); + } + }; + return ( +
+ {t("models.fastProvider")} +
+ {([false, true] as const).map(mode => ( + + ))} +
+ {t("models.fastProviderHint")} +
+ ); +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 6df6f6de8c6..f729f1289df 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -208,6 +208,7 @@ "anthropic-eof-tolerance.test.ts": "adapters/anthropic", "anthropic-error-body.test.ts": "adapters/anthropic", "anthropic-error-stop-reason.test.ts": "adapters/anthropic", + "anthropic-fast-opt-in.test.ts": "adapters/anthropic", "anthropic-fast-speed.test.ts": "adapters/anthropic", "anthropic-hardening.test.ts": "adapters/anthropic", "anthropic-image-guard.test.ts": "adapters/anthropic", diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts index 0c336f7d9f8..a1619e1afcf 100644 --- a/src/config/load-degrade.ts +++ b/src/config/load-degrade.ts @@ -16,6 +16,7 @@ import { isValidProviderName } from "./provider-name"; import { MODEL_ALIAS_PATTERN } from "../providers/default-aliases"; import { MODEL_DISCOVERY_MAX_MODELS } from "../providers/model-discovery-limits"; import { getProviderRegistryEntry, providerMatchesRegistryTransport, registryModelServiceTierCapabilityApplies } from "../providers/registry"; +import { providerFastSwitchOff } from "../providers/fast-opt-in"; import { isCodexReasoningEffort } from "../reasoning-effort"; import { refreshConfigDerivedRegistries } from "./derived-registries"; import { type OcxClaudeCodeConfig, type OcxConfig } from "../types"; @@ -835,6 +836,7 @@ export function inheritedFastWireConflictProviderNames( const conflicts: string[] = []; for (const [name, provider] of Object.entries(config.providers)) { if (provider.fastWire !== null || provider.supportsServiceTier === false) continue; + if (providerFastSwitchOff(name, provider)) continue; const registry = providerMatchesRegistryTransport(name, provider) ? getProviderRegistryEntry(name) : undefined; diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 6e78dd577d9..75976b171e5 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -292,6 +292,7 @@ export const providerConfigSchema = z.object({ annotateEmptyToolOutputs: z.boolean().optional(), foldDeveloperRoleToSystem: z.boolean().optional(), fastWire: fastWireSchema.nullable().optional(), + fastEnabled: z.boolean().optional(), supportsServiceTier: z.boolean().optional(), modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(), diff --git a/src/providers/fast-opt-in.ts b/src/providers/fast-opt-in.ts new file mode 100644 index 00000000000..c4bf32419a3 --- /dev/null +++ b/src/providers/fast-opt-in.ts @@ -0,0 +1,31 @@ +import type { OcxProviderConfig } from "../types"; +import { getProviderRegistryEntry } from "./registry"; +import type { ProviderRegistryEntry } from "./registry/types"; + +/** + * Whether a provider's Fast lane is switched off. + * + * `fastEnabled: false` turns Fast off on any provider. A registry entry marked `fastOptIn` bills its + * Fast lane beyond the plan (Anthropic fast mode draws usage credits at 2x price), so it stays off + * until the operator sets `fastEnabled: true`. Off is expressed as provider capability `false`, + * which every Fast consumer already treats as a global denial. + * + * The registry entry is matched by name without a transport check on purpose: this can only turn + * Fast off, so a custom endpoint that reuses the name loses nothing it could safely keep. + */ +export function fastSwitchOff( + provider: Pick, + entry: Pick | undefined, +): boolean { + if (provider.fastEnabled === false) return true; + if (provider.fastEnabled === true) return false; + return entry?.fastOptIn === true; +} + +/** `fastSwitchOff` with the registry entry looked up by provider name. */ +export function providerFastSwitchOff( + providerName: string | undefined, + provider: Pick, +): boolean { + return fastSwitchOff(provider, providerName ? getProviderRegistryEntry(providerName) : undefined); +} diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index e3e0ca7ec01..171a20f7539 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -25,6 +25,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { requiresPairedResponsesToolResults: "none", annotateEmptyToolOutputs: "none", supportsServiceTier: "none", + fastEnabled: "none", modelSupportsServiceTier: "record", preserveResponsesReasoningContent: "none", modelReasoningEffortsAuthoritative: "none", diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index f4f4b5c3c5c..6b91a3f860f 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -475,9 +475,11 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // Claude fast mode on the subscription lane (Claude Code `/fast`): the OAuth route accepts // `speed` and gates it on account entitlement (usage credits / org enablement), probed live // 2026-09-23 (devlog/_plan/260923_anthropic_fast_speed/020_probe-evidence.md). + // Off until the operator opts in: fast mode draws usage credits at 2x price. fastWire: ANTHROPIC_FAST_WIRE, modelSupportsServiceTier: { ...ANTHROPIC_FAST_MODELS }, fastTierDescription: ANTHROPIC_FAST_TIER_DESCRIPTION, + fastOptIn: true, }, { id: "anthropic-apikey", @@ -500,6 +502,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ fastWire: ANTHROPIC_FAST_WIRE, modelSupportsServiceTier: { ...ANTHROPIC_FAST_MODELS }, fastTierDescription: ANTHROPIC_FAST_TIER_DESCRIPTION, + fastOptIn: true, }, { id: "kimi", diff --git a/src/providers/registry/model-ids.ts b/src/providers/registry/model-ids.ts index 219aea75ae1..2de8eda3f20 100644 --- a/src/providers/registry/model-ids.ts +++ b/src/providers/registry/model-ids.ts @@ -83,6 +83,7 @@ export const REGISTRY_FIELD_MODEL_ID_ROLES = { modelSupportsServiceTier: RECORD_KEYS, keyAuthServiceTier: KEY_AUTH_SERVICE_TIER, fastTierDescription: NONE, + fastOptIn: NONE, modelServiceTierCapabilityBaseUrlGuard: NONE, preserveResponsesReasoningContent: NONE, dropResponsesReasoningItems: NONE, diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts index 9a4abd38919..d59d7c87d0a 100644 --- a/src/providers/registry/types.ts +++ b/src/providers/registry/types.ts @@ -257,6 +257,11 @@ export interface ProviderRegistryEntry { }; /** Provider-specific copy for the Codex catalog's Fast tier. */ fastTierDescription?: string; + /** + * The Fast lane is billed beyond the plan, so it stays off until the operator sets + * `providers..fastEnabled: true` (see `providerFastSwitchOff`). + */ + fastOptIn?: boolean; /** * Registry-only destination guard for `modelSupportsServiceTier`. This scopes vendor evidence * without changing provider ownership, routing, authentication, or config validation. diff --git a/src/providers/resolved-model-policy.ts b/src/providers/resolved-model-policy.ts index 2689e8c0370..e2cf956e9d4 100644 --- a/src/providers/resolved-model-policy.ts +++ b/src/providers/resolved-model-policy.ts @@ -2,6 +2,7 @@ import type { ModelCapabilities, OcxProviderConfig } from "../types"; import { MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter } from "../types"; import { isCanonicalOpenAiForwardProvider } from "./openai-tiers"; import { resolveProviderAuthTransport } from "./fastwire"; +import { fastSwitchOff } from "./fast-opt-in"; import { registryEntrySupportsLiveModelDiscovery } from "./static-model-discovery"; import type { InboundWire, ProviderRegistryEntry, ResponsesTerminalRepairPolicy } from "./registry/types"; import { @@ -187,7 +188,10 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode legacyClinePassLadder || provider.reasoningEfforts === undefined ? entry?.reasoningEfforts ? "registry" : "unknown" : "operator"); put("chatServiceTier", provider.chatServiceTier ?? keyAuthDefaults?.chatServiceTier ?? entry?.chatServiceTier, provider.chatServiceTier !== undefined ? "operator" : keyAuthDefaults?.chatServiceTier !== undefined ? "registry" : entry?.chatServiceTier !== undefined ? "registry" : "unknown"); - put("supportsServiceTier", provider.supportsServiceTier ?? keyAuthDefaults?.supportsServiceTier ?? entry?.supportsServiceTier, + // The Fast switch overrides every capability source; see providerFastSwitchOff. + const fastOff = fastSwitchOff(provider, input.registryEntry); + if (fastOff) put("supportsServiceTier", false, provider.fastEnabled === false ? "operator" : "registry"); + else put("supportsServiceTier", provider.supportsServiceTier ?? keyAuthDefaults?.supportsServiceTier ?? entry?.supportsServiceTier, provider.supportsServiceTier !== undefined ? "operator" : keyAuthDefaults?.supportsServiceTier !== undefined ? "registry" : entry?.supportsServiceTier !== undefined ? "registry" : "unknown"); if (entry && !registryEntrySupportsLiveModelDiscovery(entry)) put("liveModels", false, "registry"); putMergedMap("modelDisplayNames", entry?.modelDisplayNames, provider.modelDisplayNames); @@ -325,7 +329,9 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode const modelSupportsReasoningSummaries = modelValue(providerPolicy.modelSupportsReasoningSummaries) ?? (modelValue(providerPolicy.modelReasoningSummaryDelivery) !== undefined ? true : undefined); const modelSupportsVerbosity = modelValue(providerPolicy.modelSupportsVerbosity) ?? providerPolicy.supportsVerbosity; - const modelSupportsServiceTier = modelValue(providerPolicy.modelSupportsServiceTier) ?? providerPolicy.supportsServiceTier; + const modelSupportsServiceTier = fastOff + ? false + : modelValue(providerPolicy.modelSupportsServiceTier) ?? providerPolicy.supportsServiceTier; const model: ResolvedPerModelStaticPolicy = { adapter, ...(modelContextWindow !== undefined ? { contextWindow: modelContextWindow } : {}), @@ -380,7 +386,7 @@ export function resolveModelPolicy(input: ResolveModelPolicyInput): ResolvedMode } modelProvenance.supportsVerbosity = modelOrProviderSource(provider.modelSupportsVerbosity, entry?.modelSupportsVerbosity, provider.supportsVerbosity, entry?.supportsVerbosity); const exactServiceTierSource = modelSource(provider.modelSupportsServiceTier, registryServiceTierDefaults); - modelProvenance.supportsServiceTier = exactServiceTierSource !== "unknown" ? exactServiceTierSource + modelProvenance.supportsServiceTier = !fastOff && exactServiceTierSource !== "unknown" ? exactServiceTierSource : providerProvenance.supportsServiceTier ?? "unknown"; modelProvenance.responsesUpstreamStreaming = model.responsesUpstreamStreaming === undefined ? "unknown" : "registry"; modelProvenance.responsesTerminalRepair = model.responsesTerminalRepair === undefined ? "unknown" : "registry"; diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts index 9a89bc57c6e..c435da8d6f6 100644 --- a/src/providers/service-tier.ts +++ b/src/providers/service-tier.ts @@ -15,6 +15,7 @@ import { type FastPolicyAuthority, type ResolvedFastPolicy, } from "./fastwire"; +import { providerFastSwitchOff } from "./fast-opt-in"; /** OpenAI-compatible adapters that can carry the standard `service_tier` field. */ export const SERVICE_TIER_ADAPTERS = new Set(["openai-chat", "openai-responses"]); @@ -35,6 +36,7 @@ type ServiceTierCapabilityProvider = Pick< | "apiKeyTransport" | "chatServiceTier" | "fastWire" + | "fastEnabled" >; function cloneRegistryWireDefaults( @@ -83,9 +85,14 @@ function buildFastPolicyAuthority( && registryModelServiceTierCapabilityApplies(registry, capabilityProvider) ? registry.modelSupportsServiceTier : undefined; - const providerCapability = capabilityProvider.supportsServiceTier - ?? keyAuthDefaults?.supportsServiceTier - ?? registry?.supportsServiceTier; + const fastSwitchOff = providerFastSwitchOff(providerName, { + fastEnabled: capabilityProvider.fastEnabled ?? provider.fastEnabled, + }); + const providerCapability = fastSwitchOff + ? false + : capabilityProvider.supportsServiceTier + ?? keyAuthDefaults?.supportsServiceTier + ?? registry?.supportsServiceTier; const authority: FastPolicyAuthority = Object.freeze({ providerAdapter: provider.adapter, providerAuthMode: provider.authMode ?? registry?.authKind ?? "key", diff --git a/src/router.ts b/src/router.ts index ceedf9378be..97fad209f8e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -26,6 +26,7 @@ import { import { registryModelIdKeys } from "./providers/registry/model-ids"; import { applyDirectReasoningEffortContracts, hasLegacyClinePassReasoningEfforts } from "./providers/derive"; import { cloneFastWire } from "./providers/fastwire"; +import { fastSwitchOff } from "./providers/fast-opt-in"; import { providerMatchesRegistryTransportWithStaticGuards, providerSupportsLiveModelDiscovery, @@ -393,6 +394,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(provider.supportsServiceTier === undefined && registryEntry.supportsServiceTier !== undefined ? { supportsServiceTier: registryEntry.supportsServiceTier } : {}), + // An off Fast switch is a provider-wide denial on the runtime provider, so a Fast policy + // resolved without the provider name still refuses (providerFastSwitchOff). + ...(fastSwitchOff(provider, registryEntry) ? { supportsServiceTier: false } : {}), // Registry-only web-search capability: without this backfill a saved provider row reaches // the Responses adapter with the flag `undefined`, so the capability gate added in #2262 // reads "unclassified" and forwards Codex's OpenAI-only `web_search` config fields. xAI diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index bb5c05e10d2..72cb3e2c7db 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -950,6 +950,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { mcpMaxResultBytes: "editor", modelAdapters: "editor", fastWire: "editor", + fastEnabled: "editor", baseUrl: "editor", responsesPath: "editor", chatCompletionsPath: "editor", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 02150cab03d..fd5c743aae2 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -450,6 +450,13 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "fastEnabled")) { + const value = rawBody.fastEnabled; + if (value === null) delete next.fastEnabled; + else if (typeof value === "boolean") next.fastEnabled = value; + else return { error: "fastEnabled must be a boolean or null" }; + touched = true; + } if (Object.hasOwn(rawBody, "xaiResponsesOptIn")) { if (name !== "xai") return { error: "xaiResponsesOptIn is valid only for provider xai" }; if (typeof rawBody.xaiResponsesOptIn !== "boolean") { @@ -942,6 +949,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; /** diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 4b37c35334d..b5b10648e2e 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -47,7 +47,7 @@ only canonical Fable, Opus, or Sonnet labels after removing terminal controls; u | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | | `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | -| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. | +| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | diff --git a/tests/adapters/anthropic/anthropic-fast-opt-in.test.ts b/tests/adapters/anthropic/anthropic-fast-opt-in.test.ts new file mode 100644 index 00000000000..3c2332c52d9 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-fast-opt-in.test.ts @@ -0,0 +1,168 @@ +import { afterAll, describe, expect, setDefaultTimeout, 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 { inheritedFastWireConflictProviderNames } from "../../../src/config/load-degrade"; +import { fastSwitchOff, providerFastSwitchOff } from "../../../src/providers/fast-opt-in"; +import { getProviderRegistryEntry } from "../../../src/providers/registry"; +import { decideTier } from "../../../src/providers/fastwire"; +import { fastPolicyForModel } from "../../../src/providers/service-tier"; +import { captureRouteStaticPolicy, routedProviderConfig } from "../../../src/router"; +import { startServer } from "../../../src/server"; +import { catalogFastRowEligible } from "../../../src/server/fast-row"; +import type { OcxConfig, OcxProviderConfig } from "../../../src/types"; +import { managementFetch as fetch } from "../../helpers/management-auth"; +import { removeTreeWithRetry } from "../../helpers/remove-tree"; + +/** + * Anthropic fast mode draws usage credits at 2x price, so the `anthropic` and `anthropic-apikey` + * registry entries mark their Fast lane opt-in: it stays off until `fastEnabled: true` + * (devlog/_plan/260924_anthropic_fast_opt_in). + */ + +setDefaultTimeout(60_000); + +const FAST_MODEL = "claude-opus-5-5"; + +function anthropic(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "key", + apiKey: "test-token", + models: [FAST_MODEL], + ...overrides, + } as OcxProviderConfig; +} + +describe("Anthropic Fast opt-in", () => { + test("both Anthropic registry entries are opt-in and Cursor is not", () => { + expect(getProviderRegistryEntry("anthropic")?.fastOptIn).toBe(true); + expect(getProviderRegistryEntry("anthropic-apikey")?.fastOptIn).toBe(true); + expect(getProviderRegistryEntry("cursor")?.fastOptIn).toBeUndefined(); + expect(getProviderRegistryEntry("openai")?.fastOptIn).toBeUndefined(); + }); + + test("the switch reads the operator value before the registry default", () => { + expect(providerFastSwitchOff("anthropic-apikey", {})).toBe(true); + expect(providerFastSwitchOff("anthropic-apikey", { fastEnabled: true })).toBe(false); + expect(providerFastSwitchOff("cursor", {})).toBe(false); + expect(providerFastSwitchOff("cursor", { fastEnabled: false })).toBe(true); + expect(providerFastSwitchOff(undefined, {})).toBe(false); + expect(fastSwitchOff({}, { fastOptIn: true })).toBe(true); + }); + + test("default off: the Fast policy denies, drops forced fast mode, and publishes no --fast row", () => { + for (const name of ["anthropic", "anthropic-apikey"]) { + const policy = fastPolicyForModel(anthropic(), FAST_MODEL, name); + expect(policy).toMatchObject({ capability: false, eligibility: "capability-unsupported" }); + expect(decideTier(policy, true, "priority")).toEqual({ kind: "drop" }); + const config = { providers: { [name]: anthropic() } } as unknown as OcxConfig; + expect(catalogFastRowEligible(config, { provider: name, id: FAST_MODEL })).toBe(false); + } + }); + + test("fastEnabled: true restores the documented fast lane and its speed value", () => { + const policy = fastPolicyForModel(anthropic({ fastEnabled: true }), FAST_MODEL, "anthropic-apikey"); + expect(policy).toMatchObject({ capability: true, eligibility: "eligible" }); + expect(decideTier(policy, true, undefined)).toEqual({ kind: "set", value: "fast" }); + const config = { providers: { "anthropic-apikey": anthropic({ fastEnabled: true }) } } as unknown as OcxConfig; + expect(catalogFastRowEligible(config, { provider: "anthropic-apikey", id: FAST_MODEL })).toBe(true); + // Unsupported models stay unclassified even when the switch is on. + expect(fastPolicyForModel(anthropic({ fastEnabled: true }), "claude-sonnet-5", "anthropic-apikey").eligibility) + .toBe("unclassified"); + }); + + test("fastEnabled: false denies an ordinary service-tier provider", () => { + const relay = { adapter: "openai-responses", baseUrl: "https://relay.example/v1", supportsServiceTier: true } as OcxProviderConfig; + expect(fastPolicyForModel(relay, "gpt-x", "relay").eligibility).toBe("eligible"); + expect(fastPolicyForModel({ ...relay, fastEnabled: false }, "gpt-x", "relay").eligibility) + .toBe("capability-unsupported"); + }); + + test("static model policy and the routed provider carry the denial", () => { + const off = captureRouteStaticPolicy("anthropic-apikey", FAST_MODEL, anthropic()); + expect(off.model.supportsServiceTier).toBe(false); + expect(off.model.fastTierDescription).toBeUndefined(); + const on = captureRouteStaticPolicy("anthropic-apikey", FAST_MODEL, anthropic({ fastEnabled: true })); + expect(on.model.supportsServiceTier).toBe(true); + + // A policy resolved without the provider name still refuses on the routed provider. + const routed = routedProviderConfig("anthropic-apikey", anthropic()); + expect(routed.supportsServiceTier).toBe(false); + expect(fastPolicyForModel(routed, FAST_MODEL).eligibility).toBe("capability-unsupported"); + expect(routedProviderConfig("anthropic-apikey", anthropic({ fastEnabled: true })).supportsServiceTier).toBeUndefined(); + }); + + test("an off switch is not reported as a fastWire=null conflict", () => { + const config = { providers: { "anthropic-apikey": anthropic({ fastWire: null }) } } as unknown as OcxConfig; + expect(inheritedFastWireConflictProviderNames(config)).toEqual([]); + const enabled = { providers: { "anthropic-apikey": anthropic({ fastWire: null, fastEnabled: true }) } } as unknown as OcxConfig; + expect(inheritedFastWireConflictProviderNames(enabled)).toEqual(["anthropic-apikey"]); + }); +}); + +describe("Anthropic Fast switch over the management API", () => { + const previousHome = process.env.OPENCODEX_HOME; + const testDir = mkdtempSync(join(tmpdir(), "ocx-anthropic-fast-opt-in-")); + afterAll(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(testDir); + }); + + test("GET reports the opt-in switch and PATCH sets, clears, and rejects fastEnabled", async () => { + process.env.OPENCODEX_HOME = testDir; + saveConfig({ + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "relay", + providers: { + relay: { adapter: "openai-chat", baseUrl: "https://relay.example/v1", apiKey: "sk-relay" }, + "anthropic-apikey": anthropic(), + }, + } as OcxConfig); + const server = startServer(0); + const patch = (body: unknown) => fetch(new URL("/api/providers?name=anthropic-apikey", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const summary = async (name: string) => { + const response = await fetch(new URL("/api/providers", server.url)); + const rows = await response.json() as Array<{ name: string; fastOptIn?: { enabled: boolean } }>; + return rows.find(row => row.name === name); + }; + try { + expect((await summary("anthropic-apikey"))?.fastOptIn).toEqual({ enabled: false }); + expect(await summary("relay")).not.toHaveProperty("fastOptIn"); + + const reject = await patch({ fastEnabled: "yes" }); + expect(reject.status).toBe(400); + expect(await reject.json()).toMatchObject({ error: "fastEnabled must be a boolean or null" }); + + expect((await patch({ fastEnabled: true })).status).toBe(200); + expect(loadConfig().providers["anthropic-apikey"]?.fastEnabled).toBe(true); + expect((await summary("anthropic-apikey"))?.fastOptIn).toEqual({ enabled: true }); + + // A full provider save from the edit form omits the PATCH-owned switch and keeps it. + const overwrite = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "anthropic-apikey", + provider: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "key", note: "edited" }, + }), + }); + expect(overwrite.status).toBe(200); + expect(loadConfig().providers["anthropic-apikey"]?.fastEnabled).toBe(true); + + expect((await patch({ fastEnabled: null })).status).toBe(200); + expect(loadConfig().providers["anthropic-apikey"]).not.toHaveProperty("fastEnabled"); + expect((await summary("anthropic-apikey"))?.fastOptIn).toEqual({ enabled: false }); + } finally { + await server.stop(true); + } + }); +}); diff --git a/tests/adapters/anthropic/anthropic-fast-speed.test.ts b/tests/adapters/anthropic/anthropic-fast-speed.test.ts index cbfede59563..3aa7a5a1deb 100644 --- a/tests/adapters/anthropic/anthropic-fast-speed.test.ts +++ b/tests/adapters/anthropic/anthropic-fast-speed.test.ts @@ -16,6 +16,8 @@ function provider(authMode: "key" | "oauth" = "key", overrides: Partial { baseUrl: "https://api.anthropic.com", authMode: provider === "anthropic" ? "oauth" : "key", liveModels: false, + // Anthropic Fast is opt-in; enable it so the export roster includes the --fast selectors. + fastEnabled: true, }, }, } as unknown as OcxConfig; From 8b562bfc7998767a61e15685564763c1e87ba58a Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 13:46:13 +0900 Subject: [PATCH 22/48] test(codex): share one Bun transpiler cache across write-lock children (#5732) The home-environment cases give every child a fresh fake HOME, and Bun keeps its runtime transpiler cache under the HOME-derived cache directory, so each holder re-transpiled the whole lock graph and the describe's warm-up paid for nothing. On windows-latest those holders took 5-19 s against 1-4 s for the same child with the ambient home, and "case 0" crossed INTERNAL_DEADLINE_MS twice in run 35953803435 after a new test file shifted it to the front of its batch. Pin BUN_RUNTIME_TRANSPILER_CACHE_PATH to one per-process directory for the warm-up and every child. The lock identity under test reads only HOME and USERPROFILE, so no assertion changes. --- .../codex-write-lock.test.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/codex-integration/codex-write-lock.test.ts b/tests/codex-integration/codex-write-lock.test.ts index a92fc302a21..0b810e1f0cf 100644 --- a/tests/codex-integration/codex-write-lock.test.ts +++ b/tests/codex-integration/codex-write-lock.test.ts @@ -8,7 +8,7 @@ * caller told to retry something that will fail identically forever is how a UI * spins on a problem only the user can fix. */ -import { afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { resolveCodexCoordinatorDatabasePath, @@ -285,16 +285,35 @@ describe("two real processes contend for one lock", () => { */ const childPath = helperPath("codex-write-lock-child.ts"); + /* + * Bun keeps its runtime transpiler cache under the user's home cache directory unless + * BUN_RUNTIME_TRANSPILER_CACHE_PATH names one: HOME on macOS/Linux (a child with a fresh HOME + * wrote a new Library/Caches/bun/@t@ of 4.1 MB on macOS) and USERPROFILE on Windows. The + * home-environment cases below give the children fake HOME/USERPROFILE directories that the + * warm-up never populated, so each of them re-transpiled the whole lock graph and the warm-up + * paid for nothing: on windows-latest those holders took 5-19 s + * against 1-4 s for the same child with the ambient home, and "case 0" crossed + * INTERNAL_DEADLINE_MS twice in run 35953803435. The lock identity under test reads only + * HOME/USERPROFILE, so sharing Bun's own cache changes no assertion. + */ + const transpilerCache = mkdtempSync(join(tmpdir(), "ocx-write-lock-transpiler-")); + const sharedCacheEnv = { BUN_RUNTIME_TRANSPILER_CACHE_PATH: transpilerCache }; + // This describe's first spawned child pays the cold codex write-lock helper graph. // Load that graph during setup so its readiness bound measures lock behavior alone. beforeAll(async () => { - await warmModuleGraph({ graph: "codex-write-lock-child", entry: childPath }); + await warmModuleGraph({ graph: "codex-write-lock-child", entry: childPath, env: sharedCacheEnv }); }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + afterAll(() => { + removeTreeWithRetry(transpilerCache); + }); + function spawnChild(payload: Record) { return Bun.spawn(["bun", childPath], { env: { ...process.env, + ...sharedCacheEnv, CODEX_HOME: codexHome, // N is the lock under test. Give the child processes in this case their // own C database so unrelated files in the same Bun batch cannot make a @@ -312,6 +331,7 @@ describe("two real processes contend for one lock", () => { return Bun.spawn(["bun", childPath], { env: { ...process.env, + ...sharedCacheEnv, CODEX_HOME: codexHome, OPENCODEX_HOME: join(root, ".opencodex"), ...env, From 359616ef200e10ea04948237003b9df4f212698e Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 14:09:53 +0900 Subject: [PATCH 23/48] test(claude): skip POSIX mode checks for picker files on Windows (#5734) The Desktop picker tests added in #5728 assert 0o600 on the profile, state and model snapshot files. Windows reports 0o666 for any writable file, so windows 3/9 and 4/9 failed on dev (ci.yml lane=all run 35957063545) with "Expected: 384, Received: 438". Guard the three assertions the same way claude-picker-ca.test.ts guards its key file. --- .../claude-desktop-picker-profile.test.ts | 6 ++++-- tests/claude-integration/claude-picker-models.test.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/claude-integration/claude-desktop-picker-profile.test.ts b/tests/claude-integration/claude-desktop-picker-profile.test.ts index 19479d99e67..c8043ba1b66 100644 --- a/tests/claude-integration/claude-desktop-picker-profile.test.ts +++ b/tests/claude-integration/claude-desktop-picker-profile.test.ts @@ -72,7 +72,9 @@ describe("Claude Desktop picker profile", () => { expect(first).toMatchObject({ ok: true, changed: true }); if (!first.ok) throw new Error(first.reason); expect(readFileSync(first.path, "utf8")).toBe('{"egressProxyUrl":"http://127.0.0.1:41234"}\n'); - expect(statSync(first.path).mode & 0o777).toBe(0o600); + // POSIX permission bits only: Windows reports 0o666 for any writable file (ACLs carry the + // real protection), the same exemption claude-picker-ca.test.ts makes for its key file. + if (process.platform !== "win32") expect(statSync(first.path).mode & 0o777).toBe(0o600); const metadata = readJson(join(library, "_meta.json")); expect(metadata.foreignMeta).toBe(true); expect(Object.keys(metadata).filter(key => key.toLowerCase().includes("opencodex"))).toEqual([]); @@ -80,7 +82,7 @@ describe("Claude Desktop picker profile", () => { expect(metadata.appliedId).toBe(picker.id); const statePath = join(configDir, "claude-picker", "profile-state.json"); expect(readJson(statePath)).toEqual({ entryId: picker.id, previousAppliedId: previous }); - expect(statSync(statePath).mode & 0o777).toBe(0o600); + if (process.platform !== "win32") expect(statSync(statePath).mode & 0o777).toBe(0o600); expect(inspectDesktopPickerProfile({ env: env(), configDir })).toMatchObject({ kind: "applied", proxyUrl: "http://127.0.0.1:41234" }); expect(applyDesktopPickerProfile({ proxyPort: 41234, env: env(), configDir })).toMatchObject({ ok: true, changed: false, path: first.path }); diff --git a/tests/claude-integration/claude-picker-models.test.ts b/tests/claude-integration/claude-picker-models.test.ts index 4f6561b9ab1..5ea768f6cc0 100644 --- a/tests/claude-integration/claude-picker-models.test.ts +++ b/tests/claude-integration/claude-picker-models.test.ts @@ -50,7 +50,8 @@ test("snapshot persists mode 0600, loads synchronously, and retains last good on await first.refresh(); const saved = first.current(); expect(saved?.models).toHaveLength(2); - expect(statSync(path).mode & 0o777).toBe(0o600); + // POSIX permission bits only; Windows reports 0o666 for any writable file. + if (process.platform !== "win32") expect(statSync(path).mode & 0o777).toBe(0o600); expect(JSON.parse(readFileSync(path, "utf8"))).toEqual(saved); const restarted = createPickerModelSnapshot(load, path); expect(restarted.current()).toEqual(saved); From 8cb20f7ce86342cfa77af3009354121c267496d1 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 14:31:44 +0900 Subject: [PATCH 24/48] fix(claude-intercept): picker CA trust that Desktop accepts, with IP names excluded (#5731) * fix(claude-intercept): trust the picker CA without a host policy string Chromium skips keychain trust settings that carry a policy string, so the claude.ai-scoped trust left Claude Desktop rejecting the picker leaf while verify-cert reported it trusted. The CA's critical name constraints already limit it to claude.ai. * fix(claude-intercept): exclude IP names from the picker CA and replace host-scoped trust A DNS-only permitted subtree leaves the iPAddress form unconstrained, so a leaf with an IP SAN signed by the picker CA would chain for any address. The picker CA now also carries excludedSubtrees for every IPv4 and IPv6 address, and a persisted CA without that exclusion is rotated on reload. Trust added by the first build carried the claude.ai policy string, which verify-cert honours and Chromium skips. inspectPickerTrust now reads the user trust settings and reports such a CA as untrusted, so the trust step replaces the setting instead of leaving Desktop on ERR_CERT_AUTHORITY_INVALID. * fix(claude-intercept): report picker trust unknown when trust settings cannot be read An unreadable trust-settings export could hide the host-scoped setting Chromium skips, and arming on it cuts Desktop off from claude.ai. The inspection now returns unknown, which never arms; the enable path re-adds trust and checks again. Test fakes write an export with no picker entry. * docs(devlog): show the IP exclusion in the picker CA name-constraints sketch --- .../000_plan.md | 2 +- .../020_wp3_picker_core.md | 41 +++++++++++---- src/claude/intercept/local-ca.ts | 38 +++++++++++--- src/claude/intercept/picker-ca.ts | 13 ++++- src/claude/intercept/picker-trust.ts | 40 +++++++++++++-- structure/clients/claude-desktop.md | 8 ++- .../claude-desktop-picker-routes.test.ts | 1 + .../claude-desktop-picker.test.ts | 10 +++- .../claude-picker-ca.test.ts | 50 +++++++++++++++++-- .../claude-picker-runtime.test.ts | 3 +- .../claude-picker-trust.test.ts | 50 +++++++++++++++++-- 11 files changed, 224 insertions(+), 32 deletions(-) diff --git a/devlog/_plan/260924_claude_desktop_picker_mode/000_plan.md b/devlog/_plan/260924_claude_desktop_picker_mode/000_plan.md index 5fe499d77be..0d7b3b00549 100644 --- a/devlog/_plan/260924_claude_desktop_picker_mode/000_plan.md +++ b/devlog/_plan/260924_claude_desktop_picker_mode/000_plan.md @@ -64,7 +64,7 @@ Decision IDs come from the architect proposal; dispositions are main's. | --- | --- | --- | | D1 | Default `gateway`; resolver takes observations (owned first-party settings applied/stale → legacy first-party) with precedence explicit → observed gateway → gateway fingerprint → owned first-party settings → gateway. Implicit applies persist the preserved mode. `/api/sync` stops writing a gateway profile when the resolved mode is first-party. | Accepted. Status stays read-only. Reflections r1/r2: the selected owned gateway row joins the observation; the intercept-disabled fallback is removed, so an observed first-party install keeps its mode and an apply with the intercept disabled is refused with `intercept_disabled`. | | D2 | One owner for the risk text (`src/claude/desktop-risk.ts`), `riskWarning` in status, CLI apply/status, native toggle response, dashboard selector + active card, 10 locales, 8 guides; default badge moves to gateway. | Accepted. | -| D3 | Separate picker CA under `/claude-picker/`, critical nameConstraints permitting `claude.ai`, leaf SAN `claude.ai` only; macOS `security add-trusted-cert -r trustRoot -p ssl -s claude.ai -k `, trust checked with `security verify-cert -q -L -c -p ssl -n claude.ai`, removal with `security remove-trusted-cert`. | Accepted, amended: tests use a fake command runner; no real keychain in CI. Reflection r1 gap 4 folded: "trusted" also requires the login keychain to hold a certificate whose SHA-1 equals the current picker CA (`security find-certificate -a -Z -c `), and `verify-cert` searches that keychain (`-k`). Audit r1 blocker 1 folded: the claim is narrowed to "`claude.ai` and its subdomains" (an RFC 5280 dNSName subtree cannot be exact), and it is only claimed for verifiers shown to enforce it: a Bun/BoringSSL test rejects an off-host leaf issued by the picker CA, and wp5 runs Apple `verify-cert` on an ephemeral off-host leaf after trust; whatever that shows is what the PR states. The primary control stays the 0600 key that never leaves the machine. | +| D3 | Separate picker CA under `/claude-picker/`, critical nameConstraints permitting `claude.ai` and excluding every IP address, leaf SAN `claude.ai` only; macOS `security add-trusted-cert -r trustRoot -p ssl -k ` (the first build passed `-s claude.ai`; #5731 dropped it because Chromium skips host-scoped trust), trust checked with `security verify-cert -q -L -c -p ssl -n claude.ai`, removal with `security remove-trusted-cert`. | Accepted, amended: tests use a fake command runner; no real keychain in CI. Reflection r1 gap 4 folded: "trusted" also requires the login keychain to hold a certificate whose SHA-1 equals the current picker CA (`security find-certificate -a -Z -c `), and `verify-cert` searches that keychain (`-k`). Audit r1 blocker 1 folded: the claim is narrowed to "`claude.ai` and its subdomains" (an RFC 5280 dNSName subtree cannot be exact), and it is only claimed for verifiers shown to enforce it: a Bun/BoringSSL test rejects an off-host leaf issued by the picker CA, and wp5 runs Apple `verify-cert` on an ephemeral off-host leaf after trust; whatever that shows is what the PR states. The primary control stays the 0600 key that never leaves the machine. | | D4 | CONNECT decides per connection: `messages` (api.anthropic.com), `picker` (claude.ai, only when desired + first-party effective + listener ready + cached trust matches the CA fingerprint), else `blind`. Trust cache invalidated on toggle/rotation, rechecked on a bounded interval. | Accepted. | | D5 | Dedicated `node:https` HTTP/1.1 terminator for claude.ai with `request` and `upgrade` handlers, fixed upstream `claude.ai:443`, verified TLS, raw headers and bodies streamed. | Accepted after a spike on Bun 1.4.0 (`/private/tmp/ocx-picker-spike/spike.ts`): gzip bytes, two `Set-Cookie` headers and a WebSocket upgrade passed through unchanged. | | D6 | Rewrite only GET bootstrap responses (`/edge-api/bootstrap`, `/edge-api/bootstrap/{org}/app_start`, `/api/bootstrap…`) with a `code` surface; clone a selectable Claude entry per route; decode gzip/br/deflate under compressed and decompressed caps; fail open with the original bytes. | Accepted, amended: the bootstrap request's `accept-encoding` is narrowed to `gzip, deflate, br` so zstd never arrives. Reflection r1 gap 6 partly folded: clones drop `fast_mode` and every version-gate key (`/version/i`) with the presentation fields; `thinking` and `capabilities` stay because the intercept translates effort and handles images for routed models. | diff --git a/devlog/_plan/260924_claude_desktop_picker_mode/020_wp3_picker_core.md b/devlog/_plan/260924_claude_desktop_picker_mode/020_wp3_picker_core.md index ff6fb25d6e5..2affdd4c9da 100644 --- a/devlog/_plan/260924_claude_desktop_picker_mode/020_wp3_picker_core.md +++ b/devlog/_plan/260924_claude_desktop_picker_mode/020_wp3_picker_core.md @@ -31,17 +31,29 @@ egress profile; with no profile applied, none of this code sees Desktop traffic. const OID = { + nameConstraints: "2.5.29.30", … -+export interface AuthorityOptions { commonName: string; permittedDnsNames?: readonly string[] } ++export interface AuthorityOptions { ++ commonName: string; ++ permittedDnsNames?: readonly string[]; ++ excludeAllIpAddresses?: boolean; // default true (#5731) ++} ++ ++/** iPAddress bases (address + mask, all zero) covering every IPv4 and every IPv6 address. */ ++export const ALL_IP_ADDRESS_BASES: readonly Uint8Array[] = [new Uint8Array(8), new Uint8Array(32)]; + -+/** RFC 5280 NameConstraints with permittedSubtrees of dNSName bases only. */ -+function nameConstraints(permitted: readonly string[]): Uint8Array { ++/** RFC 5280 NameConstraints: permittedSubtrees of dNSName bases, excludedSubtrees of every IP. */ ++function nameConstraints(permitted: readonly string[], excludeAllIpAddresses: boolean): Uint8Array { + const subtrees = permitted.map(name => sequence(contextTag(2, new TextEncoder().encode(name), false))); -+ return sequence(contextTag(0, concat(...subtrees))); ++ const excluded = ALL_IP_ADDRESS_BASES.map(base => sequence(contextTag(7, base, false))); ++ return sequence( ++ contextTag(0, concat(...subtrees)), ++ ...(excludeAllIpAddresses ? [contextTag(1, concat(...excluded))] : []), ++ ); +} + +export function createCertificateAuthority(options: AuthorityOptions): LocalInterceptCa { …same body as + createLocalInterceptCa, with commonName from options and, when permittedDnsNames is non-empty, -+ extension(OID.nameConstraints, true, nameConstraints(options.permittedDnsNames)) … } ++ extension(OID.nameConstraints, true, nameConstraints(options.permittedDnsNames, ++ options.excludeAllIpAddresses !== false)) … } +export function issueServerLeaf(ca: LocalInterceptCa, issuerCommonName: string, hosts: readonly string[]): PemKeyPair -export function createLocalInterceptCa(): LocalInterceptCa { … +export function createLocalInterceptCa(): LocalInterceptCa { @@ -74,10 +86,13 @@ export function issuePickerLeaf(ca: PickerCa, configDir: string): PemKeyPair; // Reload validation (audit wp3 r1, High). The shared loader only checks CA status, key pairing and self-signature, so `ensurePersistedAuthority` gains `accept?: (cert: X509Certificate) => boolean`, and `ensurePickerCa` passes one that requires subject CN `PICKER_CA_COMMON_NAME` and a **critical** -nameConstraints extension whose permittedSubtrees hold exactly one dNSName, `claude.ai`, and no -excludedSubtrees. A persisted CA that fails it (for example a valid, key-matching CA without -constraints) is regenerated under the lease. The new fingerprint makes trust `untrusted` until the -operator trusts it again, so an unconstrained root is never loaded and trusted as the picker CA. +nameConstraints extension whose permittedSubtrees hold exactly one dNSName, `claude.ai`, and +whose excludedSubtrees hold exactly two iPAddress bases, all-zero IPv4 (8 bytes) and all-zero IPv6 +(32 bytes), so no IP-address leaf chains to it (PR #5731 review: a DNS-only permitted list leaves the +iPAddress form unconstrained). A persisted CA that fails it (for example a valid, key-matching CA +without constraints, or the first claude.ai-only format) is regenerated under the lease. The new +fingerprint makes trust `untrusted` until the operator trusts it again, so an unconstrained root is +never loaded and trusted as the picker CA. ## picker-trust.ts @@ -91,9 +106,15 @@ export async function inspectPickerTrust(leafPath: string, caSha1: string, run?: // darwin only; "trusted" needs both: // 1. ["find-certificate", "-a", "-Z", "-c", PICKER_CA_COMMON_NAME, loginKeychainPath()] lists caSha1 (the current CA) // 2. ["verify-cert", "-q", "-L", "-c", leafPath, "-p", "ssl", "-n", "claude.ai", "-k", loginKeychainPath()] exits 0 +// 3. ["trust-settings-export", ] does not show kSecTrustSettingsPolicyString in the +// caSha1 entry (a host-scoped setting from an earlier build; Chromium skips it, so it counts +// as untrusted and the trust step replaces it); an unreadable export → unknown, so the picker +// never arms on a setting it could not inspect // missing record or exit 1 → untrusted; a runner failure → unknown export async function trustPickerCa(caPath: string, run?: SecurityRunner, platform?: NodeJS.Platform): Promise<{ ok: boolean; reason?: "unsupported" | "declined_or_failed" }>; -// ["add-trusted-cert", "-r", "trustRoot", "-p", "ssl", "-s", "claude.ai", "-k", loginKeychainPath(), caPath] +// ["add-trusted-cert", "-r", "trustRoot", "-p", "ssl", "-k", loginKeychainPath(), caPath] +// (no "-s claude.ai": found in the live proof, Chromium skips host-scoped trust settings and +// Desktop failed with ERR_CERT_AUTHORITY_INVALID; the name constraints do the scoping) export async function untrustPickerCa(caPath: string, fingerprintSha1: string, run?: SecurityRunner, platform?: NodeJS.Platform): Promise<{ ok: boolean }>; // ["remove-trusted-cert", caPath] then ["delete-certificate", "-Z", fingerprintSha1, loginKeychainPath()] ``` diff --git a/src/claude/intercept/local-ca.ts b/src/claude/intercept/local-ca.ts index 772555dcc81..79f8063d385 100644 --- a/src/claude/intercept/local-ca.ts +++ b/src/claude/intercept/local-ca.ts @@ -113,10 +113,21 @@ function extension(oid: string, critical: boolean, value: Uint8Array): Uint8Arra : sequence(objectIdentifier(oid), octetString(value)); } -/** RFC 5280 permittedSubtrees: each GeneralSubtree has one dNSName base. */ -function nameConstraints(permitted: readonly string[]): Uint8Array { +/** iPAddress bases (address + mask, all zero) that cover every IPv4 and every IPv6 address. */ +export const ALL_IP_ADDRESS_BASES: readonly Uint8Array[] = [new Uint8Array(8), new Uint8Array(32)]; + +/** + * RFC 5280 NameConstraints. permittedSubtrees holds one dNSName base per name. A DNS-only permitted + * list leaves other name forms unconstrained, so excludedSubtrees names every IPv4 and IPv6 + * address unless the caller opts out. + */ +function nameConstraints(permitted: readonly string[], excludeAllIpAddresses: boolean): Uint8Array { const subtrees = permitted.map(name => sequence(contextTag(2, new TextEncoder().encode(name), false))); - return sequence(contextTag(0, concat(...subtrees))); + const excluded = ALL_IP_ADDRESS_BASES.map(base => sequence(contextTag(7, base, false))); + return sequence( + contextTag(0, concat(...subtrees)), + ...(excludeAllIpAddresses ? [contextTag(1, concat(...excluded))] : []), + ); } function subjectPublicKeyInfo(key: KeyObject): Uint8Array { @@ -183,6 +194,8 @@ export interface LocalInterceptCa extends PemKeyPair { export interface AuthorityOptions { commonName: string; permittedDnsNames?: readonly string[]; + /** With permittedDnsNames: also exclude every IP address (default true). */ + excludeAllIpAddresses?: boolean; } export function createCertificateAuthority(options: AuthorityOptions): LocalInterceptCa { @@ -200,7 +213,7 @@ export function createCertificateAuthority(options: AuthorityOptions): LocalInte extension(OID.keyUsage, true, bitString(Uint8Array.of(0x06), 1)), extension(OID.subjectKeyIdentifier, false, octetString(keyIdentifier(publicKey))), ...(options.permittedDnsNames?.length - ? [extension(OID.nameConstraints, true, nameConstraints(options.permittedDnsNames))] + ? [extension(OID.nameConstraints, true, nameConstraints(options.permittedDnsNames, options.excludeAllIpAddresses !== false))] : []), ], }); @@ -216,7 +229,17 @@ export function createLocalInterceptCa(): LocalInterceptCa { return createCertificateAuthority({ commonName: CLAUDE_INTERCEPT_CA_COMMON_NAME }); } -/** Issue a serverAuth leaf for `hosts` (first entry becomes the CN; all become SAN dNSNames). */ +/** IPv4 literal to its four octets, or null. Only the leaf SAN encoder needs it. */ +function ipv4Octets(host: string): Uint8Array | null { + const parts = host.split("."); + if (parts.length !== 4 || !parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return null; + return Uint8Array.from(parts.map(Number)); +} + +/** + * Issue a serverAuth leaf for `hosts` (first entry becomes the CN). Names become SAN dNSNames and + * IPv4 literals become iPAddress entries. + */ export function issueServerLeaf(ca: LocalInterceptCa, issuerCommonName: string, hosts: readonly string[]): PemKeyPair { if (hosts.length === 0) throw new Error("intercept leaf requires at least one host"); const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); @@ -232,7 +255,10 @@ export function issueServerLeaf(ca: LocalInterceptCa, issuerCommonName: string, extension(OID.keyUsage, true, bitString(Uint8Array.of(0x80), 7)), extension(OID.extendedKeyUsage, false, sequence(objectIdentifier(OID.serverAuth))), extension(OID.subjectAltName, false, sequence( - ...hosts.map(host => contextTag(2, new TextEncoder().encode(host), false)), + ...hosts.map(host => { + const octets = ipv4Octets(host); + return octets ? contextTag(7, octets, false) : contextTag(2, new TextEncoder().encode(host), false); + }), )), extension(OID.authorityKeyIdentifier, false, sequence(contextTag(0, keyIdentifier(ca.publicKey), false))), ], diff --git a/src/claude/intercept/picker-ca.ts b/src/claude/intercept/picker-ca.ts index 77ddfd8c8a6..e1885b1acb1 100644 --- a/src/claude/intercept/picker-ca.ts +++ b/src/claude/intercept/picker-ca.ts @@ -2,6 +2,7 @@ import { createHash, X509Certificate } from "node:crypto"; import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { + ALL_IP_ADDRESS_BASES, ensurePersistedAuthority, issueServerLeaf, type LocalInterceptCa, @@ -59,7 +60,17 @@ function constrainedToPickerHost(value: Buffer): boolean { const root = readDer(value, 0); if (!root || root.tag !== 0x30 || root.next !== value.length) return false; const fields = children(root.body); - if (!fields || fields.length !== 1 || fields[0]!.tag !== 0xa0) return false; + // permittedSubtrees [0] with exactly claude.ai, excludedSubtrees [1] with every IPv4 and IPv6 address. + if (!fields || fields.length !== 2 || fields[0]!.tag !== 0xa0 || fields[1]!.tag !== 0xa1) return false; + const excluded = children(fields[1]!.body); + if (!excluded || excluded.length !== ALL_IP_ADDRESS_BASES.length) return false; + const excludesAllIps = excluded.every((subtree, index) => { + if (subtree.tag !== 0x30) return false; + const base = children(subtree.body); + return !!base && base.length === 1 && base[0]!.tag === 0x87 + && base[0]!.body.equals(Buffer.from(ALL_IP_ADDRESS_BASES[index]!)); + }); + if (!excludesAllIps) return false; const subtrees = children(fields[0]!.body); if (!subtrees || subtrees.length !== 1 || subtrees[0]!.tag !== 0x30) return false; const subtree = children(subtrees[0]!.body); diff --git a/src/claude/intercept/picker-trust.ts b/src/claude/intercept/picker-trust.ts index 3ad0343d657..5a2b9c29eb0 100644 --- a/src/claude/intercept/picker-trust.ts +++ b/src/claude/intercept/picker-trust.ts @@ -1,4 +1,5 @@ -import { homedir } from "node:os"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { PICKER_CA_COMMON_NAME, PICKER_HOST } from "./picker-ca"; @@ -28,6 +29,32 @@ function hasFingerprint(output: string, expected: string): boolean { }); } +/** + * Whether the current CA's user trust settings carry a policy string (a host scope such as the + * `-s claude.ai` earlier builds used). verify-cert honours those, but Chromium skips them, so + * Desktop would reject the picker leaf; the CA then counts as untrusted and trust is added again, + * which replaces the setting. `null` when the settings cannot be read; the caller then reports + * `unknown`, because arming on a setting Chromium skips would cut Desktop off from claude.ai. + */ +async function hostScopedTrust(caSha1: string, run: SecurityRunner): Promise { + let dir: string | undefined; + try { + dir = mkdtempSync(join(tmpdir(), "ocx-picker-trust-")); + const file = join(dir, "trust-settings.plist"); + if ((await run(["trust-settings-export", file])).code !== 0) return null; + const xml = readFileSync(file, "utf8"); + const at = xml.indexOf(`${caSha1.replace(/:/g, "").toUpperCase()}`); + if (at < 0) return false; + // The entry's trustSettings array holds flat dictionaries, so its first ends it. + const end = xml.indexOf("", at); + return xml.slice(at, end < 0 ? undefined : end).includes("kSecTrustSettingsPolicyString"); + } catch { // no-excuse-ok: catch -- unreadable trust settings are no evidence of a host scope. + return null; + } finally { + if (dir) rmSync(dir, { recursive: true, force: true }); + } +} + export async function inspectPickerTrust( leafPath: string, caSha1: string, @@ -43,7 +70,11 @@ export async function inspectPickerTrust( if (!hasFingerprint(found.stdout, caSha1)) return "untrusted"; const verified = await run(["verify-cert", "-q", "-L", "-c", leafPath, "-p", "ssl", "-n", PICKER_HOST, "-k", keychain]); - return verified.code === 0 ? "trusted" : verified.code === 1 ? "untrusted" : "unknown"; + if (verified.code === 0) { + const scoped = await hostScopedTrust(caSha1, run); + return scoped === null ? "unknown" : scoped ? "untrusted" : "trusted"; + } + return verified.code === 1 ? "untrusted" : "unknown"; } catch { // no-excuse-ok: catch -- OS command unavailable or denied; never claim trust. return "unknown"; } @@ -56,8 +87,11 @@ export async function trustPickerCa( ): Promise<{ ok: boolean; reason?: "unsupported" | "declined_or_failed" }> { if (platform !== "darwin") return { ok: false, reason: "unsupported" }; try { + // No `-s ` policy string: Chromium (Claude Desktop) skips trust settings that carry one, + // so a host-scoped setting leaves Desktop rejecting the picker leaf. The CA's critical name + // constraints already limit it to claude.ai; macOS verify-cert rejects any other name. const result = await run(["add-trusted-cert", "-r", "trustRoot", "-p", "ssl", - "-s", PICKER_HOST, "-k", loginKeychainPath(), caPath]); + "-k", loginKeychainPath(), caPath]); return result.code === 0 ? { ok: true } : { ok: false, reason: "declined_or_failed" }; } catch { // no-excuse-ok: catch -- user decline and command failure share a safe result. return { ok: false, reason: "declined_or_failed" }; diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index ab7443c76dc..375921dccdc 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -135,7 +135,13 @@ decision is armed: macOS, persisted resolved Desktop mode first-party, Desktop i `claudeCode.intercept.picker !== false`, no disarm latch, listener up, and the current picker CA trusted in the login keychain (`picker-trust.ts`). The picker CA (`picker-ca.ts`, under `/claude-picker/`, 0600 key) carries critical name constraints permitting only -`claude.ai` and is regenerated on reload when they are missing. The relay verifies the upstream +`claude.ai` and excluding every IPv4 and IPv6 address, and is regenerated on reload when either is +missing, which gives it a new fingerprint to trust. Trust is added without a policy string: Chromium +skips host-scoped trust settings, so `inspectPickerTrust` treats a current CA whose exported user +trust settings carry `kSecTrustSettingsPolicyString` as untrusted and the trust step replaces it; an +export it cannot read makes trust `unknown`, which never arms. A +rotated-out picker certificate stays in the login keychain because `untrustPickerCa` removes only the +current one; its key was overwritten, so it can no longer sign a leaf. The relay verifies the upstream certificate, streams every body and upgrade unchanged, and rewrites only the bootstrap response's local Code picker surfaces, `ccd` (what the Desktop Code tab reads) and its `code` fallback, never the remote `ccr` (`picker-bootstrap.ts`), failing open to the original bytes; the model list diff --git a/tests/claude-integration/claude-desktop-picker-routes.test.ts b/tests/claude-integration/claude-desktop-picker-routes.test.ts index 089783c15fd..6d0311efd6a 100644 --- a/tests/claude-integration/claude-desktop-picker-routes.test.ts +++ b/tests/claude-integration/claude-desktop-picker-routes.test.ts @@ -42,6 +42,7 @@ const security: SecurityRunner = async args => { return { code: 0, stdout: `SHA-1 hash: ${sha1}\n`, stderr: "" }; } case "verify-cert": return { code: keychain.trusted ? 0 : 1, stdout: "", stderr: "" }; + case "trust-settings-export": writeFileSync(args[1]!, ""); return { code: 0, stdout: "", stderr: "" }; case "add-trusted-cert": keychain.trusted = true; return { code: 0, stdout: "", stderr: "" }; case "remove-trusted-cert": keychain.trusted = false; return { code: 0, stdout: "", stderr: "" }; default: return { code: 0, stdout: "", stderr: "" }; diff --git a/tests/claude-integration/claude-desktop-picker.test.ts b/tests/claude-integration/claude-desktop-picker.test.ts index 55c9316969e..1ba8695cf32 100644 --- a/tests/claude-integration/claude-desktop-picker.test.ts +++ b/tests/claude-integration/claude-desktop-picker.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -45,6 +45,10 @@ function pickerSecurity(options: { trusted?: boolean; addTrust?: boolean; remove : { code: 1, stdout: "", stderr: "" }; } if (args[0] === "verify-cert") return trusted ? ok : { code: 1, stdout: "", stderr: "" }; + if (args[0] === "trust-settings-export") { + writeFileSync(args[1]!, ""); + return ok; + } if (args[0] === "add-trusted-cert") { trusted = options.addTrust ?? true; return trusted ? ok : { code: 1, stdout: "", stderr: "" }; @@ -138,7 +142,9 @@ test("enable orders trust, fresh recheck, profile, and rearm", async () => { const result = await controller.enable({ persist: true, context: "server" }); expect(result).toMatchObject({ reason: "restart_required", profile: "applied", effective: true, models: 3 }); expect(events).toEqual(["persist:true", "profile", "rearm"]); - expect(trust.calls.map(call => call[0])).toEqual(["find-certificate", "add-trusted-cert", "find-certificate", "verify-cert"]); + expect(trust.calls.map(call => call[0])).toEqual([ + "find-certificate", "add-trusted-cert", "find-certificate", "verify-cert", "trust-settings-export", + ]); }); test("persisted false is allowed to become true on explicit enable", async () => { diff --git a/tests/claude-integration/claude-picker-ca.test.ts b/tests/claude-integration/claude-picker-ca.test.ts index 04dafe83783..096cddf9fe2 100644 --- a/tests/claude-integration/claude-picker-ca.test.ts +++ b/tests/claude-integration/claude-picker-ca.test.ts @@ -34,7 +34,7 @@ function parts(bytes: Buffer): ReturnType[] { return items; } -function constraints(certPem: string): { critical: boolean; dnsNames: string[]; excluded: boolean } | null { +function constraints(certPem: string): { critical: boolean; dnsNames: string[]; excludedIps: string[] } | null { const root = der(new X509Certificate(certPem).raw, 0); const tbs = parts(root.body)[0]!; const wrapper = parts(tbs.body).find(item => item.tag === 0xa3)!; @@ -44,18 +44,22 @@ function constraints(certPem: string): { critical: boolean; dnsNames: string[]; if (!matched) return null; const nc = parts(der(matched.at(-1)!.body, 0).body); const permitted = nc.find(field => field.tag === 0xa0); + const excluded = nc.find(field => field.tag === 0xa1); return { critical: matched[1]?.tag === 0x01 && matched[1].body.equals(Buffer.from([0xff])), dnsNames: permitted ? parts(permitted.body).flatMap(subtree => parts(subtree.body).filter(base => base.tag === 0x82).map(base => base.body.toString("ascii"))) : [], - excluded: nc.some(field => field.tag === 0xa1), + excludedIps: excluded ? parts(excluded.body).flatMap(subtree => + parts(subtree.body).filter(base => base.tag === 0x87).map(base => base.body.toString("hex"))) : [], }; } -test("picker root has a critical claude.ai-only DNS constraint; intercept root remains unconstrained", () => { +const ALL_IPS = ["00".repeat(8), "00".repeat(32)]; + +test("picker root has a critical claude.ai-only DNS constraint that excludes every IP; intercept root remains unconstrained", () => { const ca = ensurePickerCa(tempDir()); expect(new X509Certificate(ca.certPem).subject).toContain(`CN=${PICKER_CA_COMMON_NAME}`); - expect(constraints(ca.certPem)).toEqual({ critical: true, dnsNames: [PICKER_HOST], excluded: false }); + expect(constraints(ca.certPem)).toEqual({ critical: true, dnsNames: [PICKER_HOST], excludedIps: ALL_IPS }); expect(constraints(createLocalInterceptCa().certPem)).toBeNull(); expect(ca.fingerprint).toBe(pickerCaFingerprints(ca.certPem).sha256); expect(pickerCaFingerprints(ca.certPem).sha1).toMatch(/^[0-9A-F]{40}$/); @@ -97,6 +101,32 @@ test("TLS accepts claude.ai and rejects an off-host leaf issued by the picker ro expect(await handshake(ca.certPem, offHost, "example.com")).toBe(false); }); +async function ipHandshake(caPem: string, pair: { certPem: string; keyPem: string }): Promise { + const server = createServer({ cert: pair.certPem, key: pair.keyPem }, socket => socket.end()); + 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 test listener"); + try { + return await new Promise(resolve => { + const socket = connect({ host: "127.0.0.1", port: address.port, ca: caPem, rejectUnauthorized: true }); + socket.once("secureConnect", () => { resolve(socket.authorized); socket.destroy(); }); + socket.once("error", () => { resolve(false); socket.destroy(); }); + }); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } +} + +test("TLS rejects an IP-address leaf issued by the picker root", async () => { + const ipLeaf = (ca: Parameters[0]) => issueServerLeaf(ca, PICKER_CA_COMMON_NAME, ["127.0.0.1"]); + expect(new X509Certificate(ipLeaf(createLocalInterceptCa()).certPem).subjectAltName).toBe("IP Address:127.0.0.1"); + // Control: the same leaf shape verifies under an unconstrained root. + const unconstrained = createCertificateAuthority({ commonName: PICKER_CA_COMMON_NAME }); + expect(await ipHandshake(unconstrained.certPem, ipLeaf(unconstrained))).toBe(true); + const ca = ensurePickerCa(tempDir()); + expect(await ipHandshake(ca.certPem, ipLeaf(ca))).toBe(false); +}); + test("picker authority persists private key at 0600 and regenerates a corrupt key", () => { const dir = tempDir(); const first = ensurePickerCa(dir); @@ -120,6 +150,18 @@ test("a valid key-matching but unconstrained persisted CA is rotated", () => { expect(constraints(repaired.certPem)?.dnsNames).toEqual([PICKER_HOST]); }); +test("a claude.ai-constrained CA without the IP exclusion (the first format) is rotated", () => { + const dir = tempDir(); + ensurePickerCa(dir); + const legacy = createCertificateAuthority({ commonName: PICKER_CA_COMMON_NAME, permittedDnsNames: [PICKER_HOST], excludeAllIpAddresses: false }); + expect(constraints(legacy.certPem)?.excludedIps).toEqual([]); + writeFileSync(pickerCaCertPath(dir), legacy.certPem); + writeFileSync(join(pickerStateDir(dir), "ca.key"), legacy.keyPem); + const repaired = ensurePickerCa(dir); + expect(repaired.fingerprint).not.toBe(pickerCaFingerprints(legacy.certPem).sha256); + expect(constraints(repaired.certPem)?.excludedIps).toEqual(ALL_IPS); +}); + test("a valid constrained CA with the wrong name or DNS scope is rotated", () => { for (const options of [ { commonName: "other local CA", permittedDnsNames: [PICKER_HOST] }, diff --git a/tests/claude-integration/claude-picker-runtime.test.ts b/tests/claude-integration/claude-picker-runtime.test.ts index b7048bda01f..26f974a896a 100644 --- a/tests/claude-integration/claude-picker-runtime.test.ts +++ b/tests/claude-integration/claude-picker-runtime.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { connect, createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -41,6 +41,7 @@ function keychain(state: { trusted: boolean }) { return { code: 0, stdout: `SHA-1 hash: ${sha1}\n`, stderr: "" }; } if (args[0] === "verify-cert") return { code: state.trusted ? 0 : 1, stdout: "", stderr: "" }; + if (args[0] === "trust-settings-export") writeFileSync(args[1]!, ""); return { code: 0, stdout: "", stderr: "" }; }; return { run, calls }; diff --git a/tests/claude-integration/claude-picker-trust.test.ts b/tests/claude-integration/claude-picker-trust.test.ts index 72a74b5b4a8..0f2ae496edb 100644 --- a/tests/claude-integration/claude-picker-trust.test.ts +++ b/tests/claude-integration/claude-picker-trust.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { writeFileSync } from "node:fs"; import { inspectPickerTrust, loginKeychainPath, trustPickerCa, untrustPickerCa, type SecurityResult, type SecurityRunner, @@ -7,22 +8,64 @@ import { PICKER_CA_COMMON_NAME } from "../../src/claude/intercept/picker-ca"; const sha1 = "A".repeat(40); const ok: SecurityResult = { code: 0, stdout: "", stderr: "" }; +/** A readable trust-settings export with no entry for the picker CA. */ +const NO_ENTRIES = "trustList"; function fake(...results: SecurityResult[]): { run: SecurityRunner; calls: readonly string[][] } { const calls: string[][] = []; return { calls, run: async args => { calls.push([...args]); - return results.shift() ?? ok; + const result = results.shift() ?? ok; + if (args[0] === "trust-settings-export" && result.code === 0) writeFileSync(args[1]!, NO_ENTRIES); + return result; } }; } test("inspection requires the current root fingerprint and verifies the persisted leaf", async () => { const f = fake({ ...ok, stdout: `SHA-1 hash: ${sha1}\n` }, ok); expect(await inspectPickerTrust("/leaf.pem", sha1, f.run, "darwin")).toBe("trusted"); - expect(f.calls).toEqual([ + expect(f.calls.slice(0, 2)).toEqual([ ["find-certificate", "-a", "-Z", "-c", PICKER_CA_COMMON_NAME, loginKeychainPath()], ["verify-cert", "-q", "-L", "-c", "/leaf.pem", "-p", "ssl", "-n", "claude.ai", "-k", loginKeychainPath()], ]); + // Then the user trust settings are read to rule out a host-scoped setting. + expect(f.calls[2]?.[0]).toBe("trust-settings-export"); + expect(f.calls).toHaveLength(3); +}); + +function plist(entries: Record): string { + const body = Object.entries(entries).map(([hash, settings]) => + `${hash}trustSettings${settings}`).join(""); + return `trustList${body}`; +} + +test("a host-scoped trust setting for the current CA reads as untrusted so trust is added again", async () => { + const sslOnly = "kSecTrustSettingsPolicyNamesslServer"; + const hostScoped = "kSecTrustSettingsPolicyNamesslServer" + + "kSecTrustSettingsPolicyStringclaude.ai"; + const other = "C".repeat(40); + const cases: Array<[string, string, string]> = [ + ["host-scoped current CA", plist({ [sha1]: hostScoped }), "untrusted"], + ["SSL-only current CA", plist({ [sha1]: sslOnly }), "trusted"], + ["host scope on another cert only", plist({ [sha1]: sslOnly, [other]: hostScoped }), "trusted"], + ]; + for (const [, exported, expected] of cases) { + const run: SecurityRunner = async args => { + if (args[0] === "find-certificate") return { ...ok, stdout: `SHA-1 hash: ${sha1}\n` }; + if (args[0] === "trust-settings-export") writeFileSync(args[1]!, exported); + return ok; + }; + expect(await inspectPickerTrust("/leaf.pem", sha1, run, "darwin")).toBe(expected); + } + // Unreadable settings could hide a host scope Chromium skips, so trust stays unknown and unarmed. + const failing: SecurityRunner = async args => args[0] === "find-certificate" + ? { ...ok, stdout: `SHA-1 hash: ${sha1}\n` } + : args[0] === "trust-settings-export" ? { ...ok, code: 1 } : ok; + expect(await inspectPickerTrust("/leaf.pem", sha1, failing, "darwin")).toBe("unknown"); + const unwritten: SecurityRunner = async args => args[0] === "find-certificate" + ? { ...ok, stdout: `SHA-1 hash: ${sha1}\n` } + : ok; + expect(await inspectPickerTrust("/leaf.pem", sha1, unwritten, "darwin")).toBe("unknown"); }); test("missing or stale root never reaches leaf verification", async () => { @@ -49,7 +92,8 @@ test("trust and untrust pass the exact security argv", async () => { expect(await trustPickerCa("/ca.pem", f.run, "darwin")).toEqual({ ok: true }); expect(await untrustPickerCa("/ca.pem", sha1, f.run, "darwin")).toEqual({ ok: true }); expect(f.calls).toEqual([ - ["add-trusted-cert", "-r", "trustRoot", "-p", "ssl", "-s", "claude.ai", "-k", loginKeychainPath(), "/ca.pem"], + // No host policy string: Chromium ignores host-scoped trust settings. + ["add-trusted-cert", "-r", "trustRoot", "-p", "ssl", "-k", loginKeychainPath(), "/ca.pem"], find, ["remove-trusted-cert", "/ca.pem"], ["delete-certificate", "-Z", sha1, loginKeychainPath()], From be0b5294e55ee42db4820f9f4151f021aa4a5bba Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 15:27:08 +0900 Subject: [PATCH 25/48] fix(openai-chat): read MiMo tool-call echoes without or with a header newline (#5725) * fix(openai-chat): read MiMo tool-call echoes without or with a header newline The Chat reconciler removed a duplicated block only in the canonical BODY shape. MiMo also echoes the block without , and sometimes with a template newline after the function header; both stayed on screen next to the structured call that ran (#5724). The block pattern now accepts the unclosed form after trying the closed one, and the body comparison drops one leading newline, matching the Command Code reader's grammar. Suppression still requires the name and body to agree with a structured call, so mismatched markup stays visible. * fix(openai-chat): scan serialized blocks linearly and record ADR-5724 Review follow-up. The closed/unclosed regex pair backtracked quadratically on a long unterminated body and hid a closed body that merely contained a literal . Blocks are now read by delimiter scan: the first preceded by closes the block, and only when none appears before the next real block header does the first close it. The decision moves to its own record, ADR-5724; ADR-5548 is left as it was. * fix(openai-chat): bound the block scan by line-start headers only A body can carry a full literal header such as text(""). A separate bare block can only begin at the start of a line, so the scan now stops only at a header there. * fix(openai-chat): keep a closed block whose body has a line-start header A line-start header only bounds an unclosed candidate. When no appears before it, it is body text, and a closed
after it still ends the block. --- .../serialized-tool-call-content.ts | 77 +++++++++++++++---- .../ADR-5724-serialized-tool-call-content.md | 13 ++++ structure/providers/chat-compat.md | 9 ++- ...-chat-serialized-tool-call-content.test.ts | 77 ++++++++++++++++++- 4 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 structure/decisions/ADR-5724-serialized-tool-call-content.md diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts index 5bcd579df9f..3a46c3ebbac 100644 --- a/src/adapters/openai-chat/serialized-tool-call-content.ts +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -32,25 +32,69 @@ export interface StructuredToolCallReference { argumentsText: string; } +const BLOCK_HEADER = /\s*\r\n]+)>/y; +/** A separate bare block starts a line (see `splitAtPossibleSerializedToolCall`); a header mid-line is body text. */ +const NEXT_BLOCK_HEADER = /\n\s*\r\n]+>/g; +const FUNCTION_CLOSE = ""; +const PARAMETER_CLOSE = ""; + +function trimmedEnd(text: string, from: number, to: number): number { + while (to > from && /\s/.test(text[to - 1]!)) to--; + return to; +} + +function endsWithAt(text: string, from: number, to: number, suffix: string): boolean { + return to - suffix.length >= from && text.startsWith(suffix, to - suffix.length); +} + +/** + * The block starting at `offset`, read by delimiter scan so an unterminated block costs linear time. + * MiMo's echo may close a freeform body with a stray `` and may omit `` + * (#5724), the grammar the Command Code reader accepts too. The first `` preceded by + * `` closes the block, so a body can still carry a literal `` or header; + * with none before the next line-start block header, the first `
` does. That header only + * bounds an unclosed candidate: with no close at all before it, it is body text, and a closed + * `
` after it still ends the block. + */ +function blockAt(text: string, offset: number): SerializedToolCall | undefined { + BLOCK_HEADER.lastIndex = offset; + const header = BLOCK_HEADER.exec(text); + if (!header) return undefined; + const bodyStart = offset + header[0].length; + NEXT_BLOCK_HEADER.lastIndex = bodyStart; + const next = NEXT_BLOCK_HEADER.exec(text); + const limit = next ? next.index + 1 : text.length; + let unclosed: SerializedToolCall | undefined; + for (let close = text.indexOf(CLOSE_TAG, bodyStart); close >= 0 && (close < limit || !unclosed); + close = text.indexOf(CLOSE_TAG, close + CLOSE_TAG.length)) { + let bodyEnd = trimmedEnd(text, bodyStart, close); + const closed = endsWithAt(text, bodyStart, bodyEnd, FUNCTION_CLOSE); + if (closed) bodyEnd = trimmedEnd(text, bodyStart, bodyEnd - FUNCTION_CLOSE.length); + if (endsWithAt(text, bodyStart, bodyEnd, PARAMETER_CLOSE)) bodyEnd -= PARAMETER_CLOSE.length; + const call = { + name: header[1]!.trim(), + body: text.slice(bodyStart, bodyEnd), + start: offset, + end: close + CLOSE_TAG.length, + }; + if (closed) return call; + if (close < limit) unclosed ??= call; + } + return unclosed; +} + /** Finds complete bare blocks outside literal Markdown; ambiguous outer blocks stop the scan. */ function callsIn(text: string, context: TextContext = { fence: null, lineStart: true }): SerializedToolCall[] { - const pattern = /\s*\r\n]+)>([\s\S]*?)(?:<\/parameter>)?\s*<\/function>\s*<\/tool_call>/y; const calls: SerializedToolCall[] = []; let offset = 0; while (offset < text.length) { const split = splitAtPossibleSerializedToolCall(text.slice(offset), context, true); offset += split.emit.length; if (!split.hasOpenTag) break; - pattern.lastIndex = offset; - const match = pattern.exec(text); + const match = blockAt(text, offset); if (!match) break; // An incomplete/ambiguous outer block cannot authorize an inner call. - calls.push({ - name: match[1]!.trim(), - body: match[2]!, - start: match.index, - end: match.index + match[0].length, - }); - offset = pattern.lastIndex; + calls.push(match); + offset = match.end; context = { fence: null, lineStart: false }; } return calls; @@ -279,6 +323,11 @@ function inputFromArguments(argumentsText: string): string | undefined { } } +/** One wrapping newline after the function header is template layout, not input (vLLM `_trim_wrapping_newlines`). */ +function freeformBody(value: string): string { + return value.replace(/^\r?\n/, "").trimEnd(); +} + /** The `[start, end)` ranges of blocks whose function identity and freeform input match a dispatched call. */ function duplicatedSerializedToolCallRanges( text: string, @@ -287,9 +336,11 @@ function duplicatedSerializedToolCallRanges( ): { start: number; end: number }[] { if (structuredCalls.length === 0) return []; return callsIn(text, context).filter(call => { - const body = call.body.trimEnd(); - return structuredCalls.some(structured => - structured.names.has(call.name) && inputFromArguments(structured.argumentsText)?.trimEnd() === body); + const body = freeformBody(call.body); + return structuredCalls.some(structured => { + const input = structured.names.has(call.name) ? inputFromArguments(structured.argumentsText) : undefined; + return input !== undefined && freeformBody(input) === body; + }); }); } diff --git a/structure/decisions/ADR-5724-serialized-tool-call-content.md b/structure/decisions/ADR-5724-serialized-tool-call-content.md new file mode 100644 index 00000000000..34bb5bf1079 --- /dev/null +++ b/structure/decisions/ADR-5724-serialized-tool-call-content.md @@ -0,0 +1,13 @@ +# ADR-5724 — decision recorded under "Serialized tool-call content" + +- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#serialized-tool-call-content) + +## Decision record + +- Intent: Remove MiMo's duplicated tool-call echo on OpenAI Chat routes when it arrives in the shapes the model actually emits, not only the canonical one (#5724). +- Prior constraint: ADR-5548 suppresses a block only when its function name and freeform body agree with a structured call in the same response; mismatched markup stays byte-exact. +- Observed shapes: MiMo's echo can omit `` (`BODY`) and can put a template newline after the function header. The Command Code reader already accepts both (#5637); the Chat reconciler did not, so those echoes stayed visible beside the call that ran. +- Alternatives considered: Keep two regular expressions and try the closed form first (backtracks quadratically on a long unterminated body, and rejecting a closed match on any inner `` hides a body that merely contains that string); drop anything shaped like a tool call (discards real text); restore calls from markup when no structured call exists (a new behaviour this route has no evidence for). +- Choice: Read each block by delimiter scan. The first `` preceded by `` closes the block; if none appears before the next block header at the start of a line (`` followed by `` does. A stray `` before the close is markup, and one leading newline in the body is template layout. +- Why: It accepts the same grammar on both MiMo routes, keeps a body that contains literal tool-call tags (even a full header) intact, and costs linear time. The agreement rule from ADR-5548 is unchanged, so no new text can disappear without a matching structured call. +- Consequences: The two echo shapes are removed when they duplicate a structured call, streamed and buffered. Markup with no structured call is still shown and still runs nothing. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index b6ebdcef87e..46cb7064238 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -325,7 +325,13 @@ entry. `src/adapters/openai-chat/serialized-tool-call-content.ts` recognizes bar start of a line outside Markdown fences; inline, quoted and indented examples remain unchanged. It holds a possible serialized block, resumes ordinary text delivery when the header cannot match, and removes the block only when its function name and -freeform body match a structured call's parsed `input` in the same response. If the gateway also prefixes the structured call's JSON +freeform body match a structured call's parsed `input` in the same response. +A block may close a freeform body with a stray `` and may omit ``, and one +newline after the function header is template layout, so MiMo's echoes of those shapes match too +(#5724). Blocks are read by delimiter scan in linear time: the first `` preceded by +`` closes the block, and only when none appears before the next block header at the start +of a line does the first `` close it, so a body can still carry literal tool-call tags. +If the gateway also prefixes the structured call's JSON arguments with the same freeform body, the adapter keeps the JSON suffix only when the block body, prefix, and wrapper's `input` value all agree. Mismatched markup and arguments remain byte-exact. Silent held-content frames emit adapter heartbeats. Terminal errors and transport read failures @@ -339,6 +345,7 @@ matching and repair rules; regression coverage enters through `/v1/responses` in `tests/responses/responses-chat-tool-call-content.test.ts`. > Decision record: [ADR-5548](../decisions/ADR-5548-serialized-tool-call-content.md) +> Decision record: [ADR-5724](../decisions/ADR-5724-serialized-tool-call-content.md) ## Kimi Coding Plan prompt-cache affinity diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts index e1e64287dfb..7faf3a8572b 100644 --- a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts @@ -1,7 +1,8 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; import { SerializedToolCallContentBuffer } from "../../../src/adapters/openai-chat/serialized-tool-call-content"; -import { createTestTranslatorBudget } from "../../helpers/translator-budget"; +import type { AdapterEvent } from "../../../src/types"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "../../helpers/translator-budget"; const provider = { adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", apiKey: "key" } as const; @@ -61,3 +62,75 @@ test("an open serialized block charges only its appended bytes", () => { expect(buffer.flush([])).toBe(open + body); expect(budget.snapshot()).toMatchObject({ currentBytes: 0, overflows: 0 }); }); +describe("MiMo echo variants (#5724)", () => { + const script = 'const r = await tools.exec_command({cmd:"Get-Content a.txt"}); text(r.output);'; + const call = (input: string) => ({ index: 0, id: "call_exec", function: { name: "exec", arguments: JSON.stringify({ input }) } }); + + async function streamed(content: string, input: string): Promise { + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] } }); + const frames = [ + { choices: [{ delta: { content: content.slice(0, 30) } }] }, + { choices: [{ delta: { content: content.slice(30) } }] }, + { choices: [{ delta: { tool_calls: [call(input)] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event); + return events; + } + async function buffered(content: string, input: string): Promise { + return createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ message: { content, tool_calls: [call(input)] }, finish_reason: "tool_calls" }], + }), createTestTranslatorBudget()); + } + const visible = (events: AdapterEvent[]): string => events + .map(event => (event.type === "text_delta" ? event.text : "")) + .join(""); + + test.each([ + ["the header is followed by a template newline", `\n${script}\n`], + ["the echo omits ", `${script}`], + ])("a matching block is removed when %s", async (_label, block) => { + for (const events of [await streamed(`Reading.\n${block}`, script), await buffered(`Reading.\n${block}`, script)]) { + expect(visible(events)).toBe("Reading.\n"); + expect(events.filter(event => event.type === "tool_call_start")).toHaveLength(1); + } + }); + + test("an unclosed block with a different body stays visible", async () => { + const block = "text('other');"; + for (const events of [await streamed(block, script), await buffered(block, script)]) { + expect(visible(events)).toBe(block); + } + }); + + test("a closed block whose body carries literal tool-call tags is still matched whole", async () => { + for (const input of [ + "text('
');", + "text('');", + 'text("");', + 'const s = `\n`;\ntext(s);', + ]) { + const block = `${input}`; + for (const events of [await streamed(block, input), await buffered(block, input)]) { + expect(visible(events)).toBe(""); + } + } + }); + + test("an unclosed block followed by a closed block is read as two blocks", async () => { + const first = "text('a');"; + const second = "text('b');"; + const content = `${first}\n${second}`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { content, tool_calls: [call(first), { ...call(second), index: 1, id: "call_exec_2" }] }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + expect(visible(events)).toBe("\n"); + expect(events.filter(event => event.type === "tool_call_start")).toHaveLength(2); + }); +}); From 742ee168e4d635fe3ff4624a44c9b2dba8ac8f64 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 16:05:24 +0900 Subject: [PATCH 26/48] fix(desktop): make Cmd/Ctrl +/-/0 zoom the app window on every platform (#5737) The desktop window never enabled page zoom. Tauri leaves zoom hotkeys off by default, so WebView2 on Windows had its zoom control disabled and macOS/Linux had no handler at all. The main window now enables Tauri's zoom hotkeys. WebView2 zooms natively; on macOS and Linux Tauri injects a keydown polyfill that calls set_webview_zoom. The dashboard is served from the loopback proxy, a remote origin to Tauri, so a new capability grants that one command to the main window for http://127.0.0.1:* only. A test pins the capability's window, origin and permission, and checks the pattern against the real dashboard URL. --- .../capabilities/dashboard-zoom.json | 10 +++++ desktop/src-tauri/src/lib.rs | 4 ++ desktop/src-tauri/src/window.rs | 43 +++++++++++++++++++ structure/desktop-shell.md | 10 +++-- 4 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 desktop/src-tauri/capabilities/dashboard-zoom.json diff --git a/desktop/src-tauri/capabilities/dashboard-zoom.json b/desktop/src-tauri/capabilities/dashboard-zoom.json new file mode 100644 index 00000000000..dc2b0734244 --- /dev/null +++ b/desktop/src-tauri/capabilities/dashboard-zoom.json @@ -0,0 +1,10 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "dashboard-zoom", + "description": "Page zoom hotkeys for the main window, including the loopback dashboard", + "windows": ["main"], + "remote": { + "urls": ["http://127.0.0.1:*"] + }, + "permissions": ["core:webview:allow-set-webview-zoom"] +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 3ae6359e872..e35224af545 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -237,6 +237,10 @@ pub fn run() { .inner_size(1100.0, 720.0) .visible(false) .user_agent(&window::webview_user_agent()) + // Cmd on macOS, Ctrl elsewhere, with + / - / 0. WebView2 zooms natively; on + // macOS and Linux Tauri injects a keydown polyfill whose one IPC call is granted + // to the loopback dashboard by `capabilities/dashboard-zoom.json`. + .zoom_hotkeys_enabled(true) .on_navigation(window::navigation_allowed(app.handle().clone())) .build()?; window::configure(&window); diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index 81969f7189e..a886fb07769 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -157,4 +157,47 @@ mod tests { assert!(user_agent.contains("(X11; Linux x86_64)")); } } + + /// The zoom polyfill runs inside the loopback dashboard, which is a remote origin to Tauri. The + /// capability that lets it call `set_webview_zoom` is the only one reaching that origin, so it + /// stays pinned to this window, this origin and this one command. + #[test] + fn the_dashboard_reaches_only_the_zoom_command() { + let zoom: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/dashboard-zoom.json")) + .expect("dashboard-zoom capability is JSON"); + assert_eq!(zoom["windows"], serde_json::json!(["main"])); + assert_eq!( + zoom["remote"]["urls"], + serde_json::json!(["http://127.0.0.1:*"]) + ); + assert_eq!( + zoom["permissions"], + serde_json::json!(["core:webview:allow-set-webview-zoom"]) + ); + + // Tauri matches the origin with URLPattern; the dashboard is the loopback endpoint on + // whatever port it resolved to, and nothing beside it. + let pattern: tauri_utils::acl::RemoteUrlPattern = + "http://127.0.0.1:*".parse().expect("a URL pattern"); + let dashboard = crate::endpoint::ProxyEndpoint { + host: "127.0.0.1", + port: 10100, + } + .url("/#/usage"); + assert!(pattern.test(&url(&dashboard)), "{dashboard}"); + for value in [ + "http://localhost:10100/", + "https://127.0.0.1:10100/", + "http://127.0.0.2:10100/", + "http://example.com/", + ] { + assert!(!pattern.test(&url(value)), "{value}"); + } + + let default: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/default.json")) + .expect("default capability is JSON"); + assert!(default.get("remote").is_none()); + } } diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 99a8d347b71..53d21f71f7e 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -19,9 +19,13 @@ that cannot find its own startup state now reports that as a failure the user ca It uses no `alert`, `confirm` or `prompt`: the embedded webview implements none of the matching WKUIDelegate panel methods on macOS, so a platform dialog is declined without drawing anything. -`withGlobalTauri` is on so that page can invoke without a bundler. Only the local app origin -carries a capability, so the loopback dashboard reaches no command: `capabilities/default.json` -declares no `remote` entry, and Tauri checks the ACL for any invoke from a non-local origin. +`withGlobalTauri` is on so that page can invoke without a bundler. The bootstrap commands are +granted to the local app origin only: `capabilities/default.json` declares no `remote` entry, and +Tauri checks the ACL for any invoke from a non-local origin. The one exception is page zoom. The main +window enables Tauri's zoom hotkeys (Cmd or Ctrl with + / - / 0); WebView2 handles them natively, but +on macOS and Linux Tauri injects a keydown polyfill that calls `set_webview_zoom` from whatever page +is loaded, including the loopback dashboard. `capabilities/dashboard-zoom.json` grants that single +command to the main window for `http://127.0.0.1:*`, and a test in `window.rs` pins its shape. ## Startup, quit and the tray From df61bcecd528153c45093059035b7a573684ae18 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 17:22:53 +0900 Subject: [PATCH 27/48] =?UTF-8?q?fix(responses):=20bundle=20L2=20=E2=80=94?= =?UTF-8?q?=20Responses=20and=20streaming=20fixes=20(#5706,=20#5683,=20#57?= =?UTF-8?q?14,=20#5704,=20#5707)=20(#5738)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(claude): bound Responses user to 64 chars for long metadata.user_id Claude Code sends metadata.user_id as a ~186-char JSON string. It was copied verbatim into the Responses 'user' field, which Azure OpenAI and other OpenAI-compatible backends cap at 64 chars, so every Claude Code request routed to Azure failed with 400 "Invalid 'user': string too long". Keep short ids as-is and send the SHA-256 hex digest (already computed for prompt_cache_key) when the id exceeds 64 chars. Fixes #5705 Co-authored-by: Giulio Leone * Fix plaintext V2 SSE responses with missing content type Co-authored-by: Jerry WANG * fix(responses): exclude dropped chat reasoning from input admission * test(admission): verify reasoning payload matches gate decisions Co-authored-by: 정우철 * Allow HTTP upstream for canonical ChatGPT provider An explicit upstreamWebsocket: false now routes streaming canonical ChatGPT turns over HTTP/SSE before sending. This gives operators a supported escape from intermittent post-send WebSocket closes without replaying ambiguous turns. The default remains WebSocket and native WS controls are unavailable when HTTP is selected. Verified: focused Responses/provider suites, typecheck, structure check, privacy scan, docs build. Changed-area suite rerun pending. Co-authored-by: kosta * test(claude): pin exact user hash and the 64/65-char boundary Refs #5705 Co-authored-by: Giulio Leone * docs(architecture): attribute the admission fix to excluding unsent thinking Split the preserved-reasoning statement from the refusal rationale in the English and Korean paragraphs, as review of #5714 asked. Refs #5696 Co-authored-by: 정우철 * fix(anthropic): count ping events as upstream liveness Anthropic streams may include any number of ping events. The adapter only turned SSE comments into heartbeats, so a long silent thinking phase that pinged was cut off at stallTimeoutSec with upstream_stall_timeout. Named and data-only ping records now yield the same heartbeat. Refs #5707 * fix(management): report upstreamWebsocket as configured in GET /api/providers The row coerced an unset value to false. After the canonical ChatGPT opt-out, unset means upstream WebSocket and false means HTTP/SSE, so a save built from the row could turn WebSocket off. The row now omits the key when it is unset. Co-authored-by: kosta * docs(providers): describe the canonical ChatGPT upstreamWebsocket opt-out The reference row, its seven translations, and the provider type and schema comments still said the canonical ChatGPT transport ignores upstreamWebsocket. It now selects HTTP/SSE when set to false. The locale rows were also behind the English row on the first-party-only restriction; they are retranslated from it. Co-authored-by: kosta * fix(responses): bound the plaintext V2 SSE prefix probe by stallTimeoutSec The carried probe read the first chunk of an unlabeled body with no deadline, before the passthrough stall guard is attached. An upstream that sent headers but no body held the request and its host lease until the client gave up, and a failed read escaped the classifier. Each probe read now races a per-read inactivity window and one total budget, both stallTimeoutSec, plus the client abort signal. Timeout, abort, and read errors cancel the reader and return the existing unsupported-content-type 502. Docs now scope the recovery to missing or unrecognized non-JSON content types. Follow-up to #5683 review (maintainer, Codex, CodeRabbit). Co-authored-by: Jerry WANG --------- Co-authored-by: Giulio Leone Co-authored-by: Jerry WANG Co-authored-by: 정우철 Co-authored-by: kosta --- .../fr/reference/configuration/providers.md | 2 +- .../content/docs/guides/codex-integration.md | 7 + .../content/docs/guides/sub-agent-surface.md | 5 + .../ja/reference/configuration/providers.md | 2 +- .../content/docs/ko/reference/architecture.md | 6 + .../ko/reference/configuration/providers.md | 2 +- .../content/docs/reference/architecture.md | 6 + .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../tr/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- scripts/test-layout/layout.json | 1 + src/adapters/anthropic.ts | 12 +- src/claude/inbound.ts | 7 +- src/config/schema/leaf-validators.ts | 8 +- src/server/auth-cors.ts | 6 + src/server/management/provider-routes.ts | 3 +- src/server/responses/fetch-helpers.ts | 2 +- src/server/responses/input-admission.ts | 16 +- .../responses/native-response-control.ts | 4 +- src/server/responses/passthrough-delivery.ts | 157 ++++++++++++++- src/server/responses/ws-upstream.ts | 3 +- src/types/provider.ts | 13 +- structure/config.md | 1 + structure/gui-and-management-api.md | 5 + structure/subagents.md | 7 + structure/transports/responses-failover.md | 2 + structure/transports/responses.md | 1 + structure/transports/streaming-health.md | 9 +- .../anthropic-compatible-stream.test.ts | 66 +++++++ .../claude-integration/claude-inbound.test.ts | 26 +++ tests/fixtures/test-layout-expected.json | 1 + tests/helpers/ws-upstream-fixtures.ts | 2 +- tests/responses/ws-native-injection.test.ts | 3 + tests/responses/ws-upstream.test.ts | 12 +- tests/server/input-admission.test.ts | 42 ++++ ...gement-provider-upstream-websocket.test.ts | 174 ++++++++++++++++ .../management-provider-validation.test.ts | 6 +- ...plaintext-v2-agent-messages-server.test.ts | 185 ++++++++++++++++++ 40 files changed, 767 insertions(+), 47 deletions(-) create mode 100644 tests/server/management-provider-upstream-websocket.test.ts diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 22c0d2969d3..a847f6072a6 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -89,7 +89,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Cadencement facultatif du démarrage des requêtes sortantes côté client, distinct de l’utilisation, de la facturation et des indicateurs de limitation en amont. Le nombre de requêtes par minute est converti en intervalle régulier ; `minIntervalMs` peut imposer un intervalle plus long. Les limites du fournisseur s’appliquent à tous ses modèles, tandis que les entrées `models` ciblent les identifiants exacts des modèles en amont, par exemple `nvidia/llama-3.1-nemotron-ultra-253b-v1`, et ne peuvent qu’ajouter du délai. L’attente dans la file ne consomme pas le délai d’expiration des en-têtes de réponse en amont. Les requêtes HTTP, Responses WebSocket et les distributions explicites `fetchResponse`/`runTurn` des adaptateurs sont couvertes. | | `responsesPath?` | `string` | Chemin de ressource relatif pour les requêtes d'authentification par clé `openai-responses`. Il doit commencer par `/` et ne contenir aucun schéma, requête ou fragment. | | `chatCompletionsPath?` | `string` | Chemin de ressource relatif pour les requêtes `openai-chat`, miroir de `responsesPath` et soumis aux mêmes règles de forme. Nécessaire lorsqu'un même service en amont sert Chat Completions et Responses sous des préfixes différents : un override wire par modèle change l'adaptateur sans toucher `baseUrl`, donc sans ce réglage une requête Chat activée serait envoyée vers la base Responses. L'exemple fourni est Z.AI. | -| `upstreamWebsocket?` | `boolean` | Active le transport Responses WebSocket en amont pour les requêtes `openai-responses` (désactivé par défaut). Lorsque le service en amont prend en charge ce protocole, les requêtes POST en streaming utilisent le chemin Responses configuré (par défaut `/v1/responses`) via WSS avec une base HTTPS, puis sont reconverties en SSE. Les fournisseurs en mode forward utilisent `{baseUrl}/responses` ; les fournisseurs avec clé utilisent `responsesPath`, ou le repli historique `/v1/responses`. Une base HTTP reste en SSE ; les chemins qui ne sont pas Responses et les requêtes `openai-chat` restent en HTTP. | +| `upstreamWebsocket?` | `boolean` | Active le transport Responses WebSocket en amont pour les requêtes `openai-responses` (désactivé par défaut). N'est honoré que pour l'amont first-party `https://api.openai.com/v1` ; les points de terminaison des fournisseurs personnalisés utilisent toujours HTTP/SSE borné, car Bun ne peut pas appliquer de limite de taille aux messages WebSocket entrants avant d'avoir alloué le message complet. Pour le fournisseur canonique ChatGPT `openai`, l'omettre conserve le WebSocket en amont sur les tours éligibles, `false` envoie les tours en streaming via HTTP/SSE, et `true` est refusé ; avec `false`, le pilotage et l'injection natifs en cours de tour sont indisponibles. Ce champ est indépendant du réglage `websockets` côté client et ne change ni le point de terminaison ni les identifiants. Une base HTTP reste en SSE ; les chemins qui ne sont pas Responses et les requêtes `openai-chat` restent en HTTP. | | `supportsServiceTier?` | `boolean` | Repli à trois états pour la capacité `service_tier`. `true` : le mode rapide peut injecter le champ et les valeurs de l’appelant sont conservées. `false` : le champ est retiré et jamais injecté, et aucune déclaration précise de modèle ne peut le réactiver. Absent : le fournisseur n’est pas classé ; les valeurs de l’appelant sont conservées intactes et le mode rapide n’injecte rien, sauf pour un modèle exact activé. Le registre classe OpenAI canonique comme `true`, et DeepSeek ainsi que Volcengine Ark comme `false`. Ne le définissez explicitement que pour les passerelles personnalisées qui prennent réellement en charge les niveaux. Les routes Chat exigent en plus une autorisation globale ou propre au modèle. | | `modelSupportsServiceTier?` | `Record` | Remplacements de capacité par identifiant exact de modèle en amont. La valeur exacte `true` autorise ce modèle Chat même sans `chatServiceTier` ; `false` restreint les valeurs globales et l’autorisation Chat. Une valeur globale explicite `supportsServiceTier: false` reste fermée et ne peut pas être réactivée. Les modèles non déclarés suivent le comportement global. La requête de gestion `PATCH /api/providers` fusionne les entrées et accepte `null` pour en supprimer une. | | `chatServiceTier?` | `boolean` | Active globalement la sérialisation de `service_tier` sur `/chat/completions`. Des modèles exacts peuvent aussi l’activer avec `modelSupportsServiceTier` ; les modèles non déclarés restent bloqués lorsque ce champ est absent ou faux. | diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index cf0515fbc4e..b3494547a72 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -98,6 +98,13 @@ is a `POST` to the canonical Responses URL or a configured WebSocket route, and falls back to it when the request cannot be prepared, the `response.create` frame exceeds its size limit, or the proxy route cannot carry the socket. +To keep the built-in ChatGPT provider on HTTP/SSE, set `providers.openai.upstreamWebsocket` +to `false` in `~/.opencodex/config.json` and restart the proxy. Merge this field into the +existing `openai` provider; preserve its account mode and other settings. Omit the field +to restore the default upstream WebSocket selection. This setting does not change the +client-facing `websockets` switch or the ChatGPT account used for the request. Native +mid-turn steering and injection need upstream WebSocket and are unavailable while it is off. + Local provider pacing can also hold a request before it is dispatched at all. So a slow first output has several possible contributors, and upstream queueing is only one of them. `ocx doctor` classifies configuration and measures none of these: compare actual transport, pacing, network, diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 939d93b4ab2..f4abe08f22d 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -344,3 +344,8 @@ encrypted, but task text can be retained in Codex history, routed-provider reque response/debug state. Existing ciphertext is unchanged, and the option depends on undocumented ChatGPT and Codex behavior. See [Agent configuration: Plaintext v2 agent messages](/reference/configuration/agents/#plaintext-v2-agent-messages). +If a streamed ChatGPT response carries no content type, or one that is neither `application/json` +nor a recognizable event stream, opencodex checks a bounded Responses event prefix before restoring +the message-tool names. An `application/json` response takes the bounded JSON path instead. The +probe waits at most `stallTimeoutSec` for its prefix, and a body that stays silent, unrecognized, or +unreadable fails closed rather than reaching Codex as a successful response. diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 62e09f465cc..b3222d502f9 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -83,7 +83,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 上流の使用量、請求、レート制限表示とは別の、クライアント側の送信開始間隔調整です。プロバイダー制限は全モデルに適用され、`models` は上流の正確なモデル ID に一致し、遅延を増やす場合のみ有効です。キュー待機は応答ヘッダーのタイムアウトを消費しません。HTTP、Responses WebSocket、明示的なアダプターの `fetchResponse`/`runTurn` 送信を対象にします。 | | `responsesPath?` | `string` |キー認証 `openai-responses` リクエストの相対リソース パス。 `/` で始まり、スキーム、クエリ、またはフラグメントが含まれていない必要があります。 | | `chatCompletionsPath?` | `string` | `openai-chat` リクエストの相対リソース パス。 `responsesPath` の対となる設定で、同じ形式ルールが適用されます。1つのアップストリームが Chat Completions と Responses を異なるプレフィックスで提供する場合に必要です。モデルごとの wire override はアダプターのみを変更し `baseUrl` は変更しないため、この設定がないと有効化された Chat リクエストが Responses ベースへ送信されます。同梱例は Z.AI です。 | -| `upstreamWebsocket?` | `boolean` | `openai-responses` リクエストで使用するアップストリーム Responses WebSocket トランスポート(既定値は無効)。アップストリームがこのプロトコルに対応している場合、ストリーミング POST は設定済みの Responses パス(既定値 `/v1/responses`)へ HTTPS の WSS で接続し、通常の処理向けに SSE へ再エンコードされます。forward プロバイダーは `{baseUrl}/responses`、キー認証プロバイダーは `responsesPath`(未設定時は従来の `/v1/responses`)を使用します。HTTP のベース URL は SSE のままとなり、Responses 以外のパスと `openai-chat` リクエストは HTTP を使用します。 | +| `upstreamWebsocket?` | `boolean` | `openai-responses` リクエストで使用するアップストリーム Responses WebSocket トランスポート(既定値は無効)。ファーストパーティの `https://api.openai.com/v1` アップストリームでのみ有効です。カスタムプロバイダーのエンドポイントは常に制限付き HTTP/SSE を使用します。Bun はメッセージ全体を確保する前に受信 WebSocket メッセージのサイズ上限を適用できないためです。正規の ChatGPT `openai` プロバイダーでは、省略すると対象となるターンでアップストリーム WebSocket を使用し、`false` はストリーミングのターンを HTTP/SSE で送信し、`true` は拒否されます。`false` の間はネイティブのターン途中のステアリングとインジェクションを利用できません。このフィールドはクライアント側の `websockets` 設定とは独立しており、エンドポイントと認証情報のどちらも変更しません。HTTP のベース URL は SSE のままとなり、Responses 以外のパスと `openai-chat` リクエストは HTTP を使用します。 | | `supportsServiceTier?` | `boolean` | `service_tier` ケイパビリティの 3 状態です。`true`: fast モードが注入でき、呼び出し元の値も保持されます。`false`: フィールドは削除され、注入もされません (非対応と文書化されたアップストリームには送りません)。未設定: 未分類 — 呼び出し元の値はそのまま保持され、fast モードは注入しません。レジストリは正規 OpenAI (`true`)、DeepSeek、Volcengine Ark (`false`) を分類します。実際にティアをサポートするカスタム ゲートウェイにのみ明示的に設定してください。 | | `preserveResponsesReasoningContent?` | `boolean` | リプレイされる Responses reasoning アイテムの平文 reasoning コンテンツを消去せずに保持します (消去は ChatGPT バックエンドのルールです)。DeepSeek のように reasoning リプレイを受け入れるアップストリームで有効にしてください。プロキシ生成の `ocxr1` エンベロープは常に削除されます。 | | `disabled?` | `boolean` |プロバイダーをディスク上に保持しますが、ルーティングおよびモデル/カタログのリストからは除外します。 | diff --git a/docs-site/src/content/docs/ko/reference/architecture.md b/docs-site/src/content/docs/ko/reference/architecture.md index 1aab4ed8447..68c30f9871e 100644 --- a/docs-site/src/content/docs/ko/reference/architecture.md +++ b/docs-site/src/content/docs/ko/reference/architecture.md @@ -68,6 +68,12 @@ HTTP 경계는 `server/index/serve-options.ts`가 맡고, Responses 데이터 7. `bridge/sse.ts` / `bridge/response-json.ts`가 Responses SSE 또는 JSON을 만듭니다. `server/request-log.ts`와 `usage/`는 응답을 건드리지 않은 채 종료 상태, 지연 시간, 프로바이더/모델, 최선 추정 토큰 사용량을 기록합니다. +요청 전 입력량 추정은 라우팅된 어댑터가 실제로 보내는 내용을 따릅니다. `openai-chat` 모델이 +`preserveReasoningContentModels`에 없으면 어댑터가 이전 assistant thinking을 보내지 않으므로 +추정에서도 뺍니다. 그래서 보내지도 않는 기록 때문에 로컬 컨텍스트 한도에서 잘못 거부되는 일이 +없습니다. reasoning을 보존하는 모델과 다른 어댑터는 이전 thinking을 +실제로 보내므로 계속 계산에 넣습니다. + ## 파서 `responses/parser.ts`는 들어오는 요청을 `responses/schema.ts`(Zod)로 검증한 다음 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 0511c729375..c4eb5809204 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -83,7 +83,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 업스트림 사용량, 과금, rate-limit 지표와 별개인 선택적 클라이언트 측 아웃바운드 요청 시작 속도 조절입니다. Provider 제한은 모든 모델에 적용되고 `models` 항목은 정확한 업스트림 모델 ID와 일치하며 지연을 더 늘릴 때만 적용됩니다. 큐 대기는 응답 헤더 타임아웃을 소모하지 않습니다. HTTP, Responses WebSocket, 명시적 어댑터 `fetchResponse`/`runTurn` 전송을 포함합니다. | | `responsesPath?` | `string` | 키 인증 `openai-responses` 요청의 상대 리소스 경로입니다. 반드시 `/`로 시작해야 하며 스킴, query, fragment를 포함하면 안 됩니다. | | `chatCompletionsPath?` | `string` | `openai-chat` 요청의 상대 리소스 경로로, `responsesPath`와 동일한 형식 규칙이 적용되는 대응 항목입니다. 하나의 업스트림이 Chat Completions와 Responses를 서로 다른 접두사로 제공할 때 필요합니다. 모델별 wire override는 어댑터만 바꾸고 `baseUrl`은 그대로 두므로, 이 설정이 없으면 옵트인된 Chat 요청이 Responses base로 전송됩니다. Z.AI가 제공되는 예시입니다. | -| `upstreamWebsocket?` | `boolean` | `openai-responses` 요청에 대한 업스트림 Responses WebSocket 전송을 선택적으로 활성화합니다(기본값 `false`). 업스트림이 이 프로토콜을 지원하면 스트리밍 POST가 설정된 Responses 경로(기본값 `/v1/responses`)로 HTTPS 기반 WSS를 사용하고, 일반 파이프라인을 위해 SSE로 다시 인코딩됩니다. forward 공급자는 `{baseUrl}/responses`를 사용하고, key-auth 공급자는 `responsesPath`를 사용하며 미설정 시 기존 `/v1/responses`로 대체됩니다. HTTP 기본 URL은 SSE를 유지하고, Responses가 아닌 경로와 `openai-chat` 요청은 HTTP를 사용합니다. | +| `upstreamWebsocket?` | `boolean` | `openai-responses` 요청에 대한 업스트림 Responses WebSocket 전송을 선택적으로 활성화합니다(기본값 `false`). 퍼스트파티 `https://api.openai.com/v1` 업스트림에서만 적용되며, 사용자 지정 공급자 엔드포인트는 항상 제한된 HTTP/SSE를 사용합니다. Bun은 전체 메시지를 할당하기 전에는 수신 WebSocket 메시지 크기 제한을 적용할 수 없기 때문입니다. 정식 ChatGPT `openai` 공급자에서는 생략하면 대상 턴에서 업스트림 WebSocket을 사용하고, `false`는 스트리밍 턴을 HTTP/SSE로 전송하며, `true`는 거부됩니다. `false`이면 네이티브 턴 중 스티어링과 주입을 사용할 수 없습니다. 이 필드는 클라이언트 측 `websockets` 설정과 독립적이며 엔드포인트와 자격 증명을 변경하지 않습니다. HTTP 기본 URL은 SSE를 유지하고, Responses가 아닌 경로와 `openai-chat` 요청은 HTTP를 사용합니다. | | `supportsServiceTier?` | `boolean` | `service_tier` 케이퍼빌리티 3상태입니다. `true`: fast 모드가 주입할 수 있고 호출자 값도 보존합니다. `false`: 필드를 제거하고 절대 주입하지 않습니다(미지원으로 문서화된 업스트림에는 볼 수 없습니다). 미설정: 미분류 — 호출자가 준 값은 그대로 보존하고 fast 모드는 주입하지 않습니다. 레지스트리는 정식 OpenAI(`true`), DeepSeek, Volcengine Ark(`false`)를 분류하며, 실제로 티어를 지원하는 커스텀 게이트웨이에만 명시적으로 설정하세요. | | `preserveResponsesReasoningContent?` | `boolean` | 리플레이되는 Responses reasoning 항목의 평문 reasoning 내용을 지우지 않고 유지합니다(지우는 것은 ChatGPT 백엔드 규칙입니다). DeepSeek처럼 reasoning 리플레이를 허용하는 업스트림에 켜세요. 프록시가 만든 `ocxr1` 봉투는 항상 제거됩니다. | | `disabled?` | `boolean` | 공급자를 디스크에는 남기되, 라우팅과 모델/카탈로그 목록에서는 제외합니다. | diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index c04f647b724..1e77251a296 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -71,6 +71,12 @@ the `server/responses.ts` facade and its `server/responses/*.ts` modules: 7. `bridge/sse.ts` / `bridge/response-json.ts` produces Responses SSE or JSON. `server/request-log.ts` and `usage/` collect terminal status, latency, provider/model labels, and best-effort token usage without changing the response. +The pre-dispatch input estimate follows what the routed adapter actually sends. For `openai-chat` +models outside `preserveReasoningContentModels`, the adapter drops replayed assistant thinking, so +the estimate excludes it too; history that never reaches the provider therefore cannot cause a local +context-limit refusal. Models that preserve reasoning, and other adapters, +send that thinking and still count it. + ## The parser `responses/parser.ts` validates the incoming request with `responses/schema.ts` (Zod), then builds an diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 6cb260fa128..26898b968ec 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -203,7 +203,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | | `chatCompletionsPath?` | `string` | Relative resource path for `openai-chat` requests, the mirror of `responsesPath` and subject to the same shape rules. Needed when one upstream serves Chat Completions and Responses under different prefixes: a per-model wire override changes the adapter and leaves `baseUrl` alone, so without this an opted-in Chat request would be sent to the Responses base. Z.AI is the shipped example. | | `allowEncryptedV2AgentTasks?` | `boolean` | Disabled by default. Trust a direct key-auth `openai-responses` provider to consume or relay opaque encrypted V2 sub-agent tasks unchanged. Eligible routes skip `agentTaskRecovery`; all other routes keep the existing recovery or fail-closed behavior. OpenCodex does not decrypt, translate, or recover tasks sent through this opt-in. | -| `upstreamWebsocket?` | `boolean` | Opt-in upstream Responses WebSocket transport for `openai-responses` requests (default false). Honored only for the first-party `https://api.openai.com/v1` upstream; custom-provider endpoints always use bounded HTTP/SSE because Bun cannot enforce an inbound WebSocket message limit before allocating the complete message. The canonical ChatGPT transport is unaffected. Plain HTTP remains on SSE; non-Responses paths and `openai-chat` requests stay on HTTP. | +| `upstreamWebsocket?` | `boolean` | Opt-in upstream Responses WebSocket transport for `openai-responses` requests (default false). Honored only for the first-party `https://api.openai.com/v1` upstream; custom-provider endpoints always use bounded HTTP/SSE because Bun cannot enforce an inbound WebSocket message limit before allocating the complete message. For the canonical ChatGPT `openai` provider, omitting it keeps the upstream WebSocket on eligible turns, an explicit `false` sends streaming turns over HTTP/SSE, and provider management rejects `true`; with `false`, native mid-turn steering and injection are unavailable. The field is independent of the client-facing `websockets` setting and changes neither the endpoint nor the credential. Plain HTTP remains on SSE; non-Responses paths and `openai-chat` requests stay on HTTP. | | `supportsServiceTier?` | `boolean` | Tri-state canonical Fast capability fallback. `true` publishes Fast in the catalog, satisfies service-tier routing requirements, contributes a supported fingerprint, and lets fast mode inject the provider's canonical wire value on a compatible final adapter. `false` strips the field and never injects, and exact model declarations cannot reopen it. Absent leaves the provider unclassified: fast mode does not inject or normalize a canonical caller value, and caller values obey the final wire's forwarding permission (`chatServiceTier` on Chat; passthrough on Responses). The registry classifies canonical OpenAI (`true`), DeepSeek, and Volcengine Ark (`false`); set it explicitly only for custom gateways that genuinely support tiers. | | `fastEnabled?` | `boolean` | Operator switch for the provider's Fast lane. `false` turns Fast off (no Fast toggle, no `--fast` row, no fast wire field) and overrides `supportsServiceTier`. `true` enables a lane the registry marks opt-in. Absent keeps the registry default: off for `anthropic` and `anthropic-apikey`, whose fast mode spends usage credits at 2x price, and unchanged for every other provider. The dashboard Models page shows an Off/On row for opt-in providers. | | `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. Exact `true` enables canonical Fast for that model; exact `false` narrows provider defaults. An explicit provider-level `supportsServiceTier: false` remains fail-closed and cannot be reopened. Exact `true` does not authorize foreign caller-tier forwarding on Chat. Undeclared models fall back to provider-wide behavior. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index bb5555b55fc..db16f8502d3 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -96,7 +96,7 @@ cross-route credential fallback не существует. Строки API GPT- | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Опциональное клиентское выравнивание начала исходящих запросов, отдельное от учёта использования, биллинга и индикаторов rate limit апстрима. Лимит провайдера действует на все модели, а `models` сопоставляется с точными ID моделей апстрима и может только увеличить задержку. Ожидание очереди не расходует таймаут заголовков ответа. Поддерживаются HTTP, Responses WebSocket и явные вызовы адаптеров `fetchResponse`/`runTurn`. | | `responsesPath?` | `string` | Relative resource path для key-auth запросов `openai-responses`. Должен начинаться с `/` и не может содержать scheme, query или fragment. | | `chatCompletionsPath?` | `string` | Relative resource path для запросов `openai-chat`, зеркало `responsesPath` с теми же правилами формы. Нужен, когда один upstream обслуживает Chat Completions и Responses под разными префиксами: per-model wire override меняет адаптер и не трогает `baseUrl`, поэтому без него включённый Chat-запрос ушёл бы в Responses base. Поставляемый пример — Z.AI. | -| `upstreamWebsocket?` | `boolean` | Необязательный upstream Responses WebSocket для запросов `openai-responses` (по умолчанию `false`). Если upstream поддерживает этот протокол, потоковые POST-запросы используют настроенный путь Responses (по умолчанию `/v1/responses`), подключаются по WSS через HTTPS и перекодируются обратно в SSE для обычного конвейера. Провайдеры в режиме forward используют `{baseUrl}/responses`; провайдеры с ключом используют `responsesPath` или исторический fallback `/v1/responses`. Для HTTP остаётся SSE; пути, не относящиеся к Responses, и запросы `openai-chat` остаются на HTTP. | +| `upstreamWebsocket?` | `boolean` | Необязательный upstream Responses WebSocket для запросов `openai-responses` (по умолчанию `false`). Учитывается только для first-party upstream `https://api.openai.com/v1`; конечные точки пользовательских провайдеров всегда используют ограниченный HTTP/SSE, поскольку Bun не может применить ограничение размера входящего сообщения WebSocket до выделения памяти под всё сообщение. У канонического провайдера ChatGPT `openai` пропуск сохраняет upstream WebSocket для подходящих ходов, `false` отправляет потоковые ходы по HTTP/SSE, а `true` отклоняется; при `false` нативное управление и внедрение в середине хода недоступны. Это поле не зависит от клиентской настройки `websockets` и не меняет ни конечную точку, ни учётные данные. Для HTTP остаётся SSE; пути, не относящиеся к Responses, и запросы `openai-chat` остаются на HTTP. | | `supportsServiceTier?` | `boolean` | Три состояния поддержки `service_tier`. `true`: fast mode может подставлять поле, значения вызывающего сохраняются. `false`: поле удаляется и никогда не подставляется (апстрим, для которого задокументировано отсутствие поддержки, не должен его получать). Не задано: провайдер не классифицирован — значения вызывающего сохраняются без изменений, fast mode не подставляет. Registry классифицирует canonical OpenAI (`true`), DeepSeek и Volcengine Ark (`false`); задавайте явно только для custom gateway'ев, реально поддерживающих tier'ы. | | `preserveResponsesReasoningContent?` | `boolean` | Сохранять plaintext reasoning content в replay'нутых Responses reasoning item'ах вместо очистки (очистка — правило ChatGPT backend'а). Включайте для upstream'ов, чей контракт принимает reasoning replay, например DeepSeek. Proxy-minted `ocxr1` envelope'ы удаляются всегда. | | `disabled?` | `boolean` | Сохранить провайдера на диске, но исключить его из routing'а и из model/catalog-listing'ов. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 58b54d63ef8..d3215cfb4b6 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -98,7 +98,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `baseUrl` | `string` | Yukarı akış API temel URL'si. Çoğu yerleşik sabit uç nokta uyumsuzluğu yok sayar; çakışma güvenli anahtar önayarları aynı adlı daha eski özel bir hedefi korur. | | `responsesPath?` | `string` | Anahtar kimlik doğrulamalı `openai-responses` istekleri için göreli kaynak yolu. `/` ile başlamalı ve şema, sorgu veya parça içermemelidir. | | `chatCompletionsPath?` | `string` | `openai-chat` istekleri için göreli kaynak yolu; `responsesPath`'in aynasıdır ve aynı şekil kurallarına tabidir. Bir upstream Chat Completions ve Responses'u farklı öneklerde sunduğunda gereklidir: model başına wire override adaptörü değiştirir ve `baseUrl`'e dokunmaz, bu yüzden bu ayar olmadan etkin bir Chat isteği Responses base'e gönderilir. Gönderilen örnek Z.AI'dir. | -| `upstreamWebsocket?` | `boolean` | `openai-responses` istekleri için isteğe bağlı upstream Responses WebSocket aktarımıdır (varsayılan `false`). Upstream bu protokolü desteklediğinde, akışlı POST istekleri yapılandırılmış Responses yolunu (varsayılan `/v1/responses`) HTTPS tabanında WSS ile kullanır ve normal işlem hattı için SSE'ye yeniden kodlanır. Forward sağlayıcılar `{baseUrl}/responses`, anahtar kimlik doğrulamalı sağlayıcılar `responsesPath` veya eski `/v1/responses` geri dönüşünü kullanır. Düz HTTP SSE olarak kalır; Responses dışı yollar ve `openai-chat` istekleri HTTP'de kalır. | +| `upstreamWebsocket?` | `boolean` | `openai-responses` istekleri için isteğe bağlı upstream Responses WebSocket aktarımıdır (varsayılan `false`). Yalnızca first-party `https://api.openai.com/v1` upstream'i için geçerlidir; özel sağlayıcı uç noktaları her zaman sınırlı HTTP/SSE kullanır, çünkü Bun gelen WebSocket iletisinin tamamı ayrılmadan önce boyut sınırını uygulayamaz. Kanonik ChatGPT `openai` sağlayıcısında bu alanı atlamak uygun turlarda upstream WebSocket'i kullanır, `false` akışlı turları HTTP/SSE üzerinden gönderir ve `true` reddedilir; `false` iken yerel tur ortası yönlendirme ve enjeksiyon kullanılamaz. Bu alan istemci tarafı `websockets` ayarından bağımsızdır ve ne uç noktayı ne de kimlik bilgisini değiştirir. Düz HTTP SSE olarak kalır; Responses dışı yollar ve `openai-chat` istekleri HTTP'de kalır. | | `supportsServiceTier?` | `boolean` | Üç durumlu `service_tier` yeteneği. `true`: hızlı mod enjekte edebilir ve arayan değerleri korunur. `false`: alan kaldırılır ve asla enjekte edilmez (desteklemediği belgelenen yukarı akış bunu almamalıdır). Yok: sağlayıcı sınıflandırılmamıştır — arayan tarafından sağlanan değerler dokunulmadan korunur ve hızlı mod asla enjekte etmez. Kayıt defteri kurallı OpenAI'yi (`true`), DeepSeek'i ve Volcengine Ark'ı (`false`) sınıflandırır; bunu yalnızca katmanları gerçekten destekleyen özel ağ geçitleri için açıkça ayarlayın. | | `preserveResponsesReasoningContent?` | `boolean` | Düz metin akıl yürütme içeriğini boşaltmak yerine (boşaltma ChatGPT arka ucunun kuralıdır) tekrarlanan Responses akıl yürütme öğelerinde tutun. DeepSeek gibi sözleşmesi akıl yürütme tekrarını kabul eden yukarı akışlar için etkinleştirin. Proxy tarafından basılan `ocxr1` zarfları her zaman kaldırılır. | | `disabled?` | `boolean` | Sağlayıcıyı diskte tutun ancak yönlendirmeden ve model/katalog listelerinden hariç tutun. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index d1e63e3dcab..7f9565b7bbf 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -83,7 +83,7 @@ selector,而不是分配一个新名称。 | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 可选的客户端出站请求启动节流,与上游用量、计费和限流指标相互独立。提供商限制适用于所有模型,`models` 按上游模型精确 ID 匹配且只能增加延迟。排队等待不计入响应头超时。覆盖 HTTP、Responses WebSocket 以及显式适配器 `fetchResponse`/`runTurn` 调用。 | | `responsesPath?` | `string` | 用于 key-auth `openai-responses` 请求的相对资源路径。必须以 `/` 开头,且不能包含 scheme、query 或 fragment。 | | `chatCompletionsPath?` | `string` | 用于 `openai-chat` 请求的相对资源路径,是 `responsesPath` 的对应项,适用相同的路径规则。当同一上游以不同前缀提供 Chat Completions 和 Responses 时需要此配置:按模型的 wire override 只更换适配器而不改动 `baseUrl`,否则已启用的 Chat 请求会被发送到 Responses base。随附示例为 Z.AI。 | -| `upstreamWebsocket?` | `boolean` | 为 `openai-responses` 请求选择性启用上游 Responses WebSocket 传输(默认 `false`)。当上游支持该协议时,流式 POST 请求会使用配置的 Responses 路径(默认 `/v1/responses`),通过 HTTPS 基础 URL 以 WSS 连接,并重新编码为常规流程使用的 SSE。forward 提供者使用 `{baseUrl}/responses`;key-auth 提供者使用 `responsesPath`,未设置时回退到传统的 `/v1/responses`。普通 HTTP 仍使用 SSE;非 Responses 路径和 `openai-chat` 请求仍使用 HTTP。 | +| `upstreamWebsocket?` | `boolean` | 为 `openai-responses` 请求选择性启用上游 Responses WebSocket 传输(默认 `false`)。仅对第一方 `https://api.openai.com/v1` 上游生效;自定义提供者端点始终使用有界 HTTP/SSE,因为 Bun 无法在分配完整消息之前对入站 WebSocket 消息实施大小限制。对于规范 ChatGPT `openai` 提供商,省略该字段会在符合条件的轮次使用上游 WebSocket,`false` 通过 HTTP/SSE 发送流式轮次,`true` 会被拒绝;设为 `false` 时,原生轮次中操控与注入不可用。该字段独立于客户端侧的 `websockets` 设置,且不改变端点或凭据。普通 HTTP 仍使用 SSE;非 Responses 路径和 `openai-chat` 请求仍使用 HTTP。 | | `supportsServiceTier?` | `boolean` | `service_tier` 能力的三态。`true`:fast 模式可以注入,调用方提供的值也会被保留。`false`:剥离该字段且绝不注入(已明确不支持的上游不会收到它)。未设置:未分类——调用方提供的值原样保留,fast 模式绝不注入。注册表已对官方 OpenAI(`true`)、DeepSeek 和 Volcengine Ark(`false`)分类;仅对真正支持分层的自定义网关显式设置。 | | `preserveResponsesReasoningContent?` | `boolean` | 在重放的 Responses reasoning 项中保留明文 reasoning 内容,而不是清空(清空是 ChatGPT 后端的规则)。对接受 reasoning 重放的上游(如 DeepSeek)启用。代理生成的 `ocxr1` 信封始终会被剥离。 | | `disabled?` | `boolean` | 将提供者保留在磁盘上,但从路由和模型/目录列表中排除。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 773aa219a2e..9df50bc2bf2 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -63,7 +63,7 @@ ocx models provider openrouter on | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 選用的用戶端出站請求啟動節流,與上游用量、計費及限流指標彼此獨立。供應商限制適用於所有模型,`models` 依上游模型精確 ID 比對且只能增加延遲。排隊等待不計入回應標頭逾時。涵蓋 HTTP、Responses WebSocket 及明確的適配器 `fetchResponse`/`runTurn` 呼叫。 | | `responsesPath?` | `string` | Key-auth `openai-responses` 請求的相對資源路徑。必須以 `/` 開頭且不含 scheme、query 或 fragment。 | | `chatCompletionsPath?` | `string` | `openai-chat` 請求的相對資源路徑,為 `responsesPath` 的對應項,適用相同的路徑規則。當同一上游以不同前綴提供 Chat Completions 與 Responses 時需要此設定:按模型的 wire override 只更換適配器而不改動 `baseUrl`,否則已啟用的 Chat 請求會送往 Responses base。隨附範例為 Z.AI。 | -| `upstreamWebsocket?` | `boolean` | 為 `openai-responses` 請求選用上游 Responses WebSocket 傳輸(預設 `false`)。當上游支援此協定時,串流 POST 請求會使用設定的 Responses 路徑(預設 `/v1/responses`),透過 HTTPS 基礎 URL 以 WSS 連線,再重新編碼為一般流程使用的 SSE。forward 供應商使用 `{baseUrl}/responses`;key-auth 供應商使用 `responsesPath`,未設定時回退到傳統的 `/v1/responses`。一般 HTTP 仍使用 SSE;非 Responses 路徑與 `openai-chat` 請求仍使用 HTTP。 | +| `upstreamWebsocket?` | `boolean` | 為 `openai-responses` 請求選用上游 Responses WebSocket 傳輸(預設 `false`)。僅對第一方 `https://api.openai.com/v1` 上游生效;自訂供應商端點一律使用有界 HTTP/SSE,因為 Bun 無法在配置完整訊息之前對傳入 WebSocket 訊息套用大小限制。對於規範 ChatGPT `openai` 供應商,省略此欄位會在符合條件的回合使用上游 WebSocket,`false` 會以 HTTP/SSE 傳送串流回合,`true` 會被拒絕;設為 `false` 時,原生回合中操控與注入無法使用。此欄位獨立於用戶端 `websockets` 設定,且不會變更端點或認證資料。一般 HTTP 仍使用 SSE;非 Responses 路徑與 `openai-chat` 請求仍使用 HTTP。 | | `disabled?` | `boolean` | 將供應商保留在磁碟上但排除於路由與模型/目錄清單。 | | `apiKey?` | `string` | API 金鑰,或在請求時解析的 `${ENV_VAR}` / `$ENV_VAR` 參考。 | | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic 金鑰標頭風格。預設為原生 `x-api-key`;僅對 key-auth `anthropic` 供應商有效。 | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f729f1289df..640da9d5874 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1059,6 +1059,7 @@ "management-provider-reasoning-lists.test.ts": "server", "management-provider-reset-replay.test.ts": "server", "management-provider-synthetic-max.test.ts": "server", + "management-provider-upstream-websocket.test.ts": "server", "management-provider-validation.test.ts": "server", "management-provider-verbosity.test.ts": "server", "management-route-registry.test.ts": "server", diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 9f5ebefde09..8f17a2710b1 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1241,7 +1241,11 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti try { for await (const record of decodeServerSentEvents(response.body, { includeComments: true, translatorBudget: budget })) { - if (record.kind === "comment") { + // Anthropic's streaming API sends `event: ping` / `{"type":"ping"}` records alongside + // SSE comments ("Event streams may also include any number of ping events"). Both mean the + // upstream is still alive, so a long silent thinking block must not look like a dead + // upstream to the bridge stall watchdog (#5707). + if (record.kind === "comment" || record.event === "ping") { yield { type: "heartbeat" }; continue; } @@ -1369,6 +1373,12 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti yield* emitDone(); break; } + case "ping": { + // A data-only `{"type":"ping"}` record carries no SSE `event:` line, so the + // liveness check above cannot see it (#5707). + yield { type: "heartbeat" }; + break; + } case "error": { const err = data.error as { message?: string } | undefined; yield { type: "error", message: err?.message ?? "Anthropic error" }; diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 489075b1e5b..547a8a1c9b7 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -442,13 +442,16 @@ function translateAnthropicRequest( if (outputConfigFormat) body.text = { format: outputConfigFormat }; let cacheKeySource: ClaudeCacheKeySource = null; if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") { - body.user = raw.metadata.user_id; + const userIdHash = createHash("sha256").update(raw.metadata.user_id).digest("hex"); + // OpenAI and Azure reject `user` longer than 64 chars, and Claude Code's metadata.user_id + // is a JSON blob well past that; send its 64-char hash instead of the raw value. + body.user = raw.metadata.user_id.length <= 64 ? raw.metadata.user_id : userIdHash; // OpenAI-side prompt caching is routed by prompt_cache_key (Codex clients send // their session id; without it consecutive /v1/messages turns reported // cached_tokens: 0 on the ChatGPT backend — devlog 090). Claude Code's // metadata.user_id embeds the session uuid, so hashing it yields a stable // per-session key with a bounded length/charset. - body.prompt_cache_key = createHash("sha256").update(raw.metadata.user_id).digest("hex").slice(0, 32); + body.prompt_cache_key = userIdHash.slice(0, 32); cacheKeySource = "metadata"; } else if (systemParts.length > 0) { // Claude Desktop sends no metadata.user_id (H1, devlog 130): without any key the diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 75976b171e5..2e1f4150a16 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -312,9 +312,11 @@ export const providerConfigSchema = z.object({ upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES) .nullish() .transform(value => value ?? undefined), - // Opt-in upstream Responses WebSocket for OpenAI-compatible providers (e.g. - // aggregators whose WebSocket ingress is measurably faster than SSE). The - // canonical ChatGPT backend WS selection is independent of this flag. + // Opt-in upstream Responses WebSocket for OpenAI-compatible providers, honored only + // for the first-party api.openai.com/v1 upstream; other custom endpoints stay on + // bounded HTTP/SSE. On the canonical ChatGPT `openai` provider the same field selects + // the transport: omitted keeps the upstream WebSocket on eligible turns, explicit + // `false` sends streaming turns over HTTP/SSE, and provider management rejects `true`. upstreamWebsocket: z.boolean().optional(), directGeminiWireRenames: z.boolean().optional(), googleToolSchemaPolicy: z.enum(["compatible", "reject-lossy"]).optional(), diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 72cb3e2c7db..96698c14e46 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -744,6 +744,12 @@ export function providerManagementConfigError( // validation and then rejected by the seed comparison, so canonical OpenAI could never // set OR clear it — the value was admitted and then refused in the same request. delete canonicalCandidate.annotateEmptyToolOutputs; + // Canonical ChatGPT keeps WebSocket as the default, but an operator may + // select the existing HTTP/SSE path without changing its auth or endpoint. + if (raw.upstreamWebsocket !== undefined) { + if (raw.upstreamWebsocket !== false) return "provider openai upstreamWebsocket must be false or omitted"; + delete canonicalCandidate.upstreamWebsocket; + } const canonical = seed && (options?.allowOperatorOverlays ? matchesCanonicalProviderSeed(canonicalCandidate, seed) : sameCanonicalProviderSeed(canonicalCandidate, seed)); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index fd5c743aae2..330b2e6c374 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -943,7 +943,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise[0], init?: RequestInit) => { - const upstreamWebsocket = provider.upstreamWebsocket === true; + const upstreamWebsocket = provider.upstreamWebsocket; if (!options.httpOnly && typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) { const egress = egressFor(input); diff --git a/src/server/responses/input-admission.ts b/src/server/responses/input-admission.ts index 950a3d90505..2ef82ca03a1 100644 --- a/src/server/responses/input-admission.ts +++ b/src/server/responses/input-admission.ts @@ -20,7 +20,7 @@ import { getModelMetadata } from "../../generated/model-metadata"; import { estimateTokens } from "../../lib/token-estimate"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { modelRecordValue } from "../../reasoning-effort"; -import type { OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../../types"; +import { modelInList, type OcxContentPart, type OcxParsedRequest, type OcxProviderConfig } from "../../types"; /** * Multiplier applied to the ceiling before refusing. @@ -112,10 +112,12 @@ function contentTokens(content: string | readonly OcxContentPart[], modelId: str * their content as `OcxAssistantContentPart[]` — text, thinking blocks, and tool calls whose * JSON arguments are frequently the largest single item in an agent conversation. A walk * that counted only `{type:"text"}` would undercount exactly the turns that trigger this - * gate. + * gate. The routed provider determines whether replayed thinking reaches the wire. */ -export function estimateInputTokens(parsed: OcxParsedRequest, modelId: string): number { +export function estimateInputTokens(parsed: OcxParsedRequest, modelId: string, provider?: OcxProviderConfig): number { const { context } = parsed; + const countThinking = provider?.adapter !== "openai-chat" + || modelInList(provider.preserveReasoningContentModels, parsed.modelId); let total = 0; for (const prompt of context.systemPrompt ?? []) total += estimateTokens(prompt, modelId); @@ -124,7 +126,9 @@ export function estimateInputTokens(parsed: OcxParsedRequest, modelId: string): if (message.role === "assistant") { for (const part of message.content) { if (part.type === "text") total += estimateTokens(part.text, modelId); - else if (part.type === "thinking") total += estimateTokens(part.thinking, modelId); + else if (part.type === "thinking") { + if (countThinking) total += estimateTokens(part.thinking, modelId); + } else total += estimateTokens(part.name, modelId) + estimateTokens(JSON.stringify(part.arguments), modelId); } // Opaque provider blob replayed verbatim upstream, so it costs real input tokens. @@ -296,7 +300,7 @@ export function checkComboTargetInputAdmission( const requiredOutputHeadroom = targetOutput === null ? requestedOutput : Math.min(requestedOutput, targetOutput); - const estimatedTokens = estimateInputTokens(parsed, modelId); + const estimatedTokens = estimateInputTokens(parsed, modelId, provider); return { admitted: estimatedTokens <= ceiling && estimatedTokens + requiredOutputHeadroom <= window, estimatedTokens, @@ -319,6 +323,6 @@ export function checkInputAdmission( ): InputAdmissionResult { const ceiling = resolveInputCeiling(provider, providerName, modelId, nativeContextCap); if (ceiling === null) return { admitted: true, estimatedTokens: 0, ceiling: null }; - const estimatedTokens = estimateInputTokens(parsed, modelId); + const estimatedTokens = estimateInputTokens(parsed, modelId, provider); return { admitted: estimatedTokens <= ceiling * ADMISSION_TOLERANCE, estimatedTokens, ceiling }; } diff --git a/src/server/responses/native-response-control.ts b/src/server/responses/native-response-control.ts index 42b926df4d3..13c4b4b48f9 100644 --- a/src/server/responses/native-response-control.ts +++ b/src/server/responses/native-response-control.ts @@ -31,9 +31,9 @@ export function markNativeControlResponse(response: Response): Response { native /** Recognize a marked native response by identity, not by caller-controlled content. */ export function isNativeControlResponse(response: Response): boolean { return nativeControlResponses.has(response); } -/** Preserve canonical ChatGPT eligibility; only injection may use the separately billed public API. */ +/** Canonical ChatGPT needs its upstream WS enabled; only injection may use the separately billed public API. */ export function nativeResponseControlEligible(provider: OcxProviderConfig, control?: NativeResponseControl): boolean { - if (isCanonicalOpenAiForwardProvider(provider)) return true; + if (isCanonicalOpenAiForwardProvider(provider)) return provider.upstreamWebsocket !== false; return control?.kind === "injection" && provider.adapter === "openai-responses" && provider.upstreamWebsocket === true && provider.authMode !== "forward" && provider.baseUrl?.replace(/\/+$/, "") === "https://api.openai.com/v1"; diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 54820bef981..91034cda824 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -128,12 +128,145 @@ import { linkAbortSignal, UPSTREAM_JSON_BODY_READ_OPTIONS } from "./core-lifetim import { registerTurn, unregisterTurn, trackStreamLifetime } from "../lifecycle"; import { relaySseEagerBounded } from "../relay-eager"; import { readBoundedResponseBody } from "../../lib/bounded-body"; +import { idleDeadline } from "../../lib/abort"; +import { resolveStallTimeoutSec } from "../../stall-timeout"; import { formatErrorResponse } from "../../bridge"; import { inspectResponseLogJson } from "../request-log"; import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; import { responsesJsonToSseStream } from "../responses-json-events"; +const PLAINTEXT_V2_SSE_PREFIX_LIMIT = 4096; + +/** Prefix-probe budget: bounds one silent gap and the whole probe alike. */ +interface PlaintextV2SseProbeOptions { + timeoutMs: number; + signal?: AbortSignal; +} + +function classifyPlaintextV2SsePrefix(prefix: string): "sse" | "unknown" | "more" { + const lastLineEnd = prefix.lastIndexOf("\n"); + if (lastLineEnd < 0) return "more"; + for (const rawLine of prefix.slice(0, lastLineEnd + 1).split("\n")) { + const line = rawLine.replace(/\r$/, "").trim(); + if (!line || line.startsWith(":")) continue; + if (/^(id|retry):/.test(line)) continue; + if (line.startsWith("event:")) { + return /^event:\s*(?:response\.[\w.-]+|error)$/.test(line) ? "sse" : "unknown"; + } + if (line.startsWith("data:")) { + try { + const value = JSON.parse(line.slice(5).trim()) as { type?: unknown }; + return typeof value.type === "string" && /^(?:response\.[\w.-]+|error)$/.test(value.type) + ? "sse" : "unknown"; + } catch { + return "unknown"; + } + } + return "unknown"; + } + return "more"; +} + +/** + * Confirm an unlabeled successful body is Responses SSE before alias restoration. + * + * The probe waits at most `timeoutMs` for a first recognized event, then hands the body to the + * client with no deadline of its own: a stall in the prefix is a probe failure, and a stall after + * it belongs to the delivered stream. + */ +async function classifyPlaintextV2SseResponse( + response: Response, + probe: PlaintextV2SseProbeOptions, +): Promise { + if (!response.body) return response; + const reader = response.body.getReader(); + // A non-conforming stream can throw synchronously from cancel(); neither that nor a + // rejected cancel may escape past the probe's own deadline. + const cancelReader = (reason?: unknown): void => { + try { + void reader.cancel(reason).catch(() => undefined); + } catch { + // Some stream implementations throw synchronously from cancel(). + } + }; + const unrecognized = (): Response => new Response(null, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + const { timeoutMs, signal } = probe; + const stalled = new DOMException("Plaintext V2 SSE prefix probe stalled", "TimeoutError"); + let rejectProbe: ((reason: unknown) => void) | undefined; + const failed = new Promise((_resolve, reject) => { rejectProbe = reject; }); + // The race below always observes this rejection; this covers a deadline that fires after the + // race already settled with a chunk, which would otherwise be an unhandled rejection. + void failed.catch(() => undefined); + const inactivity = idleDeadline(timeoutMs, () => rejectProbe?.(stalled)); + // A drip-fed body can restart the inactivity window forever, so the probe also carries one + // total budget that starts when the probe begins. + const totalTimer = timeoutMs > 0 ? setTimeout(() => rejectProbe?.(stalled), timeoutMs) : undefined; + const onAbort = (): void => rejectProbe?.(signal?.reason); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); + const decoder = new TextDecoder(); + const buffered: Uint8Array[] = []; + let prefix = ""; + let inspectedBytes = 0; + try { + while (inspectedBytes < PLAINTEXT_V2_SSE_PREFIX_LIMIT) { + if (signal?.aborted) throw signal.reason; + // Armed for every read, so a chunk that arrives restarts the window at the next iteration. + inactivity.reset(); + const read = reader.read(); + // Observe a late read rejection when the deadline or the client wins the race. + void read.catch(() => undefined); + const next = await Promise.race([read, failed]); + if (signal?.aborted) throw signal.reason; + if (next.done) break; + buffered.push(next.value); + const inspected = next.value.subarray(0, PLAINTEXT_V2_SSE_PREFIX_LIMIT - inspectedBytes); + inspectedBytes += inspected.byteLength; + prefix += decoder.decode(inspected, { stream: true }); + const kind = classifyPlaintextV2SsePrefix(prefix); + if (kind === "sse") { + let bufferedIndex = 0; + const body = new ReadableStream({ + async pull(controller) { + if (bufferedIndex < buffered.length) { + controller.enqueue(buffered[bufferedIndex++]!); + return; + } + try { + const result = await reader.read(); + if (result.done) controller.close(); + else controller.enqueue(result.value); + } catch (error) { + controller.error(error); + } + }, + cancel(reason) { return reader.cancel(reason); }, + }); + const headers = new Headers(response.headers); + headers.set("content-type", "text/event-stream"); + return new Response(body, { status: response.status, statusText: response.statusText, headers }); + } + if (kind === "unknown") break; + } + } catch (error) { + // Timeout, client abort, or a failed read fails closed through the unrecognized-body exit, + // which the caller already answers as the unsupported-content-type 502. + cancelReader(error); + return unrecognized(); + } finally { + inactivity.cancel(); + if (totalTimer !== undefined) clearTimeout(totalTimer); + signal?.removeEventListener("abort", onAbort); + } + cancelReader(); + return unrecognized(); +} + /** One responsibility of the Responses request pipeline; state owners are explicit. */ export async function deliverPassthroughResponse( requestContext: Pick, @@ -187,7 +320,6 @@ export async function deliverPassthroughResponse( ): Promise { const { logCtx, config, options, req } = requestContext; const { - upstreamResponse, codexSafetyBufferingOptions, upstream, connectMs, @@ -212,18 +344,29 @@ export async function deliverPassthroughResponse( const { openAiSidecar } = sidecarState; const { requestBindings } = transportState; + let upstreamResponse = nativeExchange.upstreamResponse; + const originalContentType = upstreamResponse.headers.get("content-type"); + if (isUsageDebugEnabled() && originalContentType) logCtx.usageDebugContentType = originalContentType; + if (responseEffects.plaintextV2AgentMessageToolNames.size > 0 + && upstreamResponse.ok && upstreamResponse.body && parsed.stream + && !originalContentType?.toLowerCase().includes("text/event-stream") + && !originalContentType?.toLowerCase().includes("application/json") + && !isCodexWsUpstreamResponse(upstreamResponse) + && !(options.nativeControl && isNativeControlResponse(upstreamResponse))) { + upstreamResponse = await classifyPlaintextV2SseResponse(upstreamResponse, { + timeoutMs: resolveStallTimeoutSec(config.stallTimeoutSec) * 1000, + signal: options.abortSignal ?? req.signal, + }); + } + const headers = sanitizePassthroughHeaders(upstreamResponse.headers, codexSafetyBufferingOptions); const resolvedModel = headers.get("openai-model")?.trim(); if (resolvedModel) { logCtx.servedModel = resolvedModel; if (!logCtx.preserveResolvedModelFromRoute) logCtx.resolvedModel = resolvedModel; } - if (isUsageDebugEnabled()) { - const upstreamContentType = upstreamResponse.headers.get("content-type"); - if (upstreamContentType) logCtx.usageDebugContentType = upstreamContentType; - } - // The chatgpt backend may omit Content-Type on SSE responses. Fall back to - // treating a successful body as SSE when the caller requested streaming. + // ChatGPT may omit Content-Type on SSE responses. Plaintext V2 responses + // reach this fallback only after their first Responses event is confirmed. const passthroughCt = headers.get("content-type")?.toLowerCase(); const isEventStream = passthroughCt?.includes("text/event-stream") || (responseEffects.plaintextV2AgentMessageToolNames.size === 0 && upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream); diff --git a/src/server/responses/ws-upstream.ts b/src/server/responses/ws-upstream.ts index 4bba3ad9a15..7e7af0791e4 100644 --- a/src/server/responses/ws-upstream.ts +++ b/src/server/responses/ws-upstream.ts @@ -85,10 +85,11 @@ export function shouldUseCodexWsUpstream( url: string, init?: RequestInit, runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), - upstreamWebsocketConfigured = false, + upstreamWebsocketConfigured?: boolean, ): boolean { if (!bunSupportsBoundedCodexWsRelay(runtime)) return false; if (socks5ProxyFromEnv()) return false; + if (url === CODEX_RESPONSES_HTTP_URL && upstreamWebsocketConfigured === false) return false; // Bun's client WebSocket API delivers only fully assembled messages and has // no enforceable inbound payload limit. Keep arbitrary provider endpoints on // bounded HTTP/SSE until the client can reject fragmented text and binary diff --git a/src/types/provider.ts b/src/types/provider.ts index 3f8651334b0..7e52a323c5c 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -468,11 +468,14 @@ export interface OcxProviderConfig { * streaming POST turns use the configured Responses path (default `/v1/responses`): forward * providers use `{baseUrl}/responses`, while key-auth providers use `responsesPath` or the * legacy `/v1/responses` fallback. HTTPS providers use wss and are re-encoded to SSE; HTTP - * providers continue using SSE, and `openai-chat` requests stay on HTTP. This mirrors the - * canonical ChatGPT backend optimization for any OpenAI-compatible gateway that speaks the - * Responses WebSocket protocol (for example an aggregator like sub2api whose WS ingress is - * measurably faster than its SSE queue). Default false. Canonical ChatGPT backend WS selection - * is independent of this flag. + * providers continue using SSE, and `openai-chat` requests stay on HTTP. On a custom provider this + * opt-in is honored only for the first-party `https://api.openai.com/v1` upstream; every other + * endpoint stays on bounded HTTP/SSE. On the canonical ChatGPT `openai` provider the field + * selects the transport instead of opting in: omitted keeps the upstream WebSocket for eligible + * turns, an explicit `false` sends streaming turns over HTTP/SSE, and provider management rejects `true`. Either + * way it is independent of the client-facing `websockets` setting and changes neither the + * endpoint nor the credential; with `false`, native mid-turn steering and injection are + * unavailable. */ upstreamWebsocket?: boolean; /** diff --git a/structure/config.md b/structure/config.md index 9330ca6dfff..57337722c7a 100644 --- a/structure/config.md +++ b/structure/config.md @@ -101,6 +101,7 @@ merge cannot turn them into a valid config while discarding the original bytes. | Retained state | `appOwnedMemoryBudgetMb` | Process-wide eviction target for app-owned logs, caches, blobs, and continuation payloads. Default 256 MiB, valid 64..4096; pinned state may temporarily exceed the target, but every pin-capable store has a finite local cap and their documented aggregate stays below `APP_OWNED_WORST_CASE_PINNED_BYTES` (512 MiB). Neither value caps RSS or native runtime memory. | | Spend | `spend.root`, `spend.identity`, `spend.pool`, `spend.retentionDays` | Durable token ceilings for the spend-reservation ledger. Absent is the default and means observe-only accounting: spend is still journaled and nothing is refused, so observe-only and enforced servers take the same state-directory writer lease. One live process may write one directory; explicit sibling instances need separate `OPENCODEX_HOME` directories. There is no default figure for any scope — the ledger is on by default, so a shipped ceiling would refuse real traffic on upgrade against a number nobody chose. Strictly validated and positive-integer only, because 0 would read as a budget and refuse everything; a malformed section degrades to no ceiling, which is why the write path rejects it and load diagnostics report it. Resolution and application live in `src/lib/spend-reservation-ledger.ts`; see [`transports/responses.md`](transports/responses.md). | | Transport | stream mode, timeouts, proxy settings, `websockets`, `emptyCompletionRetry` | `streamMode` persists in config.json; Windows services need a persisted input, and macOS uses it for explicit eager-relay opt-in. Empty-completion replay is an explicit top-level opt-in because its second upstream request may be billable. | +| Canonical ChatGPT upstream transport | `providers.openai.upstreamWebsocket` | Omitted uses upstream WebSocket when eligible; explicit `false` selects HTTP/SSE without changing the canonical provider identity. `true` is rejected on the canonical row. This is independent of the client-facing `websockets` setting. | | Provider egress | `providers..proxy`, `providers..noProxy` | An absent `proxy` inherits global egress; `"direct"` or `null` forces direct egress; HTTP(S) and SOCKS5(H) URLs select a provider-owned proxy. `noProxy` uses NO_PROXY syntax and sends a matching destination direct across either a provider-owned or inherited global proxy. `src/lib/provider-egress.ts` owns parsing and request-local resolution. | | Credentials | `apiKeys` | Data-plane only; never admitted to `/api/*`. | | Lifecycle | `codexAutoStart`, shim/start behavior, resume-history sync, storage cleanup | Startup safety reads these; see [`gui-and-management-api.md`](gui-and-management-api.md). | diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index f6c02ea1a76..2e7c2f24764 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -145,6 +145,11 @@ is validated as `compatible` or `reject-lossy` before live adoption and persiste value changes neither state. Omission remains absent and is resolved by the Google adapter rather than materialized by the management API. +`GET /api/providers` reports `upstreamWebsocket` as configured rather than coerced, and omits the +key when it is unset: on the canonical `openai` row an absent value already means the upstream +WebSocket transport, so answering `false` there would let a save that round-trips the row write +that disable back to disk. + `src/server/index.ts` authenticates and routes `/api/*`, then delegates to `src/server/management-api.ts`, which composes the route modules under `src/server/management/`. Codex account routes live in `src/codex/auth-api/routes.ts` because they own the credential store, not diff --git a/structure/subagents.md b/structure/subagents.md index ed58bb217dc..3b67eebdd78 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -32,6 +32,13 @@ snapshot repair. Malformed, conflicting, unsupported or over-limit responses fai retrying the model. Raw stream inspection cannot publish plaintext continuation state: only restored client blocks reach its dedicated bounded collector. Foreign namespaces and opaque argument/metadata values remain unchanged; the empty encrypted-function-args marker is preserved. +For a streamed response whose content type is missing or is neither `application/json` nor a +recognizable event stream, the native passthrough reads at most the first 4 KiB to confirm a +Responses SSE event before restoring aliases; an `application/json` body takes the bounded JSON +path instead. That probe is bounded by the request's `stallTimeoutSec`: one total budget for the +prefix, plus a per-read inactivity window the arrival of a chunk restarts, so a drip-fed or silent +upstream fails closed instead of holding the turn open. A body that does not match still fails +closed, and its bytes never reach Codex as a successful response. Startup warns that task text can remain in Codex history, selected-provider requests and local response/debug state. This is application-level plaintext over HTTPS, depends on undocumented diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 3b235faa42f..2629b7fb7f5 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -413,6 +413,8 @@ output actually share. When the caller declared `max_output_tokens`, `checkComboTargetInputAdmission` requires both `estimated input <= ceiling` and `estimated input + min(declared output, target output ceiling) <= window`, so the output reserve is counted once rather than charged twice against an already-tightened input budget. +Both direct and combo estimates omit replayed assistant thinking for `openai-chat` models outside +`preserveReasoningContentModels`, matching the adapter's wire omission; other targets still count it. The refusal is local: HTTP 413 `input_admission_refused` before any upstream bytes are sent, which existing combo policy already treats as a safe hop. That ordering is the whole point. A target whose diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 6c9ec9f1630..d5aeacf7151 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -8,6 +8,7 @@ The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agen is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Management provider-validation calls use the [initialization-independent relative send-path validation](../config.md#provider-relative-send-paths) before persistence. Cursor's localized native-shell names follow the [routing-commentary guard contract](../providers/cursor.md#cursor-native-exec). Plaintext collaboration restoration treats a null namespace as absent, rejects non-string namespace types, and restores the native namespace/name pair before HTTP/WS delivery and continuation publication. +When a successful streamed native response has a missing or unrecognized non-JSON content type, the plaintext V2 path confirms a bounded Responses SSE prefix, under the server's `stallTimeoutSec` probe budget, before applying that restoration; an `application/json` body takes the bounded JSON path instead, and an unknown, stalled, or unreadable body retains the fail-closed response. ## Responses HTTP/SSE diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index ce8954be79f..516c178cb61 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -31,6 +31,8 @@ NOT count as activity for the bridge's own watchdog: a bounded stall deadline (d configurable via `stallTimeoutSec`, checked on the 2 s heartbeat tick) closes the stream with `response.incomplete` / `upstream_stall_timeout` and cancels the upstream request if no real adapter events arrive. Adapter-yielded `{ type: "heartbeat" }` events DO reset the watchdog. +The Anthropic adapter maps both SSE comments and `ping` events to that heartbeat (#5707), so an +upstream that only pings while a long thinking block is silent still counts as live. Top-level `emptyCompletionRetry: true` opts Responses turns into one identical replay when an upstream turn produces neither output text nor a tool call, including a stream that ends before a @@ -211,7 +213,12 @@ the upgrade with 426 so Codex falls back to HTTP cleanly. That setting controls the client-facing upgrade only. The transparent upstream ChatGPT WS optimization described above is selected independently and still -returns the same downstream SSE contract. Its WSS route checks NO_PROXY first, then selects the +returns the same downstream SSE contract. The canonical `openai` provider uses +upstream WebSocket by default; `providers.openai.upstreamWebsocket: false` sends +its streaming turns over HTTP/SSE instead. This explicit choice also makes +native mid-turn steering and injection unavailable on that provider. It does +not change the endpoint, credential, or downstream event format. +Its WSS route checks NO_PROXY first, then selects the first non-empty HTTPS_PROXY, https_proxy, ALL_PROXY, or all_proxy value. HTTP_PROXY alone does not route WSS. Unsupported or malformed selected proxy values skip the WebSocket attempt and use the existing SSE path immediately; they never fall through to a lower-priority proxy or direct WebSocket diff --git a/tests/adapters/anthropic/anthropic-compatible-stream.test.ts b/tests/adapters/anthropic/anthropic-compatible-stream.test.ts index cc1d3ce57c0..fc6fcdd034d 100644 --- a/tests/adapters/anthropic/anthropic-compatible-stream.test.ts +++ b/tests/adapters/anthropic/anthropic-compatible-stream.test.ts @@ -89,6 +89,40 @@ function liveCommentResponse(intervalMs: number): { response: Response; stop: () }; } +/** + * A silent upstream that only sends `ping` records, then one normal completion turn. + * Deliberately carries NO SSE comment lines, so a bridge that ignored `ping` would still see + * a stale upstream and time out (#5707). + */ +function livePingResponse(intervalMs: number, durationMs: number): { response: Response } { + const encoder = new TextEncoder(); + const pingFrame = 'event: ping\ndata: {"type":"ping"}\n\n'; + let timer: ReturnType | undefined; + let finished = false; + const body = new ReadableStream({ + start(controller) { + const finish = (close: boolean) => { + if (finished) return; + finished = true; + if (timer) clearInterval(timer); + try { + if (close) controller.enqueue(encoder.encode(`${kimiCompatibleSse}\n\n`)); + controller.close(); + } catch { /* already closed */ } + }; + timer = setInterval(() => { + try { controller.enqueue(encoder.encode(pingFrame)); } catch { finish(false); } + }, intervalMs); + setTimeout(() => finish(true), durationMs); + }, + cancel() { + finished = true; + if (timer) clearInterval(timer); + }, + }); + return { response: new Response(body, { headers: { "content-type": "text/event-stream" } }) }; +} + describe("Anthropic-compatible reasoning stream termination (#312)", () => { test("comment-only stream records become adapter heartbeat events", async () => { const events = await collectAdapterEvents(new Response( @@ -130,6 +164,38 @@ describe("Anthropic-compatible reasoning stream termination (#312)", () => { expect(text).not.toContain("response.incomplete"); }); + test("ping records become adapter heartbeat events (#5707)", async () => { + // Named (`event: ping`) and data-only (`{"type":"ping"}`) records are both liveness, per + // "Event streams may also include any number of ping events". + const events = await collectAdapterEvents(arbitrarilyChunkedResponse([ + 'event: ping\ndata: {"type":"ping"}', + 'data: {"type":"ping"}', + kimiCompatibleSse, + ].join("\n\n"))); + + expect(events.slice(0, 2)).toEqual([{ type: "heartbeat" }, { type: "heartbeat" }]); + expect(events).toContainEqual({ type: "text_delta", text: "visible" }); + expect(events.at(-1)).toEqual({ type: "done", usage: undefined }); + }); + + test("ping-only live upstream does not trip the bridge stall watchdog", async () => { + const stream = bridgeToResponsesSSE( + createAnthropicAdapter(provider).parseStream(livePingResponse(25, 1_300).response), + "kimi/k3", + undefined, + undefined, + undefined, + undefined, + 50, + { stallTimeoutSec: 1 }, + ); + const text = await new Response(stream).text(); + + expect(text).not.toContain("upstream_stall_timeout"); + expect(text).toContain("response.completed"); + expect(text).toContain("visible"); + }); + test("preserves reasoning and visible text, then emits done from final message_stop", async () => { const events = await collectAdapterEvents(arbitrarilyChunkedResponse()); diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index 2390d7f82d9..512c5ed7cd0 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { AnthropicRequestError as LeafAnthropicRequestError } from "../../src/claude/inbound-records"; import { repoPath } from "../helpers/repo-root"; @@ -519,6 +520,31 @@ describe("prompt cache key provenance (devlog 130 B3)", () => { expect(body.prompt_cache_key).toMatch(/^[0-9a-f]{32}$/); }); + test("metadata.user_id longer than 64 chars is hashed into user (OpenAI/Azure limit)", () => { + const userId = JSON.stringify({ device_id: "d".repeat(64), account_uuid: "", session_id: "s".repeat(36) }); + const { body } = anthropicToResponsesTranslation({ + model: "m", max_tokens: 1, messages, + metadata: { user_id: userId }, + }); + expect(body.user).toBe(createHash("sha256").update(userId).digest("hex")); + expect(body.prompt_cache_key).toBe(createHash("sha256").update(userId).digest("hex").slice(0, 32)); + }); + + test("metadata.user_id boundary: exactly 64 chars forwarded, 65 chars hashed", () => { + const atLimit = "u".repeat(64); + const overLimit = "u".repeat(65); + const { body: forwarded } = anthropicToResponsesTranslation({ + model: "m", max_tokens: 1, messages, + metadata: { user_id: atLimit }, + }); + expect(forwarded.user).toBe(atLimit); + const { body: hashed } = anthropicToResponsesTranslation({ + model: "m", max_tokens: 1, messages, + metadata: { user_id: overLimit }, + }); + expect(hashed.user).toBe(createHash("sha256").update(overLimit).digest("hex")); + }); + test("no metadata + system present: fallback key from system hash, source=system", () => { const a = anthropicToResponsesTranslation({ model: "m", max_tokens: 1, messages, system: "be nice" }); const b = anthropicToResponsesTranslation({ model: "m", max_tokens: 1, messages, system: "be nice" }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 28b4f0a5913..471ceb3ea8b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -891,6 +891,7 @@ "management-provider-reasoning-lists.test.ts": "server", "management-provider-reset-replay.test.ts": "server", "management-provider-synthetic-max.test.ts": "server", + "management-provider-upstream-websocket.test.ts": "server", "management-provider-validation.test.ts": "server", "management-provider-verbosity.test.ts": "server", "management-route-registry.test.ts": "server", diff --git a/tests/helpers/ws-upstream-fixtures.ts b/tests/helpers/ws-upstream-fixtures.ts index 8fd6c719558..248b4608e28 100644 --- a/tests/helpers/ws-upstream-fixtures.ts +++ b/tests/helpers/ws-upstream-fixtures.ts @@ -13,7 +13,7 @@ import { */ export const BOUNDED_WS_RUNTIME = "1.4.0"; -export function shouldUseCodexWsUpstream(url: string, init?: RequestInit, upstreamWebsocket = false): boolean { +export function shouldUseCodexWsUpstream(url: string, init?: RequestInit, upstreamWebsocket?: boolean): boolean { return rawShouldUseCodexWsUpstream(url, init, BOUNDED_WS_RUNTIME, upstreamWebsocket); } diff --git a/tests/responses/ws-native-injection.test.ts b/tests/responses/ws-native-injection.test.ts index 9c67aa83410..9001c1a7a1a 100644 --- a/tests/responses/ws-native-injection.test.ts +++ b/tests/responses/ws-native-injection.test.ts @@ -416,6 +416,9 @@ test("public injection excludes custom gateways, forwarded auth and an unopted A expect(nativeResponseControlEligible({ ...provider, upstreamWebsocket: false }, channel)).toBe(false); expect(nativeResponseControlEligible({ ...provider, authMode: "forward" }, channel)).toBe(false); expect(nativeResponseControlEligible(provider)).toBe(false); + const canonical = injectionConfig().providers.openai; + expect(nativeResponseControlEligible(canonical, channel)).toBe(true); + expect(nativeResponseControlEligible({ ...canonical, upstreamWebsocket: false }, channel)).toBe(false); }); test("injection mode refuses simultaneous steering instead of fabricating protocol equivalence", () => { diff --git a/tests/responses/ws-upstream.test.ts b/tests/responses/ws-upstream.test.ts index f306942bc47..43d6b3aeef1 100644 --- a/tests/responses/ws-upstream.test.ts +++ b/tests/responses/ws-upstream.test.ts @@ -133,8 +133,7 @@ describe("shouldUseCodexWsUpstream", () => { }); test("keeps configured provider endpoints on bounded HTTP SSE", () => { - // The canonical backend ignores the flag. - expect(shouldUseCodexWsUpstream(CODEX_URL, streamingInit(), false)).toBe(true); + expect(shouldUseCodexWsUpstream(CODEX_URL, streamingInit(), false)).toBe(false); // Bun cannot reject oversized messages before assembling them, so even an // opted-in provider cannot join the WebSocket lane. expect(shouldUseCodexWsUpstream("https://sub2api.example.com/v1/responses", streamingInit(), true)).toBe(false); @@ -274,20 +273,21 @@ describe("providerFetch routing", () => { } as unknown as OcxProviderConfig; const wrapped = providerFetch(provider, BOUNDED_WS_RUNTIME); - // Eligible: WS adapter serves it, base fetch untouched. const wsResponse = await wrapped(CODEX_URL, streamingInit()); expect(wsResponse.headers.get("content-type")).toContain("text/event-stream"); expect(baseCalls).toHaveLength(0); expect(FakeWebSocket.instances).toHaveLength(1); - // Non-streaming body: base fetch. await wrapped(CODEX_URL, { method: "POST", body: JSON.stringify({ model: "m" }) }); - // Different host: base fetch. await wrapped("https://api.openai.com/v1/responses", streamingInit()); - // Request-object input: base fetch (WS path only handles string URLs). await wrapped(new Request(CODEX_URL, streamingInit() as RequestInit)); expect(baseCalls).toHaveLength(3); expect(FakeWebSocket.instances).toHaveLength(1); + provider.upstreamWebsocket = false; + const httpOnly = providerFetch(provider, BOUNDED_WS_RUNTIME); + expect(await (await httpOnly(CODEX_URL, streamingInit())).text()).toBe("base"); + expect(baseCalls).toHaveLength(4); + expect(FakeWebSocket.instances).toHaveLength(1); }); test("routes an opt-in provider's Responses streams over bounded HTTP SSE", async () => { diff --git a/tests/server/input-admission.test.ts b/tests/server/input-admission.test.ts index 90afdbe92b1..b6b18f18b3a 100644 --- a/tests/server/input-admission.test.ts +++ b/tests/server/input-admission.test.ts @@ -8,6 +8,7 @@ import { resolveOutputCeiling, } from "../../src/server/responses/input-admission"; import { modelRecordValue } from "../../src/reasoning-effort"; +import { messagesToChatFormat } from "../../src/adapters/openai-chat/messages"; import type { OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../src/types"; const CANONICAL_NATIVE: OcxProviderConfig = { @@ -29,6 +30,20 @@ function userText(text: string): OcxMessage { return { role: "user", content: text, timestamp: 0 }; } +/** Create a replay whose thinking alone exceeds the test model's context window. */ +function replayedThinking(): OcxParsedRequest { + return { + ...request([ + userText("Continue."), + { role: "assistant", timestamp: 0, content: [ + { type: "thinking", thinking: asciiTokens(30_000) }, + { type: "text", text: "Done." }, + ] }, + ]), + modelId: "m", + }; +} + /** Roughly `tokens` worth of plain ASCII at the default 4 chars/token ratio. */ function asciiTokens(tokens: number): string { return "a".repeat(tokens * 4); @@ -197,6 +212,22 @@ describe("checkInputAdmission", () => { modelContextWindows: { "m": 10_000 }, }; + /** Compare direct admission with the actual serialized reasoning payload. */ + test("ignores reasoning that openai-chat drops but counts preserved and native reasoning", () => { + const parsed = replayedThinking(); + const dropped = checkInputAdmission(parsed, provider, "custom", "m"); + expect(dropped.admitted).toBe(true); + expect(dropped.estimatedTokens).toBeLessThan(100); + expect(JSON.stringify(messagesToChatFormat(parsed, provider))).not.toContain("reasoning_content"); + expect(JSON.stringify(messagesToChatFormat(parsed, provider))).not.toContain(asciiTokens(30_000)); + + const preserved = { ...provider, preserveReasoningContentModels: ["m"] }; + expect(JSON.stringify(messagesToChatFormat(parsed, preserved))).toContain("reasoning_content"); + expect(JSON.stringify(messagesToChatFormat(parsed, preserved))).toContain(asciiTokens(30_000)); + expect(checkInputAdmission(parsed, preserved, "custom", "m").admitted).toBe(false); + expect(checkInputAdmission(parsed, { ...provider, adapter: "openai-responses" }, "custom", "m").admitted).toBe(false); + }); + test("admits input under the ceiling", () => { const result = checkInputAdmission(request([userText(asciiTokens(5_000))]), provider, "custom", "m"); expect(result.admitted).toBe(true); @@ -268,6 +299,17 @@ describe("combo target input admission", () => { modelMaxOutputTokens: { m: 32_000 }, }; + /** Verify each combo target counts only reasoning retained by its wire compiler. */ + test("uses the target's reasoning replay policy before reserving output space", () => { + const parsed = { ...replayedThinking(), options: { maxOutputTokens: 1_000 } }; + const target = { ...capped, modelContextWindows: { m: 10_000 }, modelMaxOutputTokens: { m: 1_000 } }; + expect(checkComboTargetInputAdmission(parsed, target, "custom", "m").admitted).toBe(true); + expect(JSON.stringify(messagesToChatFormat(parsed, target))).not.toContain(asciiTokens(30_000)); + const preserved = { ...target, preserveReasoningContentModels: ["m"] }; + expect(checkComboTargetInputAdmission(parsed, preserved, "custom", "m").admitted).toBe(false); + expect(JSON.stringify(messagesToChatFormat(parsed, preserved))).toContain(asciiTokens(30_000)); + }); + const withMaxOutput = (inputTokens: number, maxOutputTokens = 64_000): OcxParsedRequest => ({ ...request([userText(asciiTokens(inputTokens))]), modelId: "m", diff --git a/tests/server/management-provider-upstream-websocket.test.ts b/tests/server/management-provider-upstream-websocket.test.ts new file mode 100644 index 00000000000..a71fe838daf --- /dev/null +++ b/tests/server/management-provider-upstream-websocket.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../../src/config"; +import { handleManagementAPI } from "../../src/server/management-api"; +import * as destinationPolicy from "../../src/lib/destination-policy"; +import type { OcxConfig } from "../../src/types"; +import { ManagementRequest as Request } from "../helpers/management-auth"; +import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// Multi-step provider POST/GET flows exceed the default 5s per-test budget under +// full-suite Windows load (same flake class as management-provider-validation.test.ts). +setDefaultTimeout(60_000); + +const previousOpencodexHome = process.env.OPENCODEX_HOME; +// A per-run directory, not a fixed literal: two concurrent runs of this file, or of any +// other management test that reuses a shared path, would delete each other's +// OPENCODEX_HOME mid-flight. +const TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-management-provider-websocket-")); +let isolatedCodexHome: IsolatedCodexHome | null = null; + +const canonicalDirect = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", +} as const; + +beforeEach(() => { + isolatedCodexHome = installIsolatedCodexHome("ocx-upstream-websocket-codex-"); + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; +}); + +afterEach(() => { + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); +}); + +function makeConfig(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "nvidia", + providers: { + nvidia: { + adapter: "openai-chat", + baseUrl: "https://integrate.api.nvidia.com/v1", + apiKey: "sk-nvidia", + }, + }, + }; +} + +type ProviderRow = Record & { name: string }; +type RequestFn = (path: string, init?: RequestInit) => Promise; + +// Direct handleManagementAPI calls (no startServer) keep the write/read contract in one +// synchronous authority, matching the transport tests in management-provider-validation. +async function withRequest(liveConfig: OcxConfig, run: (request: RequestFn) => Promise): Promise { + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + const request: RequestFn = async (path, init) => { + const req = new Request(`http://127.0.0.1${path}`, init); + return handleManagementAPI(req, new URL(req.url), liveConfig, { + createManagementConvergeCodex: catalogConvergenceFactory(), + }); + }; + await run(request); + } finally { + resolvedError.mockRestore(); + } +} + +function postProvider(request: RequestFn, name: string, provider: Record) { + return request("/api/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name, provider }), + }); +} + +async function providerRow(request: RequestFn, name: string): Promise { + const list = await request("/api/providers"); + expect(list?.status).toBe(200); + const rows = await list!.json() as ProviderRow[]; + const row = rows.find(candidate => candidate.name === name); + expect(row).toBeDefined(); + return row!; +} + +/** + * Repost a GET /api/providers row through POST /api/providers. + * + * The row is the source of every value, but it is not a legal POST body verbatim: for the + * reserved `openai` name `providerManagementConfigError` compares the submitted provider + * against the registry seed key for key, so the GET-only projections (`hasApiKey`, + * `hasHeaders`, `discovery`, `entitlement`) and the row's non-seed defaults (`liveModels`, + * `models`, `disabled`, `allowPrivateNetwork`) all have to go. What remains are the + * canonical transport fields plus `upstreamWebsocket` when the row reports it -- which is + * the field under test, because before #5704 the row always reported one. + */ +function repostedProvider(row: ProviderRow): Record { + const provider: Record = {}; + for (const key of ["adapter", "baseUrl", "authMode", "codexAccountMode"] as const) { + if (row[key] !== undefined) provider[key] = row[key]; + } + if (row.upstreamWebsocket !== undefined) provider.upstreamWebsocket = row.upstreamWebsocket; + return provider; +} + +describe("provider upstream WebSocket reporting (#5704)", () => { + test("canonical openai with upstreamWebsocket unset reports no key and reposts unset", async () => { + saveConfig(makeConfig()); + const liveConfig = loadConfig(); + await withRequest(liveConfig, async (request) => { + const created = await postProvider(request, "openai", { ...canonicalDirect }); + expect(created?.status).toBe(200); + expect(loadConfig().providers.openai?.upstreamWebsocket).toBeUndefined(); + + const row = await providerRow(request, "openai"); + expect(row).not.toHaveProperty("upstreamWebsocket"); + expect(row.upstreamWebsocket).toBeUndefined(); + + const reposted = await postProvider(request, "openai", repostedProvider(row)); + expect(reposted?.status).toBe(200); + expect(loadConfig().providers.openai?.upstreamWebsocket).toBeUndefined(); + expect(liveConfig.providers.openai?.upstreamWebsocket).toBeUndefined(); + }); + }); + + test("canonical openai saved with upstreamWebsocket false reports false and reposts false", async () => { + saveConfig(makeConfig()); + const liveConfig = loadConfig(); + await withRequest(liveConfig, async (request) => { + const created = await postProvider(request, "openai", { ...canonicalDirect, upstreamWebsocket: false }); + expect(created?.status).toBe(200); + + const row = await providerRow(request, "openai"); + expect(row.upstreamWebsocket).toBe(false); + + const reposted = await postProvider(request, "openai", repostedProvider(row)); + expect(reposted?.status).toBe(200); + expect(loadConfig().providers.openai?.upstreamWebsocket).toBe(false); + }); + }); + + test("a custom provider with upstreamWebsocket true still reports true", async () => { + saveConfig(makeConfig()); + const liveConfig = loadConfig(); + await withRequest(liveConfig, async (request) => { + const created = await postProvider(request, "ws-custom", { + adapter: "openai-responses", + baseUrl: "https://api.example.test/v1", + upstreamWebsocket: true, + }); + expect(created?.status).toBe(200); + + const row = await providerRow(request, "ws-custom"); + expect(row.upstreamWebsocket).toBe(true); + + const reposted = await postProvider(request, "ws-custom", repostedProvider(row)); + expect(reposted?.status).toBe(200); + expect(loadConfig().providers["ws-custom"]?.upstreamWebsocket).toBe(true); + }); + }); +}); diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index a663550a511..46da86d4234 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -941,6 +941,8 @@ describe("provider management validation", () => { }); test("provider management permits snapshot repair only on canonical OpenAI forward seeds", () => { + expect(providerManagementConfigError("openai", { ...canonicalDirect, upstreamWebsocket: false })).toBeNull(); + expect(providerManagementConfigError("openai", { ...canonicalDirect, upstreamWebsocket: true })).toContain("must be false or omitted"); for (const mode of ["pool", "direct"] as const) { expect(providerManagementConfigError("openai", { ...canonicalDirect, @@ -3367,14 +3369,14 @@ describe("provider management validation", () => { createManagementConvergeCodex: catalogConvergenceFactory(), }); }; - const canonical = await post({ name: "openai", provider: canonicalDirect }); + const canonical = await post({ name: "openai", provider: { ...canonicalDirect, upstreamWebsocket: false } }); expect(canonical?.status).toBe(200); + expect(loadConfig().providers.openai?.upstreamWebsocket).toBe(false); expect(resolvedError).toHaveBeenCalledWith( "openai", expect.objectContaining({ baseUrl: canonicalDirect.baseUrl }), { allowBenchmarkAddresses: true }, ); - resolvedError.mockResolvedValueOnce( "baseUrl hostname custom.example.test resolves to a benchmark address (198.18.0.30); set allowPrivateNetwork:true only for intentionally local/self-hosted providers", ); diff --git a/tests/server/plaintext-v2-agent-messages-server.test.ts b/tests/server/plaintext-v2-agent-messages-server.test.ts index a81a60f1147..56125cbd192 100644 --- a/tests/server/plaintext-v2-agent-messages-server.test.ts +++ b/tests/server/plaintext-v2-agent-messages-server.test.ts @@ -45,6 +45,7 @@ function config( enabled: boolean, snapshotRepair = false, streamMode?: "auto" | "legacy-tee" | "eager-relay", + stallTimeoutSec?: number, ): OcxConfig { return { defaultProvider: "native", @@ -58,6 +59,7 @@ function config( }, plaintextV2AgentMessages: enabled, ...(streamMode ? { streamMode } : {}), + ...(stallTimeoutSec ? { stallTimeoutSec } : {}), } as OcxConfig; } @@ -274,6 +276,189 @@ describe("plaintext v2 agent messages at the Responses server boundary", () => { expect(clientBody).toContain('"encrypted_function_args":[]'); }); + test.each(["missing", "text/plain"] as const)( + "restores plaintext V2 calls from valid SSE with %s upstream content type", + async contentType => { + takeInheritedSpendHome(); + const payload = completedResponsePayload(`resp-plaintext-v2-${contentType}`); + const wire = `event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response: payload })}\n\ndata: [DONE]\n\n`; + globalThis.fetch = (async () => new Response(new TextEncoder().encode(wire), { + status: 200, + ...(contentType === "missing" ? {} : { headers: { "content-type": contentType } }), + })) as typeof fetch; + + const response = await handleResponses(collaborationRequest(), config(true), { model: "", provider: "" }); + const clientBody = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + expect(clientBody).toContain('"namespace":"collaboration"'); + expect(clientBody).toContain('"name":"spawn_agent"'); + expect(clientBody).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(clientBody).not.toContain('"name":"start_delegated_task"'); + const replay = expandPreviousResponseInput({ previous_response_id: payload.id, input: [] }) as { + input: Array>; + }; + expect(replay.input.find(value => value.type === "function_call")) + .toMatchObject({ namespace: "collaboration", name: "spawn_agent" }); + }, + ); + + test("bounds the headerless prefix probe when the upstream body produces no chunk", async () => { + takeInheritedSpendHome(); + let cancels = 0; + globalThis.fetch = (async () => new Response(new ReadableStream({ + pull: () => new Promise(() => {}), + cancel: () => { cancels += 1; }, + }), { status: 200 })) as typeof fetch; + + const started = performance.now(); + const response = await handleResponses( + collaborationRequest(), + config(true, false, undefined, 1), + { model: "", provider: "" }, + ); + const clientBody = await response.text(); + + expect(response.status).toBe(502); + expect(clientBody).toContain("unsupported content type"); + expect(cancels).toBeGreaterThan(0); + expect(performance.now() - started).toBeLessThan(3_000); + }); + + test("fails closed when the headerless prefix probe reads a failed body", async () => { + takeInheritedSpendHome(); + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(controller) { controller.error(new Error("upstream body read failed")); }, + }), { status: 200 })) as typeof fetch; + + const response = await handleResponses( + collaborationRequest(), + config(true, false, undefined, 1), + { model: "", provider: "" }, + ); + const clientBody = await response.text(); + + expect(response.status).toBe(502); + expect(clientBody).toContain("unsupported content type"); + }); + + test("drops the prefix probe deadline once the body is recognized as SSE", async () => { + takeInheritedSpendHome(); + const payload = completedResponsePayload("resp-plaintext-v2-slow-tail"); + const item = payload.output[0]!; + const encoder = new TextEncoder(); + // Chunk 1 classifies the body at once; every later chunk is a separate event, so the terminal + // is the last one and the tail lands after the probe's own one-second budget has passed. + const chunks = [ + `event: response.output_item.added\ndata: ${JSON.stringify({ + type: "response.output_item.added", + output_index: 0, + item, + })}\n\n`, + `event: response.function_call_arguments.done\ndata: ${JSON.stringify({ + type: "response.function_call_arguments.done", + item_id: "fc-spawn", + namespace: PLAINTEXT_V2_COLLABORATION_NAMESPACE, + name: `${PLAINTEXT_V2_COLLABORATION_NAMESPACE}__start_delegated_task`, + arguments: JSON.stringify({ message: "plain assignment" }), + encrypted_function_args: [], + })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: payload, + })}\n\ndata: [DONE]\n\n`, + ]; + const timers: Array> = []; + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(chunks[0]!)); + const later = (index: number): void => { + timers.push(setTimeout(() => { + controller.enqueue(encoder.encode(chunks[index]!)); + if (index + 1 < chunks.length) later(index + 1); + else controller.close(); + }, 700)); + }; + later(1); + }, + }), { status: 200 })) as typeof fetch; + + const started = performance.now(); + try { + const response = await handleResponses( + collaborationRequest(), + config(true, false, undefined, 1), + { model: "", provider: "" }, + ); + const clientBody = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/event-stream"); + expect(clientBody).toContain("data: [DONE]"); + expect(clientBody).toContain('"namespace":"collaboration"'); + expect(clientBody).toContain('"name":"spawn_agent"'); + expect(clientBody).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + expect(performance.now() - started).toBeGreaterThan(1_000); + } finally { + for (const timer of timers) clearTimeout(timer); + } + }); + + test("fails closed when a headerless body drip-feeds bytes that never classify", async () => { + takeInheritedSpendHome(); + const encoder = new TextEncoder(); + const timers: Array> = []; + let cancelled = false; + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(controller) { + const tick = (): void => { + if (cancelled) return; + controller.enqueue(encoder.encode("x")); + timers.push(setTimeout(tick, 300)); + }; + timers.push(setTimeout(tick, 300)); + }, + cancel() { cancelled = true; }, + }), { status: 200 })) as typeof fetch; + + const started = performance.now(); + try { + const response = await handleResponses( + collaborationRequest(), + config(true, false, undefined, 1), + { model: "", provider: "" }, + ); + const clientBody = await response.text(); + + expect(response.status).toBe(502); + expect(clientBody).toContain("unsupported content type"); + expect(performance.now() - started).toBeLessThan(3_000); + } finally { + cancelled = true; + for (const timer of timers) clearTimeout(timer); + } + }); + + test("recognizes a headerless SSE event split across upstream chunks", async () => { + takeInheritedSpendHome(); + const payload = completedResponsePayload("resp-plaintext-v2-split"); + const wire = `event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response: payload })}\n\ndata: [DONE]\n\n`; + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(wire.slice(0, 12))); + controller.enqueue(new TextEncoder().encode(wire.slice(12))); + controller.close(); + }, + }), { status: 200 })) as typeof fetch; + + const response = await handleResponses(collaborationRequest(), config(true), { model: "", provider: "" }); + const clientBody = await response.text(); + expect(response.status).toBe(200); + expect(clientBody).toContain('"name":"spawn_agent"'); + expect(clientBody).not.toContain(PLAINTEXT_V2_COLLABORATION_NAMESPACE); + }); + test("rejects an unclassified successful response while restoration is required", async () => { takeInheritedSpendHome(); globalThis.fetch = (async () => new Response( From 893c81c2bd80677980c40cf4abae6da7d7d46c40 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 17:37:53 +0900 Subject: [PATCH 28/48] =?UTF-8?q?fix(combos):=20bundle=20L1=20=E2=80=94=20?= =?UTF-8?q?combo/failover=20safety=20(#5741)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bridge): fail closed when enforced tool catalog is absent (cherry picked from commit 94c53f3c54b926f462dd80fa0021881a309b0279) * fix(bridge): retain unscoped null catalog compatibility (cherry picked from commit 9e908293dfe25d06f87100d1ca4f1b8108e79be6) * test(bridge): assert nested tool refusal errors (cherry picked from commit b06cc1fdd7a0d8ad6a747c86e19f47832fd23960) Co-authored-by: 정우철 * fix(combo): refuse first dispatch when send budget is exhausted (cherry picked from commit 3f71a154f777aeb8575fbe848332779bbb7220e0) * fix(combo): preserve classified 413 on denied later hop (cherry picked from commit c50c42f4feeab1ae85496b3b5c62cd4edc960b5d) Co-authored-by: 정우철 * fix(combo): cap explicit Retry-After cooldowns at one day (cherry picked from commit c0556711285e1d07a01c80b2afd0e8d4f495f1a6) * docs(combo): align translated cooldown ceilings (cherry picked from commit 11a8c4d22c3c9fcd565ee1044d6348f2f22d6ac2) * docs(combo): clarify cooldown contract and regression intent (cherry picked from commit f70015c09c73afbd751e306cabb18f6f87e9de97) Co-authored-by: 정우철 * feat(combos): explicit last-resort cooldown policy for failover A brief cooldown on a preferred target routes straight to whatever comes next in the list — including a target the operator only ever wanted used in an emergency. There is no way to say "this one is a last resort", so transient cooldown state dispatches it. `cooldownWaitPolicy: "before-last-resort"` plus `lastResort: true` on a target makes selection try the normal targets first. If they are only cooling and the earliest cooldown expires inside the combo's existing `waitForCooldownMs` budget, the request waits for that instead of dispatching the last resort. **The policy only ever defers, and that is the property the tests are built around.** When no normal target can be reached — every one cooling past the budget, already attempted, or ruled out by the caller — the last-resort target is dispatched exactly as today. A policy that could withhold it would turn a fallback into an outage, which is strictly worse than the premature routing it prevents. Five tests cover that one way each: cooling past the budget, excluded, ruled out by the caller's own predicate, a combo whose targets are all last-resort, and a zero wait budget. The deferral wait is scoped to normal targets. A short cooldown on the last-resort target must not make the request sleep on behalf of the very target the policy is avoiding — though the ordinary wait below the policy branch may still wait for it, and should, once it is the only candidate left. The test asserts which branch does the waiting rather than whether any wait happens. Both fields are omitted by default and only the exact literal `before-last-resort` opts in, matching the rule `reasoningEffortMode` already follows. A truthy non-boolean `lastResort` normalizes to false, so a config that fails validation cannot still change routing if it is loaded anyway. The normalizer's null is dropped by `sparseComboConfig`, so stored combos do not gain a meaningless key. Scoped to src/combos/resolve.ts, which #5716 does not touch — that PR changes cooldown *duration* in failover.ts, this one changes *selection*. They merge in either order. Eight mutations, seven caught, including the safety one: withholding the last resort when no normal target is reachable fails immediately. The survivor is an equivalent mutant — the `targets.some(t => !t.lastResort)` guard is a short-circuit that only avoids one wasted selection pass, since the fall-through already handles an all-last-resort combo identically. Recorded rather than papered over with a contrived assertion. Closes #5691 (cherry picked from commit a1ab7f3cb645bb38486ed8d0b775f541e86c437a) * fix(combos): address review on the last-resort cooldown policy Four findings from the review on #5736, all reproduced before changing anything. **The deferral wait and the ordinary wait now share one budget.** The worst of the four and a bug I introduced. `waitForCooldownMs` is documented as a cap per *selection attempt*, but the fall-through kept the original clock and the full budget, so a 3s deferral followed by a 9s ordinary wait spent 12s against a 10s cap — close to double in the worst case. Both the remaining budget and the clock now advance by whatever the deferral slept, and they are identical to the old values when it did not, so the non-policy path is untouched. The clock half needs its own test: sharing the budget alone still measures the second wait from the original `now`, so a target whose cooldown lapses during the deferral reads as cooling for longer than it is. Pinned by asserting the second sleep is 500ms rather than 3,500ms. **`lastResort: false` is no longer persisted.** The normalizer gives every target an explicit `false`, and the management route wrote normalized targets straight into stored config — so saving any combo added a noise key to every target, including combos that never use the policy. Only the opt-in value is stored now, matching how the combo-level policy is already handled by `sparseComboConfig`. **An omitted policy no longer deletes the stored one.** The management route preserves `cooldownMs`, `waitForCooldownMs` and `defaultEffortMode` when a request omits them; `cooldownWaitPolicy` was missing from that list, so a GUI round-trip would have dropped it. `lastResort` rides on each target and had the same problem, so it is carried over per target, matched on provider and model. **Docs.** The English config table gained rows for both keys, and the four translated guides that carry that table (ja, ko, ru, zh-cn) gained the same two rows. Those translations are mine and should be checked by a native speaker. Two mutations added for the budget fix — not counting the deferral sleep, and not advancing the clock — and both are caught. The re-anchored safety mutation still fails immediately. (cherry picked from commit a57f4195cf32fc16cbc01a6a2cf62cbd98872897) Co-authored-by: Abhishek Sharma * test(layout): register combo-last-resort.test.ts The carried #5736 test matched no layout seed, so tests/test-layout.test.ts failed on it. Register it in codex-integration in both layout files. Co-authored-by: Abhishek Sharma * fix(combos): keep omitted reasoningEffortMode and imageInput on PUT A whole-combo PUT that omitted reasoningEffortMode or imageInput reset them to strict/auto. `ocx combo set` has no flag for reasoningEffortMode, so every CLI edit silently turned an adaptive combo back to strict. The route now carries both from the stored combo when the body omits them, like it already does for cooldownMs, waitForCooldownMs, defaultEffortMode and the last-resort policy. Explicit values still replace them and invalid values are still rejected. The dashboard used omission to mean the default, so toPutBody now sends both fields explicitly; otherwise switching back to auto or strict there would never take effect. Storage stays sparse because the route strips defaults before persisting. Closes #5687 * docs(combos): state the 24h Retry-After cap and last-resort fields in the config reference The configuration reference still said every combo cooldown is capped at ten minutes and did not list lastResort or cooldownWaitPolicy. It now states the 24-hour cap on explicit Retry-After delays, documents both new fields, and the guide says the policy needs a nonzero waitForCooldownMs. * test(combos): pin lastResort and cooldownWaitPolicy carry-over on PUT A dashboard-shaped save re-sends targets without lastResort and omits the combo policy. Pin that both survive it and a rename, that a swapped-in target does not inherit the flag, and that explicit false/null clear them without leaving keys in the stored config. Co-authored-by: Abhishek Sharma * fix(combos): honor the last-resort policy on the post-failure hop After an upstream failure, core-combo first takes a synchronous pick from advanceComboAfterFailure, which ignored cooldownWaitPolicy. With normal A and B, last-resort C and B cooling briefly, a failure on A dispatched C at once and the policy never waited for B. Under the policy that pick now skips last-resort targets; a null result falls through to pickComboTargetWithWait, which waits for a normal target inside the budget or dispatches the last resort. Also pin that a last-resort target stays out of round-robin while a normal target is available. Co-authored-by: Abhishek Sharma * fix(combos): validate targets before carrying lastResort over on PUT The per-target lastResort carry-over read every target before validation, so targets: [null] threw instead of returning the structured 400, and an untrimmed re-sent target missed the stored (trimmed) one and lost its flag. Skip non-record entries and match on trimmed provider and model. Co-authored-by: Abhishek Sharma * docs(combos): describe last-resort targets as emergency-only under the policy With cooldownWaitPolicy set, a lastResort target is skipped whenever any normal target is available, for every strategy; waitForCooldownMs only adds the wait for a cooling normal target. Replace the sentence that said the policy needs a nonzero wait, and state the rule in the English reference and in the translated table rows. --------- Co-authored-by: 정우철 Co-authored-by: Abhishek Sharma --- .../src/content/docs/fr/guides/combos.md | 4 +- .../content/docs/guides/codex-integration.md | 4 + docs-site/src/content/docs/guides/combos.md | 61 +++- .../src/content/docs/ja/guides/combos.md | 8 +- .../docs/ko/guides/codex-integration.md | 2 + .../src/content/docs/ko/guides/combos.md | 12 +- .../docs/reference/configuration/routing.md | 5 +- .../src/content/docs/ru/guides/combos.md | 8 +- .../src/content/docs/tr/guides/combos.md | 4 +- .../src/content/docs/zh-cn/guides/combos.md | 8 +- .../src/content/docs/zh-tw/guides/combos.md | 4 +- gui/src/combo-workspace-data.ts | 11 +- scripts/test-layout/layout.json | 1 + src/bridge/response-json.ts | 10 +- src/bridge/sse.ts | 12 +- src/combos/failover.ts | 13 +- src/combos/resolve.ts | 105 +++++- src/combos/types.ts | 19 +- src/server/management/combo-routes.ts | 53 ++- src/server/responses/core-combo.ts | 10 +- src/types.ts | 1 + src/types/config.ts | 21 ++ structure/gui-and-management-api.md | 2 +- structure/runtime.md | 6 +- structure/transports/responses-failover.md | 2 + structure/transports/responses-wire-shapes.md | 3 +- .../combo-last-resort.test.ts | 336 ++++++++++++++++++ tests/codex-integration/combos.test.ts | 65 +++- tests/fixtures/test-layout-expected.json | 1 + tests/gui/combo-workspace-data.test.ts | 20 +- .../responses-send-budget-counts.test.ts | 56 +++ .../responses-tool-conformance.test.ts | 46 ++- tests/routing/combo-management-api.test.ts | 313 ++++++++++++++++ 33 files changed, 1150 insertions(+), 76 deletions(-) create mode 100644 tests/codex-integration/combo-last-resort.test.ts diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md index 9251951858b..09f96b182ac 100644 --- a/docs-site/src/content/docs/fr/guides/combos.md +++ b/docs-site/src/content/docs/fr/guides/combos.md @@ -207,7 +207,7 @@ Les échecs d’un combo se répartissent entre ceux qui entraînent un **bascul Une cible sautée entre en temps de recharge pendant 60 secondes par défaut. Si la réponse en amont inclut un valeur `Retry-After` valide, opencodex l’utilise à la place. Les secondes numériques et les valeurs de date HTTP sont -accepté, et chaque temps de recharge est limité à 10 minutes. +accepté, et un délai explicite `Retry-After` est plafonné à 24 heures ; les autres temps de recharge restent plafonnés à 10 minutes. La requête actuelle ne réessaye jamais la même cible tentée. Les demandes ultérieures l'ignorent jusqu'à ce qu'il soit le temps de recharge expire. S’il ne reste aucune cible éligible, le proxy renvoie HTTP 503 avec @@ -355,7 +355,7 @@ exécution d'une instance opencodex qui reçoit des requêtes de modèle. Chaque cible est actuellement inéligible : par exemple, son fournisseur est désactivé, il est en phase de refroidissement, elle a déjà été tentée pour cette requête, ou une tâche v2 chiffrée l'exclut. Vérifier la cible état du fournisseur et erreurs récentes en amont. Pour les temps de recharge, attendez la valeur par défaut de 60 secondes ou la -délai indiqué par `Retry-After` en amont (jamais plus de 10 minutes), puis réessayez. +délai indiqué par `Retry-After` en amont (au maximum 24 heures pour un `Retry-After` explicite, contre 10 minutes pour les autres), puis réessayez. ### Pourquoi mon alias a-t-il été rejeté ? diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index b3494547a72..392a0e18ccb 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -612,6 +612,10 @@ code-mode `exec` has the call converted into the matching `tools.(...)` `exec`. A catalog that genuinely declares the bare goal tool keeps it, and a catalog that declares neither the tool nor `exec` still rejects the call as undeclared. +For routed Responses turns, an explicit tool-enforcement policy also rejects client tool calls if +the request's declared-tool catalog is unavailable. An empty declared catalog rejects every client +tool call; Chat and Anthropic clients retain their own tool-validation responsibility. + Routed code-mode turns are also told the host's rules for the nested helpers before the first call: `tools.apply_patch` takes one string that opens and closes with the bare patch marker lines, the isolate has no `import`, and long-running commands are polled through `write_stdin`. When a diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 166ed292f56..82cb31fecfd 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -221,17 +221,62 @@ Combo failures are divided into **hop** failures and **terminal** failures. | First tool call of a Responses turn run by an in-process adapter (`runTurn`) that the current request did not declare, before any output or replay-unsafe side effect | Cool the target and hop with the same tool catalog. After visible output or a replay-unsafe side effect the refusal is final. Chat Completions and Anthropic Messages requests are unchanged. | | Any other unclassified error | Stop and return the error. | +If the shared request send budget refuses the first target, the combo returns a local 429 +`request_send_budget_exhausted` without contacting a provider. If it refuses a later target, +the combo returns the last real upstream failure without sending to that target. + When `cooldownMs` is unset, a hopped target uses an upstream fallback: 5 seconds for request-rate 429s with upstream code `1302` or `1305`, and 60 seconds otherwise. When it is set, `cooldownMs` applies whenever no usable upstream `Retry-After` or Codex reset signal exists, including those -request-rate 429s. Numeric `Retry-After` seconds and HTTP-date values are accepted, and every -cooldown is capped at 10 minutes. The precedence is, from strongest to weakest, explicit +request-rate 429s. Numeric `Retry-After` seconds and HTTP-date values are accepted. Explicit +server delays are capped at 24 hours; reset-derived, configured, and fallback cooldowns are capped +at 10 minutes. The precedence is, from strongest to weakest, explicit `Retry-After` → Codex reset headers (`x-codex-primary-reset-at`, `x-codex-secondary-reset-at`, or `x-codex-tertiary-reset-at`) → the combo's `cooldownMs` (when set) → the 5-second request-rate fallback for upstream rate-limit codes `1302`/`1305` → the 60-second default. A valid immediate `Retry-After: 0` remains an immediate upstream directive rather than being replaced by a configured cooldown. +### Last-resort targets + +A brief cooldown on a preferred target otherwise routes straight to whatever +comes next in the list — including a target you only ever wanted used in an +emergency. Mark it, and tell the combo to wait first: + +```json +{ + "strategy": "failover", + "cooldownWaitPolicy": "before-last-resort", + "waitForCooldownMs": 10000, + "targets": [ + { "provider": "provider-a", "model": "model-a" }, + { "provider": "provider-b", "model": "model-b" }, + { "provider": "provider-c", "model": "model-c", "lastResort": true } + ] +} +``` + +With the policy set, selection tries the normal targets first. If they are only +cooling and the earliest cooldown expires inside `waitForCooldownMs`, the +request waits for that instead of dispatching the last-resort target. That +deferral does not depend on the wait: a `lastResort` target is skipped whenever +any normal target is available, for every strategy, and under `round-robin` or +`random` it does not join the rotation at all. `waitForCooldownMs` only adds the +wait for a cooling normal target, so at the default `0` nothing waits: the last +resort is used as soon as no normal target is available. After a failed attempt, +the next pick follows the same rule. + +**The policy only ever defers.** When no normal target can be reached — every +one cooling past the budget, already attempted, or ruled out — the last-resort +target is dispatched as usual. A policy that could withhold it would turn a +fallback into an outage, which is worse than the premature routing it prevents. +The same applies to a combo whose targets are *all* marked `lastResort`: it +dispatches normally. + +`lastResort` is inert unless `cooldownWaitPolicy` is set, and both are omitted +by default, so existing combos are unaffected. Only the exact string +`before-last-resort` opts in. + The current request never retries the same attempted target. Later requests skip a cooled target until its cooldown expires; request-local compatibility rejections do not cool the target. A `Retry-After` HTTP-date that is already in the past is also preserved as an immediate upstream directive, just like `Retry-After: 0`. Set `waitForCooldownMs` to allow a later @@ -390,6 +435,10 @@ value to change it. An explicit `cooldownMs` (even `60000`) is persisted as-is b the request-rate fallback. A stored `cooldownMs` can only be removed by editing the configuration file; `waitForCooldownMs` resets to its default when a `PUT` explicitly sends `0`, because the sparse serializer omits that default. Omission preserves both values and the dashboard does not expose them yet. +Omitting `defaultEffortMode`, `reasoningEffortMode`, `imageInput`, or `cooldownWaitPolicy` likewise +keeps the stored value, and a re-sent target without `lastResort` keeps that target's flag (matched by +provider and model). The dashboard always sends `imageInput` and `reasoningEffortMode`, so switching +them back to `auto` or `strict` there still replaces the stored value. For the complete persisted configuration, see [Configuration](/reference/configuration/). @@ -416,12 +465,14 @@ Combos are stored in the top-level `combos` object, keyed by combo id: | Field | Required | Default | Rules | | --- | --- | --- | --- | -| `targets` | Yes | — | Non-empty ordered array of configured `{ provider, model, weight? }` targets. Duplicate provider/model pairs are rejected. | +| `targets` | Yes | — | Non-empty ordered array of configured `{ provider, model, weight?, lastResort? }` targets. Duplicate provider/model pairs are rejected. | | `targets[].weight` | No | `1` | Integer from 1 to 10,000. Used by round-robin and random; ignored by failover, least-used, and reset-window. | +| `targets[].lastResort` | No | `false` | Marks an emergency-only target. Inert unless `cooldownWaitPolicy` is set. Never makes a target permanently ineligible: when no normal target can be reached it is dispatched as usual. | | `strategy` | No | `"failover"` | `"failover"`, `"round-robin"`, `"random"`, `"least-used"`, or `"reset-window"`. | | `stickyLimit` | No | `1` | Integer from 1 to 100 successful requests per round-robin selection. Applies only to round-robin. | | `cooldownMs` | No | unset → upstream fallback (5 s for request-rate 429 codes `1302`/`1305`, otherwise 60 s) | Integer from 1 to 600000. When set, applies as the per-target cooldown whenever no usable upstream `Retry-After` or Codex reset signal exists, including request-rate 429s; when unset, uses the upstream fallback. | | `waitForCooldownMs` | No | `0` | Integer from 0 to 600000. Maximum time to wait for the earliest eligible cooling target before returning `combo_unavailable`; abort cancels the wait. | +| `cooldownWaitPolicy` | No | unset | `"before-last-resort"` defers targets marked `lastResort`: they are used only when no normal target is available, for every strategy, and `waitForCooldownMs` only adds the wait for a cooling normal target, so at its `0` default nothing waits and the last resort is used as soon as no normal target is available. Only that exact string opts in. The deferral wait and the ordinary wait share one `waitForCooldownMs` budget per selection attempt. | | `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max`, or `ultra`; resolved against each target's advertised ladder. | | `defaultEffortMode` | No | `"fallback"` | `"fallback"` preserves explicit caller effort. `"force"` overrides valid caller effort, requires a valid non-null default, and can increase cost and latency. | | `reasoningEffortMode` | No | `"strict"` | `"strict"` intersects every known target ladder, so one target advertising no effort control empties the combo's picker. `"adaptive"` excludes those empty ladders from the published intersection. At dispatch, explicit empty or adaptive unknown ladders remove unsupported effort/thinking controls while preserving supported non-effort reasoning fields such as `reasoning.summary`; known non-empty targets keep existing effort resolution. | @@ -445,8 +496,8 @@ it has already been attempted for this request, or an encrypted v2 task excludes provider state and recent upstream errors. For cooldowns, follow an observed `Retry-After` value first; Codex reset headers also take precedence over `cooldownMs`. If neither upstream signal is usable, the configured `cooldownMs` applies, or the upstream fallback applies -when it is unset (5 seconds for request-rate codes `1302`/`1305`, otherwise 60 seconds); every cooldown is -capped at 10 minutes. +when it is unset (5 seconds for request-rate codes `1302`/`1305`, otherwise 60 seconds). Explicit +`Retry-After` delays are capped at 24 hours; the other cooldowns are capped at 10 minutes. ### Why was my alias rejected? diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index e77bba80380..217636f4672 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -129,7 +129,7 @@ ocx combo set balanced \ |インプロセスアダプター(`runTurn`)が実行する Responses ターンで、現在のリクエストが宣言していない最初のツール呼び出し(出力やリプレイ不可の副作用より前) |ターゲットをクールダウンし、同じツールカタログで次へ進みます。可視出力やリプレイ不可の副作用の後は拒否が確定します。Chat Completions と Anthropic Messages のリクエストは変わりません。 | |その他の未分類のエラー |停止してエラーを返します。 | -`cooldownMs` が未設定の場合、ホップされたターゲットはアップストリームのフォールバックを使用します。アップストリームコード `1302` または `1305` を伴うリクエストレート 429 では 5 秒、それ以外では 60 秒です。設定されている場合、使用可能なアップストリームの `Retry-After` または Codex リセットシグナルが存在しないときは、これらのリクエストレート 429 を含め、`cooldownMs` が適用されます。数値の `Retry-After` 秒数と HTTP-date 値が受け入れられ、すべてのクールダウンは 10 分を上限とします。優先順位は強い順に、明示的な `Retry-After` → Codex リセットヘッダー(`x-codex-primary-reset-at`、`x-codex-secondary-reset-at`、または `x-codex-tertiary-reset-at`)→ コンボの `cooldownMs`(設定時)→ アップストリームのレート制限コード `1302`/`1305` に対する 5 秒のリクエストレート フォールバック → 60 秒のデフォルトです。有効な即時指定 `Retry-After: 0` は、設定されたクールダウンで置き換えられず、即時のアップストリーム指示として維持されます。 +`cooldownMs` が未設定の場合、ホップされたターゲットはアップストリームのフォールバックを使用します。アップストリームコード `1302` または `1305` を伴うリクエストレート 429 では 5 秒、それ以外では 60 秒です。設定されている場合、使用可能なアップストリームの `Retry-After` または Codex リセットシグナルが存在しないときは、これらのリクエストレート 429 を含め、`cooldownMs` が適用されます。数値の `Retry-After` 秒数と HTTP-date 値が受け入れられ、明示的な上流の `Retry-After` は最大 24 時間、リセット由来・設定済み・フォールバックのクールダウンは最大 10 分です。優先順位は強い順に、明示的な `Retry-After` → Codex リセットヘッダー(`x-codex-primary-reset-at`、`x-codex-secondary-reset-at`、または `x-codex-tertiary-reset-at`)→ コンボの `cooldownMs`(設定時)→ アップストリームのレート制限コード `1302`/`1305` に対する 5 秒のリクエストレート フォールバック → 60 秒のデフォルトです。有効な即時指定 `Retry-After: 0` は、設定されたクールダウンで置き換えられず、即時のアップストリーム指示として維持されます。 現在のリクエストは、同じ試行ターゲットを再試行しません。後続のリクエストでは、クールダウンが期限切れになるまでそのターゲットをスキップします。すでに過去の時刻である `Retry-After` の HTTP-date も、`Retry-After: 0` と同様に即時のアップストリーム指示として維持されます。`waitForCooldownMs` を設定すると、後続のリクエストは、最も早く利用可能になるターゲットのクールダウンを、その選択試行ごとにこの上限まで待ってから、新たに 1 回選択できます。したがって、複数のフェイルオーバー ホップをまたぐリクエストは合計で `hops × waitForCooldownMs` まで待つ場合があります。デフォルトは `0` です。適格なターゲットがすべて冷却中の場合、HTTP 503 ですぐにフェイルクローズし、その `combo_unavailable` 503 には最も早く終了する残りのクールダウンと等しい `Retry-After` ヘッダーが含まれ、秒単位に切り上げられ、最小値は 1 秒です。待機にはジッターがないため、同期したウェイクアップが発生する可能性があります。中止されたリクエストはこの待機をキャンセルし、通常の `client_cancelled` 応答を返します。キャンセル後にバックアップ ターゲットをディスパッチすることはありません。コンボ ターゲットのクールダウンはプロセス ローカルなコンボごとの状態であり、ネイティブ アカウント ルーティングで使用されるアカウントレベルの Codex クォータ クールダウンとは別です。 @@ -207,7 +207,7 @@ ocx combo remove --yes ### 管理 API -ヘッドレス クライアントは、`/api/combos` 上の `GET`、`PUT`、および `DELETE` を使用します。 `GET` は正規化されたコンボ定義をリストし、`PUT` は 1 つを作成または置換し (名前を変更できます)、`DELETE` は id クエリ パラメーターを受け取ります。認証と要求/応答の詳細は [管理 API リファレンス](/reference/management-api/) にあります。`PUT` 本文で `cooldownMs` または `waitForCooldownMs` のいずれかを省略すると、そのコンボに保存済みの値が維持されます。変更するには明示的な値を指定してください。明示的な `cooldownMs`(`60000` でも)はリクエストレート フォールバックを上書きするため、そのまま永続化されます。保存済みの `cooldownMs` を削除できるのは構成ファイルを編集した場合だけです。`waitForCooldownMs` は、`PUT` で `0` を明示的に指定するとデフォルトに戻ります。これはスパース シリアライザーがそのデフォルト値を省略するためです。省略すると両方の値が維持され、ダッシュボードではまだどちらも設定できません。 +ヘッドレス クライアントは、`/api/combos` 上の `GET`、`PUT`、および `DELETE` を使用します。 `GET` は正規化されたコンボ定義をリストし、`PUT` は 1 つを作成または置換し (名前を変更できます)、`DELETE` は id クエリ パラメーターを受け取ります。認証と要求/応答の詳細は [管理 API リファレンス](/reference/management-api/) にあります。`PUT` 本文で `cooldownMs` または `waitForCooldownMs` のいずれかを省略すると、そのコンボに保存済みの値が維持されます。変更するには明示的な値を指定してください。明示的な `cooldownMs`(`60000` でも)はリクエストレート フォールバックを上書きするため、そのまま永続化されます。保存済みの `cooldownMs` を削除できるのは構成ファイルを編集した場合だけです。`waitForCooldownMs` は、`PUT` で `0` を明示的に指定するとデフォルトに戻ります。これはスパース シリアライザーがそのデフォルト値を省略するためです。省略すると両方の値が維持され、ダッシュボードではまだどちらも設定できません。同様に `defaultEffortMode`、`reasoningEffortMode`、`imageInput`、または `cooldownWaitPolicy` を省略した場合も保存済みの値が維持され、`lastResort` を付けずに再送したターゲットはそのターゲットのフラグを維持します (プロバイダーとモデルで照合)。ダッシュボードは常に `imageInput` と `reasoningEffortMode` を送信するため、そこで `auto` または `strict` に戻した場合も保存済みの値が置き換わります。 永続化された設定全体については、「[構成](/reference/configuration/)」を参照してください。 @@ -236,10 +236,12 @@ ocx combo remove --yes | --- | --- | --- | --- | | `targets` |はい | — |構成された `{ provider, model, weight? }` ターゲットの空でない順序付けされた配列。重複するプロバイダーとモデルのペアは拒否されます。 | | `targets[].weight` |いいえ | `1` | 1 ~ 10,000 の整数。`round-robin` と `random` で使用され、`failover`、`least-used`、`reset-window` では無視されます。 | +| `targets[].lastResort` | いいえ | `false` | 緊急時専用のターゲットを示します。`cooldownWaitPolicy` を設定しない限り無効です。ターゲットを恒久的に除外することはありません。通常のターゲットに到達できない場合は通常どおりディスパッチされます。 | | `strategy` |いいえ | `"failover"` | `"failover"`、`"round-robin"`、`"random"`、`"least-used"`、`"reset-window"`。 | | `stickyLimit` |いいえ | `1` | `round-robin` の 1 回の選択あたり、成功したリクエスト数を指定する 1 ~ 100 の整数。`round-robin` にのみ適用されます。 | | `cooldownMs` |いいえ | 未設定 → アップストリーム フォールバック(リクエストレート 429 コード `1302`/`1305` では 5 秒、それ以外では 60 秒) | 1 ~ 600000 の整数。設定時は、使用可能なアップストリーム `Retry-After` または Codex リセットシグナルがない場合に、リクエストレート 429 を含むターゲットごとのクールダウンとして適用されます。未設定時はアップストリーム フォールバックを使用します。 | | `waitForCooldownMs` |いいえ | `0` | 0 ~ 600000 の整数。最も早く利用可能になる冷却中のターゲットを待ってから `combo_unavailable` を返すまでの最大待機時間。中止すると待機はキャンセルされます。 | +| `cooldownWaitPolicy` | いいえ | 未設定 | `"before-last-resort"` は、通常のターゲットがクールダウン中で、その残り時間が `waitForCooldownMs` に収まる間、`lastResort` を付けたターゲットを後回しにします。`lastResort` を付けたターゲットは、通常のターゲットが 1 つも利用できない場合にのみ使用されます。この文字列のみが有効です。後回しの待機と通常の待機は、1 回の選択につき同じ `waitForCooldownMs` の予算を共有します。 | | `defaultEffort` |いいえ | `null` | `low`、`medium`、`high`、`xhigh`、`max`、または `ultra`;呼び出し元が努力を省略し、ターゲットがサポートをアドバタイズした場合にのみ適用されます。 | | `reasoningEffortMode` | いいえ | `"strict"` | `strict` または `adaptive`。混在する capability の共通部分と対象別の制御正規化を選択します。 | | `alias` |いいえ |なし |オプションのトリミングされたパブリック モデル ID。上記のエイリアス ルールを使用します。空の値はエイリアスなしで保存されます。 | @@ -254,7 +256,7 @@ ocx combo remove --yes ### `combo_unavailable` が発生するのはなぜですか? -現在、すべてのターゲットは不適格です。たとえば、プロバイダーが無効になっている、冷却中である、このリクエストに対してすでに試行されている、暗号化された v2 タスクによってターゲットが除外されているなどです。ターゲットプロバイダーの状態と最近のアップストリームエラーを確認してください。クールダウンでは、まずレスポンスで確認できる `Retry-After` の値に従ってください。Codex のリセットヘッダーも `cooldownMs` より優先され、どちらのアップストリームシグナルも使用できない場合は、設定した `cooldownMs`、未設定ならアップストリーム フォールバック(リクエストレートコード `1302`/`1305` では 5 秒、それ以外では 60 秒)が適用されますが、いずれのクールダウンも 10 分を超えません。 +現在、すべてのターゲットは不適格です。たとえば、プロバイダーが無効になっている、冷却中である、このリクエストに対してすでに試行されている、暗号化された v2 タスクによってターゲットが除外されているなどです。ターゲットプロバイダーの状態と最近のアップストリームエラーを確認してください。クールダウンでは、まずレスポンスで確認できる `Retry-After` の値に従ってください。Codex のリセットヘッダーも `cooldownMs` より優先され、どちらのアップストリームシグナルも使用できない場合は、設定した `cooldownMs`、未設定ならアップストリーム フォールバック(リクエストレートコード `1302`/`1305` では 5 秒、それ以外では 60 秒)が適用されますが、明示的な `Retry-After` は最大 24 時間、その他のクールダウンは最大 10 分です。 ### 私のエイリアスが拒否されたのはなぜですか? 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 d3e00f37cac..201b96f3698 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -257,6 +257,8 @@ Codex는 디스크의 카탈로그(`$CODEX_HOME/opencodex-catalog.json`이 기 function call만 라우팅합니다. 도구 실행, 권한, 확인은 Codex에 그대로 남고 opencodex가 별도의 browser 또는 desktop-control executor를 구현하지는 않습니다. +라우팅된 Responses 턴에서 도구 선언 검증이 명시적으로 켜져 있으면, 선언된 도구 목록을 사용할 수 없을 때도 클라이언트 도구 호출을 거부합니다. 명시적으로 빈 목록은 모든 클라이언트 도구 호출을 거부합니다. Chat과 Anthropic 클라이언트의 도구 검증 책임은 그대로 유지됩니다. + Codex의 `exec` custom-tool grammar를 허용하지 않는 key-auth Responses provider의 경우, opencodex는 해당 선언과 history를 업스트림 function tool로 인코딩한 다음 스트리밍된 function-call lifecycle을 Codex에 전달하기 전에 `custom_tool_call`로 복원합니다. 네이티브 OpenAI forward routing과 지원되는 `apply_patch` custom tool은 변경되지 diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index b49eb1ae35a..614eea8873a 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -135,7 +135,11 @@ ocx combo set balanced \ | 인프로세스 어댑터(`runTurn`)가 실행하는 Responses 턴에서 현재 요청이 선언하지 않은 첫 도구 호출(출력이나 재전송 불가 부작용 이전) | 대상을 쿨다운하고 같은 도구 카탈로그로 다음 대상으로 넘어갑니다. 출력이 보였거나 재전송 불가 부작용이 생긴 뒤에는 거부가 그대로 확정됩니다. Chat Completions와 Anthropic Messages 요청은 바뀌지 않습니다. | | 그 밖의 분류되지 않은 오류 | 멈추고 오류를 반환합니다. | -`cooldownMs`가 설정되지 않으면 홉된 대상은 업스트림 폴백을 사용합니다. 업스트림 코드 `1302` 또는 `1305`인 요청 속도 제한 429는 5초, 그 외에는 60초입니다. 설정하면 사용 가능한 업스트림 `Retry-After` 또는 Codex 재설정 신호가 없을 때, 해당 요청 속도 제한 429를 포함해 `cooldownMs`가 적용됩니다. 숫자로 된 `Retry-After` 초와 HTTP-date 값을 허용하며, 모든 쿨다운은 최대 10분으로 제한됩니다. 우선순위는 강한 순서대로 명시적 `Retry-After` → Codex 재설정 헤더(`x-codex-primary-reset-at`, `x-codex-secondary-reset-at`, 또는 `x-codex-tertiary-reset-at`) → 콤보의 `cooldownMs`(설정된 경우) → 업스트림 속도 제한 코드 `1302`/`1305`의 5초 요청 속도 제한 폴백 → 60초 기본값입니다. 유효한 즉시 지시인 `Retry-After: 0`은 설정된 쿨다운으로 대체하지 않고 업스트림의 즉시 지시로 유지합니다. +공유 요청 전송 예산이 첫 대상을 거부하면 공급자에 요청하지 않고 로컬 429 +`request_send_budget_exhausted`를 반환합니다. 이후 대상을 거부하면 그 대상에 보내지 않고 +마지막 실제 업스트림 실패를 반환합니다. + +`cooldownMs`가 설정되지 않으면 홉된 대상은 업스트림 폴백을 사용합니다. 업스트림 코드 `1302` 또는 `1305`인 요청 속도 제한 429는 5초, 그 외에는 60초입니다. 설정하면 사용 가능한 업스트림 `Retry-After` 또는 Codex 재설정 신호가 없을 때, 해당 요청 속도 제한 429를 포함해 `cooldownMs`가 적용됩니다. 숫자로 된 `Retry-After` 초와 HTTP-date 값을 허용합니다. 명시적 서버 지연은 최대 24시간, 재설정 신호·설정값·폴백 쿨다운은 최대 10분으로 제한됩니다. 우선순위는 강한 순서대로 명시적 `Retry-After` → Codex 재설정 헤더(`x-codex-primary-reset-at`, `x-codex-secondary-reset-at`, 또는 `x-codex-tertiary-reset-at`) → 콤보의 `cooldownMs`(설정된 경우) → 업스트림 속도 제한 코드 `1302`/`1305`의 5초 요청 속도 제한 폴백 → 60초 기본값입니다. 유효한 즉시 지시인 `Retry-After: 0`은 설정된 쿨다운으로 대체하지 않고 업스트림의 즉시 지시로 유지합니다. 현재 요청은 이미 시도한 대상을 다시 시도하지 않습니다. 이후 요청은 쿨다운이 끝날 때까지 해당 대상을 건너뜁니다. 이미 지난 시각을 가리키는 `Retry-After` HTTP-date도 `Retry-After: 0`과 마찬가지로 업스트림의 즉시 지시로 유지됩니다. `waitForCooldownMs`를 설정하면 이후 요청은 가장 먼저 적합해지는 대상의 쿨다운을 선택 시도마다 이 한도까지 기다린 뒤 새로 한 번 선택합니다. 따라서 여러 failover 홉을 거치는 요청은 총 `hops × waitForCooldownMs`까지 기다릴 수 있습니다. 기본값은 `0`입니다. 모든 적합한 대상이 쿨다운 중이고 대기 한도가 0이거나 가장 이른 만료 시각이 대기 한도를 넘으면 요청은 즉시 HTTP 503으로 종료됩니다. 이 `combo_unavailable` 503에는 가장 이른 잔여 쿨다운과 같은 `Retry-After` 헤더가 포함되며, 값은 올림해 정수 초로 표시되고 최소 1초입니다. 대기에 지터를 적용하지 않으므로 동시에 깨어날 수 있습니다. 요청이 중단되면 이 대기가 취소되고 정상 `client_cancelled` 응답이 반환됩니다. 취소 후 백업 대상을 디스패치하지 않습니다. 콤보 대상 쿨다운은 프로세스 로컬 콤보별 상태입니다. 네이티브 계정 라우팅에서 사용하는 계정 수준 Codex 쿼터 쿨다운과는 별개입니다. @@ -211,7 +215,7 @@ ocx combo remove --yes ### Management API -헤드리스 클라이언트는 `/api/combos`에 `GET`, `PUT`, `DELETE`를 사용합니다. `GET`은 정규화된 콤보 정의를 나열하고, `PUT`은 새 항목을 만들거나 교체하며(이름 바꾸기도 가능), `DELETE`는 id 쿼리 파라미터를 사용합니다. 인증과 요청/응답 세부 내용은 [Management API reference](/reference/management-api/)에 있습니다. `PUT` 본문에서 `cooldownMs` 또는 `waitForCooldownMs`를 생략하면 해당 콤보에 이미 저장된 값이 유지됩니다. 변경하려면 값을 명시적으로 보내세요. 명시적 `cooldownMs`(`60000` 포함)는 요청 속도 제한 폴백을 덮어쓰므로 보낸 값 그대로 저장됩니다. 저장된 `cooldownMs`는 구성 파일을 편집할 때만 삭제할 수 있습니다. `waitForCooldownMs`는 `PUT`에서 `0`을 명시적으로 보내면 기본값으로 돌아갑니다. 희소 직렬화기가 이 기본값을 생략하기 때문입니다. 생략한 값은 유지되고, 대시보드에서는 아직 두 값을 설정할 수 없습니다. +헤드리스 클라이언트는 `/api/combos`에 `GET`, `PUT`, `DELETE`를 사용합니다. `GET`은 정규화된 콤보 정의를 나열하고, `PUT`은 새 항목을 만들거나 교체하며(이름 바꾸기도 가능), `DELETE`는 id 쿼리 파라미터를 사용합니다. 인증과 요청/응답 세부 내용은 [Management API reference](/reference/management-api/)에 있습니다. `PUT` 본문에서 `cooldownMs` 또는 `waitForCooldownMs`를 생략하면 해당 콤보에 이미 저장된 값이 유지됩니다. 변경하려면 값을 명시적으로 보내세요. 명시적 `cooldownMs`(`60000` 포함)는 요청 속도 제한 폴백을 덮어쓰므로 보낸 값 그대로 저장됩니다. 저장된 `cooldownMs`는 구성 파일을 편집할 때만 삭제할 수 있습니다. `waitForCooldownMs`는 `PUT`에서 `0`을 명시적으로 보내면 기본값으로 돌아갑니다. 희소 직렬화기가 이 기본값을 생략하기 때문입니다. 생략한 값은 유지되고, 대시보드에서는 아직 두 값을 설정할 수 없습니다. 마찬가지로 `defaultEffortMode`, `reasoningEffortMode`, `imageInput`, `cooldownWaitPolicy`를 생략해도 저장된 값이 유지되며, `lastResort` 없이 다시 보낸 대상은 해당 대상의 플래그를 유지합니다(공급자와 모델로 대조). 대시보드는 항상 `imageInput`과 `reasoningEffortMode`를 보내므로, 대시보드에서 `auto`나 `strict`로 되돌려도 저장된 값은 그대로 교체됩니다. 전체 지속 설정은 [Configuration](/reference/configuration/)을 보십시오. @@ -240,10 +244,12 @@ ocx combo remove --yes | --- | --- | --- | --- | | `targets` | 예 | — | 설정된 `{ provider, model, weight? }` 대상의 비어 있지 않은 순서가 있는 배열이어야 합니다. 중복된 provider/model 쌍은 거부됩니다. | | `targets[].weight` | 아니요 | `1` | 1에서 10,000 사이의 정수입니다. `round-robin`과 `random`에서 사용되며, `failover`, `least-used`, `reset-window`에서는 무시됩니다. | +| `targets[].lastResort` | 아니요 | `false` | 비상용 대상임을 표시합니다. `cooldownWaitPolicy`를 설정하지 않으면 아무 효과가 없습니다. 대상을 영구히 제외하지는 않습니다. 일반 대상에 도달할 수 없으면 평소대로 디스패치됩니다. | | `strategy` | 아니요 | `"failover"` | 허용되는 값은 `"failover"`, `"round-robin"`, `"random"`, `"least-used"`, `"reset-window"`입니다. | | `stickyLimit` | 아니요 | `1` | 한 번의 `round-robin` 선택에 유지되는 성공 요청 수로, 1에서 100 사이의 정수입니다. `round-robin`에만 적용됩니다. | | `cooldownMs` | 아니요 | 미설정 → 업스트림 폴백(요청 속도 제한 429 코드 `1302`/`1305`는 5초, 그 외는 60초) | 1에서 600000 사이의 정수입니다. 설정하면 사용 가능한 업스트림 `Retry-After` 또는 Codex 재설정 신호가 없을 때 요청 속도 제한 429를 포함한 대상별 쿨다운으로 적용됩니다. 설정하지 않으면 업스트림 폴백을 사용합니다. | | `waitForCooldownMs` | 아니요 | `0` | 0에서 600000 사이의 정수입니다. `combo_unavailable`을 반환하기 전에 가장 먼저 적합해지는 쿨다운 중인 대상을 기다리는 최대 시간입니다. 중단하면 대기가 취소됩니다. | +| `cooldownWaitPolicy` | 아니요 | 미설정 | `"before-last-resort"`는 일반 대상이 쿨다운 중이고 그 잔여 시간이 `waitForCooldownMs` 안에 들어올 때 `lastResort` 대상을 뒤로 미룹니다. `lastResort`로 표시된 대상은 사용할 수 있는 일반 대상이 하나도 없을 때만 사용됩니다. 이 문자열만 적용됩니다. 미루는 대기와 일반 대기는 선택 시도마다 같은 `waitForCooldownMs` 한도를 함께 씁니다. | | `defaultEffort` | 아니요 | `null` | `low`, `medium`, `high`, `xhigh`, `max`, 또는 `ultra`입니다. 호출자가 effort를 생략하고 대상이 지원을 광고할 때만 적용됩니다. | | `reasoningEffortMode` | 아니요 | `"strict"` | `strict` 또는 `adaptive`; 혼합 capability의 교집합과 대상별 제어 정규화를 선택합니다. | | `alias` | 아니요 | 없음 | 선택적으로 앞뒤 공백을 제거한 공개 모델 ID입니다. 위의 alias 규칙을 따릅니다. 빈 값은 alias 없음으로 저장됩니다. | @@ -262,7 +268,7 @@ opencodex 인스턴스에 기록했는지 확인하세요. 모든 대상이 현재 부적격 상태입니다. 예를 들어 프로바이더가 비활성화되었거나, cooldown 중이거나, 이 요청에서 이미 시도되었거나, 암호화된 v2 작업 때문에 제외되었을 수 있습니다. 대상 프로바이더 상태와 -최근 업스트림 오류를 확인하세요. 쿨다운에서는 먼저 응답의 `Retry-After` 값을 따르세요. Codex 재설정 헤더도 `cooldownMs`보다 우선합니다. 두 업스트림 신호를 모두 사용할 수 없을 때 설정된 `cooldownMs`를 적용하고, 미설정이면 업스트림 폴백(요청 속도 제한 코드 `1302`/`1305`는 5초, 그 외는 60초)을 적용하며 모든 쿨다운은 최대 10분으로 제한됩니다. +최근 업스트림 오류를 확인하세요. 쿨다운에서는 먼저 응답의 `Retry-After` 값을 따르세요. Codex 재설정 헤더도 `cooldownMs`보다 우선합니다. 두 업스트림 신호를 모두 사용할 수 없을 때 설정된 `cooldownMs`를 적용하고, 미설정이면 업스트림 폴백(요청 속도 제한 코드 `1302`/`1305`는 5초, 그 외는 60초)을 적용합니다. 명시적 `Retry-After` 지연은 최대 24시간, 나머지 쿨다운은 최대 10분입니다. ### alias가 거부된 이유는 무엇인가요? diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index c1bb93714e6..2c327bcd257 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -85,11 +85,12 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`, | Key | Type | Default | Meaning | | --- | --- | --- | --- | -| `targets` | `{ provider: string; model: string; weight?: number }[]` | required | Ordered concrete routes. `weight` is 1–10000 and defaults to `1`. | +| `targets` | `{ provider: string; model: string; weight?: number; lastResort?: boolean }[]` | required | Ordered concrete routes. `weight` is 1–10000 and defaults to `1`. `lastResort` marks an emergency-only target; see `cooldownWaitPolicy`. | | `strategy?` | `"failover" \| "round-robin" \| "random" \| "least-used" \| "reset-window"` | `"failover"` | Selection strategy. Target order is failover priority; weights shape round-robin and random draws; least-used follows recorded successes; reset-window follows the soonest quota reset. | | `stickyLimit?` | `number` | `1` | Successful requests retained in one round-robin batch. Range 1–100. Applies only to round-robin. | -| `cooldownMs?` | `number` | unset → upstream fallback (5 s for request-rate 429 codes `1302`/`1305`, otherwise 60 s) | Range 1–600000. When set, applies whenever no usable upstream `Retry-After` or Codex reset signal exists, including request-rate 429s; when unset, uses the upstream fallback. Upstream signals take precedence and all cooldowns are capped at 10 minutes. | +| `cooldownMs?` | `number` | unset → upstream fallback (5 s for request-rate 429 codes `1302`/`1305`, otherwise 60 s) | Range 1–600000. When set, applies whenever no usable upstream `Retry-After` or Codex reset signal exists, including request-rate 429s; when unset, uses the upstream fallback. Upstream signals take precedence. An explicit upstream `Retry-After` is capped at 24 hours; reset-derived, configured, and fallback cooldowns are capped at 10 minutes. | | `waitForCooldownMs?` | `number` | `0` | Maximum wait for the earliest eligible cooling target on each selection attempt. Range 0–600000; an abort cancels the wait. A single-target combo with a nonzero wait holds the request up to this ceiling and retries the same target instead of failing immediately; if no target was ever dispatched the wait ends in `combo_unavailable`, otherwise the last upstream failure is returned. | +| `cooldownWaitPolicy?` | `"before-last-resort"` | unset | Defers targets marked `lastResort`: they are used only when no normal target is available, for every strategy. `waitForCooldownMs` only adds the wait for a cooling normal target, so at its `0` default nothing waits and the last resort is used as soon as no normal target is available. The deferral and the ordinary wait share one `waitForCooldownMs` budget per selection attempt. Only that exact string opts in; `lastResort` is inert without it. | | `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | `defaultEffort` fills an absent `reasoning.effort` in fallback mode, or overrides valid caller effort in explicit force mode when the combo has a non-null default and the selected target has a known, nonempty supported ladder. If the target supports the configured value, it is retained; otherwise the highest supported rung at or below it is used, or the lowest supported rung when none is lower. Unknown or empty ladders omit the default. | | `defaultEffortMode?` | `"fallback" \| "force"` | `"fallback"` | Preserves caller precedence by default. Explicit force requires a valid non-null default, respects target capability and can increase cost and latency. `reasoningEffortMode` remains independent. | | `reasoningEffortMode?` | `"strict" \| "adaptive"` | `"strict"` | `"strict"` intersects all known target ladders, including empty ones; `"adaptive"` excludes empty ladders. Unknown ladders are catalog wildcards in both modes. At dispatch, explicit empty ladders remove effort/thinking controls in both modes; unknown ladders do so only in adaptive. `reasoning.summary` is preserved. Known nonempty targets retain their effort resolution, and target selection/order is unchanged. | diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index db3e632e09d..cb2ca35806f 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -166,7 +166,7 @@ ocx combo set balanced \ | Первый вызов инструмента в Responses-ходе внутрипроцессного адаптера (`runTurn`), который текущий запрос не объявлял, до любого вывода и до побочного эффекта, небезопасного для повтора | Охлаждает цель и переходит к следующей с тем же каталогом инструментов. После видимого вывода или небезопасного для повтора побочного эффекта отказ окончателен. Запросы Chat Completions и Anthropic Messages не меняются. | | Любая другая неклассифицированная ошибка | Остановиться и вернуть ошибку. | -Если `cooldownMs` не задан, цель после hop использует upstream fallback: 5 секунд для 429, ограничивающих частоту запросов, с кодом upstream `1302` или `1305`, и 60 секунд в остальных случаях. Если он задан, `cooldownMs` применяется, когда нет пригодного сигнала upstream `Retry-After` или сигнала сброса Codex, включая такие 429, ограничивающие частоту запросов. Принимаются числовые секунды в `Retry-After` и значения HTTP-date; любой cooldown ограничен 10 минутами. Приоритет от сильного к слабому: явный `Retry-After` → заголовки сброса Codex (`x-codex-primary-reset-at`, `x-codex-secondary-reset-at` или `x-codex-tertiary-reset-at`) → `cooldownMs` этой combo (если задан) → 5-секундный fallback для rate-limit-кодов upstream `1302`/`1305` → стандартные 60 секунд. Корректный немедленный `Retry-After: 0` сохраняется как немедленная директива upstream, а не заменяется настроенным cooldown. +Если `cooldownMs` не задан, цель после hop использует upstream fallback: 5 секунд для 429, ограничивающих частоту запросов, с кодом upstream `1302` или `1305`, и 60 секунд в остальных случаях. Если он задан, `cooldownMs` применяется, когда нет пригодного сигнала upstream `Retry-After` или сигнала сброса Codex, включая такие 429, ограничивающие частоту запросов. Принимаются числовые секунды в `Retry-After` и значения HTTP-date; явный `Retry-After` ограничен 24 часами, а cooldown по сбросу, настройке или fallback — 10 минутами. Приоритет от сильного к слабому: явный `Retry-After` → заголовки сброса Codex (`x-codex-primary-reset-at`, `x-codex-secondary-reset-at` или `x-codex-tertiary-reset-at`) → `cooldownMs` этой combo (если задан) → 5-секундный fallback для rate-limit-кодов upstream `1302`/`1305` → стандартные 60 секунд. Корректный немедленный `Retry-After: 0` сохраняется как немедленная директива upstream, а не заменяется настроенным cooldown. Текущий запрос никогда не повторяет уже опробованную цель. Более поздние запросы пропускают её, пока не истечёт cooldown. HTTP-date в `Retry-After`, указывающий на уже прошедшее время, также сохраняется как немедленная директива upstream, как и `Retry-After: 0`. Задайте `waitForCooldownMs`, чтобы следующий запрос мог подождать cooldown цели, которая станет подходящей раньше всех, до этого ограничения при каждой попытке выбора, а затем выполнить один новый выбор. Поэтому запрос с несколькими hop в failover может ждать в общей сложности до `hops × waitForCooldownMs`. По умолчанию это `0`: если все подходящие цели находятся в cooldown, запрос немедленно завершается HTTP 503; этот ответ `combo_unavailable` содержит заголовок `Retry-After`, равный оставшемуся cooldown цели с самым ранним окончанием, округлённый вверх до целых секунд, минимум до 1 секунды. К ожиданиям не добавляется джиттер, поэтому возможны синхронные пробуждения. Отмена запроса отменяет это ожидание и возвращает обычный ответ `client_cancelled`; после отмены резервная цель не запускается. Cooldown цели combo — состояние процесса, отдельное для каждой combo; он не связан с cooldown квоты Codex на уровне аккаунта, который используется нативной маршрутизацией аккаунта. @@ -260,7 +260,7 @@ alias и непустой display name. `create` и `update` — alias для `s Headless-клиенты используют `GET`, `PUT` и `DELETE` на `/api/combos`. `GET` возвращает список нормализованных определений combo, `PUT` создаёт или заменяет одну combo (и умеет переименовывать), а `DELETE` принимает id в query-параметре. Аутентификация и детали контрактов запросов/ответов -описаны в [справочнике Management API](/reference/management-api/). Если в теле `PUT` не указано `cooldownMs` или `waitForCooldownMs`, API сохраняет уже записанное для этой combo значение; чтобы изменить его, передайте значение явно. Явно переданный `cooldownMs` (в том числе `60000`) сохраняется как есть, поскольку он переопределяет fallback для ограничения частоты запросов. Сохранённый `cooldownMs` можно удалить только редактированием файла конфигурации; `waitForCooldownMs` возвращается к стандартному значению, если `PUT` явно передаёт `0`, поскольку разреженный сериализатор опускает это значение по умолчанию. Пропуск каждого поля сохраняет соответствующее значение, а дашборд пока не позволяет настраивать эти параметры. +описаны в [справочнике Management API](/reference/management-api/). Если в теле `PUT` не указано `cooldownMs` или `waitForCooldownMs`, API сохраняет уже записанное для этой combo значение; чтобы изменить его, передайте значение явно. Явно переданный `cooldownMs` (в том числе `60000`) сохраняется как есть, поскольку он переопределяет fallback для ограничения частоты запросов. Сохранённый `cooldownMs` можно удалить только редактированием файла конфигурации; `waitForCooldownMs` возвращается к стандартному значению, если `PUT` явно передаёт `0`, поскольку разреженный сериализатор опускает это значение по умолчанию. Пропуск каждого поля сохраняет соответствующее значение, а дашборд пока не позволяет настраивать эти параметры. Пропуск `defaultEffortMode`, `reasoningEffortMode`, `imageInput` или `cooldownWaitPolicy` также сохраняет записанное значение, а повторно отправленная цель без `lastResort` сохраняет свой флаг (сопоставление по провайдеру и модели). Дашборд всегда отправляет `imageInput` и `reasoningEffortMode`, поэтому возврат к `auto` или `strict` там по-прежнему заменяет сохранённое значение. Полную сохранённую конфигурацию см. в [Конфигурации](/reference/configuration/). @@ -289,10 +289,12 @@ Combo хранятся в объекте верхнего уровня `combos`, | --- | --- | --- | --- | | `targets` | Yes | — | Непустой упорядоченный массив настроенных целей `{ provider, model, weight? }`. Дубли пар provider/model запрещены. | | `targets[].weight` | No | `1` | Целое число от 1 до 10 000. Используется стратегиями `round-robin` и `random`; игнорируется стратегиями `failover`, `least-used` и `reset-window`. | +| `targets[].lastResort` | Нет | `false` | Помечает цель как резервную, только для аварийных случаев. Не действует, пока не задан `cooldownWaitPolicy`. Никогда не исключает цель навсегда: если ни одна обычная цель недоступна, она используется как обычно. | | `strategy` | No | `"failover"` | `"failover"`, `"round-robin"`, `"random"`, `"least-used"` или `"reset-window"`. | | `stickyLimit` | No | `1` | Целое число от 1 до 100 успешных запросов на один выбор `round-robin`. Применяется только к `round-robin`. | | `cooldownMs` | No | не задано → fallback upstream (5 с для rate-limit 429 с кодами `1302`/`1305`, иначе 60 с) | Целое число от 1 до 600000. Если задано, применяется как cooldown каждой цели, когда нет пригодного upstream `Retry-After` или сигнала сброса Codex, включая rate-limit 429; если не задано, используется fallback upstream. | | `waitForCooldownMs` | No | `0` | Целое число от 0 до 600000. Максимальное время ожидания самой ранней подходящей цели в cooldown перед возвратом `combo_unavailable`; отмена запроса отменяет ожидание. | +| `cooldownWaitPolicy` | Нет | не задан | `"before-last-resort"` откладывает цели с `lastResort`, пока обычная цель находится в остывании и это остывание укладывается в `waitForCooldownMs`. Цели с `lastResort` используются только тогда, когда ни одна обычная цель недоступна. Включает только эта строка. Отложенное ожидание и обычное ожидание делят один бюджет `waitForCooldownMs` на одну попытку выбора. | | `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max` или `ultra`; применяется только когда вызывающая сторона не указала effort, а цель объявляет поддержку. | | `reasoningEffortMode` | Нет | `"strict"` | `strict` или `adaptive`; задаёт пересечение возможностей и нормализацию параметров конкретной цели. | | `alias` | No | none | Необязательный обрезанный публичный id модели; используйте правила alias выше. Пустое значение хранится как отсутствие alias. | @@ -311,7 +313,7 @@ Id combo неизвестен. Ответ — HTTP 404 с типом `invalid_re Сейчас ни одна цель не подходит: например, провайдер отключён, находится в cooldown, уже был испробован для этого запроса или исключён из-за шифрованной задачи v2. Проверьте состояние -провайдеров цели и недавние upstream-ошибки. Для cooldown сначала следуйте значению `Retry-After` из ответа. Заголовки сброса Codex также имеют приоритет над `cooldownMs`, поэтому если ни один upstream-сигнал не пригоден, применяется заданный `cooldownMs`, а если он не задан — upstream fallback (5 секунд для rate-limit-кодов `1302`/`1305`, иначе 60 секунд); любой cooldown ограничен 10 минутами. +провайдеров цели и недавние upstream-ошибки. Для cooldown сначала следуйте значению `Retry-After` из ответа. Заголовки сброса Codex также имеют приоритет над `cooldownMs`, поэтому если ни один upstream-сигнал не пригоден, применяется заданный `cooldownMs`, а если он не задан — upstream fallback (5 секунд для rate-limit-кодов `1302`/`1305`, иначе 60 секунд); явный `Retry-After` ограничен 24 часами, а cooldown по сбросу, настройке или fallback — 10 минутами. ### Почему alias был отклонён? diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md index a837a74e312..c8d257c31c6 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -237,7 +237,7 @@ ikiye ayrılır. Atlanan bir hedef varsayılan olarak 60 saniye boyunca soğuma süresine girer. Yukarı akış yanıtı geçerli bir `Retry-After` değeri içeriyorsa opencodex bunun yerine onu kullanır. Sayısal saniyeler ve HTTP tarihi değerleri kabul edilir ve -her soğuma süresi en fazla 10 dakika ile sınırlandırılır. +açık `Retry-After` gecikmesi en fazla 24 saat, sıfırlama kaynaklı, yapılandırılmış ve varsayılan soğuma süreleri en fazla 10 dakika ile sınırlandırılır. Geçerli istek denenen aynı hedefi asla yeniden denemez. Daha sonraki istekler soğuma süresi dolana kadar onu atlar. Uygun hiçbir hedef kalmazsa proxy @@ -390,7 +390,7 @@ Her hedef şu anda uygun değildir: örneğin sağlayıcısı devre dışıdır, soğumaktadır, bu istek için zaten denenmiştir veya şifrelenmiş bir v2 görevi onu hariç tutmaktadır. Hedef sağlayıcı durumunu ve son yukarı akış hatalarını kontrol edin. Soğuma süreleri için 60 saniyelik varsayılanı veya yukarı akış -`Retry-After` süresini (asla 10 dakikadan fazla olamaz) bekleyin, ardından +`Retry-After` süresini (açık `Retry-After` için en fazla 24 saat, diğer soğuma süreleri için en fazla 10 dakika) bekleyin, ardından yeniden deneyin. ### Takma adım neden reddedildi? diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index dae57bd773f..31a5764aa7a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -155,7 +155,7 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。 | 由进程内适配器(`runTurn`)执行的 Responses 回合中,当前请求未声明的第一个工具调用(在任何输出和不可重放的副作用之前) | 让该目标进入冷却,并以相同的工具目录跳转到下一个目标。出现可见输出或不可重放的副作用之后,拒绝即为最终结果。Chat Completions 和 Anthropic Messages 请求不受影响。 | | 任何其他未分类错误 | 停止并返回错误。 | -未设置 `cooldownMs` 时,发生跳转的目标使用上游回退值:对于上游代码为 `1302` 或 `1305` 的请求速率限制 429,等待 5 秒;其他情况等待 60 秒。设置后,只要不存在可用的上游 `Retry-After` 或 Codex 重置信号,就会应用 `cooldownMs`,包括这些请求速率限制 429。接受数字形式的 `Retry-After` 秒数和 HTTP-date 值,每次冷却最多封顶 10 分钟。优先级从强到弱依次为:显式 `Retry-After` → Codex 重置标头(`x-codex-primary-reset-at`、`x-codex-secondary-reset-at` 或 `x-codex-tertiary-reset-at`)→ combo 的 `cooldownMs`(已设置时)→ 上游速率限制代码 `1302`/`1305` 的 5 秒请求速率限制回退值 → 60 秒默认值。有效的即时指令 `Retry-After: 0` 会保留为上游即时指令,不会被配置的冷却替换。 +未设置 `cooldownMs` 时,发生跳转的目标使用上游回退值:对于上游代码为 `1302` 或 `1305` 的请求速率限制 429,等待 5 秒;其他情况等待 60 秒。设置后,只要不存在可用的上游 `Retry-After` 或 Codex 重置信号,就会应用 `cooldownMs`,包括这些请求速率限制 429。接受数字形式的 `Retry-After` 秒数和 HTTP-date 值,显式上游 `Retry-After` 最多 24 小时;重置推导、配置和回退冷却最多 10 分钟。优先级从强到弱依次为:显式 `Retry-After` → Codex 重置标头(`x-codex-primary-reset-at`、`x-codex-secondary-reset-at` 或 `x-codex-tertiary-reset-at`)→ combo 的 `cooldownMs`(已设置时)→ 上游速率限制代码 `1302`/`1305` 的 5 秒请求速率限制回退值 → 60 秒默认值。有效的即时指令 `Retry-After: 0` 会保留为上游即时指令,不会被配置的冷却替换。 当前请求不会再次重试同一个已经尝试过的目标。后续请求会跳过它,直到冷却结束。已过去的 HTTP-date `Retry-After` 同样会像 `Retry-After: 0` 一样保留为上游即时指令。设置 `waitForCooldownMs` 后,后续请求可以等待最早恢复资格的目标的冷却,单次选择尝试最多等待该上限,然后重新选择一次。因此,多次故障切换跳转的请求总共最多等待 `hops × waitForCooldownMs`。默认值为 `0`;当所有合格目标都处于冷却中时,请求会立即失败并返回 HTTP 503;该 `combo_unavailable` 503 会带有 `Retry-After` 标头,其值等于剩余冷却时间最短的目标,向上取整为整秒,最小值为 1 秒。等待不加入抖动,因此可能同时唤醒。请求中止会取消这次等待并返回正常的 `client_cancelled` 响应;取消后不会调度备用目标。combo 目标冷却是进程本地、按 combo 区分的状态,与原生账户路由使用的账户级 Codex 配额冷却彼此独立。 @@ -236,7 +236,7 @@ ocx combo remove --yes ### Management API -无头客户端会对 `/api/combos` 使用 `GET`、`PUT` 和 `DELETE`。`GET` 会列出规范化后的 combo 定义,`PUT` 会创建或替换一个定义(也可以重命名一个),`DELETE` 则使用 id 查询参数。认证以及请求/响应细节请见 [Management API 参考](/reference/management-api/)。如果 `PUT` 请求体省略 `cooldownMs` 或 `waitForCooldownMs`,API 会保留该 combo 已存储的值;要更改它,请显式发送一个值。显式设置的 `cooldownMs`(即使是 `60000`)会按原值持久化,因为它会覆盖请求速率限制回退值。已存储的 `cooldownMs` 只能通过编辑配置文件删除;如果 `PUT` 显式发送 `0`,`waitForCooldownMs` 会恢复为默认值,因为稀疏序列化器会省略这个默认值。省略字段会保留对应值,dashboard 目前还不能设置这两个参数。 +无头客户端会对 `/api/combos` 使用 `GET`、`PUT` 和 `DELETE`。`GET` 会列出规范化后的 combo 定义,`PUT` 会创建或替换一个定义(也可以重命名一个),`DELETE` 则使用 id 查询参数。认证以及请求/响应细节请见 [Management API 参考](/reference/management-api/)。如果 `PUT` 请求体省略 `cooldownMs` 或 `waitForCooldownMs`,API 会保留该 combo 已存储的值;要更改它,请显式发送一个值。显式设置的 `cooldownMs`(即使是 `60000`)会按原值持久化,因为它会覆盖请求速率限制回退值。已存储的 `cooldownMs` 只能通过编辑配置文件删除;如果 `PUT` 显式发送 `0`,`waitForCooldownMs` 会恢复为默认值,因为稀疏序列化器会省略这个默认值。省略字段会保留对应值,dashboard 目前还不能设置这两个参数。省略 `defaultEffortMode`、`reasoningEffortMode`、`imageInput` 或 `cooldownWaitPolicy` 同样会保留已存储的值,重新提交的目标如果不带 `lastResort`,也会保留该目标的标记(按 provider 和模型匹配)。dashboard 始终发送 `imageInput` 和 `reasoningEffortMode`,因此在 dashboard 中将其切回 `auto` 或 `strict` 仍会替换已存储的值。 如需查看完整的持久化配置,请参见 [配置](/reference/configuration/)。 @@ -265,10 +265,12 @@ combo 会存储在顶层的 `combos` 对象中,并以 combo id 作为键: | --- | --- | --- | --- | | `targets` | 是 | — | 非空、有顺序的数组,元素为已配置的 `{ provider, model, weight? }` 目标。重复的 provider/model 对会被拒绝。 | | `targets[].weight` | 否 | `1` | 1 到 10,000 的整数。`round-robin` 和 `random` 会使用它;`failover`、`least-used` 和 `reset-window` 会忽略它。 | +| `targets[].lastResort` | 否 | `false` | 标记为仅在紧急情况下使用的目标。未设置 `cooldownWaitPolicy` 时不生效。它不会永久排除该目标:当没有普通目标可用时,仍会照常派发。 | | `strategy` | 否 | `"failover"` | `"failover"`、`"round-robin"`、`"random"`、`"least-used"` 或 `"reset-window"`。 | | `stickyLimit` | 否 | `1` | 每次 `round-robin` 选择可连续处理 1 到 100 个成功请求。仅适用于 `round-robin`。 | | `cooldownMs` | 否 | 未设置 → 上游回退值(请求速率限制代码为 `1302`/`1305` 的 429 为 5 秒,否则为 60 秒) | 1 到 600000 的整数。设置后,只要没有可用的上游 `Retry-After` 或 Codex 重置信号,就会作为每个目标的冷却时间应用,包括请求速率限制 429;未设置时使用上游回退值。 | | `waitForCooldownMs` | 否 | `0` | 0 到 600000 的整数。在返回 `combo_unavailable` 前等待最早恢复资格的冷却中目标的最长时间;请求中止会取消等待。 | +| `cooldownWaitPolicy` | 否 | 未设置 | `"before-last-resort"` 会在普通目标处于冷却中、且其剩余时间在 `waitForCooldownMs` 之内时,推迟使用标记为 `lastResort` 的目标。标记为 `lastResort` 的目标只有在没有普通目标可用时才会使用。仅此字符串生效。推迟等待与普通等待在每次选择中共用同一个 `waitForCooldownMs` 预算。 | | `defaultEffort` | 否 | `null` | `low`、`medium`、`high`、`xhigh`、`max` 或 `ultra`;仅当调用方省略 effort 且目标声明支持时才会应用。 | | `reasoningEffortMode` | 否 | `"strict"` | `strict` 或 `adaptive`;选择混合能力交集和目标级控制归一化。 | | `imageInput` | 否 | `"auto"` | `"auto"` 或 `"disabled"`。`"auto"` 仅在每个目标都支持图片时发布图片能力;`"disabled"` 强制仅文本(从对外能力中去掉图片,并在分发前拒绝带图请求)。 | @@ -284,7 +286,7 @@ combo id 不存在。响应是 HTTP 404,类型为 `invalid_request_error`。 ### 为什么会收到 `combo_unavailable`? -当前每个目标都不可用:例如,它的 provider 被禁用、它正在冷却、它已经在这次请求中被尝试过,或者加密的 v2 任务把它排除了。检查目标的 provider 状态和最近的上游错误。对于冷却,请先遵循响应中的 `Retry-After` 值。Codex 重置标头的优先级也高于 `cooldownMs`;只有在两个上游信号都不可用时,才应用已配置的 `cooldownMs`,未配置时应用上游回退值(请求速率限制代码 `1302`/`1305` 为 5 秒,否则为 60 秒),且所有冷却最多封顶 10 分钟。 +当前每个目标都不可用:例如,它的 provider 被禁用、它正在冷却、它已经在这次请求中被尝试过,或者加密的 v2 任务把它排除了。检查目标的 provider 状态和最近的上游错误。对于冷却,请先遵循响应中的 `Retry-After` 值。Codex 重置标头的优先级也高于 `cooldownMs`;只有在两个上游信号都不可用时,才应用已配置的 `cooldownMs`,未配置时应用上游回退值(请求速率限制代码 `1302`/`1305` 为 5 秒,否则为 60 秒),显式 `Retry-After` 最多 24 小时,其他冷却最多 10 分钟。 ### 为什么我的别名被拒绝了? diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md index d41d01736be..0ece36f42a1 100644 --- a/docs-site/src/content/docs/zh-tw/guides/combos.md +++ b/docs-site/src/content/docs/zh-tw/guides/combos.md @@ -169,7 +169,7 @@ Combo 失敗分為**跳轉**失敗與**終端**失敗。 | 由行程內轉接器(`runTurn`)執行的 Responses 回合中,目前請求未宣告的第一個工具呼叫(在任何輸出與不可重播的副作用之前) | 讓該目標進入冷卻,並以相同的工具目錄跳轉到下一個目標。出現可見輸出或不可重播的副作用之後,拒絕即為最終結果。Chat Completions 與 Anthropic Messages 請求不受影響。 | | 任何其他未分類錯誤 | 停止並回傳錯誤。 | -跳轉的目標預設進入 60 秒冷卻。若上游回應包含有效的 `Retry-After` 值,opencodex 改用它。接受數字秒與 HTTP-date 值,且每次冷卻上限為 10 分鐘。 +跳轉的目標預設進入 60 秒冷卻。若上游回應包含有效的 `Retry-After` 值,opencodex 改用它。接受數字秒與 HTTP-date 值。明確的上游 `Retry-After` 最長為 24 小時;重設推導、設定與預設冷卻最長為 10 分鐘。 目前請求永不重試同一已嘗試目標。後續請求會略過它直到冷卻到期。若無合格目標剩餘,代理回傳 HTTP 503 並帶 `error.code = "combo_unavailable"`。 @@ -284,7 +284,7 @@ Combo id 未知。回應為 HTTP 404 並帶 type `invalid_request_error`。執 ### 為什麼我得到 `combo_unavailable`? -每個目標目前都不合格:例如其供應商已停用、冷卻中、已為此請求嘗試過,或加密 v2 任務排除它。檢查目標供應商狀態與近期上游錯誤。對於冷卻,等待 60 秒預設或上游 `Retry-After` 期間(絕不超過 10 分鐘),然後重試。 +每個目標目前都不合格:例如其供應商已停用、冷卻中、已為此請求嘗試過,或加密 v2 任務排除它。檢查目標供應商狀態與近期上游錯誤。對於冷卻,等待 60 秒預設或上游 `Retry-After` 期間(明確的上游 `Retry-After` 最長 24 小時,其他冷卻最長 10 分鐘),然後重試。 ### 為什麼我的別名被拒絕? diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts index b89bff656fb..b1f3f19f61e 100644 --- a/gui/src/combo-workspace-data.ts +++ b/gui/src/combo-workspace-data.ts @@ -420,8 +420,8 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {} strategy: ComboStrategy; stickyLimit?: number; defaultEffort: ComboEffort | null; - imageInput?: "disabled"; - reasoningEffortMode?: "adaptive"; + imageInput: "auto" | "disabled"; + reasoningEffortMode: "strict" | "adaptive"; alias?: string; nativeAlias?: true; displayName?: string; @@ -437,8 +437,11 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {} : { provider: target.provider.trim(), model: target.model.trim() }), strategy: item.strategy, defaultEffort: item.defaultEffort, - ...(item.imageInput === "disabled" ? { imageInput: "disabled" as const } : {}), - ...(item.reasoningEffortMode === "adaptive" ? { reasoningEffortMode: "adaptive" as const } : {}), + // The server preserves an omitted field from the stored combo (#5687), so the dashboard + // must send both explicitly or switching back to auto/strict would never take effect. + // Storage stays sparse: the server drops the defaults before persisting. + imageInput: item.imageInput === "disabled" ? "disabled" : "auto", + reasoningEffortMode: item.reasoningEffortMode === "adaptive" ? "adaptive" : "strict", ...(item.strategy === "round-robin" ? { stickyLimit: item.stickyLimit } : {}), ...(item.alias && item.alias.trim() ? { alias: item.alias.trim() } : {}), ...(item.nativeAlias ? { nativeAlias: true } : {}), diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 640da9d5874..f2582895f74 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -639,6 +639,7 @@ "cold-spawn-warmup.test.ts": "ci-workflows", "combo-authoritative-reset.test.ts": "codex-integration", "combo-child-headers.test.ts": "routing", + "combo-last-resort.test.ts": "codex-integration", "combo-management-api.test.ts": "routing", "combo-stream-preflight.test.ts": "routing", "combo-workspace-data.test.ts": "gui", diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index ccb03b62087..ca9a7213d44 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -45,6 +45,7 @@ import { adapterFailureFromEvent, emptyChunks, joinChunks, responsesUsage, toolC import type { OutputItem, StringChunks } from "./internal"; import { bridgeToResponsesSSE } from "./sse"; +/** Build a buffered Responses result within a caller-owned or temporary translator budget. */ export function buildResponseJSON( events: AdapterEvent[], modelId: string, @@ -68,13 +69,14 @@ export function buildResponseJSON( } } +/** Fold adapter events into a Responses result while enforcing the requested tool boundary. */ function buildResponseJSONWithBudget( events: AdapterEvent[], modelId: string, options?: { hideThinkingSummary?: boolean; toolNsMap?: Map; - /** Request-visible tool names. When present, an upstream call outside this set fails closed. */ + /** Request-visible tool names. Required for client calls when enforcement is explicitly enabled. */ declaredToolNames?: ReadonlySet; /** See `bridgeToResponsesSSE`: enforcement is separate from normalization (#4735). */ enforceDeclaredToolNames?: boolean; @@ -443,9 +445,9 @@ function buildResponseJSONWithBudget( flushToolCall(); const effectiveName = normalizeDeclaredToolName(e.name, options?.declaredToolNames); if ( - options?.declaredToolNames - && options.enforceDeclaredToolNames !== false - && !options.declaredToolNames.has(effectiveName) + (options?.enforceDeclaredToolNames === true || options?.declaredToolNames != null) + && options?.enforceDeclaredToolNames !== false + && !options?.declaredToolNames?.has(effectiveName) ) { errorEvent = { type: "error", diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index c17886ba469..5b2f5a3de76 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -61,6 +61,7 @@ function responseError(status: number, type: string, message: string): OcxErrorP export type ResponsesTerminalStatus = "completed" | "failed" | "incomplete"; +/** Stream adapter events as Responses frames, applying tool authorization before relaying calls. */ export function bridgeToResponsesSSE( events: AsyncIterable, modelId: string, @@ -92,13 +93,14 @@ export function bridgeToResponsesSSE( * from this callback instead of re-parsing the bridged SSE. */ onUsage?: (usage: OcxUsage | undefined) => void; - /** Request-visible tool names. When present, an upstream call outside this set fails closed. */ + /** Request-visible tool names. Required for client calls when enforcement is explicitly enabled. */ declaredToolNames?: ReadonlySet; /** * Whether `declaredToolNames` is an authorization boundary this proxy enforces, or only the * catalog used to normalize provider-invented names back to declared ones. * - * Defaults to enforcing. The chat and Anthropic inbound wires set it false: those specs make + * Defaults to enforcing when a catalog is supplied. Explicit true also fails closed when the + * catalog is absent. The chat and Anthropic inbound wires set it false: those specs make * the server relay a tool call and leave execution or refusal to the client's own runner, and * harnesses on them legitimately defer part of their catalog (#4735). * @@ -1010,9 +1012,9 @@ export function bridgeToResponsesSSE( const mapped = toolNsMap?.get(effectiveName); const realName = mapped?.name ?? effectiveName; if ( - options?.declaredToolNames - && options.enforceDeclaredToolNames !== false - && !options.declaredToolNames.has(effectiveName) + (options?.enforceDeclaredToolNames === true || options?.declaredToolNames != null) + && options?.enforceDeclaredToolNames !== false + && !options?.declaredToolNames?.has(effectiveName) ) { const failure = responseError( 502, diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 3b0434e244d..5e7ca9553f3 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -15,6 +15,7 @@ interface TargetCooldown { const DEFAULT_COOLDOWN_MS = 60_000; const MAX_COOLDOWN_MS = 10 * 60_000; +const MAX_SERVER_DELAY_MS = 24 * 60 * 60_000; /** Short cooldown for request-rate 429s (for example provider code 1302) that omit Retry-After. */ export const COMBO_REQUEST_RATE_COOLDOWN_MS = 5_000; @@ -115,6 +116,7 @@ function parseHttpDate(value: string, now: number): number | undefined { ); } +/** Parse a Retry-After delay, optionally retaining an upstream delay up to one day. */ export function parseRetryAfterMs( value: string | null | undefined, now = Date.now(), @@ -122,11 +124,10 @@ export function parseRetryAfterMs( ): number | undefined { const text = value?.trim(); if (!text) return undefined; - // A local wait ceiling must not make an explicit upstream reset expire early. - // Keep legacy bounded parsing for other callers. The opt-in stores a timestamp; - // the combo picker still independently limits how long a live request waits. + // Keep legacy bounded parsing for other callers. Combo cooldowns preserve + // multi-hour upstream delays, but never quarantine a target beyond one day. const maximum = options?.preserveServerDelay === true - ? Number.MAX_SAFE_INTEGER - Math.max(0, now) + ? MAX_SERVER_DELAY_MS : MAX_COOLDOWN_MS; if (/^\d+(?:\.\d+)?$/.test(text)) { const seconds = Number(text); @@ -200,6 +201,7 @@ export function comboCooldownRetryAfterSeconds(comboId: string, now = Date.now() return String(Math.max(1, Math.ceil(remainingMs / 1000))); } +/** Record a combo target cooldown, preferring bounded upstream retry evidence. */ export function coolComboTarget( comboId: string, target: Pick, @@ -234,8 +236,7 @@ export function coolComboTarget( message: options?.message, }) ? COMBO_REQUEST_RATE_COOLDOWN_MS : DEFAULT_COOLDOWN_MS); targetCooldowns.set(cooldownMapKey(comboId, target), { - // Only the locally chosen fallback is capped at ten minutes. An explicit - // server lower bound (including one hour) remains authoritative. + // Local fallbacks are capped at ten minutes; explicit server delays at one day. cooldownUntil: now + (serverDelayMs ?? Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS)), }); sweepExpiredOnWrite(now); diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts index 13fd4ccf6d3..8043644c5d1 100644 --- a/src/combos/resolve.ts +++ b/src/combos/resolve.ts @@ -359,11 +359,19 @@ export function advanceComboAfterFailure( }); } } + // #5691: under `cooldownWaitPolicy: "before-last-resort"` this final synchronous pick + // must not dispatch an emergency target while a normal one is merely cooling. Returning + // null hands the decision to `pickComboTargetWithWait`, which waits a normal target out + // inside the combo's wait budget or dispatches the last resort when none is reachable. + // Without the policy the eligible set is unchanged and the pick is exactly as before. + const defersLastResort = combo?.cooldownWaitPolicy === "before-last-resort" + && combo.targets.some(target => !target.lastResort); return pickComboTarget(config, pick.comboId, { exclude: pick.attempted, now: options.now, eligible: target => !isComboTargetInCooldown(pick.comboId, target, options.now) - && (options.eligible?.(target) ?? true), + && (options.eligible?.(target) ?? true) + && (!defersLastResort || !target.lastResort), }); } @@ -382,23 +390,94 @@ export async function pickComboTargetWithWait( const now = options.now ?? Date.now(); const excluded = new Set(options.exclude ?? []); const customEligible = options.eligible; - const eligible = (target: Required): boolean => - !isComboTargetInCooldown(comboId, target, now) + const eligibleAt = (target: Required, at: number): boolean => + !isComboTargetInCooldown(comboId, target, at) && (customEligible?.(target) ?? true); - const pick = pickComboTarget(config, comboId, { exclude: excluded, eligible, now }); - if (pick || options.waitForCooldownMs <= 0 || options.abortSignal?.aborted) return pick; + const eligible = (target: Required): boolean => eligibleAt(target, now); + // Milliseconds already slept inside this call. `waitForCooldownMs` is documented as a cap + // per *selection attempt*, so a deferral wait and the ordinary wait below must share it — + // otherwise a 3s deferral followed by a 9s ordinary wait spends 12s against a 10s budget. + let spentWaitMs = 0; + + // #5691: `cooldownWaitPolicy: "before-last-resort"` makes one extra attempt + // over the normal targets alone, so a *brief* cooldown on a preferred target + // waits rather than dispatching a target the operator marked emergency-only. + // + // It only ever defers. Every exit below falls through to the unchanged + // selection, which still sees the last-resort target — a policy that could + // withhold it when no normal target is reachable would turn a fallback into + // an outage, which is worse than the premature routing it prevents. + const policyCombo = getCombo(config, comboId); + const defersLastResort = policyCombo?.cooldownWaitPolicy === "before-last-resort" + && policyCombo.targets.some(target => !target.lastResort); + if (defersLastResort && !options.abortSignal?.aborted) { + const normalOnly = (target: Required): boolean => + !target.lastResort && eligible(target); + const normalPick = pickComboTarget(config, comboId, { + exclude: excluded, + eligible: normalOnly, + now, + }); + if (normalPick) return normalPick; + + const waitable = policyCombo.targets.filter(target => + !target.lastResort + && targetProviderIsUsable(config, target, now) + && !excluded.has(targetKey(target)) + && isComboTargetInCooldown(comboId, target, now) + && (customEligible?.(target) ?? true), + ); + const soonest = earliestComboCooldown(comboId, waitable, now); + const normalDelay = soonest === undefined ? undefined : soonest.expiry - now; + if (normalDelay !== undefined && normalDelay <= options.waitForCooldownMs) { + console.warn( + `[combo] ${comboId}: deferring last resort, waiting ${normalDelay}ms for ${targetKey(soonest!.target)}`, + ); + try { + await (options.sleep ?? sleepWithAbort)(normalDelay, options.abortSignal); + } catch (error) { + if (options.abortSignal?.aborted) return null; + throw error; + } + spentWaitMs += normalDelay; + if (options.abortSignal?.aborted) return null; + // The combo can be deleted or renamed while this request sleeps. + if (!getCombo(config, comboId)) return null; + const waited = pickComboTarget(config, comboId, { + exclude: excluded, + now: now + normalDelay, + eligible: target => !target.lastResort + && !isComboTargetInCooldown(comboId, target, now + normalDelay) + && (customEligible?.(target) ?? true), + }); + if (waited) return waited; + } + // No normal target is reachable. Fall through; the last resort is eligible. + } + + // Both advance when the deferral above slept; they are identical to `now` and + // `options.waitForCooldownMs` when it did not, so the non-policy path is unchanged. + const clock = now + spentWaitMs; + const remainingWaitMs = options.waitForCooldownMs - spentWaitMs; + + const pick = pickComboTarget(config, comboId, { + exclude: excluded, + eligible: target => eligibleAt(target, clock), + now: clock, + }); + if (pick || remainingWaitMs <= 0 || options.abortSignal?.aborted) return pick; const combo = getCombo(config, comboId); if (!combo) throw new UnknownComboError(comboId); const waitingTargets = combo.targets.filter(target => - targetProviderIsUsable(config, target, now) + targetProviderIsUsable(config, target, clock) && !excluded.has(targetKey(target)) - && isComboTargetInCooldown(comboId, target, now) + && isComboTargetInCooldown(comboId, target, clock) && (customEligible?.(target) ?? true), ); - const earliest = earliestComboCooldown(comboId, waitingTargets, now); + const earliest = earliestComboCooldown(comboId, waitingTargets, clock); if (earliest === undefined) return null; - const delay = earliest.expiry - now; - if (delay > options.waitForCooldownMs) return null; + const delay = earliest.expiry - clock; + if (delay > remainingWaitMs) return null; // The expiry computation above is the single source of truth for the wait budget. // Its target preserves configured order for ties. const target = earliest.target; @@ -416,10 +495,8 @@ export async function pickComboTargetWithWait( if (!getCombo(config, comboId)) return null; return pickComboTarget(config, comboId, { exclude: excluded, - now: now + delay, - eligible: targetCandidate => - !isComboTargetInCooldown(comboId, targetCandidate, now + delay) - && (customEligible?.(targetCandidate) ?? true), + now: clock + delay, + eligible: targetCandidate => eligibleAt(targetCandidate, clock + delay), }); } diff --git a/src/combos/types.ts b/src/combos/types.ts index f4b3e26b2fa..56df19417a6 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -1,6 +1,6 @@ import { isCodexReasoningEffort } from "../reasoning-effort"; import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; -import type { OcxComboConfig, OcxComboDefaultEffort, OcxComboDefaultEffortMode, OcxComboReasoningEffortMode, OcxComboStrategy, OcxComboTarget, OcxProviderConfig } from "../types"; +import type { OcxComboConfig, OcxComboCooldownWaitPolicy, OcxComboDefaultEffort, OcxComboDefaultEffortMode, OcxComboReasoningEffortMode, OcxComboStrategy, OcxComboTarget, OcxProviderConfig } from "../types"; import { COMBO_NAMESPACE, isValidComboId, targetKey } from "./identifiers"; export const COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS = 0; @@ -25,6 +25,8 @@ export interface NormalizedComboConfig { stickyLimit: number; cooldownMs?: number; waitForCooldownMs: number; + /** `before-last-resort` defers lastResort targets while a normal one can be waited out (#5691). */ + cooldownWaitPolicy: OcxComboCooldownWaitPolicy | null; defaultEffort: OcxComboDefaultEffort | null; /** Client-precedence policy; `fallback` preserves legacy behavior. */ defaultEffortMode: OcxComboDefaultEffortMode; @@ -161,6 +163,13 @@ export function comboConfigIssues( || body.waitForCooldownMs > 600_000)) { issues.push({ path: ["waitForCooldownMs"], message: "waitForCooldownMs must be an integer from 0 to 600000" }); } + if (body.cooldownWaitPolicy !== undefined && body.cooldownWaitPolicy !== null + && body.cooldownWaitPolicy !== "before-last-resort") { + issues.push({ + path: ["cooldownWaitPolicy"], + message: 'cooldownWaitPolicy must be "before-last-resort" when set', + }); + } if (body.defaultEffort !== undefined && body.defaultEffort !== null && (typeof body.defaultEffort !== "string" || !isCodexReasoningEffort(body.defaultEffort))) { @@ -277,6 +286,12 @@ export function comboConfigIssues( message: `targets[${i}].weight must be an integer from 1 to 10000`, }); } + if (target.lastResort !== undefined && typeof target.lastResort !== "boolean") { + issues.push({ + path: ["targets", i, "lastResort"], + message: `targets[${i}].lastResort must be a boolean`, + }); + } if (provider && model) { const key = targetKey({ provider, model }); @@ -318,6 +333,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig stickyLimit: raw.stickyLimit ?? 1, cooldownMs: raw.cooldownMs, waitForCooldownMs: raw.waitForCooldownMs ?? COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS, + cooldownWaitPolicy: raw.cooldownWaitPolicy === "before-last-resort" ? "before-last-resort" : null, defaultEffort, defaultEffortMode: raw.defaultEffortMode === "force" && defaultEffort !== null ? "force" : "fallback", reasoningEffortMode: raw.reasoningEffortMode === "adaptive" ? "adaptive" : "strict", @@ -329,6 +345,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig provider: target.provider.trim(), model: target.model.trim(), weight: target.weight ?? 1, + lastResort: target.lastResort === true, })), }; } diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index e990252538e..c0cb63a2d2b 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -52,7 +52,7 @@ import { setDebugSettings, type DebugFlag, } from "../../lib/debug-settings"; -import type { OcxClaudeCodeConfig, OcxComboConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types"; +import type { OcxClaudeCodeConfig, OcxComboConfig, OcxConfig, OcxCustomModel, OcxProviderConfig, OcxComboCooldownWaitPolicy } from "../../types"; import { drainAndShutdown } from "../lifecycle"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; @@ -78,12 +78,14 @@ import { COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS } from "../../combos"; function sparseComboConfig(combo: T): Omit & { +}>(combo: T): Omit & { cooldownMs?: number; waitForCooldownMs?: number; + cooldownWaitPolicy?: OcxComboCooldownWaitPolicy; imageInput?: "disabled"; reasoningEffortMode?: "adaptive"; defaultEffortMode?: "force"; @@ -91,6 +93,7 @@ function sparseComboConfig { + // A non-record entry stays as the client sent it so comboConfigError reports it. + // Reading `lastResort` off it here would throw in place of that structured 400. + if (!isPlainRecord(target)) return target; + if (Object.hasOwn(target, "lastResort")) return target; + const rawProvider = target.provider; + const rawModel = target.model; + if (typeof rawProvider !== "string" || typeof rawModel !== "string") return target; + // Identity is trimmed on both sides: the normalizer trims too, so a re-sent + // " b " must still match the stored "b" instead of losing the flag. + const provider = rawProvider.trim(); + const model = rawModel.trim(); + const before = previous.targets.find( + candidate => candidate.provider === provider && candidate.model === model, + ); + return before?.lastResort ? { ...target, lastResort: true } : target; + }), + } + : {}), // The dashboard does not expose this advanced CLI/API policy. Preserve it when // a GUI round-trip omits the field instead of silently downgrading to fallback. ...(!Object.hasOwn(requestedCombo, "defaultEffortMode") && previous?.defaultEffortMode !== undefined @@ -198,6 +240,13 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise + lastResort ? { ...target, lastResort: true } : target, + ), ...(normalizedAlias ? { alias: normalizedAlias } : {}), ...(normalizedNativeAlias ? { nativeAlias: true } : {}), ...(normalizedDisplayName ? { displayName: normalizedDisplayName } : {}), diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 1178c4f86a8..7f285a89e7b 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -27,6 +27,7 @@ import { comboFailureCooldownScope, } from "../../combos"; import { formatErrorResponse } from "../../bridge"; +import { SEND_BUDGET_EXHAUSTED_CODE } from "../../lib/errors"; import { expandPreviousResponseInput, previousResponseReplayFailure, @@ -166,6 +167,7 @@ export function comboTargetSendBudget( } +/** Dispatch a Responses combo within its shared send budget and preserve terminal child failures. */ export async function executeComboResponses( req: Request, rawBody: unknown, @@ -450,13 +452,17 @@ export async function executeComboResponses( countedExternally: true, }); if (hopDecision && hopDecision.allowed) hopDecision.permit.use(); - else if (hopDecision && !firstComboTarget) { + else if (hopDecision && firstComboTarget) { + // A refused initial reservation authorizes no child send and has no upstream failure to return. + return formatErrorResponse(429, SEND_BUDGET_EXHAUSTED_CODE, "request send budget exhausted before combo dispatch"); + } + else if (hopDecision) { // Out of budget is not this target's failure. The established exhaustion contract is to // return the last real upstream answer with its status, headers and any quota body // intact rather than to mint a synthetic error, and a later target only exists because // an earlier one already recorded one. if (lastFailedChildLog) adoptFailedChildLog(lastFailedChildLog); - break; + return lastFailure!; } const targetSendBudget = comboSendScope ? comboTargetSendBudget(comboSendScope, combo.targets.length - 1 - comboTargetsDispatched) diff --git a/src/types.ts b/src/types.ts index 15c7d677bfc..2d1322996c0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,6 +77,7 @@ export type { OcxConfig, OcxAccountPoolRotationStrategy, OcxAccountPoolQuotaWindow, + OcxComboCooldownWaitPolicy, OcxComboStrategy, OcxComboDefaultEffort, OcxComboDefaultEffortMode, diff --git a/src/types/config.ts b/src/types/config.ts index 693b3e20787..802e8788871 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1147,11 +1147,20 @@ export type OcxComboDefaultEffortMode = "fallback" | "force"; */ export type OcxComboReasoningEffortMode = "strict" | "adaptive"; +/** Policy for how target cooldowns interact with `lastResort` targets (#5691). */ +export type OcxComboCooldownWaitPolicy = "before-last-resort"; + export interface OcxComboTarget { provider: string; model: string; /** Relative target weight for round-robin batches and random selection. Default 1; valid range 1..10000. */ weight?: number; + /** + * Marks an emergency-only target. Inert unless the combo sets + * `cooldownWaitPolicy`, and never makes a target permanently ineligible — + * see `OcxComboConfig.cooldownWaitPolicy` (#5691). + */ + lastResort?: boolean; } export interface OcxComboConfig { @@ -1168,6 +1177,18 @@ export interface OcxComboConfig { cooldownMs?: number; /** Maximum wait for an eligible target cooldown to expire before failing closed. Default 0; range 0..600000, per selection attempt. */ waitForCooldownMs?: number; + /** + * `before-last-resort` defers targets marked `lastResort` while a normal + * target is merely cooling and that cooldown can be waited out inside + * `waitForCooldownMs`. Omitted keeps today's behavior, where a brief cooldown + * on a preferred target routes straight to the emergency target (#5691). + * + * It only ever defers. When no normal target can be reached — all cooling + * past the budget, excluded, or ruled out by the caller — the last-resort + * target is dispatched, because a policy that could withhold it would turn a + * fallback into an outage. + */ + cooldownWaitPolicy?: OcxComboCooldownWaitPolicy; /** Used as a fallback when the client omits reasoning.effort, or as an override in `force` mode. null/omitted leaves the target default unchanged. */ defaultEffort?: OcxComboDefaultEffort | null; /** `force` makes the combo default override a valid client effort. Omitted / `fallback` preserves client precedence. */ diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 2e7c2f24764..3a6568dc472 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -189,7 +189,7 @@ this document owns is which module holds which area and what invariant that area | File-integration plans | `src/server/management/integration-routes.ts` and `aside-profile-routes.ts` — `POST /api/client-integrations/preview`, `POST /api/client-integrations/restore/preview`, and `POST /api/client-integrations/aside/profiles/{profileId}/preview`. Management-authenticated, declared non-mutating, and they write nothing: no snapshot, no lock, no maintenance, no recovery. They answer `409 integration_preview_unavailable` rather than gathering a model roster, because discovery refreshes credentials and writes the provider cache. Responses carry only declared managed schema paths, closed change kinds and an opaque fingerprint; no value, filesystem location or selected member identity appears. Mutation routes accept `operation` and `planFingerprint` together or not at all, reject a half-bound request and an operation that disagrees with the change, and answer `409 integration_preview_stale` with a freshly computed plan. Binding is an optimistic token, never authorization. [The integration contract](clients/integrations.md) owns the ordering. | | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | | Claude reset grants | `src/server/management/anthropic-reset-grant-routes.ts` — `GET /api/anthropic/reset-grants`, `POST /api/anthropic/reset-grants/consume` (lazy-loaded). Wire and fail-closed parsing live in `src/providers/anthropic-reset-grants.ts` (the Claude Code 2.1.278 `cedar_ember` contract, sent with `CLAUDE_CLI_USER_AGENT` from `src/providers/claude-cli-identity.ts`); the journal is `src/providers/anthropic-reset-grant-ledger.ts`: a cross-process `BEGIN IMMEDIATE` lock around every synchronous read-modify-write, a 90 s lease, the operation id reused as the upstream `request_id`, same-id retry only inside the vendor's ten-minute window, no settlement inferred from a re-read, and a fail-closed `500 journal_write_failed` when an answer cannot be recorded. Spending requires the `gui-session` principal. The dashboard owner is `gui/src/hooks/useAnthropicResetGrants.ts` with `gui/src/components/provider-workspace/AnthropicResetGrants.tsx` on the Anthropic OAuth rows of `ProviderAuthPanel`; after an unknown outcome the dialog only retries the same id. Design and audit record: [`../devlog/_plan/260923_claude_reset_grants/010_plan.md`](../devlog/_plan/260923_claude_reset_grants/010_plan.md). | -| Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. | +| 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. | | 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. | | Sidebar | `src/server/management/sidebar-routes.ts` — `GET/POST /api/github/star` and `GET /api/update/badge`. Sidebar state is cosmetic; a failed fetch degrades silently. | diff --git a/structure/runtime.md b/structure/runtime.md index 3927b555fe7..fd4d98298c7 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -486,9 +486,7 @@ Renamed fixed-key providers receive [missing reasoning metadata](catalog.md#rena Translated audio/file admission follows the [final-adapter input contract](adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. ## Request-local target compatibility -Google's final adapter compiler may emit an opt-in, content-free -[tool-schema loss diagnostic](providers/google.md#google-tool-schema-loss-reporting). It observes -adapter-local narrowing only and changes neither provider routing nor the serialized request body. +Google's final adapter compiler may emit an opt-in, content-free [tool-schema loss diagnostic](providers/google.md#google-tool-schema-loss-reporting). It observes adapter-local narrowing only and changes neither provider routing nor the serialized request body. `src/adapters/openai-responses.ts` omits only top-level `user` at the canonical ChatGPT Codex forward destination. Claude translation retains its original identity and prompt-cache key; public API and noncanonical gateways retain their `user` field. Input roles, tool-schema properties, safety identifiers and original replay bodies are not changed. @@ -504,6 +502,8 @@ This is also why the classifier cannot duplicate visible output. Native byte str Regression coverage: `tests/responses/responses-forward-prompt-envelope.test.ts`, `tests/routing/router-combo-failover-classification.test.ts`, `tests/routing/routing-policy-fallback.test.ts`, `tests/helpers/combo-context-overflow-cases.ts`, and `tests/server/server-combo-failover-e2e.test.ts`. +`src/combos/failover.ts` caps explicit upstream `Retry-After` target cooldowns at 24 hours while reset-derived, configured, and fallback cooldowns remain capped at 10 minutes. + ## Combo default effort precedence `src/combos/request.ts` keeps `reasoningEffortMode` and `defaultEffortMode` independent. diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 2629b7fb7f5..b16045ac125 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -390,6 +390,8 @@ that shows the same client resending. The existing provider HTTP-status policy and the shared physical-send budget remain independent: zero refuses dispatch, invalid counts fail, and a stopped send is counted once. +A denied first combo target returns a local typed 429 `request_send_budget_exhausted` without +dispatch; a denied later hop returns the last real upstream failure without contacting that target. `src/bridge/errors.ts` retains only the allowlisted non-replayable transport codes, reapplies the in-process marker, attaches no `Retry-After`, and restates 429 for the refusal code alone so a combo or adapter formatter holding an upstream-shaped 502 cannot hand the diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 15b1127c91d..50bfe6a7daa 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -259,7 +259,8 @@ one in another provider's vocabulary, and a replayed item names a call that alre is the worst place to guess. Membership enforcement is that flag, `enforceDeclaredToolNames`, and only the `responses` inbound -wire enforces. A routed provider that names a tool the request never declared ends the turn there: +wire enforces. Explicit enforcement with no declared catalog also refuses client tool calls rather +than treating the missing set as permission. A routed provider that names a tool the request never declared ends the turn there: `src/bridge/sse.ts` emits `response.failed` and `src/bridge/response-json.ts` returns a failed response, both carrying `undeclared client tool`. That is the #1700 contract and it stands. Codex executes a top-level tool call, so a hallucinated `apply_patch` — which under code mode exists only diff --git a/tests/codex-integration/combo-last-resort.test.ts b/tests/codex-integration/combo-last-resort.test.ts new file mode 100644 index 00000000000..31789bd928d --- /dev/null +++ b/tests/codex-integration/combo-last-resort.test.ts @@ -0,0 +1,336 @@ +// #5691: an explicit last-resort cooldown policy for failover combos. +// +// Without it, a *brief* cooldown on a preferred target makes the ordinary +// selector fall straight through to a target the operator marked emergency-only. +// The policy says: when a normal target is merely cooling and we could wait it +// out inside the combo's existing wait budget, wait — do not dispatch the +// last resort yet. +// +// The property that matters more than the feature is the one in +// `TestThePolicyNeverCausesAnOutage` below: a policy that could keep a +// last-resort target ineligible when every normal target is genuinely gone +// would convert a fallback into an outage, which is strictly worse than the +// premature routing it exists to prevent. +import { beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { + advanceComboAfterFailure, + clearComboSelectionState, + clearComboTargetCooldowns, + coolComboTarget, + pickComboTarget, + pickComboTargetWithWait, +} from "../../src/combos"; +import type { OcxConfig } from "../../src/types/config"; + +function config(overrides: Record = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "a", + providers: { + a: { adapter: "openai-chat", baseUrl: "https://a.example/v1", apiKey: "ka", models: ["m1"] }, + b: { adapter: "openai-chat", baseUrl: "https://b.example/v1", apiKey: "kb", models: ["m2"] }, + c: { adapter: "openai-chat", baseUrl: "https://c.example/v1", apiKey: "kc", models: ["m3"] }, + }, + combos: { + free: { + strategy: "failover", + cooldownWaitPolicy: "before-last-resort", + waitForCooldownMs: 10_000, + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + { provider: "c", model: "m3", lastResort: true }, + ], + ...overrides, + }, + }, + } as unknown as OcxConfig; +} + +const NOW = 1_000_000; +const noSleep = async () => {}; + +beforeEach(() => { + clearComboTargetCooldowns(); + clearComboSelectionState(); +}); + +describe("last-resort cooldown policy", () => { + test("a healthy normal target is picked, as before", async () => { + const cfg = config(); + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, sleep: noSleep, + }); + expect(pick?.target.provider).toBe("a"); + }); + + test("a brief cooldown on the preferred target waits instead of taking the last resort", async () => { + const cfg = config(); + const targets = cfg.combos!.free!.targets; + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 3_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 4_000 }); + + const sleeps: number[] = []; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, + sleep: async (ms: number) => { sleeps.push(ms); }, + }); + expect(sleeps).toEqual([3_000]); + expect(pick?.target.provider).toBe("a"); + expect(pick?.target.provider).not.toBe("c"); + } finally { + warn.mockRestore(); + } + }); + + test("without the policy the last resort is taken immediately, as today", async () => { + const cfg = config({ cooldownWaitPolicy: undefined }); + const targets = cfg.combos!.free!.targets; + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 3_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 4_000 }); + + const sleeps: number[] = []; + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, + sleep: async (ms: number) => { sleeps.push(ms); }, + }); + expect(sleeps).toEqual([]); + expect(pick?.target.provider).toBe("c"); + }); +}); + +describe("the policy never causes an outage", () => { + test("every normal target cooling beyond the wait budget releases the last resort", async () => { + const cfg = config(); + const targets = cfg.combos!.free!.targets; + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 600_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 600_000 }); + + const sleeps: number[] = []; + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, + sleep: async (ms: number) => { sleeps.push(ms); }, + }); + expect(sleeps).toEqual([]); + expect(pick?.target.provider).toBe("c"); + }); + + test("every normal target excluded releases the last resort", async () => { + const cfg = config(); + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, sleep: noSleep, + exclude: ["a/m1", "b/m2"], + }); + expect(pick?.target.provider).toBe("c"); + }); + + test("every normal target ruled out by the caller releases the last resort", async () => { + const cfg = config(); + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, sleep: noSleep, + eligible: target => target.provider === "c", + }); + expect(pick?.target.provider).toBe("c"); + }); + + test("a combo of only last-resort targets still dispatches", async () => { + // Degenerate, but an operator can write it, and "defer the last resort + // until a normal target is available" must not mean "never dispatch". + const cfg = config({ + targets: [{ provider: "c", model: "m3", lastResort: true }], + }); + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, sleep: noSleep, + }); + expect(pick?.target.provider).toBe("c"); + }); + + test("the deferral never waits on the last-resort target's own cooldown", async () => { + // The deferral wait exists to give a *normal* target time to recover. If + // the last-resort target were in that waitable set, a short cooldown on it + // would make the request sleep on behalf of the very target the policy is + // trying not to use yet. + // + // The ordinary wait below the policy branch may still wait for it, and + // should: once no normal target is reachable, the last resort is the only + // candidate, and a one-second cooldown on it is worth waiting out. The + // assertion is therefore about which branch does the waiting, not whether + // any wait happens. + const cfg = config(); + const targets = cfg.combos!.free!.targets; + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 600_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 600_000 }); + coolComboTarget("free", targets[2]!, { now: NOW, cooldownMs: 1_000 }); + + const warnings: string[] = []; + const warn = spyOn(console, "warn").mockImplementation((message: string) => { + warnings.push(String(message)); + }); + try { + await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, sleep: noSleep, + }); + expect(warnings.some(line => line.includes("deferring last resort"))).toBe(false); + } finally { + warn.mockRestore(); + } + }); + + test("a deferral wait and the ordinary wait share one budget", async () => { + // Reported on #5736. `waitForCooldownMs` is a cap per *selection attempt*, so the two + // waits inside one call must not each spend it. Here the normal target's cooldown ends + // at 3s but it re-cools immediately, and the last resort frees at 9s: waiting 3s and + // then a further 9s spends 12s against a 10s budget. + const cfg = config(); + const targets = cfg.combos!.free!.targets; + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 3_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 600_000 }); + coolComboTarget("free", targets[2]!, { now: NOW, cooldownMs: 9_000 }); + + const sleeps: number[] = []; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, + sleep: async (ms: number) => { + sleeps.push(ms); + // The normal target re-cools the moment its first cooldown lapses, which is what + // sends the call on to the ordinary wait with budget already spent. + coolComboTarget("free", targets[0]!, { now: NOW + 3_000, cooldownMs: 600_000 }); + }, + }); + const total = sleeps.reduce((sum, ms) => sum + ms, 0); + expect(total).toBeLessThanOrEqual(10_000); + } finally { + warn.mockRestore(); + } + }); + + test("the ordinary wait measures from after the deferral slept, not before it", async () => { + // Sharing the budget is not enough on its own: the clock has to move too. The normal + // target frees at 3s (and immediately re-cools); the last resort frees at 3.5s. After + // sleeping 3s the remaining wait is 500ms, not the 3,500ms it would be if the fall-through + // still measured from the original `now`. + const cfg = config(); + const targets = cfg.combos!.free!.targets; + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 3_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 600_000 }); + coolComboTarget("free", targets[2]!, { now: NOW, cooldownMs: 3_500 }); + + const sleeps: number[] = []; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 10_000, now: NOW, + sleep: async (ms: number) => { + sleeps.push(ms); + coolComboTarget("free", targets[0]!, { now: NOW + 3_000, cooldownMs: 600_000 }); + }, + }); + expect(sleeps).toEqual([3_000, 500]); + } finally { + warn.mockRestore(); + } + }); + + test("a zero wait budget still releases the last resort rather than failing", async () => { + const cfg = config({ waitForCooldownMs: 0 }); + const targets = cfg.combos!.free!.targets; + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 3_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 3_000 }); + + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 0, now: NOW, sleep: noSleep, + }); + expect(pick?.target.provider).toBe("c"); + }); +}); + +describe("the synchronous post-failure hop honors the policy", () => { + // `advanceComboAfterFailure` is the hop that runs immediately after an upstream failure. + // Its pick is synchronous and cannot wait, so under the policy it must decline rather than + // dispatch the emergency target: a null result is what makes the caller fall through to + // `pickComboTargetWithWait`, the only selector that can wait out a normal target. + test("a last-resort target is not dispatched while a normal target is briefly cooling", async () => { + const cfg = config(); + const targets = cfg.combos!.free!.targets; + const failed = pickComboTarget(cfg, "free", { now: NOW })!; + expect(failed.target.provider).toBe("a"); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 3_000 }); + + expect(advanceComboAfterFailure(cfg, failed, { now: NOW })).toBeNull(); + + const sleeps: number[] = []; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const pick = await pickComboTargetWithWait(cfg, "free", { + exclude: failed.attempted, + waitForCooldownMs: 10_000, now: NOW, + sleep: async (ms: number) => { sleeps.push(ms); }, + }); + expect(sleeps).toEqual([3_000]); + expect(pick?.target.provider).toBe("b"); + } finally { + warn.mockRestore(); + } + }); + + test("a normal target cooling past the wait budget releases the last resort without waiting", async () => { + const cfg = config(); + const targets = cfg.combos!.free!.targets; + const failed = pickComboTarget(cfg, "free", { now: NOW })!; + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 60_000 }); + + expect(advanceComboAfterFailure(cfg, failed, { now: NOW })).toBeNull(); + + const sleeps: number[] = []; + const pick = await pickComboTargetWithWait(cfg, "free", { + exclude: failed.attempted, + waitForCooldownMs: 10_000, now: NOW, + sleep: async (ms: number) => { sleeps.push(ms); }, + }); + expect(sleeps).toEqual([]); + expect(pick?.target.provider).toBe("c"); + }); + + test("without the policy the synchronous hop still takes the last resort", () => { + // The deferral is scoped to the policy: an unconfigured combo keeps the behaviour it + // had before, emergency target included. + const cfg = config({ cooldownWaitPolicy: undefined }); + const targets = cfg.combos!.free!.targets; + const failed = pickComboTarget(cfg, "free", { now: NOW })!; + expect(failed.target.provider).toBe("a"); + coolComboTarget("free", targets[0]!, { now: NOW, cooldownMs: 3_000 }); + coolComboTarget("free", targets[1]!, { now: NOW, cooldownMs: 3_000 }); + + const next = advanceComboAfterFailure(cfg, failed, { now: NOW }); + expect(next?.target.provider).toBe("c"); + }); +}); + +describe("round-robin keeps the last resort emergency-only", () => { + test("repeated selections never reach a last-resort target while a normal one is healthy", async () => { + // The policy is not a failover-only feature: with a zero wait budget, a healthy normal + // target must still win every ordinary selection, so the emergency target is reached + // only when the normal one stops being eligible. + const cfg = config({ + strategy: "round-robin", + waitForCooldownMs: 0, + targets: [ + { provider: "a", model: "m1" }, + { provider: "c", model: "m3", lastResort: true }, + ], + }); + + const providers: string[] = []; + for (let attempt = 0; attempt < 5; attempt++) { + const pick = await pickComboTargetWithWait(cfg, "free", { + waitForCooldownMs: 0, now: NOW, sleep: noSleep, + }); + providers.push(pick!.target.provider); + } + expect(providers).toEqual(["a", "a", "a", "a", "a"]); + }); +}); diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index 00cbf4a28fd..4778be101a8 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -490,6 +490,7 @@ describe("combo request cloning", () => { describe("combo target cooldowns", () => { const target = { provider: "a", model: "m1" }; + /** Verify numeric and HTTP-date delays obey the selected local or server ceiling. */ test("parses numeric and date Retry-After values with exact bounds", () => { const now = Date.parse("2026-07-18T00:00:00.000Z"); expect(parseRetryAfterMs("0.001", now)).toBe(1); @@ -498,6 +499,29 @@ describe("combo target cooldowns", () => { expect(parseRetryAfterMs(new Date(now + 90_000).toUTCString(), now)).toBe(90_000); expect(parseRetryAfterMs(new Date(now + 90_000).toUTCString().toLowerCase(), now)).toBe(90_000); expect(parseRetryAfterMs(new Date(now + 900_000).toUTCString(), now)).toBe(600_000); + const serverDelay = { preserveServerDelay: true }; + expect(parseRetryAfterMs("14400", now, serverDelay)).toBe(14_400_000); + expect(parseRetryAfterMs("86400", now, serverDelay)).toBe(86_400_000); + expect(parseRetryAfterMs("999999", now, serverDelay)).toBe(86_400_000); + expect(parseRetryAfterMs(new Date(now + 4 * 60 * 60_000).toUTCString(), now, serverDelay)).toBe(14_400_000); + expect(parseRetryAfterMs(new Date(now + 2 * 86_400_000).toUTCString(), now, serverDelay)).toBe(86_400_000); + }); + + /** Verify recorded cooldowns expire at their own ceiling without truncating valid delays. */ + test("caps only explicit server cooldowns at one day", () => { + const now = Date.parse("2026-07-18T00:00:00.000Z"); + coolComboTarget("numeric-retry", target, { now, retryAfter: "999999" }); + coolComboTarget("date-retry", target, { now, retryAfter: new Date(now + 2 * 86_400_000).toUTCString() }); + coolComboTarget("multi-hour-retry", target, { now, retryAfter: "14400" }); + coolComboTarget("local-fallback", target, { now, cooldownMs: 999_999_999 }); + for (const comboId of ["numeric-retry", "date-retry"]) { + expect(isComboTargetInCooldown(comboId, target, now + 86_400_000 - 1)).toBe(true); + expect(isComboTargetInCooldown(comboId, target, now + 86_400_000)).toBe(false); + } + expect(isComboTargetInCooldown("multi-hour-retry", target, now + 14_400_000 - 1)).toBe(true); + expect(isComboTargetInCooldown("multi-hour-retry", target, now + 14_400_000)).toBe(false); + expect(isComboTargetInCooldown("local-fallback", target, now + 600_000 - 1)).toBe(true); + expect(isComboTargetInCooldown("local-fallback", target, now + 600_000)).toBe(false); }); test("rejects missing malformed zero and expired Retry-After values", () => { @@ -1547,6 +1571,9 @@ describe("combo validation and normalization", () => { stickyLimit: 1, cooldownMs: undefined, waitForCooldownMs: 0, + // #5691: null unless explicitly configured, so an absent or malformed + // value can never silently opt a combo into deferring its last resort. + cooldownWaitPolicy: null, defaultEffort: "high", defaultEffortMode: "fallback", reasoningEffortMode: "strict", @@ -1554,9 +1581,45 @@ describe("combo validation and normalization", () => { alias: null, nativeAlias: false, displayName: null, - targets: [{ provider: "a", model: "m1", weight: 2 }], + targets: [{ provider: "a", model: "m1", weight: 2, lastResort: false }], }); expect(normalizeComboConfig({ targets: [{ provider: "a", model: "m1" }] }).defaultEffort).toBeNull(); + // #5691: both new fields default to the inert value, and only the exact + // literal opts in — the same rule reasoningEffortMode follows below. + expect(normalizeComboConfig({ + cooldownWaitPolicy: "eventually" as never, + targets: [{ provider: "a", model: "m1" }], + }).cooldownWaitPolicy).toBeNull(); + expect(normalizeComboConfig({ + cooldownWaitPolicy: "before-last-resort", + targets: [{ provider: "a", model: "m1", lastResort: true }], + })).toMatchObject({ + cooldownWaitPolicy: "before-last-resort", + targets: [{ provider: "a", model: "m1", lastResort: true }], + }); + expect(comboConfigIssues("free", { + cooldownWaitPolicy: "eventually" as never, + targets: [{ provider: "a", model: "m1" }], + }, baseConfig().providers).some(issue => issue.path[0] === "cooldownWaitPolicy")).toBe(true); + expect(comboConfigIssues("free", { + targets: [{ provider: "a", model: "m1", lastResort: "yes" as never }], + }, baseConfig().providers).some(issue => issue.path[2] === "lastResort")).toBe(true); + // …and a truthy non-boolean normalizes to false rather than opting in, so a + // config that fails validation cannot still change routing if it is loaded. + expect(normalizeComboConfig({ + targets: [{ provider: "a", model: "m1", lastResort: "yes" as never }], + }).targets[0]!.lastResort).toBe(false); + // #5736: the normalizer's explicit `false` must not reach stored config. Targets that + // never opt in keep exactly the keys they had, so enabling this feature does not add a + // noise key to every target of every combo in the file. + const sparseTargets = normalizeComboConfig({ + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2", lastResort: true }, + ], + }).targets.map(({ lastResort, ...target }) => (lastResort ? { ...target, lastResort: true } : target)); + expect(Object.hasOwn(sparseTargets[0]!, "lastResort")).toBe(false); + expect(sparseTargets[1]).toMatchObject({ lastResort: true }); // Anything that is not the literal "adaptive" normalizes to today's behavior, so a // malformed or absent value can never silently opt a user in. expect(normalizeComboConfig({ targets: [{ provider: "a", model: "m1" }] }).reasoningEffortMode).toBe("strict"); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 471ceb3ea8b..b4b1c78cf70 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -471,6 +471,7 @@ "cold-spawn-warmup.test.ts": "ci-workflows", "combo-authoritative-reset.test.ts": "codex-integration", "combo-child-headers.test.ts": "routing", + "combo-last-resort.test.ts": "codex-integration", "combo-management-api.test.ts": "routing", "combo-stream-preflight.test.ts": "routing", "combo-workspace-data.test.ts": "gui", diff --git a/tests/gui/combo-workspace-data.test.ts b/tests/gui/combo-workspace-data.test.ts index e7540206368..1c7629d078c 100644 --- a/tests/gui/combo-workspace-data.test.ts +++ b/tests/gui/combo-workspace-data.test.ts @@ -283,10 +283,12 @@ describe("combo-workspace-data", () => { expect(parsedItem?.reasoningEffortMode).toBe("adaptive"); expect(toPutBody(parsedItem!).combo.reasoningEffortMode).toBe("adaptive"); - // The default stays off the wire so a GET -> PUT round-trip never writes it back. - expect(toPutBody(combo()).combo).not.toHaveProperty("reasoningEffortMode"); - expect(toPutBody(combo({ reasoningEffortMode: "strict" })).combo) - .not.toHaveProperty("reasoningEffortMode"); + // Strict now goes on the wire explicitly (#5687): the server preserves an omitted field + // from the stored combo, so an omitted strict could never replace a stored adaptive. + // Storage stays sparse — the server drops the default before persisting. + expect(toPutBody(combo()).combo.reasoningEffortMode).toBe("strict"); + expect(toPutBody(combo({ reasoningEffortMode: "strict" })).combo.reasoningEffortMode) + .toBe("strict"); }); test("draftEquals treats a reasoningEffortMode change as dirty", () => { @@ -536,6 +538,8 @@ describe("combo-workspace-data", () => { ], strategy: "round-robin", defaultEffort: "high", + imageInput: "auto", + reasoningEffortMode: "strict", stickyLimit: 7, }, }); @@ -550,6 +554,8 @@ describe("combo-workspace-data", () => { targets: [{ provider: "a", model: "m1" }], strategy: "failover", defaultEffort: "medium", + imageInput: "auto", + reasoningEffortMode: "strict", }, }); expect("stickyLimit" in failoverBody.combo).toBe(false); @@ -582,6 +588,8 @@ describe("combo-workspace-data", () => { ], strategy: "failover", defaultEffort: "medium", + imageInput: "auto", + reasoningEffortMode: "strict", alias: "deepseek-v4-flash", }, }); @@ -768,10 +776,10 @@ describe("combo imageInput draft persistence", () => { expect(draftEquals(base, disabled)).toBe(false); }); - test("toPutBody emits imageInput only when disabled", () => { + test("toPutBody always sends imageInput so auto can replace a stored disabled", () => { const auto = emptyDraft("x"); auto.targets = [{ provider: "a", model: "m1" }]; - expect(toPutBody(auto).combo).not.toHaveProperty("imageInput"); + expect(toPutBody(auto).combo.imageInput).toBe("auto"); const disabled = { ...auto, imageInput: "disabled" as const }; expect(toPutBody(disabled).combo.imageInput).toBe("disabled"); }); diff --git a/tests/responses/responses-send-budget-counts.test.ts b/tests/responses/responses-send-budget-counts.test.ts index b741792ed3c..510a03c3963 100644 --- a/tests/responses/responses-send-budget-counts.test.ts +++ b/tests/responses/responses-send-budget-counts.test.ts @@ -297,6 +297,62 @@ describe("upstream sends per logical request", () => { expect(sendCounts(logCtx)).toEqual([3]); }); + test("a denied first combo reservation makes no upstream request", async () => { + const upstream = alwaysFailing(502, "upstream busy"); + const logCtx: RequestLogContext = { model: "", provider: "" }; + const denied = createRequestExecutionBudget(); + // Combo derives its own policy while sharing this counter; spend past that derived cap. + denied.used = comboExecutionBudgetPolicy(2).maxTotalModelSends; + + takeSpendHome(); + const response = await handleResponses( + responsesRequest("combo/fan"), comboOverTargets(2), logCtx, { sendBudget: denied }, + ); + const body = await response.text(); + expect(response.status).toBe(429); + expect(body).toContain("request_send_budget_exhausted"); + expect(upstream.authorizations).toHaveLength(0); + expect(totalSends(logCtx)).toBe(0); + }); + + test("a denied later combo reservation returns the prior upstream failure", async () => { + const budget = createRequestExecutionBudget(); + const hits: string[] = []; + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + hits.push(new Headers(init?.headers).get("authorization") ?? ""); + budget.used = comboExecutionBudgetPolicy(2).maxTotalModelSends; + return Response.json({ error: { message: "first target busy", type: "rate_limit_error" } }, { status: 429 }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + takeSpendHome(); + const response = await handleResponses( + responsesRequest("combo/fan"), comboOverTargets(2), logCtx, { sendBudget: budget }, + ); + expect(response.status).toBe(429); + expect(await response.text()).toContain("first target busy"); + expect(hits).toEqual(["Bearer sk-t0"]); + }); + + test("a denied later combo reservation preserves a classified 413 response", async () => { + const budget = createRequestExecutionBudget(); + const hits: string[] = []; + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + hits.push(new Headers(init?.headers).get("authorization") ?? ""); + budget.used = comboExecutionBudgetPolicy(2).maxTotalModelSends; + return Response.json({ error: { message: "maximum context length exceeded", type: "invalid_request_error" } }, { status: 413 }); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + takeSpendHome(); + const response = await handleResponses( + responsesRequest("combo/fan"), comboOverTargets(2), logCtx, { sendBudget: budget }, + ); + expect(response.status).toBe(413); + expect(await response.text()).toContain("maximum context length exceeded"); + expect(hits).toEqual(["Bearer sk-t0"]); + }); + test("a three-target combo fan-out gives every declared target a send and stays bounded", async () => { const upstream = alwaysFailing(502, "upstream busy"); const logCtx: RequestLogContext = { model: "", provider: "" }; diff --git a/tests/responses/responses-tool-conformance.test.ts b/tests/responses/responses-tool-conformance.test.ts index 1d540492a50..433c5a07359 100644 --- a/tests/responses/responses-tool-conformance.test.ts +++ b/tests/responses/responses-tool-conformance.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "bun:test"; import { parseRequest } from "../../src/responses/parser"; import { cursorRequestUsesCodeMode } from "../../src/adapters/cursor/tool-definitions"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../../src/bridge"; import type { AdapterEvent } from "../../src/types"; -import { jsonItemTypes, jsonToolItems, streamedView } from "../helpers/responses-conformance"; +import { collectSse, jsonItemTypes, jsonToolItems, replay, streamedView } from "../helpers/responses-conformance"; /** * Responses tool round-trip conformance @@ -467,3 +468,46 @@ describe("parallel tool-call capability", () => { expect(view.snapshot[1]?.payload).toBe("{\"path\":\"b.txt\"}"); }); }); + +describe("declared tool enforcement at the bridge", () => { + /** Compare authorization and failure details across buffered and streamed responses. */ + it("fails closed without a catalog only when enforcement is active on both response shapes", async () => { + const events: AdapterEvent[] = [ + { type: "tool_call_start", id: "call_1", name: "read_file" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "done" }, + ]; + const cases = [ + { label: "explicit missing", options: { enforceDeclaredToolNames: true }, refused: true }, + { label: "explicit null", options: { enforceDeclaredToolNames: true, declaredToolNames: null as unknown as ReadonlySet }, refused: true }, + { label: "explicit empty", options: { enforceDeclaredToolNames: true, declaredToolNames: new Set() }, refused: true }, + { label: "implicit catalog", options: { declaredToolNames: new Set(["other_tool"]) }, refused: true }, + { label: "declared", options: { enforceDeclaredToolNames: true, declaredToolNames: new Set(["read_file"]) }, refused: false }, + { label: "chat/Anthropic scope", options: { enforceDeclaredToolNames: false, declaredToolNames: new Set(["other_tool"]) }, refused: false }, + { label: "unscoped bridge", options: {}, refused: false }, + { label: "unscoped null", options: { declaredToolNames: null as unknown as ReadonlySet }, refused: false }, + ]; + for (const { label, options, refused } of cases) { + const frames = await collectSse(bridgeToResponsesSSE(replay(events), MODEL, undefined, undefined, undefined, undefined, 2_000, options)); + const json = buildResponseJSON(events, MODEL, options); + expect(frames.some(frame => frame.event === "response.failed"), label).toBe(refused); + expect(frames.some(frame => frame.event === "response.output_item.added"), label).toBe(!refused); + expect(json.status, label).toBe(refused ? "failed" : "completed"); + if (refused) { + const expectedError = { + type: "upstream_error", + message: expect.stringContaining("undeclared client tool"), + }; + expect(json.error, label).toMatchObject(expectedError); + const failed = frames.find(frame => frame.event === "response.failed"); + expect(failed?.data, label).toMatchObject({ response: { error: { + type: "server_error", + code: "upstream_server_error", + message: expectedError.message, + } } }); + } else expect(json.error, label).toBeUndefined(); + expect((json.output as unknown[]).length, label).toBe(refused ? 0 : 1); + } + }); +}); diff --git a/tests/routing/combo-management-api.test.ts b/tests/routing/combo-management-api.test.ts index 7bdd2fb69e1..3267d8d968b 100644 --- a/tests/routing/combo-management-api.test.ts +++ b/tests/routing/combo-management-api.test.ts @@ -473,6 +473,319 @@ describe("combo management API", () => { }); }); + test("PUT preserves omitted reasoningEffortMode and imageInput (#5687)", async () => { + await withTempHome(async () => { + const config = baseConfig({ combos: undefined }); + saveConfig(config); + + const created = await comboApi(config, "PUT", "/api/combos", { + id: "keep", + combo: { + targets: [{ provider: "a", model: "m1" }], + reasoningEffortMode: "adaptive", + imageInput: "disabled", + }, + }); + expect(created?.status).toBe(200); + expect(config.combos?.keep).toMatchObject({ + reasoningEffortMode: "adaptive", + imageInput: "disabled", + }); + + // `ocx combo set` and other API clients have no flag for either field, so a whole-combo + // PUT that omits them carries the stored values forward instead of resetting to strict/auto. + const omitted = await comboApi(config, "PUT", "/api/combos", { + id: "keep", + combo: { + targets: [{ provider: "a", model: "m1" }], + strategy: "round-robin", + }, + }); + expect(omitted?.status).toBe(200); + expect(config.combos?.keep).toMatchObject({ + strategy: "round-robin", + reasoningEffortMode: "adaptive", + imageInput: "disabled", + }); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.combos?.keep).toMatchObject({ + reasoningEffortMode: "adaptive", + imageInput: "disabled", + }); + const listed = await responseJson(await comboApi(config, "GET", "/api/combos")); + expect(listed.combos).toEqual([expect.objectContaining({ + id: "keep", reasoningEffortMode: "adaptive", imageInput: "disabled", + })]); + + // Explicit values still replace, and the default is never materialized on disk or on the wire. + const defaults = await comboApi(config, "PUT", "/api/combos", { + id: "keep", + combo: { + targets: [{ provider: "a", model: "m1" }], + reasoningEffortMode: "strict", + imageInput: "auto", + }, + }); + expect(defaults?.status).toBe(200); + const defaultsBody = await responseJson(defaults); + expect(defaultsBody.combo).not.toHaveProperty("reasoningEffortMode"); + expect(defaultsBody.combo).not.toHaveProperty("imageInput"); + expect(config.combos?.keep).not.toHaveProperty("reasoningEffortMode"); + expect(config.combos?.keep).not.toHaveProperty("imageInput"); + const sparseDisk = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(sparseDisk.combos?.keep).not.toHaveProperty("reasoningEffortMode"); + expect(sparseDisk.combos?.keep).not.toHaveProperty("imageInput"); + + // An explicit opt-in survives being re-sent alongside an invalid sibling field: the + // rejected request must not have replaced the stored combo either. + const reseeded = await comboApi(config, "PUT", "/api/combos", { + id: "keep", + combo: { + targets: [{ provider: "a", model: "m1" }], + reasoningEffortMode: "adaptive", + imageInput: "disabled", + }, + }); + expect(reseeded?.status).toBe(200); + for (const invalid of [{ reasoningEffortMode: "bogus" }, { imageInput: "bogus" }]) { + const rejected = await comboApi(config, "PUT", "/api/combos", { + id: "keep", + combo: { targets: [{ provider: "a", model: "m1" }], ...invalid }, + }); + expect(rejected?.status).toBe(400); + expect(config.combos?.keep).toMatchObject({ + reasoningEffortMode: "adaptive", + imageInput: "disabled", + }); + } + + // A rename re-reads the previous combo under its source id, so the carry-over follows it. + const renamed = await comboApi(config, "PUT", "/api/combos", { + id: "kept", + renameFrom: "keep", + combo: { targets: [{ provider: "a", model: "m1" }] }, + }); + expect(renamed?.status).toBe(200); + expect(config.combos?.keep).toBeUndefined(); + expect(config.combos?.kept).toMatchObject({ + reasoningEffortMode: "adaptive", + imageInput: "disabled", + }); + }); + }); + + test("PUT carries lastResort and cooldownWaitPolicy through a dashboard-shaped save (#5691)", async () => { + await withTempHome(async () => { + const config = baseConfig({ combos: undefined }); + saveConfig(config); + + const created = await comboApi(config, "PUT", "/api/combos", { + id: "deferred", + combo: { + strategy: "failover", + cooldownWaitPolicy: "before-last-resort", + waitForCooldownMs: 10000, + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2", lastResort: true }, + ], + }, + }); + expect(created?.status).toBe(200); + expect(await responseJson(created)).toMatchObject({ + combo: { cooldownWaitPolicy: "before-last-resort" }, + }); + expect(config.combos?.deferred).toMatchObject({ + cooldownWaitPolicy: "before-last-resort", + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2", lastResort: true }, + ], + }); + + // The dashboard exposes neither the combo-level policy nor the per-target flag, so its + // whole-combo save re-sends the target list without them. Carrying both forward is what + // keeps a GUI edit from silently turning a deferred target back into a normal one. + const dashboard = await comboApi(config, "PUT", "/api/combos", { + id: "deferred", + combo: { + strategy: "failover", + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + ], + }, + }); + expect(dashboard?.status).toBe(200); + expect(config.combos?.deferred).toMatchObject({ + cooldownWaitPolicy: "before-last-resort", + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2", lastResort: true }, + ], + }); + // The normalizer gives every target an explicit `lastResort: false`; storage stays sparse + // and only the opt-in value is written, so the first target carries no key at all. + expect(config.combos?.deferred?.targets?.[0]).not.toHaveProperty("lastResort"); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.combos?.deferred).toMatchObject({ cooldownWaitPolicy: "before-last-resort" }); + expect(persisted.combos?.deferred?.targets?.[0]).not.toHaveProperty("lastResort"); + expect(persisted.combos?.deferred?.targets?.[1]).toMatchObject({ lastResort: true }); + + // The carry-over re-reads the previous combo under its source id, so a rename keeps it. + const renamed = await comboApi(config, "PUT", "/api/combos", { + id: "deferred-next", + renameFrom: "deferred", + combo: { + strategy: "failover", + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + ], + }, + }); + expect(renamed?.status).toBe(200); + expect(config.combos?.deferred).toBeUndefined(); + expect(config.combos?.["deferred-next"]).toMatchObject({ + cooldownWaitPolicy: "before-last-resort", + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2", lastResort: true }, + ], + }); + + // The flag belongs to the target identity (provider+model), so a swapped-in target does + // not inherit it from whatever used to occupy that position. + const swapped = await comboApi(config, "PUT", "/api/combos", { + id: "deferred-next", + combo: { + strategy: "failover", + targets: [ + { provider: "a", model: "m1" }, + { provider: "c", model: "m3" }, + ], + }, + }); + expect(swapped?.status).toBe(200); + expect(config.combos?.["deferred-next"]).toMatchObject({ + cooldownWaitPolicy: "before-last-resort", + targets: [ + { provider: "a", model: "m1" }, + { provider: "c", model: "m3" }, + ], + }); + expect(config.combos?.["deferred-next"]?.targets?.[1]).not.toHaveProperty("lastResort"); + + // Explicit values still replace: `lastResort: false` drops the flag and a null policy + // clears the combo-level opt-in rather than pinning the string. + const cleared = await comboApi(config, "PUT", "/api/combos", { + id: "deferred-next", + combo: { + strategy: "failover", + cooldownWaitPolicy: null, + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2", lastResort: false }, + ], + }, + }); + expect(cleared?.status).toBe(200); + expect((await responseJson(cleared)).combo).not.toHaveProperty("cooldownWaitPolicy"); + expect(config.combos?.["deferred-next"]).not.toHaveProperty("cooldownWaitPolicy"); + expect(config.combos?.["deferred-next"]?.targets).toHaveLength(2); + for (const target of config.combos?.["deferred-next"]?.targets ?? []) { + expect(target).not.toHaveProperty("lastResort"); + } + const clearedDisk = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(clearedDisk.combos?.["deferred-next"]).not.toHaveProperty("cooldownWaitPolicy"); + expect(clearedDisk.combos?.["deferred-next"]?.targets).toHaveLength(2); + for (const target of clearedDisk.combos?.["deferred-next"]?.targets ?? []) { + expect(target).not.toHaveProperty("lastResort"); + } + }); + }); + + // Review finding on #5736: the per-target carry-over inspected every entry before + // comboConfigError validated the list, so a malformed entry threw a TypeError out of the + // handler instead of producing the structured 400 the rest of the API returns. + test("PUT validates a non-record target instead of throwing in the lastResort carry-over", async () => { + await withTempHome(async () => { + const config = baseConfig({ combos: undefined }); + saveConfig(config); + const created = await comboApi(config, "PUT", "/api/combos", { + id: "guarded", + combo: { + strategy: "failover", + cooldownWaitPolicy: "before-last-resort", + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2", lastResort: true }, + ], + }, + }); + expect(created?.status).toBe(200); + + const malformed = await comboApi(config, "PUT", "/api/combos", { + id: "guarded", + combo: { strategy: "failover", targets: [null] }, + }); + expect(malformed?.status).toBe(400); + expect(await responseJson(malformed)).toMatchObject({ + error: expect.stringContaining("targets[0]"), + }); + // Rejected means rejected: the stored combo keeps the target list it already had. + expect(config.combos?.guarded).toMatchObject({ + cooldownWaitPolicy: "before-last-resort", + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2", lastResort: true }, + ], + }); + }); + }); + + // Same carry-over, second half of the finding: identity is trimmed on both sides, because the + // normalizer trims. A client that pads provider/model re-sends the same target and must keep + // the flag rather than silently reintroducing a normal target. + test("PUT matches the lastResort carry-over against trimmed target identity", async () => { + await withTempHome(async () => { + const config = baseConfig({ combos: undefined }); + saveConfig(config); + const created = await comboApi(config, "PUT", "/api/combos", { + id: "padded", + combo: { + strategy: "failover", + cooldownWaitPolicy: "before-last-resort", + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2", lastResort: true }, + ], + }, + }); + expect(created?.status).toBe(200); + + const padded = await comboApi(config, "PUT", "/api/combos", { + id: "padded", + combo: { + strategy: "failover", + targets: [ + { provider: "a", model: "m1" }, + { provider: " b ", model: " m2 " }, + ], + }, + }); + expect(padded?.status).toBe(200); + expect(config.combos?.padded).toMatchObject({ + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2", lastResort: true }, + ], + }); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.combos?.padded?.targets?.[1]).toMatchObject({ lastResort: true }); + }); + }); + test("PUT rejects an unknown reasoningEffortMode", async () => { await withTempHome(async () => { const config = baseConfig({ combos: undefined }); From e535c655ac86da0250cfdf485f5a0a99199967fd Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 17:38:25 +0900 Subject: [PATCH 29/48] =?UTF-8?q?fix(ci):=20bundle=20L6=20=E2=80=94=20re-a?= =?UTF-8?q?ttestation=20timestamps,=20event-driven=20sideband=20wait,=20on?= =?UTF-8?q?e-process=20test=20leaks=20(#5740)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: accept later PR timestamps for re-attestation * ci: document delayed re-attestation events * test(ci): cover re-attestation timestamp rejection * test(server): wait for the sideband ceiling echo on events, not a 15s timer The sideband ceiling case raced its 50MiB relay against an inner 15s setTimeout and failed on an ordinary macOS shard at 15318ms (#4997). The transfer is what the case asserts, so the wait now settles only on the echo, an early close, a client error, or a failed assertion. onTestFinished owns teardown and reports the stage and peer summary even when the harness budget ends the case. Refs #4997 * test: stop hung or mocking test files leaking into later files in one process Running tests/server, tests/images, or tests/lib in one Bun process (#5439) failed for reasons outside the failing files: - download-connect-deadline-default mocked node:dns/promises and src/lib/pinned-http and never put them back, so pinned-https-get, tests/lib/pinned-http, transport-null-body, and download-cap-default's own capture of the real modules all saw the stub. It now captures the real modules first and restores them in afterAll, as download-cap-default does. - cancel-body-on-abort and server-key-failover-e2e stub globalThis.fetch and restored it only in the test's own finally, which a per-test timeout skips. Both now restore it from afterEach. The live-relay case also fails fast with the handler's status when handleLive settles without calling fetch, instead of waiting forever on the read it expected. - server-key-failover-e2e stopped its proxies only in each test's finally, so one hung case kept the process-wide spend-ledger lease and every later file failed startServer with SPEND_LEDGER_OWNER_HOME_CONFLICT. Its servers are now tracked and stopped from afterEach, and request pacing is reset before and after each test. Refs #5439 * test(server): put module mocks back after the files that install them mock.module outlives the file that calls it, so five tests/server files left stubs installed for every later file in a one-process run (#5439): context-history (auth-context, routing, openai-sidecar, auth-cors, responses, lifecycle), server-combo-failover-e2e (adapter-resolve, upstream-retry), server-combo-zero-output-failover (adapter-resolve), companion-settings (open-url) and startup-action-control-elevation (node:child_process). The auth-cors stub alone makes later proxy tests answer 401 "test credential missing". Each file now snapshots the real module before mocking it and re-registers it in afterAll, the shape download-cap-default already uses. Refs #5439 * test(server): release the native-main block the stop-hardening case leaves behind "a rejected native-lifecycle release still drains the ACL flight" makes releaseNativeMainStartupLifecycle throw, so the real release never ran for the server it started. Under the spoofed win32 platform startServer takes a process-wide "ownership-unknown" native-main block, which then stayed on for the rest of the process: in a one-process tests/server run all 31 failing reserve-ingress cases saw isNativeMainTrafficBlocked() true (#5439). The case now runs the real release once the spy is restored and asserts the gate is open again. Removing that release makes the new assertion fail. Refs #5439 * test(server): start the replace-retry counter cases from zero system-routes reset the process-wide Windows replace-retry counters after each case but not before the first, so in a one-process tests/server run "a clean replace records nothing" read two config:EPERM retries an earlier file had recorded (#5439). Reset before each case as well. Refs #5439 * test(server): start the logs display-metrics cases from an empty request log management-api-logs-metrics cleared the process-wide request log after each case but not before the first, so in a one-process tests/server run its first case read a Kiro row an earlier file had logged and priced it (#5439). Clear the log before each case as well. Refs #5439 --------- Co-authored-by: Vadevious --- .github/scripts/pr-quality-state.test.cjs | 35 ++++ .github/scripts/pr-readiness-reattest.cjs | 9 +- .../pr-readiness-reattest.test.ts | 13 ++ .../download-connect-deadline-default.test.ts | 13 +- tests/server/cancel-body-on-abort.test.ts | 32 +++- tests/server/companion-settings.test.ts | 8 +- tests/server/context-history.test.ts | 20 ++- .../management-api-logs-metrics.test.ts | 3 + .../server/server-combo-failover-e2e.test.ts | 13 +- .../server-combo-zero-output-failover.test.ts | 10 +- tests/server/server-key-failover-e2e.test.ts | 68 +++++--- tests/server/server-live.test.ts | 157 +++++++++--------- .../server-stop-config-hardening.test.ts | 9 + .../startup-action-control-elevation.test.ts | 10 +- tests/server/system-routes.test.ts | 7 +- 15 files changed, 294 insertions(+), 113 deletions(-) diff --git a/.github/scripts/pr-quality-state.test.cjs b/.github/scripts/pr-quality-state.test.cjs index 131205e4b54..cf68abc8276 100644 --- a/.github/scripts/pr-quality-state.test.cjs +++ b/.github/scripts/pr-quality-state.test.cjs @@ -795,6 +795,41 @@ describe("durable readiness re-attestation", () => { assert.equal(result.pending.checkpointAt, null); }); + it("accepts a delayed author event when the live head and body remain unchanged", () => { + const pending = { version: 1, headSha: HEAD_A, baseRef: "dev", generation: 2, phase: "await-clear", checkpointAt: CHECKPOINT }; + const result = advanceReattestation({ + pending, + legacy: false, + current: true, + readiness: readiness(0), + live: live(body0, { updatedAt: "2026-09-22T09:00:01.000Z" }), + event: authorEdit(body0, body4), + }); + assert.equal(result.pending.phase, "await-check"); + assert.equal(result.pending.checkpointAt, null); + }); + + it("rejects future author events and missing or invalid live timestamps", () => { + const pending = { version: 1, headSha: HEAD_A, baseRef: "dev", generation: 2, phase: "await-clear", checkpointAt: CHECKPOINT }; + for (const [name, liveUpdatedAt, eventUpdatedAt] of [ + ["future author event", LIVE_TIME, "2026-09-22T01:00:02.000Z"], + ["missing live timestamp", undefined, LIVE_TIME], + ["invalid live timestamp", "not-a-time", LIVE_TIME], + ]) { + const result = advanceReattestation({ + pending, + legacy: false, + current: true, + readiness: readiness(0), + live: live(body0, { updatedAt: liveUpdatedAt }), + event: authorEdit(body0, body4, { updatedAt: eventUpdatedAt }), + }); + assert.equal(result.pending.phase, "await-clear", name); + assert.equal(result.changed, false, name); + assert.equal(result.canComplete, false, name); + } + }); + it("rejects equal timestamps, title-only edits, and stale or reordered payloads", () => { const pending = { version: 1, headSha: HEAD_A, baseRef: "dev", generation: 2, phase: "await-clear", checkpointAt: CHECKPOINT }; const cases = [ diff --git a/.github/scripts/pr-readiness-reattest.cjs b/.github/scripts/pr-readiness-reattest.cjs index 59576cfb32b..776eaa1ce6b 100644 --- a/.github/scripts/pr-readiness-reattest.cjs +++ b/.github/scripts/pr-readiness-reattest.cjs @@ -115,6 +115,10 @@ function samePending(left, right) { function qualifyingAuthorBodyEdit({ live, event, checkpointAt }) { const checkpointMs = Date.parse(checkpointAt); const eventMs = Date.parse(event?.updatedAt ?? ""); + // GitHub can advance the live PR timestamp after the author event arrives. + // Do not cap the lag: a delayed event still proves this author's post-checkpoint + // edit when the exact body and head are unchanged at the live read. + const liveMs = Date.parse(live?.updatedAt ?? ""); return Boolean( event?.name === "pull_request_target" && event.action === "edited" && @@ -123,9 +127,8 @@ function qualifyingAuthorBodyEdit({ live, event, checkpointAt }) { event.headSha === live.headSha && typeof event.body === "string" && event.body === live.body && typeof event.previousBody === "string" && event.previousBody !== event.body && - event.updatedAt === live.updatedAt && - Number.isFinite(checkpointMs) && Number.isFinite(eventMs) && - eventMs > checkpointMs + Number.isFinite(checkpointMs) && Number.isFinite(eventMs) && Number.isFinite(liveMs) && + eventMs > checkpointMs && eventMs <= liveMs ); } diff --git a/tests/ci-workflows/pr-readiness-reattest.test.ts b/tests/ci-workflows/pr-readiness-reattest.test.ts index e7afad46d6a..d6da586dc95 100644 --- a/tests/ci-workflows/pr-readiness-reattest.test.ts +++ b/tests/ci-workflows/pr-readiness-reattest.test.ts @@ -90,6 +90,19 @@ describe("author-applied policy migration with durable re-attestation", () => { expect(pending(saved(retick, T5))).toBeNull(); }); + test("clear edit survives a live PR timestamp eight hours later", async () => { + const first = await initialize(); + const laterComment = "2026-09-22T08:00:08Z"; + const result = await runEnforcePrTarget(script, { + pr: { body: body(CURRENT, 0), draft: true, head: { sha: HEAD }, updated_at: "2026-09-22T08:00:06Z" }, + eventPayload: { body: body(CURRENT, 0), draft: true, head: { sha: HEAD }, updated_at: T3 }, + eventAction: "edited", previousBody: body(OLD), comments: [first], commentUpdatedAt: laterComment, + }); + expect(pending(saved(result, laterComment)).phase).toBe("await-check"); + expect(promotions(result)).toEqual([]); + expect(bodyWrites(result)).toEqual([]); + }); + test("identical pending replay does not duplicate notices or mutate author content", async () => { const first = await initialize(); const replay = await runEnforcePrTarget(script, { diff --git a/tests/images/download-connect-deadline-default.test.ts b/tests/images/download-connect-deadline-default.test.ts index 959e787d3e3..0774e9ed944 100644 --- a/tests/images/download-connect-deadline-default.test.ts +++ b/tests/images/download-connect-deadline-default.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, mock, test } from "bun:test"; +import { afterAll, describe, expect, mock, test } from "bun:test"; // The default downloader inside connectPublicHttps is the production path for // provider-returned image/video URLs (downloadImageToArtifact and @@ -6,6 +6,17 @@ import { describe, expect, mock, test } from "bun:test"; // production callers. A connect deadline wired only into pinnedHttpsGet would // therefore never arm in production — this suite pins the default path. +// `mock.module` outlives this file: Bun keeps both overrides below for every file that +// runs after this one in the same process, including download-cap-default's own capture of +// the "real" modules and tests/lib's pinned-http suites (#5439). Keep the real modules, +// captured before anything here is mocked, and put them back. +const realDns = { ...(await import("node:dns/promises")) }; +const realPinnedHttp = { ...(await import("../../src/lib/pinned-http")) }; +afterAll(() => { + mock.module("node:dns/promises", () => realDns); + mock.module("../../src/lib/pinned-http", () => realPinnedHttp); +}); + const lookupMock = mock(async (): Promise<{ address: string; family: number }[]> => [ { address: "93.184.216.34", family: 4 }, ]); diff --git a/tests/server/cancel-body-on-abort.test.ts b/tests/server/cancel-body-on-abort.test.ts index 24f1df143c1..19b7e032ddd 100644 --- a/tests/server/cancel-body-on-abort.test.ts +++ b/tests/server/cancel-body-on-abort.test.ts @@ -5,11 +5,17 @@ import { handleResponses } from "../../src/server/responses"; import type { OcxConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +// Captured once so a test that never finishes (Bun's per-test timeout) cannot leave the stubbed +// fetch installed for every later file in the same process. +const REAL_FETCH = globalThis.fetch; + let releaseSpendHome: (() => void) | undefined; afterEach(() => { // Release the lease before later teardown can replace the preload sandbox home. releaseSpendHome?.(); releaseSpendHome = undefined; + // Bun runs afterEach even when a test times out, so this is the restore that survives a hang. + globalThis.fetch = REAL_FETCH; }); function bodyWithCancelSpy(): { body: ReadableStream; cancelled: () => boolean } { @@ -79,8 +85,14 @@ describe("readBodyCapped settles the stream when a read throws", () => { const requestAbort = new AbortController(); const events: string[] = []; let rejectRead!: (reason: unknown) => void; + let readReached = false; let markReadStarted!: () => void; - const readStarted = new Promise(resolve => { markReadStarted = resolve; }); + const readStarted = new Promise(resolve => { + markReadStarted = () => { + readReached = true; + resolve(); + }; + }); const reader = { read(): Promise> { @@ -139,7 +151,23 @@ describe("readBodyCapped settles the stream when a read throws", () => { body: "offer", signal: requestAbort.signal, }), config, { model: "", provider: "" }); - await readStarted; + // Race the start signal against the handler settling: a handler that returns an error + // response without ever fetching would otherwise hang here until the harness kills the test. + await Promise.race([ + readStarted, + pending.then( + async settled => { + if (readReached) return; + throw new Error( + `handleLive settled before reaching fetch: status ${settled.status} ${(await settled.text()).slice(0, 300)}`, + ); + }, + (reason: unknown) => { + if (readReached) return; + throw new Error(`handleLive settled before reaching fetch: ${reason instanceof Error ? reason.message : String(reason)}`); + }, + ), + ]); requestAbort.abort(new DOMException("client closed request", "AbortError")); expect((await pending).status).toBe(499); diff --git a/tests/server/companion-settings.test.ts b/tests/server/companion-settings.test.ts index cdda72200b0..7798790935c 100644 --- a/tests/server/companion-settings.test.ts +++ b/tests/server/companion-settings.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, mock, test } from "bun:test"; +import { afterAll, describe, expect, mock, test } from "bun:test"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,12 +10,18 @@ import { } from "../../src/companion/settings"; import type { OcxConfig } from "../../src/types"; +// `mock.module` outlives this file: Bun keeps the override below for every file that runs after +// this one in the same process. This is a spread snapshot of the real module, taken before it. +const realOpenUrl = { ...(await import("../../src/lib/open-url")) }; const opened: string[] = []; mock.module("../../src/lib/open-url", () => ({ openUrl: (url: string) => { opened.push(url); }, })); +afterAll(() => { // Put the real module back for every later file in the same process. + mock.module("../../src/lib/open-url", () => realOpenUrl); +}); const { resetCompanionPresenceForTests } = await import("../../src/server/management/companion-routes"); const { handleManagementAPI } = await import("../../src/server/management-api"); diff --git a/tests/server/context-history.test.ts b/tests/server/context-history.test.ts index b44c144ab92..ece8d07291b 100644 --- a/tests/server/context-history.test.ts +++ b/tests/server/context-history.test.ts @@ -1,4 +1,3 @@ -// mock.module replacements require file isolation (bun test --isolate). import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -33,6 +32,15 @@ let outgoingAccount = "test-only"; let accountMode = "pool"; let validated=0; let probe=false;let released=0;let directError=false;let duringSelection:(()=>void)|undefined; +// `mock.module` outlives this file: Bun keeps all six overrides below for every file that runs +// after this one in the same process. Keep the real modules, captured before anything here is +// mocked, and put them back. +const realAuthContext = { ...(await import("../../src/codex/auth-context")) }; +const realRouting = { ...(await import("../../src/codex/routing")) }; +const realSidecar = { ...(await import("../../src/providers/openai-sidecar")) }; +const realAuthCors = { ...(await import("../../src/server/auth-cors")) }; +const realResponses = { ...(await import("../../src/server/responses")) }; +const realLifecycle = { ...(await import("../../src/server/lifecycle")) }; const errors = { CodexAccountCooldownError: class extends Error {}, CodexMainSubstitutionUnavailableError: class extends Error {}, @@ -61,7 +69,6 @@ mock.module("../../src/codex/auth-context",()=>({ cooldownErrorResponse:()=>new Response("cooldown",{status:429}), codexMainProfileDrainingResponse:()=>new Response("draining",{status:503}), })); -const realRouting = await import("../../src/codex/routing"); mock.module("../../src/codex/routing",()=>({...realRouting, formatCodexProviderForLog:()=>"openai-test"})); mock.module("../../src/providers/openai-sidecar",()=>({listOpenAiForwardSidecarCandidates:()=>[{providerName:"openai",provider:{baseUrl:"https://chatgpt.com/backend-api/codex"},accountMode}]})); class ForwardAdmissionCredentialError extends Error {} @@ -92,7 +99,14 @@ const originalFetch=globalThis.fetch; function setFetch(handler: (input: string | URL | Request, init?: RequestInit) => Promise): void { globalThis.fetch = Object.assign(handler, { preconnect: originalFetch.preconnect }); } -afterAll(()=>{if(previousCodexHome===undefined)delete process.env.CODEX_HOME;else process.env.CODEX_HOME=previousCodexHome;resetContextRelayActivationForTests();clearContextSessionOwnersForTests();globalThis.fetch=originalFetch;mock.restore();}); +afterAll(()=>{try{if(previousCodexHome===undefined)delete process.env.CODEX_HOME;else process.env.CODEX_HOME=previousCodexHome;resetContextRelayActivationForTests();clearContextSessionOwnersForTests();globalThis.fetch=originalFetch;mock.restore();}finally{ + mock.module("../../src/codex/auth-context",()=>realAuthContext); + mock.module("../../src/codex/routing",()=>realRouting); + mock.module("../../src/providers/openai-sidecar",()=>realSidecar); + mock.module("../../src/server/auth-cors",()=>realAuthCors); + mock.module("../../src/server/responses",()=>realResponses); + mock.module("../../src/server/lifecycle",()=>realLifecycle); +}}); beforeEach(()=>{clearContextSessionOwnersForTests();for (const id of ["root", "root-test", "s"]) seedOwner(id);outgoingAccount="test-only";globalThis.fetch=originalFetch;materialized=undefined;materializationError=undefined;selection=undefined;validated=0;materializationOptions=undefined;outgoingBearer="test-only";accountMode="pool";probe=false;released=0;directError=false;duringSelection=undefined;setContextFeature(true);}); describe("context relay contract",()=>{ diff --git a/tests/server/management-api-logs-metrics.test.ts b/tests/server/management-api-logs-metrics.test.ts index 27f9091557c..46335170bc1 100644 --- a/tests/server/management-api-logs-metrics.test.ts +++ b/tests/server/management-api-logs-metrics.test.ts @@ -46,6 +46,9 @@ let testDir = ""; let previousHome: string | undefined; beforeEach(() => { + // The request log is process-wide: start empty so the first case does not read a row an + // earlier file left behind (a one-process tests/server run handed it a Kiro entry). + clearRequestLogsForTests(); // addRequestLog persists to usage.jsonl; without a scratch OPENCODEX_HOME a bare // `bun test ` run from outside the repo (no bunfig preload) writes these // fixture rows into the real ~/.opencodex log and poisons the GUI Usage page. diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index f4904cb38dd..1fd65f10304 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -4,7 +4,7 @@ import { comboProviderFactory } from "../helpers/combo-provider"; import { registerComboContextOverflowCases } from "../helpers/combo-context-overflow-cases"; import { registerComboContextHeadroomCases } from "../helpers/combo-context-headroom-cases"; import { sessionLaneIdFromRequest } from "../../src/server/request-log-conversation"; -import { afterEach, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; import { logsFromApiBody } from "../helpers/logs-api"; import { managementFetch as fetch, ManagementRequest as Request } from "../helpers/management-auth"; import { mkdtempSync } from "node:fs"; @@ -58,9 +58,11 @@ import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; // default 5s per-test budget (same flake class as 810fa115 / claude-management-api). setDefaultTimeout(30_000); -const actualResolver = await import("../../src/server/adapter-resolve"); +// `mock.module` outlives this file: Bun keeps both overrides below for every file that runs after +// this one in the same process. These are spread snapshots of the real modules, taken before them. +const actualResolver = { ...(await import("../../src/server/adapter-resolve")) }; const actualResolveAdapter = actualResolver.resolveAdapter; -const actualRetry = await import("../../src/lib/upstream-retry"); +const actualRetry = { ...(await import("../../src/lib/upstream-retry")) }; const actualFetchWithTransientRetry = actualRetry.fetchWithTransientRetry; const { createCursorAdapter } = await import("../../src/adapters/cursor"); import type { CursorTransportFactory } from "../../src/adapters/cursor/transport"; @@ -130,6 +132,11 @@ mock.module("../../src/lib/upstream-retry", () => ({ }, })); +afterAll(() => { // Put the real modules back for every later file in the same process. + mock.module("../../src/server/adapter-resolve", () => actualResolver); + mock.module("../../src/lib/upstream-retry", () => actualRetry); +}); + const { handleResponses } = await import("../../src/server/responses"); const { handleResponsesCompact } = await import("../../src/server/responses/compact"); type HandleOptions = NonNullable[3]>; diff --git a/tests/server/server-combo-zero-output-failover.test.ts b/tests/server/server-combo-zero-output-failover.test.ts index b4056f69208..72afd27a812 100644 --- a/tests/server/server-combo-zero-output-failover.test.ts +++ b/tests/server/server-combo-zero-output-failover.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, setDefaultTimeout, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -20,7 +20,9 @@ import { import type { ProviderAdapter } from "../../src/adapters/base"; import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "../../src/types"; -const actualResolver = await import("../../src/server/adapter-resolve"); +// `mock.module` outlives this file: Bun keeps the override below for every file that runs after +// this one in the same process. This is a spread snapshot of the real module, taken before it. +const actualResolver = { ...(await import("../../src/server/adapter-resolve")) }; const actualResolveAdapter = actualResolver.resolveAdapter; let customRunTurn: NonNullable | undefined; @@ -44,6 +46,10 @@ mock.module("../../src/server/adapter-resolve", () => ({ }, })); +afterAll(() => { // Put the real module back for every later file in the same process. + mock.module("../../src/server/adapter-resolve", () => actualResolver); +}); + const { handleResponses } = await import("../../src/server/responses"); /** diff --git a/tests/server/server-key-failover-e2e.test.ts b/tests/server/server-key-failover-e2e.test.ts index aedce1306b0..ea964c0eb0a 100644 --- a/tests/server/server-key-failover-e2e.test.ts +++ b/tests/server/server-key-failover-e2e.test.ts @@ -37,7 +37,22 @@ let previousHome: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; let upstream: ReturnType | null = null; +// A test that hangs until Bun's per-test timeout never runs its own `finally`, so a server +// started here would outlive the test still holding the process-wide spend-ledger lease — every +// later file in the same process then fails to start its own server. Track them for the sweep. +const trackedServers = new Set>(); +// Same reason for fetch: several cases stub it and restore it only in their own `finally`. +const REAL_FETCH = globalThis.fetch; + +function startTrackedServer(port = 0): ReturnType { + const server = startServer(port); + trackedServers.add(server); + return server; +} + beforeEach(() => { + // Do not inherit a pacing runtime swapped in by an earlier file or an earlier timed-out test. + resetProviderRequestPacingForTest(); previousHome = process.env.OPENCODEX_HOME; isolatedCodexHome = installIsolatedCodexHome("ocx-keyfail-e2e-codex-"); testDir = mkdtempSync(join(tmpdir(), "ocx-keyfail-e2e-")); @@ -48,6 +63,16 @@ beforeEach(() => { }); afterEach(async () => { + // Reap before anything else: a server that survives this file keeps the spend-ledger lease. + // Every stop is attempted even when an earlier one rejects, and the failure is raised only + // after the rest of this cleanup has run. Re-stopping a server a test already stopped is safe. + const stopFailures: unknown[] = []; + for (const server of trackedServers) { + trackedServers.delete(server); + try { await server.stop(true); } catch (error) { stopFailures.push(error); } + } + resetProviderRequestPacingForTest(); + globalThis.fetch = REAL_FETCH; await upstream?.stop(true); upstream = null; await flushNativeMainStartupReleases(); @@ -63,6 +88,7 @@ afterEach(async () => { clearKeyCooldowns(); clearReasoningReplayCacheForTests(); clearBridgeSearchReplayCacheForTests(); + if (stopFailures.length > 0) throw new AggregateError(stopFailures, "tracked server stop failed in afterEach"); }); describe("server 429 key failover (end-to-end)", () => { @@ -133,7 +159,7 @@ describe("server 429 key failover (end-to-end)", () => { requestPacing: { enabled: true, minIntervalMs: 100 }, } } } as OcxConfig; saveConfig(config); - const server = startServer(0); + const server = startTrackedServer(); const abort = new AbortController(); try { await waitForProviderRequestSlot("paced", config.providers.paced); @@ -185,7 +211,7 @@ describe("server 429 key failover (end-to-end)", () => { { id: "first", key: "${OCX_SELECTION_E2E_KEY}" }, { id: "second", key: "synthetic-second" }, ], } } } as OcxConfig); - const server = startServer(0); + const server = startTrackedServer(); try { const response = await fetch(new URL(`/v1/${inbound}`, server.url), { method: "POST", headers: { "content-type": "application/json" }, @@ -249,7 +275,7 @@ describe("server 429 key failover (end-to-end)", () => { }, } as OcxConfig; saveConfig(config); - server = startServer(0); + server = startTrackedServer(); const res = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "content-type": "application/json" }, @@ -336,7 +362,7 @@ describe("server 429 key failover (end-to-end)", () => { }, } as OcxConfig; saveConfig(config); - server = startServer(0); + server = startTrackedServer(); const res = await originalFetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "content-type": "application/json" }, @@ -412,7 +438,7 @@ describe("server 429 key failover (end-to-end)", () => { }, } as OcxConfig; saveConfig(config); - const server = startServer(0); + const server = startTrackedServer(); try { const res = await fetch(new URL(surface === "chat" ? "/v1/chat/completions" : "/v1/responses", server.url), { method: "POST", @@ -466,7 +492,7 @@ describe("server 429 key failover (end-to-end)", () => { baseUrl: `http://127.0.0.1:${upstream.port}/v1`, allowPrivateNetwork: true, apiKey: "synthetic-first", apiKeyPool: [{ id: "first", key: "synthetic-first" }, { id: "second", key: "synthetic-second" }] } }, } as OcxConfig); - const server = startServer(0); + const server = startTrackedServer(); try { const response = await fetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "content-type": "application/json" }, @@ -502,7 +528,7 @@ describe("server 429 key failover (end-to-end)", () => { metered: { adapter, authMode: "key", apiKey: "synthetic-key", allowPrivateNetwork: true, baseUrl: `http://127.0.0.1:${upstream.port}` }, } } as OcxConfig); - const server = startServer(0); + const server = startTrackedServer(); try { const response = await fetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "content-type": "application/json" }, @@ -558,7 +584,7 @@ describe("server 429 key failover (end-to-end)", () => { }, } as OcxConfig; saveConfig(config); - const server = startServer(0); + const server = startTrackedServer(); try { const res = await fetch(new URL("/v1/responses", server.url), { method: "POST", @@ -707,7 +733,7 @@ describe("server 429 key failover (end-to-end)", () => { }, } as OcxConfig; saveConfig(config); - const server = startServer(0); + const server = startTrackedServer(); const headers = { "content-type": "application/json", "x-codex-parent-thread-id": "thread-key-rotation", @@ -790,7 +816,7 @@ describe("server 429 key failover (end-to-end)", () => { }, } as OcxConfig; saveConfig(config); - const server = startServer(0); + const server = startTrackedServer(); try { const res = await fetch(new URL("/v1/responses", server.url), { method: "POST", @@ -836,7 +862,7 @@ describe("server 429 key failover (end-to-end)", () => { }, } as OcxConfig; saveConfig(config); - const server = startServer(0); + const server = startTrackedServer(); try { const res = await fetch(new URL("/v1/responses", server.url), { method: "POST", @@ -901,7 +927,7 @@ describe("server 429 key failover (end-to-end)", () => { }, } as OcxConfig; saveConfig(config); - const server = startServer(0); + const server = startTrackedServer(); try { const res = await fetch(new URL("/v1/responses", server.url), { method: "POST", @@ -951,7 +977,7 @@ describe("server 429 key failover (end-to-end)", () => { test("a cooled committed key is replaced before the first attempt", async () => { const seen = await cooledCommittedKeySetup("round-robin"); - const server = startServer(0); + const server = startTrackedServer(); try { const response = await fetch(new URL("/v1/chat/completions", server.url), { method: "POST", headers: { "content-type": "application/json" }, @@ -969,7 +995,7 @@ describe("server 429 key failover (end-to-end)", () => { test("without a configured strategy the cooled key is still used", async () => { const seen = await cooledCommittedKeySetup(); - const server = startServer(0); + const server = startTrackedServer(); try { const response = await fetch(new URL("/v1/chat/completions", server.url), { method: "POST", headers: { "content-type": "application/json" }, @@ -1027,7 +1053,7 @@ describe("server 429 key failover (end-to-end)", () => { const restored = loadConfig(); restored.providers["env-pooled"]!.apiKey = "\${OCX_KEYFAIL_COOLED}"; saveConfig(restored); - const server = startServer(0); + const server = startTrackedServer(); try { const response = await fetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "content-type": "application/json" }, @@ -1086,7 +1112,7 @@ test.each([false, true])("chat-native attributes same-key 429 usage then the rot apiKeyPool: [{ id: "one", key: "key-one" }, { id: "two", key: "key-two" }], retryOn429: { attempts: 1, intervalMs: 100, maxIntervalMs: 100, respectRetryAfter: false }, } } } as OcxConfig); - const server = startServer(0); + const server = startTrackedServer(); try { const response = await fetch(new URL("/v1/chat/completions", server.url), { method: "POST", @@ -1139,7 +1165,7 @@ test("chat-native preserves same-key retry, key rotation, usage, and request log apiKeyPool: [{ id: "one", key: "key-one" }, { id: "two", key: "key-two" }], retryOn429: { attempts: 1, intervalMs: 100, maxIntervalMs: 100, respectRetryAfter: false }, } } } as OcxConfig); - const server = startServer(0); + const server = startTrackedServer(); try { const response = await fetch(new URL("/v1/chat/completions", server.url), { method: "POST", @@ -1188,7 +1214,7 @@ test.each([false, true])("key refetch retains transient recovery metadata (strea apiKey: "synthetic-refetch-a", apiKeyPool: [{ id: "a", key: "synthetic-refetch-a" }, { id: "b", key: "synthetic-refetch-b" }], transientRetryOn5xx: { attempts: 3 }, retryOn429: { attempts: 0 }, } } } as OcxConfig); - const server = startServer(0); + const server = startTrackedServer(); try { const response = await fetch(new URL("/v1/responses", server.url), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "refetch/test", input: "hello", stream }) }); @@ -1307,7 +1333,7 @@ test("a keyed caller's bridged search is restored on its next turn", async () => const config = bridgedReplayConfig(baseUrl, false); saveConfig(config); seedBridgedSearch(baseUrl, bridgeCallerPrincipal(config)); - const server = startServer(0); + const server = startTrackedServer(); try { const response = await fetch(new URL("/v1/responses", server.url), bridgedReplayRequest(BRIDGE_CALLER_KEY)); expect(response.status).toBe(200); @@ -1329,7 +1355,7 @@ test("a keyless loopback caller neither restores nor shares a bridged search", a saveConfig(config); seedBridgedSearch(baseUrl, bridgeCallerPrincipal(config)); seedBridgedSearch(baseUrl, "loopback"); - const server = startServer(0); + const server = startTrackedServer(); try { const response = await fetch(new URL("/v1/responses", server.url), bridgedReplayRequest(undefined)); expect(response.status).toBe(200); @@ -1370,7 +1396,7 @@ test("a dispatch-time key switch rebuilds the bridged-search restore under the n // the request below carries, but the credential whose selection is about to lapse. seedBridgedSearch(baseUrl, bridgeCallerPrincipal(config)); - const server = startServer(0); + const server = startTrackedServer(); const abort = new AbortController(); try { await waitForProviderRequestSlot("pooled", config.providers.pooled); diff --git a/tests/server/server-live.test.ts b/tests/server/server-live.test.ts index 85f100ec3c2..161f2fa3492 100644 --- a/tests/server/server-live.test.ts +++ b/tests/server/server-live.test.ts @@ -2,7 +2,7 @@ * /v1/live relay: Codex App / ChatGPT voice POSTs call-create against the injected base_url, * so the proxy must relay it to an OpenAI upstream instead of the /v1/* JSON-404 guard. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, onTestFinished, test } from "bun:test"; import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { saveCodexAccountCredential } from "../../src/codex/account-store"; @@ -609,83 +609,28 @@ test("call-create and its sideband join bind to the same pool account (openai/co }, { timeout: 20_000 }); test("sideband GET /v1/live/{callId} relays the exact frame ceiling bidirectionally", async () => { - // The peer is a helper so it can report what it saw: this case's only symptom on failure is - // its own deadline, which names neither the slow leg nor whether the reply was ever sent. + // The peer is a helper so it can report what it saw: this case's failure arrives as a timeout, + // which names neither the slow leg nor whether the reply was ever sent. const { server: upstream, seenPaths, seenUpgradeHeaders, probe } = sidebandRelayUpstream(MAX_WS_FRAME_BYTES); - // Created inside the try: a startServer throw used to leak the peer and the socket override. + // No inner deadline: the 50MiB transfer is the thing the assertions are about, so a wall clock + // over it would fail the contract for being the runner rather than the relay. The harness budget + // below is the only bound left, and a case it kills emits no failure message of its own, so + // cleanup lives on `onTestFinished` -- which runs however this case ends -- and reports the stall. let restoreWebSocket: (() => void) | undefined; let live: ReturnType | undefined; - try { - saveConfig(forwardConfig()); - // Redirect ChatGPT sideband targets to the local mock; the config stays canonical. A sibling - // helper because this case is at its size cap and the repo answer is not to compress. - const { OriginalWebSocket, restore } = redirectSidebandWebSocket(upstream.port); - restoreWebSocket = restore; - const server = live = startServer(0); - const client = openSidebandClient(OriginalWebSocket, server.url, "/v1/live/rtc_sideband", DIRECT_CHATGPT_TOKEN); - // The try opens here, one statement after the client exists, so the timer and the phase - // recorder are both created inside the block that closes them. - let phase: ReturnType | undefined; - let timer: ReturnType | undefined; - try { - // Each leg is its own segment and the timer's ticks read the peer's event count, so a - // segment that reaches no further milestone is visible as such. That is weaker than proof - // of a stall: a tick without movement says no milestone was reached, not that nothing moved. - phase = phaseTimer("sideband 50MiB exact frame ceiling", probe.progress); - const leg = phase; - await new Promise((resolve, reject) => { - const fail = (reason: string): void => reject(new Error(`${reason}; peer: ${probe.summary()}`)); - timer = setTimeout(() => fail("sideband timeout"), 15_000); - let stage: "echo-roundtrip" | "await-ceiling-echo" | "done" = "echo-roundtrip"; - leg.split("upgrade"); - client.addEventListener("open", () => { - probe.noteClient("open"); - leg.split("echo-roundtrip"); - client.send("ping-sideband"); - }); - client.addEventListener("close", event => { - probe.noteClient("close=" + event.code); - // ANY close before this case settles is its own outcome, not a deadline. Keying that - // on the first echo left the harder half unreported: a close after the ping and - // before the ceiling echo -- the disconnect a 50MiB frame is most likely to cause -- - // fell through to the 15s timeout and read as a slow peer. - if (stage !== "done") fail(`sideband closed during ${stage}`); - }); - client.addEventListener("message", (event) => { - try { - probe.noteClient("message"); - if (stage === "echo-roundtrip") { - expect(String(event.data)).toBe("echo:ping-sideband"); - leg.split("allocate-ceiling-frame"); - const frame = Buffer.alloc(MAX_WS_FRAME_BYTES); - // The send is synchronous, so allocating the frame, handing it to the socket and - // waiting for the acknowledgement are three separate costs. Timing them as one - // segment reported allocation time as wait time. - leg.split("send-ceiling-frame"); - client.send(frame); - stage = "await-ceiling-echo"; - leg.split("await-ceiling-echo"); - return; - } - expect(String(event.data)).toBe(`bytes:${MAX_WS_FRAME_BYTES}`); - expectSidebandUpgrade({ seenPaths, seenUpgradeHeaders }, "/v1/live/rtc_sideband", DIRECT_CHATGPT_TOKEN); - stage = "done"; - resolve(); - } catch (err) { - reject(err); - } - }); - client.addEventListener("error", () => fail("client websocket error")); - }); - } finally { - // The case owns both: leaving them for the runner to collect is a resource leak whatever - // it did or did not contribute to any particular deadline. - phase?.end(); - clearTimeout(timer); - client.close(); - } - } finally { + let client: WebSocket | undefined; + let phase: ReturnType | undefined; + // Read by the hook, so it outlives the promise whose handlers advance it. + let stage: "echo-roundtrip" | "await-ceiling-echo" | "done" = "echo-roundtrip"; + let cleanedUp = false; + const cleanup = async (): Promise => { + if (cleanedUp) return; + cleanedUp = true; + // What a timed-out case cannot otherwise print: the stage it stalled in and what the peer saw. + if (stage !== "done") console.info(`[sideband ceiling] ended during ${stage}; peer: ${probe.summary()}`); + phase?.end(); + client?.close(); restoreWebSocket?.(); // Nested so the peer is stopped even when stopping the proxy throws: one failed shutdown // must not leave the other listener running for every case after this one. @@ -694,7 +639,69 @@ test("sideband GET /v1/live/{callId} relays the exact frame ceiling bidirectiona } finally { await upstream.stop(true); } - } + }; + onTestFinished(cleanup); + + saveConfig(forwardConfig()); + // Redirect ChatGPT sideband targets to the local mock; the config stays canonical. A sibling + // helper because this case is at its size cap and the repo answer is not to compress. + const { OriginalWebSocket, restore } = redirectSidebandWebSocket(upstream.port); + restoreWebSocket = restore; + const server = live = startServer(0); + const ws = openSidebandClient(OriginalWebSocket, server.url, "/v1/live/rtc_sideband", DIRECT_CHATGPT_TOKEN); + client = ws; + // Each leg is its own segment and the timer's ticks read the peer's event count, so a + // segment that reaches no further milestone is visible as such. That is weaker than proof + // of a stall: a tick without movement says no milestone was reached, not that nothing moved. + phase = phaseTimer("sideband 50MiB exact frame ceiling", probe.progress); + const leg = phase; + // Settles on events only: the two messages, a close before `done`, a client error, or a throw. + await new Promise((resolve, reject) => { + // Silent once the hook owns teardown: a later rejection would be unhandled, not a result. + const fail = (reason: string): void => { + if (cleanedUp) return; + reject(new Error(`${reason}; peer: ${probe.summary()}`)); + }; + leg.split("upgrade"); + ws.addEventListener("open", () => { + probe.noteClient("open"); + leg.split("echo-roundtrip"); + ws.send("ping-sideband"); + }); + ws.addEventListener("close", event => { + probe.noteClient("close=" + event.code); + // ANY close before this case settles is its own outcome, not a deadline. Keying that + // on the first echo left the harder half unreported: a close after the ping and + // before the ceiling echo -- the disconnect a 50MiB frame is most likely to cause -- + // reached no message of its own and waited out the harness budget. + if (stage !== "done") fail(`sideband closed during ${stage}`); + }); + ws.addEventListener("message", (event) => { + try { + probe.noteClient("message"); + if (stage === "echo-roundtrip") { + expect(String(event.data)).toBe("echo:ping-sideband"); + leg.split("allocate-ceiling-frame"); + const frame = Buffer.alloc(MAX_WS_FRAME_BYTES); + // The send is synchronous, so allocating the frame, handing it to the socket and + // waiting for the acknowledgement are three separate costs. Timing them as one + // segment reported allocation time as wait time. + leg.split("send-ceiling-frame"); + ws.send(frame); + stage = "await-ceiling-echo"; + leg.split("await-ceiling-echo"); + return; + } + expect(String(event.data)).toBe(`bytes:${MAX_WS_FRAME_BYTES}`); + expectSidebandUpgrade({ seenPaths, seenUpgradeHeaders }, "/v1/live/rtc_sideband", DIRECT_CHATGPT_TOKEN); + stage = "done"; + resolve(); + } catch (err) { + if (!cleanedUp) reject(err); + } + }); + ws.addEventListener("error", () => fail("client websocket error")); + }); }, { timeout: 20_000 }); test("standalone GET /v1/realtime?intent=quicksilver&model= upgrades and relays bidirectionally", async () => { diff --git a/tests/server/server-stop-config-hardening.test.ts b/tests/server/server-stop-config-hardening.test.ts index 102292cc615..131decd40b4 100644 --- a/tests/server/server-stop-config-hardening.test.ts +++ b/tests/server/server-stop-config-hardening.test.ts @@ -114,8 +114,12 @@ test("a rejected native-lifecycle release still drains the ACL flight before sto throw new Error("native release exploded"); }); let server: ReturnType | null = null; + // Kept separate and never nulled: the body nulls `server` to show stop() has settled, but + // the real native-lifecycle release still has to run against the object startServer returned. + let startedServer: ReturnType | null = null; try { server = startServer(0); + startedServer = server; let settled: "pending" | "rejected" | "resolved" = "pending"; let rejection: unknown; const stopping = server.stop(true).then(() => { settled = "resolved"; }, (error: unknown) => { settled = "rejected"; rejection = error; }); @@ -136,6 +140,11 @@ test("a rejected native-lifecycle release still drains the ACL flight before sto releaseSpy.mockRestore(); aclSpy.mockRestore(); if (server) await server.stop(true).catch(() => undefined); + if (startedServer) await nativeStartup.releaseNativeMainStartupLifecycle(startedServer); + // The spoofed win32 platform makes startServer take a process-wide ownership block, and the + // throwing spy meant it was never dropped: this case must not leave that gate blocked for + // every later test file sharing the process. + expect(nativeStartup.isNativeMainTrafficBlocked()).toBe(false); } }); diff --git a/tests/server/startup-action-control-elevation.test.ts b/tests/server/startup-action-control-elevation.test.ts index 66fdbff3e4d..3b8129c9e5e 100644 --- a/tests/server/startup-action-control-elevation.test.ts +++ b/tests/server/startup-action-control-elevation.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import * as childProcess from "node:child_process"; import { WINDOWS_SCHTASKS_CREATE_ACCESS_DENIED_MARKER } from "../../src/lib/windows-elevation"; @@ -13,11 +13,19 @@ const execFileMock = mock(( const finalizeMock = mock(async () => ({ kind: "done" as const })); +// `mock.module` outlives this file: Bun keeps the override below for every file that runs after +// this one in the same process. This is a spread snapshot of the real module, taken before it. +const realChildProcess = { ...(await import("node:child_process")) }; + mock.module("node:child_process", () => ({ ...childProcess, execFile: execFileMock, })); +afterAll(() => { // Put the real module back for every later file in the same process. + mock.module("node:child_process", () => realChildProcess); +}); + const { classifyCliInstallFailure, clearStartupInstallPartialBlock, diff --git a/tests/server/system-routes.test.ts b/tests/server/system-routes.test.ts index 45907fb6abd..78b72f05c2a 100644 --- a/tests/server/system-routes.test.ts +++ b/tests/server/system-routes.test.ts @@ -11,7 +11,7 @@ * zero across the suite would need a finalizer that aggregates many short-lived * sharded processes, which does not exist. This file covers the route. */ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { handleManagementAPI } from "../../src/server/management-api"; import { @@ -57,6 +57,11 @@ function flakyIo(failures: number, code = "EBUSY", platform: NodeJS.Platform = " }; } +// The counters are process-wide: start from zero so an earlier file's retries are not read here. +beforeEach(() => { + resetWindowsReplaceRetryCountersForTests(); +}); + afterEach(() => { resetWindowsReplaceRetryCountersForTests(); }); From a8d526f98326c36074da9d46a8f4898ec592c635 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 18:18:33 +0900 Subject: [PATCH 30/48] =?UTF-8?q?fix(providers):=20bundle=20L3=20=E2=80=94?= =?UTF-8?q?=20MiMo,=20DeepSeek,=20Google,=20Command=20Code=20and=20xAI=20a?= =?UTF-8?q?dapter=20fixes=20(#5739)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(quota): label DeepSeek balance with the selected row's currency (#5692) A CNY-billed account showed 'API balance ($76.88)'. The symbol now follows the balance_infos row that was picked: USD keeps $, CNY uses ¥, other codes prefix the amount, and a row without a currency keeps the legacy $. * fix(registry): publish MiMo token-plan context, output and modality facts (#5695) The mimo token-plan entry declared no model-level capacity, so V2.6 rows reached clients without a context window, output cap or input modalities. Xiaomi's model pages list 1M context and 128K output for all four roster ids, image input for V2.6 Pro/Flash and V2.5, and text only for V2.5 Pro. Video/audio have no catalog vocabulary and are not claimed; noVisionModels is unchanged. * fix(google): give array tool parameters without items a string item schema (#5689) A tool parameter declared as {type: array} with no items reached Gemini unchanged and could be rejected. The sanitizer now materializes items {type: string} for any array it emits without items (missing, tuple, or invalid source items). Valid item schemas are unchanged, the budget-exhausted path is untouched, and no loss category is recorded. * fix(openai-chat): reconcile repeated MiMo tool-call echoes (carries #5693) MiMo 2.6 Pro over OpenCode Go can echo two identical bare blocks in assistant text beside one structured call whose input repeats the body twice. The pair is now suppressed when exactly one structured call agrees with its function and input, and a doubled input (direct or newline joined, input as the only key) is reduced to one copy. Ambiguous or mismatched markup stays visible. Rebuilt on the blockAt/freeformBody reader from #5725, so the comparison also holds for the canonical MiMo layout with a newline after the function header. Co-authored-by: Vadevious * fix(xai): give grok-4.7-build-fast grok-4.7's documented metadata (#5576) The discovered grok-4.7-build-fast id fell back to a 128K window and a generic effort ladder. xAI documents Grok 4.7 Fast as the same model on faster infrastructure (Cursor and Grok Build only), so the id now carries grok-4.7's 500K window, low..xhigh ladder with a high default, image input, and the reasoning-model stop/penalty/reasoning-replay lists. The wire pin, service tier and lineup seed stay unclaimed until probed. * fix(cli): resolve Codex catalog slugs in ocx effort model (#5096) ocx effort model command-code/deepseek-deepseek-v4.1-flash, the slug the Codex catalog publishes, reported an empty ladder because the selector was split at the first slash and looked up literally. The model part now decodes through the router's known-id slug codec before the ladder, wire map and noReasoningModels lookups, so the slug and the exact id command-code/deepseek/deepseek-v4.1-flash report the same ladder. Output names the resolved id and adds requestedModel / 'Resolved from' when it differs. Unresolvable ids behave as before; no ladder rows change. * fix(command-code): keep MiMo tool-call markup after prose off the text channel (#5698) The Command Code tool-text filter only held a text block that opened with . MiMo's gateway echo can arrive after ordinary prose in the same delta, and interleaved reasoning interrupted held blocks, so the raw envelope reached the client while the native call also ran. - A delta is split at the marker: prose keeps its streamed or queued path and the markup starts a fresh probe block. Leading whitespace still uses the existing probe. - Held blocks are no longer interrupted by interleaved events; the queued byte bound still flushes an envelope that never resolves. - An envelope the strict parser rejects but that opens and closes around a declared function is dropped on the duplicate and clean-finish paths. Markup that parses but does not fit its schema is still released as text. Reimplemented from the reporter's validated patch in the issue. Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com> * docs(command-code): describe the prose split, held envelopes and loose-envelope drop (#5698) * test(layout): register L3 regression files; add the L3 lane plan * fix(google): charge synthesized array items to the schema node budget (#5689) Review follow-up: the materialized items schema was added after traversal without consuming a node, so many bare array leaves could exceed the 1,024-node bound. Synthesis now reserves one node and is skipped, with node-budget-widened reported, once the budget is spent. * fix(openai-chat): reduce a doubled echo input only when one call qualifies Review follow-up to the #5693 carry: the doubled-input repair ran per structured call, so two qualifying calls were both rewritten while the pair itself stayed visible as ambiguous. Both flush sites now reconcile a response's calls as one batch, and the reduction applies only when exactly one call qualifies. * fix(command-code): drop a malformed echo only for its own native call; track only probing blocks Review follow-ups to #5698: a malformed envelope was dropped when any native call exhausted its candidates, even one for another tool; it now needs a native call for the function it declares, otherwise it is released as text. Held blocks are no longer kept in activeProbes just to be skipped on every event. * fix(openai-chat): count an already-agreeing call as a competing echo explanation Review follow-up: with a doubled call A and a call B whose input already equals the repeated block, A was still reduced because only doubled shapes were counted. Both now count as explanations, and the reduction applies only when there is exactly one. * fix(google): omit an array the node budget cannot complete instead of emitting it bare Review follow-up to #5689: when the budget ran out at an array, the retained array could still be emitted without items, which Gemini rejects for the whole request. Every sanitizeSchema exit now completes an array's items or returns BUDGET_EXHAUSTED so the caller omits it, cascading to a parent that lost its own items. Non-array schemas keep the existing budget behaviour. * test(openai-chat): pin fenced repeated echoes as visible and unrepaired Review follow-up: a repeated pair inside a Markdown fence opened in an earlier chunk keeps its doubled input and stays visible, and a fenced echo does not repair the argument prefix beside it. The buffer never holds a complete block inside a fence, so the reducer and drain share the same starting context; these tests guard that. --------- Co-authored-by: Vadevious Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com> --- .../260924_l3_provider_adapters/010_plan.md | 122 +++++++ .../src/content/docs/reference/adapters.md | 20 +- scripts/test-layout/layout.json | 5 + src/adapters/command-code-tool-text.ts | 156 +++++++-- src/adapters/google-tool-schema.ts | 29 +- src/adapters/openai-chat.ts | 18 +- .../serialized-tool-call-content.ts | 118 ++++++- src/cli/effort.ts | 22 +- src/providers/quota/vendor-probes-key.ts | 10 +- src/providers/registry/entries-core.ts | 15 +- src/providers/registry/entries-extended.ts | 18 +- .../ADR-5548-serialized-tool-call-content.md | 2 +- structure/providers-and-adapters.md | 2 +- structure/providers/chat-compat.md | 6 + structure/providers/google.md | 7 +- structure/providers/xai-grok.md | 6 + .../google/google-tool-schema.test.ts | 162 ++++++++- ...-chat-serialized-tool-call-content.test.ts | 322 ++++++++++++++++++ tests/cli/cli-effort-slug.test.ts | 141 ++++++++ .../catalog-vision-sidecar-modalities.test.ts | 2 +- tests/fixtures/test-layout-expected.json | 7 +- ...command-code-tool-text-prose-split.test.ts | 205 +++++++++++ .../providers/deepseek-quota-currency.test.ts | 85 +++++ .../mimo-token-plan-capacity.test.ts | 84 +++++ tests/providers/provider-quota.test.ts | 2 +- .../provider-registry-parity.test.ts | 2 +- .../xai/grok-47-build-fast-metadata.test.ts | 59 ++++ tests/providers/xai/xai-no-stop.test.ts | 1 + tests/providers/xai/xai-transport.test.ts | 2 + .../responses-chat-tool-call-content.test.ts | 22 +- 30 files changed, 1580 insertions(+), 72 deletions(-) create mode 100644 devlog/_plan/260924_l3_provider_adapters/010_plan.md create mode 100644 tests/cli/cli-effort-slug.test.ts create mode 100644 tests/providers/command-code-tool-text-prose-split.test.ts create mode 100644 tests/providers/deepseek-quota-currency.test.ts create mode 100644 tests/providers/mimo-token-plan-capacity.test.ts create mode 100644 tests/providers/xai/grok-47-build-fast-metadata.test.ts diff --git a/devlog/_plan/260924_l3_provider_adapters/010_plan.md b/devlog/_plan/260924_l3_provider_adapters/010_plan.md new file mode 100644 index 00000000000..960e278485f --- /dev/null +++ b/devlog/_plan/260924_l3_provider_adapters/010_plan.md @@ -0,0 +1,122 @@ +# L3 provider adapters — diff-level plan (wp1) + +Lane L3 bundles seven independent provider-adapter fixes into one PR against `dev` +(branch `codex/260924-l3-provider-adapters`, base `be0b5294e5`). Each item has its own +writer scope, and the lane lead registers every new test file in +`scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. + +## Items and diffs + +### 1. #5692 DeepSeek quota currency symbol +- `src/providers/quota/vendor-probes-key.ts` `fetchDeepSeekQuota`: read `preferred.currency`, + map USD→`$`, CNY→`¥`, anything else → `" "` prefix (trimmed, upper-cased; empty/missing → `$` + keeps legacy behaviour only when the row has no currency). Both label branches use it. +- Test: new sibling `tests/providers/deepseek-quota-currency.test.ts` (provider-quota.test.ts sits at + its 3763-line cap): CNY-only row → `API balance (¥76.88)`; USD row → `$`; CNY with granted → both + amounts use `¥`; unknown currency (e.g. EUR) → code prefix. + +### 2. #5689 Google array without items +- `src/adapters/google-tool-schema.ts` `sanitizeSchema`: after the `items` block, when + `out.type === "array"` and `out.items` is absent (source had no items, tuple items dropped, invalid + items widened, or budget ran out), set `out.items = { type: "string" }` and count a loss + (`invalid-schema-widened`) only when the source had no usable items. Valid `items` untouched; + nullable arrays keep `nullable`. Also covers `anyOf`-normalized arrays (apply after anyOf merge). +- Tests in `tests/adapters/google/google-tool-schema.test.ts` (486 lines, uncapped): issue repro + `{type:object, required:[values], properties:{values:{type:array}}}`; nested array; tuple items; + existing valid items byte-identical; non-array unaffected. + +### 3. #5695 mimo token-plan capacity facts +- `src/providers/registry/entries-extended.ts` `mimo` entry: add + `modelContextWindows` (all four ids 1_048_576), `modelMaxOutputTokens` (all four 131_072), + `modelInputModalities` (v2.6-pro, v2.6-flash, v2.5: `["text","image"]`; v2.5-pro: `["text"]`). + Source: mimo.mi.com/models/en-US/ (fetched 2026-09-24: 1M context, 128K output; v2.6-pro/flash + and v2.5 input Text/Image/Video/Audio, v2.5-pro Text). Video/audio are not representable in the + catalog's modality vocabulary, so only text/image are claimed. Keep `noVisionModels` and + `preserveCustomDestination`; update the comment. +- Test: registry/catalog assertion in a sibling test file (e.g. `tests/providers/mimo-token-plan-capacity.test.ts`). + +### 4. Carry #5693 (Vadevious) on #5725 +- `src/adapters/openai-chat/serialized-tool-call-content.ts`: add `repeatedCallIn` built on the + current `callsIn`/`blockAt`; in `duplicatedSerializedToolCallRanges` suppress the adjacent identical + pair only when exactly one structured call matches (compare with `freeformBody`); in + `repairArgumentsDuplicatedBesideSerializedCall` reduce a doubled `input` (direct or newline joined) + when `input` is the only key. +- Tests: port the PR's tests into `tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts` + and `tests/responses/responses-chat-tool-call-content.test.ts`; docs: adapters.md bullet, + `structure/providers/chat-compat.md` paragraph, ADR-5548 consequences line. +- Commit trailer: `Co-authored-by: Vadevious `. + +### 5. #5698 command-code filter (marciodps) +- `src/adapters/command-code-tool-text.ts` per the reporter's final patch, with fixes: + mid-prose marker split in `textDelta` (not when the prefix is only whitespace on a probing block + with an empty probe — the existing probe already holds `"\n"`); shared + `probeBlockText` / `queueProseDelta` helpers; `breakOpenBlocks` skips held blocks; + `isLooseEnvelope` (null-safe `exec` result) used in `matchNative` and `settle` to drop malformed + envelopes naming a declared tool. +- Tests: new sibling `tests/providers/command-code-tool-text-prose-split.test.ts`: prose+markup in + one delta with native duplicate (dropped); prose+markup clean finish (restored); marker at index 0 + after streaming prose; leading-whitespace markup still held; interleaved reasoning keeps held + block; captured malformed `junk` does not throw; never-closing + partial markup released as text. +- Commit trailer: `Co-authored-by: marciodps ` (or their commit email if public). + +### 6. #5096 remainder: `ocx effort model` slug resolution +- `src/cli/effort.ts` `inspectModelEffort`: after splitting provider/model, when the model is not a + known id, decode it with `decodeRoutedModelId(model, knownModelIdsForProvider(provider, prov, config))` + (router.ts / slug-codec.ts). Report `model` as the resolved native id and add `requestedModel` when it + differs. Unresolvable ids keep today's behaviour. +- Tests: new sibling `tests/cli/cli-effort-slug.test.ts`: `command-code/deepseek-deepseek-v4.1-flash` + and `command-code/deepseek/deepseek-v4.1-flash` report the same ladder as + `COMMAND_CODE_MODEL_REASONING_EFFORTS["deepseek/deepseek-v4.1-flash"]`; same for GLM 5.3 FlashX and + Gemini 3.8 Flash (the ids named in the latest issue comment). Ladders are read from the SSOT, not + restated, so no tier is invented. + +### 7. #5576 grok-4.7-build-fast +- `src/providers/registry/entries-core.ts` xAI entry: add `grok-4.7-build-fast` to + `modelContextWindows` (500_000), `modelReasoningEfforts` (low..xhigh), `modelDefaultReasoningEfforts` + (high), `modelInputModalities` (text,image). Not added to `XAI_MODELS`: xAI documents Grok 4.7 Fast as + "the same model served on faster infrastructure… not available on the public xAI API" + (docs.x.ai/developers/grok-4-7, fetched 2026-09-24). No service-tier claim. +- Test: new sibling `tests/providers/xai/grok-47-build-fast-metadata.test.ts` asserting the four facts + equal grok-4.7's. + +## Out of scope +#5421; the "per-model maps lost on restart" half of #5576; the opencode-go `openai-chat` wiring from +#5698 (reported against #5499 in the PR body). + +## Verification +`bun run typecheck`; each focused test file above plus existing neighbours +(`tests/providers/command-code-tool-text.test.ts`, `tests/providers/provider-quota.test.ts`, +`tests/adapters/google/google-tool-schema*.test.ts`, `tests/cli/cli-effort.test.ts`, xAI and catalog +parity suites); `bun run test:changed`; `bun run privacy:scan`; `bun run structure:check`. + + +## Audit fold (A, round 1 verdict FAIL → amendments) + +1. Item 2 materializes `items: {type:"string"}` without adding a loss category (representation fix, keeps + `lossy:false` contracts). Budget-exhausted return stays untouched (`google-tool-schema.test.ts:479-481`). + Existing tests that pin an array output without items (contract test ~578-587, tuple case ~303-322) update + their expected `parameters` only; category sets stay. `structure/providers/google.md` gains one sentence. +2. Item 1 also rewrites `tests/providers/provider-quota.test.ts:1131-1136` (CNY row) to `¥`, line-neutral + (file at its 3763 cap). +3. Item 7: add `grok-4.7-build-fast` to `modelContextWindows`, `modelReasoningEfforts`, + `modelDefaultReasoningEfforts`, `modelInputModalities`, plus the reasoning-model parameter lists xAI documents + for reasoning models (`noStopModels`, `noPenaltyModels`, `preserveReasoningContentModels`). Not added: + `modelWireDefaults` and `modelSupportsServiceTier` (live-probed on grok-4.7 only), `XAI_MODELS`. Update exact + literals: `provider-registry-parity.test.ts:1319`, `xai-no-stop.test.ts:47`, `xai-transport.test.ts:634,844`. + `structure/providers/xai-grok.md` gains a line. +4. Item 6: the decoded id replaces `modelId` before `modelInList` / `configuredReasoningEfforts` / + `reasoningEffortMapFor`. +5. Item 4: compare with `freeformBody` on both sides, tail must be exactly one repetition, add a leading-newline + case. Rebuttal: the newline-joined doubled-input repair stays — #5693 commit 7854ac8 added it with its own + regression test and the PR body documents it. +6. Item 5 source is marciodps' third follow-up comment on #5698 (2026-09-23T19:33Z), full patch vs 2.64.0. + Preserve the marker-free fast path (`command-code-tool-text.test.ts:388`), the queue-visit bound (`:324`), + and whitespace-probe salvage. +7. Lead registers every new test file in both layout maps. + + +Round 2 verdict PASS. Residuals: item 2 drops the original "budget ran out" clause, and the two contract +tests assert only loss reports, so no expectation needs editing there; item 7 keeps grok-4.7-build-fast on +the provider-default wire, to be re-checked on first live discovery. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 78828bdfb52..575311f8a4e 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -62,6 +62,11 @@ transport; it does not infer subscription attribution from the inbound protocol. collects `usage`. Providers listed in `reasoningDetailsModels` (MiniMax M-series) instead read structured `delta.reasoning_details` segments, whose `text` arrives as cumulative snapshots and is prefix-diffed, and replay preserved reasoning as a `reasoning_details` array. +- Suppresses bare `` text when it duplicates a structured call, and collapses two + immediately adjacent identical blocks when exactly one structured call agrees with their function + and input. A doubled `input` is reduced to one copy, joined either directly or by one newline, + and only when the arguments object holds no key besides `input`. Trailing whitespace after the + pair is suppressed; mismatched or example markup remains visible. - ClinePass uses the live-verified gateway format `reasoning: { enabled: true, effort }` (or `{ enabled: false }` when reasoning is disabled); its public API docs do not currently specify this request shape. The adapter preserves requested `low`, `medium`, `high`, `xhigh`, and `max` @@ -212,11 +217,16 @@ only on `/provider/v1/messages`; the pin applies only while the provider points endpoint. It supports forwarding `prompt_cache_key`; this is separate from the OAuth adapter's session header and does not guarantee a provider cache hit. The OAuth `command-code` preset streams `/alpha/generate` as NDJSON. MiMo tool-call -markup echoed by the gateway as text is removed when it duplicates a real call. After a -clean stop or tool-call finish, a complete declared-tool call with no native counterpart -is restored as a real call; an interrupted or failed turn leaves the markup as text. A -freeform call echoed without its `` close counts as complete once -`` arrives. This applies to every MiMo model Command Code serves. +markup echoed by the gateway as text is removed when it duplicates a real call, including +markup the gateway appends after ordinary prose in the same chunk; a marker split across +chunks is still shown as text. Reasoning or other events arriving in between no longer +release a held envelope. After a clean stop or tool-call finish, a complete declared-tool +call with no native counterpart is restored as a real call; an interrupted or failed turn +leaves the markup as text. A call the parser cannot read is dropped rather than printed +when it still opens, closes, and names a declared tool, and either the real call for that +tool arrives or the turn finishes cleanly. A freeform call echoed without its +`` close counts as complete once `` arrives. This applies to every +MiMo model Command Code serves. ## `anthropic` diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f2582895f74..eddd75946fd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -167,6 +167,11 @@ } }, "explicit": { + "deepseek-quota-currency.test.ts": "providers", + "mimo-token-plan-capacity.test.ts": "providers", + "command-code-tool-text-prose-split.test.ts": "providers", + "cli-effort-slug.test.ts": "cli", + "grok-47-build-fast-metadata.test.ts": "providers/xai", "abort-idle-deadline.test.ts": "lib", "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", diff --git a/src/adapters/command-code-tool-text.ts b/src/adapters/command-code-tool-text.ts index 74e33542d97..dccc591814b 100644 --- a/src/adapters/command-code-tool-text.ts +++ b/src/adapters/command-code-tool-text.ts @@ -22,6 +22,12 @@ import { validatesRestoredValue } from "./command-code-restored-schema"; * A text block that opens with `` is therefore held instead of streamed. It is dropped * when a native call proves it is a duplicate, or restored on an eligible clean MiMo finish when * it names a declared tool with arguments that fit its schema. Other markup is released unchanged. + * MiMo can also append the markup after ordinary prose inside one text block; the stream filter + * splits such a delta at the marker and holds the markup part the same way (#5698; a marker split + * across deltas after prose is still released as text). + * A malformed envelope that still opens and closes around a declared function name, but that the + * strict parser rejects, is dropped instead of released when the native call for that same function + * arrives, and on the clean-finish path, so the echo never reaches the client. * Later text waits behind unresolved markup within the same byte bound. */ @@ -81,6 +87,21 @@ function tryJson(value: string): unknown { try { return JSON.parse(value); } catch { return undefined; } } +/** + * The declared function name of an envelope echo the strict parser rejected, or undefined when the + * text is anything else: malformed parameter tags, a missing ``, garbage inside the body. + * Opening and closing as an envelope with a known function name is enough to keep it off the client + * once the native duplicate call for that same name — which carries the canonical execution — + * arrives; a native call for another tool says nothing about this envelope. + */ +function looseEnvelopeName(text: string, declared: CommandCodeDeclaredTools | undefined): string | undefined { + const trimmed = text.trim(); + if (!trimmed.startsWith(TOOL_CALL_MARKER) || !trimmed.endsWith("")) return undefined; + if (trimmed.slice(TOOL_CALL_MARKER.length).includes(TOOL_CALL_MARKER)) return undefined; + const fn = /\s]+)>/.exec(trimmed); + return fn !== null && (declared?.has(fn[1]!) ?? false) ? fn[1]! : undefined; +} + function deepEqual(left: unknown, right: unknown): boolean { if (Object.is(left, right)) return true; if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false; @@ -251,7 +272,10 @@ const encoder = new TextEncoder(); export class CommandCodeToolTextFilter { private readonly openInputs = new Map(); private readonly blocks = new Map(); - /** Only nonempty blocks still deciding whether their text is markup need boundary visits. */ + /** + * Only blocks still deciding whether their text is markup (state "probing") need boundary visits; + * a held block is deliberately absent, so no interleaved event can interrupt it. + */ private readonly activeProbes = new Map(); /** Held blocks in arrival order, including ended ones awaiting a verdict. */ private held: TextBlock[] = []; @@ -283,6 +307,13 @@ export class CommandCodeToolTextFilter { for (const [key, block] of this.activeProbes) { this.queueOperations++; if (key === exceptKey) continue; + // Guard only: a held block is not tracked here (textDelta adds probing blocks and drops them + // from the map on the transition to held). It must stay held until settle, in arrival order — + // interrupting it on interleaved events (reasoning deltas, other blocks, the native call + // itself) cleared complete and malformed envelopes alike and released the echoed call as text. + // The queued-byte bound (makeRoom) still caps memory and wait, flushing everything as text if a + // held envelope never resolves while the stream keeps producing. + if (block.state !== "probing") continue; this.activeProbes.delete(key); block.interrupted = true; block.state = "queued"; @@ -310,8 +341,44 @@ export class CommandCodeToolTextFilter { block = { id: key, markupParts: [], probe: "", bytes: 0, state: "probing", ended: false, interrupted: false, candidates: new Set(this.openInputs.keys()) }; this.blocks.set(key, block); } - if (block.state === "streaming" && this.head === this.pending.length) { - return [...boundaryEvents, { type: "text_delta", text }]; + // MiMo can append tool-call markup after ordinary prose inside one text block. The probe below + // only recognizes a block that opens with the marker, so a marker arriving after prose would + // reach the client (captured 2026-09-23 from xiaomi/mimo-v2.6-pro: prose, then + // "..." echoed by the gateway as one text delta). Split the delta at + // the marker: prose keeps its queued or streamed path, the markup starts a fresh probe block + // and follows the normal hold-and-restore route. A probing block that has consumed nothing but + // whitespace keeps its probe instead, because that probe already holds the marker. + if (block.state !== "held") { + const markerIndex = text.indexOf(TOOL_CALL_MARKER); + const whitespaceLead = markerIndex > 0 && block.state === "probing" && block.probe === "" + && text.slice(0, markerIndex).trim() === ""; + // markerIndex === 0 on a probing block is the ordinary hold path; on any other state the + // block is ordinary text and the marker must still start a fresh probe block. + if (!whitespaceLead && (markerIndex > 0 || (markerIndex === 0 && block.state !== "probing"))) { + const prose = markerIndex > 0 ? text.slice(0, markerIndex) : ""; + const marked = markerIndex > 0 ? text.slice(markerIndex) : text; + let proseEvents: AdapterEvent[] = []; + if (prose) { + if (block.state === "streaming" && this.head === this.pending.length) { + proseEvents = [{ type: "text_delta", text: prose }]; + } else { + if (block.state === "dropped" || block.state === "streaming") { + block = { id: key, markupParts: [], probe: "", bytes: 0, state: "queued", ended: false, interrupted: true, candidates: new Set() }; + this.blocks.set(key, block); + } + proseEvents = this.queueProseDelta(block, prose); + this.probeBlockText(block, prose); + } + } + if (block.state === "queued") block.state = "streaming"; + this.activeProbes.delete(key); + const probeBlock: TextBlock = { id: key, markupParts: [], probe: "", bytes: 0, state: "probing", ended: false, interrupted: false, candidates: new Set(this.openInputs.keys()) }; + this.blocks.set(key, probeBlock); + return [...boundaryEvents, ...proseEvents, ...this.textDelta(id, marked)]; + } + if (markerIndex === -1 && block.state === "streaming" && this.head === this.pending.length) { + return [...boundaryEvents, { type: "text_delta", text }]; + } } // Once a duplicate is dropped, later text is a new chunk at its own wire position. if (block.state === "dropped" || block.state === "streaming") { @@ -320,7 +387,7 @@ export class CommandCodeToolTextFilter { } const preceding = this.makeRoom(encoder.encode(text).byteLength); this.retain(block, text); - if (block.state === "probing" || block.state === "held") this.activeProbes.set(key, block); + if (block.state === "probing") this.activeProbes.set(key, block); const bytes = encoder.encode(text).byteLength; const tail = this.pending.at(-1); if (tail?.kind === "chunk" && tail.block === block && this.head < this.pending.length) { @@ -332,24 +399,8 @@ export class CommandCodeToolTextFilter { this.queueOperations++; } this.queuedBytes += bytes; - if (block.state === "probing") { - for (const char of text) { - if (!block.probe && char.trim() === "") continue; - block.probe += char; - if (!TOOL_CALL_MARKER.startsWith(block.probe)) { - block.state = "queued"; - block.markupParts = []; - break; - } - if (block.probe === TOOL_CALL_MARKER) { - block.state = "held"; - block.probe = ""; - this.held.push(block); - break; - } - } - } - if (block.state !== "probing" && block.state !== "held") this.activeProbes.delete(key); + this.probeBlockText(block, text); + if (block.state !== "probing") this.activeProbes.delete(key); return [...boundaryEvents, ...preceding, ...this.limitPending()]; } @@ -411,7 +462,8 @@ export class CommandCodeToolTextFilter { remaining.push(block); continue; } - const markup = parseToolCallMarkup(block.markupParts.join("")); + const text = block.markupParts.join(""); + const markup = parseToolCallMarkup(text); if (markup && markup.name === name && markupMatchesInput(markup, input)) { this.drop(block); block.state = "dropped"; @@ -420,6 +472,16 @@ export class CommandCodeToolTextFilter { } block.candidates.delete(id); if (block.candidates.size === 0) { + // A malformed envelope cannot match a native input, but it is still an envelope: when the + // native call is for the function it declares, that call carries the execution, so drop the + // echo rather than releasing it as text. A native call for any other tool proves nothing + // about this envelope, so it keeps the release-as-text path below. + if (markup === undefined && looseEnvelopeName(text, this.declared) === name) { + this.drop(block); + block.state = "dropped"; + this.activeProbes.delete(block.id); + continue; + } block.state = "queued"; block.markupParts = []; this.activeProbes.delete(block.id); @@ -472,6 +534,16 @@ export class CommandCodeToolTextFilter { events.push({ type: "tool_call_end" }); salvaged = true; lastTextBlock = undefined; + } else if (restore && !block.interrupted && markup === undefined + && looseEnvelopeName(block.markupParts.join(""), this.declared) !== undefined) { + // A malformed envelope is still an envelope: the native duplicate (observed in every + // capture) carries the call, so the echo is dropped rather than rendered as text. + // Releasing it would put the raw markup back on screen; restoring it could execute a + // second time alongside the native call. The parser must have rejected the text, so an + // envelope that parses but does not fit its schema keeps the release-as-text contract. + this.drop(block); + block.state = "dropped"; + lastTextBlock = undefined; } else { block.state = "queued"; block.markupParts = []; @@ -501,6 +573,44 @@ export class CommandCodeToolTextFilter { block.bytes += bytes; } + /** The incremental open-of-block probe: decide whether the block's text is tool-call markup. */ + private probeBlockText(block: TextBlock, text: string): void { + if (block.state !== "probing") return; + for (const char of text) { + if (!block.probe && char.trim() === "") continue; + block.probe += char; + if (!TOOL_CALL_MARKER.startsWith(block.probe)) { + block.state = "queued"; + block.markupParts = []; + break; + } + if (block.probe === TOOL_CALL_MARKER) { + block.state = "held"; + block.probe = ""; + this.held.push(block); + break; + } + } + } + + /** Route ordinary prose through the queued wire path (shared by the mid-stream marker split). */ + private queueProseDelta(block: TextBlock, prose: string): AdapterEvent[] { + const preceding = this.makeRoom(encoder.encode(prose).byteLength); + this.retain(block, prose); + const bytes = encoder.encode(prose).byteLength; + const tail = this.pending.at(-1); + if (tail?.kind === "chunk" && tail.block === block && this.head < this.pending.length) { + tail.parts.push(prose); + tail.bytes += bytes; + this.queueOperations++; + } else { + this.pending.push({ kind: "chunk", block, parts: [prose], bytes }); + this.queueOperations++; + } + this.queuedBytes += bytes; + return preceding; + } + private drop(block: TextBlock): void { this.budget.releaseRetained(block.bytes, { kind: "live_transient" }); this.queuedBytes = Math.max(0, this.queuedBytes - block.bytes); diff --git a/src/adapters/google-tool-schema.ts b/src/adapters/google-tool-schema.ts index a28290fe602..84ff3b4bc50 100644 --- a/src/adapters/google-tool-schema.ts +++ b/src/adapters/google-tool-schema.ts @@ -604,6 +604,28 @@ function sanitizeProperties( return properties; } +/** + * Gemini rejects an array declaration that carries no `items` (#5689), so no return from + * `sanitizeSchema` may leave an array incomplete. A string item keeps the declaration valid: it + * narrows an unconstrained item rather than widening a constraint, so the loss report, which counts + * widened or dropped constraints, does not record it. The synthesized node is part of the emitted + * tree and charges the node budget like any other, because a wide enough fan-out of `items`-less + * arrays otherwise pushed the output past MAX_SCHEMA_NODES. When the budget cannot pay for that + * item, the array is omitted (`BUDGET_EXHAUSTED`) for its caller to drop instead of being emitted + * bare, which Gemini would reject for the whole request. A parent whose own `items` came back + * exhausted reaches this same rule, so an incomplete array is never nested in a retained one. + */ +function completeArrayItems(out: Schema, state: SanitizeState): SanitizeResult { + if (out.type !== "array" || Object.hasOwn(out, "items")) return out; + if (state.remainingNodes <= 0) { + reportBudgetExhausted(state); + return BUDGET_EXHAUSTED; + } + state.remainingNodes -= 1; + out.items = { type: "string" }; + return out; +} + function sanitizeSchema( node: unknown, defs: Map, @@ -725,7 +747,8 @@ function sanitizeSchema( if (state.remainingNodes <= 0) { if (Object.hasOwn(node, "items") || Object.hasOwn(node, "anyOf")) reportBudgetExhausted(state); - return out; + // An array that the budget stopped before its `items` traversal takes the same rule. + return completeArrayItems(out, state); } if (Array.isArray(node.items)) { @@ -739,7 +762,7 @@ function sanitizeSchema( if (state.remainingNodes <= 0) { if (Object.hasOwn(node, "anyOf")) reportBudgetExhausted(state); - return out; + return completeArrayItems(out, state); } if (Object.hasOwn(node, "anyOf")) { const normalized = normalizeAnyOf(node.anyOf, defs, depth, refDepth, state); @@ -753,7 +776,7 @@ function sanitizeSchema( } Object.assign(out, normalized); } - return out; + return completeArrayItems(out, state); } export function sanitizeGeminiToolParametersWithReport( diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 8b96b64a03d..28173b2b04d 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -45,7 +45,7 @@ import { messagesToChatFormat } from "./openai-chat/messages"; import { withOpenAIChatToolNames } from "./openai-chat/tool-name-registry"; import { openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire"; import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema"; -import { reconcileSerializedToolCallEvents, reconcileStructuredToolCall, SerializedToolCallContentBuffer } from "./openai-chat/serialized-tool-call-content"; +import { reconcileSerializedToolCallEvents, reconcileStructuredToolCall, reconcileStructuredToolCalls, SerializedToolCallContentBuffer } from "./openai-chat/serialized-tool-call-content"; export { stripBracketedModelSuffix } from "./openai-chat/wire"; export { buildOpenAIChatPassthroughRequest } from "./openai-chat/passthrough"; @@ -337,8 +337,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd return yield* terminateWithError(unnamedToolCallEvent(pendingUsage)); } } - // Held serialized markup is released only now, reconciled against the calls it may duplicate. - const references = calls.map(call => reconcileStructuredToolCall(call.name, toolNames.restore(call.name), call.args, toolCallContent.current())); + // Held markup is released only now, as one batch per response: the doubled-input repair needs every call. + const references = reconcileStructuredToolCalls(calls.map(call => ({ wireName: call.name, restoredName: toolNames.restore(call.name), argumentsText: call.args })), toolCallContent.current()); calls.forEach((call, index) => { call.args = references[index]!.argumentsText; }); yield* toolCallContent.drain(references); for (const call of calls) { @@ -772,7 +772,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (typeof msg.content === "string") events.push(...splitInlineThinkContent(provider.inlineThinkTagModels, lastRequestedModelId, budget, msg.content)); const contentEnd = events.length; const answerText = events.slice(contentStart).map(event => (event.type === "text_delta" ? event.text : "")).join(""); - const references: ReturnType[] = []; + // Each call holds the delta event it emitted, so the batch repair sets its arguments later. + const structuredCalls: { wireName: string; restoredName: string; argumentsText: string; delta: Extract }[] = []; const rawToolCalls = msg.tool_calls; if (rawToolCalls !== undefined && rawToolCalls !== null) { if (!Array.isArray(rawToolCalls)) { @@ -795,12 +796,13 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd logInvalidToolCalls("response", rawToolCalls); return [invalidToolCallsEvent(rawToolCalls, "response", usage)]; } - references.push(reconcileStructuredToolCall(name, toolNames.restore(name), args, answerText)); - events.push({ type: "tool_call_start", id, name: toolNames.restore(name) }); - events.push({ type: "tool_call_delta", arguments: references.at(-1)!.argumentsText }); - events.push({ type: "tool_call_end" }); + const delta: Extract = { type: "tool_call_delta", arguments: args }; + structuredCalls.push({ wireName: name, restoredName: toolNames.restore(name), argumentsText: args, delta }); + events.push({ type: "tool_call_start", id, name: toolNames.restore(name) }, delta, { type: "tool_call_end" }); } } + const references = reconcileStructuredToolCalls(structuredCalls, answerText); + structuredCalls.forEach((call, index) => { call.delta.arguments = references[index]!.argumentsText; }); reconcileSerializedToolCallEvents(events, contentStart, contentEnd, references, budget); const stopReason = stopReasonFor(choice.finish_reason); events.push({ diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts index 3a46c3ebbac..3dfee8acdcd 100644 --- a/src/adapters/openai-chat/serialized-tool-call-content.ts +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -100,6 +100,17 @@ function callsIn(text: string, context: TextContext = { fence: null, lineStart: return calls; } +/** + * The first block, and only when the text after it is exactly one repetition of that same block + * (trailing whitespace allowed). The returned range covers the pair and any trailing whitespace. + */ +function repeatedCallIn(text: string, context?: TextContext): SerializedToolCall | undefined { + const first = callsIn(text, context)[0]; + if (!first) return undefined; + if (text.slice(first.end).trimEnd() !== text.slice(first.start, first.end).trimEnd()) return undefined; + return { ...first, end: text.length }; +} + /** Splits safe visible text from a possible control block while carrying Markdown context across chunks. */ export function splitAtPossibleSerializedToolCall( text: string, @@ -328,6 +339,19 @@ function freeformBody(value: string): string { return value.replace(/^\r?\n/, "").trimEnd(); } +/** + * Whether a structured call's freeform input already equals the body of `repeated`. Such a call + * explains the repeated pair on its own, which is what competes with a doubled call in the same + * batch: both readings account for the two blocks, and the response never says which one it meant. + */ +function agreesWithRepeatedBlock( + structured: StructuredToolCallReference, + repeated: SerializedToolCall, +): boolean { + const input = structured.names.has(repeated.name) ? inputFromArguments(structured.argumentsText) : undefined; + return input !== undefined && freeformBody(input) === freeformBody(repeated.body); +} + /** The `[start, end)` ranges of blocks whose function identity and freeform input match a dispatched call. */ function duplicatedSerializedToolCallRanges( text: string, @@ -335,6 +359,12 @@ function duplicatedSerializedToolCallRanges( context?: TextContext, ): { start: number; end: number }[] { if (structuredCalls.length === 0) return []; + const repeated = repeatedCallIn(text, context); + if (repeated) { + // Without a single agreeing call the pair is ambiguous, so no shape of it is suppressed. + const matching = structuredCalls.filter(structured => agreesWithRepeatedBlock(structured, repeated)); + return matching.length === 1 ? [{ start: repeated.start, end: repeated.end }] : []; + } return callsIn(text, context).filter(call => { const body = freeformBody(call.body); return structuredCalls.some(structured => { @@ -359,6 +389,32 @@ export function stripDuplicatedSerializedToolCalls( return result + text.slice(cursor); } +/** + * The reduced arguments when the freeform body of the repeated block is written twice in the + * single string "input" field, or undefined for any other shape. Only the batch reconciler may + * apply it: the reduction rewrites executable arguments, so it needs a uniqueness proof. + */ +function doubledInputReduction( + argumentsText: string, + functionNames: ReadonlySet, + repeated: SerializedToolCall, +): string | undefined { + if (!functionNames.has(repeated.name)) return undefined; + const body = freeformBody(repeated.body); + try { + const parsed = JSON.parse(argumentsText) as unknown; + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + && Object.keys(parsed).length === 1 + && ((parsed as Record).input === body + body + || (parsed as Record).input === body + "\n" + body)) { + return JSON.stringify({ input: body }); + } + } catch { + // A malformed concatenation is handled by the prefix repair instead. + } + return undefined; +} + /** Removes a malformed argument prefix only when a bare block and the JSON suffix prove identical input. */ export function repairArgumentsDuplicatedBesideSerializedCall( argumentsText: string, @@ -397,9 +453,68 @@ export function repairArgumentsDuplicatedBesideSerializedCall( return argumentsText; } +/** One structured call as the reconciler sees it, before its arguments meet the visible text. */ +export interface StructuredToolCallInput { + wireName: string; + restoredName: string; + argumentsText: string; +} + +/** + * Repairs the arguments of every structured call in one response against the visible text that + * response carried, and returns them in input order. The per-call prefix repair stands alone, + * because the markup it proves is matched against that call's own repaired input. The + * doubled-input reduction is applied only when exactly ONE call in the batch qualifies: it either + * carries the doubled shape or already agrees with the repeated block body. It rewrites executable + * arguments, and a second qualifying call leaves the block ambiguous, so the uniqueness proof has + * to cover the whole batch rather than one call at a time. + */ +export function reconcileStructuredToolCalls( + calls: readonly StructuredToolCallInput[], + serializedText: string, +): StructuredToolCallReference[] { + const references = calls.map(call => { + const names = new Set([call.wireName, call.restoredName]); + return { names, argumentsText: repairArgumentsDuplicatedBesideSerializedCall(call.argumentsText, names, serializedText) }; + }); + return reduceUnambiguousDoubledInput(references, serializedText); +} + +/** + * Applies the doubled-input reduction across the batch. The doubled shape is valid JSON, so the + * prefix repair returns it untouched, and the reduction only ever rewrites a call the repair left + * alone. A call whose input already equals the repeated body is a competing explanation, not a + * bystander: both readings account for the pair and the response never picks one, so a batch with + * two qualifying calls keeps every argument exactly as sent. The reduction then rewrites nothing, + * and the markup is left to the range matcher, which suppresses the pair only when exactly one + * call already agrees. A lone qualifying call is always the doubled one, because a call that + * already agrees leaves nothing to reduce. + */ +function reduceUnambiguousDoubledInput( + references: readonly StructuredToolCallReference[], + serializedText: string, +): StructuredToolCallReference[] { + const repeated = repeatedCallIn(serializedText); + if (!repeated) return [...references]; + const candidates = references.map(reference => ({ + reduction: doubledInputReduction(reference.argumentsText, reference.names, repeated), + explains: agreesWithRepeatedBlock(reference, repeated), + })); + if (candidates.filter(candidate => candidate.explains || candidate.reduction !== undefined).length !== 1) { + return [...references]; + } + return references.map((reference, index) => { + const reduction = candidates[index]!.reduction; + return reduction === undefined ? reference : { names: reference.names, argumentsText: reduction }; + }); +} + /** * One structured call as the reconciler sees it: both the wire name and its restored client name * identify it, and its arguments are repaired against the visible text the same response carried. + * A single call is its own batch, so the doubled-input reduction still applies here; a caller + * holding several calls of one response must pass them together to + * `reconcileStructuredToolCalls` so the reduction sees all of them. */ export function reconcileStructuredToolCall( wireName: string, @@ -407,8 +522,7 @@ export function reconcileStructuredToolCall( argumentsText: string, serializedText: string, ): StructuredToolCallReference { - const names = new Set([wireName, restoredName]); - return { names, argumentsText: repairArgumentsDuplicatedBesideSerializedCall(argumentsText, names, serializedText) }; + return reconcileStructuredToolCalls([{ wireName, restoredName, argumentsText }], serializedText)[0]!; } /** diff --git a/src/cli/effort.ts b/src/cli/effort.ts index 1daed4c6792..709e3e47647 100644 --- a/src/cli/effort.ts +++ b/src/cli/effort.ts @@ -7,6 +7,8 @@ import { mapReasoningEffort, reasoningEffortMapFor, } from "../reasoning-effort"; +import { decodeRoutedModelId } from "../providers/slug-codec"; +import { knownModelIdsForProvider } from "../router"; import { findLiveProxy } from "../server/proxy-liveness"; import { modelInList, type OcxConfig } from "../types"; import { @@ -290,19 +292,26 @@ function inspectModelEffort(modelTarget: string, wantsJson: boolean): void { ); } - const isReasoningDisabled = modelInList(provider.noReasoningModels, modelId); - const efforts = configuredReasoningEfforts(provider, modelId); - const wireMap = reasoningEffortMapFor(provider, modelId); + // A Codex-facing slug encodes the inner "/" of a namespaced native id + // (`command-code/deepseek-deepseek-v4.1-flash` for `deepseek/deepseek-v4.1-flash`), so the + // literal id only resolves against the ladder through the decode the router already uses. + const known = knownModelIdsForProvider(providerName, provider, config); + const resolvedModelId = known.includes(modelId) ? modelId : decodeRoutedModelId(modelId, known); + + const isReasoningDisabled = modelInList(provider.noReasoningModels, resolvedModelId); + const efforts = configuredReasoningEfforts(provider, resolvedModelId); + const wireMap = reasoningEffortMapFor(provider, resolvedModelId); // Derive sample ladder directly from canonical CODEX_REASONING_LEVELS (#3528 review) const mappedExamples: Record = {}; for (const { effort } of CODEX_REASONING_LEVELS) { - mappedExamples[effort] = mapReasoningEffort(provider, modelId, effort); + mappedExamples[effort] = mapReasoningEffort(provider, resolvedModelId, effort); } const result = { provider: providerName, - model: modelId, + model: resolvedModelId, + ...(resolvedModelId !== modelId ? { requestedModel: modelId } : {}), reasoningDisabled: isReasoningDisabled, supportedEfforts: efforts ?? null, wireMap: wireMap ?? null, @@ -310,7 +319,8 @@ function inspectModelEffort(modelTarget: string, wantsJson: boolean): void { }; const lines = [ - `Reasoning effort configuration for ${providerName}/${modelId}:`, + `Reasoning effort configuration for ${providerName}/${resolvedModelId}:`, + ...(resolvedModelId !== modelId ? [` Resolved from: ${modelId}`] : []), ` Reasoning disabled: ${isReasoningDisabled ? "yes (noReasoningModels)" : "no"}`, ` Supported ladder: ${efforts ? efforts.join(", ") : "(default / unconstrained)"}`, ` Wire mapping overrides: ${wireMap ? JSON.stringify(wireMap) : "(standard provider mapping)"}`, diff --git a/src/providers/quota/vendor-probes-key.ts b/src/providers/quota/vendor-probes-key.ts index 6c44162ea43..59347607f5e 100644 --- a/src/providers/quota/vendor-probes-key.ts +++ b/src/providers/quota/vendor-probes-key.ts @@ -371,9 +371,15 @@ async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): const toppedUp = toFiniteNumber(preferred.topped_up_balance); const balance = totalBalance ?? grantedBalance ?? toppedUp; if (balance === undefined || balance < 0) return null; + // The rows are currency-scoped, so the symbol has to follow the row that was + // picked: the two glyph currencies keep their sign, any other ISO code + // prefixes the amount, and a row without one keeps the legacy dollar. + const currency = String(preferred.currency ?? "").trim().toUpperCase(); + const sign = currency === "CNY" ? "¥" : currency === "" || currency === "USD" ? "$" : `${currency} `; + const amount = (value: number) => `${sign}${value.toFixed(2)}`; const label = grantedBalance !== undefined && grantedBalance > 0 - ? `API balance ($${balance.toFixed(2)} total, $${grantedBalance.toFixed(2)} granted)` - : `API balance ($${balance.toFixed(2)})`; + ? `API balance (${amount(balance)} total, ${amount(grantedBalance)} granted)` + : `API balance (${amount(balance)})`; return report(provider, "deepseek:balance", { customWindows: [{ label, percent: 0 }], updatedAt: Date.now(), diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 6b91a3f860f..c84ec3d4f20 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -267,6 +267,12 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // 260813: grok-4.6 added per docs.x.ai/developers/grok-4-6. Context/vision still match // grok-4.5; the reasoning ladder does not — 4.6 adds the documented xhigh rung. models: XAI_MODELS, + // grok-4.7-build-fast arrives only through OAuth discovery. We read it as the Grok Build id of + // what xAI documents as Grok 4.7 Fast: "the same model served on faster infrastructure", + // offered in Cursor and Grok Build only, not on the public xAI API (docs.x.ai/developers/grok-4-7, + // fetched 2026-09-24). It therefore inherits grok-4.7's documented facts in the lists below. + // Its wire pin and service tier stay unclaimed until probed, which is why it is absent from + // XAI_MODELS, modelWireDefaults and modelSupportsServiceTier. // Live 2026-09-20: Chat Completions rejects `stop` on grok-4.6 // (`400 invalid-argument "Model grok-4.6 does not support parameter stop."`). // xAI documents `stop` as unsupported for reasoning models. Claude Code @@ -276,6 +282,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // Live 2026-09-23: grok-4.7 answers the same 400. noStopModels: [ "grok-4.7", + "grok-4.7-build-fast", "grok-4.6", "grok-4.5", "grok-4.3", @@ -300,6 +307,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // Non-reasoning ids keep caller penalties. noPenaltyModels: [ "grok-4.7", + "grok-4.7-build-fast", "grok-4.6", "grok-4.5", "grok-4.3", @@ -364,6 +372,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // (they are already listed in noVisionModels below). modelInputModalities: { "grok-4.7": ["text", "image"], + "grok-4.7-build-fast": ["text", "image"], "grok-4.6": ["text", "image"], "grok-4.5": ["text", "image"], "grok-4.3": ["text", "image"], @@ -376,7 +385,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // reasoning_content as the top cause of prompt-cache misses on multi-turn conversations // (docs.x.ai prompt-caching/multi-turn, verified 2026-07-13 — devlog/_plan/260713_grok_caching). // Models that never emit reasoning simply have no thinking parts to replay (no-op). - preserveReasoningContentModels: ["grok-4.7", "grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning"], + preserveReasoningContentModels: ["grok-4.7", "grok-4.7-build-fast", "grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning"], // grok-4.5 reasoning is always-on with low/medium/high (no off tier, no xhigh). // grok-4.6 adds xhigh per docs.x.ai/developers/model-capabilities/text/reasoning; // multi-agent accepts the same four wire values to select 4 or 16 collaborators. xAI @@ -385,15 +394,17 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // 2026-09-23 live probe accepted low..xhigh and rejected max on both wires; // devlog/_plan/260923_grok47_parity/010_probe-evidence.md. "grok-4.7": ["low", "medium", "high", "xhigh"], + "grok-4.7-build-fast": ["low", "medium", "high", "xhigh"], "grok-4.6": ["low", "medium", "high", "xhigh"], "grok-4.5": ["low", "medium", "high"], "grok-4.20-multi-agent-0309": ["low", "medium", "high", "xhigh"], }, - modelDefaultReasoningEfforts: { "grok-4.7": "high", "grok-4.6": "high" }, + modelDefaultReasoningEfforts: { "grok-4.7": "high", "grok-4.7-build-fast": "high", "grok-4.6": "high" }, modelContextWindows: { // 500k confirmed by context_length_exceeded: // devlog/_plan/260923_grok47_parity/010_probe-evidence.md. "grok-4.7": 500_000, + "grok-4.7-build-fast": 500_000, "grok-4.6": 500_000, "grok-4.5": 500_000, "grok-4.3": 1_000_000, diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index d96f6605043..42c0bbda406 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -1218,11 +1218,23 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ adapter: "openai-chat", authKind: "key", dashboardUrl: "https://xiaomimimo.com", - // Token-plan roster per Xiaomi's token-plan model list (V2.6 Pro and Flash). No jawcodeBundle, - // so no plan-specific facts are claimed; usage estimates still come from the model-level vendor - // price fallback (the pay-as-you-go equivalent), exactly as they did for V2.5. + // Token-plan roster per Xiaomi's token-plan model list (V2.6 Pro and Flash). Model-level facts + // come from Xiaomi's model pages (mimo.mi.com/models/en-US/, fetched 2026-09-24): 1M context, + // 128K max output; V2.6 Pro/Flash and V2.5 take text/image/video/audio, V2.5 Pro text only. The + // catalog vocabulary has no video or audio, so only text/image are claimed. The token plan speaks + // the same API format as pay-as-you-go, so these are model facts rather than plan facts. No + // jawcodeBundle: pricing and entitlement stay unclaimed, and usage estimates still come from the + // model-level vendor price fallback, exactly as they did for V2.5. defaultModel: "mimo-v2.6-pro", models: ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.5-pro", "mimo-v2.5"], + modelContextWindows: { "mimo-v2.6-pro": 1_048_576, "mimo-v2.6-flash": 1_048_576, "mimo-v2.5-pro": 1_048_576, "mimo-v2.5": 1_048_576 }, + modelMaxOutputTokens: { "mimo-v2.6-pro": 131_072, "mimo-v2.6-flash": 131_072, "mimo-v2.5-pro": 131_072, "mimo-v2.5": 131_072 }, + modelInputModalities: { + "mimo-v2.6-pro": ["text", "image"], + "mimo-v2.6-flash": ["text", "image"], + "mimo-v2.5": ["text", "image"], + "mimo-v2.5-pro": ["text"], + }, // The gateway validates the ladder strictly and rejects anything above `high`. reasoningEfforts: ["low", "medium", "high"], reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" }, diff --git a/structure/decisions/ADR-5548-serialized-tool-call-content.md b/structure/decisions/ADR-5548-serialized-tool-call-content.md index 13b2a1c31d0..5693306cc97 100644 --- a/structure/decisions/ADR-5548-serialized-tool-call-content.md +++ b/structure/decisions/ADR-5548-serialized-tool-call-content.md @@ -9,5 +9,5 @@ - Alternatives considered: Drop all tool-call-looking content, add a provider-specific switch, or reconcile serialized blocks with structured calls at the Chat adapter boundary. - Choice: Hold only a possible complete markup block and suppress or repair it only when the function name and duplicated body agree with a structured call in the same response. - Why: Agreement between both representations is deterministic and avoids changing ordinary commentary, mismatched markup, or unrelated providers' valid text. -- Consequences: Matching calls no longer appear twice; same-name/different-body examples remain visible; the small held region is translator-budgeted and emits heartbeats while held; terminal failures retain held text without dispatching tools; malformed concatenated arguments are repaired only for the exact duplicated wrapper shape. +- Consequences: Matching calls no longer appear twice; an exact pair of immediately adjacent identical blocks with one doubled structured input is reduced to one call; same-name/different-body examples remain visible; the small held region is translator-budgeted and emits heartbeats while held; terminal failures retain held text without dispatching tools; malformed concatenated arguments are repaired only for proven duplicate shapes. - Follow-up (260924): the streaming hold is bounded (8 KiB of prose after a closed block, 4 MiB total); past a bound held text is released unsuppressed. See structure/providers/chat-compat.md. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index b5b10648e2e..25076e841a7 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -55,7 +55,7 @@ only canonical Fable, Opus, or Sonnet labels after removing terminal controls; u | `src/adapters/devin.ts`, `src/adapters/devin/cloud-direct/` | Devin runTurn transport over Cognition Connect-RPC. `GetChatMessage` uses the Responses provider executor and shared physical-send budget; catalog and JWT support RPCs remain outside inference-send accounting. Provider-stated 429 reset delays are surfaced to the client rather than slept inside an admitted turn, so they cannot retain shared active-turn capacity. A recorded tenant host is used only for the stored account whose credential owns the transmitted key, searched in the configured provider id and then its deprecated alias; a configured, forwarded, or unmatched key uses the configured base URL or the US default. | | `src/adapters/kiro.ts` and `src/adapters/kiro/` | Kiro event/tool/thinking/truncation/retry handling. The original path is a facade over leaves for wire identity, reasoning, conversation state, token estimation, payload assembly, streaming, and the adapter. | | `src/adapters/mimo-free.ts` | Mimo Free transport (client identity + JWT). Concurrent requests share one JWT bootstrap bound only to its timeout; each request stops waiting on its own abort without cancelling the others. | -| `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous text run, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Native, reasoning, and other intervening events release an open markup block as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. | +| `src/adapters/command-code.ts`, `src/adapters/command-code-tool-text.ts`, `src/adapters/command-code-restored-schema.ts` | Command Code OAuth NDJSON translation. For every `xiaomi/mimo-` model, text, native calls, reasoning, and terminal decisions share one byte-bounded queue with linear queue visits. Markup is deduplicated against matching native calls; text-only restoration requires one contiguous text run, a clean finish, a declared tool, and arguments validated against supported schema constraints. A parameter-free (freeform) block may omit `` but must end with ``; parameter blocks keep the canonical close. Markup appended after prose in the same delta is split off at the marker and held like a block that opens with ``; a marker split across deltas after prose is still released as text. Native, reasoning, and other intervening events interrupt a still-probing block but leave a held block held in arrival order, and the queued byte bound still flushes an unresolved envelope as text. An envelope the strict parser rejects but that opens with ``, closes with ``, and names a declared function is dropped when a native call for that same function arrives and on a clean finish; markup that parses but fits no supported schema is still released as text. Regex patterns, other unsupported constraints, and abnormal finishes fail closed. `tests/providers/command-code-tool-text-prose-split.test.ts` covers the split, the interleaved-event hold, and both drop paths. | | `src/adapters/image.ts`, `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Image conversion for adapter ingress and Anthropic-specific normalization/limits. An image's ladder position is pinned to its own identity (content hash + media type), so appending a newer image cannot re-encode older ones and bust Anthropic's prompt prefix cache (#4532). | | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/upstream-http-error.ts` | Shared adapter execution support: turn queueing, tool-catalog nudging, client identity, upstream error normalization. | diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 46cb7064238..3677b03e3db 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -334,6 +334,12 @@ of a line does the first `` close it, so a body can still carry lite If the gateway also prefixes the structured call's JSON arguments with the same freeform body, the adapter keeps the JSON suffix only when the block body, prefix, and wrapper's `input` value all agree. Mismatched markup and arguments remain byte-exact. +Two immediately adjacent identical bare blocks, with optional trailing whitespace after the pair, +are suppressed only when exactly one structured call matches their function name and carries their +body as `input`, either as one copy or as two copies joined directly or by one newline. Reducing a +doubled `input` requires an arguments object with no keys besides `input`; extra keys leave it +unchanged. Unrelated structured calls do not prevent suppression, and other repeated shapes remain +unchanged. Silent held-content frames emit adapter heartbeats. Terminal errors and transport read failures drain all held text, including matching serialized blocks, because pending tools are not dispatched. The held bytes use the shared translator budget. The streaming hold is bounded (`ingestStreaming`): once a closed block is followed by more than 8 KiB of prose with no block open after it, or held text plus queued events would pass 4 MiB, everything held is released in order with nothing suppressed, so an unmatched block no longer delays the rest of the answer to the end of the turn. A duplicate is the tail of the content, so its reconciliation is unaffected; past either bound the stream prefers delivery (the pre-#5548 raw markup) over suppression. Buffered responses keep the unbounded `ingest` because their structured calls are already known (`tests/adapters/openai/openai-chat-serialized-tool-call-hold-bound.test.ts`). For a model opted into inline `` splitting, diff --git a/structure/providers/google.md b/structure/providers/google.md index 5582a356d9f..2253b157472 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -88,7 +88,12 @@ Every sanitizer branch that widens or drops an accepted-value constraint has a c including type unions and unsupported types, conditional and tuple constraints, reference-overlay replacement, and root object coercion. Lossless normalization does not set `lossy`: accepted type case folding, duplicate enum/required removal, nullable-union collapse, and string-const conversion -preserve the accepted value set. Annotation-only fields such as title, default, examples, comments, +preserve the accepted value set; an array left without `items` is emitted with `items: { type: "string" }` +because Gemini rejects an array declaration without an item type; that narrows an unconstrained item +rather than widening a constraint, so it does not set `lossy` either. The synthesized item is itself +part of the emitted tree and charges the 1,024-node allowance, so an array the budget can no longer +complete is omitted — along with any parent that lost its own `items` to the same rule — and records +`node-budget-widened` instead of emitting a declaration Gemini would reject. Annotation-only fields such as title, default, examples, comments, deprecated, read-only/write-only, external documentation and examples are omitted without loss. Local-reference siblings use 2020-12-style conjunctive semantics for loss accounting, while the wire transform retains its implemented overlay-wins merge; enum reports compare that intersection diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 4d553d3d90d..7d0030de0b7 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -61,6 +61,12 @@ 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`. + ### Policy-refusal 403 xAI sometimes refuses a turn with HTTP 403 and a bare refusal sentence (`I can't help with that diff --git a/tests/adapters/google/google-tool-schema.test.ts b/tests/adapters/google/google-tool-schema.test.ts index 776a5138670..486f078c564 100644 --- a/tests/adapters/google/google-tool-schema.test.ts +++ b/tests/adapters/google/google-tool-schema.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { sanitizeGeminiToolParameters } from "../../../src/adapters/google-tool-schema"; +import { + sanitizeGeminiToolParameters, + sanitizeGeminiToolParametersWithReport, +} from "../../../src/adapters/google-tool-schema"; function countSchemaNodes(value: unknown): number { if (!value || typeof value !== "object" || Array.isArray(value)) return 0; @@ -12,6 +15,24 @@ function countSchemaNodes(value: unknown): number { return count; } +/** + * Gemini rejects an array declaration that carries no `items` (#5689), which fails the whole tool + * request. Returns the path of every emitted array that lacks them, walking the same places as + * `countSchemaNodes`. + */ +function findArraysWithoutItems(value: unknown, path = "root"): string[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const schema = value as Record; + const offenders = schema.type === "array" && schema.items === undefined ? [path] : []; + if (schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties)) { + for (const [name, child] of Object.entries(schema.properties)) { + offenders.push(...findArraysWithoutItems(child, `${path}.${name}`)); + } + } + offenders.push(...findArraysWithoutItems(schema.items, `${path}.items`)); + return offenders; +} + describe("sanitizeGeminiToolParameters", () => { test("drops JSON-Schema keywords outside Google's documented function-schema subset", () => { const out = sanitizeGeminiToolParameters({ @@ -452,7 +473,7 @@ describe("sanitizeGeminiToolParameters", () => { expect(choice).toEqual({ description: "kept" }); }); - test("does not read items after earlier traversal exhausts the budget", () => { + test("omits an array left without items after earlier traversal exhausts the budget", () => { const container: Record = { type: "array", properties: Object.fromEntries(Array.from( @@ -469,18 +490,145 @@ describe("sanitizeGeminiToolParameters", () => { }, }); - const out = sanitizeGeminiToolParameters({ + const result = sanitizeGeminiToolParametersWithReport({ type: "object", properties: { container }, - }); - const sanitized = (out.properties as Record>).container; + }, { endpointClass: "ai-studio" }); + const properties = result.parameters.properties as Record>; + // The traversal stops before the `items` keyword is read, and the array it can no longer + // complete is omitted rather than emitted without them. expect(readItems).toBe(false); - expect(sanitized.items).toBeUndefined(); - expect(countSchemaNodes(out)).toBe(1_024); + expect(findArraysWithoutItems(result.parameters)).toEqual([]); + expect(properties.container).toBeUndefined(); + expect(result.lossReport.categories["node-budget-widened"]).toBe(1); + }); + + test("charges synthesized array items to the node budget", () => { + const names = Array.from({ length: 2_000 }, (_, index) => `field_${index}`); + const result = sanitizeGeminiToolParametersWithReport({ + type: "object", + properties: Object.fromEntries(names.map(name => [name, { type: "array" }])), + }, { endpointClass: "ai-studio" }); + + // Every retained array leaf costs two nodes: the leaf and the `items` synthesized for it, and no + // retained leaf may be an array the budget left without them. + expect(countSchemaNodes(result.parameters)).toBeLessThanOrEqual(1_024); + expect(findArraysWithoutItems(result.parameters)).toEqual([]); + const properties = result.parameters.properties as Record>; + const retained = Object.keys(properties); + expect(retained).toHaveLength(511); + expect(retained.map(name => properties[name])).toEqual( + retained.map(() => ({ type: "array", items: { type: "string" } })), + ); + expect(result.lossReport.categories["node-budget-widened"]).toBeGreaterThan(0); + }); + + test("omits a nested array whose items cannot be completed inside the node budget", () => { + // Each `grid` costs three nodes: the outer array, its inner array, and the item synthesized for + // the inner one. The one-node `pad` property puts the boundary mid-grid, so the last grid can + // complete neither level: the inner array is omitted for want of an item node and the outer one + // follows it, rather than the inner array being nested into a retained outer array without them. + const properties: Record = { pad: { type: "string" } }; + for (let index = 0; index < 2_000; index++) { + properties[`grid_${index}`] = { type: "array", items: { type: "array" } }; + } + const result = sanitizeGeminiToolParametersWithReport({ + type: "object", + properties, + }, { endpointClass: "ai-studio" }); + + expect(countSchemaNodes(result.parameters)).toBeLessThanOrEqual(1_024); + // The inner array cannot pay for its item node, so it is omitted; the outer array that lost its + // `items` this way is omitted with it instead of being retained bare. + expect(findArraysWithoutItems(result.parameters)).toEqual([]); + const retained = result.parameters.properties as Record>; + expect(Object.keys(retained)).toHaveLength(341); + expect(retained.pad).toEqual({ type: "string" }); + expect(retained.grid_339).toEqual({ + type: "array", + items: { type: "array", items: { type: "string" } }, + }); + expect(retained.grid_340).toBeUndefined(); + expect(result.lossReport.categories["node-budget-widened"]).toBe(1); }); test("falls back to an object schema for non-object input", () => { expect(sanitizeGeminiToolParameters(undefined)).toEqual({ type: "object", properties: {} }); expect(sanitizeGeminiToolParameters("nope")).toEqual({ type: "object", properties: {} }); }); + + test("materializes items on an array left without them (issue #5689)", () => { + const result = sanitizeGeminiToolParametersWithReport({ + type: "object", + required: ["values"], + properties: { values: { type: "array" } }, + }, { endpointClass: "ai-studio" }); + const values = (result.parameters.properties as Record>).values; + expect(values.items).toEqual({ type: "string" }); + expect(values).toEqual({ type: "array", items: { type: "string" } }); + // Gemini needs the item type present; adding it widens nothing, so `lossy` stays false. + expect(result.lossReport.lossy).toBe(false); + expect(result.lossReport.categories).toEqual({}); + }); + + test("materializes items for an array nested in array items", () => { + const out = sanitizeGeminiToolParameters({ + type: "object", + properties: { grid: { type: "array", items: { type: "array" } } }, + }); + const grid = (out.properties as Record>).grid; + expect(grid).toEqual({ type: "array", items: { type: "array", items: { type: "string" } } }); + }); + + test("materializes items when a tuple's prefix list is dropped", () => { + const result = sanitizeGeminiToolParametersWithReport({ + type: "object", + properties: { pair: { type: "array", items: [{ type: "string" }, { type: "number" }] } }, + }, { endpointClass: "ai-studio" }); + const pair = (result.parameters.properties as Record>).pair; + expect(pair).toEqual({ type: "array", items: { type: "string" } }); + expect(result.lossReport.categories).toEqual({ "tuple-prefix-dropped": 1 }); + }); + + test("materializes items for an array collapsed from a nullable anyOf", () => { + const out = sanitizeGeminiToolParameters({ + type: "object", + properties: { ids: { anyOf: [{ type: "array" }, { type: "null" }] } }, + }); + expect((out.properties as Record>).ids).toEqual({ + type: "array", + items: { type: "string" }, + nullable: true, + }); + }); + + test("leaves valid array items unchanged", () => { + const out = sanitizeGeminiToolParameters({ + type: "object", + properties: { + list: { type: "array", items: { type: "integer", description: "kept" }, minItems: 1 }, + enumList: { type: "array", items: { enum: ["a", "b"] } }, + }, + }); + const props = out.properties as Record>; + expect(props.list).toEqual({ type: "array", items: { type: "integer", description: "kept" } }); + expect(props.enumList).toEqual({ type: "array", items: { enum: ["a", "b"] } }); + }); + + test("does not add items to a non-array property", () => { + const out = sanitizeGeminiToolParameters({ + type: "object", + properties: { + text: { type: "string" }, + widened: {}, + nested: { type: "object", properties: { inner: { type: "array" } } }, + }, + }); + const props = out.properties as Record>; + expect(Object.hasOwn(props.text, "items")).toBe(false); + expect(Object.hasOwn(props.widened, "items")).toBe(false); + expect(Object.hasOwn(props.nested, "items")).toBe(false); + const inner = (props.nested.properties as Record>).inner; + expect(inner.items).toEqual({ type: "string" }); + }); }); diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts index 7faf3a8572b..bc44648654c 100644 --- a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts @@ -31,6 +31,290 @@ test("buffered Chat responses reconcile matching serialized and structured tool }); }); +test("buffered Chat responses reconcile two identical echoed blocks and doubled input", async () => { + const script = "const names = []; text(names);"; + const block = `${script}`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content: block + block, + tool_calls: [{ + id: "call_exec", + function: { name: "exec", arguments: JSON.stringify({ input: script + script }) }, + }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", + arguments: JSON.stringify({ input: script }), + }); +}); + +test("buffered Chat responses suppress two echoed blocks when structured input is already single", async () => { + const script = "text('ok');"; + const block = `${script}`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content: block + block, + tool_calls: [{ + id: "call_exec", + function: { name: "exec", arguments: JSON.stringify({ input: script }) }, + }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", + arguments: JSON.stringify({ input: script }), + }); +}); + +test("buffered Chat responses suppress two echoed blocks with a trailing newline", async () => { + const script = "text('ok');"; + const block = `${script}`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content: block + block + "\n", + tool_calls: [{ id: "call_exec", function: { name: "exec", arguments: JSON.stringify({ input: script }) } }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", arguments: JSON.stringify({ input: script }), + }); +}); + +test("buffered Chat responses repair two echoed blocks with newline-joined input", async () => { + const script = "text('ok');"; + const block = `${script}`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content: block + block, + tool_calls: [{ + id: "call_exec", + function: { name: "exec", arguments: JSON.stringify({ input: script + "\n" + script }) }, + }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", + arguments: JSON.stringify({ input: script }), + }); +}); + +test("buffered Chat responses suppress a repeated echo beside an unrelated structured call", async () => { + const script = "text('ok');"; + const block = `${script}`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content: block + block, + tool_calls: [ + { id: "call_exec", function: { name: "exec", arguments: JSON.stringify({ input: script }) } }, + { id: "call_other", function: { name: "other", arguments: JSON.stringify({ input: "other" }) } }, + ], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: JSON.stringify({ input: script }) }, + { type: "tool_call_delta", arguments: JSON.stringify({ input: "other" }) }, + ]); +}); + +test("buffered Chat responses preserve repeated markup when two structured calls match", async () => { + const script = "text('ok');"; + const block = `${script}`; + const content = block + block; + const argumentsText = JSON.stringify({ input: script }); + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content, + tool_calls: [ + { id: "call_one", function: { name: "exec", arguments: argumentsText } }, + { id: "call_two", function: { name: "exec", arguments: argumentsText } }, + ], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([{ type: "text_delta", text: content }]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: argumentsText }, + { type: "tool_call_delta", arguments: argumentsText }, + ]); +}); + +test("buffered Chat responses preserve repeated markup when the structured input differs", async () => { + const script = "text('example');"; + const content = `${script}`.repeat(2); + const argumentsText = JSON.stringify({ input: script + "text('other');" }); + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content, + tool_calls: [{ id: "call_exec", function: { name: "exec", arguments: argumentsText } }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.find(event => event.type === "text_delta")).toEqual({ type: "text_delta", text: content }); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", + arguments: argumentsText, + }); +}); + +test("buffered Chat responses reduce a doubled input behind the MiMo wrapping newline", async () => { + // The canonical MiMo layout puts one template newline after the function header, so the block + // body and the doubled structured input only agree once both sides are freeform-normalized. + const script = "text('ok');"; + const block = `\n${script}\n`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content: block + block, + tool_calls: [{ + id: "call_exec", + function: { name: "exec", arguments: JSON.stringify({ input: script + script }) }, + }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", + arguments: JSON.stringify({ input: script }), + }); +}); + +test("buffered Chat responses keep doubled input when two structured calls qualify", async () => { + // Two qualifying calls leave the repeated block ambiguous, so rewriting either argument would + // execute something the response never proved. Both keep their input and the markup stays visible. + const script = "text('ok');"; + const block = `${script}`; + const content = block + block; + const argumentsText = JSON.stringify({ input: script + script }); + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { content, tool_calls: [ + { id: "call_one", function: { name: "exec", arguments: argumentsText } }, + { id: "call_two", function: { name: "exec", arguments: argumentsText } }, + ] }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([{ type: "text_delta", text: content }]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: argumentsText }, + { type: "tool_call_delta", arguments: argumentsText }, + ]); +}); + +test("buffered Chat responses keep a doubled input when another call already agrees with the blocks", async () => { + // The single-input call explains the repeated pair on its own, so the doubled call beside it is a + // competing reading rather than the unique one, and neither argument is rewritten. Exactly one + // call still agrees with the blocks, so the range matcher suppresses the pair: the markup is + // settled, the doubled input is not. + const script = "text('ok');"; + const block = `${script}`; + const content = block + block; + const doubledArguments = `{"input":"${script}${script}"}`; + const singleArguments = `{"input":"${script}"}`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { content, tool_calls: [ + { id: "call_doubled", function: { name: "exec", arguments: doubledArguments } }, + { id: "call_single", function: { name: "exec", arguments: singleArguments } }, + ] }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: doubledArguments }, + { type: "tool_call_delta", arguments: singleArguments }, + ]); +}); + +test("streamed Chat responses keep doubled input when two structured calls qualify", async () => { + const script = "text('ok');"; + const block = `${script}`; + const content = block + block; + const argumentsText = JSON.stringify({ input: script + script }); + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] } }); + const frames = [ + { choices: [{ delta: { content: content.slice(0, 40) } }] }, + { choices: [{ delta: { content: content.slice(40) } }] }, + { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_one", function: { name: "exec", arguments: argumentsText } }] } }] }, + { choices: [{ delta: { tool_calls: [{ index: 1, id: "call_two", function: { name: "exec", arguments: argumentsText } }] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event); + + expect(events.filter(event => event.type === "text_delta")).toEqual([{ type: "text_delta", text: content }]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: argumentsText }, + { type: "tool_call_delta", arguments: argumentsText }, + ]); +}); + +test("streamed Chat responses keep doubled input when another call already agrees with the blocks", async () => { + const script = "text('ok');"; + const block = `${script}`; + const content = block + block; + const doubledArguments = `{"input":"${script}${script}"}`; + const singleArguments = `{"input":"${script}"}`; + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] } }); + const frames = [ + { choices: [{ delta: { content: content.slice(0, 40) } }] }, + { choices: [{ delta: { content: content.slice(40) } }] }, + { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_doubled", function: { name: "exec", arguments: doubledArguments } }] } }] }, + { choices: [{ delta: { tool_calls: [{ index: 1, id: "call_single", function: { name: "exec", arguments: singleArguments } }] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event); + + expect(events.filter(event => event.type === "text_delta")).toEqual([]); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: doubledArguments }, + { type: "tool_call_delta", arguments: singleArguments }, + ]); +}); + test("buffered Chat responses preserve serialized markup for a different function", async () => { const content = "literal example"; const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ @@ -106,6 +390,44 @@ describe("MiMo echo variants (#5724)", () => { } }); + test("a repeated block pair inside a Markdown fence keeps its doubled input and stays visible", async () => { + // The fence opener lands in the first streamed chunk, so the buffer carries an open fence + // when the identical pair arrives. Fenced markup is user-visible, so neither the arguments + // nor the text may change: the reduction and the suppression scan must read the same context. + const block = `${script}`; + const content = `Look at this example here\n\`\`\`\n${block}${block}`; + for (const events of [await streamed(content, script + script), await buffered(content, script + script)]) { + expect(visible(events)).toBe(content); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: JSON.stringify({ input: script + script }) }, + ]); + } + }); + + test("a fenced echo does not repair the malformed argument prefix beside it", async () => { + // The prefix repair reads the same held text through its own `callsIn` scan, so the fenced + // pair must not prove an echo there either: the arguments the gateway sent stay untouched. + const block = `${script}`; + const argumentsText = script + JSON.stringify({ input: script }); + const content = `Look at this example here\n\`\`\`\n${block}${block}`; + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + adapter.buildRequest({ modelId: "mimo-v2.6-pro", stream: true, options: {}, context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] } }); + const frames = [ + { choices: [{ delta: { content: content.slice(0, 30) } }] }, + { choices: [{ delta: { content: content.slice(30) } }] }, + { choices: [{ delta: { tool_calls: [{ index: 0, id: "call_exec", function: { name: "exec", arguments: argumentsText } }] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(body))) if (event.type !== "heartbeat") events.push(event); + + expect(visible(events)).toBe(content); + expect(events.filter(event => event.type === "tool_call_delta")).toEqual([ + { type: "tool_call_delta", arguments: argumentsText }, + ]); + }); + test("a closed block whose body carries literal tool-call tags is still matched whole", async () => { for (const input of [ "text('');", diff --git a/tests/cli/cli-effort-slug.test.ts b/tests/cli/cli-effort-slug.test.ts new file mode 100644 index 00000000000..38761d26437 --- /dev/null +++ b/tests/cli/cli-effort-slug.test.ts @@ -0,0 +1,141 @@ +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 { handleEffortCommand } from "../../src/cli/effort"; +import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "../../src/providers/command-code-efforts"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { encodeRoutedModelId } from "../../src/providers/slug-codec"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * Codex-facing slug and native id for the Command Code routes named in #5096. The slug form is + * the one the Codex catalog shows (inner "/" encoded by `encodeRoutedModelId`), and the native + * form is the key the provider ladder is registered under. + */ +const ROUTED_MODELS = [ + { native: "deepseek/deepseek-v4.1-flash", codexSlug: "deepseek-deepseek-v4.1-flash" }, + { native: "z-ai/glm-5.3-flashx", codexSlug: "z-ai-glm-5.3-flashx" }, + { native: "google/gemini-3.8-flash", codexSlug: "google-gemini-3.8-flash" }, +] as const; + +const COMMAND_CODE = "command-code"; + +/** Ladder read from the SSOT rather than restated, so no tier is invented here. */ +function ladderFor(nativeId: string): string[] { + const ladder = COMMAND_CODE_MODEL_REASONING_EFFORTS[nativeId]; + if (!ladder) throw new Error(`Command Code reasoning ladder missing for ${nativeId}`); + return ladder; +} + +let tempHome: string | null = null; +const savedHome = process.env.OPENCODEX_HOME; +let logOrig = console.log; +let errorOrig = console.error; + +beforeEach(() => { + logOrig = console.log; + errorOrig = console.error; + tempHome = mkdtempSync(join(tmpdir(), "ocx-effort-slug-test-")); + process.env.OPENCODEX_HOME = tempHome; + // Transport fields mirror the registry entry, and the ladder is the registry's own table: + // this is the row a signed-in Command Code provider resolves to. + const initialConfig: OcxConfig = { + port: 10100, + defaultProvider: COMMAND_CODE, + providers: { + [COMMAND_CODE]: { + adapter: "command-code", + baseUrl: "https://api.commandcode.ai", + authMode: "oauth", + modelReasoningEfforts: COMMAND_CODE_MODEL_REASONING_EFFORTS, + }, + }, + } as unknown as OcxConfig; + writeFileSync(join(tempHome, "config.json"), JSON.stringify(initialConfig, null, 2), "utf8"); +}); + +afterEach(() => { + console.log = logOrig; + console.error = errorOrig; + if (savedHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = savedHome; + if (tempHome) { + removeTreeWithRetry(tempHome); + tempHome = null; + } +}); + +async function inspect(target: string, json: boolean): Promise<{ code: number; out: string; errors: string[] }> { + const logs: string[] = []; + const errors: string[] = []; + console.log = (...parts: unknown[]) => { logs.push(parts.map(String).join(" ")); }; + console.error = (...parts: unknown[]) => { errors.push(parts.map(String).join(" ")); }; + const argv = json ? ["model", target, "--json"] : ["model", target]; + const code = await handleEffortCommand(argv, {}); + return { code, out: logs.join("\n"), errors }; +} + +async function inspectJson(target: string): Promise> { + const { code, out, errors } = await inspect(target, true); + expect(errors).toEqual([]); + expect(code).toBe(0); + return JSON.parse(out) as Record; +} + +describe("ocx effort model routed-slug resolution", () => { + test("the #5096 slug literals are what the codec encodes these native ids to", () => { + for (const { native, codexSlug } of ROUTED_MODELS) { + expect(encodeRoutedModelId(native)).toBe(codexSlug); + } + }); + + test("the configured Command Code row matches the registry transport and ladder", () => { + const entry = getProviderRegistryEntry(COMMAND_CODE); + expect(entry?.adapter).toBe("command-code"); + expect(entry?.baseUrl).toBe("https://api.commandcode.ai"); + expect(entry?.modelReasoningEfforts).toEqual(COMMAND_CODE_MODEL_REASONING_EFFORTS); + }); + + for (const { native, codexSlug } of ROUTED_MODELS) { + test(`${COMMAND_CODE}/${codexSlug} resolves to ${native} and reports its registry ladder`, async () => { + const result = await inspectJson(`${COMMAND_CODE}/${codexSlug}`); + expect(result.model).toBe(native); + expect(result.requestedModel).toBe(codexSlug); + expect(result.supportedEfforts).toEqual(ladderFor(native)); + }); + + test(`${COMMAND_CODE}/${native} reports the same ladder without a resolution note`, async () => { + const result = await inspectJson(`${COMMAND_CODE}/${native}`); + expect(result.model).toBe(native); + expect(result.requestedModel).toBeUndefined(); + expect(result.supportedEfforts).toEqual(ladderFor(native)); + }); + } + + test("text output names the resolved id and the slug it came from", async () => { + const { native, codexSlug } = ROUTED_MODELS[0]; + const { code, out } = await inspect(`${COMMAND_CODE}/${codexSlug}`, false); + expect(code).toBe(0); + expect(out).toContain(`Reasoning effort configuration for ${COMMAND_CODE}/${native}:`); + expect(out).toContain(` Resolved from: ${codexSlug}`); + expect(out).toContain(`Supported ladder: ${ladderFor(native).join(", ")}`); + }); + + test("a native id in text output carries no resolution note", async () => { + const { native } = ROUTED_MODELS[0]; + const { code, out } = await inspect(`${COMMAND_CODE}/${native}`, false); + expect(code).toBe(0); + expect(out).toContain(`Reasoning effort configuration for ${COMMAND_CODE}/${native}:`); + expect(out).not.toContain("Resolved from:"); + }); + + test("an unresolvable id keeps today's output: no ladder, no requestedModel", async () => { + const result = await inspectJson(`${COMMAND_CODE}/not-a-real-model`); + expect(result.model).toBe("not-a-real-model"); + expect(result.requestedModel).toBeUndefined(); + expect(result.supportedEfforts).toBeNull(); + }); +}); + diff --git a/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts b/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts index 6df91ab4e50..b5698848aad 100644 --- a/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts +++ b/tests/codex-integration/catalog-vision-sidecar-modalities.test.ts @@ -107,7 +107,7 @@ describe("vision-sidecar catalog modalities", () => { expect(applyProviderConfigHints("mimo", canonical, { id: "mimo-v2.5", provider: "mimo", - }).inputModalities).toBeUndefined(); + }).inputModalities).toEqual(["text", "image"]); // native, from the registry's modelInputModalities const customDestination: OcxProviderConfig = { adapter: "openai-chat", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b4b1c78cf70..ce99be13d82 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1603,5 +1603,10 @@ "zhipu-bigmodel-responses-quota.test.ts": "providers", "zz-ci-api-usage-isolation.test.ts": "ci-workflows", "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", - "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows" + "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", + "deepseek-quota-currency.test.ts": "providers", + "mimo-token-plan-capacity.test.ts": "providers", + "command-code-tool-text-prose-split.test.ts": "providers", + "cli-effort-slug.test.ts": "cli", + "grok-47-build-fast-metadata.test.ts": "providers/xai" } diff --git a/tests/providers/command-code-tool-text-prose-split.test.ts b/tests/providers/command-code-tool-text-prose-split.test.ts new file mode 100644 index 00000000000..7ca96b075a5 --- /dev/null +++ b/tests/providers/command-code-tool-text-prose-split.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from "bun:test"; +import { createCommandCodeAdapter } from "../../src/adapters/command-code"; +import { CommandCodeToolTextFilter } from "../../src/adapters/command-code-tool-text"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const provider: OcxProviderConfig = { + adapter: "command-code", + baseUrl: "https://api.commandcode.ai", + authMode: "oauth", + apiKey: "secret-command-key", +}; + +const JS = "const r = await tools.exec_command({cmd:\"sed -n '1,40p' src/a.ts\"});\ntext(r.output);"; +const MARKUP = "" + JS + ""; + +// Captured 2026-09-23 from xiaomi/mimo-v2.6-pro through the live proxy: the echoed envelope lost the +// parameter key, its ">" and its closing tag, so the body runs to and cannot parse. +const MALFORMED = [ + " text(r.output)); ", +].join("\n"); + +const EXEC_TOOL = { name: "exec", description: "Run JavaScript", freeform: true, + parameters: { type: "object", properties: { input: { type: "string", description: "Raw freeform input for this tool." } }, required: ["input"] } }; + +const READ_TOOL = { name: "read", description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } }; + +function ndjson(events: unknown[]): Response { + return new Response(events.map(event => JSON.stringify(event)).join("\n")); +} + +function parsed(): OcxParsedRequest { + return { + modelId: "xiaomi/mimo-v2.6-flash", + stream: true, + context: { systemPrompt: ["system"], messages: [{ role: "user", content: "go", timestamp: 1 }], tools: [EXEC_TOOL] }, + options: { maxOutputTokens: 100 }, + }; +} + +/** Run events through one adapter instance the way the server does: buildRequest, fetchResponse, parseStream. */ +async function adapterEvents(events: unknown[]): Promise { + const adapter = createCommandCodeAdapter({ ...provider, fetch: (async () => ndjson(events)) as typeof fetch } as OcxProviderConfig); + const request = await adapter.buildRequest(parsed()); + const response = await adapter.fetchResponse!(request); + const out: AdapterEvent[] = []; + for await (const event of adapter.parseStream(response, createTestTranslatorBudget())) out.push(event); + return out; +} + +const texts = (events: AdapterEvent[]) => events.filter(event => event.type === "text_delta").map(event => (event as { text: string }).text).join(""); +const calls = (events: AdapterEvent[]) => { + const out: Array<{ id: string; name: string; args: string }> = []; + for (const event of events) { + if (event.type === "tool_call_start") out.push({ id: event.id, name: event.name, args: "" }); + if (event.type === "tool_call_delta") out[out.length - 1]!.args += event.arguments; + } + return out; +}; +const done = (events: AdapterEvent[]) => events.find(event => event.type === "done") as { stopReason?: string } | undefined; + +const textBlock = (text: string) => [ + { type: "text-start", id: "t" }, + { type: "text-delta", id: "t", text }, + { type: "text-end", id: "t" }, +]; + +/** A filter that declares only the freeform exec tool, plus its budget for leak assertions. */ +function execFilter() { + const budget = createTestTranslatorBudget(); + return { budget, filter: new CommandCodeToolTextFilter(budget, new Map([["exec", { freeform: true, schema: EXEC_TOOL.parameters }]])) }; +} + +/** A filter that also declares a second tool, for native calls that are not the envelope's. */ +function twoToolFilter() { + const budget = createTestTranslatorBudget(); + return { budget, filter: new CommandCodeToolTextFilter(budget, new Map([ + ["exec", { freeform: true, schema: EXEC_TOOL.parameters }], + ["read", { freeform: false, schema: READ_TOOL.parameters }], + ])) }; +} + +describe("Command Code markup echoed after prose in one text block", () => { + const PROSE = "Running it now.\n"; + const proseMarkup = PROSE + MARKUP; + + test("drops the markup and keeps the prose when the native call carries the same input", async () => { + const events = await adapterEvents([ + { type: "tool-input-start", id: "call_c1", toolName: "exec" }, + ...textBlock(proseMarkup), + { type: "tool-call", toolCallId: "call_c1", toolName: "exec", input: JS, dynamic: true, invalid: true }, + { type: "finish", rawFinishReason: "tool_calls" }, + ]); + expect(texts(events)).toBe(PROSE); + expect(calls(events)).toEqual([{ id: "call_c1", name: "exec", args: JS }]); + expect(done(events)?.stopReason).toBe("tool_calls"); + }); + + test("restores the trailing markup as a call on a clean finish", async () => { + const events = await adapterEvents([...textBlock(proseMarkup), { type: "finish", rawFinishReason: "stop" }]); + expect(texts(events)).toBe(PROSE); + const [call] = calls(events); + expect(call).toMatchObject({ name: "exec", args: JSON.stringify({ input: JS }) }); + expect(call!.id).toMatch(/^call_ocx_[0-9a-f]{32}$/); + expect(done(events)?.stopReason).toBe("tool_calls"); + }); + + test("holds a marker that opens a fresh block after streamed prose", () => { + const { budget, filter } = execFilter(); + expect(filter.textDelta("t", "Running it now.")).toEqual([{ type: "text_delta", text: "Running it now." }]); + // The streamed block used to pass the marker straight through instead of holding it. + expect(filter.textDelta("t", MARKUP)).toEqual([]); + const finished = filter.finish(); + expect(finished.salvaged).toBe(true); + expect(finished.events.map(event => event.type)).toEqual(["tool_call_start", "tool_call_delta", "tool_call_end"]); + expect(texts(finished.events)).toBe(""); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("keeps markup behind a newline on the ordinary hold path", async () => { + const events = await adapterEvents([...textBlock("\n" + MARKUP), { type: "finish", rawFinishReason: "stop" }]); + expect(texts(events)).toBe(""); + expect(calls(events)).toMatchObject([{ name: "exec", args: JSON.stringify({ input: JS }) }]); + expect(done(events)?.stopReason).toBe("tool_calls"); + }); + + test("keeps a held block through an interleaved reasoning event", () => { + const { budget, filter } = execFilter(); + const thinking: AdapterEvent = { type: "thinking_delta", thinking: "about to call exec" }; + expect(filter.textStart("t")).toEqual([]); + expect(filter.textDelta("t", MARKUP)).toEqual([]); + // Reasoning used to break the held block open and put the echoed call on screen as text. + expect(filter.enqueueEvent(thinking, "about to call exec")).toEqual([]); + expect(filter.textEnd("t")).toEqual([]); + const finished = filter.finish(); + expect(finished.salvaged).toBe(true); + expect(finished.events.map(event => event.type)).toEqual(["tool_call_start", "tool_call_delta", "tool_call_end", "thinking_delta"]); + expect(texts(finished.events)).toBe(""); + expect(budget.snapshot().currentBytes).toBe(0); + }); +}); + +describe("Command Code malformed envelope echo", () => { + test("drops a malformed envelope when the native call arrives for it", async () => { + const events = await adapterEvents([ + { type: "tool-input-start", id: "call_c1", toolName: "exec" }, + ...textBlock(MALFORMED), + { type: "tool-call", toolCallId: "call_c1", toolName: "exec", input: JS, dynamic: true, invalid: true }, + { type: "finish", rawFinishReason: "tool_calls" }, + ]); + expect(texts(events)).toBe(""); + expect(calls(events)).toEqual([{ id: "call_c1", name: "exec", args: JS }]); + expect(done(events)?.stopReason).toBe("tool_calls"); + }); + + test("releases a malformed envelope when the native call is for another tool", () => { + const { budget, filter } = twoToolFilter(); + const args = JSON.stringify({ path: "src/a.ts" }); + expect(filter.textDelta("t", MALFORMED)).toEqual([]); + expect(filter.textEnd("t")).toEqual([]); + // A read call proves nothing about an exec envelope, so it must not consume the echo the way a + // matching exec call does: the text is released rather than dropped, still ahead of the call. + const events = [...filter.nativeCall("call_r1", "read", args), ...filter.releaseAll()]; + expect(texts(events)).toBe(MALFORMED); + expect(calls(events)).toEqual([{ id: "call_r1", name: "read", args }]); + expect(events.findIndex(event => event.type === "text_delta")) + .toBeLessThan(events.findIndex(event => event.type === "tool_call_start")); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("drops a malformed envelope when the native call names it", () => { + const { budget, filter } = twoToolFilter(); + expect(filter.textDelta("t", MALFORMED)).toEqual([]); + expect(filter.textEnd("t")).toEqual([]); + const events = [...filter.nativeCall("call_c1", "exec", JS), ...filter.releaseAll()]; + expect(texts(events)).toBe(""); + expect(calls(events)).toEqual([{ id: "call_c1", name: "exec", args: JS }]); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("drops a malformed envelope on a clean finish instead of restoring it", async () => { + const events = await adapterEvents([...textBlock(MALFORMED), { type: "finish", rawFinishReason: "stop" }]); + expect(texts(events)).toBe(""); + expect(calls(events)).toEqual([]); + expect(done(events)?.stopReason).toBe("stop"); + }); + + test("releases a marker pair with no function name as text", async () => { + const junk = "junk"; + const events = await adapterEvents([...textBlock(junk), { type: "finish", rawFinishReason: "stop" }]); + expect(texts(events)).toBe(junk); + expect(calls(events)).toEqual([]); + }); + + test("releases a never-closing envelope as text", async () => { + const partial = "abc"; + const events = await adapterEvents([...textBlock(partial), { type: "finish", rawFinishReason: "stop" }]); + expect(texts(events)).toBe(partial); + expect(calls(events)).toEqual([]); + }); +}); diff --git a/tests/providers/deepseek-quota-currency.test.ts b/tests/providers/deepseek-quota-currency.test.ts new file mode 100644 index 00000000000..0dd8001f4ef --- /dev/null +++ b/tests/providers/deepseek-quota-currency.test.ts @@ -0,0 +1,85 @@ +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 { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../src/providers/quota"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const originalFetch = globalThis.fetch; +const previousOpencodexHome = process.env.OPENCODEX_HOME; +let opencodexHome: string; + +function deepSeekConfig(): OcxConfig { + return { + defaultProvider: "deepseek", + providers: { + deepseek: { + adapter: "openai-chat", + authMode: "key", + baseUrl: "https://api.deepseek.com", + apiKey: "deepseek-secret", + }, + }, + } as OcxConfig; +} + +/** Answer the DeepSeek balance probe with exactly these `balance_infos` rows. */ +async function balanceLabel(rows: unknown[]): Promise { + globalThis.fetch = (async () => new Response(JSON.stringify({ + is_available: true, + balance_infos: rows, + }), { status: 200 })) as typeof fetch; + + const result = await fetchProviderQuotaReports(deepSeekConfig(), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("deepseek:balance"); + return result.reports[0]?.quota.customWindows?.[0]?.label; +} + +beforeEach(() => { + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-deepseek-quota-")); + process.env.OPENCODEX_HOME = opencodexHome; + clearProviderQuotaCache(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearProviderQuotaCache(); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + removeTreeWithRetry(opencodexHome); +}); + +describe("DeepSeek quota balance currency", () => { + test("a CNY-only row renders the yuan sign", async () => { + expect(await balanceLabel([{ currency: "CNY", total_balance: "76.88" }])) + .toBe("API balance (¥76.88)"); + }); + + test("a CNY row with a granted balance renders both amounts in yuan", async () => { + expect(await balanceLabel([{ currency: "CNY", total_balance: "76.88", granted_balance: "12.5" }])) + .toBe("API balance (¥76.88 total, ¥12.50 granted)"); + }); + + test("a USD row keeps the dollar sign", async () => { + expect(await balanceLabel([{ currency: "USD", total_balance: "6" }])) + .toBe("API balance ($6.00)"); + }); + + test("any other currency prefixes the upper-cased code", async () => { + expect(await balanceLabel([{ currency: "eur", total_balance: "5" }])) + .toBe("API balance (EUR 5.00)"); + }); + + test("a row without a currency keeps the legacy dollar sign", async () => { + expect(await balanceLabel([{ total_balance: "5" }])) + .toBe("API balance ($5.00)"); + }); + + test("a USD row is still preferred over a CNY row", async () => { + expect(await balanceLabel([{ currency: "CNY", total_balance: "10" }, { currency: "USD", total_balance: "7" }])) + .toBe("API balance ($7.00)"); + }); +}); diff --git a/tests/providers/mimo-token-plan-capacity.test.ts b/tests/providers/mimo-token-plan-capacity.test.ts new file mode 100644 index 00000000000..cecd298396c --- /dev/null +++ b/tests/providers/mimo-token-plan-capacity.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { applyProviderConfigHints } from "../../src/codex/catalog"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { resolveModelPolicy } from "../../src/providers/resolved-model-policy"; +import { routeModel } from "../../src/router"; +import type { OcxConfig } from "../../src/types"; + +// Xiaomi's model pages (mimo.mi.com/models/en-US/, fetched 2026-09-24): 1M context, +// 128K max output, and text/image/video/audio input for V2.6 Pro/Flash and V2.5 (V2.5 Pro +// documents text only). The catalog vocabulary has no video or audio entry. +const CONTEXT_WINDOW = 1_048_576; +const MAX_OUTPUT = 131_072; +const ROSTER = ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.5-pro", "mimo-v2.5"]; + +const entry = () => getProviderRegistryEntry("mimo")!; + +function policy(modelId: string) { + return resolveModelPolicy({ + providerName: "mimo", + modelId, + provider: { adapter: "openai-chat", baseUrl: entry().baseUrl, authMode: "key" }, + registryEntry: entry(), + transportMatchedRegistry: true, + effectiveAuth: { authMode: "key" }, + }); +} + +describe("MiMo token-plan capacity facts", () => { + test("the registry entry carries the vendor window, output and modality maps", () => { + const mimo = entry(); + expect(mimo.modelContextWindows).toEqual(Object.fromEntries(ROSTER.map(id => [id, CONTEXT_WINDOW]))); + expect(mimo.modelMaxOutputTokens).toEqual(Object.fromEntries(ROSTER.map(id => [id, MAX_OUTPUT]))); + expect(mimo.modelInputModalities).toEqual({ + "mimo-v2.6-pro": ["text", "image"], + "mimo-v2.6-flash": ["text", "image"], + "mimo-v2.5": ["text", "image"], + "mimo-v2.5-pro": ["text"], + }); + // Every map key is on the roster, and no plan-specific entitlement is claimed. + for (const id of ROSTER) expect(mimo.models, id).toContain(id); + expect(mimo.jawcodeBundle).toBeUndefined(); + }); + + test("the seed carries the facts, so a saved token-plan config inherits them", () => { + const seed = providerConfigSeed(entry()); + expect(seed.modelContextWindows).toEqual(Object.fromEntries(ROSTER.map(id => [id, CONTEXT_WINDOW]))); + expect(seed.modelMaxOutputTokens).toEqual(Object.fromEntries(ROSTER.map(id => [id, MAX_OUTPUT]))); + expect(seed.modelInputModalities).toEqual(entry().modelInputModalities!); + expect(seed.noVisionModels).toEqual(["mimo-v2.5-pro"]); + }); + + test("a routed V2.6 Pro row reports the vendor window, output and image input", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "mimo", + providers: { mimo: { ...providerConfigSeed(entry()), apiKey: "k", liveModels: true } }, + }; + const route = routeModel(config, "mimo/mimo-v2.6-pro"); + const row = applyProviderConfigHints("mimo", route.provider, { provider: "mimo", id: route.modelId }); + expect(row.contextWindow).toBe(CONTEXT_WINDOW); + expect(row.maxOutputTokens).toBe(MAX_OUTPUT); + expect(row.inputModalities).toEqual(["text", "image"]); + // V2.6 Flash is the sibling the same pages cover. + expect(applyProviderConfigHints("mimo", route.provider, { provider: "mimo", id: "mimo-v2.6-flash" }).inputModalities) + .toEqual(["text", "image"]); + }); + + test("the token-plan policy reports V2.5 Pro as text-only and V2.5 as image-capable", () => { + for (const id of ROSTER) { + const resolved = policy(id); + expect(resolved.model.contextWindow, id).toBe(CONTEXT_WINDOW); + expect(resolved.model.maxOutputTokens, id).toBe(MAX_OUTPUT); + // The maps are model facts, not plan prices: no per-model reasoning or tier claim is added. + expect(resolved.model.reasoningEfforts, id).toEqual(["low", "medium", "high"]); + } + const v25 = policy("mimo-v2.5"); + expect(v25.model.inputModalities).toEqual(["text", "image"]); + // The claim is read from the registry, not from a vendor-free default. + expect(v25.provenance.model.inputModalities).toBe("registry"); + // V2.5 Pro documents text-only input upstream, so the modality map must not widen it. + expect(policy("mimo-v2.5-pro").model.inputModalities).toEqual(["text"]); + }); +}); diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 0c9c8b210c4..228be2e729b 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -1130,7 +1130,7 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports).toHaveLength(1); expect(result.reports[0]?.source).toBe("deepseek:balance"); expect(result.reports[0]?.quota.customWindows).toEqual([{ - label: "API balance ($6.00 total, $4.00 granted)", + label: "API balance (¥6.00 total, ¥4.00 granted)", percent: 0, }]); expect(seen).toHaveLength(1); diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 2bb927063a8..7ad56d082fd 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -1316,7 +1316,7 @@ describe("provider registry parity", () => { expect(OAUTH_PROVIDERS.xai.providerConfig.modelReasoningEfforts?.["grok-4.7"]).toEqual(["low", "medium", "high", "xhigh"]); expect(OAUTH_PROVIDERS.xai.providerConfig.modelReasoningEfforts?.["grok-4.6"]).toEqual(["low", "medium", "high", "xhigh"]); expect(OAUTH_PROVIDERS.xai.providerConfig.modelReasoningEfforts?.["grok-4.5"]).toEqual(["low", "medium", "high"]); - expect(OAUTH_PROVIDERS.xai.providerConfig.modelDefaultReasoningEfforts).toEqual({ "grok-4.7": "high", "grok-4.6": "high" }); + expect(OAUTH_PROVIDERS.xai.providerConfig.modelDefaultReasoningEfforts).toEqual({ "grok-4.7": "high", "grok-4.7-build-fast": "high", "grok-4.6": "high" }); expect(OAUTH_PROVIDERS.xai.providerConfig.modelInputModalities?.["grok-4.7"]).toEqual(["text", "image"]); expect(OAUTH_PROVIDERS.xai.providerConfig.modelReasoningEffortMap).toBeUndefined(); expect(OAUTH_PROVIDERS.xai.providerConfig.noVisionModels).toContain("grok-build-0.1"); diff --git a/tests/providers/xai/grok-47-build-fast-metadata.test.ts b/tests/providers/xai/grok-47-build-fast-metadata.test.ts new file mode 100644 index 00000000000..f24f3749024 --- /dev/null +++ b/tests/providers/xai/grok-47-build-fast-metadata.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { getProviderRegistryEntry } from "../../../src/providers/registry"; +import { XAI_MODELS } from "../../../src/providers/registry/model-seeds"; +import type { ProviderRegistryEntry } from "../../../src/providers/registry/types"; + +// 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. +const BASE = "grok-4.7"; +const BUILD_FAST = "grok-4.7-build-fast"; + +function xai(): ProviderRegistryEntry { + const entry = getProviderRegistryEntry("xai"); + if (!entry) throw new Error("xai registry entry missing"); + return entry; +} + +// Each assertion reads the base value from the registry instead of restating it: a later +// grok-4.7 correction has to move the Fast row with it, and a restated literal would hide that. +const MAPS = [ + ["modelContextWindows", (entry: ProviderRegistryEntry) => entry.modelContextWindows], + ["modelReasoningEfforts", (entry: ProviderRegistryEntry) => entry.modelReasoningEfforts], + ["modelDefaultReasoningEfforts", (entry: ProviderRegistryEntry) => entry.modelDefaultReasoningEfforts], + ["modelInputModalities", (entry: ProviderRegistryEntry) => entry.modelInputModalities], +] as const; + +const LISTS = ["noStopModels", "noPenaltyModels", "preserveReasoningContentModels"] as const; + +describe("xai grok-4.7-build-fast metadata", () => { + for (const [field, read] of MAPS) { + test(`${field} carries the grok-4.7 value`, () => { + const map = read(xai()); + expect(map?.[BASE]).toBeDefined(); + expect(map?.[BUILD_FAST]).toEqual(map?.[BASE]); + }); + } + + for (const field of LISTS) { + test(`${field} seeds the id directly after grok-4.7`, () => { + const list = xai()[field] ?? []; + expect(list).toContain(BASE); + expect(list.indexOf(BUILD_FAST)).toBe(list.indexOf(BASE) + 1); + }); + } + + test("claims no lineup slot, wire pin or service tier", () => { + 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. + expect(entry.modelWireDefaults?.[BASE]).toBeDefined(); + expect(entry.modelWireDefaults?.[BUILD_FAST]).toBeUndefined(); + expect(entry.modelSupportsServiceTier?.[BASE]).toBe(true); + expect(entry.modelSupportsServiceTier?.[BUILD_FAST]).toBeUndefined(); + }); +}); diff --git a/tests/providers/xai/xai-no-stop.test.ts b/tests/providers/xai/xai-no-stop.test.ts index b4a29268aef..f3d96d61b03 100644 --- a/tests/providers/xai/xai-no-stop.test.ts +++ b/tests/providers/xai/xai-no-stop.test.ts @@ -8,6 +8,7 @@ import { withTestTranslatorBudget } from "../../helpers/translator-budget"; const XAI_NO_STOP_MODELS = [ "grok-4.7", + "grok-4.7-build-fast", "grok-4.6", "grok-4.5", "grok-4.3", diff --git a/tests/providers/xai/xai-transport.test.ts b/tests/providers/xai/xai-transport.test.ts index 4faf3866c1d..263a1c049df 100644 --- a/tests/providers/xai/xai-transport.test.ts +++ b/tests/providers/xai/xai-transport.test.ts @@ -635,6 +635,7 @@ describe("xAI reasoning_content cache preservation", () => { const entry = getProviderRegistryEntry("xai"); expect(entry?.preserveReasoningContentModels).toEqual([ "grok-4.7", + "grok-4.7-build-fast", "grok-4.6", "grok-4.5", "grok-4.3", @@ -826,6 +827,7 @@ describe("xAI reasoning_content cache preservation", () => { describe("xAI reasoning models reject penalty parameters", () => { const REASONING = [ "grok-4.7", + "grok-4.7-build-fast", "grok-4.6", "grok-4.5", "grok-4.3", diff --git a/tests/responses/responses-chat-tool-call-content.test.ts b/tests/responses/responses-chat-tool-call-content.test.ts index c9b876a184c..46804f2d481 100644 --- a/tests/responses/responses-chat-tool-call-content.test.ts +++ b/tests/responses/responses-chat-tool-call-content.test.ts @@ -10,12 +10,16 @@ afterEach(() => { releaseSpendHome = undefined; }); -test("/v1/responses suppresses OpenAI Chat tool-call markup duplicated by a structured call", async () => { +async function checkEchoedToolCall( + repeated: boolean, + trailingNewline = false, + newlineJoinedInput = false, +): Promise { const savedFetch = globalThis.fetch; const script = "const result = await tools.exec_command({cmd: \"pwd\"});\ntext(result.output);"; const leaked = `${script}\n`; const commentary = "I'll run it now.\n"; - const content = commentary + leaked; + const content = commentary + leaked + (repeated ? leaked : "") + (trailingNewline ? "\n" : ""); const split = commentary.length + 5; const frames = [ { choices: [{ delta: { content: content.slice(0, split) } }] }, @@ -26,7 +30,12 @@ test("/v1/responses suppresses OpenAI Chat tool-call markup duplicated by a stru tool_calls: [{ index: 0, id: "call_exec", - function: { name: "exec", arguments: script + JSON.stringify({ input: script }) }, + function: { + name: "exec", + arguments: repeated + ? JSON.stringify({ input: script + (newlineJoinedInput ? "\n" : "") + script }) + : script + JSON.stringify({ input: script }), + }, }], }, }], @@ -85,4 +94,9 @@ test("/v1/responses suppresses OpenAI Chat tool-call markup duplicated by a stru } finally { globalThis.fetch = savedFetch; } -}); +} + +test("/v1/responses suppresses one echoed block", () => checkEchoedToolCall(false)); +test("/v1/responses suppresses two echoed blocks with doubled input", () => checkEchoedToolCall(true)); +test("/v1/responses suppresses trailing newline and repairs newline-joined doubled input", () => + checkEchoedToolCall(true, true, true)); From dd7cb695a94c942c994e22d51893477751ea8803 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 24 Sep 2026 19:10:17 +0900 Subject: [PATCH 31/48] =?UTF-8?q?fix(desktop,gui):=20bundle=20L5=20?= =?UTF-8?q?=E2=80=94=20macOS=20reopen,=20hidden-window=20polling,=20Hermes?= =?UTF-8?q?=20affinity,=20web-search=20Off,=20quota=20bar=20(#5742)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): reopen the dashboard from the macOS app icon (cherry picked from commit 920c0527eef08acf4c6d07a170e0ea42d311b10c) Co-authored-by: Jian Gong * fix(desktop,gui): pause dashboard polling while the desktop window is hidden WebView2 does not flip document.visibilityState when the Tauri window hides to the tray, so the dashboard kept polling. The shell now publishes window.__OPENCODEX_HOST_VISIBLE__ and an opencodex:host-visibility event for the main window on show, hide and every page load, and visibility-poll, client-resource and Combos read one deduped predicate from gui/src/host-visibility.ts. macOS WKWebView was measured to flip visibilityState already. Refs #5493 * fix(hermes): generate and safely adopt dynamic session affinity (cherry picked from commit fa097ba515cf7864f16fcde8eeed8d216bb3b7b0) Closes #5710 Co-authored-by: Jian Gong * feat(sidecar): make Off selectable in the web-search card and switch Codex's web_search off The web-search sidecar could only be turned off by editing `config.json`. That is not enough when an MCP search server should be the only search path: Codex keeps declaring its native hosted `web_search` tool until its own root `web_search` mode says otherwise, and the tool a client advertises is the tool the model reaches for — so the model kept calling the native tool instead of the MCP one. Off is now the first row of the Dashboard's web-search model picker (i18n `dash.webSearchOff`, all ten locales) and `ocx agent sidecar web --enabled off` accepts the same switch. Both go through the existing `PUT /api/sidecar-settings`, which persists `webSearchSidecar.enabled` and — only when the switch actually MOVES — re-runs the Codex config injection, so the sidecar state and Codex's client-side key follow each other immediately instead of at the next `ocx sync`. The response carries the Codex-side write as `codexWebSearch` (`applied`/`reason`/`retryable`), the same report the Desktop switches use. Ownership follows the routing keys: while the sidecar is off the injection owns root `web_search` and writes `web_search = "disabled"` — the only mode that removes the native tool. A user-owned root line is replaced in that state because two root keys of the same name are invalid TOML; the journal snapshot returns it on `ocx restore`. Switching the sidecar back on removes only the marker-owned pair, so a re-enabled sidecar cannot be left with nothing to intercept. (cherry picked from commit d2ec419a02e7904aa59e7ada793dd3694914c79c) * review: report the Codex-side write in the Dashboard and sharpen the docs CodeRabbit review on #5709: - The web-search card now warns when the switch was stored but Codex's own `web_search` key was not rewritten, and clears that warning once a sync applies the stored settings (`sidecarCodexWritePending`, i18n `dash.webSearchCodexSync` in all ten locales). Verified against a sandboxed Dashboard with the Codex write made to fail: the card showed the warning for both directions of the switch. - `reference/cli/agents.md` and the sidecars guide no longer imply the CLI always prints the `Codex config:` line: only a save that moves the switch triggers a write, and the ordinary `not_requested` answer prints nothing extra. (cherry picked from commit 07410d75230a2a33fb8fc78ad59ef5564c9ca922) * fix(codex): journal the web-search switch's ownership both ways Review of the first pass found two ways the root `web_search` key could end up in the state this feature exists to avoid. Ownership by value (#1798 rule). A Codex app reserialize keeps values and drops comments, so `web_search = "disabled"` could survive with no marker above it. Switching the sidecar back on then left the line in place: the sidecar is on, and the client still advertises no native tool for it to intercept. The journal now records the value the injection wrote (`injectedRootWebSearch`) and the strip consumes a marker-less line whose value matches it exactly, so a user's own mode is still never mistaken for ours. The operator's mode is no longer lost. Off has to remove a user-owned root line, because two root keys of the same name are invalid TOML. The journal now carries that exact line (`replacedRootWebSearch`) and the pass that switches the sidecar back on puts it in our pair's place — including for a line the journal snapshot predates, which `ocx restore` alone cannot cover. A second injection while the switch is still off keeps the recorded line instead of clearing it. `ensureRootWebSearchDisabled` reports what it did (the line it removed, the value it wrote) rather than returning a bare string; the plan passes both to `markJournalInjectedState`. The purge path in `remove.ts` is the enabled direction of the same transform, so it drops our residue by value and returns the operator's line as well. Tests: the pure cases plus an end-to-end spawn test that runs off, simulates the comment-dropping rewrite, and runs on again against the journal. (cherry picked from commit 128d55b8e025e4a47702f7dd73b28a8fffa90440) * fix(gui): keep an outstanding Codex-write warning across other saves A save that did not move the web-search switch answers `not_requested` about a Codex file it never touched. Saving anything else in the meantime (a Vision setting, for instance) therefore replaced the stored report and cleared the warning while the native tool was still being advertised. The report now survives that answer and is settled only by a write that ran or by a successful model sync. (cherry picked from commit 26e315bb1af8a089241815f7c14adb74933fe60e) * fix(codex,gui): read quoted web_search keys and keep a pending report on a failed save Third review round on the web-search switch. - `isRootWebSearchLine` accepts the quoted spellings TOML reads as the same key (`"web_search"` / `'web_search'`), which is what `tomlStringPattern` already matches for the value evidence. Without it a config written as `"web_search" = "live"` got a second root key from us, and two root keys of the same name stop Codex from loading the file at all — the outcome the ownership rule exists to prevent. - `saveSidecar`'s catch no longer clears `sidecarCodexApply`. A request that failed before any answer arrived says nothing about the Codex file, so an outstanding report stays until a write that ran or a successful sync settles it; clearing it there was the same disappearing-warning bug through another path. - The ownership test changes the operator's mode in place instead of appending a second root key, so the unchanged-content assertion exercises valid TOML. (cherry picked from commit 86b525ddb88d1eaf6a5e4a16fff83ad9aca2d22b) Co-authored-by: Robin Bially <7304732+RobinBially@users.noreply.github.com> * docs(sidecar): translate the web-search Off switch into the locale pages The carried English docs for the web-search sidecar Off row, the --enabled off CLI flag and the enabled? config field now have matching text in fr, ja, ko, ru, tr, zh-cn and zh-tw. * feat(gui): add header provider quota summary bar (cherry picked from commit c17af7a85c1ca4a2a6c1c31cde15ba45dc674262) * docs: describe dashboard quota summary bar (cherry picked from commit ebda3609892703c60902587cc1f1aefe8442c1d6) Co-authored-by: Caesar7812 <279176182+Caesar7812@users.noreply.github.com> * fix(gui): keep the quota summary bar off Startup and inside an error boundary Review follow-ups for the carried header quota bar: hide it on the Startup page, wrap it in the page ErrorBoundary, use the shared z-index tokens, correct the provider-quotas ownership comments now that the bar keeps its own passive 60s read, align the Korean terms, and add the section to the fr, ja, ru, tr, zh-cn and zh-tw dashboard guides. Co-authored-by: Caesar7812 <279176182+Caesar7812@users.noreply.github.com> * perf(gui): look up quota headline windows by id instead of find() in a loop React Doctor js-index-maps warning on the carried quota summary derivation. * fix(gui,docs): address review on the quota summary bar Let the bar's mobile Combos layout fill the remaining grid row instead of a second viewport, wrap long provider window labels in the popover, announce a failed or recovered read through a polite live region, and describe the headline as the preferred window (weekly first) shown on every page except Startup in all eight docs locales. * fix(gui): keep quota chip percent on the same side of its color threshold, and let a second click close a pinned chip --------- Co-authored-by: Jian Gong Co-authored-by: Robin Bially <7304732+RobinBially@users.noreply.github.com> Co-authored-by: Codex Co-authored-by: Caesar7812 <279176182+Caesar7812@users.noreply.github.com> --- desktop/src-tauri/src/lib.rs | 18 ++ desktop/src-tauri/src/window.rs | 23 ++ .../src/content/docs/fr/guides/desktop-app.md | 2 + .../content/docs/fr/guides/integrations.md | 2 + .../src/content/docs/fr/guides/sidecars.md | 10 + .../content/docs/fr/guides/web-dashboard.md | 19 ++ .../content/docs/fr/reference/cli/agents.md | 10 + .../docs/fr/reference/configuration/server.md | 2 +- .../src/content/docs/guides/desktop-app.md | 2 + .../src/content/docs/guides/integrations.md | 22 ++ docs-site/src/content/docs/guides/sidecars.md | 23 ++ .../src/content/docs/guides/web-dashboard.md | 16 ++ .../src/content/docs/ja/guides/desktop-app.md | 2 + .../content/docs/ja/guides/integrations.md | 2 + .../src/content/docs/ja/guides/sidecars.md | 9 + .../content/docs/ja/guides/web-dashboard.md | 10 + .../content/docs/ja/reference/cli/agents.md | 9 + .../docs/ja/reference/configuration/server.md | 2 +- .../src/content/docs/ko/guides/desktop-app.md | 2 + .../content/docs/ko/guides/integrations.md | 2 + .../src/content/docs/ko/guides/sidecars.md | 9 + .../content/docs/ko/guides/web-dashboard.md | 10 + .../content/docs/ko/reference/cli/agents.md | 9 + .../docs/ko/reference/configuration/server.md | 2 +- .../src/content/docs/reference/cli/agents.md | 10 + .../docs/reference/configuration/server.md | 2 +- .../src/content/docs/ru/guides/desktop-app.md | 2 + .../content/docs/ru/guides/integrations.md | 2 + .../src/content/docs/ru/guides/sidecars.md | 10 + .../content/docs/ru/guides/web-dashboard.md | 10 + .../content/docs/ru/reference/cli/agents.md | 9 + .../docs/ru/reference/configuration/server.md | 2 +- .../src/content/docs/tr/guides/desktop-app.md | 2 + .../content/docs/tr/guides/integrations.md | 2 + .../src/content/docs/tr/guides/sidecars.md | 9 + .../content/docs/tr/guides/web-dashboard.md | 19 ++ .../content/docs/tr/reference/cli/agents.md | 9 + .../docs/tr/reference/configuration/server.md | 2 +- .../content/docs/zh-cn/guides/desktop-app.md | 2 + .../content/docs/zh-cn/guides/integrations.md | 2 + .../src/content/docs/zh-cn/guides/sidecars.md | 8 + .../docs/zh-cn/guides/web-dashboard.md | 10 + .../docs/zh-cn/reference/cli/agents.md | 7 + .../zh-cn/reference/configuration/server.md | 2 +- .../content/docs/zh-tw/guides/desktop-app.md | 2 + .../content/docs/zh-tw/guides/integrations.md | 2 + .../src/content/docs/zh-tw/guides/sidecars.md | 8 + .../docs/zh-tw/guides/web-dashboard.md | 15 ++ .../docs/zh-tw/reference/cli/agents.md | 7 + .../zh-tw/reference/configuration/server.md | 2 +- gui/src/App.tsx | 6 + gui/src/client-resource.ts | 27 +- .../ProviderWorkspaceShell.tsx | 10 +- .../quota-summary-bar/QuotaSummaryBar.tsx | 163 ++++++++++++ .../quota-summary-bar/quota-summary-bar.css | 242 ++++++++++++++++++ gui/src/host-visibility.ts | 71 +++++ gui/src/i18n/de.ts | 10 + gui/src/i18n/en.ts | 10 + gui/src/i18n/fr.ts | 10 + gui/src/i18n/ja.ts | 10 + gui/src/i18n/ko.ts | 10 + gui/src/i18n/ru.ts | 10 + gui/src/i18n/tr.ts | 10 + gui/src/i18n/vi.ts | 10 + gui/src/i18n/zh-TW.ts | 10 + gui/src/i18n/zh.ts | 10 + gui/src/pages/Combos.tsx | 7 +- gui/src/pages/Providers.tsx | 4 +- gui/src/pages/dashboard-overview-sections.tsx | 30 ++- gui/src/pages/dashboard-shared.ts | 48 +++- gui/src/pages/use-dashboard-data.ts | 14 + gui/src/quota-summary.ts | 120 +++++++++ gui/src/visibility-poll.ts | 19 +- gui/tests/host-visibility.test.ts | 196 ++++++++++++++ gui/tests/quota-summary.test.ts | 54 ++++ scripts/test-layout/layout.json | 5 + src/cli/agent.ts | 18 +- src/cli/runtime-api.ts | 17 ++ src/cli/system-command.ts | 10 +- src/clients/config-export.ts | 3 + src/codex/desktop-switches.ts | 10 +- src/codex/inject.ts | 6 + src/codex/inject/config-toml.ts | 137 ++++++++++ src/codex/inject/plan.ts | 23 ++ src/codex/inject/remove.ts | 22 +- src/codex/journal.ts | 36 +++ src/integrations/owned-refresh.ts | 2 +- src/integrations/ownership-policy.ts | 33 ++- src/integrations/state.ts | 4 +- src/integrations/writer.ts | 9 + src/server/management/config-routes.ts | 29 ++- structure/clients/integrations.md | 20 ++ structure/config.md | 14 + structure/desktop-shell.md | 19 ++ tests/cli/cli-headless-parity.test.ts | 55 ++++ tests/clients/desktop-exit-ownership.test.ts | 12 + tests/clients/desktop-host-visibility.test.ts | 82 ++++++ .../integrations-hermes-affinity.test.ts | 161 ++++++++++++ .../codex-web-search-switch.test.ts | 209 +++++++++++++++ .../client-config-export-new-clients.test.ts | 1 + tests/fixtures/test-layout-expected.json | 5 + .../gui/dashboard-sidecar-codex-write.test.ts | 39 +++ .../sidecar-settings-web-search-off.test.ts | 138 ++++++++++ 103 files changed, 2541 insertions(+), 63 deletions(-) create mode 100644 gui/src/components/quota-summary-bar/QuotaSummaryBar.tsx create mode 100644 gui/src/components/quota-summary-bar/quota-summary-bar.css create mode 100644 gui/src/host-visibility.ts create mode 100644 gui/src/quota-summary.ts create mode 100644 gui/tests/host-visibility.test.ts create mode 100644 gui/tests/quota-summary.test.ts create mode 100644 tests/clients/desktop-host-visibility.test.ts create mode 100644 tests/clients/integrations-hermes-affinity.test.ts create mode 100644 tests/codex-integration/codex-web-search-switch.test.ts create mode 100644 tests/gui/dashboard-sidecar-codex-write.test.ts create mode 100644 tests/vision/sidecar-settings-web-search-off.test.ts diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e35224af545..5b34a446136 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -242,6 +242,19 @@ pub fn run() { // to the loopback dashboard by `capabilities/dashboard-zoom.json`. .zoom_hotkeys_enabled(true) .on_navigation(window::navigation_allowed(app.handle().clone())) + // 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 + // to land during a reload. Re-sending the current state here is what keeps the + // GUI's answer correct across navigation. + .on_page_load(|window, payload| { + if matches!(payload.event(), tauri::webview::PageLoadEvent::Finished) { + window::report_visibility( + &window, + window.is_visible().unwrap_or(false), + ); + } + }) .build()?; window::configure(&window); if startup::LaunchOrigin::detect() == startup::LaunchOrigin::User { @@ -260,6 +273,11 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building OpenCodex desktop shell") .run(|app, event| { + // Dock/Finder reopening an existing macOS app does not launch a second instance. + #[cfg(target_os = "macos")] + if let tauri::RunEvent::Reopen { .. } = event { + show_dashboard(app.clone()); + } // Window close and the platform quit gesture arrive here as an exit request, and until // this handler existed they went straight through to a SIGKILL of the runtime. D2 makes // them hide; only the tray's Quit, and an update's coordinated restart, get past. diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index a886fb07769..c61887b06f2 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -79,14 +79,37 @@ fn is_app_origin(url: &Url) -> bool { pub fn show(window: &WebviewWindow) { let _ = window.show(); let _ = window.set_focus(); + report_visibility(window, true); apply_tray_policy(window.app_handle(), true); } pub fn hide(window: &WebviewWindow) { let _ = window.hide(); + report_visibility(window, false); apply_tray_policy(window.app_handle(), false); } +/// Tell the main window's page whether its host window is visible. +/// +/// Windows WebView2 does not flip `document.visibilityState` when the host window is hidden +/// (tauri issues #10592 and #6864), so the dashboard's pollers keep running while the app sits in +/// the tray; macOS WKWebView does flip it. Publishing the host's own answer gives the GUI one +/// signal on every platform instead of one that is correct on only some of them. +/// +/// Only the `main` window publishes: `exit::hide_windows` hides every window through `hide`, +/// and the tray popup carries its own equivalent bridge, so an unguarded report would claim the +/// dashboard was hidden because a popup was. A page that has not loaded yet simply misses the eval; +/// the page-load hook re-sends the current state. +pub fn report_visibility(window: &WebviewWindow, visible: bool) { + if window.label() != "main" { + return; + } + let script = format!( + "window.__OPENCODEX_HOST_VISIBLE__ = {visible}; window.dispatchEvent(new CustomEvent('opencodex:host-visibility', {{detail: {visible}}}));" + ); + let _ = window.eval(script); +} + #[cfg(target_os = "macos")] fn apply_tray_policy(app: &AppHandle, visible: bool) { let policy = if visible { diff --git a/docs-site/src/content/docs/fr/guides/desktop-app.md b/docs-site/src/content/docs/fr/guides/desktop-app.md index ee633d9205a..e5e42513de5 100644 --- a/docs-site/src/content/docs/fr/guides/desktop-app.md +++ b/docs-site/src/content/docs/fr/guides/desktop-app.md @@ -44,6 +44,8 @@ L’application demande à son CLI intégré d’exécuter `ocx resolve --json` Utilisez l’action **Open dashboard** ou **Open in browser** de la zone de notification pour passer du tableau de bord intégré à votre navigateur habituel. Le menu permet aussi de rechercher les mises à jour. +Sur macOS, fermer le tableau de bord laisse l’application active dans la barre des menus. Ouvrez à nouveau OpenCodex depuis le Dock ou le Finder pour réafficher le tableau de bord sans redémarrer le proxy. + ## Utilisation dans la zone de notification Sur macOS et Windows, cliquez sur l’icône pour ouvrir un panneau compact d’utilisation. L’action **Show usage** l’ouvre également, notamment sous Linux lorsque la zone de notification ne transmet pas les clics. Sous Linux, le tableau de bord s’ouvre au démarrage, même si l’environnement de bureau n’affiche pas d’icône. diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index 1f3e04ada55..fa50e57a3d6 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -134,6 +134,8 @@ Kimi Code, gjc, MiniMax Code et Raycast — documents YAML, JSON5 et TOML rééc d'opencodex ont été modifiées, le commutateur se verrouille et la désactivation est refusée plutôt que de deviner quelles modifications vous appartiennent. +Exception pour Hermes : l'ajout de `session_affinity_header: session-id` seul dans un bloc déjà géré peut être adopté via **Apply** ; toute autre modification d'un champ géré reste un conflit. Jusqu'à cette application, l'actualisation automatique de la liste des modèles est également suspendue. Le réglage concerne tous les modèles du provider et nécessite une version de Hermes qui le prend en charge ; il ne garantit aucun taux de succès du cache. Voir le [guide de mise à niveau en anglais](/guides/integrations/#hermes-session-affinity). + ## Prévisualiser et confirmer les modifications Appliquer, Remplacer, Désactiver et Restaurer commencent désormais par un aperçu. La boîte de dialogue diff --git a/docs-site/src/content/docs/fr/guides/sidecars.md b/docs-site/src/content/docs/fr/guides/sidecars.md index 367b98d9c3e..1fcc91cfec6 100644 --- a/docs-site/src/content/docs/fr/guides/sidecars.md +++ b/docs-site/src/content/docs/fr/guides/sidecars.md @@ -164,6 +164,16 @@ le délai d'attente et la limite précédemment choisis. les clés omises inchangées. `timeoutMs` utilise les limites entières de l'environnement d'exécution (1–2147483647 ms). +La carte du service auxiliaire de recherche web reprend la même forme de contrôle : la première +ligne du sélecteur de modèle est **Désactivé (Off)**. La désactivation arrête l'interception de +`web_search` par OpenCodex et l'intégration Codex écrit `web_search = "disabled"` dans +`~/.codex/config.toml`, car Codex continue sinon d'annoncer son propre outil hébergé +`web_search` natif, ce qu'il faut lorsqu'un serveur de recherche MCP doit être le seul chemin +de recherche. La réactivation supprime cette ligne et rétablit la ligne racine `web_search` +écrite par l'opérateur, enregistrée dans le journal Codex. L'écriture exige un +`~/.codex/config.toml` géré (`ocx sync`) ; la carte du tableau de bord vous avertit +lorsqu'elle n'a pas eu lieu et `ocx agent sidecar web --enabled off` indique si elle a réussi. + Vous pouvez toujours définir `enabled: false` dans `config.json` si vous préférez modifier le fichier directement. La recherche et la description d'images avec OAuth Anthropic réutilisent les identifiants Claude Code existants du magasin d'empreintes précédent. Testez néanmoins ce comportement avec le diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 1e4c90f5061..f82047f2ec4 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -37,6 +37,25 @@ remplissage automatique. Le tableau de bord lui-même ne conserve le jeton qu'en dans `localStorage` ni dans `sessionStorage` ; son enregistrement dépend entièrement du navigateur ou du gestionnaire de mots de passe. +## Barre de résumé des quotas + +Une ligne de résumé en haut de chaque page, sauf la page Sécurité au démarrage, indique +l'utilisation actuelle des quotas de chaque fournisseur, par exemple +`OpenAI 31% | Claude 54% | xAI 12% | Google 8%`. Elle lit les mêmes rapports de quotas que l'espace +fournisseur (`GET /api/provider-quotas`, toutes les 60 secondes tant que l'onglet est visible) et ne +force jamais d'actualisation en amont. + +- Chaque étiquette affiche la fenêtre signalée prioritaire : d'abord hebdomadaire, puis mensuelle, + puis 5 heures, puis une fenêtre nommée par le fournisseur ou des crédits prépayés. +- Une étiquette passe en ambre à 70 % d'utilisation et en rouge à 90 %. +- Survolez une étiquette ou cliquez dessus pour voir toutes les fenêtres signalées avec leur heure de + réinitialisation et l'heure de la lecture. Appuyez sur Échap ou cliquez ailleurs pour fermer une + étiquette épinglée. +- Les fournisseurs qui ne signalent aucune fenêtre de quota sont omis. La barre est masquée quand + aucun fournisseur n'en signale. +- Le bord droit indique quand le tableau de bord a lu les rapports pour la dernière fois. Il passe en + ambre lorsque la dernière lecture a échoué et que la lecture précédente est encore affichée. + ## Fonctions disponibles | Zone | Fonction | diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 75e2777cecf..b63eb62f9c3 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -15,8 +15,18 @@ les modes de surface, la délégation, l'effort et le comportement de repli s'em ```bash ocx agent subagents set ark/model-a,openai/gpt-5.5 +ocx agent sidecar web --enabled off ``` +`--enabled off` est le même interrupteur que la ligne **Désactivé (Off)** du tableau de bord : +OpenCodex cesse d'exécuter le service auxiliaire et l'intégration Codex écrit +`web_search = "disabled"` dans `~/.codex/config.toml`, ce qui permet à un serveur de +recherche MCP d'être le seul chemin de recherche. `--enabled on` supprime à nouveau cette ligne. +Lorsque l'enregistrement déplace réellement l'interrupteur, la commande signale l'écriture côté Codex +qu'elle a déclenchée (`codexWebSearch` avec `--json`, une ligne `Codex config:` +sinon) et renvoie vers `ocx sync` quand elle n'a pas pu avoir lieu. L'option fonctionne aussi +pour `vision`. + ### `ocx v2 |threads |mode-hint >` Gérez l'indicateur de fonctionnalité Codex `multi_agent_v2` et le mode surface multi-agents à trois états. diff --git a/docs-site/src/content/docs/fr/reference/configuration/server.md b/docs-site/src/content/docs/fr/reference/configuration/server.md index ffa3e2e1d8d..95c6db11987 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/server.md +++ b/docs-site/src/content/docs/fr/reference/configuration/server.md @@ -221,7 +221,7 @@ l'API Images d'OpenAI et la forme de réponse attendue par Codex. | Champ | Type | Par défaut | Signification | | --- | --- | --- | --- | -| `enabled?` | `boolean` | activé lorsqu'il est utilisable | Interrupteur principal. | +| `enabled?` | `boolean` | activé lorsqu'il est utilisable | Interrupteur principal. Avec `false`, OpenCodex cesse d'intercepter `web_search` et l'intégration Codex écrit `web_search = "disabled"` dans `~/.codex/config.toml`. | | `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | Une valeur explicite est prioritaire ; l'absence de valeur sélectionne toujours `openai`. `anthropic` et `xai` ne s'exécutent que s'ils sont configurés explicitement ; `gemini` et `exa` restent réservés jusqu'à la livraison de leur executor. | | `model?` | `string` | dépendant du backend | `gpt-5.6-luna` pour OpenAI, `claude-sonnet-5` pour Anthropic ou `grok-4.6` pour xAI. L'héritage explicite `gpt-5.4-mini` migre au démarrage. | | `exaApiKey?` | `string` | aucun | Clé opérateur pour le backend `exa`. Écriture seule : les lectures de gestion ne renvoient jamais la valeur stockée. | diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index df6541e21f6..31ee4150d81 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -59,6 +59,8 @@ it from the tray or launch the app again. Use the tray's **Open dashboard** or **Open in browser** action to move between the 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. + ## Usage in the tray On macOS and Windows, click the tray icon to open a compact usage window. The tray's diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index ee28e6e7adc..71549cf66e1 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -200,6 +200,28 @@ undoable. The switch itself stays locked, because the switch cannot know which e you meant to keep — only you can say so. Nothing else is relaxed: a file we cannot parse, or one whose structure we cannot reason about, still refuses. +## Hermes session affinity + +The generated `providers.opencodex` block includes `session_affinity_header: session-id` for all +models. This names a header; Hermes supplies its dynamic conversation identifier. OpenCodex does +not write a shared static identifier or change `api_mode` to enable affinity. + +Use a Hermes version supporting [per-provider request options](https://hermes-agent.nousresearch.com/docs/user-guide/configuring-models#per-provider-request-options). +Older versions may ignore or discard the option; a valid configuration alone does not prove that +Hermes sends the header. Conversation isolation, compaction lineage and auxiliary/child requests +follow Hermes' affinity semantics. This setting does not guarantee a particular cache-hit rate. + +For an existing managed integration, open **Integrations → Hermes**, review **Apply**, and confirm +the update. Until then, it shows **Update needed** and implicit catalog refresh leaves it unchanged, +including its model list. Reading the page does not upgrade the configuration. After Apply, normal +catalog refresh resumes and retains the setting; **Replace** also includes it. + +If you already added exactly `session_affinity_header: session-id` inside the managed block, Apply +can adopt it when all other managed settings still match the ownership record. This is the narrow +exception to the conflict rule above: other edits, a different header name, or a block without a +matching ownership record still require conflict resolution. Unrelated YAML settings and comments +remain untouched, and the existing snapshot and Restore workflow applies to the upgrade. + ## Preview and confirm changes Apply, Replace, Disable, and Restore now begin with a preview. The dialog shows exactly which diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 1f80f4fbc95..022b02faa31 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -207,6 +207,29 @@ timeout, and limit. omitted keys unchanged. `timeoutMs` uses the runtime integer bounds (1–2147483647 ms). +The web-search sidecar card carries the same control shape: the model picker's first row is +**Off**. Off does two things, and the second one is the reason the row exists. OpenCodex stops +intercepting `web_search`, and the Codex integration writes Codex's own +`web_search = "disabled"` mode into `~/.codex/config.toml` — because Codex keeps declaring its +native hosted `web_search` tool until its own mode says otherwise, and the tool a client +advertises is the one the model reaches for. An operator who wants an MCP search server to be +the only search path needs both halves; otherwise the model keeps calling the native tool. + +`web_search` is Codex's key with its own value space (`disabled`, `cached`, `indexed`, `live`). +OpenCodex only ever writes `disabled` while the sidecar is off, and removes its marker-owned line +again once the sidecar is back on — a re-enabled sidecar whose client still had the native tool +switched off would have nothing to intercept. The write needs a managed `~/.codex/config.toml` (`ocx +sync`); the management response reports it as `codexWebSearch`, and both surfaces that can show it +do: the Dashboard's web-search card warns when the write did not happen, and `ocx agent sidecar web +--enabled off` prints whether it happened. Only a save that moves the switch triggers the write, so +the ordinary "nothing changed" answer reports `not_requested` and prints nothing extra. A root +`web_search` line the operator set by hand is replaced while the sidecar is off, since two root keys +of the same name are not valid TOML. Its exact text is recorded in the Codex journal and put back in +its place when the sidecar is switched on again — including for a line added after the journal +snapshot was taken, which `ocx restore` alone cannot cover. The same record is what still +recognizes our own `disabled` line when the Codex app has rewritten `config.toml` and dropped the +comment that named its owner. + You can still set `enabled: false` in `config.json` if you prefer to edit the file directly. Anthropic-OAuth search and image description reuse the existing Claude Code OAuth fingerprint precedent, but should be soak-tested with the diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 72c1a821d96..414c590274c 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -76,6 +76,22 @@ one column and model/effort controls share another. On narrower screens, control labels in the same reading order. Long version labels are shortened visually; hover the version badge or the version value to read the full value. +### Quota summary bar + +A one-line summary at the top of every page except the Startup page shows each provider's current +quota usage, for example `OpenAI 31% | Claude 54% | xAI 12% | Google 8%`. It reads the same provider +quota reports as the Providers workspace (`GET /api/provider-quotas`, every 60 seconds while the tab +is visible) and never forces an upstream refresh. + +- Each chip shows the preferred reported window: weekly first, then monthly, then 5-hour, then a + provider-named window or prepaid credits. +- A chip turns amber at 70% used and red at 90% used. +- Hover or click a chip to see every reported window with its reset time and the time the reading + was taken. Press Escape or click elsewhere to close a pinned chip. +- Providers that report no quota window are left out. The bar is hidden when no provider reports one. +- The right edge shows when the dashboard last read the reports. It turns amber when the latest + read failed and the previous reading is still shown. + ## What you can do | Area | What it does | diff --git a/docs-site/src/content/docs/ja/guides/desktop-app.md b/docs-site/src/content/docs/ja/guides/desktop-app.md index c9ceaba2685..ee829b46ed2 100644 --- a/docs-site/src/content/docs/ja/guides/desktop-app.md +++ b/docs-site/src/content/docs/ja/guides/desktop-app.md @@ -44,6 +44,8 @@ sudo apt install ./OpenCodex--linux-amd64.deb トレイの **Open dashboard** または **Open in browser** で、埋め込みダッシュボードと通常のブラウザを切り替えられます。トレイから更新の確認もできます。 +macOS では、ダッシュボードを閉じてもアプリはメニューバーで動作し続けます。Dock または Finder から OpenCodex を再度開くと、プロキシを再起動せずにダッシュボードが再表示されます。 + ## トレイでの使用量表示 macOS と Windows ではトレイアイコンをクリックするとコンパクトな使用量ウィンドウが開きます。トレイの **Show usage** 操作でも開けます。これはトレイのクリックイベントを転送しない Linux デスクトップでも使えます。Linux では、デスクトップ環境にトレイアイコンが表示されなくても起動時にダッシュボードが開きます。 diff --git a/docs-site/src/content/docs/ja/guides/integrations.md b/docs-site/src/content/docs/ja/guides/integrations.md index 5fa241bf77f..43ccd49d704 100644 --- a/docs-site/src/content/docs/ja/guides/integrations.md +++ b/docs-site/src/content/docs/ja/guides/integrations.md @@ -85,6 +85,8 @@ Disable は opencodex が自身のものとして記録した項目だけを削 ロックされても操作不能ではありません。競合したクライアントには、概要カードとクライアントページの両方で、スイッチの横に **Replace** が表示されます。管理対象設定が置かれている内容を opencodex のブロックで置き換える操作で、先に確認を求めます。ダイアログにはファイル名、失われる内容、元に戻すためのスナップショットが示されます。スイッチ自体はロックされたままです。どの編集を維持するか判断できるのは利用者だけだからです。それ以外の制約は緩めません。解析できないファイルや、構造を安全に判断できないファイルは引き続き拒否されます。 +Hermes のセッション識別設定には例外があります。管理対象の設定に `session_affinity_header: session-id` だけを追加した場合、**Apply** で取り込めます。他の管理対象フィールドの変更は引き続き競合になります。適用するまでバックグラウンドのモデル一覧更新も保留されます。この設定は provider 内の全モデルに適用され、対応する Hermes バージョンが必要です。キャッシュヒット率は保証されません。[英語のアップグレード説明](/guides/integrations/#hermes-session-affinity)を参照してください。 + ## 変更内容を確認して確定する Apply、Replace、Disable、Restore はプレビューから始まります。ダイアログには、変更対象の管理設定が、範囲を限定した変更パスと値の追加・更新・削除の区別とともに表示されます。確定前に内容を確認してください。 diff --git a/docs-site/src/content/docs/ja/guides/sidecars.md b/docs-site/src/content/docs/ja/guides/sidecars.md index 6e41dfdb407..4d623877c98 100644 --- a/docs-site/src/content/docs/ja/guides/sidecars.md +++ b/docs-site/src/content/docs/ja/guides/sidecars.md @@ -137,5 +137,14 @@ OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.6-luna` を `PUT /api/sidecar-settings` は同じフィールドを受け付けます。部分更新では省略したキーをそのまま残します。`timeoutMs` はランタイムの整数範囲(1–2147483647 ms)を使います。 +Web 検索サイドカーのカードも同じ構成です。モデルピッカーの先頭行が **オフ (Off)** です。オフにすると +OpenCodex は `web_search` への介入をやめ、Codex 統合は `~/.codex/config.toml` に +`web_search = "disabled"` を書き込みます。Codex は自身のモードがそうなるまでネイティブの +ホスト型 `web_search` ツールを広告し続けるためで、MCP 検索サーバーだけを検索経路にしたい +場合に必要です。再びオンにするとこの行は削除され、Codex ジャーナルに記録されたオペレーター自身の +ルート `web_search` 行が復元されます。この書き込みには管理対象の +`~/.codex/config.toml`(`ocx sync`)が必要で、書き込みが行われなかった場合は +ダッシュボードのカードが警告し、`ocx agent sidecar web --enabled off` が結果を報告します。 + ファイルを直接編集したい場合は、これまでどおり `config.json` で `enabled` を `false` にできます。Anthropic OAuth 検索と画像説明は既存の Claude Code OAuth fingerprint 先例に従いますが、実際のアカウントと作業量で十分 soak test するのが無難です。全 フィールドは[設定リファレンス](/ja/reference/configuration/server/#サイドカー)を参照してください。 diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 022eb1b100d..6191b3313fd 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -27,6 +27,16 @@ bun run dev:gui リモートダッシュボードでは標準のパスワードフォームが表示され、ブラウザのパスワードマネージャーで保存・自動入力できます。ダッシュボード自体はトークンをメモリ内だけに保持し、`localStorage` や `sessionStorage` には書き込みません。保存するかどうかはブラウザまたはパスワードマネージャーだけが決定します。 +## クォータ概要バー + +起動安全性ページを除くすべてのページ上部の 1 行の概要に、各プロバイダーの現在のクォータ使用率が表示されます。例: `OpenAI 31% | Claude 54% | xAI 12% | Google 8%`。プロバイダー画面と同じクォータレポート(`GET /api/provider-quotas`、タブが表示されている間は 60 秒ごと)を読み取り、上流への強制更新は行いません。 + +- 各チップには、報告されたウィンドウのうち優先されるものを表示します。週間、30 日、5 時間、プロバイダー固有のウィンドウ、前払いクレジットの順です。 +- 70% 使用でアンバー色、90% 使用で赤色になります。 +- チップにカーソルを合わせるかクリックすると、報告されたすべてのウィンドウとそのリセット時刻、読み取り時刻が表示されます。固定したチップは Escape キーか外側のクリックで閉じます。 +- クォータウィンドウを報告しないプロバイダーは表示しません。報告するプロバイダーがない場合はバー全体が隠れます。 +- 右端には、ダッシュボードが最後にレポートを読み取った時刻が表示されます。最新の読み取りに失敗し、前回の値が表示されたままのときはアンバー色になります。 + ## できること | 領域 | 機能 | diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index 933c5b41af0..20d62ca3eca 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -13,8 +13,17 @@ description: マルチエージェント、コンボ、可観測性、アクセ ```bash ocx agent subagents set ark/model-a,openai/gpt-5.5 +ocx agent sidecar web --enabled off ``` +`--enabled off` はダッシュボードの **オフ (Off)** 行と同じスイッチです。OpenCodex はサイドカーを +実行しなくなり、Codex 統合は `~/.codex/config.toml` に +`web_search = "disabled"` を書き込むため、MCP 検索サーバーだけを検索経路にできます。 +`--enabled on` はその行を再び削除します。保存でスイッチが実際に切り替わったとき、コマンドは +Codex 側の書き込み(`--json` では `codexWebSearch`、それ以外では末尾の +`Codex config:` 行)を報告し、書き込みできなかった場合は `ocx sync` を案内します。 +このフラグは `vision` でも機能します。 + ### `ocx v2 |threads >` Codex `multi_agent_v2` 機能フラグとスリーステート マルチエージェント サーフェス モードを管理します。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index 7809e29485c..faae3c179a8 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -145,7 +145,7 @@ Codex は、タイトルやコミット メッセージなどのタスクに小 |フィールド |タイプ |デフォルト |意味 | | --- | --- | --- | --- | -| `enabled?` | `boolean` |使用可能な場合はオン |マスタースイッチ。 | +| `enabled?` | `boolean` |使用可能な場合はオン |マスタースイッチ。`false` のとき OpenCodex は `web_search` への介入をやめ、Codex 統合は `~/.codex/config.toml` に `web_search = "disabled"` を書き込みます。 | | `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | 明示設定が優先され、未設定なら常に `openai` です。`anthropic` と `xai` は明示設定時のみ実行され、`gemini` と `exa` は executor が提供されるまで予約値です。 | | `model?` | `string` |バックエンド依存 | OpenAI は `gpt-5.6-luna`、Anthropic は `claude-sonnet-5`、xAI は `grok-4.6`。従来の明示的な `gpt-5.4-mini` は開始時に移行されます。 | | `exaApiKey?` | `string` | なし | `exa` バックエンドのオペレーターキー。書き込み専用で、管理 API の読み取りでは保存値を返しません。 | diff --git a/docs-site/src/content/docs/ko/guides/desktop-app.md b/docs-site/src/content/docs/ko/guides/desktop-app.md index d910373bb80..ee2c150da04 100644 --- a/docs-site/src/content/docs/ko/guides/desktop-app.md +++ b/docs-site/src/content/docs/ko/guides/desktop-app.md @@ -44,6 +44,8 @@ sudo apt install ./OpenCodex--linux-amd64.deb 트레이의 **Open dashboard** 또는 **Open in browser**를 사용하면 내장 대시보드와 일반 브라우저를 오갈 수 있습니다. 트레이에서는 업데이트도 확인할 수 있습니다. +macOS에서는 대시보드를 닫아도 앱이 메뉴 막대에서 계속 실행됩니다. Dock 또는 Finder에서 OpenCodex를 다시 열면 프록시를 재시작하지 않고 대시보드가 다시 표시됩니다. + ## 트레이에서 사용량 보기 macOS와 Windows에서는 트레이 아이콘을 클릭하면 작은 사용량 창이 열립니다. 트레이의 **Show usage**로도 열 수 있으며, 트레이 클릭 이벤트를 전달하지 않는 Linux 데스크톱에서도 사용할 수 있습니다. Linux에서는 트레이 아이콘이 표시되지 않는 환경을 포함해 시작할 때 대시보드가 열립니다. diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md index a2377a39682..5d27c27ba69 100644 --- a/docs-site/src/content/docs/ko/guides/integrations.md +++ b/docs-site/src/content/docs/ko/guides/integrations.md @@ -85,6 +85,8 @@ Disable은 opencodex가 소유한다고 기록한 항목만 제거합니다. 이 잠겨도 해결 방법이 있습니다. 충돌한 클라이언트는 개요 카드와 클라이언트 페이지의 스위치 옆에 **Replace**를 표시합니다. opencodex 설정을 담은 부분을 새 블록으로 교체하기 전에 확인을 요청합니다. 대화 상자에 파일 이름, 잃게 될 내용, 되돌릴 수 있게 해 주는 스냅샷이 나옵니다. 스위치는 어떤 편집을 유지할지 판단할 수 없으므로 잠긴 채로 둡니다. 그 결정은 사용자가 해야 합니다. 그 밖의 거부 조건은 완화하지 않습니다. 파싱할 수 없거나 구조를 판단할 수 없는 파일은 여전히 거부합니다. +Hermes 세션 식별 설정에는 예외가 있습니다. 기존 관리 설정에 `session_affinity_header: session-id`만 추가했다면 **Apply**로 수용할 수 있습니다. 다른 관리 필드의 수정은 계속 충돌로 처리됩니다. 적용 전에는 백그라운드 모델 목록 갱신도 보류됩니다. 이 설정은 provider의 모든 모델에 적용되며 해당 기능을 지원하는 Hermes 버전이 필요합니다. 캐시 적중률은 보장하지 않습니다. [영문 업그레이드 안내](/guides/integrations/#hermes-session-affinity)를 참조하세요. + ## 변경 미리 보기와 확인 Apply, Replace, Disable, Restore는 미리 보기로 시작합니다. 대화 상자는 제한된 변경 경로와 각 값의 추가·갱신·삭제 여부를 포함해 어떤 관리 설정이 바뀔지 정확히 보여줍니다. 확인하기 전에 계획을 검토하세요. diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index 9bd1733985d..b1aca7ee1b1 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -139,6 +139,15 @@ OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.6-luna`를 폴백으로 `PUT /api/sidecar-settings`는 같은 필드를 받습니다. 부분 업데이트는 보내지 않은 키를 유지합니다. `timeoutMs`는 런타임 정수 범위(1–2147483647 ms)를 사용합니다. +웹 검색 사이드카 카드도 같은 구성입니다. 모델 선택기의 첫 행은 **끔 (Off)** 행입니다. 끄면 +OpenCodex가 `web_search` 가로채기를 멈추고 Codex 통합이 `~/.codex/config.toml`에 +`web_search = "disabled"`를 씁니다. Codex는 자체 모드가 그렇게 될 때까지 네이티브 호스팅 +`web_search` 도구를 계속 광고하므로, MCP 검색 서버만 유일한 검색 경로가 되어야 할 때 +필요합니다. 다시 켜면 이 줄이 제거되고 Codex 저널에 기록된 운영자가 작성한 루트 +`web_search` 줄이 복원됩니다. 이 쓰기에는 관리되는 `~/.codex/config.toml` +(`ocx sync`)이 필요하며, 쓰기가 일어나지 않으면 대시보드 카드가 경고하고 +`ocx agent sidecar web --enabled off`가 결과를 보고합니다. + 파일을 직접 고치고 싶다면 이전처럼 `config.json`에서 `enabled`를 `false`로 두면 됩니다. Anthropic OAuth 검색과 이미지 설명은 기존 Claude Code OAuth fingerprint 선례를 따르지만, 실제 계정과 작업량으로 충분히 soak test하는 편이 좋습니다. 전체 필드는 [설정 레퍼런스](/ko/reference/configuration/server/#sidecars)를 참고하세요. diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index 0789923699d..36b89423368 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -27,6 +27,16 @@ bun run dev:gui 원격 대시보드는 표준 비밀번호 폼을 표시하므로 브라우저 비밀번호 관리자가 토큰 저장과 자동 완성을 제안할 수 있습니다. 대시보드 자체는 토큰을 메모리에만 보관하며 `localStorage`나 `sessionStorage`에 쓰지 않습니다. 저장 여부는 전적으로 브라우저 또는 비밀번호 관리자가 결정합니다. +## 사용량 요약 바 + +시작 안전성 페이지를 제외한 모든 페이지 상단의 한 줄 요약에 프로바이더별 현재 할당량 사용률이 표시됩니다. 예: `OpenAI 31% | Claude 54% | xAI 12% | Google 8%`. 프로바이더 작업 화면과 같은 할당량 보고(`GET /api/provider-quotas`)를 탭이 보이는 동안 60초마다 읽으며, 업스트림 강제 새로고침은 하지 않습니다. + +- 각 항목은 보고된 창 가운데 우선순위가 높은 창을 표시합니다. 주간, 월간, 5시간, 프로바이더 고유 창 또는 선불 크레딧 순서입니다. +- 70% 이상 사용하면 주황색, 90% 이상이면 빨간색으로 표시됩니다. +- 항목에 마우스를 올리거나 클릭하면 보고된 모든 창의 사용률, 초기화 시각, 데이터 기준 시각이 나옵니다. 고정된 항목은 Escape 키나 바깥 클릭으로 닫습니다. +- 할당량 창을 보고하지 않는 프로바이더는 표시하지 않습니다. 보고하는 프로바이더가 없으면 요약 바 전체가 숨겨집니다. +- 오른쪽 끝에 마지막으로 읽은 시각이 표시됩니다. 최근 읽기에 실패해 이전 값을 보여 주는 동안에는 주황색으로 바뀝니다. + ## 할 수 있는 일 | 영역 | 기능 | diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 3f74c33e646..8dec1675fc8 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -14,8 +14,17 @@ description: 멀티 에이전트, 콤보, 관측성, 접근, 통합, 시스템, ```bash ocx agent subagents set ark/model-a,openai/gpt-5.5 +ocx agent sidecar web --enabled off ``` +`--enabled off`는 대시보드의 **끔 (Off)** 행과 같은 스위치입니다. OpenCodex는 사이드카를 +실행하지 않고 Codex 통합은 `~/.codex/config.toml`에 +`web_search = "disabled"`를 쓰므로, MCP 검색 서버만 검색 경로로 쓸 수 있습니다. +`--enabled on`은 그 줄을 다시 제거합니다. 저장이 실제로 스위치를 옮기면 명령은 트리거된 +Codex 쪽 쓰기(`--json`에서는 `codexWebSearch`, 그 밖에는 마지막 +`Codex config:` 줄)를 보고하고, 쓰기가 불가능했다면 `ocx sync`를 안내합니다. +이 플래그는 `vision`에도 동작합니다. + ### `ocx effort [status|set|clear]` 실행 중인 프록시를 통해 메인·서브에이전트의 reasoning-effort 상한을 조회하거나 변경하며, diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 277f8c40f82..62ade9f14cc 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -193,7 +193,7 @@ Codex는 제목과 커밋 메시지 같은 작업에 작은 보조 모델을 사 | 필드 | 형식 | 기본값 | 의미 | | --- | --- | --- | --- | -| `enabled?` | `boolean` | on when usable | 주 스위치입니다. | +| `enabled?` | `boolean` | on when usable | 주 스위치입니다. `false`이면 OpenCodex는 `web_search` 가로채기를 멈추고 Codex 통합이 `~/.codex/config.toml`에 `web_search = "disabled"`를 씁니다. | | `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | 명시값이 우선입니다. 생략하면 항상 `openai`입니다. `anthropic`과 `xai`는 명시적으로 설정할 때만 실행되며, `gemini`와 `exa`는 executor가 제공될 때까지 예약 상태입니다. | | `model?` | `string` | backend-dependent | OpenAI는 `gpt-5.6-luna`, Anthropic은 `claude-sonnet-5`, xAI는 `grok-4.6`입니다. 레거시로 명시된 `gpt-5.4-mini`는 시작 시 마이그레이션됩니다. | | `exaApiKey?` | `string` | 없음 | `exa` 백엔드용 운영자 키입니다. 쓰기 전용이며 관리 API 조회에서는 저장된 값을 반환하지 않습니다. | diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 0f68be9f7ac..166b83c8cb7 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -31,8 +31,18 @@ stay writable). ```bash ocx agent sidecar web --list ocx agent sidecar web --model gpt-5.6-luna +ocx agent sidecar web --enabled off ``` +`--enabled off` is the same switch as the Dashboard's Off row: OpenCodex stops running the +sidecar and the Codex integration writes `web_search = "disabled"` into `~/.codex/config.toml`, +which is what lets an MCP search server be the only search path. `--enabled on` removes that +marker-owned line again. When the save actually moves the switch, the command reports the +Codex-side write it triggered (`codexWebSearch` in `--json`, a trailing `Codex config:` line +otherwise) and points at `ocx sync` when it could not happen; a save that leaves the switch +where it was has nothing to report and prints no `Codex config:` line. The flag works for +`vision` too. + ### `ocx effort [status|set|clear]` Inspect or change main and subagent reasoning-effort caps through the live proxy, or the local diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 0394a17d5f3..d3aee62c2c1 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -657,7 +657,7 @@ Images API paths and response shape expected by Codex. | Field | Type | Default | Meaning | | --- | --- | --- | --- | -| `enabled?` | `boolean` | on when usable | Master switch. | +| `enabled?` | `boolean` | on when usable | Master switch. When false, OpenCodex stops intercepting `web_search` AND the Codex integration writes `web_search = "disabled"` into `~/.codex/config.toml`. | | `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | Explicit wins; unset always resolves to `openai`. `anthropic` and `xai` run only when explicitly configured; `gemini` and `exa` remain reserved until their executors ship. | | `model?` | `string` | backend-dependent | `gpt-5.6-luna` for OpenAI, `claude-sonnet-5` for Anthropic, or `grok-4.6` for xAI. Legacy explicit `gpt-5.4-mini` migrates on start. | | `exaApiKey?` | `string` | none | Operator key for the `exa` backend. Write-only: management reads never return the stored value. | diff --git a/docs-site/src/content/docs/ru/guides/desktop-app.md b/docs-site/src/content/docs/ru/guides/desktop-app.md index d52917226c8..ce4f17b5c7a 100644 --- a/docs-site/src/content/docs/ru/guides/desktop-app.md +++ b/docs-site/src/content/docs/ru/guides/desktop-app.md @@ -60,6 +60,8 @@ CLI подтвердил отсутствие прокси; неопределё Используйте действия **Open dashboard** или **Open in browser** в системной панели, чтобы переключаться между встроенным дашбордом и обычным браузером. Там же доступны проверки обновлений. +В macOS после закрытия дашборда приложение продолжает работать в строке меню. Откройте OpenCodex снова через Dock или Finder, чтобы вернуть дашборд без перезапуска прокси. + ## Использование в системной панели На macOS и Windows нажмите значок в системной панели, чтобы открыть компактное окно использования. diff --git a/docs-site/src/content/docs/ru/guides/integrations.md b/docs-site/src/content/docs/ru/guides/integrations.md index e63b5ba93fd..e496ae9b70f 100644 --- a/docs-site/src/content/docs/ru/guides/integrations.md +++ b/docs-site/src/content/docs/ru/guides/integrations.md @@ -231,6 +231,8 @@ JSON5 и TOML при записи всего документа либо обы это можете решить только вы. Другие ограничения не ослаблены: файл, который нельзя разобрать или безопасно понять, по-прежнему отклоняется. +Исключение для Hermes: если в управляемый блок добавлено только `session_affinity_header: session-id`, изменение можно принять через **Apply**. Другие изменения управляемых полей остаются конфликтами. До применения обновления фоновое обновление списка моделей также приостановлено. Настройка действует для всех моделей provider и требует версии Hermes с поддержкой этой функции; доля попаданий в кеш не гарантируется. См. [инструкцию по обновлению на английском](/guides/integrations/#hermes-session-affinity). + ## Предпросмотр и подтверждение изменений Apply, Replace, Disable и Restore теперь начинаются с предпросмотра. Диалог diff --git a/docs-site/src/content/docs/ru/guides/sidecars.md b/docs-site/src/content/docs/ru/guides/sidecars.md index 1a15066a9b5..337c69062cc 100644 --- a/docs-site/src/content/docs/ru/guides/sidecars.md +++ b/docs-site/src/content/docs/ru/guides/sidecars.md @@ -155,6 +155,16 @@ opencodex описывает каждое изображение **до** осн `PUT /api/sidecar-settings` принимает те же поля. Частичное обновление оставляет непереданные ключи без изменений. `timeoutMs` использует целочисленные границы рантайма (1–2147483647 мс). +Карточка сайдкара web-search устроена так же: первая строка выбора модели — **Выкл. (Off)**. +Выключение останавливает перехват `web_search` со стороны OpenCodex, а интеграция Codex +записывает `web_search = "disabled"` в `~/.codex/config.toml`: до этого Codex +продолжает объявлять свой нативный hosted-инструмент `web_search`, а выключить его нужно, +когда единственным путём поиска должен стать MCP-сервер. Обратное включение удаляет эту строку и +возвращает корневую строку `web_search`, заданную оператором и записанную в журнале Codex. +Запись требует управляемого `~/.codex/config.toml` (`ocx sync`); если она не +произошла, карточка дашборда предупреждает об этом, а +`ocx agent sidecar web --enabled off` сообщает результат. + Если удобнее править файл, по-прежнему можно поставить `enabled: false` в `config.json`. Поиск и описание изображений через Anthropic OAuth переиспользуют существующий прецедент OAuth-отпечатка Claude Code, но их стоит обкатать с целевым аккаунтом и нагрузкой. diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 2e09211db1e..458a5a16cf5 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -27,6 +27,16 @@ bun run dev:gui Удалённый дашборд показывает стандартную форму пароля, поэтому менеджер паролей браузера может предложить сохранить и автозаполнять токен. Сам дашборд хранит токен только в памяти и не записывает его в `localStorage` или `sessionStorage`; решение о сохранении полностью остаётся за браузером или менеджером паролей. +## Полоса сводки квот + +Одна строка в верхней части каждой страницы, кроме страницы «Безопасность запуска», показывает текущее использование квот каждым провайдером, например `OpenAI 31% | Claude 54% | xAI 12% | Google 8%`. Она читает те же отчёты о квотах провайдеров, что и рабочая область провайдера (`GET /api/provider-quotas`, каждые 60 секунд, пока вкладка видима), и никогда не форсирует обновление на стороне провайдера. + +- Каждая метка показывает приоритетное из сообщённых окон: сначала недельное, затем месячное, затем 5-часовое, затем окно с именем провайдера или предоплаченные кредиты. +- Метка становится янтарной при 70% использования и красной при 90%. +- Наведите курсор на метку или нажмите её, чтобы увидеть все сообщённые окна со временем сброса и временем снятия показаний. Нажмите Escape или щёлкните в другом месте, чтобы закрыть закреплённую метку. +- Провайдеры, не сообщающие ни одного окна квоты, не показываются. Полоса скрыта, когда ни один провайдер их не сообщает. +- Правый край показывает, когда дашборд последний раз читал отчёты. Он становится янтарным, если последнее чтение не удалось, а предыдущие значения всё ещё показаны. + ## Возможности | Раздел | Что делает | diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index ad02e88b179..e2ad3a31e22 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -17,8 +17,17 @@ surface mode, delegation, effort и fallback, описано в ```bash ocx agent subagents set ark/model-a,openai/gpt-5.5 +ocx agent sidecar web --enabled off ``` +`--enabled off` — тот же переключатель, что и строка **Выкл. (Off)** в дашборде: OpenCodex +перестаёт запускать сайдкар, а интеграция Codex записывает `web_search = "disabled"` в +`~/.codex/config.toml`, что и позволяет использовать MCP-сервер как единственный путь поиска. +`--enabled on` снова удаляет эту строку. Когда сохранение действительно переключает +состояние, команда сообщает о записи на стороне Codex (`codexWebSearch` в `--json`, +иначе завершающая строка `Codex config:`) и предлагает `ocx sync`, если запись не +удалась. Флаг работает и для `vision`. + ### `ocx v2 |threads >` Управляйте feature flag'ом Codex `multi_agent_v2` и трёхсостоянием multi-agent surface mode. diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index 971c1fa5220..05212194817 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -174,7 +174,7 @@ Codex использует маленькие helper-model'и для задач | Поле | Тип | По умолчанию | Значение | | --- | --- | --- | --- | -| `enabled?` | `boolean` | on when usable | Главный переключатель. | +| `enabled?` | `boolean` | on when usable | Главный переключатель. При `false` OpenCodex перестаёт перехватывать `web_search`, а интеграция Codex записывает `web_search = "disabled"` в `~/.codex/config.toml`. | | `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | Явный выбор выигрывает; отсутствие значения всегда означает `openai`. `anthropic` и `xai` запускаются только при явной настройке; `gemini` и `exa` зарезервированы до появления executor. | | `model?` | `string` | backend-dependent | `gpt-5.6-luna` для OpenAI, `claude-sonnet-5` для Anthropic или `grok-4.6` для xAI. Старый явный `gpt-5.4-mini` мигрирует при старте. | | `exaApiKey?` | `string` | отсутствует | Ключ оператора для backend `exa`. Только для записи: management-read никогда не возвращает сохранённое значение. | diff --git a/docs-site/src/content/docs/tr/guides/desktop-app.md b/docs-site/src/content/docs/tr/guides/desktop-app.md index d0c8c59320b..b0b635909f8 100644 --- a/docs-site/src/content/docs/tr/guides/desktop-app.md +++ b/docs-site/src/content/docs/tr/guides/desktop-app.md @@ -44,6 +44,8 @@ Uygulama, paketindeki CLI'dan `ocx resolve --json` çalıştırmasını ister ve Gömülü kontrol paneli ile normal tarayıcınız arasında geçmek için tepsideki **Open dashboard** veya **Open in browser** eylemini kullanın. Tepsi, güncelleme denetimlerini de sunar. +macOS’te kontrol panelini kapattığınızda uygulama menü çubuğunda çalışmaya devam eder. Proxy’yi yeniden başlatmadan kontrol panelini geri getirmek için OpenCodex’i Dock veya Finder üzerinden yeniden açın. + ## Tepside kullanım macOS ve Windows'ta küçük kullanım penceresini açmak için tepsi simgesine tıklayın. Tepsideki **Show usage** eylemi de pencereyi açar; tıklama olaylarını iletmeyen Linux tepsilerinde de çalışır. Linux'ta masaüstü ortamı tepsi simgesi göstermese bile kontrol paneli başlangıçta açılır. diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index 298930cd408..1c5496f4b44 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -155,6 +155,8 @@ Kimi Code, gjc, MiniMax Code, Raycast — bütün belge olarak yazılan YAML, JS kendi girdilerimiz düzenlenmişse, anahtar kilitlenir ve hangi düzenlemelerin size ait olduğunu tahmin etmek yerine devre dışı bırakmayı reddeder. +Hermes istisnası: yönetilen bloğa yalnızca `session_affinity_header: session-id` eklenmişse **Apply** ile benimsenebilir; diğer yönetilen alan değişiklikleri çakışma olarak kalır. Uygulanana kadar arka plandaki model listesi güncellemeleri de bekletilir. Ayar provider içindeki tüm modeller için geçerlidir ve bu özelliği destekleyen bir Hermes sürümü gerektirir; önbellek isabet oranı garanti edilmez. [İngilizce yükseltme açıklamasına](/guides/integrations/#hermes-session-affinity) bakın. + ## Değişiklikleri önizleyin ve onaylayın Uygula, Değiştir, Devre dışı bırak ve Geri yükle işlemleri artık bir önizlemeyle başlar. İletişim diff --git a/docs-site/src/content/docs/tr/guides/sidecars.md b/docs-site/src/content/docs/tr/guides/sidecars.md index 3c1f80e104c..fdd53a327ac 100644 --- a/docs-site/src/content/docs/tr/guides/sidecars.md +++ b/docs-site/src/content/docs/tr/guides/sidecars.md @@ -196,6 +196,15 @@ akıl yürütmeyi, zaman aşımını ve sınırı korur. atlanan anahtarları değiştirmeden bırakır. `timeoutMs` çalışma zamanı tamsayı sınırlarını kullanır (1–2147483647 ms). +Web arama sidecar kartı aynı denetim yapısını taşır: model seçicisinin ilk satırı **Kapalı (Off)**'dır. +Kapatmak OpenCodex'in `web_search` yakalamasını durdurur ve Codex entegrasyonu +`~/.codex/config.toml` dosyasına `web_search = "disabled"` yazar; çünkü Codex kendi +modu aksini söyleyene kadar yerleşik barındırılan `web_search` aracını bildirmeye devam eder ve +tek arama yolu bir MCP arama sunucusu olacaksa bu gerekir. Yeniden açmak bu satırı kaldırır ve Codex +günlüğüne kaydedilmiş operatörün kendi kök `web_search` satırını geri getirir. Yazma işlemi +yönetilen bir `~/.codex/config.toml` (`ocx sync`) gerektirir; gerçekleşmezse kontrol +paneli kartı uyarır ve `ocx agent sidecar web --enabled off` sonucu bildirir. + Dosyayı doğrudan düzenlemeyi tercih ediyorsanız `config.json` içinde yine de `enabled: false` ayarlayabilirsiniz. Anthropic-OAuth araması ve görsel açıklaması mevcut Claude Code OAuth parmak izi emsalini yeniden kullanır, ancak diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index 86d55eb3d80..11546c89a54 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -39,6 +39,25 @@ yalnızca bellekte tutar ve `localStorage` veya `sessionStorage`'a yazmaz; kaydedilip kaydedilmeyeceği tamamen tarayıcının veya şifre yöneticisinin kararıdır. +## Kota özeti çubuğu + +Başlangıç güvenliği sayfası dışındaki her sayfanın üst kısmındaki tek satırlık özet, her +sağlayıcının geçerli kota kullanımını gösterir; örneğin +`OpenAI 31% | Claude 54% | xAI 12% | Google 8%`. Sağlayıcı çalışma alanıyla aynı kota +raporlarını okur (`GET /api/provider-quotas`, sekme görünürken 60 saniyede bir) ve hiçbir +zaman yukarı akışta yenilemeye zorlamaz. + +- Her etiket, bildirilen pencereler arasında tercih edileni gösterir: önce haftalık, sonra + aylık, sonra 5 saatlik, sonra sağlayıcı adlı bir pencere veya ön ödemeli krediler. +- Etiket %70 kullanımda amber rengine, %90 kullanımda kırmızıya döner. +- Bildirilen tüm pencereleri sıfırlama saati ve okuma zamanıyla görmek için etiketin + üzerine gelin veya tıklayın. Sabitlenmiş bir etiketi kapatmak için Escape'e basın veya + başka bir yere tıklayın. +- Kota penceresi bildirmeyen sağlayıcılar gösterilmez. Hiçbir sağlayıcı bildirmiyorsa + çubuk gizlenir. +- Sağ kenar, kontrol panelinin raporları en son ne zaman okuduğunu gösterir. Son okuma + başarısız olduğunda ve önceki değer hâlâ gösterildiğinde amber renge döner. + ## Neler yapabilirsiniz | Alan | Ne yapar | diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index cfc0d8525c7..c1fb9849612 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -18,8 +18,17 @@ yüzeyleri](/tr/guides/sub-agent-surface/) sayfasına bakın. ```bash ocx agent subagents set ark/model-a,openai/gpt-5.5 +ocx agent sidecar web --enabled off ``` +`--enabled off`, kontrol panelindeki **Kapalı (Off)** satırıyla aynı anahtardır: OpenCodex +sidecar'ı çalıştırmayı bırakır ve Codex entegrasyonu `~/.codex/config.toml` dosyasına +`web_search = "disabled"` yazar; tek arama yolu olarak bir MCP arama sunucusunun kullanılmasını bu +sağlar. `--enabled on` bu satırı yeniden kaldırır. Kaydetme anahtarı gerçekten değiştirdiğinde +komut tetiklediği Codex tarafı yazmayı bildirir (`--json` içinde `codexWebSearch`, aksi +halde son satırda `Codex config:`) ve yazma yapılamadığında `ocx sync` adresini gösterir. +Bayrak `vision` için de çalışır. + ### `ocx v2 |threads |mode-hint >` Codex `multi_agent_v2` özellik bayrağını ve üç durumlu çoklu ajan yüzey modunu diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index ca1c34024cd..018cad76a64 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -244,7 +244,7 @@ Images API yollarını ve yanıt şeklini uygulamalıdır. | Alan | Tip | Varsayılan | Anlamı | | --- | --- | --- | --- | -| `enabled?` | `boolean` | kullanılabilir olduğunda açık | Ana anahtar. | +| `enabled?` | `boolean` | kullanılabilir olduğunda açık | Ana anahtar. `false` olduğunda OpenCodex `web_search` yakalamayı bırakır ve Codex entegrasyonu `~/.codex/config.toml` dosyasına `web_search = "disabled"` yazar. | | `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | Açık değer kazanır; ayarlanmadığında her zaman `openai` seçilir. `anthropic` ve `xai` yalnızca açıkça yapılandırıldığında çalışır; `gemini` ve `exa` executor'ları sunulana kadar ayrılmıştır. | | `model?` | `string` | arka uca bağlı | OpenAI için `gpt-5.6-luna`, Anthropic için `claude-sonnet-5` veya xAI için `grok-4.6`. Eski açık `gpt-5.4-mini` başlangıçta geçirilir. | | `exaApiKey?` | `string` | yok | `exa` arka ucu için operatör anahtarı. Yalnızca yazılır; yönetim okumaları saklanan değeri asla döndürmez. | diff --git a/docs-site/src/content/docs/zh-cn/guides/desktop-app.md b/docs-site/src/content/docs/zh-cn/guides/desktop-app.md index 2e94868ce1d..0342194eb07 100644 --- a/docs-site/src/content/docs/zh-cn/guides/desktop-app.md +++ b/docs-site/src/content/docs/zh-cn/guides/desktop-app.md @@ -44,6 +44,8 @@ sudo apt install ./OpenCodex--linux-amd64.deb 使用托盘中的 **Open dashboard** 或 **Open in browser**,可在内嵌仪表盘与常用浏览器之间切换。托盘也提供更新检查。 +在 macOS 上,关闭仪表盘后,应用会继续在菜单栏中运行。从 Dock 或 Finder 再次打开 OpenCodex 即可恢复仪表盘,无需重启代理。 + ## 在托盘中查看用量 在 macOS 和 Windows 上,点击托盘图标即可打开紧凑的用量窗口。托盘中的 **Show usage** 也能打开它,包括在不转发点击事件的 Linux 桌面上。在 Linux 上,仪表盘会在启动时打开,即使桌面环境没有显示托盘图标也是如此。 diff --git a/docs-site/src/content/docs/zh-cn/guides/integrations.md b/docs-site/src/content/docs/zh-cn/guides/integrations.md index 3ebc705dc34..2d7a03b7f75 100644 --- a/docs-site/src/content/docs/zh-cn/guides/integrations.md +++ b/docs-site/src/content/docs/zh-cn/guides/integrations.md @@ -85,6 +85,8 @@ Disable 只移除 opencodex 记录为自己管理的条目。如果文件在写 锁定状态并非无解。有冲突的客户端会在概览卡片和自身页面的开关旁显示 **Replace**。它会将占据我们设置位置的内容替换为 opencodex 将写入的配置块,并事先询问:对话框会显示文件名、说明会丢失什么,并指向可用于撤销的快照。开关本身仍锁定,因为它无法知道你希望保留哪些编辑;只有你能决定。其他限制没有放宽:无法解析或无法可靠理解结构的文件仍会拒绝处理。 +Hermes 的会话标识升级是上述冲突规则的特例:已有受管配置仅新增 `session_affinity_header: session-id` 时,可通过 **Apply** 接纳;其他受管字段的修改仍会冲突。升级前,后台刷新会同时暂停该集成的模型列表更新。此设置适用于该 provider 的所有模型,需要支持该能力的 Hermes 版本,且不保证缓存命中率。详见[英文升级说明](/guides/integrations/#hermes-session-affinity)。 + ## 预览并确认变更 Apply、Replace、Disable 和 Restore 都先显示预览。对话框准确列出会变化的托管设置,包括有界的变更路径及每项变更是添加、更新还是移除。确认前请检查计划。 diff --git a/docs-site/src/content/docs/zh-cn/guides/sidecars.md b/docs-site/src/content/docs/zh-cn/guides/sidecars.md index 598469e0726..15605d2dcf9 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-cn/guides/sidecars.md @@ -126,5 +126,13 @@ Dashboard 和管理 API 都使用 `gpt-5.6-luna` 作为回退。启动时仍会 `PUT /api/sidecar-settings` 接受相同字段。部分更新会保留未提交的键。`timeoutMs` 使用运行时整数边界(1–2147483647 毫秒)。 +Web 搜索 sidecar 卡片采用相同的控件形态:模型选择器的第一行是 **关闭 (Off)**。关闭会停止 +OpenCodex 对 `web_search` 的拦截,同时 Codex 集成会把 `web_search = "disabled"` +写入 `~/.codex/config.toml`;因为 Codex 在自身模式如此声明前会一直声明其原生托管的 +`web_search` 工具,而当 MCP 搜索服务器需要成为唯一搜索路径时,这正是必需的。重新开启会 +移除该行,并恢复 Codex 日志中记录的、由操作者写入的根级 `web_search` 行。该写入需要受管理的 +`~/.codex/config.toml`(`ocx sync`);若未执行,仪表盘卡片会给出警告, +`ocx agent sidecar web --enabled off` 也会报告结果。 + 如果更想直接改文件,仍可在 `config.json` 中把 `enabled` 设为 `false`。Anthropic OAuth 搜索和图像描述沿用现有 Claude Code OAuth fingerprint 先例,但仍应使用目标账户和实际负载充分 soak test。所有字段见 [配置参考](/zh-cn/reference/configuration/server/#侧车)。 diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index c49221a332a..78bd947d131 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -26,6 +26,16 @@ bun run dev:gui 远程仪表盘会显示标准密码表单,浏览器密码管理器可以提示保存并自动填充 token。仪表盘本身只在内存中保存 token,不会写入 `localStorage` 或 `sessionStorage`;是否持久保存完全由浏览器或密码管理器决定。 +## 配额摘要栏 + +除启动安全页面外,每个页面顶部的一行摘要会显示各 provider 当前的配额用量,例如 `OpenAI 31% | Claude 54% | xAI 12% | Google 8%`。它读取与提供方工作区相同的配额报告(`GET /api/provider-quotas`,标签页可见时每 60 秒一次),并且绝不会强制刷新上游。 + +- 每个条目显示优先选用的已报告窗口:依次为每周、30 天、5 小时,然后是 provider 自命名窗口或预付额度。 +- 用量达到 70% 时变为琥珀色,达到 90% 时变为红色。 +- 悬停或点击条目可查看所有已报告的窗口及其重置时间和读取时间。按 Escape 或点击其他位置可关闭已固定的条目。 +- 不报告任何配额窗口的 provider 不会显示。所有 provider 都未报告时,整条栏会隐藏。 +- 右端显示仪表盘上次读取报告的时间。当最近一次读取失败且仍在显示上一次读数时,它会变为琥珀色。 + ## 可以完成哪些操作 | 区域 | 作用 | diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index 85fd3f4e719..b1a0d4306ce 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -13,8 +13,15 @@ description: 多代理、combo、可观测性、访问、集成、系统和配 ```bash ocx agent subagents set ark/model-a,openai/gpt-5.5 +ocx agent sidecar web --enabled off ``` +`--enabled off` 与仪表盘中的 **关闭 (Off)** 行是同一个开关:OpenCodex 不再运行该 sidecar, +Codex 集成会把 `web_search = "disabled"` 写入 `~/.codex/config.toml`,这正是让 MCP +搜索服务器成为唯一搜索路径的前提。`--enabled on` 会再次移除该行。当保存确实改变了开关状态时, +命令会报告由此触发的 Codex 侧写入(`--json` 中的 `codexWebSearch`,否则为末尾的 +`Codex config:` 行),并在无法写入时提示 `ocx sync`。该标志对 `vision` 同样有效。 + ### `ocx v2 |threads >` 管理 Codex 的 `multi_agent_v2` 功能标志和三态多代理 surface 模式。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 095c9aeda51..afdd4c517d9 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -157,7 +157,7 @@ Codex 会为标题、提交信息等任务使用较小的辅助模型。启用 | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | -| `enabled?` | `boolean` | 在可用时启用 | 总开关。 | +| `enabled?` | `boolean` | 在可用时启用 | 总开关。为 `false` 时,OpenCodex 停止拦截 `web_search`,并且 Codex 集成会把 `web_search = "disabled"` 写入 `~/.codex/config.toml`。 | | `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | 显式配置优先;省略时始终使用 `openai`。`anthropic` 和 `xai` 仅在显式配置时运行;`gemini` 和 `exa` 在 executor 发布前仍为保留值。 | | `model?` | `string` | 依后端而定 | OpenAI 使用 `gpt-5.6-luna`,Anthropic 使用 `claude-sonnet-5`,xAI 使用 `grok-4.6`。旧的显式 `gpt-5.4-mini` 会在启动时迁移。 | | `exaApiKey?` | `string` | 无 | `exa` 后端的操作员密钥。仅可写入:管理读取绝不会返回已存储的值。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/desktop-app.md b/docs-site/src/content/docs/zh-tw/guides/desktop-app.md index 111f96f84eb..94febd97fa6 100644 --- a/docs-site/src/content/docs/zh-tw/guides/desktop-app.md +++ b/docs-site/src/content/docs/zh-tw/guides/desktop-app.md @@ -44,6 +44,8 @@ sudo apt install ./OpenCodex--linux-amd64.deb 透過系統匣的 **Open dashboard** 或 **Open in browser**,可以在內嵌儀表板與一般瀏覽器間切換。系統匣也提供更新檢查。 +在 macOS 上,關閉儀表板後,應用程式會繼續在選單列中執行。從 Dock 或 Finder 再次開啟 OpenCodex 即可恢復儀表板,無須重新啟動代理。 + ## 系統匣中的用量資訊 在 macOS 與 Windows 上,點擊系統匣圖示可開啟精簡用量視窗。系統匣的 **Show usage** 也能開啟它,包括不會轉送點擊事件的 Linux 桌面環境。Linux 會在啟動時開啟儀表板,即使桌面環境不顯示系統匣圖示也一樣。 diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 83f5cc7cf6a..12dc51aff58 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -84,6 +84,8 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil 停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP、DSH 與 Hermes** 同樣不受旁邊編輯影響,但原因不同:它們的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(OpenClaw、Kimi Code、gjc、MiniMax Code、Raycast——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 +Hermes 的會話標識升級是上述衝突規則的特例:既有受管設定僅新增 `session_affinity_header: session-id` 時,可透過 **Apply** 接納;其他受管欄位的修改仍會衝突。升級前,背景重新整理也會暫停此整合的模型清單更新。此設定適用於該 provider 的所有模型,需要支援此能力的 Hermes 版本,且不保證快取命中率。詳見[英文升級說明](/guides/integrations/#hermes-session-affinity)。 + ## 預覽並確認變更 套用、取代、停用與回復現在都會先顯示預覽。對話框會明確列出哪些受管理的設定將會變更, diff --git a/docs-site/src/content/docs/zh-tw/guides/sidecars.md b/docs-site/src/content/docs/zh-tw/guides/sidecars.md index 8d0e3056dac..fae3319f226 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-tw/guides/sidecars.md @@ -121,5 +121,13 @@ OAuth 帳號時使用 `anthropic`,否則使用 `openai`。明確選擇 `anthro `PUT /api/sidecar-settings` 接受相同欄位。部分更新會保留未提交的鍵。`timeoutMs` 使用執行時整數邊界(1–2147483647 毫秒)。 +Web 搜尋 sidecar 卡片採用相同的控制項形態:模型選擇器的第一列是 **關閉 (Off)**。關閉會停止 +OpenCodex 對 `web_search` 的攔截,同時 Codex 整合會把 `web_search = "disabled"` +寫入 `~/.codex/config.toml`;因為 Codex 在自身模式如此宣告前會一直宣告其原生託管的 +`web_search` 工具,而當 MCP 搜尋伺服器需要成為唯一搜尋路徑時,這正是必要的。重新開啟會 +移除該行,並還原 Codex 日誌中記錄、由操作者寫入的根層級 `web_search` 行。該寫入需要受管理的 +`~/.codex/config.toml`(`ocx sync`);若未執行,儀表板卡片會提出警告, +`ocx agent sidecar web --enabled off` 也會回報結果。 + 如果更想直接改檔案,仍可在 `config.json` 中把 `enabled` 設為 `false`。Anthropic OAuth 搜尋和圖像描述沿用現有 Claude Code OAuth fingerprint 先例,但仍應使用目標帳號和實際負載充分 soak test。所有欄位見 [設定參考](/zh-tw/reference/configuration/server/#sidecar)。 diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index e1f171fc1f2..5a8f521bf30 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -31,6 +31,21 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 儀表板本身仍然只在記憶體中保留 token,不會寫入 `localStorage` 或 `sessionStorage`;是否儲存完全 由瀏覽器或密碼管理員決定。 +## 配額摘要列 + +除啟動安全頁面外,每個頁面頂端的一行摘要會顯示各供應商目前的配額用量,例如 +`OpenAI 31% | Claude 54% | xAI 12% | Google 8%`。它讀取與供應商工作區相同的配額報告 +(`GET /api/provider-quotas`,分頁可見時每 60 秒一次),且絕不會強制重新整理上游。 + +- 每個項目顯示優先選用的已回報視窗:依序為每週、30 天、5 小時,然後是供應商自訂視窗或 + 預付額度。 +- 用量達 70% 時轉為琥珀色,達 90% 時轉為紅色。 +- 將游標移到項目上或點擊項目,可查看所有已回報的視窗及其重設時間和讀取時間。按 + Escape 或點擊其他位置可關閉已固定的項目。 +- 未回報任何配額視窗的供應商不會顯示。所有供應商都未回報時,整條列會隱藏。 +- 右端顯示儀表板上次讀取報告的時間。當最近一次讀取失敗且仍在顯示上一次讀數時,它 + 會轉為琥珀色。 + ## 可以完成哪些操作 | 區域 | 作用 | diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 9a61042db27..9b06950e1a3 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -13,8 +13,15 @@ description: 多代理、組合、可觀測性、存取、整合、系統與設 ```bash ocx agent subagents set ark/model-a,openai/gpt-5.5 +ocx agent sidecar web --enabled off ``` +`--enabled off` 與儀表板中的 **關閉 (Off)** 列是同一個開關:OpenCodex 不再執行該 sidecar, +Codex 整合會把 `web_search = "disabled"` 寫入 `~/.codex/config.toml`,這正是讓 MCP +搜尋伺服器成為唯一搜尋路徑的前提。`--enabled on` 會再次移除該行。當儲存確實改變開關狀態時, +指令會回報由此觸發的 Codex 端寫入(`--json` 中的 `codexWebSearch`,否則為結尾的 +`Codex config:` 行),並在無法寫入時提示 `ocx sync`。該旗標對 `vision` 同樣有效。 + ### `ocx v2 |threads >` 管理 Codex 的 `multi_agent_v2` 功能旗標與三態多代理介面模式。 diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index ad1aa7c670d..a64472156fb 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -179,7 +179,7 @@ Codex 使用小型 helper 模型處理如標題與 commit 訊息等任務。啟 | 欄位 | 型別 | 預設值 | 意義 | | --- | --- | --- | --- | -| `enabled?` | `boolean` | 可用時開啟 | 主開關。 | +| `enabled?` | `boolean` | 可用時開啟 | 主開關。為 `false` 時,OpenCodex 停止攔截 `web_search`,且 Codex 整合會把 `web_search = "disabled"` 寫入 `~/.codex/config.toml`。 | | `backend?` | `"openai" \| "anthropic" \| "xai" \| "gemini" \| "exa"` | `openai` | 明確設定優先;省略時一律使用 `openai`。`anthropic` 與 `xai` 僅在明確設定時執行;`gemini` 與 `exa` 在 executor 推出前仍為保留值。 | | `model?` | `string` | 視 backend 而定 | OpenAI 為 `gpt-5.6-luna`、Anthropic 為 `claude-sonnet-5`、xAI 為 `grok-4.6`。舊版明確 `gpt-5.4-mini` 在啟動時遷移。 | | `exaApiKey?` | `string` | 無 | `exa` backend 的操作員金鑰。僅可寫入:管理讀取永遠不會傳回已儲存的值。 | diff --git a/gui/src/App.tsx b/gui/src/App.tsx index c9b2002ee30..fba76e0f448 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -12,6 +12,7 @@ import Integrations from "./pages/Integrations"; import Startup from "./pages/Startup"; import RemoteWorkspace from "./pages/RemoteWorkspace"; import ErrorBoundary from "./components/ErrorBoundary"; +import QuotaSummaryBar from "./components/quota-summary-bar/QuotaSummaryBar"; import { SidebarGithubRow } from "./components/sidebar-github-row"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons"; import { useI18n, useT, LOCALES, localeDisplayName, type Locale, type TKey } from "./i18n/shared"; @@ -464,6 +465,11 @@ export default function App() {
+ {targetsSettled && page !== "startup" && (!targets.connected || sharedSessionReady) && ( + + + + )} {/* Combos is full-bleed, unlike every other surface, and it is reachable only as a Models tab. `.main-inner` is App's element, so App is the only place that diff --git a/gui/src/client-resource.ts b/gui/src/client-resource.ts index 1ec40fc28fc..bed565430dc 100644 --- a/gui/src/client-resource.ts +++ b/gui/src/client-resource.ts @@ -1,4 +1,5 @@ import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react"; +import { hostDocumentHidden, onHostVisibilityChange } from "./host-visibility"; export type ResourceSnapshot = { data: T | undefined; @@ -196,7 +197,7 @@ function joinPollBucket(store: Store, intervalMs: number) { /** True when the document is currently hidden. Safe on non-browser runtimes. */ function documentIsHidden(): boolean { - return typeof document !== "undefined" && document.visibilityState === "hidden"; + return hostDocumentHidden(); } /** @@ -274,11 +275,14 @@ function recomputePoll(store: Store) { * * `replaceInflight: false` keeps this from cancelling work a visible-again mount just * started; if something is already loading, that request is the fresh answer. + * + * The subscription is host-visibility's deduped one, so a single hide (both the + * document event and the desktop host event on macOS) sweeps once, not twice. */ -let moduleVisibilityListener: (() => void) | null = null; +let moduleVisibilityUnsubscribe: (() => void) | null = null; function ensureVisibilityListener(_store: Store) { - if (typeof document === "undefined" || moduleVisibilityListener) return; + if (typeof document === "undefined" || moduleVisibilityUnsubscribe) return; const onVisibility = () => { syncAllBuckets(); if (documentIsHidden()) return; @@ -291,18 +295,15 @@ function ensureVisibilityListener(_store: Store) { } } }; - document.addEventListener("visibilitychange", onVisibility); - moduleVisibilityListener = onVisibility; + moduleVisibilityUnsubscribe = onHostVisibilityChange(onVisibility); } /** Drop the shared listener once nothing polls at all. */ function removeVisibilityListener(_store: Store) { - if (!moduleVisibilityListener) return; + if (!moduleVisibilityUnsubscribe) return; if (pollBuckets.size > 0) return; - if (typeof document !== "undefined") { - document.removeEventListener("visibilitychange", moduleVisibilityListener); - } - moduleVisibilityListener = null; + moduleVisibilityUnsubscribe(); + moduleVisibilityUnsubscribe = null; } async function runFetch( @@ -675,10 +676,8 @@ export function clearClientResourceStoresForTests(): void { } // The shared listener outlives individual stores, so the reset must drop it too or // a later suite's document would keep a handler bound to the previous one. - if (moduleVisibilityListener && typeof document !== "undefined") { - document.removeEventListener("visibilitychange", moduleVisibilityListener); - } - moduleVisibilityListener = null; + moduleVisibilityUnsubscribe?.(); + moduleVisibilityUnsubscribe = null; } /** diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index e478656baa2..7f9091bae5d 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -120,10 +120,12 @@ export default function ProviderWorkspaceShell({ /** * Called when a FORCED quota read settles, with whether it succeeded. * - * The shell owns the only `/api/provider-quotas` read, so it owns the only truthful - * completion signal. An operator-facing refresh button that resolved on its own would - * report success before the response landed — `fetchProviderQuotas(true)` is a - * synchronous state bump, not a request. + * The shell owns the only `/api/provider-quotas` read in this workspace, forced + * `?refresh=1` included — the header QuotaSummaryBar keeps a separate passive 60s read + * that never forces one — so the shell owns the only truthful completion signal for a + * forced refresh. An operator-facing refresh button that resolved on its own would report + * success before the response landed: `fetchProviderQuotas(true)` is a synchronous state + * bump, not a request. */ onQuotaRefreshSettled?: (ok: boolean, epoch: number) => void; /** True when the bump came from a mutation that needs the server to bypass its TTL. */ diff --git a/gui/src/components/quota-summary-bar/QuotaSummaryBar.tsx b/gui/src/components/quota-summary-bar/QuotaSummaryBar.tsx new file mode 100644 index 00000000000..655c7b459e2 --- /dev/null +++ b/gui/src/components/quota-summary-bar/QuotaSummaryBar.tsx @@ -0,0 +1,163 @@ +/** + * QuotaSummaryBar — always-visible provider quota strip above every page. + * + * Self-contained on purpose: App mounts it with one line, and it owns its own read of + * `/api/provider-quotas` (the same endpoint and 60s cadence Combos uses). It never forces + * `?refresh=1`, so it adds no upstream quota probes beyond the server's own TTL. + */ +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useDataSurface } from "../../data-surface"; +import { useI18n, type Locale, type TFn } from "../../i18n/shared"; +import { formatProviderDisplayName } from "../../provider-icons"; +import { freshQuotaReportsFromResponse, type ProviderQuotaReportView } from "../../provider-workspace/report"; +import { buildQuotaSummary, formatQuotaPercent, type QuotaSummaryRow, type QuotaSummarySeverity, type QuotaSummaryWindow } from "../../quota-summary"; +import { formatResetFuture } from "../QuotaBars"; +import "./quota-summary-bar.css"; + +interface QuotaSummaryData { + fetchedAt: number; + reports: Record; +} + +const POLL_MS = 60_000; + +function formatClock(ms: number, locale: Locale): string { + try { + return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", hour12: false }).format(ms); + } catch { + return new Date(ms).toTimeString().slice(0, 5); + } +} + +function windowLabel(window: QuotaSummaryWindow, t: TFn): string { + return window.labelKey ? t(window.labelKey) : window.label ?? window.id; +} + +function severityText(severity: QuotaSummarySeverity, t: TFn): string { + if (severity === "critical") return t("quotaSummary.critical"); + if (severity === "warn") return t("quotaSummary.warn"); + return ""; +} + +function QuotaSummaryItem({ row, t, locale }: { row: QuotaSummaryRow; t: TFn; locale: Locale }) { + const [hovered, setHovered] = useState(false); + const [pinned, setPinned] = useState(false); + const rootRef = useRef(null); + const popoverId = useId(); + const open = hovered || pinned; + + useEffect(() => { + if (!open) return; + const onPointer = (event: PointerEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) setPinned(false); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { setPinned(false); setHovered(false); } + }; + document.addEventListener("pointerdown", onPointer); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("pointerdown", onPointer); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const { headline } = row; + const warning = severityText(row.severity, t); + return ( +
  • setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + {open && ( +
    +
    + {row.label} + {warning && {warning}} +
    +
  • + + {row.windows.map(window => ( + + + + + + ))} + +
    {windowLabel(window, t)}{formatQuotaPercent(window.percent)} + {window.resetAt !== undefined ? formatResetFuture(window.resetAt, t, locale) : "-"} +
    + {row.updatedAt !== undefined && ( +
    + {t(row.observed ? "quotaSummary.observedAt" : "quotaSummary.dataAt", { time: formatClock(row.updatedAt, locale) })} +
    + )} + + )} + + ); +} + +export default function QuotaSummaryBar({ apiBase }: { apiBase: string }) { + const { t, locale } = useI18n(); + const load = useCallback(async (signal: AbortSignal): Promise => { + const response = await fetch(`${apiBase}/api/provider-quotas`, { signal }); + if (!response.ok) throw new Error("quota summary load failed"); + const body = await response.json() as { reports?: unknown } | null; + return { fetchedAt: Date.now(), reports: freshQuotaReportsFromResponse(body?.reports) }; + }, [apiBase]); + const resource = useDataSurface( + `ocx.quota-summary.provider-quotas.v1:${apiBase}`, + [apiBase], + load, + { isEmpty: data => Object.keys(data.reports).length === 0, pollMs: POLL_MS, pauseWhenHidden: true }, + ); + + const data = resource.data; + if (!data) return null; + const rows = buildQuotaSummary(data.reports, provider => formatProviderDisplayName(provider, t)); + if (rows.length === 0) return null; + const stale = !resource.lastAttemptOk; + + return ( +
    +
      + {rows.map(row => )} +
    + + {t("quotaSummary.updated", { time: formatClock(data.fetchedAt, locale) })} + + {/* + Always mounted so the announcement survives the transition: an element that is + inserted already carrying its text is not reliably read out, so the failure and + the recovery would otherwise both go unannounced. Only this span is a live + region — the timestamp beside it changes every 60s and would not stop talking. + */} + + {stale ? t("quotaSummary.refreshFailed") : ""} + +
    + ); +} diff --git a/gui/src/components/quota-summary-bar/quota-summary-bar.css b/gui/src/components/quota-summary-bar/quota-summary-bar.css new file mode 100644 index 00000000000..307a93f519e --- /dev/null +++ b/gui/src/components/quota-summary-bar/quota-summary-bar.css @@ -0,0 +1,242 @@ +.quota-summary-bar { + position: sticky; + top: 0; + z-index: var(--z-sticky); + display: flex; + align-items: center; + gap: 12px; + min-height: 34px; + padding: 4px 16px; + border-bottom: 1px solid var(--border); + background: var(--bg); + font-size: var(--text-label); +} + +.quota-summary-list { + display: flex; + flex: 1; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} + +.quota-summary-item { + position: relative; +} + +.quota-summary-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + border: 1px solid transparent; + border-radius: 999px; + background: none; + color: var(--text); + font: inherit; + cursor: pointer; +} + +.quota-summary-chip:hover, +.quota-summary-chip[aria-expanded="true"] { + border-color: var(--border); +} + +.quota-summary-chip:focus-visible { + outline: 2px solid var(--text); + outline-offset: 1px; +} + +.quota-summary-name { + color: var(--muted); +} + +.quota-summary-pct { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.quota-summary-flag { + display: inline-grid; + place-items: center; + width: 14px; + height: 14px; + border-radius: 50%; + color: var(--bg); + font-size: var(--text-micro); + font-weight: 700; +} + +.quota-summary-item--warn .quota-summary-chip { + background: var(--amber-soft); +} + +.quota-summary-item--warn .quota-summary-pct { + color: var(--amber); +} + +.quota-summary-item--warn .quota-summary-flag { + background: var(--amber); +} + +.quota-summary-item--critical .quota-summary-chip { + border-color: var(--red); + background: var(--red-soft); +} + +.quota-summary-item--critical .quota-summary-pct { + color: var(--red); +} + +.quota-summary-item--critical .quota-summary-flag { + background: var(--red); +} + +.quota-summary-popover { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: var(--z-popover); + min-width: 260px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); +} + +.quota-summary-popover-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; +} + +.quota-summary-badge { + padding: 1px 6px; + border-radius: 999px; + font-size: var(--text-caption); +} + +.quota-summary-badge--warn { + color: var(--amber); + background: var(--amber-soft); +} + +.quota-summary-badge--critical { + color: var(--red); + background: var(--red-soft); +} + +.quota-summary-table { + width: 100%; + border-collapse: collapse; +} + +.quota-summary-table th, +.quota-summary-table td { + padding: 3px 0; + font-weight: 400; + text-align: left; + white-space: nowrap; +} + +.quota-summary-table td.quota-summary-row-pct { + padding: 3px 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; +} + +.quota-summary-row-reset { + color: var(--muted); + white-space: nowrap; +} + +/* + Window labels are provider-named and arrive verbatim, so they can be longer than + the popover is wide. `nowrap` on the label cell pushed the percent and reset cells + out of a narrow mobile popover; let the label wrap and keep the numbers intact, so + the row stays readable instead of losing its figures off the right edge. +*/ +.quota-summary-table th[scope="row"] { + white-space: normal; + overflow-wrap: anywhere; +} + +.quota-summary-row--warn .quota-summary-row-pct { + color: var(--amber); +} + +.quota-summary-row--critical .quota-summary-row-pct { + color: var(--red); +} + +.quota-summary-popover-foot { + margin-top: 6px; + color: var(--muted); + font-size: var(--text-caption); +} + +.quota-summary-updated { + flex-shrink: 0; + color: var(--muted); + font-size: var(--text-caption); + white-space: nowrap; +} + +.quota-summary-updated--stale { + color: var(--amber); +} + +/* + The combos workspace is a fixed 100dvh shell. With the bar above it, let the shell take + the remaining height instead of pushing the page 1 bar-height past the viewport. +*/ +.main:has(> .quota-summary-bar):has(> .main-inner--combos .combos-workspace-shell) { + display: flex; + flex-direction: column; + height: 100dvh; +} + +.main:has(> .quota-summary-bar) > .main-inner.main-inner--combos:has(.combos-workspace-shell) { + flex: 1 1 auto; + min-height: 0; + height: auto; +} + +/* The mobile top bar is already sticky at top: 0; scroll the summary with the page there. */ +@media (max-width: 760px) { + .quota-summary-bar { + position: static; + padding: 4px 10px; + } + + .quota-summary-popover { + position: fixed; + top: auto; + left: 8px; + right: 8px; + min-width: 0; + margin-top: 4px; + } + + /* + The mobile app grid already reserves an `auto` row for `.mobile-topbar` above a + `1fr` main row (see the narrow-screen block in styles.css). A `100dvh` main row + therefore measures a full viewport *below* the bar: the document becomes bar + height + viewport, the page scrolls by exactly the bar, and the combos shell is + clipped at the fold. Fill the row the grid reserved, mirroring how + `.main-inner--combos` itself drops to `height: 100%` on mobile. + */ + .main:has(> .quota-summary-bar):has(> .main-inner--combos .combos-workspace-shell) { + height: 100%; + min-height: 0; + } +} diff --git a/gui/src/host-visibility.ts b/gui/src/host-visibility.ts new file mode 100644 index 00000000000..b8264b33e52 --- /dev/null +++ b/gui/src/host-visibility.ts @@ -0,0 +1,71 @@ +/** + * The single answer to "is the dashboard hidden right now?". + * + * A plain browser is answered by `document.visibilityState`. The desktop shell is not + * always: WebView2 on Windows is reported to keep "visible" while the Tauri window sits + * hidden in the tray (tauri issues #10592, #6864), so every dashboard poller went on + * fetching for a window nobody could see. macOS WKWebView does flip it (measured). + * The native side closes that gap by pushing the truth into the page — + * `window.__OPENCODEX_HOST_VISIBLE__` plus an `opencodex:host-visibility` event, on + * every show/hide and again after each page load — and this module folds both signals + * into one predicate. + * + * Consumers read {@link hostDocumentHidden} and subscribe through + * {@link onHostVisibilityChange} instead of touching `document.visibilityState`; + * the tray popup keeps its own equivalent bridge (`opencodex:tray-visibility`). + */ + +declare global { + interface Window { + /** + * Pushed by the desktop shell: `false` while the main dashboard window is hidden + * to the tray, `true` when it is shown. Absent in a browser, where the flag has no + * meaning and `undefined !== false` keeps the document the only signal. + */ + __OPENCODEX_HOST_VISIBLE__?: boolean; + } +} + +/** True when the dashboard is hidden — by the browser tab or by the desktop host. */ +export function hostDocumentHidden(): boolean { + if (typeof document !== "undefined" && document.visibilityState === "hidden") return true; + return typeof window !== "undefined" && window.__OPENCODEX_HOST_VISIBLE__ === false; +} + +/** + * Call `callback` on every real host-visibility transition, and return the unsubscribe. + * + * Both signals are watched: `visibilitychange` for browsers and macOS, the custom host + * event for the Windows case the standard event cannot see. On macOS both arrive for a + * single hide, and a consumer's visible-again path is a make-up fetch, so the + * transition is deduped against the last computed value per subscription — one hide is + * one callback, one show is one callback, and a duplicate signal costs nothing. + */ +export function onHostVisibilityChange(callback: () => void): () => void { + let last = hostDocumentHidden(); + + const notify = () => { + const next = hostDocumentHidden(); + if (next === last) return; + last = next; + callback(); + }; + + const onHostEvent = (event: Event) => { + const detail = (event as CustomEvent).detail; + // The flag must land before the reading below, or the event would evaluate against + // the previous state and the transition would be deduped away. + if (typeof window !== "undefined" && typeof detail === "boolean") { + window.__OPENCODEX_HOST_VISIBLE__ = detail; + } + notify(); + }; + + if (typeof document !== "undefined") document.addEventListener("visibilitychange", notify); + if (typeof window !== "undefined") window.addEventListener("opencodex:host-visibility", onHostEvent); + + return () => { + if (typeof document !== "undefined") document.removeEventListener("visibilitychange", notify); + if (typeof window !== "undefined") window.removeEventListener("opencodex:host-visibility", onHostEvent); + }; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index b50da156f74..3d6318e2d71 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -356,6 +356,8 @@ export const de: Record = { "dash.visionModelHint": "Modell zur Beschreibung von Bildern für nur-Text-Routen. Erfordert ChatGPT-Login.", "dash.webSearchSidecar": "Websuche-Sidecar", "dash.webSearchSidecarHint": "Backend und Modell für die Websuche gerouteter Modelle auswählen.", + "dash.webSearchOff": "Aus", + "dash.webSearchCodexSync": "Gespeichert. Codex' Config wurde nicht neu geschrieben – nutze „Modelle synchronisieren“.", "dash.webSearchStream": "Antworten live streamen", "dash.webSearchStreamHint": "Führenden Text und Reasoning live streamen, bis das Modell über einen Tool-Aufruf entscheidet; der Rest bleibt für das Abfangen der Suche gepuffert. Text vor einer Suche kann sich teilweise wiederholen.", "dash.visionSidecar": "Vision-Sidecar", @@ -3177,4 +3179,12 @@ export const de: Record = { "remote.event.status": "Status", "remote.event.tool": "Remote-Werkzeug", "remote.event.error": "Fehler", + "quotaSummary.aria": "Anbieter-Kontingentübersicht", + "quotaSummary.updated": "Aktualisiert {time}", + "quotaSummary.dataAt": "Daten von {time}", + "quotaSummary.observedAt": "Beobachtet um {time}", + "quotaSummary.warn": "Über 70 % genutzt", + "quotaSummary.critical": "Über 90 % genutzt", + "quotaSummary.credits": "Guthaben", + "quotaSummary.refreshFailed": "Letzte Aktualisierung fehlgeschlagen; vorheriger Stand wird angezeigt", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 199e6e9abb2..b6a9c700e4a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -368,6 +368,8 @@ export const en = { "dash.visionModelHint": "Model used to describe images for text-only routed models. Requires ChatGPT login.", "dash.webSearchSidecar": "Web search sidecar", "dash.webSearchSidecarHint": "Choose the backend and model used for web search on routed models.", + "dash.webSearchOff": "Off", + "dash.webSearchCodexSync": "Saved. Codex's config was not rewritten — run Sync models to apply it.", "dash.webSearchStream": "Stream answers live", "dash.webSearchStreamHint": "Stream the model’s leading text and reasoning live until it decides on a tool call; the rest of the turn stays buffered for search interception. Text written before a search may partially repeat.", "dash.visionSidecar": "Vision sidecar", @@ -3211,6 +3213,14 @@ export const en = { "remote.event.status": "Status", "remote.event.tool": "Remote tool", "remote.event.error": "Error", + "quotaSummary.aria": "Provider quota summary", + "quotaSummary.updated": "Updated {time}", + "quotaSummary.dataAt": "Data from {time}", + "quotaSummary.observedAt": "Observed at {time}", + "quotaSummary.warn": "70%+ used", + "quotaSummary.critical": "90%+ used", + "quotaSummary.credits": "Credits", + "quotaSummary.refreshFailed": "Last refresh failed; showing the previous reading", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4b87fe14749..3915f020b15 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -358,6 +358,8 @@ export const fr: Record = { "dash.visionModelHint": "Modèle utilisé pour décrire les images aux modèles routés en mode texte uniquement. Nécessite une connexion à ChatGPT.", "dash.webSearchSidecar": "Service auxiliaire de recherche Web", "dash.webSearchSidecarHint": "Choisissez le moteur et le modèle utilisés pour la recherche Web sur les modèles routés.", + "dash.webSearchOff": "Désactivé", + "dash.webSearchCodexSync": "Enregistré. La config de Codex n'a pas été réécrite — lancez « Synchroniser les modèles ».", "dash.webSearchStream": "Diffuser les réponses en direct", "dash.webSearchStreamHint": "Diffuse en direct le texte initial et le raisonnement du modèle jusqu’à ce qu’il décide d’appeler un outil ; le reste du tour demeure en mémoire tampon pour intercepter la recherche. Le texte produit avant une recherche peut être partiellement répété.", "dash.visionSidecar": "Service auxiliaire de vision", @@ -3166,4 +3168,12 @@ export const fr: Record = { "remote.event.status": "État", "remote.event.tool": "Outil distant", "remote.event.error": "Erreur", + "quotaSummary.aria": "Résumé des quotas des fournisseurs", + "quotaSummary.updated": "Mis à jour à {time}", + "quotaSummary.dataAt": "Données de {time}", + "quotaSummary.observedAt": "Observé à {time}", + "quotaSummary.warn": "Plus de 70 % utilisés", + "quotaSummary.critical": "Plus de 90 % utilisés", + "quotaSummary.credits": "Crédits", + "quotaSummary.refreshFailed": "Échec de la dernière actualisation ; affichage de la lecture précédente", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 29a825d3326..93d04f38a92 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -365,6 +365,8 @@ export const ja: Record = { "dash.visionModelHint": "テキスト専用ルーティングモデルで画像を説明するために使うモデル。ChatGPT ログインが必要です。", "dash.webSearchSidecar": "ウェブ検索サイドカー", "dash.webSearchSidecarHint": "ルーティングモデルでウェブ検索に使うバックエンドとモデルを選択します。", + "dash.webSearchOff": "オフ", + "dash.webSearchCodexSync": "保存しました。Codex の設定はまだ書き換えられていません —「モデルを同期」を実行してください。", "dash.webSearchStream": "回答をライブ配信", "dash.webSearchStreamHint": "モデルがツール呼び出しを決定するまで、先頭のテキストと推論をライブ配信します。以降は検索インターセプトのためバッファされます。検索前のテキストは一部繰り返される場合があります。", "dash.visionSidecar": "ビジョンサイドカー", @@ -3199,4 +3201,12 @@ export const ja: Record = { "remote.event.status": "状態", "remote.event.tool": "リモートツール", "remote.event.error": "エラー", + "quotaSummary.aria": "プロバイダークォータ概要", + "quotaSummary.updated": "{time} 更新", + "quotaSummary.dataAt": "{time} 時点のデータ", + "quotaSummary.observedAt": "{time} に観測", + "quotaSummary.warn": "70%以上使用", + "quotaSummary.critical": "90%以上使用", + "quotaSummary.credits": "クレジット", + "quotaSummary.refreshFailed": "最新の更新に失敗しました。前回の値を表示しています", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 2e87cd5b5ff..66a2f3300ca 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -360,6 +360,8 @@ export const ko: Record = { "dash.visionModelHint": "텍스트 전용 라우팅 모델에 이미지를 설명하는 데 사용되는 모델입니다. ChatGPT 로그인 필요.", "dash.webSearchSidecar": "웹 검색 사이드카", "dash.webSearchSidecarHint": "라우팅 모델의 웹 검색에 쓸 백엔드와 모델을 고릅니다.", + "dash.webSearchOff": "끔", + "dash.webSearchCodexSync": "저장했습니다. Codex 설정이 아직 다시 작성되지 않았습니다 — “모델 동기화”를 실행하세요.", "dash.webSearchStream": "응답 실시간 스트리밍", "dash.webSearchStreamHint": "모델이 도구 호출을 결정할 때까지 앞부분 텍스트와 추론을 실시간 스트리밍합니다. 이후는 검색 가로채기를 위해 버퍼링됩니다. 검색 전 텍스트가 일부 반복될 수 있습니다.", "dash.visionSidecar": "비전 사이드카", @@ -3199,4 +3201,12 @@ export const ko: Record = { "remote.event.status": "상태", "remote.event.tool": "원격 도구", "remote.event.error": "오류", + "quotaSummary.aria": "프로바이더 사용량 요약", + "quotaSummary.updated": "{time} 갱신", + "quotaSummary.dataAt": "{time} 기준 데이터", + "quotaSummary.observedAt": "{time} 관측", + "quotaSummary.warn": "70% 이상 사용", + "quotaSummary.critical": "90% 이상 사용", + "quotaSummary.credits": "크레딧", + "quotaSummary.refreshFailed": "최근 갱신에 실패해 이전 값을 표시합니다", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 1279c60b099..29428d8126b 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -365,6 +365,8 @@ export const ru: Record = { "dash.visionModelHint": "Модель, которая описывает изображения для маршрутизируемых моделей, работающих только с текстом. Требуется вход в аккаунт ChatGPT.", "dash.webSearchSidecar": "Сайдкар веб-поиска", "dash.webSearchSidecarHint": "Выберите бэкенд и модель, используемые для веб-поиска на маршрутизируемых моделях.", + "dash.webSearchOff": "Выкл", + "dash.webSearchCodexSync": "Сохранено. Конфигурация Codex не перезаписана — запустите «Синхронизировать модели».", "dash.webSearchStream": "Стримить ответы вживую", "dash.webSearchStreamHint": "Транслировать начальный текст и рассуждения вживую, пока модель не решит вызвать инструмент; остальное буферизуется для перехвата поиска. Текст до поиска может частично повторяться.", "dash.visionSidecar": "Сайдкар для изображений", @@ -3200,4 +3202,12 @@ export const ru: Record = { "remote.event.status": "Состояние", "remote.event.tool": "Удалённый инструмент", "remote.event.error": "Ошибка", + "quotaSummary.aria": "Сводка квот провайдеров", + "quotaSummary.updated": "Обновлено в {time}", + "quotaSummary.dataAt": "Данные на {time}", + "quotaSummary.observedAt": "Замечено в {time}", + "quotaSummary.warn": "Использовано более 70%", + "quotaSummary.critical": "Использовано более 90%", + "quotaSummary.credits": "Кредиты", + "quotaSummary.refreshFailed": "Последнее обновление не удалось; показаны предыдущие данные", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index cef6fa07031..8814780b1c1 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -366,6 +366,8 @@ export const tr: Record = { "dash.visionModelHint": "Salt metin yönlendirilen modeller için görselleri tanımlamakta kullanılan model. ChatGPT girişi gerektirir.", "dash.webSearchSidecar": "Web arama yan aracı (sidecar)", "dash.webSearchSidecarHint": "Yönlendirilen modellerde web araması için kullanılan arka ucu ve modeli seçin.", + "dash.webSearchOff": "Kapalı", + "dash.webSearchCodexSync": "Kaydedildi. Codex yapılandırması henüz yeniden yazılmadı — “Modelleri senkronize et”i çalıştırın.", "dash.webSearchStream": "Yanıtları canlı akıt", "dash.webSearchStreamHint": "Model bir araç çağrısına karar verene kadar baştaki metni ve akıl yürütmeyi canlı akıtır; kalanı arama yakalama için arabelleğe alınır. Aramadan önce yazılan metin kısmen tekrarlanabilir.", "dash.visionSidecar": "Görsel yan aracı (sidecar)", @@ -3200,4 +3202,12 @@ export const tr: Record = { "remote.event.status": "Durum", "remote.event.tool": "Uzak araç", "remote.event.error": "Hata", + "quotaSummary.aria": "Sağlayıcı kota özeti", + "quotaSummary.updated": "{time} güncellendi", + "quotaSummary.dataAt": "{time} verisi", + "quotaSummary.observedAt": "{time} gözlemlendi", + "quotaSummary.warn": "%70+ kullanıldı", + "quotaSummary.critical": "%90+ kullanıldı", + "quotaSummary.credits": "Krediler", + "quotaSummary.refreshFailed": "Son yenileme başarısız; önceki değer gösteriliyor", }; diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 099ce7cafb1..78fd3b1e6d5 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -358,6 +358,8 @@ export const vi: Record = { "dash.visionModelHint": "Model được sử dụng để mô tả hình ảnh cho các model định tuyến chỉ hỗ trợ văn bản. Yêu cầu đăng nhập ChatGPT.", "dash.webSearchSidecar": "Web search sidecar", "dash.webSearchSidecarHint": "Chọn backend và model được sử dụng cho tìm kiếm web trên các models định tuyến.", + "dash.webSearchOff": "Tắt", + "dash.webSearchCodexSync": "Đã lưu. Cấu hình Codex chưa được ghi lại — hãy chạy “Đồng bộ models”.", "dash.webSearchStream": "Phát trực tuyến (Stream) các câu trả lời trực tiếp", "dash.webSearchStreamHint": "Phát trực tuyến các văn bản dẫn dắt và quá trình lý luận của model cho đến khi nó quyết định gọi một công cụ; phần còn lại của lượt chạy sẽ được lưu đệm (buffered) để can thiệp tìm kiếm. Văn bản được viết trước một tìm kiếm có thể lặp lại một phần.", "dash.visionSidecar": "Vision sidecar", @@ -3169,4 +3171,12 @@ export const vi: Record = { "models.fastRows.disabled": "Đã tắt hàng model Fast.", "models.fastRows.loadFailed": "Không thể tải cài đặt hàng Fast.", "models.fastRows.updateFailed": "Không thể cập nhật cài đặt hàng Fast.", + "quotaSummary.aria": "Tóm tắt hạn mức nhà cung cấp", + "quotaSummary.updated": "Cập nhật lúc {time}", + "quotaSummary.dataAt": "Dữ liệu lúc {time}", + "quotaSummary.observedAt": "Ghi nhận lúc {time}", + "quotaSummary.warn": "Đã dùng trên 70%", + "quotaSummary.critical": "Đã dùng trên 90%", + "quotaSummary.credits": "Tín dụng", + "quotaSummary.refreshFailed": "Lần làm mới gần nhất thất bại; đang hiển thị số liệu trước đó", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 1c8d62080b6..583fe6c4213 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -255,6 +255,8 @@ export const zhTW: Record = { "dash.visionModelHint": "為純文字路由模型描述圖像的模型。需要 ChatGPT 登入。", "dash.webSearchSidecar": "網頁搜尋附屬服務", "dash.webSearchSidecarHint": "選擇路由模型進行網頁搜尋時使用的後端和模型。", + "dash.webSearchOff": "關閉", + "dash.webSearchCodexSync": "已儲存。Codex 的設定尚未重寫 — 請執行「同步模型」。", "dash.webSearchStream": "即時串流輸出回答", "dash.webSearchStreamHint": "即時串流輸出開頭的文字和推理,直到模型決定呼叫工具;其餘部分為攔截搜尋而保持緩衝。搜尋前的文字可能會部分重複。", "dash.visionSidecar": "視覺附屬服務", @@ -3163,4 +3165,12 @@ export const zhTW: Record = { "remote.event.status": "狀態", "remote.event.tool": "遠端工具", "remote.event.error": "錯誤", + "quotaSummary.aria": "供應商配額概覽", + "quotaSummary.updated": "{time} 更新", + "quotaSummary.dataAt": "{time} 的資料", + "quotaSummary.observedAt": "{time} 觀測", + "quotaSummary.warn": "已用 70% 以上", + "quotaSummary.critical": "已用 90% 以上", + "quotaSummary.credits": "額度", + "quotaSummary.refreshFailed": "最近一次重新整理失敗,顯示上次資料", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 6d05e3854a8..5d222962c05 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -360,6 +360,8 @@ export const zh: Record = { "dash.visionModelHint": "为纯文本路由模型描述图像的模型。需要 ChatGPT 登录。", "dash.webSearchSidecar": "网页搜索附属服务", "dash.webSearchSidecarHint": "选择路由模型进行网页搜索时使用的后端和模型。", + "dash.webSearchOff": "关闭", + "dash.webSearchCodexSync": "已保存。Codex 的配置尚未重写 — 请运行“同步模型”。", "dash.webSearchStream": "实时流式输出回答", "dash.webSearchStreamHint": "实时流式输出开头的文本和推理,直到模型决定调用工具;其余部分为拦截搜索而保持缓冲。搜索前的文本可能会部分重复。", "dash.visionSidecar": "视觉附属服务", @@ -3198,4 +3200,12 @@ export const zh: Record = { "remote.event.status": "状态", "remote.event.tool": "远程工具", "remote.event.error": "错误", + "quotaSummary.aria": "提供商配额概览", + "quotaSummary.updated": "{time} 更新", + "quotaSummary.dataAt": "{time} 的数据", + "quotaSummary.observedAt": "{time} 观测", + "quotaSummary.warn": "已用 70% 以上", + "quotaSummary.critical": "已用 90% 以上", + "quotaSummary.credits": "额度", + "quotaSummary.refreshFailed": "最近一次刷新失败,显示上次数据", }; diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx index 18b997a4b50..6d299c6c992 100644 --- a/gui/src/pages/Combos.tsx +++ b/gui/src/pages/Combos.tsx @@ -8,6 +8,7 @@ import { nextProviderQuotaStateExpiration, toPutBody, } from "../combo-workspace-data"; +import { hostDocumentHidden, onHostVisibilityChange } from "../host-visibility"; import { hideRedundantChatGptForwardProviders } from "../provider-workspace/catalog"; import { readSessionListCacheEntry, writeSessionListCacheEntry } from "../session-list-cache"; import { Notice } from "../ui"; @@ -252,11 +253,11 @@ export default function Combos({ // A new snapshot may be newer than this clock, so unknown state also gets one immediate check. const timer = window.setTimeout(recheck, quotaExpiry === undefined ? 0 : Math.max(0, quotaExpiry - Date.now())); - const onVisible = () => { if (document.visibilityState === "visible") recheck(); }; - document.addEventListener("visibilitychange", onVisible); + const onVisible = () => { if (!hostDocumentHidden()) recheck(); }; + const unsubscribeVisibility = onHostVisibilityChange(onVisible); return () => { window.clearTimeout(timer); - document.removeEventListener("visibilitychange", onVisible); + unsubscribeVisibility(); }; }, [active, apiBase, quotaResource.data, quotaResource.lastAttemptOk, quotaExpiry]); diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index dc865957936..87bf848cd20 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -448,7 +448,9 @@ export default function Providers({ apiBase }: { apiBase: string }) { // back empty on the next visit. A microtask cannot be cancelled, so the requests always go out. // Guarded per identity because StrictMode double-invokes this effect on mount and an // uncancellable microtask would otherwise bootstrap the page twice. - // Quotas: workspace shell owns /api/provider-quotas — do not double-fetch on mount. + // Quotas: the workspace shell owns this page's /api/provider-quotas read, including the + // forced ?refresh=1 fan-out — do not double-fetch on mount. The header QuotaSummaryBar + // keeps its own separate, passive 60s read of the same endpoint. if (bootstrapKeyRef.current === apiBase) return; bootstrapKeyRef.current = apiBase; void Promise.resolve().then(() => { diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index 2aa28acb107..8ea50ba758c 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -17,6 +17,8 @@ import { shadowCallModelOptions, webSearchSidecarSelectionForModel, updateJobLabel, + webSearchEnabledPatch, + sidecarCodexWritePending, visionEnabledPatch, visionMaxDescriptionsPatch, visionReasoningLadder, @@ -441,10 +443,14 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { t, settings, settingsSaving, syncing, toggleCodexAutoStart, toggleCodexDesktopAuthless, toggleCodexClientCompaction, sidecar, sidecarSaving, sidecarModels, visionModels, models, saveSidecar, + sidecarCodexApply, shadowCall, shadowCallSaving, shadowCallHelpTriggerRef, shadowCallHelpOpen, setShadowCallHelpOpen, saveShadowCall, } = d; const visionEnabled = sidecar?.vision?.enabled !== false; const visionModel = visionEnabled ? (sidecar?.vision?.model ?? "gpt-5.6-luna") : ""; + const webSearchEnabled = sidecar?.webSearch?.enabled !== false; + // Same shape as the Vision card: Off is a row in the picker, and choosing a model is the way back. + const webSearchModel = webSearchEnabled ? (sidecar?.webSearch?.model ?? "gpt-5.6-luna") : ""; const persistedVisionReasoning = sidecar?.vision?.reasoning ?? "low"; const visionLadder = visionReasoningLadder(models, visionModel); const visionReasoning = clampVisionReasoningToLadder(visionLadder, persistedVisionReasoning); @@ -553,6 +559,15 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) {
    {t("dash.webSearchSidecar")}
    {t("dash.webSearchSidecarHint")}
    + {/* The switch is stored even when Codex's own key was not rewritten. Saying nothing + here would read as "the native tool is off now", which is exactly the state the + operator asked for and may not have. */} + {sidecarCodexWritePending(sidecarCodexApply) && ( +
    + + {t("dash.webSearchCodexSync")} +
    + )}
    {/* Same two-row shape as the vision card: the model select owns the first row, and the secondary control sits right-aligned on its own row below. Sharing the @@ -561,10 +576,17 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) {