diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index a99e5c61a6..17ba708161 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/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 94ce7e9103..8805a76431 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1039,6 +1039,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 917aba8e2b..4014c6d4e0 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1569,6 +1569,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 c7ad8b6ee5..e2d16d0be7 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1501,6 +1501,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 c26bfc7902..58c6eb4e33 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1472,6 +1472,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 709bfeb3ca..56413e638d 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1062,6 +1062,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 bc699216e9..0349b70ec8 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1523,6 +1523,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 622ebb3c19..bb59b3502e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1519,6 +1519,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 f5f94c4e31..7cb49fe91b 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2083,6 +2083,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 c1a4ceaa25..ac6081a4f9 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1056,6 +1056,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 d96d8f182d..d7660c5a5f 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -46,6 +46,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", @@ -92,7 +101,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", @@ -101,6 +110,7 @@ function OverviewCard({ message: row.toggleBlocked.message, }), undefined, row.togglePath ?? undefined) : null; + const toggleOn = row.toggleOn ?? row.applied; return (
  • @@ -131,7 +141,7 @@ function OverviewCard({ {row.toggle && onToggle && (
    @@ -440,6 +450,7 @@ export default function IntegrationsOverview({ const refreshNativeDetails = () => { nativeResource.refresh(); + codexResource.refresh(); claudeResource.refresh(); grokResource.refresh(); }; @@ -492,7 +503,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 @@ -667,7 +678,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 9f3628c4f7..1e92e77717 100644 --- a/gui/tests/integrations-surfaces.test.tsx +++ b/gui/tests/integrations-surfaces.test.tsx @@ -53,6 +53,8 @@ type JournalRow = { let stateResponse: () => Response; let journalRows: JournalRow[]; let putResponse: () => Response; +let codexRoutingResponse: () => Response; +let codexDesiredEnabled = true; /** * The overview also reads Codex routing, API keys, Claude Code, Claude Desktop * and the Grok fence. Default answers keep every existing test's card grid @@ -101,6 +103,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; failExtraSources = false; const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -115,7 +119,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: [] }); @@ -125,15 +129,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 }); @@ -883,6 +913,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 { previousOpencodexHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; fixtureRoot = 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 +93,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" });