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 40b7a179d7..992f097e3d 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -158,6 +158,8 @@ les adresses IPv6 entre crochets et `*` ; par exemple, indiquez explicitement ` restent bloquées. Les requêtes de diagnostic rejettent les redirections et signalent une cible dont les identifiants ont été retirés. L'examen des redirections des requêtes ordinaires vers les fournisseurs reste distinct de cette protection de diagnostic. +Deux accommodements fake-IP DNS existent pour les utilisateurs de Clash / Surge / Mihomo, et tous deux ne s'appliquent qu'aux *réponses* DNS — une adresse littérale dans l'URL reste rejetée. La plage de benchmark IANA `198.18.0.0/15` (et ses écritures IPv6 IPv4-mapped) est acceptée dès qu'un proxy sortant s'applique à l'hôte. La plage IPv6 fake-IP par défaut de Mihomo `fdfe:dcba:9876::/48` est acceptée sous une condition plus stricte : la variable de proxy correspondant au schéma de l'URL (`HTTPS_PROXY` pour `https:`, `HTTP_PROXY` pour `http:` ; `ALL_PROXY` ne compte pas) doit être définie, l'hôte ne doit pas correspondre à `NO_PROXY`, et la requête est alors explicitement liée à ce proxy. Tout autre ULA, un préfixe adjacent ou une réponse fake-IP mélangée à une vraie réponse privée exige toujours `allowPrivateNetwork: true`. La validation à l'enregistrement du fournisseur n'applique jamais l'accommodement IPv6. + ## Groupe de comptes Codex Utilisez **Codex Auth** dans le tableau de bord pour ajouter des comptes au groupe et actualiser les quotas. `config.json` stocke les diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index a1667494ba..b2ae7fcb91 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -7,6 +7,14 @@ opencodex makes Codex route through the proxy by editing two things Codex reads: (`$CODEX_HOME/config.toml`, default `~/.codex/config.toml`) and its model catalog. Every edit is 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 +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 +clients. Re-enabling rebuilds the catalog from the models available at that time, so it does not +restore the Codex files byte for byte. + The proxy exposes one bare `openai` Codex-login route with Pool(default) and Direct account modes, plus `openai-apikey/` for the configured API key. Pool includes main plus added accounts; Direct uses only the caller/main bearer. The routes do not fall back to one another. Shipped v1 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 3b54798ce4..922a77f3c3 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -134,6 +134,8 @@ API キープロバイダーは、リテラルキーまたは環境参照を保 プライベート/ローカル宛先には `allowPrivateNetwork: true` が必要で、送信プロキシがアクティブな場合は、一致する `NO_PROXY` エントリが必要です。ループバックは自動的に追加されます。 CIDR エントリは解釈されないため、各 LAN ホストを明示的にリストします。マッチャーは、正確なホスト、ドメイン サフィックス、オプションのポート、括弧で囲まれた IPv6、および `*` をサポートします。たとえば、`192.168.1.50` を明示的にリストします。メタデータとリンクローカル宛先はブロックされたままになります。診断リクエストはリダイレクトを拒否し、資格情報が剥奪されたターゲットを報告します。通常のプロバイダー要求のリダイレクト レビューは、この診断ガードとは独立したままになります。 +Clash / Surge / Mihomo 利用者向けの fake-IP DNS 例外は 2 種類あり、いずれも DNS の*応答*にのみ適用されます。URL に書かれたリテラルアドレスは引き続き拒否されます。IANA ベンチマーク範囲 `198.18.0.0/15`(IPv4-mapped IPv6 表記を含む)は、そのホストにアウトバウンドプロキシが適用される場合に許可されます。Mihomo の既定 IPv6 fake-IP 範囲 `fdfe:dcba:9876::/48` はより厳しい条件でのみ許可されます。URL スキームに一致するプロキシ変数(`https:` は `HTTPS_PROXY`、`http:` は `HTTP_PROXY`、`ALL_PROXY` は対象外)が設定されていること、ホストが `NO_PROXY` に一致しないことが必要で、その場合リクエストはそのプロキシに明示的に固定されます。それ以外の ULA、隣接プレフィックス、実際のプライベート応答と混在した fake-IP 応答には引き続き `allowPrivateNetwork: true` が必要です。プロバイダー保存時の検証には IPv6 例外は適用されません。 + ## Codexアカウントプール pool アカウントの追加と quota 更新はダッシュボードの **Codex Auth** ページで処理してください。設定には secret で 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 927535c845..da0b770ba2 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -134,6 +134,8 @@ API 키 공급자는 리터럴 키나 환경 참조를 둘 수 있습니다. OAu 사설/로컬 목적지는 `allowPrivateNetwork: true`가 필요하며, 아웃바운드 프록시가 활성화된 경우에는 일치하는 `NO_PROXY` 항목도 필요합니다. loopback은 자동으로 추가됩니다. CIDR 항목은 해석하지 않으므로 각 LAN 호스트는 따로 적어야 합니다. matcher는 정확한 호스트, 도메인 접미사, 선택적 포트, 괄호로 감싼 IPv6, `*`를 지원합니다. 예를 들면 `192.168.1.50`은 따로 적어야 합니다. 메타데이터와 link-local 목적지는 계속 차단됩니다. 진단 요청은 리디렉션을 거부하고, 자격 증명이 제거된 대상만 보고합니다. 일반적인 공급자 요청의 리디렉션 검토는 이 진단 가드와 별도로 유지됩니다. +Clash / Surge / Mihomo 사용자를 위한 fake-IP DNS 예외는 두 가지이며, 둘 다 DNS *응답*에만 적용됩니다. URL에 적힌 리터럴 주소는 그대로 거부됩니다. IANA 벤치마크 대역 `198.18.0.0/15`(IPv4-mapped IPv6 표기 포함)은 해당 호스트에 아웃바운드 프록시가 적용될 때 허용됩니다. Mihomo 기본 IPv6 fake-IP 대역 `fdfe:dcba:9876::/48`은 더 엄격한 조건에서만 허용됩니다. URL 스킴에 맞는 프록시 변수(`https:`는 `HTTPS_PROXY`, `http:`는 `HTTP_PROXY`, `ALL_PROXY`는 해당 없음)가 설정되어 있어야 하고, 호스트가 `NO_PROXY`에 걸리지 않아야 하며, 그 경우 요청은 해당 프록시에 명시적으로 묶여 나갑니다. 그 밖의 ULA, 인접 프리픽스, 실제 사설 응답과 섞인 fake-IP 응답은 여전히 `allowPrivateNetwork: true`가 필요합니다. 프로바이더 저장 시점 검증에는 IPv6 예외가 적용되지 않습니다. + ## Codex 계정 풀 pool 계정 추가와 quota 갱신은 대시보드의 **Codex Auth** 페이지에서 처리하세요. 설정에는 secret이 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 3f3e286863..93c6fa63f3 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -242,18 +242,22 @@ interrupted package update removed either file, it logs one `installation is inc stops instead of retrying the same missing executable every five seconds. Reinstall opencodex, then run `ocx service repair` to refresh the task with the restored package paths. -On Linux, the systemd unit invokes the first regular, executable `ocx` file found on `PATH` at -install time rather than the Bun and CLI paths inside the installed package tree. Version managers such as +On macOS and Linux, the launchd plist and the systemd unit invoke the first regular, executable +`ocx` file found on `PATH` at install time rather than the Bun and CLI paths inside the installed +package tree. Version managers such as **mise** and **asdf** install into a versioned directory and delete the old one on upgrade, which -used to leave the unit pointing at files that no longer existed — systemd then restart-looped while -still reporting the service as installed. A shim path survives the upgrade, so the unit keeps -resolving. Source checkouts without an `ocx` launcher keep the previous direct Bun + CLI form. A +used to leave the service definition pointing at files that no longer existed — systemd then +restart-looped while still reporting the service as installed, and launchd kept the old build serving +until it was restarted by hand. A shim path survives the upgrade, so the definition keeps resolving. Source checkouts without an `ocx` launcher keep the previous direct Bun + CLI form. A trusted `OPENCODEX_BUN_PATH` selected before Bun starts is preserved through the shim; package-local bundled Bun paths are deliberately rediscovered after upgrades instead of being pinned in the unit. -Units installed before this change still carry the old versioned paths and cannot migrate +Definitions installed before this change still carry the old versioned paths and cannot migrate themselves — once the old executable is deleted, no opencodex code runs to fix it. Run -`ocx service repair` once after upgrading; subsequent version changes need no action. +`ocx service repair` once after upgrading; after that, each service start follows the launcher. +An already-running proxy is not replaced by an external upgrade: restart the service (or run +`ocx service repair`) so the new build serves, and treat a CLI/proxy version mismatch warning as +exactly that signal. | Subcommand | Action | | --- | --- | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 77eacc929b..7d6d4fa1d7 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -351,6 +351,16 @@ destinations stay blocked. Diagnostic requests reject redirects and report a credential-stripped target. Ordinary provider request redirect review remains separate from this diagnostic guard. +Two fake-IP DNS accommodations exist for Clash / Surge / Mihomo users, and both apply to DNS +*answers* only — a literal address in the URL is still rejected. The IANA benchmark range +`198.18.0.0/15` (and its IPv4-mapped IPv6 spellings) is accepted whenever an outbound proxy applies +to the host. Mihomo's default IPv6 fake-IP range `fdfe:dcba:9876::/48` is accepted on a stricter +gate: the proxy variable that matches the URL scheme (`HTTPS_PROXY` for `https:`, `HTTP_PROXY` for +`http:`; `ALL_PROXY` does not count) must be set, the host must not match `NO_PROXY`, and the +request is then bound to that proxy explicitly. Any other ULA, an adjacent prefix, or a fake-IP answer +mixed with a real private answer still requires `allowPrivateNetwork: true`. Provider save-time +validation never applies the IPv6 accommodation. + ## Codex account pool Use **Codex Auth** in the dashboard to add pool accounts and refresh quotas. `config.json` stores 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 7dddd9f09c..26ab107b45 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -162,6 +162,8 @@ domain suffix, необязательные порты, IPv6 в квадратн не следуют redirect'ам и в результатах показывают только credential-stripped target. Проверка redirect'ов для обычных provider-request'ов реализована отдельно и к этому guard не относится. +Для пользователей Clash / Surge / Mihomo предусмотрены два исключения fake-IP DNS, и оба применяются только к DNS-*ответам* — литеральный адрес в URL по-прежнему отклоняется. Диапазон IANA benchmark `198.18.0.0/15` (включая IPv4-mapped IPv6 записи) принимается, когда к хосту применяется исходящий прокси. Диапазон IPv6 fake-IP по умолчанию в Mihomo `fdfe:dcba:9876::/48` принимается при более строгом условии: должна быть задана переменная прокси, соответствующая схеме URL (`HTTPS_PROXY` для `https:`, `HTTP_PROXY` для `http:`; `ALL_PROXY` не учитывается), хост не должен совпадать с `NO_PROXY`, и тогда запрос явно привязывается к этому прокси. Любой другой ULA, соседний префикс или fake-IP ответ вперемешку с реальным приватным ответом по-прежнему требуют `allowPrivateNetwork: true`. Валидация при сохранении провайдера никогда не применяет IPv6-исключение. + ## Пул аккаунтов Codex Используйте страницу **Codex Auth** дашборда для добавления аккаунтов пула и обновления квот. 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 22d5c9056b..217c8e2466 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -171,6 +171,8 @@ hedefleri engellenmiş olarak kalır. Teşhis istekleri yönlendirmeleri reddede kimlik bilgisi kaldırılmış bir hedef bildirir. Sıradan sağlayıcı isteği yeniden yönlendirme incelemesi bu teşhis korumasından ayrı kalır. +Clash / Surge / Mihomo kullanıcıları için iki fake-IP DNS istisnası vardır ve ikisi de yalnızca DNS *yanıtlarına* uygulanır; URL'deki literal adres yine reddedilir. IANA benchmark aralığı `198.18.0.0/15` (IPv4-mapped IPv6 yazımları dahil), ana bilgisayara bir giden proxy uygulandığında kabul edilir. Mihomo'nun varsayılan IPv6 fake-IP aralığı `fdfe:dcba:9876::/48` daha sıkı bir koşulla kabul edilir: URL şemasıyla eşleşen proxy değişkeni (`https:` için `HTTPS_PROXY`, `http:` için `HTTP_PROXY`; `ALL_PROXY` sayılmaz) ayarlı olmalı, ana bilgisayar `NO_PROXY` ile eşleşmemeli ve istek daha sonra açıkça o proxy'ye bağlanır. Diğer tüm ULA'lar, komşu önekler veya gerçek bir özel yanıtla karışık fake-IP yanıtları hâlâ `allowPrivateNetwork: true` gerektirir. Sağlayıcı kaydetme zamanı doğrulaması IPv6 istisnasını hiçbir zaman uygulamaz. + ## Codex hesap havuzu Havuz hesapları eklemek ve kotaları yenilemek için kontrol panelinde **Codex 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 7c4c873ac8..fe3b8cafe5 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,6 +134,8 @@ API key 提供者可以持有字面量 key,或环境引用。OAuth 提供者 私有/本地目标需要 `allowPrivateNetwork: true`,并且在出站代理启用时,还需要匹配的 `NO_PROXY` 条目。回环地址会自动加入;每个 LAN 主机都必须显式列出,因为 CIDR 条目不会被解释。匹配器支持精确主机、域后缀、可选端口、带方括号的 IPv6 以及 `*`;例如,应显式列出 `192.168.1.50`。元数据和链路本地目标仍会被阻止。诊断请求会拒绝重定向,并报告一个已剥离凭据的目标。普通提供者请求的重定向审查仍然独立于这个诊断保护。 +面向 Clash / Surge / Mihomo 用户的 fake-IP DNS 例外有两种,且都只作用于 DNS *应答*——URL 中的字面地址仍会被拒绝。IANA 基准段 `198.18.0.0/15`(含 IPv4-mapped IPv6 写法)在该主机适用出站代理时被接受。Mihomo 默认的 IPv6 fake-IP 段 `fdfe:dcba:9876::/48` 采用更严格的门槛:必须设置与 URL 协议匹配的代理变量(`https:` 对应 `HTTPS_PROXY`,`http:` 对应 `HTTP_PROXY`,`ALL_PROXY` 不算),主机不能命中 `NO_PROXY`,随后请求会被显式绑定到该代理。其他 ULA、相邻前缀,或与真实私网应答混合的 fake-IP 应答仍需要 `allowPrivateNetwork: true`。提供方保存时的校验不应用该 IPv6 例外。 + ## Codex 账户池 请在仪表盘 **Codex Auth** 页面添加 pool account 并刷新 quota。配置只保存非 secret account 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 43001e1ccd..32511b66b0 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 @@ -108,6 +108,8 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 私有/本機目的地需要 `allowPrivateNetwork: true`,且當對外代理活躍時需要相符的 `NO_PROXY` 項目。回送會自動加入;請明確列出每個 LAN 主機,因為 CIDR 項目不被解讀。比對器支援精確主機、網域後綴、可選連接埠、方括號 IPv6 與 `*`;例如,明確列出 `192.168.1.50`。中繼資料與 link-link 目標保持被封鎖。診斷請求拒絕重新導向並回報已剝離憑證的目標。普通供應商請求的重新導向審查與此診斷防護分開。 +針對 Clash / Surge / Mihomo 使用者的 fake-IP DNS 例外有兩種,且都只作用於 DNS *回應*——URL 中的字面位址仍會被拒絕。IANA 基準區段 `198.18.0.0/15`(含 IPv4-mapped IPv6 寫法)在該主機適用對外代理時被接受。Mihomo 預設的 IPv6 fake-IP 區段 `fdfe:dcba:9876::/48` 採更嚴格的門檻:必須設定與 URL 協定相符的代理變數(`https:` 對應 `HTTPS_PROXY`,`http:` 對應 `HTTP_PROXY`,`ALL_PROXY` 不算),主機不得命中 `NO_PROXY`,之後請求會被明確綁定到該代理。其他 ULA、相鄰前綴,或與真實私網回應混合的 fake-IP 回應仍需要 `allowPrivateNetwork: true`。提供者儲存時的驗證不套用此 IPv6 例外。 + ## Codex 帳號池 在儀表板中使用 **Codex Auth** 新增池帳號並重新整理配額。`config.json` 儲存非秘密中繼資料;access 與 refresh token 使用強化的憑證存放。池路由將新/未綁定指派、基於用量的主動切換與失敗復原分開。綁定任務通常保留親和性,但 `quota` 可在其超過用量閾值後的下一個請求時重新綁定它,而暫停、冷卻、重新認證與失敗處理可獨立清除或移動路由。未綁定請求沒有即時帳號綁定;這可包含代理重啟或親和性重置後的既有可見任務。Pre-stream 的 429 或 402 在同一個請求中於一個合格的備用帳號上重試一次,即使基於用量的主動切換關閉。帳號變更保留並重播對話 context,但跨帳號的供應商端 prompt-cache 重用不保證,cache 可能需要重新暖機。 diff --git a/docs/pr-assets/3407-codex-disable-dialog.png b/docs/pr-assets/3407-codex-disable-dialog.png new file mode 100644 index 0000000000..10f1256e09 Binary files /dev/null and b/docs/pr-assets/3407-codex-disable-dialog.png differ diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 408292ad96..2f34948385 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1065,6 +1065,12 @@ export const de: Record = { "integrations.native.msg.desktopEnabled": "Claude-Desktop-Integration aktiviert.", "integrations.detail.grokModels": "{count} Modell(e) verbunden", "integrations.detail.grokAbsent": "Kein opencodex-Block in der Konfiguration", + "integrations.dialog.codex.title": "Codex-Integration deaktivieren?", + "integrations.dialog.codex.changes": "opencodex entfernt seine Weiterleitung aus {path}, entfernt sein generiertes Profil, stellt den nativen Modellkatalog wieder her und kennzeichnet fortsetzbare Threads wieder für natives Codex.", + "integrations.dialog.codex.breakage": "Normales Codex verbindet sich direkt mit OpenAI; Modelle, die von anderen Providern geroutet wurden, verschwinden aus Codex. Proxy und /v1/responses bleiben für andere Clients aktiv.", + "integrations.dialog.codex.undo": "Beim erneuten Aktivieren wird der geroutete Katalog aus den dann verfügbaren Modellen neu erstellt und Codex wieder injiziert. Der Verlauf fortsetzbarer Threads wird in die passende Richtung nutzbar gemacht, aber die Dateien werden nicht Byte für Byte wiederhergestellt.", + "integrations.dialog.codex.sideEffect": "Wenn du nach der Injektion durch opencodex ein geroutetes Root-Modell ausgewählt hast, entfernt das Deaktivieren diese Auswahl; beim erneuten Aktivieren kann sie nicht rekonstruiert werden — wähle das Modell erneut. Wenn ein externer model_provider Codex besitzt, entfernt opencodex nur sein veraltetes Journal und lässt Konfiguration, Katalog und Verlauf unverändert.", + "integrations.dialog.codex.confirm": "Deaktivieren", "integrations.dialog.grok.title": "Grok-Build-Integration deaktivieren?", "integrations.dialog.grok.changes": "Aus {path} wird nur der von opencodex markierte Block entfernt. Manuell geschriebener Inhalt außerhalb des Blocks bleibt unverändert.", "integrations.dialog.grok.breakage": "Nach dem Deaktivieren verschwinden die opencodex-Modellaliase aus Grok Build. Modelle, die mit dem xAI-Konto verwendet wurden, bleiben erhalten.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7532cffcaf..7a3a21b11a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1597,6 +1597,12 @@ export const en = { "integrations.cursor.colReasoning": "Reasoning", "integrations.cursor.colContext": "Context", "integrations.cursor.guide": "Open the Cursor Private Inference guide", + "integrations.dialog.codex.title": "Disable the Codex integration?", + "integrations.dialog.codex.changes": "opencodex will remove its routing from {path}, remove its generated profile, restore the native model catalog, and retag resumable threads for native Codex.", + "integrations.dialog.codex.breakage": "Plain codex will connect directly to OpenAI, and models routed from other providers will disappear from Codex. The proxy and /v1/responses stay running for other clients.", + "integrations.dialog.codex.undo": "Turning this back on rebuilds the routed catalog from the models available then and injects Codex again. Resume history is made usable in the matching direction, but its files are not restored byte for byte.", + "integrations.dialog.codex.sideEffect": "If you selected a routed root model after opencodex injected the config, disabling removes that model selection and turning the integration back on cannot reconstruct it; select the model again. If an external model_provider owns Codex, opencodex removes only its stale journal and leaves the config, catalog, and history unchanged.", + "integrations.dialog.codex.confirm": "Disable", "integrations.dialog.grok.title": "Disable the Grok Build integration?", "integrations.dialog.grok.changes": "Only the block marked by opencodex will be removed from {path}. Content written outside the block will remain unchanged.", "integrations.dialog.grok.breakage": "Disabling removes the opencodex model aliases from Grok Build. Models used with your xAI account remain available.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 51f3440520..e953217ede 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1529,6 +1529,12 @@ export const fr: Record = { "integrations.detail.desktopNotInstalled": "La bibliothèque de configuration de Claude Desktop n’est pas installée", "integrations.detail.grokModels": "{count} modèle(s) câblés", "integrations.detail.grokAbsent": "Aucun bloc opencodex dans la configuration", + "integrations.dialog.codex.title": "Désactiver l’intégration Codex ?", + "integrations.dialog.codex.changes": "opencodex supprimera son routage de {path}, supprimera son profil généré, restaurera le catalogue de modèles natif et réattribuera les fils reprenables à Codex natif.", + "integrations.dialog.codex.breakage": "codex se connectera directement à OpenAI et les modèles routés depuis d’autres fournisseurs disparaîtront de Codex. Le proxy et /v1/responses resteront actifs pour les autres clients.", + "integrations.dialog.codex.undo": "La réactivation reconstruit le catalogue routé avec les modèles alors disponibles et réinjecte Codex. L’historique reprenable redevient utilisable dans la direction correspondante, mais ses fichiers ne sont pas restaurés octet par octet.", + "integrations.dialog.codex.sideEffect": "Si vous avez sélectionné un modèle racine routé après l’injection de la configuration par opencodex, sa désactivation supprime cette sélection et la réactivation ne peut pas la reconstituer ; sélectionnez à nouveau le modèle. Si un model_provider externe possède Codex, opencodex supprime uniquement son journal obsolète et laisse la configuration, le catalogue et l’historique inchangés.", + "integrations.dialog.codex.confirm": "Désactiver", "integrations.dialog.grok.title": "Désactiver l’intégration Grok Build ?", "integrations.dialog.grok.changes": "Seul le bloc marqué par opencodex sera supprimé de {path}. Le contenu écrit en dehors du bloc restera inchangé.", "integrations.dialog.grok.breakage": "La désactivation supprime les alias de modèles opencodex de Grok Build. Les modèles utilisés avec votre compte xAI restent disponibles.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 27f16fa7dd..e0f8e317c3 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1499,6 +1499,12 @@ export const ja: Record = { "integrations.native.msg.desktopEnabled": "Claude Desktop 連携を有効にしました。", "integrations.detail.grokModels": "モデル {count} 個を接続済み", "integrations.detail.grokAbsent": "設定に opencodex ブロックがありません", + "integrations.dialog.codex.title": "Codex 連携を解除しますか?", + "integrations.dialog.codex.changes": "opencodex は {path} から自身のルーティングを削除し、生成したプロファイルを削除し、ネイティブのモデルカタログを復元し、再開可能なスレッドをネイティブ Codex 用に再タグ付けします。", + "integrations.dialog.codex.breakage": "通常の codex は OpenAI に直接接続し、他のプロバイダー経由でルーティングされていたモデルは Codex から消えます。プロキシと /v1/responses は他のクライアント向けに動作し続けます。", + "integrations.dialog.codex.undo": "再び有効にすると、その時点で利用できるモデルからルーティングカタログを再構築し、Codex を再注入します。再開可能な履歴は対応する方向で利用できるようになりますが、ファイルはバイト単位では復元されません。", + "integrations.dialog.codex.sideEffect": "opencodex が設定を注入した後にルートのルーティングモデルを選択していた場合、解除するとその選択も削除され、再有効化しても復元できません。モデルをもう一度選択してください。外部の model_provider が Codex を所有している場合、opencodex は古いジャーナルだけを削除し、設定・カタログ・履歴は変更しません。", + "integrations.dialog.codex.confirm": "解除", "integrations.dialog.grok.title": "Grok Build 連携を解除しますか?", "integrations.dialog.grok.changes": "{path} から、opencodex が印を付けたブロックだけを削除します。ブロック外に直接書いた内容はそのまま残します。", "integrations.dialog.grok.breakage": "解除すると、Grok Build から opencodex のモデルエイリアスが消えます。xAI アカウントで使用していたモデルはそのままです。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 4fd519a64a..ab644d34d4 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1089,6 +1089,12 @@ export const ko: Record = { "integrations.native.msg.desktopEnabled": "Claude Desktop 통합을 켰습니다.", "integrations.detail.grokModels": "모델 {count}개 연결됨", "integrations.detail.grokAbsent": "설정에 opencodex 블록이 없습니다", + "integrations.dialog.codex.title": "Codex 통합을 끌까요?", + "integrations.dialog.codex.changes": "{path}에서 opencodex 라우팅을 제거하고 생성한 프로필을 삭제하며, 기본 Codex 모델 카탈로그를 복원하고, 재개 가능한 스레드에 기본 Codex 태그를 다시 붙입니다.", + "integrations.dialog.codex.breakage": "일반 codex는 OpenAI에 직접 연결되고, 다른 프로바이더로 라우팅되던 모델은 Codex에서 사라집니다. 다른 클라이언트를 위한 프록시와 /v1/responses는 계속 실행됩니다.", + "integrations.dialog.codex.undo": "다시 켜면 당시 사용 가능한 모델로 라우팅 카탈로그를 다시 만들고 Codex를 다시 주입합니다. 재개 기록은 맞는 방향으로 사용할 수 있게 되지만 파일이 바이트 단위로 복원되지는 않습니다.", + "integrations.dialog.codex.sideEffect": "opencodex가 구성을 주입한 뒤 라우팅된 루트 모델을 선택했다면, 해제할 때 그 모델 선택도 제거되며 다시 켜도 복원할 수 없습니다. 모델을 다시 선택하세요. 외부 model_provider가 Codex를 소유하면 opencodex는 오래된 저널만 제거하고 구성, 카탈로그, 기록은 그대로 둡니다.", + "integrations.dialog.codex.confirm": "해제", "integrations.dialog.grok.title": "Grok Build 연동을 해제할까요?", "integrations.dialog.grok.changes": "{path}에서 opencodex가 표시해 둔 블록만 제거합니다. 블록 바깥에 직접 쓴 내용은 그대로 둡니다.", "integrations.dialog.grok.breakage": "해제하면 Grok Build에서 opencodex 모델 별칭이 사라집니다. xAI 계정으로 쓰던 모델은 그대로입니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 46ce2821e0..d1e721ebe6 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1550,6 +1550,12 @@ export const ru: Record = { "integrations.native.msg.desktopEnabled": "Интеграция Claude Desktop включена.", "integrations.detail.grokModels": "Подключено моделей: {count}", "integrations.detail.grokAbsent": "В конфигурации нет блока opencodex", + "integrations.dialog.codex.title": "Отключить интеграцию Codex?", + "integrations.dialog.codex.changes": "opencodex удалит свою маршрутизацию из {path}, удалит созданный профиль, восстановит нативный каталог моделей и снова пометит возобновляемые треды для нативного Codex.", + "integrations.dialog.codex.breakage": "Обычный codex подключится напрямую к OpenAI, а модели, маршрутизируемые через других провайдеров, исчезнут из Codex. Прокси и /v1/responses продолжат работать для других клиентов.", + "integrations.dialog.codex.undo": "При повторном включении каталог маршрутизации будет собран из доступных на тот момент моделей, а Codex будет внедрён снова. История возобновляемых тредов станет пригодной в соответствующем направлении, но файлы не будут восстановлены побайтно.", + "integrations.dialog.codex.sideEffect": "Если после внедрения конфигурации opencodex вы выбрали корневую маршрутизируемую модель, отключение удалит этот выбор, и повторное включение не сможет его восстановить; выберите модель снова. Если внешний model_provider владеет Codex, opencodex удалит только устаревший журнал, оставив конфигурацию, каталог и историю без изменений.", + "integrations.dialog.codex.confirm": "Отключить", "integrations.dialog.grok.title": "Отключить интеграцию Grok Build?", "integrations.dialog.grok.changes": "Из {path} будет удалён только блок, отмеченный opencodex. Содержимое, добавленное вручную вне блока, останется без изменений.", "integrations.dialog.grok.breakage": "После отключения псевдонимы моделей opencodex исчезнут из Grok Build. Модели, использовавшиеся с учётной записью xAI, останутся доступны.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index e3c22531c6..5a39f0c3c3 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1547,6 +1547,12 @@ export const tr: Record = { "integrations.detail.desktopNotInstalled": "Claude Desktop kütüphanesi yüklü değil", "integrations.detail.grokModels": "{count} model bağlandı", "integrations.detail.grokAbsent": "Konfigürasyonda opencodex bloğu yok", + "integrations.dialog.codex.title": "Codex entegrasyonu devre dışı bırakılsın mı?", + "integrations.dialog.codex.changes": "opencodex {path} içindeki yönlendirmesini kaldıracak, oluşturduğu profili silecek, yerel model kataloğunu geri yükleyecek ve sürdürülebilir iş parçacıklarını yerel Codex için yeniden etiketleyecek.", + "integrations.dialog.codex.breakage": "Plain codex doğrudan OpenAI'ye bağlanacak ve diğer sağlayıcılardan yönlendirilen modeller Codex'ten kaybolacak. Proxy ve /v1/responses diğer istemciler için çalışmaya devam edecek.", + "integrations.dialog.codex.undo": "Yeniden açmak, o sırada kullanılabilen modellerden yönlendirilmiş kataloğu yeniden oluşturur ve Codex'i tekrar enjekte eder. Sürdürülebilir geçmiş uygun yönde kullanılabilir olur, ancak dosyaları bayt bayt geri yüklenmez.", + "integrations.dialog.codex.sideEffect": "opencodex yapılandırmayı enjekte ettikten sonra yönlendirilmiş bir kök model seçtiyseniz, devre dışı bırakmak bu seçimi kaldırır ve yeniden açmak onu yeniden oluşturamaz; modeli tekrar seçin. Harici bir model_provider Codex'in sahibiyse opencodex yalnızca eski günlüğünü kaldırır, yapılandırmayı, kataloğu ve geçmişi değiştirmez.", + "integrations.dialog.codex.confirm": "Devre Dışı Bırak", "integrations.dialog.grok.title": "Grok Build entegrasyonu devre dışı bırakılsın mı?", "integrations.dialog.grok.changes": "Yalnızca {path} dosyasında opencodex tarafından işaretlenen blok kaldırılacaktır. Blok dışında yazılan içerik değişmeden kalır.", "integrations.dialog.grok.breakage": "Devre dışı bırakmak, opencodex model takma adlarını Grok Build'den kaldırır. xAI hesabınızla kullanılan modeller kullanılabilir kalır.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index c23c74e6e3..94101c189e 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2121,6 +2121,12 @@ export const zhTW: Record = { "integrations.detail.desktopNotInstalled": "未安裝 Claude Desktop 設定程式庫", "integrations.detail.grokModels": "已接入 {count} 個模型", "integrations.detail.grokAbsent": "設定中沒有 opencodex 區塊", + "integrations.dialog.codex.title": "要停用 Codex 整合嗎?", + "integrations.dialog.codex.changes": "opencodex 將從 {path} 移除路由、刪除其產生的設定、還原原生模型目錄,並將可恢復的執行緒重新標記為原生 Codex。", + "integrations.dialog.codex.breakage": "一般 codex 將直接連線至 OpenAI,其他供應商路由的模型將從 Codex 消失。代理與 /v1/responses 仍會繼續為其他用戶端執行。", + "integrations.dialog.codex.undo": "再次啟用後,會根據當時可用的模型重建路由目錄並重新注入 Codex。可恢復歷史會在對應方向重新可用,但檔案不會逐位元組還原。", + "integrations.dialog.codex.sideEffect": "如果你在 opencodex 注入設定後選取了路由根模型,停用會移除該模型選擇,再次啟用也無法重建;請重新選取模型。如果外部 model_provider 擁有 Codex,opencodex 只會移除過時的日誌,設定、目錄與歷史保持不變。", + "integrations.dialog.codex.confirm": "停用", "integrations.dialog.grok.title": "要停用 Grok Build 整合嗎?", "integrations.dialog.grok.changes": "只會從 {path} 移除由 opencodex 標記的區塊。區塊之外寫入的內容將保持不變。", "integrations.dialog.grok.breakage": "停用後,Grok Build 中的 opencodex 模型別名將消失。透過 xAI 帳號使用的模型不受影響。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 1a1f71513f..5626b46abd 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1082,6 +1082,12 @@ export const zh: Record = { "integrations.native.msg.desktopEnabled": "Claude Desktop 集成已开启。", "integrations.detail.grokModels": "已接入 {count} 个模型", "integrations.detail.grokAbsent": "配置中没有 opencodex 区块", + "integrations.dialog.codex.title": "要停用 Codex 集成吗?", + "integrations.dialog.codex.changes": "opencodex 将从 {path} 中移除路由,删除其生成的配置,恢复原生模型目录,并将可恢复的线程重新标记为原生 Codex。", + "integrations.dialog.codex.breakage": "普通 codex 将直接连接 OpenAI,其他提供商路由的模型将从 Codex 中消失。代理和 /v1/responses 仍会继续为其他客户端运行。", + "integrations.dialog.codex.undo": "再次启用后,将根据当时可用的模型重建路由目录并重新注入 Codex。可恢复历史会按对应方向变得可用,但其文件不会逐字节恢复。", + "integrations.dialog.codex.sideEffect": "如果你在 opencodex 注入配置后选择了路由根模型,停用会移除该模型选择,再次启用也无法重建它;请重新选择模型。如果外部 model_provider 拥有 Codex,opencodex 只会删除其过时日志,配置、目录和历史记录保持不变。", + "integrations.dialog.codex.confirm": "停用", "integrations.dialog.grok.title": "要停用 Grok Build 集成吗?", "integrations.dialog.grok.changes": "只会从 {path} 中删除由 opencodex 标记的区块。区块之外手动写入的内容将保持不变。", "integrations.dialog.grok.breakage": "停用后,Grok Build 中的 opencodex 模型别名将消失。通过 xAI 账号使用的模型不受影响。", diff --git a/gui/src/pages/integrations/IntegrationsOverview.tsx b/gui/src/pages/integrations/IntegrationsOverview.tsx index 9a22456ef8..70897d5f0e 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -48,6 +48,15 @@ const GROK_DISABLE_COPY: ConsequenceCopy = { confirmKey: "integrations.dialog.grok.confirm", }; +const CODEX_DISABLE_COPY: ConsequenceCopy = { + titleKey: "integrations.dialog.codex.title", + changesKey: "integrations.dialog.codex.changes", + breakageKey: "integrations.dialog.codex.breakage", + undoKey: "integrations.dialog.codex.undo", + sideEffectKey: "integrations.dialog.codex.sideEffect", + confirmKey: "integrations.dialog.codex.confirm", +}; + const DESKTOP_DISABLE_COPY: ConsequenceCopy = { titleKey: "integrations.dialog.desktop.title", changesKey: "integrations.dialog.desktop.changes", @@ -94,7 +103,7 @@ function OverviewCard({ const detail = row.detail ?? (row.detailKey ? t(row.detailKey, row.detailVars ?? undefined) : null); const toggleBlocked = row.toggleBlocked !== null && (row.applied || row.toggleBlocked.reason === "orphaned_marker"); - const blockedText = toggleBlocked && row.toggleBlocked && (row.toggle === "claude" || row.toggle === "grok") + const blockedText = toggleBlocked && row.toggleBlocked && (row.toggle === "claude" || row.toggle === "grok" || row.toggle === "codex") ? describeRefusal(t, new NativeApiError(409, { error: "native integration change refused", code: "native_integration_refused", @@ -103,6 +112,7 @@ function OverviewCard({ message: row.toggleBlocked.message, }), undefined, row.togglePath ?? undefined) : null; + const toggleOn = row.toggleOn ?? row.applied; return (
  • @@ -133,7 +143,7 @@ function OverviewCard({ {row.toggle && onToggle && (
    @@ -431,6 +441,7 @@ export default function IntegrationsOverview({ const refreshNativeDetails = () => { nativeResource.refresh(); + codexResource.refresh(); claudeResource.refresh(); grokResource.refresh(); }; @@ -483,7 +494,7 @@ export default function IntegrationsOverview({ void toggleCard(row, next); return; } - // Grok and Desktop disables edit another program's file. + // Codex, Grok, and Desktop disables edit another program's file. const activeElement = document.activeElement; restoreFocusRef.current = activeElement?.tagName === "BUTTON" ? activeElement as HTMLButtonElement @@ -697,7 +708,14 @@ export default function IntegrationsOverview({ )} {pendingToggle && ( setPendingToggle(null)} onConfirm={async () => { await toggleCard(pendingToggle, false); diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index df456e438a..4dd347b90c 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -182,33 +182,72 @@ export function isAppliedState(state: VisualIntegrationState): boolean { /** * Codex CLI. * + * The native status owns the desired switch state, install detection, and the + * real Codex config path. The startup-health payload owns observed routing: * `routingInjected` — server-derived as `routingKind === "opencodex-local"` — - * is the only field that answers "is opencodex in Codex's path right now". - * `status` mixes in service viability and reboot safety, which is the Startup - * page's question, so a `protected` status with no injected routing still - * reads as not applied here. + * answers whether opencodex is in Codex's path right now. Keeping those facts + * separate lets the card show a disabled switch while the observed state still + * reports what Codex is actually using. */ -function codexRow(payload: CodexRoutingPayload | null): OverviewRow { +function codexRow( + payload: CodexRoutingPayload | null, + native: NativeStatus | undefined, + nativeSettled: boolean | undefined, +): OverviewRow { const base = { id: "codex" as const, hash: "integrations/codex", labelKey: "integrations.tab.codex" as TKey, toggle: "codex" as const, - toggleBlocked: null, - togglePath: null, + toggleBlocked: native?.disableBlocked ?? null, + togglePath: native?.configPath ?? null, status: null, detail: null, detailVars: null, }; - if (!payload) return { ...base, state: "unknown", installed: false, applied: false, detailKey: null }; - // The proxy answering at all means Codex CLI is present: it is the client - // this product exists for, and there is no separate detection probe. + + // Compatibility for callers written before native status joined the + // overview. The live page always passes nativeSettled explicitly. + if (nativeSettled === undefined) { + if (!payload) return { ...base, state: "unknown", installed: false, applied: false, detailKey: null }; + if (payload.routingInjected !== true) { + return { + ...base, + state: "absent", + installed: true, + applied: false, + detail: payload.recommendedCommand ?? null, + detailKey: payload.recommendedCommand ? null : "integrations.detail.codexAbsent", + }; + } + return { + ...base, + state: payload.status === "error" ? "stale" : "current", + installed: true, + applied: true, + detailKey: "integrations.detail.codexRouted", + }; + } + + if (!nativeSettled) { + return { ...base, state: "unknown", installed: false, applied: false, detailKey: null }; + } + if (!native) { + return { ...base, toggle: null, state: "unknown", installed: false, applied: false, detailKey: null }; + } + + const toggleOn = native.desiredEnabled; + if (!payload) { + return { ...base, state: "unknown", installed: native.installed, applied: false, toggleOn, detailKey: null }; + } + if (payload.routingInjected !== true) { return { ...base, state: "absent", - installed: true, + installed: native.installed, applied: false, + toggleOn, // The command that would fix it beats a restatement of the badge. detail: payload.recommendedCommand ?? null, detailKey: payload.recommendedCommand ? null : "integrations.detail.codexAbsent", @@ -217,8 +256,9 @@ function codexRow(payload: CodexRoutingPayload | null): OverviewRow { return { ...base, state: payload.status === "error" ? "stale" : "current", - installed: true, + installed: native.installed, applied: true, + toggleOn, detailKey: "integrations.detail.codexRouted", }; } @@ -493,12 +533,13 @@ function fileRow(status: IntegrationStatus): OverviewRow { * strip above the grid, so the eye moves the same way in both. */ export function buildOverviewRows(sources: OverviewSources): OverviewRows { + const nativeCodex = sources.native?.find(status => status.clientId === "codex"); const nativeClaude = sources.native?.find(status => status.clientId === "claude"); const nativeGrok = sources.native?.find(status => status.clientId === "grok"); // One lookup table, not a find per client (react-doctor js-index-maps). const statusByClient = new Map(sources.clients.map(status => [status.clientId, status])); const rows: OverviewRow[] = [ - codexRow(sources.codex), + codexRow(sources.codex, nativeCodex, sources.nativeSettled), claudeRow(sources.claude, nativeClaude, sources.nativeSettled), claudeDesktopRow( sources.claudeDesktop, diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index 5fe85be752..5bd673f849 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -5,6 +5,7 @@ import { type OverviewSources, } from "../src/pages/integrations/overview-clients"; import type { IntegrationStatus } from "../src/pages/integrations/integration-api"; +import type { NativeStatus } from "../src/pages/integrations/native-api"; /** * The overview's whole job is to not lie about what is applied, so these tests @@ -25,6 +26,18 @@ function fileStatus(overrides: Partial = {}): IntegrationStat }; } +function codexNative(overrides: Partial = {}): NativeStatus { + return { + clientId: "codex", + state: "current", + installed: true, + configPath: "/tmp/codex/config.toml", + desiredEnabled: true, + disableBlocked: null, + ...overrides, + }; +} + function sources(overrides: Partial = {}): OverviewSources { return { clients: [], @@ -64,23 +77,61 @@ test("Codex reads routingInjected, not status", () => { // `protected` is about surviving a reboot. With no injected routing the // proxy is not in Codex's path, and the card must say so. const notInjected = buildOverviewRows( - sources({ codex: { routingInjected: false, status: "protected" } }), + sources({ codex: { routingInjected: false, status: "protected" }, native: [codexNative()] }), ); expect(rowById(notInjected, "codex").state).toBe("absent"); expect(rowById(notInjected, "codex").applied).toBe(false); const injected = buildOverviewRows( - sources({ codex: { routingInjected: true, status: "at-risk" } }), + sources({ codex: { routingInjected: true, status: "at-risk" }, native: [codexNative()] }), ); expect(rowById(injected, "codex").state).toBe("current"); expect(rowById(injected, "codex").applied).toBe(true); const broken = buildOverviewRows( - sources({ codex: { routingInjected: true, status: "error" } }), + sources({ codex: { routingInjected: true, status: "error" }, native: [codexNative()] }), ); expect(rowById(broken, "codex").state).toBe("stale"); }); +test("Codex keeps desired switch state separate from observed routing", () => { + const cleanupPending = buildOverviewRows(sources({ + codex: { routingInjected: true, status: "native" }, + native: [{ + clientId: "codex", + state: "absent", + installed: true, + configPath: "/live/codex/config.toml", + desiredEnabled: false, + disableBlocked: null, + }], + })); + expect(rowById(cleanupPending, "codex")).toMatchObject({ + state: "current", + applied: true, + installed: true, + toggleOn: false, + togglePath: "/live/codex/config.toml", + }); + + const disabled = buildOverviewRows(sources({ + codex: { routingInjected: false, status: "native" }, + native: [{ + clientId: "codex", + state: "absent", + installed: true, + configPath: "/live/codex/config.toml", + desiredEnabled: false, + disableBlocked: null, + }], + })); + expect(rowById(disabled, "codex")).toMatchObject({ + state: "absent", + applied: false, + toggleOn: false, + }); +}); + test("Claude Desktop: applied but not the served profile reads as stale", () => { const desktopNative = [{ clientId: "claude-desktop" as const, @@ -191,6 +242,13 @@ test("every client counts toward the summary, not just the file clients", () => claude: { enabled: true }, claudeDesktop: { desiredEnabled: true, installed: true, applied: true, stale: true, activeProfile: true }, native: [{ + clientId: "codex", + state: "current", + installed: true, + configPath: "/tmp/codex/config.toml", + desiredEnabled: true, + disableBlocked: null, + }, { clientId: "claude-desktop", state: "current", installed: true, diff --git a/gui/tests/integrations-surfaces.test.tsx b/gui/tests/integrations-surfaces.test.tsx index 642484a07b..7222d371f9 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -54,6 +54,8 @@ type JournalRow = { let stateResponse: () => Response; let journalRows: JournalRow[]; let putResponse: () => Response; +let codexRoutingResponse: () => Response; +let codexDesiredEnabled = true; let deleteResponse: () => Response; /** * The overview also reads Codex routing, API keys, Claude Code, Claude Desktop @@ -103,6 +105,8 @@ beforeEach(() => { apiBase = `http://ocx-test-${mountCount}.invalid`; stateResponse = () => json(status()); putResponse = () => json({ ok: true, clientId: "hermes", changed: true, state: "absent", message: "disabled" }); + codexRoutingResponse = () => json({ routingInjected: false, status: "native", recommendedCommand: null }); + codexDesiredEnabled = true; deleteResponse = () => json({ ok: true, clientId: "hermes", opId: "op-old", snapshotRemoved: true }); failExtraSources = false; @@ -119,7 +123,7 @@ beforeEach(() => { if (url.includes("/api/startup-health")) { return failExtraSources ? json({ error: "nope" }, 500) - : json({ routingInjected: false, status: "native", recommendedCommand: null }); + : codexRoutingResponse(); } if (url.includes("/api/keys")) { return failExtraSources ? json({ error: "nope" }, 500) : json({ keys: [] }); @@ -129,15 +133,41 @@ beforeEach(() => { ? json({ error: "nope" }, 500) : json({ desiredEnabled: true, installed: true, observedKind: "standard", applied: false, stale: false, activeProfile: null, appliedAt: null }); } - if (url.includes("/api/native-integrations")) { - return json({ clients: [{ - clientId: "claude-desktop", - state: "absent", - installed: true, - configPath: "/tmp/desktop", - desiredEnabled: true, - disableBlocked: null, - }] }); + if (method === "PUT" && url.endsWith("/api/native-integrations/codex")) { + const body = init?.body ? JSON.parse(String(init.body)) as { enabled?: unknown } : {}; + codexDesiredEnabled = body.enabled === true; + codexRoutingResponse = () => json({ + routingInjected: codexDesiredEnabled, + status: "native", + recommendedCommand: null, + }); + return json({ + ok: true, + clientId: "codex", + changed: true, + state: codexDesiredEnabled ? "current" : "absent", + message: codexDesiredEnabled ? "enabled" : "disabled", + desiredEnabled: codexDesiredEnabled, + }); + } + if (method === "GET" && url.includes("/api/native-integrations")) { + return failExtraSources + ? json({ error: "nope" }, 500) + : json({ clients: [{ + clientId: "codex", + state: codexDesiredEnabled ? "current" : "absent", + installed: true, + configPath: "/tmp/codex/config.toml", + desiredEnabled: codexDesiredEnabled, + disableBlocked: null, + }, { + clientId: "claude-desktop", + state: "absent", + installed: true, + configPath: "/tmp/desktop", + desiredEnabled: true, + disableBlocked: null, + }] }); } if (url.includes("/api/claude-code")) { return failExtraSources ? json({ error: "nope" }, 500) : json({ enabled: false }); @@ -940,6 +970,40 @@ test("every reachable client gets a card, not just the file six", async () => { expect(testWindow.location.hash).toBe("#integrations/claude/desktop"); }); +test("Codex disable uses Codex consequences and refreshes observed routing", async () => { + codexRoutingResponse = () => json({ routingInjected: true, status: "native", recommendedCommand: null }); + await mountOverview(); + + const sw = switchFor("codex"); + expect(sw?.getAttribute("aria-pressed")).toBe("true"); + await act(async () => { sw!.click(); }); + + // Opening the consequence gate must not mutate anything, and it must name + // the Codex file and the effects of restoring native Codex. + expect(requests.some(request => request.method === "PUT")).toBe(false); + const dialog = container.querySelector(".integration-consequence-dialog")!; + expect(dialog.textContent).toContain("Disable the Codex integration?"); + expect(dialog.textContent).toContain("/tmp/codex/config.toml"); + expect(dialog.textContent).toContain("/v1/responses"); + expect(dialog.textContent).not.toContain("Grok Build"); + + const confirm = Array.from(dialog.querySelectorAll("button")).find( + button => (button.textContent ?? "").trim() === "Disable", + ) as HTMLButtonElement; + await act(async () => { confirm.click(); }); + await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 50)); }); + + const put = requests.find(request => request.method === "PUT"); + expect(put?.url).toContain("/api/native-integrations/codex"); + expect(put?.body).toEqual({ enabled: false }); + // The mock changes startup-health only after the mutation. This assertion + // therefore proves the Codex observed resource, not merely the native toggle, + // was refreshed. + expect(switchFor("codex")?.getAttribute("aria-pressed")).toBe("false"); + expect(container.querySelector(".integration-card[data-client='codex'] .badge") + ?.getAttribute("data-integration-state")).toBe("absent"); +}); + test("a source that cannot be read is unknown, never 'not applied'", async () => { /* * The five extra reads settle independently. Painting a failed one as diff --git a/gui/tests/overview-state-merge.test.ts b/gui/tests/overview-state-merge.test.ts index e1697fa42b..e6333741e6 100644 --- a/gui/tests/overview-state-merge.test.ts +++ b/gui/tests/overview-state-merge.test.ts @@ -9,6 +9,7 @@ function nativeStatus(clientId: NativeStatus["clientId"], overrides: Partial hextets[index] === group); +} + function firstIpv6Hextet(hostname: string): number | null { const head = hostname.split(":")[0]; if (!head) return 0; @@ -385,7 +404,13 @@ export function assessUrlDestination(url: string): UrlDestinationAssessment | nu */ export async function resolvePublicAddresses( url: string, - options?: string | { context?: string; allowPrivateNetwork?: boolean; allowBenchmarkAddresses?: boolean }, + options?: string | { + context?: string; + allowPrivateNetwork?: boolean; + allowBenchmarkAddresses?: boolean; + /** Mihomo IPv6 fake-IP (`fdfe:dcba:9876::/48`) DNS answers; see `isMihomoIpv6FakeIpAnswer`. */ + allowMihomoIpv6FakeIp?: boolean; + }, ): Promise<{ hostname: string; addresses: { address: string; family: number }[]; @@ -396,6 +421,7 @@ export async function resolvePublicAddresses( : options?.context?.trim() || "image URL"; const privateNetworkAllowed = typeof options === "object" && options?.allowPrivateNetwork === true; const benchmarkAllowed = typeof options === "object" && options?.allowBenchmarkAddresses === true; + const mihomoIpv6Allowed = typeof options === "object" && options?.allowMihomoIpv6FakeIp === true; let hostname: string; try { hostname = normalizeHostname(new URL(url.trim()).hostname); @@ -440,7 +466,10 @@ export async function resolvePublicAddresses( // fake-IP DNS, not a LAN provider. Accept it without allowPrivateNetwork and // do not mark the destination private, so the caller's HTTP(S)_PROXY path // still applies (credit #1748). - if (benchmarkAllowed && isBenchmarkDnsAnswer(address, assessment)) { + if ( + (benchmarkAllowed && isBenchmarkDnsAnswer(address, assessment)) + || (mihomoIpv6Allowed && isMihomoIpv6FakeIpAnswer(address, assessment)) + ) { validatedAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) }); continue; } diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index f067aa927d..bfbce5f508 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -7,7 +7,7 @@ import { resolvePublicAddresses, } from "./destination-policy"; import { pinnedHttpGet, pinnedHttpPost } from "./pinned-http"; -import { outboundProxyConfigured } from "./proxy-env"; +import { effectiveProxyFor, outboundProxyConfigured } from "./proxy-env"; import { publicProviderBaseUrl } from "./provider-url"; type ProviderGetInit = Omit; @@ -139,6 +139,11 @@ async function providerOutboundRequest( } const parsed = postUrl ?? new URL(url); const proxyConfigured = configuredProxyFor(); + // Snapshot the scheme-matched proxy once, before the DNS await, so admission and transport + // below reason about the same value. `null` here means "no proxy fetch would actually use", + // even if some other proxy variable is set. + const effectiveProxy = effectiveProxyFor(parsed); + const allowMihomoIpv6FakeIp = effectiveProxy !== null && !noProxyMatches(parsed); const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses; const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet; const pinnedPost = dependencies.pinnedPost ?? pinnedHttpPost; @@ -155,6 +160,12 @@ async function providerOutboundRequest( // match is a direct route, so it keeps the benchmark answer rejected. Image/Lab // fetch never passes this flag. allowBenchmarkAddresses: proxyConfigured && !noProxyMatches(parsed), + // Mihomo IPv6 fake-IP (fdfe:dcba:9876::/48) answers are admitted on a stricter gate + // than the benchmark range: the proxy must be the one fetch will use for this URL's + // scheme, and the request below is then bound to it explicitly (#3462). A ULA answer + // is otherwise indistinguishable from a real private host, so proxy presence alone + // is not enough. + allowMihomoIpv6FakeIp, }); } catch (error) { const dnsResolutionFailed = error instanceof DestinationDnsResolutionError @@ -169,7 +180,10 @@ async function providerOutboundRequest( } if (proxyConfigured && !resolved.privateNetwork) { warnProxyBoundaryOnce(); - return globalThis.fetch(url, { ...init, method, redirect: "manual" }); + // When the Mihomo exception could have admitted an answer, pin the transport to the + // proxy the admission assumed instead of letting fetch re-infer it from the environment. + const proxy = allowMihomoIpv6FakeIp ? effectiveProxy : undefined; + return globalThis.fetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) }); } if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) { const hostname = normalizeProxyHostname(parsed.hostname); diff --git a/src/lib/proxy-env.ts b/src/lib/proxy-env.ts index d34688ab95..46df592689 100644 --- a/src/lib/proxy-env.ts +++ b/src/lib/proxy-env.ts @@ -16,3 +16,25 @@ export function outboundProxyConfigured( ): boolean { return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env)); } + +/** + * The proxy URL that Bun's fetch will actually use for `url`, or null when none applies. + * + * Bun selects by scheme: `HTTPS_PROXY` for `https:` targets, `HTTP_PROXY` for `http:`. + * `ALL_PROXY` is deliberately not consulted here — fetch does not honour it, so a caller + * that needs "this request will ride the proxy" as a precondition must not count it. + * Presence of *some* proxy variable (`outboundProxyConfigured`) is not that guarantee. + */ +export function effectiveProxyFor( + url: URL, + env: ProxyEnvMap = process.env, +): string | null { + const key: ProxyEnvKey | null = url.protocol === "https:" + ? "HTTPS_PROXY" + : url.protocol === "http:" + ? "HTTP_PROXY" + : null; + if (!key) return null; + const value = env[key]?.trim() || env[key.toLowerCase()]?.trim(); + return value ? value : null; +} diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index af56e8866c..e63001c980 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -17,9 +17,11 @@ * Design of record: devlog/_fin/260803_integrations_toggle_all/030 (routes), * 011 (Claude Code), 012 (Grok). */ +import { join } from "node:path"; import { loadConfig, saveConfigPreservingClaudeCode } from "../../config"; import { readRuntimePort } from "../../config/process-state"; import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits } from "../../codex/catalog"; +import { getCodexHome } from "../../codex/paths"; import { providerContextCap } from "../../providers/context-cap"; import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; @@ -682,8 +684,9 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro if (url.pathname === "/api/native-integrations" && req.method === "GET") { const { getConfigPath } = await import("../../config"); + const codexConfigPath = join(getCodexHome(), "config.toml"); return jsonResponse({ - clients: [claudeStatus(config, getConfigPath()), grokStatus(config), codexStatus(config, getConfigPath()), desktopStatus(config)], + clients: [claudeStatus(config, getConfigPath()), grokStatus(config), codexStatus(config, codexConfigPath), desktopStatus(config)], } satisfies NativeStatusListEnvelope); } diff --git a/src/service.ts b/src/service.ts index 84b7da3817..b37e88c3db 100644 --- a/src/service.ts +++ b/src/service.ts @@ -205,9 +205,9 @@ export interface ServiceInstallState { bunPath?: string; cliPath?: string; /** - * Linux only. The stable `ocx` launcher the unit actually invokes, when one was found. - * Present means `bunPath`/`cliPath` are provenance for the install, NOT what systemd - * runs — so staleness must be judged against THIS path instead. A version-manager + * launchd and systemd. The stable `ocx` launcher the service definition actually invokes, + * when one was found. Present means `bunPath`/`cliPath` are provenance for the install, + * NOT what the service runs — so staleness must be judged against THIS path instead. A version-manager * upgrade replaces the directory those two point into while the launcher survives, and * checking the old pair would report a stale service that is in fact healthy. */ @@ -486,8 +486,21 @@ function writeServiceApiTokenFile(): string | null { return path; } -export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string { - const { bun, bunRuntimeSource, cli } = cliEntry(); +/** + * Render the launchd plist. Mirrors `buildUnit`: when `deps.launcher` names a stable `ocx` + * executable, the job execs that launcher instead of the package-local Bun + CLI pair, so a + * version-manager upgrade (mise, asdf, nvm) that replaces the package directory is picked up + * on the next launchd start instead of leaving the old build serving (#3464 — the macOS + * counterpart of #2898). Discovery belongs to `installLaunchd()`; the default here is the + * legacy pair so callers and tests stay hermetic. + */ +export function buildPlist( + proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(), + deps: { launcher?: string | null; runtime?: DurableBunRuntime } = {}, +): string { + const runtime = deps.runtime ?? durableBunRuntime(); + const { bun, bunRuntimeSource, cli } = cliEntry(runtime); + const launcher = deps.launcher ?? null; const log = logPath(); const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; const codexHome = process.env.CODEX_HOME?.trim(); @@ -495,8 +508,16 @@ export function buildPlist(proxyEnv: { name: string; value: string }[] = resolve const opencodexHome = process.env.OPENCODEX_HOME?.trim(); const envLines = [ ` OCX_SERVICE1`, - ` ${BUN_RUNTIME_SOURCE_ENV}${bunRuntimeSource}`, - ` ${BUN_RUNTIME_PATH_ENV}${plistString(bun)}`, + ...(launcher ? [] : [ + ` ${BUN_RUNTIME_SOURCE_ENV}${bunRuntimeSource}`, + ` ${BUN_RUNTIME_PATH_ENV}${plistString(bun)}`, + ]), + // A launcher resolves the current package's bundled Bun after every upgrade. Preserve + // only a proof-bound shell override; baking a package-local path here would recreate + // the version-manager pin that launcher mode exists to remove (same rule as buildUnit). + launcher && runtime.source === "override" + ? ` ${runtime.overrideEnv}${plistString(runtime.path)}` + : null, ` PATH${plistString(path)}`, codexHome ? ` CODEX_HOME${plistString(codexHome)}` : null, codexSqliteHome ? ` CODEX_SQLITE_HOME${plistString(codexSqliteHome)}` : null, @@ -504,7 +525,9 @@ export function buildPlist(proxyEnv: { name: string; value: string }[] = resolve ...proxyEnv.map(({ name, value }) => ` ${name}${plistString(value)}`), ].filter((line): line is string => Boolean(line)).join("\n"); - const command = buildServiceShellCommand(bun, cli); + const command = launcher + ? buildServiceLauncherShellCommand(launcher) + : buildServiceShellCommand(bun, cli); return ` @@ -569,6 +592,23 @@ function buildServiceLauncherShellCommand(launcher: string, port = resolveServic return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(launcher)} start --port ${port}`; } +/** + * The exec line the installed launchd plist is expected to carry, derived from the recorded + * install state rather than rediscovered: a launcher install runs the launcher, a legacy or + * stateless install runs the Bun + CLI pair. `start` and `status` compare the live job + * against this, so both must follow the launcher or a healthy launcher-backed job reads as + * "an OLDER plist" (#3464). PATH is deliberately NOT re-walked here. + */ +export function expectedLaunchdCommand( + port: number, + deps: { state?: ServiceInstallState | null; entry?: { bun: string; cli: string } } = {}, +): string { + const state = deps.state === undefined ? readServiceInstallState() : deps.state; + if (state?.launcherPath) return buildServiceLauncherShellCommand(state.launcherPath, port); + const entry = deps.entry ?? cliEntry(); + return buildServiceShellCommand(entry.bun, entry.cli, port); +} + /** * The `--port ` actually baked into the installed launchd plist, or null when it * cannot be read. macOS only — named for launchd rather than "service" so no caller @@ -2251,7 +2291,10 @@ function installLaunchd(): void { // Capture this BEFORE writing: the write below makes the plist exist unconditionally, // so a post-write existsSync would call every fresh install an "installed" service. const wasInstalled = existsSync(p); - writeServiceDefinitionFile(p, buildPlist(), "utf8"); + // Resolve the launcher ONCE and hand the same value to the plist and to install state, + // so the staleness diagnostic judges exactly what launchd runs. + const launcher = stableLauncherEntry(); + writeServiceDefinitionFile(p, buildPlist(resolvedProxyEnv(), { launcher }), "utf8"); // Best-effort: an absent job is fine here, and a failed unload is caught by the // load verification below with a better message than a raw unload error. runLaunchctl(["unload", p]); @@ -2268,7 +2311,7 @@ function installLaunchd(): void { + `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`, ); } - writeServiceInstallState(); + writeServiceInstallState("scheduler", launcher); } /** * Deps are named for the layer they replace, not for the process API: `launchctl` @@ -2291,9 +2334,8 @@ export function startLaunchd(deps: { // already be bootstrapped from THIS plist, which is a no-op rather than an error. // `install` can assume a stale job (it just rewrote the plist); `start` cannot, and // throwing here would break `ocx service start` on every healthy service. - const entry = cliEntry(); const live = (deps.matches ?? launchdJobMatchesPlist)( - buildServiceShellCommand(entry.bun, entry.cli), + expectedLaunchdCommand(installedServiceListenPort()), ); if (live.loaded && live.matchesPlist) { console.log("ℹ️ service was already loaded from the current plist; nothing to do."); @@ -4234,14 +4276,11 @@ export async function serviceStatusReport( // Linux/Windows and make the stale-plist case untestable there. const stalePlist = deps.matchesPlist?.() ?? (process.platform === "darwin" ? (() => { - const entry = cliEntry(); // Pass the INSTALLED port explicitly: the default third argument is // resolveServiceListenPort(), which reads OCX_BAKE_PORT/config.port, so after // a config edit the expected string would never match and every run would // print a false "OLDER plist". - return launchdJobMatchesPlist( - buildServiceShellCommand(entry.bun, entry.cli, installedServiceListenPort()), - ); + return launchdJobMatchesPlist(expectedLaunchdCommand(installedServiceListenPort())); })() : null); const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index e1068b68ae..e363776e75 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -33,19 +33,21 @@ enumeration twice made a measured 12.3-second fallback cost roughly 25 seconds b - 다른 대안 대신 이 방식을 선택한 이유: Removing or weakening revalidation widens the install race, while a global/TTL cache can outlive startup and stale absence can authorize the wrong home. Exact targeted-result identity lets the ordinary no-task locale fallback coalesce without hiding changed evidence. - 장점, 단점 및 영향: The reported stable zh-CN absence path performs two cheap targeted queries and one full listing. A task that appears is detected by the second targeted query; changed or failed evidence triggers a fresh fail-closed decision, so unusual churn may still pay for two listings rather than guess. -## Linux stable service launcher +## Stable service launcher (launchd and systemd) -Systemd installation resolves the first absolute `ocx` PATH candidate that is both a regular file +Launchd and systemd installation resolve the first absolute `ocx` PATH candidate that is both a regular file and executable, keeps that path lexical so a version-manager shim remains an indirection, and -records the same single resolution in the unit and service state. Unit construction never performs -PATH discovery itself: callers provide either the resolved launcher or an explicit direct Bun/CLI +records the same single resolution in the service definition and service state. Definition +construction (`buildPlist`, `buildUnit`) never performs PATH discovery itself: callers provide either the resolved launcher or an explicit direct Bun/CLI fallback, keeping diagnostics and tests independent of the host PATH. Launcher mode omits the package-local Bun provenance pair because an upgrade may delete that versioned tree. The only runtime path carried through the launcher is a pre-Bun, proof-bound `OPENCODEX_BUN_PATH` whose durable runtime source is `override`; bundled and process fallbacks are rediscovered by the current launcher. The API-auth token remains file-backed and is loaded only by -the service shell at start. +the service shell at start. On macOS, `start` and detailed `status` compare the live launchd job +against `expectedLaunchdCommand`, which follows the recorded `launcherPath` rather than re-walking +PATH, so a launcher-backed job is never misreported as an older plist (#3464). [Decision Log] - 목적과 의도: Keep systemd services upgrade-stable without losing an explicitly trusted Bun override or accepting a non-executable PATH placeholder. @@ -65,6 +67,17 @@ only a typed DNS-resolution failure degrades to proxy resolution; every literal, resolved-address policy error still rejects. Proxy mode logs once that the proxy-selected peer cannot be pinned. Private destinations additionally require allowPrivateNetwork plus NO_PROXY. +Two fake-IP DNS accommodations exist, both for resolved answers only (a literal address in the URL +still rejects). The IANA benchmark range (198.18/15 and its IPv4-mapped IPv6 spellings) is admitted +whenever any outbound proxy applies to the host, because the range itself marks the answer synthetic. +Mihomo's default IPv6 fake-IP range (fdfe:dcba:9876::/48) is ULA and carries no such mark, so it is +admitted only when the proxy variable that matches the URL scheme is set (HTTPS_PROXY for https:, +HTTP_PROXY for http:; ALL_PROXY is not consulted because Bun fetch does not honour it), the host is +not in NO_PROXY, and the request is then bound to that proxy through Bun's explicit `proxy` option +rather than environment inference. Both gates live in the outbound wrapper, not in classification: +`classifyIpv6` and config-time validation (`providerDestinationResolvedError`) never admit the +ULA, so provider save-time checks are unaffected (#3462). + Both paths reject redirects and expose only credential-stripped final-address guidance. This phase does not cover ordinary requests, streaming, retries, or per-hop redirect review on those paths. Caller-owned `provider.fetch` executors are also deferred: they receive literal/config checks and diff --git a/tests/codex-integration/native-codex-toggle.test.ts b/tests/codex-integration/native-codex-toggle.test.ts index d78e41c1d2..db3c8c7df6 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 { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; @@ -22,7 +22,9 @@ import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; let fixtureRoot = ""; +let codexHome = ""; let previousOpencodexHome: string | undefined; +let previousCodexHome: string | undefined; const cleanup: string[] = []; function baseConfig(): OcxConfig { @@ -72,9 +74,15 @@ function persistedCodexIntent(): unknown { beforeEach(() => { previousOpencodexHome = process.env.OPENCODEX_HOME; - fixtureRoot = mkdtempSync(join(tmpdir(), "ocx-codex-toggle-")); + previousCodexHome = process.env.CODEX_HOME; + // realpath: macOS hands out /var/... from tmpdir() but getCodexHome() resolves to + // /private/var/..., and the status row reports the resolved path. + fixtureRoot = realpathSync(mkdtempSync(join(tmpdir(), "ocx-codex-toggle-"))); + codexHome = join(fixtureRoot, "codex"); + mkdirSync(codexHome); cleanup.push(fixtureRoot); process.env.OPENCODEX_HOME = fixtureRoot; + process.env.CODEX_HOME = codexHome; writeFileSync(join(fixtureRoot, "config.json"), JSON.stringify(baseConfig(), null, 2)); writeFileSync(join(fixtureRoot, "service-state.json"), JSON.stringify({ version: 2, @@ -87,9 +95,18 @@ beforeEach(() => { afterEach(() => { if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; while (cleanup.length) removeTreeWithRetry(cleanup.pop()!); }); +test("the status row names Codex's effective config file", async () => { + const response = await dispatch(baseConfig(), "/api/native-integrations"); + const body = await response!.json() as { clients: { clientId: string; configPath: string }[] }; + const codex = body.clients.find(client => client.clientId === "codex"); + expect(codex?.configPath).toBe(join(codexHome, "config.toml")); +}); + describe("request validation", () => { test("a non-boolean enabled is rejected before anything is written", async () => { const result = await put(baseConfig(), { enabled: "false" }); diff --git a/tests/providers/provider-outbound.test.ts b/tests/providers/provider-outbound.test.ts index 5cf7d60039..2853e0e335 100644 --- a/tests/providers/provider-outbound.test.ts +++ b/tests/providers/provider-outbound.test.ts @@ -421,3 +421,100 @@ describe("provider outbound POST transport", () => { expect(calls).toBe(0); }); }); + +describe("#3462 Mihomo IPv6 fake-IP admission is gated on the scheme-matched proxy fetch will use", () => { + type Captured = { allowMihomoIpv6FakeIp?: boolean }; + const ULA = "fdfe:dcba:9876::7e"; + const target = "https://opencode.ai/zen/v1/models"; + + async function run(env: Record, opts: { admit: boolean }) { + for (const key of proxyKeys) delete process.env[key]; + for (const [k, v] of Object.entries(env)) process.env[k] = v; + const originalFetch = globalThis.fetch; + const fetchInits: (RequestInit & { proxy?: string })[] = []; + globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => { + fetchInits.push((init ?? {}) as RequestInit & { proxy?: string }); + return new Response('{"data":[{"id":"muse-spark-1.3-contributor"}]}', { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + try { + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const resolveOptions: Captured[] = []; + const { dependencies, captured } = directDependencies(new Response(null, { status: 500 })); + dependencies.resolveAddresses = mock(async (_url: string, options?: Captured) => { + resolveOptions.push({ allowMihomoIpv6FakeIp: options?.allowMihomoIpv6FakeIp }); + if (!options?.allowMihomoIpv6FakeIp) { + throw new Error(`provider URL hostname opencode.ai resolves to private-network address (${ULA})`); + } + return { hostname: "opencode.ai", addresses: [{ address: ULA, family: 6 }], privateNetwork: false }; + }) as ProviderOutboundDependencies["resolveAddresses"]; + + const attempt = providerOutboundGet("opencode-go", { baseUrl: "https://opencode.ai/zen/v1" }, target, {}, dependencies); + if (opts.admit) { + const response = await attempt; + expect(response.status).toBe(200); + } else { + await expect(attempt).rejects.toThrow(/private-network address/); + } + expect(captured.address).toBeUndefined(); + return { resolveOptions, fetchInits }; + } finally { + globalThis.fetch = originalFetch; + } + } + + test("HTTPS target + HTTPS_PROXY: admitted, and the fetch is bound to that proxy explicitly", async () => { + const { resolveOptions, fetchInits } = await run({ HTTPS_PROXY: "http://127.0.0.1:7897" }, { admit: true }); + expect(resolveOptions).toEqual([{ allowMihomoIpv6FakeIp: true }]); + expect(fetchInits).toHaveLength(1); + expect(fetchInits[0]!.proxy).toBe("http://127.0.0.1:7897"); + expect(fetchInits[0]!.redirect).toBe("manual"); + }); + + test("lowercase https_proxy is honoured the same way", async () => { + const { resolveOptions, fetchInits } = await run({ https_proxy: "http://127.0.0.1:7897" }, { admit: true }); + expect(resolveOptions).toEqual([{ allowMihomoIpv6FakeIp: true }]); + expect(fetchInits[0]!.proxy).toBe("http://127.0.0.1:7897"); + }); + + test("HTTPS target + HTTP_PROXY only: fetch would not use it, so the ULA is not admitted", async () => { + const { resolveOptions, fetchInits } = await run({ HTTP_PROXY: "http://127.0.0.1:7897" }, { admit: false }); + expect(resolveOptions).toEqual([{ allowMihomoIpv6FakeIp: false }]); + expect(fetchInits).toHaveLength(0); + }); + + test("HTTPS target + ALL_PROXY only: not admitted", async () => { + const { resolveOptions, fetchInits } = await run({ ALL_PROXY: "socks5://127.0.0.1:7891" }, { admit: false }); + expect(resolveOptions).toEqual([{ allowMihomoIpv6FakeIp: false }]); + expect(fetchInits).toHaveLength(0); + }); + + test("NO_PROXY match is a direct route: not admitted even with HTTPS_PROXY", async () => { + const { resolveOptions } = await run({ HTTPS_PROXY: "http://127.0.0.1:7897", NO_PROXY: "opencode.ai" }, { admit: false }); + expect(resolveOptions).toEqual([{ allowMihomoIpv6FakeIp: false }]); + }); + + test("without any proxy the branch is byte-identical: no flag, no proxy option", async () => { + const { resolveOptions, fetchInits } = await run({}, { admit: false }); + expect(resolveOptions).toEqual([{ allowMihomoIpv6FakeIp: false }]); + expect(fetchInits).toHaveLength(0); + }); +}); + +describe("effectiveProxyFor picks the variable Bun fetch actually honours", () => { + test("scheme-matched selection; ALL_PROXY is never consulted", async () => { + const { effectiveProxyFor } = await import("../../src/lib/proxy-env"); + const https = new URL("https://opencode.ai/zen/v1/models"); + const http = new URL("http://ollama.lan:11434/v1/models"); + expect(effectiveProxyFor(https, { HTTPS_PROXY: "http://p:1" })).toBe("http://p:1"); + expect(effectiveProxyFor(https, { https_proxy: " http://p:2 " })).toBe("http://p:2"); + expect(effectiveProxyFor(https, { HTTP_PROXY: "http://p:3" })).toBeNull(); + expect(effectiveProxyFor(https, { ALL_PROXY: "http://p:4" })).toBeNull(); + expect(effectiveProxyFor(http, { HTTP_PROXY: "http://p:5" })).toBe("http://p:5"); + expect(effectiveProxyFor(http, { HTTPS_PROXY: "http://p:6" })).toBeNull(); + expect(effectiveProxyFor(https, { HTTPS_PROXY: " " })).toBeNull(); + expect(effectiveProxyFor(new URL("ftp://x/"), { HTTPS_PROXY: "http://p:7", HTTP_PROXY: "http://p:7" })).toBeNull(); + }); +}); diff --git a/tests/routing/destination-policy-resolved.test.ts b/tests/routing/destination-policy-resolved.test.ts index 7b471bd793..39eac5331c 100644 --- a/tests/routing/destination-policy-resolved.test.ts +++ b/tests/routing/destination-policy-resolved.test.ts @@ -382,3 +382,76 @@ describe("#2810 explicit-zero mapped benchmark answers under the fake-IP opt-in" } }); }); + +describe("#3462 Mihomo IPv6 fake-IP answers (fdfe:dcba:9876::/48) under the dedicated opt-in", () => { + const OPT_IN = { context: "provider URL", allowMihomoIpv6FakeIp: true } as const; + const URL_ = "https://opencode.ai/zen/v1/models"; + + test("the reported answer is accepted and stays non-private", async () => { + lookupMock.mockResolvedValueOnce([{ address: "fdfe:dcba:9876::7e", family: 6 }]); + const resolved = await resolvePublicAddresses(URL_, OPT_IN); + expect(resolved.privateNetwork).toBe(false); + expect(resolved.addresses).toEqual([{ address: "fdfe:dcba:9876::7e", family: 6 }]); + }); + + test("compressed, uppercase, expanded and non-zero-fourth-hextet spellings all match the /48", async () => { + for (const address of [ + "FDFE:DCBA:9876::1", + "fdfe:dcba:9876:0:0:0:0:1", + "fdfe:dcba:9876:ffff::1", + "fdfe:dcba:9876:1:2:3:4:5", + ]) { + lookupMock.mockResolvedValueOnce([{ address, family: 6 }]); + const resolved = await resolvePublicAddresses(URL_, OPT_IN); + expect(resolved.privateNetwork).toBe(false); + } + }); + + test("rejects without the opt-in, and the benchmark opt-in alone does not admit it", async () => { + lookupMock.mockResolvedValueOnce([{ address: "fdfe:dcba:9876::7e", family: 6 }]); + await expect(resolvePublicAddresses(URL_, { context: "provider URL" })) + .rejects.toThrow("private-network address (fdfe:dcba:9876::7e)"); + + lookupMock.mockResolvedValueOnce([{ address: "fdfe:dcba:9876::7e", family: 6 }]); + await expect(resolvePublicAddresses(URL_, { context: "provider URL", allowBenchmarkAddresses: true })) + .rejects.toThrow("private-network address (fdfe:dcba:9876::7e)"); + }); + + test("a literal ULA URL still rejects even with the opt-in (DNS answers only)", async () => { + await expect(resolvePublicAddresses("https://[fdfe:dcba:9876::7e]/v1/models", OPT_IN)) + .rejects.toThrow("private-network address"); + }); + + test("adjacent prefixes, ordinary ULA, loopback, metadata and RFC1918 stay rejected", async () => { + for (const [address, family, detail] of [ + ["fdfe:dcba:9877::1", 6, "private-network address"], + ["fdfe:dcba:9875::1", 6, "private-network address"], + ["fdfd:dcba:9876::1", 6, "private-network address"], + ["fd00::1", 6, "private-network address"], + ["::1", 6, "loopback address"], + ["169.254.169.254", 4, "metadata"], + ["10.0.0.5", 4, "private-network address"], + ] as const) { + lookupMock.mockResolvedValueOnce([{ address, family }]); + await expect(resolvePublicAddresses(URL_, OPT_IN)).rejects.toThrow(detail); + } + }); + + test("a fake-IP answer mixed with a real private answer still rejects", async () => { + lookupMock.mockResolvedValueOnce([ + { address: "fdfe:dcba:9876::7e", family: 6 }, + { address: "10.0.0.5", family: 4 }, + ]); + await expect(resolvePublicAddresses(URL_, OPT_IN)).rejects.toThrow("private-network address (10.0.0.5)"); + }); + + test("config-time validation is unchanged: the canonical benchmark opt-in never admits the ULA", async () => { + lookupMock.mockResolvedValueOnce([{ address: "fdfe:dcba:9876::7e", family: 6 }]); + const error = await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + { allowBenchmarkAddresses: true }, + ); + expect(error).toContain("private-network address (fdfe:dcba:9876::7e)"); + }); +}); diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts index 8c9d10cb8a..e36a2785b6 100644 --- a/tests/service/service.test.ts +++ b/tests/service/service.test.ts @@ -7,7 +7,7 @@ import { pathToFileURL } from "node:url"; import * as serviceModule from "../../src/service"; import { saveConfig } from "../../src/config"; import { windowsEnvIndirectBatchValue } from "../../src/lib/win-paths"; -import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml as buildWindowsTaskXmlProduction, buildWindowsTaskXmlDocument, deriveWindowsServiceDiagnostic, deriveWindowsServiceDiagnosticForCurrentUser, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, reportServiceServing, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, stableLauncherEntry, systemdNeedsDaemonReload, systemdServiceInstallCleanupOps, uninstallSystemd, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy as windowsTaskRegistrationHealthyProduction } from "../../src/service"; +import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml as buildWindowsTaskXmlProduction, buildWindowsTaskXmlDocument, deriveWindowsServiceDiagnostic, deriveWindowsServiceDiagnosticForCurrentUser, expectedLaunchdCommand, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, reportServiceServing, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, stableLauncherEntry, systemdNeedsDaemonReload, systemdServiceInstallCleanupOps, uninstallSystemd, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy as windowsTaskRegistrationHealthyProduction } from "../../src/service"; import type { ServiceDiagnostic } from "../../src/service"; import { definitionCarriesCredential, resolvedProxyEnv, writeServiceDefinitionFile } from "../../src/service"; import { buildWinswXml } from "../../src/lib/winsw"; @@ -1182,6 +1182,68 @@ describe("launchd service plist", () => { expect(direct).toContain("OCX_BUN_RUNTIME_PATH"); }); + + // #3464. The macOS counterpart of the systemd launcher test above: a mise/asdf upgrade replaces + // the versioned package directory, and a plist that named the old Bun + CLI pair keeps launchd + // on the stale build until someone restarts it. Naming the shim lets the next start follow it. + test("a stable launcher install names the launcher in the plist and bakes no versioned path (#3464)", () => { + const launcher = "/home/u/.local/share/mise/shims/ocx"; + const plist = buildPlist(resolvedProxyEnv({}), { + launcher, + runtime: { path: "/opt/opencodex/versioned/bun", source: "bundled", overrideEnv: "OPENCODEX_BUN_PATH" }, + }); + + expect(plist).toContain(launcher); + expect(plist).toContain("start --port"); + for (const forbidden of [ + "OCX_BUN_RUNTIME_PATH", + "OCX_BUN_RUNTIME_SOURCE", + "OPENCODEX_BUN_PATH", + "/opt/opencodex/versioned/bun", + "cli/index.ts", + ]) expect(plist).not.toContain(forbidden); + // The token still comes from the file at start, never from the plist. + expectTextToContainPath(plist, serviceApiTokenFilePath()); + expect(plist).toContain("OPENCODEX_API_AUTH_TOKEN"); + // launchdListenPort reads the same "start --port N" tail from either command shape. + expect(launchdListenPort({ readPlist: () => plist })).toBe(resolveServiceListenPort()); + + // Without a launcher the plist keeps the previous shape, so source checkouts are unaffected. + const direct = buildPlist(resolvedProxyEnv({}), { launcher: null }); + expectTextToContainPath(direct, join("cli", "index.ts")); + expect(direct).toContain("OCX_BUN_RUNTIME_PATH"); + expect(direct).toContain("OCX_BUN_RUNTIME_SOURCE"); + }); + + test("launcher mode preserves only a proof-bound Bun override, never an ambient one (#3464)", () => { + const launcher = "/home/u/.local/share/mise/shims/ocx"; + const trusted = buildPlist(resolvedProxyEnv({}), { + launcher, + runtime: { path: "/custom/bun", source: "override", overrideEnv: "OPENCODEX_BUN_PATH" }, + }); + expect(trusted).toContain("OPENCODEX_BUN_PATH/custom/bun"); + expect(trusted).not.toContain("OCX_BUN_RUNTIME_PATH"); + + const bundled = buildPlist(resolvedProxyEnv({}), { + launcher, + runtime: { path: "/custom/bun", source: "bundled", overrideEnv: "OPENCODEX_BUN_PATH" }, + }); + expect(bundled).not.toContain("OPENCODEX_BUN_PATH"); + expect(bundled).not.toContain("/custom/bun"); + }); + + test("launcher paths with shell and XML metacharacters stay quoted in the plist (#3464)", () => { + const launcher = "/home/u/My Tools & Shims/it's/ocx"; + const plist = buildPlist(resolvedProxyEnv({}), { + launcher, + runtime: { path: "/opt/bun", source: "bundled", overrideEnv: "OPENCODEX_BUN_PATH" }, + }); + // XML-escaped ampersand inside the ProgramArguments string; the shell quoting survives. + expect(plist).toContain("&"); + expect(plist).not.toContain("Shims/it's/ocx start"); + expect(launchdListenPort({ readPlist: () => plist })).toBe(resolveServiceListenPort()); + }); + // The scenario itself, executed rather than asserted: retarget the shim the way an upgrade // does, delete the old version, and check the generated command still reaches live code. test("the generated launcher command follows a retargeted shim after the old version is gone", () => { @@ -3143,6 +3205,38 @@ describe("launchctl load verification", () => { }); }); + + // #3464. start and status compare the live job against the command the plist SHOULD carry. + // A launcher install carries the launcher line, so the comparison must follow the recorded + // install state or every healthy launcher-backed service reads as "an OLDER plist". + describe("expectedLaunchdCommand follows the recorded launcher", () => { + const entry = { bun: "/opt/opencodex/versioned/bun", cli: "/opt/opencodex/versioned/src/cli/index.ts" }; + const base = { version: 2 as const, codexHome: "/h/.codex", opencodexHome: "/h/.opencodex", backend: "scheduler" as const }; + + test("a recorded launcher yields the launcher exec line at the installed port", () => { + const command = expectedLaunchdCommand(14001, { + state: { ...base, bunPath: entry.bun, cliPath: entry.cli, launcherPath: "/home/u/.local/share/mise/shims/ocx" }, + entry, + }); + expect(command).toContain("exec '/home/u/.local/share/mise/shims/ocx' start --port 14001"); + expect(command).not.toContain(entry.cli); + }); + + test("v1 / legacy state without a launcher yields the Bun + CLI pair", () => { + const command = expectedLaunchdCommand(14001, { + state: { version: 1, codexHome: "/h/.codex", opencodexHome: "/h/.opencodex", bunPath: entry.bun, cliPath: entry.cli }, + entry, + }); + expect(command).toContain(`exec '${entry.bun}' '${entry.cli}' start --port 14001`); + }); + + test("missing state falls back to the Bun + CLI pair and never re-walks PATH", () => { + const command = expectedLaunchdCommand(14001, { state: null, entry }); + expect(command).toContain(`exec '${entry.bun}' '${entry.cli}' start --port 14001`); + expect(command).not.toContain("shims/ocx"); + }); + }); + describe("startLaunchd", () => { // A runLaunchctl RESULT, not a spawnSync result. const failedLoad = () => ({ ok: true, stdout: "", stderr: "Load failed: 5: Input/output error" });