diff --git a/devlog/_plan/260907_lane_c/040_desktop.md b/devlog/_plan/260907_lane_c/040_desktop.md index ad7fb60177..4eb4ebffd5 100644 --- a/devlog/_plan/260907_lane_c/040_desktop.md +++ b/devlog/_plan/260907_lane_c/040_desktop.md @@ -402,3 +402,7 @@ index 84ac5f67b..b6be3c2f6 100644 ``` Audit amendment: clear catalogRefreshPending only if sync status is affirmative success, not HTTP 200 skipped. Add skipped/no-write regression. + +## Lane E documentation handoff + +After run 34106956362 reported a GUI lint failure, include the separately prepared code-mode host-rule translations in the seven fr/ja/ko/ru/tr/zh-cn/zh-tw Codex integration guides. The patch adds 51 documentation lines matching the existing English paragraph; it does not modify runtime code or provider guides. Apply on the Desktop layer, record `docs handoff from lane E` and `[skip ci]` in its own commit, then cascade the fallback layer and dispatch the top CI again. Local documentation install/build remains NOT RUN. diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md index 02ffa8a211..a351adeae5 100644 --- a/docs-site/src/content/docs/fr/guides/codex-integration.md +++ b/docs-site/src/content/docs/fr/guides/codex-integration.md @@ -233,6 +233,14 @@ opencodex encode cette déclaration et son historique sous forme d'outil de fonc cycle de vie diffusé de l'appel de fonction en `custom_tool_call` avant que Codex ne le reçoive. Le routage natif par transfert OpenAI et l'outil personnalisé `apply_patch`, qui est pris en charge, restent inchangés. +Avant le premier appel, les tours routés en mode code reçoivent aussi les règles de l'hôte pour les +outils auxiliaires imbriqués : `tools.apply_patch` prend une seule chaîne qui commence et se termine +par les lignes de marqueur de patch seules, sans habillage ; l'isolate ne dispose pas de `import`, +et les commandes longues sont interrogées via `write_stdin`. Lorsqu'un résultat exec en mode code +sur le chemin natif Responses routé, Kiro ou Cursor contient encore l'un des messages d'échec de +l'hôte, opencodex ajoute une indication d'une ligne qui nomme la règle. Cette modification ne +réécrit ni le code du modèle ni le texte de son patch. + Le fournisseur sélectionné doit prendre en charge les appels de fonctions ou d'outils. Un fournisseur purement textuel dépourvu de cette prise en charge ne peut pas utiliser `exec`, Browser ni Computer Use. Les lignes OpenAI natives conservent leur mode d'outil en amont. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 7d66e72c3f..ff2df04fdd 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -215,6 +215,15 @@ HTTP/SSE. ### Authless Codex Desktop (opt-in) +In **Dashboard → Overview**, **Open Codex without signing in** controls this existing +opt-in preference. The switch defaults to **off** when the setting is absent or false; +an existing explicit `codexDesktopAuthless: true` stays enabled. The dashboard saves +the preference and runs a full sync. Restart Codex Desktop after changing it. +If synchronization fails, the saved preference remains and the dashboard shows the error; +retry **Sync** before restarting. Account-gated Desktop features may be unavailable +when enabled. Upstream credentials, local eligibility, remote admission authentication +and user-owned gateway settings retain their existing requirements. + Codex Desktop shows its ChatGPT login screen whenever the active provider requires OpenAI auth. If your OpenCodex setup never uses ChatGPT credentials (routed providers only, or a blocked `chatgpt.com`), you can opt out of that gate: diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index d1d977b35c..46c33320f2 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -147,6 +147,14 @@ Codex の `exec` custom-tool grammar を受け付けない key-auth Responses pr `custom_tool_call` へ復元します。ネイティブ OpenAI の forward routing と、対応済みの `apply_patch` custom tool は 変更されません。 +ルーティングされた code-mode のターンには、最初の呼び出し前に、ネストされたヘルパーに関する +ホストの規則も伝えられます。`tools.apply_patch` は、装飾を付けないパッチマーカー行で始まり、 +同様のマーカー行で終わる単一の文字列を受け取ります。isolate では `import` を使用できず、 +長時間実行されるコマンドは `write_stdin` でポーリングします。ネイティブのルーティング済み Responses、 +Kiro、または Cursor の経路で、code-mode の exec 結果にホストの失敗メッセージがまだ含まれている場合、 +opencodex は該当する規則を示す 1 行のヒントを追加します。この変更でモデルのコードやパッチのテキストを +書き換えることはありません。 + 選択した provider は function/tool calling をサポートしている必要があります。tool call に対応しない text-only provider では `exec`、Browser、Computer Use は使用できません。ネイティブ OpenAI の項目は上流の tool mode を そのまま維持します。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 44551de837..1f324adaf2 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -149,6 +149,13 @@ history를 업스트림 function tool로 인코딩한 다음 스트리밍된 fun `custom_tool_call`로 복원합니다. 네이티브 OpenAI forward routing과 지원되는 `apply_patch` custom tool은 변경되지 않습니다. +라우팅된 code-mode 턴에는 첫 호출 전에 중첩 helper에 대한 호스트 규칙도 전달됩니다. +`tools.apply_patch`는 별도 장식 없이 패치 마커만 있는 줄로 시작하고 끝나는 하나의 문자열을 받습니다. +isolate에서는 `import`를 사용할 수 없으며, 오래 실행되는 명령은 `write_stdin`으로 폴링합니다. +네이티브 라우팅 Responses, Kiro 또는 Cursor 경로의 code-mode exec 결과에 호스트의 실패 메시지 중 +하나가 여전히 포함되어 있으면, opencodex는 해당 규칙을 명시하는 한 줄짜리 힌트를 덧붙입니다. +이 변경은 모델의 코드나 패치 텍스트를 다시 작성하지 않습니다. + 선택한 provider는 function/tool calling을 지원해야 합니다. tool call을 지원하지 않는 text-only provider에서는 `exec`, Browser 또는 Computer Use를 사용할 수 없습니다. 네이티브 OpenAI 항목은 업스트림 tool mode를 그대로 유지합니다. diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 9707a3ea44..23581e56f6 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -224,6 +224,14 @@ opencodex кодирует объявление и историю как functio потоковый lifecycle function call в `custom_tool_call` до передачи в Codex. Нативная forward- маршрутизация OpenAI и поддерживаемый custom tool `apply_patch` остаются без изменений. +Перед первым вызовом маршрутизируемые ходы в code-mode также получают правила хоста для вложенных +вспомогательных инструментов: `tools.apply_patch` принимает одну строку, которая начинается и +заканчивается отдельными строками маркеров патча без дополнительного оформления; в isolate нет +`import`, а длительные команды опрашиваются через `write_stdin`. Если результат exec в code-mode +на нативном маршрутизируемом пути Responses, Kiro или Cursor всё ещё содержит одно из сообщений +хоста об ошибке, opencodex добавляет однострочную подсказку с указанием правила. Это изменение +не переписывает код модели или текст её патча. + Выбранный provider должен поддерживать function/tool calling. Text-only provider без tool calls не может использовать `exec`, Browser или Computer Use. Нативные записи OpenAI сохраняют свой upstream tool mode без изменений. diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md index 02fae0f468..d13af52274 100644 --- a/docs-site/src/content/docs/tr/guides/codex-integration.md +++ b/docs-site/src/content/docs/tr/guides/codex-integration.md @@ -262,6 +262,14 @@ fonksiyon aracı olarak kodlar, ardından akışlı fonksiyon çağrısı yaşam Codex görmeden önce `custom_tool_call`'a geri yükler. Yerel OpenAI iletme yönlendirmesi ve desteklenen `apply_patch` özel aracı değişmeden kalır. +Yönlendirilen code-mode turlarına, ilk çağrıdan önce iç içe geçmiş yardımcılar için geçerli olan +ana makine kuralları da bildirilir: `tools.apply_patch`, yalnızca yama işaretçilerinden oluşan +satırlarla başlayan ve biten tek bir dize alır; isolate içinde `import` yoktur ve uzun süren +komutlar `write_stdin` üzerinden yoklanır. Yerel yönlendirilmiş Responses, Kiro veya Cursor yolundaki +bir code-mode exec sonucu hâlâ ana makinenin hata mesajlarından birini içeriyorsa opencodex, +ilgili kuralı belirten tek satırlık bir ipucu ekler. Bu değişiklik modelin kodunu veya yama metnini +yeniden yazmaz. + Seçilen sağlayıcı fonksiyon/araç çağrısını desteklemelidir. Araç çağrısı desteği olmayan salt metin bir sağlayıcı `exec`, Tarayıcı veya Bilgisayar Kullanımını kullanamaz. Yerel OpenAI satırları yukarı akış araç modunu değiştirmeden tutar. diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 552c0c3f53..0c3df0c0e2 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -196,6 +196,12 @@ Codex 显示的模型来自一个磁盘上的 catalog(默认是 `$CODEX_HOME/o 历史记录编码成上游 function tool,再在 Codex 收到结果前,把流式 function-call lifecycle 还原成 `custom_tool_call`。原生 OpenAI forward routing 和已支持的 `apply_patch` custom tool 保持不变。 +路由的 code-mode 轮次还会在首次调用前收到宿主对嵌套辅助工具的规则:`tools.apply_patch` +接收一个字符串,首尾必须是没有额外包装的独立补丁标记行;isolate 中没有 `import`,长时间运行的 +命令通过 `write_stdin` 轮询。如果原生路由 Responses、Kiro 或 Cursor 路径上的 code-mode exec +结果仍包含宿主的某条失败消息,opencodex 会追加一行提示,指出对应规则。此变更不会重写模型的 +代码或补丁文本。 + 所选 provider 必须支持 function/tool calling。不支持 tool call 的 text-only provider 无法使用 `exec`、 Browser 或 Computer Use。原生 OpenAI 条目会保持其上游 tool mode 不变。 diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md index f371457be9..44ee5bbb3b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md @@ -201,6 +201,12 @@ metadata,使用 Codex 的 `low | medium | high | xhigh | max | ultra` 檔位 歷史編碼成上游 function tool,再於 Codex 看見前將串流 function-call lifecycle 還原成 `custom_tool_call`。原生 OpenAI forward 路由與受支援的 `apply_patch` custom tool 維持不變。 +路由的 code-mode 回合也會在首次呼叫前收到主機對巢狀輔助工具的規則:`tools.apply_patch` +接收一個字串,開頭與結尾必須是沒有額外包裝的獨立補丁標記行;isolate 中沒有 `import`,長時間執行的 +命令透過 `write_stdin` 輪詢。如果原生路由 Responses、Kiro 或 Cursor 路徑上的 code-mode exec +結果仍包含主機的某則失敗訊息,opencodex 會附加一行提示,指出對應規則。這項變更不會重寫模型的 +程式碼或補丁文字。 + 所選 provider 必須支援 function/tool calling。不支援 tool call 的純文字 provider 無法使用 `exec`、 Browser 或 Computer Use。原生 OpenAI 列保留上游 tool mode 不變。 diff --git a/docs/pr-assets/codex-desktop-opt-in.jpg b/docs/pr-assets/codex-desktop-opt-in.jpg new file mode 100644 index 0000000000..d06203e711 Binary files /dev/null and b/docs/pr-assets/codex-desktop-opt-in.jpg differ diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 5faab4b356..74533a04d3 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2469,6 +2469,8 @@ export const de: Record = { "dash.visionTimeout": "Timeout", "dash.visionTimeoutInvalid": "Geben Sie eine ganze Zahl von {min} bis {max} Millisekunden ein.", "dash.visionAdvancedPopover": "Erweiterte Vision-Einstellungen", + "dash.codexDesktopAuthless": "Codex ohne Anmeldung öffnen", + "dash.codexDesktopAuthlessHint": "Standardmäßig aus. Überspringt die separate Desktop-Anmeldung bei geeigneten lokalen Verbindungen. Zugangsdaten für den Anbieter bleiben erforderlich. Codex nach einer Änderung neu starten. Kontogebundene Desktop-Funktionen können fehlen.", "models.newPolicyGlobal": "Neue Modelle zunächst deaktivieren", "models.newPolicyProvider": "Richtlinie für neue Modelle", "models.newPolicy_inherit": "Übernehmen", "models.newPolicy_off": "Aus", "models.newPolicy_on": "An", "models.newBadge": "NEU", "models.newCount": "{count} neu, aus", "models.aliases": "Aliase", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c71208942f..2197dcb735 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -575,6 +575,8 @@ export const en = { "models.keepNativeOnV1Hint": "ChatGPT encrypts v2 child tasks only when a ChatGPT-native parent stays on v2, so Grok and Claude cannot read them. Turn this on to keep Sol/Terra on v1 and avoid that encryption. Routed parents keep v2.", "models.v2Help": "Controls the multi-agent surface for all models.\n\nv1: Classic single-thread agent. Every model uses the v1 collab surface.\nbase: Upstream defaults — sol/terra use v2, luna uses v1, others follow the codex feature flag.\nv2: Multi-thread agent with spawn_agent. Every model uses the v2 collab surface.\n\nOn v2, Keep ChatGPT on v1 leaves Sol/Terra on the v1 surface so they can still spawn Grok or Claude. ChatGPT encrypts v2 child tasks; routed models cannot read them. Routed parents stay on v2.\n\nChanges apply to new sessions.", "dash.multiAgent": "Sub-agent", + "dash.codexDesktopAuthless": "Open Codex without signing in", + "dash.codexDesktopAuthlessHint": "Off by default. Skip the separate Desktop sign-in for eligible local connections. Upstream credentials are still required. Restart Codex after changing this setting. Account-gated Desktop features may be unavailable.", "models.v2Conflict": "[agents] max_threads is set — codex will refuse to start; remove it from config.toml", "models.v2Applied": "Sub-agent mode updated — applies to new sessions (restart the Codex app to refresh the picker)", "models.v2ThreadsLabel": "Max threads", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e1b3519ef1..cca4b70347 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -560,6 +560,8 @@ export const fr: Record = { "models.keepNativeOnV1Hint": "ChatGPT chiffre les tâches enfants v2 uniquement lorsqu’un parent natif ChatGPT reste sur v2, de sorte que Grok et Claude ne peuvent pas les lire. Activez cette option pour garder Sol/Terra sur v1 et éviter ce chiffrement. Les parents routés restent sur v2.", "models.v2Help": "Contrôle l’interface multi-agent pour tous les modèles.\n\nv1 : agent classique à fil unique. Tous les modèles utilisent l’interface collab v1.\nbase : valeurs par défaut en amont — sol/terra utilisent v2, luna utilise v1 et les autres suivent l’indicateur de fonctionnalité codex.\nv2 : agent multifil avec spawn_agent. Tous les modèles utilisent l’interface collab v2.\n\nEn v2, « Garder ChatGPT sur v1 » laisse Sol/Terra sur l’interface v1 afin qu’ils puissent encore lancer Grok ou Claude. ChatGPT chiffre les tâches enfants v2 ; les modèles routés ne peuvent pas les lire. Les parents routés restent sur v2.\n\nLes modifications s’appliquent aux nouvelles sessions.", "dash.multiAgent": "Sous-agent", + "dash.codexDesktopAuthless": "Ouvrir Codex sans se connecter", + "dash.codexDesktopAuthlessHint": "Désactivé par défaut. Ignore la connexion Desktop séparée pour les connexions locales admissibles. Les identifiants du fournisseur restent nécessaires. Redémarrez Codex après toute modification. Certaines fonctions Desktop liées au compte peuvent être indisponibles.", "models.v2Conflict": "[agents] max_threads est défini — codex refusera de démarrer ; supprimez-le de config.toml", "models.v2Applied": "Mode sous-agent mis à jour — s’applique aux nouvelles sessions (redémarrez l’application Codex pour actualiser le sélecteur)", "models.v2ThreadsLabel": "Nombre maximal de fils", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index cf94831583..97aa85a6fb 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2490,6 +2490,8 @@ export const ja: Record = { "dash.visionTimeout": "タイムアウト", "dash.visionTimeoutInvalid": "{min} から {max} ミリ秒の整数を入力してください。", "dash.visionAdvancedPopover": "詳細なビジョン設定", + "dash.codexDesktopAuthless": "ログインせずに Codex を開く", + "dash.codexDesktopAuthlessHint": "既定ではオフです。対象のローカル接続で Desktop の個別ログインを省略します。上流プロバイダーの認証情報は引き続き必要です。変更後は Codex を再起動してください。アカウントに依存する Desktop 機能が利用できない場合があります。", "models.newPolicyGlobal": "新しいモデルを無効で追加", "models.newPolicyProvider": "新しいモデルのポリシー", "models.newPolicy_inherit": "継承", "models.newPolicy_off": "オフ", "models.newPolicy_on": "オン", "models.newBadge": "新着", "models.newCount": "新着 {count} 件、オフ", "models.aliases": "エイリアス", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index c1959482b7..4e66544528 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2491,6 +2491,8 @@ export const ko: Record = { "dash.visionTimeout": "제한 시간", "dash.visionTimeoutInvalid": "{min}에서 {max} 밀리초 사이의 정수를 입력하세요.", "dash.visionAdvancedPopover": "고급 비전 설정", + "dash.codexDesktopAuthless": "로그인 없이 Codex 열기", + "dash.codexDesktopAuthlessHint": "기본값은 꺼짐입니다. 지원되는 로컬 연결에서 별도의 Desktop 로그인을 건너뜁니다. 업스트림 인증 정보는 여전히 필요합니다. 변경 후 Codex를 다시 시작하세요. 계정에 연결된 Desktop 기능을 사용하지 못할 수 있습니다.", "models.newPolicyGlobal": "새 모델을 비활성화 상태로 추가", "models.newPolicyProvider": "새 모델 정책", "models.newPolicy_inherit": "상속", "models.newPolicy_off": "끔", "models.newPolicy_on": "켬", "models.newBadge": "신규", "models.newCount": "신규 {count}개, 꺼짐", "models.aliases": "별칭", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0109f5ebdc..e7f199a06a 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2492,6 +2492,8 @@ export const ru: Record = { "dash.visionTimeout": "Таймаут", "dash.visionTimeoutInvalid": "Введите целое число от {min} до {max} миллисекунд.", "dash.visionAdvancedPopover": "Дополнительные настройки изображений", + "dash.codexDesktopAuthless": "Открывать Codex без входа", + "dash.codexDesktopAuthlessHint": "По умолчанию выключено. Пропускает отдельный вход в Desktop для допустимых локальных подключений. Учётные данные провайдера по-прежнему нужны. После изменения перезапустите Codex. Функции Desktop, связанные с аккаунтом, могут быть недоступны.", "models.newPolicyGlobal": "Добавлять новые модели выключенными", "models.newPolicyProvider": "Политика новых моделей", "models.newPolicy_inherit": "Наследовать", "models.newPolicy_off": "Выкл.", "models.newPolicy_on": "Вкл.", "models.newBadge": "НОВАЯ", "models.newCount": "Новых: {count}, выкл.", "models.aliases": "Псевдонимы", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index fa8b8e9c25..8c8f9ff802 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2492,6 +2492,8 @@ export const tr: Record = { "dash.visionTimeout": "Zaman aşımı", "dash.visionTimeoutInvalid": "{min} ile {max} milisaniye arasında bir tam sayı girin.", "dash.visionAdvancedPopover": "Gelişmiş görsel ayarları", + "dash.codexDesktopAuthless": "Codex’i oturum açmadan başlat", + "dash.codexDesktopAuthlessHint": "Varsayılan olarak kapalıdır. Uygun yerel bağlantılarda ayrı Desktop oturum açma adımını atlar. Sağlayıcı kimlik bilgileri yine gereklidir. Değişiklikten sonra Codex’i yeniden başlatın. Hesaba bağlı Desktop özellikleri kullanılamayabilir.", "models.newPolicyGlobal": "Yeni modeller devre dışı başlasın", "models.newPolicyProvider": "Yeni model ilkesi", "models.newPolicy_inherit": "Devral", "models.newPolicy_off": "Kapalı", "models.newPolicy_on": "Açık", "models.newBadge": "YENİ", "models.newCount": "{count} yeni, kapalı", "models.aliases": "Takma adlar", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 3bc246543a..eeec289911 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2454,6 +2454,8 @@ export const zhTW: Record = { "dash.visionTimeout": "逾時", "dash.visionTimeoutInvalid": "請輸入 {min} 到 {max} 毫秒之間的整數。", "dash.visionAdvancedPopover": "進階視覺設定", + "dash.codexDesktopAuthless": "無需登入即可開啟 Codex", + "dash.codexDesktopAuthlessHint": "預設關閉。為符合條件的本機連線略過獨立的 Desktop 登入。仍需上游供應商憑證。變更後請重新啟動 Codex。依賴帳戶的 Desktop 功能可能無法使用。", "models.newPolicyGlobal": "新模型預設停用", "models.newPolicyProvider": "新模型策略", "models.newPolicy_inherit": "繼承", "models.newPolicy_off": "關閉", "models.newPolicy_on": "開啟", "models.newBadge": "新增", "models.newCount": "{count} 個新增,已關閉", "models.aliases": "別名", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b10c48688d..a62e9b2d6b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2490,6 +2490,8 @@ export const zh: Record = { "dash.visionTimeout": "超时", "dash.visionTimeoutInvalid": "请输入 {min} 到 {max} 毫秒之间的整数。", "dash.visionAdvancedPopover": "高级视觉设置", + "dash.codexDesktopAuthless": "无需登录即可打开 Codex", + "dash.codexDesktopAuthlessHint": "默认关闭。为符合条件的本地连接跳过单独的 Desktop 登录。仍需上游提供商凭据。更改后请重启 Codex。依赖账户的 Desktop 功能可能不可用。", "models.newPolicyGlobal": "新模型默认停用", "models.newPolicyProvider": "新模型策略", "models.newPolicy_inherit": "继承", "models.newPolicy_off": "关闭", "models.newPolicy_on": "开启", "models.newBadge": "新增", "models.newCount": "{count} 个新增,已关闭", "models.aliases": "别名", diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index 8da531f97c..6606c4f560 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -163,7 +163,7 @@ export function DashboardInjectionPanel({ d }: { apiBase: string; d: Dash }) { export function DashboardMaintenancePanel({ d }: { d: Dash }) { const { - t, runSync, syncing, updateTriggerRef, openUpdateDialog, updateLoading, updateOpen, + t, runSync, syncing, settingsSaving, updateTriggerRef, openUpdateDialog, updateLoading, updateOpen, syncResult, syncError, updateJob, reconnecting, clearSyncFeedback, } = d; const syncHoldsWarning = !!syncResult && ( @@ -211,7 +211,7 @@ export function DashboardMaintenancePanel({ d }: { d: Dash }) {
{t("dash.syncModelsHint")}
-
+
+
+
+
{t("dash.codexDesktopAuthless")}
+
{t("dash.codexDesktopAuthlessHint")}
+ {settings?.catalogRefreshPending &&
{t("codexAuth.catalogRefreshPending")}
} +
+ +
+
+
{/* Both sidecar cards wear the DashboardInjectionPanel shell: the PANEL is the flex row, copy left, controls right. */} diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index 0793a7def2..b6914586b6 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -48,6 +48,8 @@ export interface ProviderInfo { name: string; adapter: string; baseUrl: string; export interface ModelInfo { id: string; provider: string; namespaced: string; owned_by?: string; reasoningEfforts?: string[] } export interface SettingsData { codexAutoStart: boolean; + codexDesktopAuthless?: boolean; + catalogRefreshPending?: boolean; /** Whether a login may open a browser on the machine running the proxy. */ oauthOpenBrowser?: boolean; port: number; @@ -126,6 +128,7 @@ export type Installer = "npm" | "bun" | "source"; export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed"; export interface SyncResult { ok: boolean; + status?: "applied" | "skipped" | "catalog-only" | "refused"; added: number; catalogPath: string | null; catalogExists: boolean; diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 6f84950ce1..8634e7caa8 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -361,7 +361,12 @@ export function useDashboardData(apiBase: string) { useEffect(() => { const data = settingsPoll.data; if (!data) return; - if (data.settings !== undefined) setSettings(data.settings); + if (data.settings !== undefined) { + const next = data.settings; + // GET settings does not report application receipts. Keep a saved preference's + // pending indication until an affirmative sync result clears it. + setSettings(prev => ({ ...next, catalogRefreshPending: prev?.catalogRefreshPending === true || next.catalogRefreshPending })); + } // Latest-wins: only seed from settings when no newer dedicated probe has committed // while this settings poll was in flight. Always merge against the live ref. if ( @@ -607,23 +612,24 @@ export function useDashboardData(apiBase: string) { finally { setInjectionSaving(false); } }; - const toggleCodexAutoStart = async () => { - if (!settings || settingsSaving) return; - const next = !settings.codexAutoStart; + const toggleCodexSetting = async (key: "codexAutoStart" | "codexDesktopAuthless") => { + if (!settings || settingsSaving || syncing) return; + const next = !(settings[key] ?? (key === "codexAutoStart")); setSettingsSaving(true); settingsMutationInFlightRef.current = true; - setSettings({ ...settings, codexAutoStart: next }); + setSettings({ ...settings, [key]: next }); try { const res = await fetch(`${apiBase}/api/settings`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ codexAutoStart: next }), + body: JSON.stringify({ [key]: next }), }); - const data = await requireJson<{ codexAutoStart: boolean; startupHealth?: SettingsData["startupHealth"] }>(res, "save failed"); + const data = await requireJson(res, "save failed"); settingsMutationEpochRef.current += 1; - setSettings(prev => prev ? { ...prev, codexAutoStart: data.codexAutoStart, startupHealth: data.startupHealth ?? prev.startupHealth } : prev); + setSettings(prev => prev ? { ...prev, [key]: data[key], catalogRefreshPending: key === "codexDesktopAuthless" ? data.catalogRefreshPending : prev.catalogRefreshPending, startupHealth: data.startupHealth ?? prev.startupHealth } : prev); + if (key === "codexDesktopAuthless") await runSync(); } catch { - setSettings(prev => prev ? { ...prev, codexAutoStart: !next } : prev); + setSettings(prev => prev ? { ...prev, [key]: !next } : prev); setError(true); } finally { settingsMutationInFlightRef.current = false; @@ -631,6 +637,9 @@ export function useDashboardData(apiBase: string) { } }; + const toggleCodexAutoStart = () => toggleCodexSetting("codexAutoStart"); + const toggleCodexDesktopAuthless = () => toggleCodexSetting("codexDesktopAuthless"); + // Clears the sync result/error in this hook. The dashboard toast owns its own dismissal // timer but must publish the dismissal here: syncResult/syncError live above the dashboard // tabs, so a component-local flag alone would let a stale result remount as a fresh toast @@ -649,6 +658,9 @@ export function useDashboardData(apiBase: string) { const res = await fetch(`${apiBase}/api/sync`, { method: "POST" }); const data = await requireJson(res, "sync failed"); setSyncResult(data); + if (data.ok && data.status === "applied") { + setSettings(prev => prev ? { ...prev, catalogRefreshPending: false } : prev); + } if (data.projectConfigGrouped) setProjectConfigWarnings(data.projectConfigGrouped); } catch (err) { setSyncError(err instanceof Error ? err.message : String(err)); @@ -789,7 +801,7 @@ export function useDashboardData(apiBase: string) { effortCapHelpTriggerRef, updateTriggerRef, maHelpTriggerRef, shadowCallHelpTriggerRef, effortCapHelpDialogRef, updateDialogRef, maHelpDialogRef, shadowCallHelpDialogRef, filteredGroups, sidecarModels, visionModels, - saveSidecar, saveShadowCall, switchMaMode, toggleCodexAutoStart, runSync, clearSyncFeedback, + saveSidecar, saveShadowCall, switchMaMode, toggleCodexAutoStart, toggleCodexDesktopAuthless, runSync, clearSyncFeedback, fetchUpdateCheck, closeUpdateDialog, openUpdateDialog, changeUpdateChannel, runUpdate, }; } diff --git a/gui/tests/vision-sidecar-dashboard.test.tsx b/gui/tests/vision-sidecar-dashboard.test.tsx index dc762de58f..738ed866e6 100644 --- a/gui/tests/vision-sidecar-dashboard.test.tsx +++ b/gui/tests/vision-sidecar-dashboard.test.tsx @@ -12,7 +12,8 @@ import { LanguageProvider } from "../src/i18n/provider"; import { DashboardSidecarPanels } from "../src/pages/dashboard-overview-sections"; import type { SidecarData, SidecarPatch } from "../src/pages/dashboard-shared"; import { mergeSidecarSetting } from "../src/pages/dashboard-shared"; -import type { useDashboardData } from "../src/pages/use-dashboard-data"; +import { useDashboardData } from "../src/pages/use-dashboard-data"; +import { setClientResourceData } from "../src/client-resource"; const globals = ["document", "window", "navigator", "IS_REACT_ACT_ENVIRONMENT"] as const; let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; @@ -382,4 +383,114 @@ test("model and reasoning saves still omit enabled, limit, and timeout", async ( expect(patches).toHaveLength(2); expect(patches[1]).toEqual({ vision: { reasoning: "high" } }); assertVisionControlFieldsOmitted(patches[1]!); -}); \ No newline at end of file +}); + +test("Desktop login switch defaults off, preserves explicit opt-in, and disables while saving", async () => { + const { d } = harness(); + let clicks = 0; + d.toggleCodexDesktopAuthless = async () => { clicks += 1; }; + d.settings = { codexAutoStart: true, port: 10100, hostname: "127.0.0.1" }; + await mount(d); + const toggle = () => host.querySelector(`button[aria-label="${en["dash.codexDesktopAuthless"]}"]`)!; + expect(toggle().getAttribute("aria-pressed")).toBe("false"); + d.settings.codexDesktopAuthless = true; + await mount(d); + expect(toggle().getAttribute("aria-pressed")).toBe("true"); + await act(async () => { toggle().click(); }); + expect(clicks).toBe(1); + d.settings.codexDesktopAuthless = false; + d.settings.catalogRefreshPending = true; + d.settingsSaving = true; + await mount(d); + expect(toggle().getAttribute("aria-pressed")).toBe("false"); + expect(toggle().disabled).toBe(true); + expect(host.textContent).toContain(en["codexAuth.catalogRefreshPending"]); +}); + + +test.each([undefined, false, true])("Desktop login preference %s persists before full sync; sync failure keeps the saved preference", async (initial) => { + const originalFetch = globalThis.fetch; + const writes: Array<{ path: string; body: unknown }> = []; + let latest: Dash | undefined; + let saved = initial; + const apiBase = `/authless-test-${String(initial)}`; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)); + writes.push({ path, body }); + if (body.codexDesktopAuthless !== undefined) { + saved = body.codexDesktopAuthless; + return Response.json({ codexDesktopAuthless: saved, catalogRefreshPending: true }); + } + return Response.json({ codexAutoStart: body.codexAutoStart, catalogRefreshPending: false }); + } + if (path.endsWith("/api/sync")) { + writes.push({ path, body: null }); + return Response.json({ error: "sync unavailable" }, { status: 503 }); + } + if (path.endsWith("/api/settings")) { + return Response.json({ codexAutoStart: true, codexDesktopAuthless: saved, port: 10100, hostname: "127.0.0.1" }); + } + return Response.json({}, { status: 503 }); + }) as typeof fetch; + function Harness() { latest = useDashboardData(apiBase); return null; } + try { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); + expect(latest?.settings?.codexDesktopAuthless).toBe(initial); + await act(async () => { await latest!.toggleCodexDesktopAuthless(); }); + expect(writes).toEqual([ + { path: `${apiBase}/api/settings`, body: { codexDesktopAuthless: !initial } }, + { path: `${apiBase}/api/sync`, body: null }, + ]); + expect(latest?.settings?.codexDesktopAuthless).toBe(!initial); + expect(latest?.syncError).toBe("sync unavailable"); + expect(latest?.settings?.catalogRefreshPending).toBe(true); + await act(async () => { await latest!.toggleCodexAutoStart(); }); + expect(latest?.settings?.codexAutoStart).toBe(false); + expect(latest?.settings?.catalogRefreshPending).toBe(true); + } finally { + await act(async () => { root?.unmount(); }); + root = null; + globalThis.fetch = originalFetch; + } +}); + + +test.each(["skipped", "catalog-only", "applied"])("Desktop preference pending state follows %s sync application evidence", async (syncStatus) => { + const originalFetch = globalThis.fetch; + let latest: Dash | undefined; + const apiBase = `/authless-sync-${syncStatus}`; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (init?.method === "PUT") return Response.json({ codexDesktopAuthless: true, catalogRefreshPending: true }); + if (path.endsWith("/api/sync")) return Response.json({ ok: true, status: syncStatus, message: syncStatus }); + if (path.endsWith("/api/settings")) return Response.json({ codexAutoStart: true, codexDesktopAuthless: false, port: 10100, hostname: "127.0.0.1" }); + return Response.json({}, { status: 503 }); + }) as typeof fetch; + function Harness() { latest = useDashboardData(apiBase); return null; } + try { + const { createRoot } = await import("react-dom/client"); + await act(async () => { root = createRoot(host); root.render(); }); + await act(async () => { await latest!.toggleCodexDesktopAuthless(); }); + expect(latest?.settings?.codexDesktopAuthless).toBe(true); + expect(latest?.settings?.catalogRefreshPending).toBe(syncStatus !== "applied"); + expect(latest?.syncResult?.status).toBe(syncStatus); + // A fresh settings poll has no application receipt and cannot erase pending. + await act(async () => { + setClientResourceData(`dashboard-settings:${apiBase}`, { + settings: { codexAutoStart: true, codexDesktopAuthless: true, port: 10100, hostname: "127.0.0.1" }, + }); + }); + expect(latest?.settings?.codexDesktopAuthless).toBe(true); + expect(latest?.settings?.catalogRefreshPending === true).toBe(syncStatus !== "applied"); + } finally { + await act(async () => { root?.unmount(); }); + root = null; + globalThis.fetch = originalFetch; + } +}); diff --git a/src/server/startup-health-cache.ts b/src/server/startup-health-cache.ts index 70380eb4ed..2c12e0bbc3 100644 --- a/src/server/startup-health-cache.ts +++ b/src/server/startup-health-cache.ts @@ -50,6 +50,23 @@ export interface StartupHealthCacheDeps { ) => Promise; } +/** + * Return the last completed probe immediately and refresh it in the background. + * + * Settings are consumed by several dashboard controls. They must not block on a + * Windows service-manager probe; the dedicated /api/startup-health route owns + * the fresh, bounded diagnostic read. + */ +export function getStartupHealthSnapshot( + config: Pick, + deps: StartupHealthCacheDeps = {}, +): StartupHealth { + const now = deps.now ?? Date.now; + if (cached && now() - cached.timestamp < CACHE_TTL_MS) return cached.value; + refreshInBackground(config, deps); + return cached ? markStartupHealthDiagnosticStale(cached.value) : conservativeFallback(config); +} + export function markStartupHealthDiagnosticStale(value: StartupHealth): StartupHealth { if (!value.localRoutingDependency) return { ...value, diagnosticStale: true }; return { @@ -134,15 +151,20 @@ function refreshInBackground( ): void { if (inflight) return; const startedGeneration = generation; - const probe = (deps.probe ?? runProbe)(config).then(value => { - if (startedGeneration === generation) { - cached = { timestamp: (deps.now ?? Date.now)(), value }; - } - return value; - }); - inflight = probe.finally(() => { - if (inflight === probe || startedGeneration === generation) inflight = null; - }); + const probe: Promise = Promise.resolve() + .then(() => (deps.probe ?? runProbe)(config)) + .then(value => { + if (startedGeneration === generation) { + cached = { timestamp: (deps.now ?? Date.now)(), value }; + } + return value; + }) + .catch(() => cached ? markStartupHealthDiagnosticStale(cached.value) : conservativeFallback(config)) + .finally(() => { + // An invalidated probe must never clear the newer generation's flight. + if (inflight === probe) inflight = null; + }); + inflight = probe; } /** Stale-while-revalidate: service-manager probes never hold open a model/UI request. */ diff --git a/tests/codex-integration/codex-inject.test.ts b/tests/codex-integration/codex-inject.test.ts index 84ac5f67b6..b6be3c2f69 100644 --- a/tests/codex-integration/codex-inject.test.ts +++ b/tests/codex-integration/codex-inject.test.ts @@ -31,8 +31,8 @@ describe("Codex config injection", () => { }); describe("authless Codex Desktop opt-in (#1107)", () => { - test("default target on loopback stays Design B and byte-identical", () => { - const target = standaloneCodexRoutingTarget(10100, {}); + test.each([undefined, false])("disabled preference %s on loopback stays Design B and byte-identical", (codexDesktopAuthless) => { + const target = standaloneCodexRoutingTarget(10100, { codexDesktopAuthless }); expect(target.desktopAuthless).toBeUndefined(); expect(buildProfileFile(target, null)).toBe(buildProfileFile(10100, null)); expect(buildProviderTableBlock(target)).toContain("requires_openai_auth = true"); diff --git a/tests/service/autostart-health.test.ts b/tests/service/autostart-health.test.ts index 639f1b34c3..213a118e2b 100644 --- a/tests/service/autostart-health.test.ts +++ b/tests/service/autostart-health.test.ts @@ -3,7 +3,7 @@ import { deriveStartupHealth, formatStartupRoutingDetail, startupHealthSummary } import { unusedProxyWarningLines } from "../../src/cli/status"; import { classifyCodexRouting, hasInjectedCodexRouting } from "../../src/codex/inject"; import { handleManagementAPI } from "../../src/server/management-api"; -import { getCachedStartupHealth, invalidateStartupHealthCache, markStartupHealthDiagnosticStale } from "../../src/server/startup-health-cache"; +import { getCachedStartupHealth, getStartupHealthSnapshot, invalidateStartupHealthCache, markStartupHealthDiagnosticStale } from "../../src/server/startup-health-cache"; import type { OcxConfig } from "../../src/types"; const base = { @@ -277,6 +277,89 @@ describe("Codex startup health", () => { await pendingProbe; invalidateStartupHealthCache(); }); + + test("settings snapshot starts a probe without waiting for it", async () => { + invalidateStartupHealthCache(); + let releaseProbe!: (value: ReturnType) => void; + const pendingProbe = new Promise>(resolve => { + releaseProbe = resolve; + }); + + const health = getStartupHealthSnapshot( + { codexAutoStart: true }, + { probe: async () => pendingProbe }, + ); + + expect(health.diagnosticStale).toBe(true); + releaseProbe(deriveStartupHealth({ ...base, routingKind: "native" })); + await pendingProbe; + invalidateStartupHealthCache(); + }); + + test("snapshot preserves fresh protection and returns expired protection before a controlled probe settles", async () => { + invalidateStartupHealthCache(); + let now = 1_000; + const config = { codexAutoStart: true }; + const protectedHealth = deriveStartupHealth({ ...base, serviceInstalled: true, serviceViable: true, serviceEnabled: true, serviceRunning: true }); + await getCachedStartupHealth(config, { now: () => now, probe: async () => protectedHealth, waitForProbe: probe => probe }); + let calls = 0; + let release!: (value: typeof protectedHealth) => void; + const pending = new Promise(resolve => { release = resolve; }); + const deps = { now: () => now, probe: () => { calls += 1; return pending; }, waitForProbe: (probe: Promise) => probe }; + expect(getStartupHealthSnapshot(config, deps)).toBe(protectedHealth); + expect(calls).toBe(0); + now += 30_000; + const snapshot = getStartupHealthSnapshot(config, deps); + expect(snapshot).toMatchObject({ diagnosticStale: true, status: "at-risk", rebootSafe: false }); + // Snapshot has returned while the manually controlled probe remains unresolved. + expect(getStartupHealthSnapshot(config, deps)).toEqual(snapshot); + const fresh = getCachedStartupHealth(config, deps); + const replacement = deriveStartupHealth({ ...base, routingKind: "custom-remote" }); + release(replacement); + expect(await fresh).toBe(replacement); + expect(calls).toBe(1); + invalidateStartupHealthCache(); + }); + + test.each(["reject", "throw"])("detached snapshot probe handles %s and permits a later retry", async (failure) => { + invalidateStartupHealthCache(); + const config = { codexAutoStart: true }; + const failed = getStartupHealthSnapshot(config, { probe: () => { + if (failure === "throw") throw new Error("controlled probe failure"); + return Promise.reject(new Error("controlled probe failure")); + } }); + expect(failed.diagnosticStale).toBe(true); + const settled = await getCachedStartupHealth(config, { waitForProbe: probe => probe }); + expect(settled.diagnosticStale).toBe(true); + const replacement = deriveStartupHealth({ ...base, routingKind: "native" }); + expect(await getCachedStartupHealth(config, { probe: async () => replacement, waitForProbe: probe => probe })).toBe(replacement); + invalidateStartupHealthCache(); + }); + + test("invalidated probe cannot replace or clear a newer flight", async () => { + invalidateStartupHealthCache(); + const config = { codexAutoStart: true }; + type Health = ReturnType; + let oldRelease!: (value: Health) => void; + let newRelease!: (value: Health) => void; + const oldProbe = new Promise(resolve => { oldRelease = resolve; }); + const newProbe = new Promise(resolve => { newRelease = resolve; }); + getStartupHealthSnapshot(config, { probe: () => oldProbe }); + const oldWait = getCachedStartupHealth(config, { waitForProbe: probe => probe }); + invalidateStartupHealthCache(); + getStartupHealthSnapshot(config, { probe: () => newProbe }); + const newer = getCachedStartupHealth(config, { waitForProbe: probe => probe }); + oldRelease(deriveStartupHealth(base)); + await oldWait; + let spuriousCalls = 0; + getStartupHealthSnapshot(config, { probe: async () => { spuriousCalls += 1; return deriveStartupHealth(base); } }); + const expected = deriveStartupHealth({ ...base, routingKind: "native" }); + newRelease(expected); + expect(await newer).toBe(expected); + expect(getStartupHealthSnapshot(config)).toBe(expected); + expect(spuriousCalls).toBe(0); + invalidateStartupHealthCache(); + }); }); import { ManagementRequest as Request } from "../helpers/management-auth";