diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 49cd7d6113e..0405b2807d9 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -169,24 +169,24 @@ unknown signifie qu’opencodex ne peut pas déterminer si les réglages pointen ### Mode picker : modèles opencodex dans le sélecteur Code first-party -Le mode picker fait partie du mode first-party. Sur macOS, il est activé par défaut lorsque first-party +Le mode picker fait partie du mode first-party. Sur macOS et Windows, il est activé par défaut lorsque first-party est sélectionné, sauf si `claudeCode.intercept.picker: false` est défini. Il modifie le sélecteur de modèles de l'onglet Code de Desktop first-party pour y afficher les modèles opencodex disponibles par leur nom. Lors de la première activation, macOS peut demander l'autorisation d'une autorité de certification -locale dans le trousseau de connexion. Cette autorité est limitée à `claude.ai` et à ses sous-domaines. +locale dans le trousseau de connexion, et Windows dans le magasin racine de l'utilisateur actuel. Cette autorité est limitée à `claude.ai` et à ses sous-domaines. Son identité de signature exportable est protégée par le magasin d'identifiants du système et réutilisée lors des redémarrages ordinaires. Aucune clé de signature picker en clair n'est enregistrée dans le répertoire de configuration OpenCodex. La validation complète de l'autorité contrainte et la vérification -de la confiance du système restent obligatoires. Avec la même identité approuvée et un magasin accessible, -un redémarrage n'ajoute ni ne supprime de réglages de confiance des certificats. La restauration au démarrage -n'installe jamais la confiance : si elle manque, est révoquée ou inconnue, le picker reste en attente. -Exécutez explicitement `ocx claude desktop picker on` ou `ocx claude desktop picker trust` pour l'accorder. +de la confiance du système restent obligatoires, avec sur Windows la correspondance exacte de l'empreinte et une feuille vérifiée. Avec la même identité approuvée et un magasin accessible, +un redémarrage n'ajoute ni ne supprime de réglages de confiance des certificats macOS. La restauration au démarrage +ou la vérification d'état n'installe jamais la confiance : si elle manque, est révoquée ou inconnue, le picker reste en attente. +Exécutez explicitement `ocx claude desktop picker on` ou `ocx claude desktop picker trust` pour l'accorder, y compris la confirmation Windows si nécessaire. Une migration unique depuis une ancienne identité peut demander votre consentement pour supprimer sa confiance. Si ce nettoyage échoue, le picker reste indisponible et le profil appliqué utilise un relais sans déchiffrement jusqu'à sa réussite. macOS peut aussi demander de déverrouiller le trousseau ou -d'autoriser l'accès d'une application aux identifiants, y compris après un redémarrage ou une mise à jour. -Le picker reste non pris en charge sous Windows et Linux : aucun travail de CA, de magasin d'identifiants +d'autoriser l'accès d'une application aux identifiants, y compris après un redémarrage ou une mise à jour. Sur Windows, `on` ou `trust` explicite peut afficher une confirmation d'ajout de la racine. +Le picker reste non pris en charge sous Linux : aucun travail de CA, de magasin d'identifiants ou de proxy picker n'y démarre. L'interception Claude principale reste disponible ; ses fichiers CA locaux sont protégés par des vérifications du propriétaire, des liens symboliques, des permissions et des ACL Windows. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 52558d6c9b8..3a577733454 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -315,10 +315,13 @@ entries, is kept. Claude Desktop first-party routes its Code tab and subagents through OpenCodex. The standalone Claude Code CLI has a separate first-party switch. Both clients read the same `~/.claude/settings.json` proxy and CA settings: if only one switch is on, the other client still transits the local proxy, where TLS terminates, but its Messages requests relay to Anthropic unchanged. Other Anthropic paths relay unchanged and unrelated hosts remain blind tunnels. With the CLI switch on, the standalone `claude` also lists routed models in `/model`; see [Model picker in the CLI](#model-picker-in-the-cli). :::note[Windows system proxy (Clash, v2rayN, corporate proxies)] -When a Windows system proxy is on, Claude Desktop hands it to the Code tab as `HTTPS_PROXY`, and +For picker-off installs or pending installs with no applied picker profile, a Windows system proxy +is handed to the Code tab as `HTTPS_PROXY`, and that value takes precedence over the OpenCodex proxy in `~/.claude/settings.json`. The Code tab then goes around OpenCodex and routed models fail there, while the standalone CLI keeps working. -Add `api.anthropic.com` to your proxy client's system-proxy bypass list (in Clash Verge, +An applied selected picker profile uses Desktop's fixed egress proxy while it is available, even when +missing trust leaves the picker pending and `claude.ai` relays blindly. System-proxy bypass advice +applies when there is no selected available picker profile. Add `api.anthropic.com` to your proxy client's system-proxy bypass list (in Clash Verge, `system_proxy_bypass`), then fully quit and reopen Claude Desktop. `ocx doctor` reports this under "Claude Desktop first-party vs Windows system proxy". It cannot evaluate a PAC script or automatic proxy detection (WPAD, "Automatically detect settings"), so it reports those as unknown; @@ -349,17 +352,17 @@ hub, so `ocx claude desktop apply` there uses the gateway profile. ### Picker mode: opencodex models in the first-party Code-tab picker -Picker mode is part of first-party mode. On macOS it is on by default when first-party is selected, +Picker mode is part of first-party mode. On macOS and Windows it is on by default when first-party is selected, unless `claudeCode.intercept.picker: false` is set. It changes the first-party Desktop Code-tab picker so it lists available opencodex models by name. The first time it is enabled, macOS may ask you to -trust a local certificate authority in the login keychain. That authority is constrained to `claude.ai` +trust a local certificate authority in the login keychain, and Windows may ask you to trust it in the current-user Root store. That authority is constrained to `claude.ai` and its subdomains. OpenCodex protects its exportable signing identity in the OS credential store and reuses the same validated certificate and key across normal restarts. No plaintext picker signing key is stored in the OpenCodex config directory. The full constrained CA validation and OS trust -verification still apply. With the same approved identity and an available credential store, restarting -OpenCodex does not add or remove Certificate Trust Settings. Startup restore never installs trust: +verification still apply, including the exact fingerprint with a verified leaf on Windows. With the same approved identity and an available credential store, restarting +OpenCodex does not add or remove macOS Certificate Trust Settings. Restart and status never install trust: if trust is missing, revoked or unknown, the picker stays pending. Run `ocx claude desktop picker on` -or `ocx claude desktop picker trust` explicitly to grant trust. +or `ocx claude desktop picker trust` explicitly to grant trust, including the Windows confirmation when needed. Startup also attempts to remove a legacy on-disk picker signing key before checking whether interception is enabled. Cleanup is best-effort and does not enable interception or block startup. @@ -367,9 +370,9 @@ One-time migration from an older picker identity may require consent to remove i If cleanup cannot finish, the picker stays unavailable and the applied profile uses a blind relay until cleanup succeeds; `ocx claude desktop picker status` reports that state. macOS may separately ask you to unlock the keychain or approve an application's access to stored credentials. These native -access prompts can still occur on restart or upgrade. +access prompts can still occur on restart or upgrade. On Windows, explicit `on` or `trust` may show a confirmation to add the root. -Picker mode remains unsupported on Windows and Linux: OpenCodex starts no picker CA, credential-store +Picker mode remains unsupported on Linux: OpenCodex starts no picker CA, credential-store or proxy work there. The main Claude intercept remains available with ownership, symlink, file-permission and Windows ACL checks protecting its local CA files. diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 4d1cd5938b4..27618ac397d 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -166,21 +166,21 @@ unknown は設定がまだ opencodex のプロキシを指すか判断できな ### Picker モード: 1P の Code タブで opencodex モデルを表示する -Picker モードは 1P モードの一部です。macOS で 1P を選ぶとデフォルトで有効になりますが、 +Picker モードは 1P モードの一部です。macOS と Windows で 1P を選ぶとデフォルトで有効になりますが、 `claudeCode.intercept.picker: false` を設定した場合は無効です。1P の Desktop の Code タブにある モデルピッカーを書き換え、利用できる opencodex モデルを名前付きで表示します。初回の有効化時は、 -macOS がログインキーチェーン内のローカル証明書認証局を信頼するよう求めることがあります。この認証局の +macOS がログインキーチェーン内、Windows が現在のユーザーのルートストアでローカル証明書認証局を信頼するよう求めることがあります。この認証局の 制約は `claude.ai` とそのサブドメインに限られます。エクスポート可能な署名用 ID は OS の資格情報ストアで 保護され、通常の再起動でも同じ証明書と鍵を再利用します。OpenCodex の設定ディレクトリには Picker の -署名鍵を平文で保存しません。制約付き CA の完全な検証と OS の信頼確認は引き続き必要です。 -承認済みの同じ ID と利用可能な資格情報ストアがあれば、再起動で証明書の信頼設定を追加・削除しません。 -起動時の復元は信頼をインストールしません。信頼が未設定、取り消し済み、不明の場合は保留となり、 -`ocx claude desktop picker on` または `ocx claude desktop picker trust` を明示的に実行して信頼を付与します。 +署名鍵を平文で保存しません。制約付き CA の完全な検証と OS の信頼確認は引き続き必要です(Windows では正確なフィンガープリントと検証済みリーフで確認します)。 +承認済みの同じ ID と利用可能な資格情報ストアがあれば、再起動で macOS の証明書の信頼設定を追加・削除しません。 +起動時の復元や状態確認は信頼をインストールしません。信頼が未設定、取り消し済み、不明の場合は保留となり、 +`ocx claude desktop picker on` または `ocx claude desktop picker trust` を明示的に実行して信頼を付与します(Windows では確認が表示されることがあります)。 旧 ID からの一度限りの移行では、以前の信頼を削除するために同意が必要になる場合があります。 削除が完了するまでは Picker は利用できず、適用済みプロファイルは復号しない中継を使います。 キーチェーンのロック解除やアプリの資格情報へのアクセス許可は macOS 独自の動作で、再起動や更新時にも -表示される場合があります。Windows と Linux は Picker 非対応で、Picker の CA、資格情報ストア、 +表示される場合があります。Windows では `on` や `trust` の明示的な実行時にルート追加の確認が表示されることがあります。Linux は Picker 非対応で、Picker の CA、資格情報ストア、 プロキシの処理を開始しません。通常の Claude インターセプトは利用でき、ローカル CA ファイルには 所有者、シンボリックリンク、ファイル権限、Windows ACL の検査が適用されます。 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index f4a6f3354ec..eb05333120d 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -192,21 +192,21 @@ unknown은 설정이 아직 opencodex 프록시를 가리키는지 판단할 수 ### Picker 모드: 1P Code 탭에 opencodex 모델 표시하기 -Picker 모드는 1P 모드의 일부예요. macOS에서 1P를 선택하면 기본으로 켜지지만, +Picker 모드는 1P 모드의 일부예요. macOS와 Windows에서 1P를 선택하면 기본으로 켜지지만, `claudeCode.intercept.picker: false`를 설정하면 꺼져요. 1P Desktop의 Code 탭 모델 선택기를 바꿔서 -사용 가능한 opencodex 모델을 이름으로 보여줘요. 처음 켤 때 macOS 로그인 키체인에서 로컬 인증 기관을 +사용 가능한 opencodex 모델을 이름으로 보여줘요. 처음 켤 때 macOS 로그인 키체인이나 Windows 현재 사용자 Root 저장소에서 로컬 인증 기관을 신뢰하라는 메시지가 표시될 수 있어요. 이 인증 기관은 `claude.ai`와 그 하위 도메인으로 제한돼요. 내보낼 수 있는 서명용 인증서와 키는 OS 자격 증명 저장소로 보호하며, 일반적인 재시작에서는 같은 것을 재사용해요. OpenCodex 설정 디렉터리에 Picker 서명 키를 평문으로 저장하지 않아요. 제한된 CA의 전체 검증과 OS 신뢰 확인은 -계속 적용돼요. 승인한 인증서와 키가 같고 자격 증명 저장소를 사용할 수 있다면 재시작 시 인증서 신뢰 설정을 -추가하거나 삭제하지 않아요. 시작 시 복원은 신뢰를 설치하지 않아요. 신뢰가 없거나 철회됐거나 확인할 수 없다면 +계속 적용돼요(Windows에서는 정확한 지문과 확인된 리프로 검증해요). 승인한 인증서와 키가 같고 자격 증명 저장소를 사용할 수 있다면 재시작 시 macOS 인증서 신뢰 설정을 +추가하거나 삭제하지 않아요. 시작 시 복원이나 상태 확인은 신뢰를 설치하지 않아요. 신뢰가 없거나 철회됐거나 확인할 수 없다면 Picker는 대기 상태로 남아요. `ocx claude desktop picker on` 또는 `ocx claude desktop picker trust`를 직접 실행해 -신뢰를 부여하면 돼요. +신뢰를 부여하면 돼요(Windows에서는 확인 창이 나올 수 있어요). 이전 인증서에서 한 번 마이그레이션할 때는 기존 신뢰를 제거하기 위한 동의가 필요할 수 있어요. 정리가 끝나지 않으면 Picker는 사용할 수 없고 적용된 프로필은 복호화하지 않는 중계로 동작해요. 키체인 잠금 해제나 앱의 -자격 증명 접근 허용은 별도의 macOS 동작이며, 재시작이나 업데이트 때도 메시지가 나올 수 있어요. -Windows와 Linux에서는 Picker를 지원하지 않으며 Picker CA, 자격 증명 저장소, 프록시 작업을 시작하지 않아요. +자격 증명 접근 허용은 별도의 macOS 동작이며, 재시작이나 업데이트 때도 메시지가 나올 수 있어요. Windows에서는 `on`이나 `trust`를 직접 실행할 때 루트 추가 확인이 나올 수 있어요. +Linux에서는 Picker를 지원하지 않으며 Picker CA, 자격 증명 저장소, 프록시 작업을 시작하지 않아요. 기본 Claude 인터셉트는 계속 사용할 수 있고, 로컬 CA 파일에 소유자, 심볼릭 링크, 파일 권한, Windows ACL 검사를 적용해요. Picker 모드가 켜져 있는 동안 Claude Desktop의 네트워크는 OpenCodex를 거쳐요. OpenCodex가 중단되면 diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 13c5632fb0f..c520548e72b 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -180,22 +180,22 @@ unknown означает, что opencodex не может определить, ### Режим picker: модели opencodex в селекторе Code first-party -Режим picker входит в режим first-party. В macOS он включён по умолчанию при выборе first-party, +Режим picker входит в режим first-party. В macOS и Windows он включён по умолчанию при выборе first-party, если не задано `claudeCode.intercept.picker: false`. Он изменяет селектор моделей во вкладке Code first-party Desktop и показывает доступные модели opencodex по именам. При первом включении macOS может -попросить доверить локальному центру сертификации в связке ключей для входа. Этот центр ограничен +попросить доверить локальному центру сертификации в связке ключей для входа, а Windows — в хранилище корней текущего пользователя. Этот центр ограничен `claude.ai` и его поддоменами. Экспортируемая идентичность подписи защищена хранилищем учётных данных ОС и повторно используется при обычных перезапусках. Ключ подписи picker не хранится открытым текстом в -каталоге конфигурации OpenCodex. Полная проверка ограниченного CA и доверия ОС сохраняется. Если одобренная +каталоге конфигурации OpenCodex. Полная проверка ограниченного CA и доверия ОС сохраняется, в Windows — по точному отпечатку с проверенным листом. Если одобренная идентичность не изменилась и хранилище доступно, перезапуск не добавляет и не удаляет настройки доверия -сертификатам. Восстановление при запуске никогда не устанавливает доверие: если оно отсутствует, отозвано +сертификатам macOS. Восстановление при запуске и проверка состояния никогда не устанавливают доверие: если оно отсутствует, отозвано или неизвестно, picker остаётся в ожидании. Для предоставления доверия явно выполните -`ocx claude desktop picker on` или `ocx claude desktop picker trust`. +`ocx claude desktop picker on` или `ocx claude desktop picker trust`, включая подтверждение Windows при необходимости. При однократной миграции со старой идентичности может потребоваться согласие на удаление прежнего доверия. Пока очистка не завершена, picker недоступен, а применённый профиль использует туннель без расшифровки. macOS отдельно может попросить разблокировать связку ключей или разрешить приложению доступ к учётным -данным, в том числе после перезапуска или обновления. Windows и Linux по-прежнему не поддерживают picker: +данным, в том числе после перезапуска или обновления. В Windows явные `on` или `trust` могут запросить подтверждение добавления корня. Linux по-прежнему не поддерживает picker: работа с его CA, хранилищем и прокси не запускается. Основной перехват Claude остаётся доступен; для его локальных файлов CA проверяются владелец, символические ссылки, права доступа и ACL Windows. diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 82340db7c25..3b6b953d344 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -191,22 +191,22 @@ unknown, ayarların hâlâ opencodex vekiline işaret edip etmediğinin belirlen ### Picker modu: first-party Code sekmesinde opencodex modelleri -Picker modu first-party modunun bir parçasıdır. macOS'ta first-party seçildiğinde varsayılan olarak +Picker modu first-party modunun bir parçasıdır. macOS ve Windows'ta first-party seçildiğinde varsayılan olarak açıktır; `claudeCode.intercept.picker: false` ayarlanırsa kapalı kalır. First-party Desktop'ın Code sekmesindeki model seçiciyi değiştirerek kullanılabilir opencodex modellerini adlarıyla listeler. -İlk etkinleştirmede macOS, giriş anahtar zincirinde yerel bir sertifika yetkilisine güvenmenizi isteyebilir. +İlk etkinleştirmede macOS, giriş anahtar zincirinde, Windows ise geçerli kullanıcının kök deposunda yerel bir sertifika yetkilisine güvenmenizi isteyebilir. Bu yetkili `claude.ai` ve alt alan adlarıyla sınırlıdır. Dışa aktarılabilir imzalama kimliği işletim sisteminin kimlik bilgisi deposunda korunur ve normal yeniden başlatmalarda aynı sertifika ve anahtar yeniden kullanılır. OpenCodex yapılandırma dizininde düz metin picker imza anahtarı saklanmaz. Kısıtlı CA'nın tam doğrulaması ve -sistemdeki güven denetimi devam eder. Onaylanmış kimlik aynıysa ve depo erişilebilirse yeniden başlatma, -sertifika güven ayarlarını eklemez veya kaldırmaz. Başlangıçta geri yükleme hiçbir zaman güven yüklemez: +sistemdeki güven denetimi devam eder (Windows'ta tam parmak izi ve doğrulanmış yaprak ile). Onaylanmış kimlik aynıysa ve depo erişilebilirse yeniden başlatma, +macOS sertifika güven ayarlarını eklemez veya kaldırmaz. Başlangıçta geri yükleme ve durum denetimi hiçbir zaman güven yüklemez: güven eksik, iptal edilmiş veya bilinmiyorsa picker beklemede kalır. Güven vermek için açıkça -`ocx claude desktop picker on` veya `ocx claude desktop picker trust` komutunu çalıştırın. +`ocx claude desktop picker on` veya `ocx claude desktop picker trust` komutunu çalıştırın, gerekiyorsa Windows onayıyla birlikte. Eski bir kimlikten bir defalık geçişte önceki güveni kaldırmak için onay gerekebilir. Temizlik tamamlanana kadar picker kullanılamaz ve uygulanmış profil şifre çözmeyen bir aktarma kullanır. macOS ayrıca anahtar zincirinin kilidini açmanızı veya uygulamanın kimlik bilgilerine erişimini onaylamanızı isteyebilir; -bu istemler yeniden başlatma veya güncelleme sonrasında da çıkabilir. Windows ve Linux'ta picker desteklenmez: +bu istemler yeniden başlatma veya güncelleme sonrasında da çıkabilir. Windows'ta açık `on` veya `trust` kök ekleme onayını gösterebilir. Linux'ta picker desteklenmez: picker CA, kimlik bilgisi deposu veya proxy işlemleri başlatılmaz. Ana Claude yakalama işlevi kullanılabilir; yerel CA dosyaları için sahiplik, sembolik bağlantı, dosya izinleri ve Windows ACL denetimleri uygulanır. diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 5a28a1ac65b..8652c595cd7 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -158,18 +158,18 @@ unknown 表示 opencodex 无法确定设置是否仍指向自己的代理。外 ### Picker 模式:在第一方 Code 标签页中显示 opencodex 模型 -Picker 模式是第一方模式的一部分。在 macOS 上选择第一方时默认开启;设置 +Picker 模式是第一方模式的一部分。在 macOS 和 Windows 上选择第一方时默认开启;设置 `claudeCode.intercept.picker: false` 后会保持关闭。它会修改第一方 Desktop 的 Code 标签页模型选择器, -按名称列出可用的 opencodex 模型。首次开启时,macOS 可能会要求你在登录钥匙串中信任本地证书颁发机构。 +按名称列出可用的 opencodex 模型。首次开启时,macOS 可能会要求你在登录钥匙串中、Windows 可能会要求你在当前用户根存储中信任本地证书颁发机构。 该颁发机构限制为 `claude.ai` 及其子域名。可导出的签名身份由 OS 凭据存储保护,普通重启会复用同一证书和密钥。 -OpenCodex 配置目录中不保存明文 Picker 签名密钥。受限 CA 的完整验证和 OS 信任检查仍然适用。 -已批准的身份不变且凭据存储可用时,重启不会添加或删除证书信任设置。启动恢复绝不会安装信任: +OpenCodex 配置目录中不保存明文 Picker 签名密钥。受限 CA 的完整验证和 OS 信任检查仍然适用(Windows 按精确指纹与已验证叶子验证)。 +已批准的身份不变且凭据存储可用时,重启不会添加或删除 macOS 证书信任设置。启动恢复与状态检查绝不会安装信任: 若信任缺失、被撤销或无法确认,Picker 会保持待处理状态。请明确运行 `ocx claude desktop picker on` -或 `ocx claude desktop picker trust` 来授予信任。 +或 `ocx claude desktop picker trust` 来授予信任(Windows 需要时含确认)。 从旧身份进行一次性迁移时,清除原有信任可能需要同意。清理未完成时,Picker 不可用,已应用的配置档案 -会使用不解密的中继。macOS 还可能单独要求解锁钥匙串或批准应用访问凭据,重启或升级时也可能出现这些提示。 -Windows 和 Linux 仍不支持 Picker,不会启动 Picker CA、凭据存储或代理工作。主要 Claude 拦截功能仍可用, +会使用不解密的中继。macOS 还可能单独要求解锁钥匙串或批准应用访问凭据,重启或升级时也可能出现这些提示。Windows 上显式 `on` 或 `trust` 可能显示添加根的确认。 +Linux 仍不支持 Picker,不会启动 Picker CA、凭据存储或代理工作。主要 Claude 拦截功能仍可用, 其本地 CA 文件受到所有者、符号链接、文件权限及 Windows ACL 检查保护。 Picker 模式开启期间,Claude Desktop 通过 OpenCodex 访问网络。如果 OpenCodex 停止,Desktop 会处于离线状态, diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index c91637c60ae..dee9b6374a1 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -146,18 +146,18 @@ unknown 表示 opencodex 無法確定設定是否仍指向自己的代理。外 ### Picker 模式:在第一方 Code 分頁顯示 opencodex 模型 -Picker 模式是第一方模式的一部分。在 macOS 上選擇第一方時預設開啟;設定 +Picker 模式是第一方模式的一部分。在 macOS 和 Windows 上選擇第一方時預設開啟;設定 `claudeCode.intercept.picker: false` 後會保持關閉。它會修改第一方 Desktop 的 Code 分頁模型選擇器, -依名稱列出可用的 opencodex 模型。首次開啟時,macOS 可能會要求你在登入鑰匙圈中信任本機憑證授權單位。 +依名稱列出可用的 opencodex 模型。首次開啟時,macOS 可能會要求你在登入鑰匙圈中、Windows 可能會要求你在目前使用者根存放區中信任本機憑證授權單位。 該授權單位限制為 `claude.ai` 及其子網域。可匯出的簽章身分由 OS 認證資料儲存區保護,一般重新啟動會重用相同的憑證與金鑰。 -OpenCodex 設定目錄不會儲存明文 Picker 簽章金鑰。受限 CA 的完整驗證與 OS 信任檢查仍然適用。 -已核准的身分不變且認證資料儲存區可用時,重新啟動不會新增或移除憑證信任設定。啟動復原絕不會安裝信任: +OpenCodex 設定目錄不會儲存明文 Picker 簽章金鑰。受限 CA 的完整驗證與 OS 信任檢查仍然適用(Windows 按精確指紋與已驗證葉片驗證)。 +已核准的身分不變且認證資料儲存區可用時,重新啟動不會新增或移除 macOS 憑證信任設定。啟動復原與狀態檢查絕不會安裝信任: 若信任缺失、遭撤銷或無法確認,Picker 會維持待處理狀態。請明確執行 `ocx claude desktop picker on` -或 `ocx claude desktop picker trust` 來授予信任。 +或 `ocx claude desktop picker trust` 來授予信任(Windows 需要時含確認)。 從舊身分進行一次性移轉時,清除原有信任可能需要同意。清理未完成時,Picker 無法使用,已套用的設定檔 -會使用不解密的中繼。macOS 也可能另外要求解鎖鑰匙圈或核准應用程式存取憑證,重新啟動或升級時也可能出現這些提示。 -Windows 與 Linux 仍不支援 Picker,不會啟動 Picker CA、認證資料儲存區或代理作業。主要 Claude 攔截功能仍可用, +會使用不解密的中繼。macOS 也可能另外要求解鎖鑰匙圈或核准應用程式存取憑證,重新啟動或升級時也可能出現這些提示。Windows 上明確執行 `on` 或 `trust` 可能顯示新增根的確認。 +Linux 仍不支援 Picker,不會啟動 Picker CA、認證資料儲存區或代理作業。主要 Claude 攔截功能仍可用, 其本機 CA 檔案受到擁有者、符號連結、檔案權限及 Windows ACL 檢查保護。 Picker 模式開啟期間,Claude Desktop 會透過 OpenCodex 存取網路。如果 OpenCodex 停止,Desktop 會離線, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 4973d9e3b4c..8defeb54187 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3412,8 +3412,8 @@ export const de: Record = { "claudeDesktop.picker.toggle": "Claude-Desktop-Auswahl aktivieren", "claudeDesktop.picker.state.active": "Auswahl ist aktiv", "claudeDesktop.picker.state.restart": "Claude Desktop vollständig beenden und erneut öffnen, um das Auswahlprofil anzuwenden.", - "claudeDesktop.picker.state.trustPending": "Warten auf den Schlüsselbundschritt", - "claudeDesktop.picker.state.trustDeclined": "Schlüsselbundvertrauen wurde abgelehnt", + "claudeDesktop.picker.state.trustPending": "Warten auf den Zertifikatvertrauensschritt", + "claudeDesktop.picker.state.trustDeclined": "Zertifikatvertrauen wurde abgelehnt", "claudeDesktop.picker.state.proxyUnavailable": "Auswahlproxy läuft nicht", "claudeDesktop.picker.state.unsupported": "Auswahlmodus wird auf diesem Betriebssystem nicht unterstützt", "claudeDesktop.picker.state.notFirstParty": "Die Auswahl ist nur im First-Party-Modus verfügbar", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 29e27e4c18a..5ebdd9eb2ea 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3526,8 +3526,8 @@ export const en = { "claudeDesktop.picker.toggle": "Enable Claude Desktop picker", "claudeDesktop.picker.state.active": "Picker is active", "claudeDesktop.picker.state.restart": "Fully quit and reopen Claude Desktop to finish applying the picker profile.", - "claudeDesktop.picker.state.trustPending": "Waiting for the keychain step", - "claudeDesktop.picker.state.trustDeclined": "Keychain trust was declined", + "claudeDesktop.picker.state.trustPending": "Waiting for certificate trust", + "claudeDesktop.picker.state.trustDeclined": "Certificate trust was declined", "claudeDesktop.picker.state.proxyUnavailable": "Picker proxy is not running", "claudeDesktop.picker.state.unsupported": "Picker mode is unsupported on this OS", "claudeDesktop.picker.state.notFirstParty": "Picker is available only in first-party mode", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 1f387a8bafb..595ddad9648 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3439,8 +3439,8 @@ export const fr: Record = { "claudeDesktop.picker.toggle": "Activer le sélecteur Claude Desktop", "claudeDesktop.picker.state.active": "Sélecteur actif", "claudeDesktop.picker.state.restart": "Quittez complètement Claude Desktop, puis rouvrez-le pour appliquer le profil du sélecteur.", - "claudeDesktop.picker.state.trustPending": "En attente de l’étape du trousseau", - "claudeDesktop.picker.state.trustDeclined": "La confiance du trousseau a été refusée", + "claudeDesktop.picker.state.trustPending": "En attente de l’étape de confiance du certificat", + "claudeDesktop.picker.state.trustDeclined": "La confiance du certificat a été refusée", "claudeDesktop.picker.state.proxyUnavailable": "Le proxy du sélecteur n’est pas démarré", "claudeDesktop.picker.state.unsupported": "Le mode sélecteur n’est pas pris en charge sur ce système", "claudeDesktop.picker.state.notFirstParty": "Le sélecteur est disponible uniquement en mode first-party", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ceca93cc617..68ff0ddd660 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3128,8 +3128,8 @@ export const ja: Record = { "claudeDesktop.picker.toggle": "Claude Desktop ピッカーを有効化", "claudeDesktop.picker.state.active": "ピッカーは有効です", "claudeDesktop.picker.state.restart": "ピッカープロファイルの適用を完了するには、Claude Desktop を完全に終了して再起動してください。", - "claudeDesktop.picker.state.trustPending": "キーチェーンの手順を待っています", - "claudeDesktop.picker.state.trustDeclined": "キーチェーンの信頼が拒否されました", + "claudeDesktop.picker.state.trustPending": "証明書の信頼手順を待っています", + "claudeDesktop.picker.state.trustDeclined": "証明書の信頼が拒否されました", "claudeDesktop.picker.state.proxyUnavailable": "ピッカープロキシが起動していません", "claudeDesktop.picker.state.unsupported": "この OS ではピッカーモードを利用できません", "claudeDesktop.picker.state.notFirstParty": "ピッカーはファーストパーティーモードでのみ利用できます", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 9f4026aa7b2..3c0499344e9 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3459,8 +3459,8 @@ export const ko: Record = { "claudeDesktop.picker.toggle": "Claude Desktop 선택기 켜기", "claudeDesktop.picker.state.active": "선택기가 활성화됨", "claudeDesktop.picker.state.restart": "선택기 프로필 적용을 마치려면 Claude Desktop을 완전히 종료한 뒤 다시 열어주세요.", - "claudeDesktop.picker.state.trustPending": "키체인 단계 대기 중", - "claudeDesktop.picker.state.trustDeclined": "키체인 신뢰가 거부됨", + "claudeDesktop.picker.state.trustPending": "인증서 신뢰 단계 대기 중", + "claudeDesktop.picker.state.trustDeclined": "인증서 신뢰가 거부됨", "claudeDesktop.picker.state.proxyUnavailable": "선택기 프록시가 실행 중이 아님", "claudeDesktop.picker.state.unsupported": "이 운영체제에서는 선택기 모드를 지원하지 않음", "claudeDesktop.picker.state.notFirstParty": "선택기는 퍼스트파티 모드에서만 사용할 수 있음", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 8f9bb868fa1..a52206a45a3 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -3482,8 +3482,8 @@ export const pt: Record = { "claudeDesktop.picker.toggle": "Ativar o seletor do Claude Desktop", "claudeDesktop.picker.state.active": "O seletor está ativo", "claudeDesktop.picker.state.restart": "Feche completamente e reabra o Claude Desktop para concluir a aplicação do perfil do seletor.", - "claudeDesktop.picker.state.trustPending": "Aguardando a etapa do chaveiro", - "claudeDesktop.picker.state.trustDeclined": "A confiança no chaveiro foi recusada", + "claudeDesktop.picker.state.trustPending": "Aguardando a etapa de confiança do certificado", + "claudeDesktop.picker.state.trustDeclined": "A confiança no certificado foi recusada", "claudeDesktop.picker.state.proxyUnavailable": "O proxy do seletor não está em execução", "claudeDesktop.picker.state.unsupported": "O modo seletor não é compatível com este sistema operacional", "claudeDesktop.picker.state.notFirstParty": "O seletor só está disponível no modo de primeira parte", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 3d16af32b33..f5b73365450 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3218,8 +3218,8 @@ export const ru: Record = { "claudeDesktop.picker.toggle": "Включить выбор Claude Desktop", "claudeDesktop.picker.state.active": "Выбор активен", "claudeDesktop.picker.state.restart": "Полностью закройте и снова откройте Claude Desktop, чтобы применить профиль выбора.", - "claudeDesktop.picker.state.trustPending": "Ожидание шага связки ключей", - "claudeDesktop.picker.state.trustDeclined": "Доверие связке ключей отклонено", + "claudeDesktop.picker.state.trustPending": "Ожидание подтверждения доверия сертификату", + "claudeDesktop.picker.state.trustDeclined": "Доверие сертификату отклонено", "claudeDesktop.picker.state.proxyUnavailable": "Прокси выбора не запущен", "claudeDesktop.picker.state.unsupported": "Режим выбора не поддерживается в этой ОС", "claudeDesktop.picker.state.notFirstParty": "Выбор доступен только в режиме first-party", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 3bf06e2157f..f5c906eaf48 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3462,8 +3462,8 @@ export const tr: Record = { "claudeDesktop.picker.toggle": "Claude Desktop seçiciyi etkinleştir", "claudeDesktop.picker.state.active": "Seçici etkin", "claudeDesktop.picker.state.restart": "Seçici profilini uygulamak için Claude Desktop’tan tamamen çıkıp yeniden açın.", - "claudeDesktop.picker.state.trustPending": "Anahtar zinciri adımı bekleniyor", - "claudeDesktop.picker.state.trustDeclined": "Anahtar zinciri güveni reddedildi", + "claudeDesktop.picker.state.trustPending": "Sertifika güven adımı bekleniyor", + "claudeDesktop.picker.state.trustDeclined": "Sertifika güveni reddedildi", "claudeDesktop.picker.state.proxyUnavailable": "Seçici proxy’si çalışmıyor", "claudeDesktop.picker.state.unsupported": "Seçici modu bu işletim sisteminde desteklenmiyor", "claudeDesktop.picker.state.notFirstParty": "Seçici yalnızca first-party modunda kullanılabilir", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 92b5561346f..b401d94c9fb 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3450,8 +3450,8 @@ export const vi: Record = { "claudeDesktop.picker.toggle": "Bật bộ chọn Claude Desktop", "claudeDesktop.picker.state.active": "Bộ chọn đang hoạt động", "claudeDesktop.picker.state.restart": "Thoát hoàn toàn rồi mở lại Claude Desktop để hoàn tất áp dụng hồ sơ bộ chọn.", - "claudeDesktop.picker.state.trustPending": "Đang chờ bước trong chuỗi khóa", - "claudeDesktop.picker.state.trustDeclined": "Đã từ chối tin cậy chuỗi khóa", + "claudeDesktop.picker.state.trustPending": "Đang chờ bước tin cậy chứng chỉ", + "claudeDesktop.picker.state.trustDeclined": "Đã từ chối tin cậy chứng chỉ", "claudeDesktop.picker.state.proxyUnavailable": "Proxy bộ chọn chưa chạy", "claudeDesktop.picker.state.unsupported": "Hệ điều hành này không hỗ trợ chế độ bộ chọn", "claudeDesktop.picker.state.notFirstParty": "Bộ chọn chỉ khả dụng trong chế độ first-party", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index fff221d842b..88a0fb8b91d 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3429,8 +3429,8 @@ export const zhTW: Record = { "claudeDesktop.picker.toggle": "啟用 Claude Desktop 選擇器", "claudeDesktop.picker.state.active": "選擇器已啟用", "claudeDesktop.picker.state.restart": "請完整結束並重新開啟 Claude Desktop,完成選擇器設定套用。", - "claudeDesktop.picker.state.trustPending": "正在等待鑰匙圈步驟", - "claudeDesktop.picker.state.trustDeclined": "鑰匙圈信任遭拒", + "claudeDesktop.picker.state.trustPending": "正在等待憑證信任步驟", + "claudeDesktop.picker.state.trustDeclined": "憑證信任遭拒", "claudeDesktop.picker.state.proxyUnavailable": "選擇器代理伺服器未執行", "claudeDesktop.picker.state.unsupported": "此作業系統不支援選擇器模式", "claudeDesktop.picker.state.notFirstParty": "選擇器僅適用於第一方模式", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 81bc809e20e..8f2849ae0e9 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3432,8 +3432,8 @@ export const zh: Record = { "claudeDesktop.picker.toggle": "启用 Claude Desktop 选择器", "claudeDesktop.picker.state.active": "选择器已启用", "claudeDesktop.picker.state.restart": "请完全退出并重新打开 Claude Desktop,以完成选择器配置的应用。", - "claudeDesktop.picker.state.trustPending": "正在等待钥匙串步骤", - "claudeDesktop.picker.state.trustDeclined": "钥匙串信任已被拒绝", + "claudeDesktop.picker.state.trustPending": "正在等待证书信任步骤", + "claudeDesktop.picker.state.trustDeclined": "证书信任已被拒绝", "claudeDesktop.picker.state.proxyUnavailable": "选择器代理未运行", "claudeDesktop.picker.state.unsupported": "此操作系统不支持选择器模式", "claudeDesktop.picker.state.notFirstParty": "选择器仅在第一方模式下可用", diff --git a/gui/tests/claude-desktop-picker.test.tsx b/gui/tests/claude-desktop-picker.test.tsx index f4ed7cd4caf..7c6d17f180e 100644 --- a/gui/tests/claude-desktop-picker.test.tsx +++ b/gui/tests/claude-desktop-picker.test.tsx @@ -98,7 +98,7 @@ test("sends the persisted toggle and renders a reported proxy refusal", async () expect(container.querySelector(".notice-err")).toBeNull(); response = { status: 200, body: { ok: true, picker: { ...basePicker, reason: "trust_pending", hint: "ocx claude desktop picker trust" } } }; await act(async () => { (container.querySelector("[role=switch]") as HTMLButtonElement).click(); }); - expect(container.querySelector(".claude-picker-state")?.textContent).toContain("Waiting for the keychain step"); + expect(container.querySelector(".claude-picker-state")?.textContent).toContain("Waiting for certificate trust"); expect(container.querySelector(".claude-picker-state code")?.textContent).toBe("ocx claude desktop picker trust"); }); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ffd84bbd044..d5b75891caf 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -391,7 +391,7 @@ "claude-cli-picker-surface.test.ts": "claude-integration", "claude-picker-bootstrap.test.ts": "claude-integration", "claude-picker-ca.test.ts": "claude-integration", "claude-picker-ca-store.test.ts": "claude-integration", "claude-picker-listener.test.ts": "claude-integration", "claude-picker-models.test.ts": "claude-integration", "claude-picker-recovery.test.ts": "claude-integration", - "claude-picker-runtime.test.ts": "claude-integration", "claude-picker-startup.test.ts": "claude-integration", "claude-picker-trust.test.ts": "claude-integration", + "claude-picker-runtime.test.ts": "claude-integration", "claude-picker-startup.test.ts": "claude-integration", "claude-picker-trust.test.ts": "claude-integration", "claude-picker-windows.test.ts": "claude-integration", "claude-picker-trust-windows.test.ts": "claude-integration", "claude-desktop-remote-hub.test.ts": "claude-integration", "claude-dotenv-provenance-transport.test.ts": "claude-integration", "claude-estimate-projection.test.ts": "claude-integration", diff --git a/skills/ocx/references/01_surface_integrations.md b/skills/ocx/references/01_surface_integrations.md index bd8079e18b5..d73e9821909 100644 --- a/skills/ocx/references/01_surface_integrations.md +++ b/skills/ocx/references/01_surface_integrations.md @@ -154,7 +154,7 @@ State-changing: yes. JSON mode: `none`. -- Needs a running proxy, first-party mode and macOS. The first time, macOS asks to trust a local certificate authority limited to claude.ai; when the server cannot show that prompt the command runs the trust step in this terminal. +- Needs a running proxy, first-party mode and macOS or Windows. The first time, the OS asks to trust a local certificate authority limited to claude.ai; when the server cannot show that prompt the command runs the trust step in this terminal. - Claude Desktop then reaches the network through opencodex; fully quit and reopen Desktop afterwards. ### `ocx claude desktop picker off` @@ -177,7 +177,7 @@ JSON mode: `none`. Usage: `ocx claude desktop picker trust` -Run the macOS keychain step for picker mode in this terminal, then ask the server to finish enabling it. +Run the OS certificate trust step for picker mode in this terminal, then ask the server to finish enabling it. State-changing: yes. diff --git a/src/claude/desktop-picker-platform.ts b/src/claude/desktop-picker-platform.ts new file mode 100644 index 00000000000..4d3b8ea50b2 --- /dev/null +++ b/src/claude/desktop-picker-platform.ts @@ -0,0 +1,4 @@ +/** Platforms with a Desktop egress profile and an OS trust adapter for the picker. */ +export function supportsDesktopPicker(platform: NodeJS.Platform = process.platform): boolean { + return platform === "darwin" || platform === "win32"; +} diff --git a/src/claude/desktop-picker.ts b/src/claude/desktop-picker.ts index 11145870372..6826899d2e7 100644 --- a/src/claude/desktop-picker.ts +++ b/src/claude/desktop-picker.ts @@ -23,6 +23,7 @@ import { type DesktopPickerProfileOptions, } from "./desktop-picker-profile"; import { resolveClaudeDesktopMode, observeClaudeDesktopMode } from "./desktop-first-party"; +import { supportsDesktopPicker } from "./desktop-picker-platform"; /** Desktop no longer selects the picker profile, so removing the CA's trust cannot strand it. */ function profileReleased(profile: DesktopPickerProfileInspection): boolean { @@ -91,7 +92,7 @@ function profileOptions(deps: DesktopPickerControllerDeps): DesktopPickerProfile } function statusReasonForConfig(config: OcxConfig, platform: NodeJS.Platform, proxyBound: boolean): DesktopPickerReason { - if (platform !== "darwin") return "unsupported_platform"; + if (!supportsDesktopPicker(platform)) return "unsupported_platform"; if (resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party") return "not_first_party"; if (!claudeDesktopIntegrationEnabled(config)) return "integration_off"; if (config.claudeCode?.intercept?.picker === false) return "disabled"; @@ -101,11 +102,11 @@ function statusReasonForConfig(config: OcxConfig, platform: NodeJS.Platform, pro function emptyStatus(config: OcxConfig, platform: NodeJS.Platform, reason: DesktopPickerReason): DesktopPickerStatus { return { - desired: platform === "darwin" + desired: supportsDesktopPicker(platform) && claudeDesktopIntegrationEnabled(config) && resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) === "first-party" && config.claudeCode?.intercept?.picker !== false, - supported: platform === "darwin", + supported: supportsDesktopPicker(platform), trust: "unknown", profile: "absent", listenerReady: false, @@ -142,7 +143,7 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) const trust = trustOverride ?? runtime.trust; const effective = runtime.effective && profileCurrent && trust === "trusted"; let reason: DesktopPickerReason; - if (platform !== "darwin") reason = "unsupported_platform"; + if (!supportsDesktopPicker(platform)) reason = "unsupported_platform"; else if (!runtime.desired) reason = statusReasonForConfig(deps.readConfig(), platform, deps.proxyPort() !== null); else if (deps.proxyPort() === null) reason = "proxy_unavailable"; else if (trust !== "trusted") reason = "trust_pending"; @@ -178,7 +179,7 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) async function untrustCurrentCa(): Promise { const caPath = pickerCaCertPath(deps.configDir); - if (platform !== "darwin" || !existsSync(caPath)) return true; + if (!supportsDesktopPicker(platform) || !existsSync(caPath)) return true; try { const sha1 = pickerCaFingerprints(readFileSync(caPath, "utf8")).sha1; return (await untrustPickerCa(caPath, sha1, deps.security, platform)).ok; @@ -207,7 +208,7 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) return withTrustFailure({ ...runtimeStatus(observedTrust), reason }, trustFailed); }; const check = (config: OcxConfig, includePreference: boolean): DesktopPickerReason | null => { - if (platform !== "darwin") return "unsupported_platform"; + if (!supportsDesktopPicker(platform)) return "unsupported_platform"; if (resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party") return "mode_not_committed"; if (!claudeDesktopIntegrationEnabled(config)) return "integration_off"; if (includePreference && config.claudeCode?.intercept?.picker === false) return "disabled"; @@ -336,7 +337,7 @@ export async function removeDesktopPickerArtifacts(options: { configDir?: string return { ok: false, residual }; } const caPath = pickerCaCertPath(configDir); - if (platform === "darwin" && existsSync(caPath)) { + if (supportsDesktopPicker(platform) && existsSync(caPath)) { try { const sha1 = pickerCaFingerprints(readFileSync(caPath, "utf8")).sha1; if (!(await untrustPickerCa(caPath, sha1, options.security, platform)).ok) residual.push("trust"); diff --git a/src/claude/desktop-system-proxy.ts b/src/claude/desktop-system-proxy.ts index 85933c1250b..aec5110d201 100644 --- a/src/claude/desktop-system-proxy.ts +++ b/src/claude/desktop-system-proxy.ts @@ -1,4 +1,6 @@ import type { OcxConfig } from "../types"; +import { connect } from "node:net"; +import { directLocalHttpFetch } from "../server/direct-local-http"; import { readWindowsProxyBypassRegistry, readWindowsSystemProxy, @@ -8,6 +10,7 @@ import { } from "../lib/windows-system-proxy"; import { inspectDesktopFirstParty, observeClaudeDesktopMode } from "./desktop-first-party"; import { desktopFirstPartyDesired } from "./first-party-settings"; +import { inspectDesktopPickerProfile, type DesktopPickerProfileInspection } from "./desktop-picker-profile"; /** * Whether the Windows system proxy silently bypasses Desktop first-party mode. @@ -17,7 +20,8 @@ import { desktopFirstPartyDesired } from "./first-party-settings"; * as `HTTPS_PROXY`/`HTTP_PROXY`. That inherited value takes precedence over the `env` block * OpenCodex writes into the user's `~/.claude/settings.json` (only Claude Code managed settings * override it), so Code-tab traffic goes to the system proxy and never reaches the intercept. - * Routed subagent models then fail, and nothing else reports why. A system proxy such as Clash or + * A selected picker profile with an available fixed egress relay takes precedence instead. + * Otherwise routed subagent models fail. A system proxy such as Clash or * v2rayN, with no bypass for the API host, is the common case. * * Observe-only: this reads the registry and never changes it. A PAC script and WPAD automatic @@ -123,6 +127,39 @@ export interface DesktopSystemProxyDeps { firstPartyState?: () => DesktopFirstPartySettingsState; readSystemProxy?: () => WindowsSystemProxyResult; readBypass?: () => WindowsProxyBypassValues | null; + inspectPickerProfile?: () => DesktopPickerProfileInspection; + pickerRelayAvailable?: (proxyUrl: string) => boolean | Promise; +} + +/** Observe the selected endpoint even in a separate doctor process or a blind-only recovery relay. */ +async function livePickerRelayAvailable(proxyUrl: string): Promise { + const port = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/.exec(proxyUrl)?.[1]; + if (!port || Number(port) > 65535) return false; + try { + // A listening TCP socket alone is not evidence of an OpenCodex CONNECT relay. + const response = await directLocalHttpFetch(proxyUrl, { signal: AbortSignal.timeout(1_000) }); + if (response.status !== 405 || response.headers.get("allow") !== "CONNECT" + || !(await response.text()).startsWith("OpenCodex Claude intercept proxy accepts only CONNECT tunnels.")) return false; + // The main intercept proxy has the same HTTP response but requires credentials the Desktop + // profile cannot send. A loopback CONNECT is denied before dialing or choosing any tunnel; + // 403 proves unauthenticated admission, whereas the credential-bearing proxy returns 407. + return await new Promise(resolve => { + const socket = connect({ host: "127.0.0.1", port: Number(port) }); + const finish = (available: boolean) => { clearTimeout(timer); socket.destroy(); resolve(available); }; + const timer = setTimeout(() => finish(false), 1_000); + let head = ""; + socket.once("error", () => finish(false)); + socket.once("close", () => finish(false)); + socket.once("connect", () => socket.write("CONNECT 127.0.0.1:1 HTTP/1.1\r\nHost: 127.0.0.1:1\r\n\r\n")); + socket.on("data", bytes => { + head += bytes.toString("latin1"); + if (head.includes("\r\n\r\n")) finish(/^HTTP\/1\.[01] 403 Forbidden\r\n/.test(head)); + else if (head.length > 8 * 1024) finish(false); + }); + }); + } catch { // no-excuse-ok: catch -- failed relay probes retain the registry diagnostic. + return false; + } } function liveFirstPartyState( @@ -137,15 +174,24 @@ function liveFirstPartyState( } } -/** Reads the live state. Registry reads run only on Windows with a Desktop first-party env. */ -export function collectDesktopSystemProxy( +/** Registry reads run only on Windows with a Desktop first-party env and no verified picker relay. */ +export async function collectDesktopSystemProxy( config: Pick, deps: DesktopSystemProxyDeps = {}, -): DesktopSystemProxyAssessment { +): Promise { const platform = deps.platform ?? process.platform; if (platform !== "win32") return { kind: "not-applicable" }; const firstParty = (deps.firstPartyState ?? (() => liveFirstPartyState(config)))(); if (firstParty === "off") return { kind: "not-applicable" }; + if (firstParty === "applied" && config.claudeCode?.intercept?.picker !== false) { + try { + const profile = (deps.inspectPickerProfile ?? (() => inspectDesktopPickerProfile({ platform })))(); + if (profile.kind === "applied" && await (deps.pickerRelayAvailable ?? livePickerRelayAvailable)(profile.proxyUrl)) { + return { kind: "not-applicable" }; + } + } catch { // no-excuse-ok: catch -- missing profile/relay evidence must not hide a registry conflict. + } + } return assessDesktopSystemProxy({ platform, firstParty, diff --git a/src/claude/intercept/picker-ca-startup.ts b/src/claude/intercept/picker-ca-startup.ts index 010032439d5..63d1a9f292b 100644 --- a/src/claude/intercept/picker-ca-startup.ts +++ b/src/claude/intercept/picker-ca-startup.ts @@ -2,12 +2,13 @@ import { ensurePickerCa, type PickerCa } from "./picker-ca"; import { drainPendingPickerCaUntrust } from "./picker-ca-cleanup"; import type { PickerCaStore } from "./picker-ca-store"; import type { SecurityRunner } from "./picker-trust"; +import { supportsDesktopPicker } from "../desktop-picker-platform"; /** Internal activation seam: finish legacy cleanup before a persistent issuer is usable. */ export async function preparePersistentPickerAuthority(options: { configDir: string; store?: PickerCaStore; security?: SecurityRunner; platform: NodeJS.Platform; }): Promise { - if (options.platform !== "darwin") throw new Error("picker_ca_unsupported_platform"); + if (!supportsDesktopPicker(options.platform)) throw new Error("picker_ca_unsupported_platform"); const drain = () => drainPendingPickerCaUntrust(options.configDir, options.security, options.platform); if (!await drain()) throw new Error("picker_ca_pending_untrust"); const ca = ensurePickerCa(options.configDir, { rotation: "startup", persistent: true, store: options.store }); diff --git a/src/claude/intercept/picker-runtime.ts b/src/claude/intercept/picker-runtime.ts index 4fbb72be313..7b4f56310e3 100644 --- a/src/claude/intercept/picker-runtime.ts +++ b/src/claude/intercept/picker-runtime.ts @@ -7,15 +7,16 @@ * `intercept`. Everything else, and claude.ai whenever any condition fails, is a blind tunnel. * * The decision is cached by `refresh()` and only read by `selectTunnel`, so a CONNECT never - * waits on the keychain or the catalog. Arming needs all of: macOS, the persisted resolved mode is + * waits on the OS trust store or the catalog. Arming needs all of: a supported platform, the persisted resolved mode is * first-party (observation-aware, so a pre-field first-party install counts), Desktop intent on, * `claudeCode.intercept.picker !== false`, the disarm latch clear, the picker listener up and the - * current picker CA trusted in the login keychain. `disarm()` stops terminating immediately and + * current picker CA trusted in the OS root store. `disarm()` stops terminating immediately and * latches; only the picker controller's owner-only `rearm()` clears the latch. */ import { join } from "node:path"; import type { OcxConfig } from "../../types"; import type { ClaudeDesktopMode } from "../desktop-first-party"; +import { supportsDesktopPicker } from "../desktop-picker-platform"; import type { TunnelDecision } from "./connect-proxy"; import type { PemKeyPair } from "./local-ca"; import { PICKER_HOST, ensurePickerCa, issuePickerLeaf, pickerCaFingerprints, pickerLeafCertPath, pickerStateDir, type PickerCa } from "./picker-ca"; @@ -108,7 +109,7 @@ export function pickerDesired( mode: ClaudeDesktopMode, platform: NodeJS.Platform = process.platform, ): boolean { - return platform === "darwin" + return supportsDesktopPicker(platform) && config.clientIntegrations?.["claude-desktop"] !== false && mode === "first-party" && config.claudeCode?.intercept?.picker !== false; @@ -225,7 +226,7 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker desired = pickerDesired(fresh, mode, platform); if (!desired || latched) { armed = false; - reason = platform !== "darwin" ? "unsupported_platform" : latched ? "disarmed" : "not_desired"; + reason = !supportsDesktopPicker(platform) ? "unsupported_platform" : latched ? "disarmed" : "not_desired"; closeListener(); return; } @@ -312,7 +313,7 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker const current = snapshot?.current() ?? null; return { desired, - supported: platform === "darwin", + supported: supportsDesktopPicker(platform), trust, listenerReady: listener !== null, effective: armed && listener !== null && !latched && !stopped, diff --git a/src/claude/intercept/picker-trust-windows.ts b/src/claude/intercept/picker-trust-windows.ts new file mode 100644 index 00000000000..e108103b567 --- /dev/null +++ b/src/claude/intercept/picker-trust-windows.ts @@ -0,0 +1,338 @@ +import { X509Certificate } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation"; +import { + acceptsPickerAuthority, + PICKER_CA_COMMON_NAME, + PICKER_HOST, + pickerCaFingerprints, +} from "./picker-ca"; +import type { PickerTrustState, SecurityResult } from "./picker-trust"; + +/** powershell.exe argv result; alias so the two trust seams never drift apart. */ +export type WindowsTrustResult = SecurityResult; +/** Per-script options; fakes may ignore them, so single-arg fakes stay portable. */ +export interface WindowsTrustRunnerOptions { timeoutMs?: number } + +/** + * powershell.exe argv runner. A SecurityRunner satisfies this shape so macOS-style + * fakes stay portable. The caller in picker-trust.ts only forwards an explicitly + * injected runner and otherwise leaves this undefined for the Windows default. + */ +export type WindowsTrustRunner = ( + args: readonly string[], + options?: WindowsTrustRunnerOptions, +) => Promise; + +/** Read-only argv prefix: no profile, no logo, no interaction. Used for inspection. */ +export const WINDOWS_TRUST_POWERSHELL_PREFIX = ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"] as const; +/** Mutating argv prefix: the OS confirmation dialog must be able to appear. */ +export const WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX = ["-NoLogo", "-NoProfile", "-Command"] as const; + +/** Read-only inspection budget: a hung store lookup must never stall status checks. */ +export const WINDOWS_TRUST_INSPECT_TIMEOUT_MS = 10_000; +/** Interactive import budget: Root installs raise the OS confirmation dialog. */ +export const WINDOWS_TRUST_IMPORT_TIMEOUT_MS = 120_000; +/** + * Removal budget stays bounded: predecessor cleanup also runs in headless migration + * and startup paths where an OS confirmation dialog cannot be answered. A timed-out or + * declined removal reports failure and is retried on the next explicit activation. + */ +export const WINDOWS_TRUST_UNTRUST_TIMEOUT_MS = 30_000; + +const OUTPUT_LIMIT = 64 * 1024; +const TRUST_MARKER = "OCX_PICKER_TRUST="; +const SHA1_PATTERN = /^[0-9A-F]{40}$/; +const BASE64_PATTERN = /^[A-Za-z0-9+/=]*$/; + +/** Uppercase hex without separators, or null. Trust matches on this, never on the CN. */ +export function normalizePickerTrustSha1(value: string): string | null { + const normalized = value.replace(/[:\s]/g, "").toUpperCase(); + return SHA1_PATTERN.test(normalized) ? normalized : null; +} + +/** + * Copy the environment minus every PSModulePath entry (case-insensitive). + * The trust scripts are .NET-only so module autoload is pure cost and shadowing + * risk: a PSModulePath entry can shadow resolution and stall process start on + * hosts with a cold module cache. Nothing here needs a module. + */ +export function sanitizeWindowsPowerShellEnv(env: NodeJS.ProcessEnv = process.env): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (key.toLowerCase() === "psmodulepath") continue; + out[key] = value; + } + return out; +} + +/** + * Production runner: trusted System32 powershell.exe, hidden window, sanitized + * module path, bounded runtime. Timeout and output-read errors kill the child and report code null + * so callers map it to unknown/declined instead of hanging periodic status. + */ +export const defaultWindowsPowerShellRunner: WindowsTrustRunner = async (args, options) => { + const timeoutMs = options?.timeoutMs ?? WINDOWS_TRUST_INSPECT_TIMEOUT_MS; + let exe: string; + try { + exe = resolveTrustedWindowsPowerShellExe(); + } catch (error) { + return { code: null, stdout: "", stderr: error instanceof Error ? error.message : String(error) }; + } + let child: { kill(): void } | undefined; + let timer: ReturnType | undefined; + try { + const proc = Bun.spawn([exe, ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + env: sanitizeWindowsPowerShellEnv(), + }); + child = proc; + const pending = (async () => { + const [out, err, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { out, err, code }; + })(); + const settled = await new Promise<{ out: string; err: string; code: number } | null>((resolve, reject) => { + timer = setTimeout(() => { + try { child?.kill(); } catch { /* already gone */ } + resolve(null); + }, timeoutMs); + pending.then(resolve, reject); + }); + if (timer !== undefined) clearTimeout(timer); + if (settled === null) { + return { code: null, stdout: "", stderr: `powershell trust script timed out after ${timeoutMs}ms` }; + } + return { code: settled.code, stdout: settled.out.slice(-OUTPUT_LIMIT), stderr: settled.err.slice(-OUTPUT_LIMIT) }; + } catch (error) { + if (timer !== undefined) clearTimeout(timer); + try { child?.kill(); } catch { /* already gone */ } + return { code: null, stdout: "", stderr: error instanceof Error ? error.message : String(error) }; + } +}; + +function base64Utf8(value: string): string { + return Buffer.from(value, "utf8").toString("base64"); +} + +/** A filesystem path carried as data, never as PowerShell syntax. */ +function encodedPathExpression(path: string): string { + const encoded = base64Utf8(path); + if (!BASE64_PATTERN.test(encoded)) throw new Error("path encoding failed"); + return `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}'))`; +} + +/** Interpolated constants must not carry a quote or wildcard that changes the script. */ +function psLiteral(value: string, label: string): string { + if (value.includes("'") || /[*?[\]]/.test(value)) throw new Error(`unsafe ${label} for script literal`); + return `'${value}'`; +} + +function markerOf(stdout: string): string | null { + const line = stdout + .split(/\r?\n/) + .map(entry => entry.trim()) + .find(entry => entry.startsWith(TRUST_MARKER)); + return line === undefined ? null : line.slice(TRUST_MARKER.length); +} + +function currentUserRootStore(): string { + return "New-Object Security.Cryptography.X509Certificates.X509Store('Root','CurrentUser')"; +} + +function subjectLikePattern(): string { + if (PICKER_CA_COMMON_NAME.length === 0 || /['*?[\]]/.test(PICKER_CA_COMMON_NAME)) { + throw new Error("unsafe picker CA common name for script literal"); + } + return `'*CN=${PICKER_CA_COMMON_NAME}*'`; +} + +/** + * Inspect script: exact SHA-1 lookup in CurrentUser Root, persisted-leaf chain build + * against that root, plus DNS-name and validity checks. Emits exactly one marker + * line and exits 0 (trusted), 1 (untrusted) or 2 (error). Read-only: trust is never + * added or removed here; installation happens only on explicit activation via trust. + */ +export function buildWindowsTrustInspectScript(leafPath: string, wantSha1: string): string { + const want = normalizePickerTrustSha1(wantSha1); + if (want === null) throw new Error("picker trust fingerprint must be 40 hex characters"); + if (leafPath.length === 0) throw new Error("picker trust leaf path must not be empty"); + const host = psLiteral(PICKER_HOST, "picker host"); + return [ + "$ErrorActionPreference='Stop'", + `$leafFile=${encodedPathExpression(leafPath)}`, + `$want=${psLiteral(want, "picker CA fingerprint")}`, + `$store=${currentUserRootStore()}`, + "$store.Open('ReadOnly')", + "try {", + "$found=@($store.Certificates | Where-Object { $_.Thumbprint -eq $want })", + `if($found.Count -eq 0) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`, + "$ca=$found[0]", + `if($ca.Subject -notlike ${subjectLikePattern()}) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`, + "$leaf=New-Object Security.Cryptography.X509Certificates.X509Certificate2($leafFile)", + "$dns=$leaf.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::DnsName,$false)", + `if($dns -ne ${host}) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`, + "$now=Get-Date", + `if($leaf.NotBefore -gt $now -or $leaf.NotAfter -lt $now) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`, + `if($leaf.Issuer -ne $ca.Subject) { Write-Output '${TRUST_MARKER}UNTRUSTED'; exit 1 }`, + "$chain=New-Object Security.Cryptography.X509Certificates.X509Chain", + "$chain.ChainPolicy.RevocationMode=[Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck", + "$chain.ChainPolicy.VerificationFlags=[Security.Cryptography.X509Certificates.X509VerificationFlags]::AllowUnknownCertificateAuthority", + "$chain.ChainPolicy.ExtraStore.Add($ca) | Out-Null", + "$built=$chain.Build($leaf)", + "$root=$null", + "if($chain.ChainElements.Count -gt 0) { $root=$chain.ChainElements[$chain.ChainElements.Count-1].Certificate }", + `if($built -and ($null -ne $root) -and ($root.Thumbprint -eq $want)) { Write-Output '${TRUST_MARKER}TRUSTED'; exit 0 }`, + `Write-Output '${TRUST_MARKER}UNTRUSTED'`, + "exit 1", + `} catch { Write-Output '${TRUST_MARKER}ERROR'; exit 2 } finally { $store.Close() }`, + ].join(";"); +} + +/** + * Install script: imports the snapshot file into CurrentUser Root after re-checking + * its thumbprint inside the script. Adding to Root raises the OS confirmation + * dialog and that user approval is the install gate; the powershell.exe process + * itself stays hidden and only the OS dialog is ever visible. + */ +export function buildWindowsTrustInstallScript(caPath: string, wantSha1: string): string { + const want = normalizePickerTrustSha1(wantSha1); + if (want === null) throw new Error("picker trust fingerprint must be 40 hex characters"); + if (caPath.length === 0) throw new Error("picker trust CA path must not be empty"); + return [ + "$ErrorActionPreference='Stop'", + `$caFile=${encodedPathExpression(caPath)}`, + `$want=${psLiteral(want, "picker CA fingerprint")}`, + "$cert=New-Object Security.Cryptography.X509Certificates.X509Certificate2($caFile)", + `if($cert.Thumbprint -ne $want) { Write-Output '${TRUST_MARKER}ERROR'; exit 2 }`, + `$store=${currentUserRootStore()}`, + "$store.Open('ReadWrite')", + "try {", + "$found=@($store.Certificates | Where-Object { $_.Thumbprint -eq $want })", + "if($found.Count -eq 0) { $store.Add($cert) }", + `Write-Output '${TRUST_MARKER}INSTALLED'`, + "exit 0", + `} catch { Write-Output '${TRUST_MARKER}ERROR'; exit 2 } finally { $store.Close() }`, + ].join(";"); +} + +/** + * Remove script: verifies picker identity for every exact-thumbprint Root entry before + * deleting any of them, then re-checks. Legacy picker roots need not match the new install profile. + * Removal from Root raises OS confirmation like an install, so this runs on the + * interactive prefix with a bounded 30s budget: predecessor cleanup also runs in headless + * removal may prompt, mirroring the macOS keychain prompt on migration cleanup. + */ +export function buildWindowsTrustRemoveScript(wantSha1: string): string { + const want = normalizePickerTrustSha1(wantSha1); + if (want === null) throw new Error("picker trust fingerprint must be 40 hex characters"); + return [ + "$ErrorActionPreference='Stop'", + `$want=${psLiteral(want, "picker CA fingerprint")}`, + `$pickerName=${psLiteral(PICKER_CA_COMMON_NAME, "picker CA common name")}`, + "try {", + `$store=${currentUserRootStore()}`, + "$store.Open('ReadWrite')", + "try { $found=@($store.Certificates | Where-Object { $_.Thumbprint -eq $want });", + "foreach($c in $found) {", + `if($c.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName,$false) -ne $pickerName) { Write-Output '${TRUST_MARKER}ERROR'; exit 2 }`, + "}", + "foreach($c in $found) { $store.Remove($c) } } finally { $store.Close() }", + `$check=${currentUserRootStore()}`, + "$check.Open('ReadOnly')", + "try { $left=@($check.Certificates | Where-Object { $_.Thumbprint -eq $want });", + `if($left.Count -eq 0) { Write-Output '${TRUST_MARKER}REMOVED'; exit 0 }`, + `Write-Output '${TRUST_MARKER}ERROR'; exit 1 } finally { $check.Close() }`, + `} catch { Write-Output '${TRUST_MARKER}ERROR'; exit 2 }`, + ].join(";"); +} + +function powershellArgs(script: string, interactive: boolean): string[] { + const prefix = interactive ? WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX : WINDOWS_TRUST_POWERSHELL_PREFIX; + return [...prefix, script]; +} + +export async function inspectPickerTrustWindows( + leafPath: string, + caSha1: string, + run?: WindowsTrustRunner, +): Promise { + if (normalizePickerTrustSha1(caSha1) === null || leafPath.length === 0) return "untrusted"; + const exec = run ?? defaultWindowsPowerShellRunner; + try { + const result = await exec(powershellArgs(buildWindowsTrustInspectScript(leafPath, caSha1), false), { + timeoutMs: WINDOWS_TRUST_INSPECT_TIMEOUT_MS, + }); + if (result.code === 0 && markerOf(result.stdout) === "TRUSTED") return "trusted"; + if (result.code === 1 && markerOf(result.stdout) === "UNTRUSTED") return "untrusted"; + return "unknown"; + } catch { + return "unknown"; + } +} + +export async function trustPickerCaWindows( + caPath: string, + run?: WindowsTrustRunner, + cert?: { pem?: string }, +): Promise<{ ok: boolean; reason?: "unsupported" | "declined_or_failed" }> { + // The caller gates this on explicit activation; status and restart paths never call it. + // The installed bytes are always a private snapshot of the authority authorized here, + // never the shared ca.pem a same-user process could swap mid-flight. + const exec = run ?? defaultWindowsPowerShellRunner; + let privateDir: string | undefined; + try { + let pem = cert?.pem; + if (pem === undefined) pem = readFileSync(caPath, "utf8"); + if (!acceptsPickerAuthority(pem)) return { ok: false, reason: "declined_or_failed" }; + const { sha1 } = pickerCaFingerprints(pem); + privateDir = mkdtempSync(join(tmpdir(), "ocx-picker-ca-win-")); + const snapshot = join(privateDir, "ca.pem"); + writeFileSync(snapshot, pem, { mode: 0o600 }); + const result = await exec(powershellArgs(buildWindowsTrustInstallScript(snapshot, sha1), true), { + timeoutMs: WINDOWS_TRUST_IMPORT_TIMEOUT_MS, + }); + return result.code === 0 && markerOf(result.stdout) === "INSTALLED" + ? { ok: true } + : { ok: false, reason: "declined_or_failed" }; + } catch { + return { ok: false, reason: "declined_or_failed" }; + } finally { + if (privateDir !== undefined) rmSync(privateDir, { recursive: true, force: true }); + } +} + +export async function untrustPickerCaWindows( + fingerprintSha1: string, + run?: WindowsTrustRunner, + cert?: { pem: string }, +): Promise<{ ok: boolean }> { + const exec = run ?? defaultWindowsPowerShellRunner; + if (normalizePickerTrustSha1(fingerprintSha1) === null) return { ok: false }; + try { + // Public recovery state is not removal authority. Accept legacy picker roots, + // but reject foreign certificates before invoking even an injected runner. + if (cert) { + const root = new X509Certificate(cert.pem); + const names = root.subject.split("\n"); + if (!root.ca || !names.includes(`CN=${PICKER_CA_COMMON_NAME}`) + || names.filter(name => name.startsWith("CN=")).length !== 1 + || root.issuer !== root.subject || !root.verify(root.publicKey) + || pickerCaFingerprints(cert.pem).sha1 !== normalizePickerTrustSha1(fingerprintSha1)) return { ok: false }; + } + const result = await exec(powershellArgs(buildWindowsTrustRemoveScript(fingerprintSha1), true), { + timeoutMs: WINDOWS_TRUST_UNTRUST_TIMEOUT_MS, + }); + return result.code === 0 && markerOf(result.stdout) === "REMOVED" ? { ok: true } : { ok: false }; + } catch { + return { ok: false }; + } +} diff --git a/src/claude/intercept/picker-trust.ts b/src/claude/intercept/picker-trust.ts index 48f4711ba0e..2d45ebeff9a 100644 --- a/src/claude/intercept/picker-trust.ts +++ b/src/claude/intercept/picker-trust.ts @@ -2,6 +2,11 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { PICKER_CA_COMMON_NAME, PICKER_HOST } from "./picker-ca"; +import { + inspectPickerTrustWindows, + trustPickerCaWindows, + untrustPickerCaWindows, +} from "./picker-trust-windows"; /** Trust is scoped to the current root fingerprint and a verified persisted leaf. */ export type PickerTrustState = "trusted" | "untrusted" | "unsupported" | "unknown"; @@ -61,6 +66,11 @@ export async function inspectPickerTrust( run: SecurityRunner = defaultSecurityRunner, platform: NodeJS.Platform = process.platform, ): Promise { + if (platform === "win32") { + // The macOS default runner is never passed down: only an explicitly injected + // runner crosses over, otherwise the Windows default powershell.exe runner runs. + return inspectPickerTrustWindows(leafPath, caSha1, run === defaultSecurityRunner ? undefined : run); + } if (platform !== "darwin") return "unsupported"; const keychain = loginKeychainPath(); try { @@ -86,6 +96,9 @@ export async function trustPickerCa( platform: NodeJS.Platform = process.platform, cert?: { pem?: string }, ): Promise<{ ok: boolean; reason?: "unsupported" | "declined_or_failed" }> { + if (platform === "win32") { + return trustPickerCaWindows(caPath, run === defaultSecurityRunner ? undefined : run, cert); + } if (platform !== "darwin") return { ok: false, reason: "unsupported" }; // `cert.pem` is the caller-verified certificate bytes: the shared on-disk ca.pem is writable by // same-user processes and could be swapped between fingerprint inspection and this install, so @@ -117,6 +130,12 @@ export async function untrustPickerCa( run: SecurityRunner = defaultSecurityRunner, platform: NodeJS.Platform = process.platform, ): Promise<{ ok: boolean }> { + if (platform === "win32") { + try { + return await untrustPickerCaWindows(fingerprintSha1, run === defaultSecurityRunner ? undefined : run, + { pem: readFileSync(caPath, "utf8") }); + } catch { return { ok: false }; } + } if (platform !== "darwin") return { ok: false }; const keychain = loginKeychainPath(); // Listed means the current picker CA is in the login keychain; unlisted means nothing to remove, diff --git a/src/claude/intercept/runtime.ts b/src/claude/intercept/runtime.ts index 7f0d3692243..b0d562df939 100644 --- a/src/claude/intercept/runtime.ts +++ b/src/claude/intercept/runtime.ts @@ -1,4 +1,5 @@ import type { Server } from "bun"; +import { supportsDesktopPicker } from "../desktop-picker-platform"; import type { OcxConfig } from "../../types"; import { getConfigDir } from "../../config/paths"; import type { DesktopPickerController } from "../desktop-picker"; @@ -28,7 +29,7 @@ import { buildClaudeInterceptEnv, migrateClaudeInterceptSettings } from "./setti * proxy. Desktop also hands that proxy to the Claude Code processes it spawns, so the choice is per * client: Claude Code (no User-Agent on CONNECT) trusts only the intercept CA and gets the * api.anthropic.com intercept and nothing else; the app itself (a browser User-Agent) trusts only - * the login keychain and never meets the api.anthropic.com intercept, and only its claude.ai + * the OS root store and never meets the api.anthropic.com intercept, and only its claude.ai * tunnels may be terminated by the picker runtime (src/claude/intercept/picker-runtime.ts). */ @@ -144,7 +145,7 @@ export interface StartClaudeInterceptOptions { createPicker?: (options: CreatePickerRuntimeOptions) => PickerRuntime; /** Test seam: bind real CONNECT handlers on kernel-assigned ports without probe-and-release races. */ startProxy?: typeof startConnectProxy; - /** Test seams: the macOS `security` runner and platform for the picker runtime and controller. */ + /** Test seams: the platform trust runner and platform for the picker runtime and controller. */ pickerCaStore?: PickerCaStore; pickerSecurity?: SecurityRunner; pickerPlatform?: NodeJS.Platform; @@ -211,7 +212,7 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio let pickerReason: ClaudeInterceptState["pickerReason"] = null; let pickerFailurePort: number | undefined; try { - if (options.loadPickerRoutes && (options.pickerPlatform ?? process.platform) === "darwin") { + if (options.loadPickerRoutes && supportsDesktopPicker(options.pickerPlatform ?? process.platform)) { // Remove any legacy plaintext key before inspecting or activating picker state. discardPickerCaKey(configDir); const { inspectDesktopPickerProfile } = await import("../desktop-picker-profile"); diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index 4a374354b43..ea6de6653cd 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -646,7 +646,7 @@ export const CAPABILITIES: readonly Capability[] = [ flags: [], mutates: true, json: "none", - details: ["Needs a running proxy, first-party mode and macOS. The first time, macOS asks to trust a local certificate authority limited to claude.ai; when the server cannot show that prompt the command runs the trust step in this terminal.","Claude Desktop then reaches the network through opencodex; fully quit and reopen Desktop afterwards."], + details: ["Needs a running proxy, first-party mode and macOS or Windows. The first time, the OS asks to trust a local certificate authority limited to claude.ai; when the server cannot show that prompt the command runs the trust step in this terminal.","Claude Desktop then reaches the network through opencodex; fully quit and reopen Desktop afterwards."], usage: "ocx claude desktop picker on", }, { @@ -661,7 +661,7 @@ export const CAPABILITIES: readonly Capability[] = [ }, { command: ["claude","desktop","picker","trust"], - summary: "Run the macOS keychain step for picker mode in this terminal, then ask the server to finish enabling it.", + summary: "Run the OS certificate trust step for picker mode in this terminal, then ask the server to finish enabling it.", routes: [{"method":"GET","path":"/api/claude-desktop/picker"},{"method":"PUT","path":"/api/claude-desktop/picker"}], flags: [], mutates: true, diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 01c53522a53..ab00b68cec7 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1467,7 +1467,7 @@ export async function runDoctor(args: string[] = []): Promise { // Observe-only and warning-level: a bypassed Desktop first-party install still serves the // CLI and every non-Desktop client, so this never records a doctor failure. - const desktopSystemProxyLines = formatDesktopSystemProxyLines(collectDesktopSystemProxy(doctorConfig)); + const desktopSystemProxyLines = formatDesktopSystemProxyLines(await collectDesktopSystemProxy(doctorConfig)); if (desktopSystemProxyLines.length > 0) { console.log("\nClaude Desktop first-party vs Windows system proxy"); for (const line of desktopSystemProxyLines) console.log(line); diff --git a/src/cli/uninstall-client-state.ts b/src/cli/uninstall-client-state.ts index f200bf11b1f..2b6e11377e7 100644 --- a/src/cli/uninstall-client-state.ts +++ b/src/cli/uninstall-client-state.ts @@ -5,9 +5,13 @@ import { assertClientLifecycleHeld, withClientLifecycle } from "../client/lifecy import { inspectRemoteDesktopCleanup, readDesktopDisconnectReceipt } from "../claude/desktop-remote-store"; import { removeOwnedConfigState, type ConfigRemovalResult } from "../lib/config-ownership"; import { windowsSecretAclReapPendingAtOrBelow } from "../lib/windows-secret-acl"; +import { supportsDesktopPicker } from "../claude/desktop-picker-platform"; import { sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; import { cleanupOwnedIntegrationsBeforeUninstall } from "./uninstall-integrations"; +/** Outcome of retiring the Desktop picker profile and every picker root this install trusted. */ +export interface PickerUninstallCleanup { ok: boolean; residual?: string[] } + export interface UninstallClientStateDeps { readConnection: typeof readClientConnectionState; inspectDesktop: typeof inspectRemoteDesktopCleanup; @@ -18,6 +22,25 @@ export interface UninstallClientStateDeps { remove: () => ConfigRemovalResult; /** True while a timed-out icacls child still owns a path at or below the config directory. */ aclReapPending: (rootPath: string) => boolean; + /** Release the picker profile, then untrust the current and any pending predecessor picker root. */ + removePickerArtifacts: () => Promise; +} + +/** + * The picker root lives in the OS trust store (login keychain, CurrentUser Root on Windows), outside + * the config directory. Deleting config first would erase ca.pem and the pending-untrust journal, + * the only records naming which root to remove, and strand a trusted root after uninstall. + */ +async function removePickerArtifactsBeforeUninstall(): Promise { + if (!supportsDesktopPicker()) return { ok: true }; + const configDir = getConfigDir(); + const [{ removeDesktopPickerArtifacts }, { drainPendingPickerCaUntrust }] = await Promise.all([ + import("../claude/desktop-picker"), + import("../claude/intercept/picker-ca-cleanup"), + ]); + const removed = await removeDesktopPickerArtifacts({ configDir }); + if (!removed.ok) return removed; + return await drainPendingPickerCaUntrust(configDir) ? { ok: true } : { ok: false, residual: ["pending_trust"] }; } const defaults: UninstallClientStateDeps = { @@ -29,6 +52,7 @@ const defaults: UninstallClientStateDeps = { cleanupIntegrations: cleanupOwnedIntegrationsBeforeUninstall, remove: () => removeOwnedConfigState(getConfigDir()), aclReapPending: rootPath => windowsSecretAclReapPendingAtOrBelow(rootPath), + removePickerArtifacts: removePickerArtifactsBeforeUninstall, }; /** Restore connection-owned client artifacts before removing their ownership/recovery records. */ @@ -67,6 +91,14 @@ export async function removeOwnedConfigAfterDesktopCleanup( throw new Error("Client cleanup refused: finish Desktop recovery before uninstalling."); } + // Picker cleanup takes L itself, so it runs before the final lock. A residual keeps every + // record the retry needs: the config directory still names the root to untrust. + const picker = await deps.removePickerArtifacts(); + if (!picker.ok) { + throw new Error(`Client cleanup refused: Claude Desktop picker cleanup left ${picker.residual?.join(", ") || "state"} behind; ` + + "approve the certificate removal prompt or run 'ocx claude desktop picker off', then rerun 'ocx uninstall'."); + } + // A connection can appear while asynchronous cleanup is finishing. The final // inspection and actual remover share L, but never hold C while deleting its DB. return deps.withLifecycle(async held => { diff --git a/src/server/management/claude-desktop-picker-routes.ts b/src/server/management/claude-desktop-picker-routes.ts index 337746e8494..59b7306767c 100644 --- a/src/server/management/claude-desktop-picker-routes.ts +++ b/src/server/management/claude-desktop-picker-routes.ts @@ -44,7 +44,7 @@ export function pickerPreferenceOn(config: Pick): boole } const PICKER_BODY_KEYS = new Set(["enabled", "persist", "trustedLocally", "callerAddedTrust"]); -/** Enable outcomes that are progress, not refusal: on, waiting for a Desktop restart, or for the keychain step. */ +/** Enable outcomes that are progress, not refusal: on, waiting for a Desktop restart, or for certificate trust. */ const ENABLE_ACCEPTED = new Set(["active", "restart_required", "trust_pending"]); interface PickerRequestBody { enabled: boolean; persist: boolean; trustedLocally?: boolean; callerAddedTrust?: boolean } diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 9079441d769..f2a0b13f494 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -665,7 +665,7 @@ function pickerStateNote(picker: DesktopPickerStatus): string { return "Picker mode is on: fully quit and reopen Claude Desktop to see OpenCodex models in the Code tab."; } if (picker.reason === "trust_pending" || picker.reason === "trust_declined") { - return `Picker mode is waiting for the keychain step: run ${picker.hint ?? "ocx claude desktop picker trust"}.`; + return `Picker mode is waiting for certificate trust: run ${picker.hint ?? "ocx claude desktop picker trust"}.`; } return `Picker mode is off (${picker.reason}).`; } diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 5622ef531ce..aaec5f7ca48 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -121,7 +121,8 @@ and the auto-detect (WPAD) flag in `Connections\DefaultConnectionSettings`, and a bypass, no covering proxy, or an undecidable PAC script or WPAD. A failed registry read is reported as unreadable, never as an absent value, and a stale settings env never earns an `ok`. It never prints the proxy value and never records a doctor failure, because the CLI and other clients -still route. +still route. An applied, selected picker profile uses Desktop's app-owned fixed egress proxy while that proxy is available, including when missing trust leaves the picker pending and `claude.ai` tunnels relay blindly. Missing trust preserves the profile and its selection. The system-proxy observation applies to picker-off installs and pending installs with no applied picker profile. +Doctor verifies the selected profile's loopback CONNECT relay directly, independently of picker trust or effectiveness. Off, absent, unselected, unreadable, stale-settings and unavailable-relay cases retain the registry diagnostic; a credential-bearing main intercept proxy does not count as Desktop's egress relay. Surfaces: `ocx claude desktop apply [--first-party|--gateway]` in `src/cli/claude-desktop.ts`; `ocx claude config set --first-party on|off` and the Claude Code page switch control the CLI intent; `ocx ensure` refreshes a stale or absent env while it is on. @@ -183,17 +184,20 @@ The shared CONNECT primitive accepts optional `allowedTargets` authorities. It s normalizes that list at startup; an empty list denies all, and other host/port pairs receive 403 before tunnel selection or dialing. Authentication and loopback refusal remain in force. Existing Claude consumers omit this option and retain blind forwarding; it enables no new integration or certificate trust. +Windows trust removal in `src/claude/intercept/picker-trust-windows.ts` requires the exact picker common name and fingerprint before deleting a current-user Root entry. Public recovery certificates are verified as self-signed picker roots before removal; legacy roots may be removed without matching the current installation extension profile. Windows local-CA publication in `src/claude/intercept/local-ca-files.ts` hardens legacy inherited DACLs only after verifying the current owner and exclusively current-user, SYSTEM or Administrators grants; already private directories skip hardening. Newly created exclusive files and directories are hardened before strict owner/ACL verification and before CA access. ACL verification, including inherited SQLite sidecar ACLs, is memoized by bigint device/inode/birthtime within one publication; birthtime distinguishes recycled file IDs while preserving same-volume rename identity. Path and descriptor identity checks remain active on every access, and removed or replaced entries retire their memo. The authority primitive accepts `validityDays` from 1 through 3650 for short-lived callers; omitted values preserve the existing 3650-day CA lifetime. This parameter does not install trust or rotate an existing authority. -On macOS, when the lifecycle passes `loadPickerRoutes` (the server always does), `startClaudeIntercept` also +Gateway remains the default Desktop mode. On macOS and Windows, picker intent defaults on only within the chosen first-party mode: either an explicit Desktop integration `false` or `claudeCode.intercept.picker: false` disables it. Default intent does not install certificate trust; startup and status only inspect trust, and explicit activation owns installation. + +On macOS and Windows, when the lifecycle passes `loadPickerRoutes` (the server always does), `startClaudeIntercept` also wires Claude Desktop picker mode: a second loopback CONNECT proxy on the dedicated picker proxy port (`getClaudeInterceptState()?.pickerProxyPort`), used as Desktop's pinned egress proxy. Desktop also hands that proxy to the Claude Code processes it spawns, and the two trust different CAs, so the tunnel is chosen per client from the CONNECT head: a tunnel without a browser User-Agent (Claude Code, trusting only the intercept CA) gets the `api.anthropic.com` intercept and every other target blind, never the picker; a tunnel with Chromium's `Mozilla/` User-Agent (the app, trusting only the -login keychain) is asked of the picker runtime (`src/claude/intercept/picker-runtime.ts`), which +login keychain on macOS or the current-user Root store on Windows) is asked of the picker runtime (`src/claude/intercept/picker-runtime.ts`), which blind-tunnels every target except `claude.ai:443`. Production always uses the configured adjacent ports. Lifecycle tests inject only the CONNECT factory and bind the real handlers on kernel-assigned ports; this preserves request handling while @@ -204,9 +208,9 @@ any local process already reaches (the `api.anthropic.com` intercept is on the C too; the `claude.ai` relay verifies upstream and adds no credential) and breaks only its own TLS, because each terminator presents a certificate only its intended client trusts. `claude.ai:443` is intercepted only while the runtime's cached -decision is armed: macOS, persisted resolved Desktop mode first-party, Desktop intent on, +decision is armed: macOS or Windows, persisted resolved Desktop mode first-party, Desktop intent on, `claudeCode.intercept.picker !== false`, no disarm latch, listener up, and the current picker CA -trusted in the login keychain (`picker-trust.ts`). A loopback TCP front in `picker-listener.ts` +trusted in the OS store (`picker-trust.ts`): the login keychain on macOS, the current-user Root store on Windows by exact fingerprint with a verified leaf. A loopback TCP front in `picker-listener.ts` reads ClientHello ALPN through `src/claude/intercept/client-hello.ts`, reassembling across TCP splits and up to 16 TLS records within 64 KiB of wire bytes and a 10-second deadline. It splices the untouched connection to an HTTP/2 server when the client offers `h2`, or to the native @@ -256,11 +260,10 @@ name constraints permitting only `claude.ai` and excluding every IPv4 and IPv6 a signing identity is protected by the OS credential store and scoped to the canonical config directory; normal restarts reuse the same validated certificate and key. No plaintext picker signing key is stored in that directory; public certificates and non-secret identity metadata remain under -`/claude-picker/`. Every unbound intercept startup attempt makes a best-effort cleanup of legacy `ca.key` before eligibility checks, including client role, disabled routing/interception, and ephemeral public ports; cleanup failures do not block startup. See the [runtime lifecycle contract](../runtime.md#claude-intercept-pair). Windows and Linux skip picker CA, credential-store and proxy construction entirely; the main intercept pair remains available. +`/claude-picker/`. Every unbound intercept startup attempt makes a best-effort cleanup of legacy `ca.key` before eligibility checks, including client role, disabled routing/interception, and ephemeral public ports; cleanup failures do not block startup. See the [runtime lifecycle contract](../runtime.md#claude-intercept-pair). Linux skips picker CA, credential-store and proxy construction entirely; the main intercept pair remains available. On restart the lifecycle keeps the applied profile and restores through the controller with `allowTrustPrompt: false`. An unchanged approved identity with an available credential store needs -no Certificate Trust Settings add/remove operation. Missing, revoked or unknown trust leaves the -picker pending; restore never installs trust. Explicit `on` or `trust` completes the trust step. +no macOS Certificate Trust Settings add/remove operation. Missing, revoked or unknown trust leaves the picker pending; restart and status never install trust. Explicit `on`, `trust`, or first-party apply completes the trust step, including the Windows current-user Root confirmation when needed. Legacy predecessor cleanup may still require consent during migration. Native keychain unlock and application-access dialogs are controlled by macOS; restart or upgrade does not guarantee their absence. `picker-ca-store.ts` owns the versioned OS credential service, canonical-config identity namespace, @@ -272,14 +275,12 @@ predecessor before writing the credential, verifies readback, then commits metad it removes the journal last. Recovery requires matching journal/store identity; missing initialized credentials, unavailable storage or inconsistent metadata fail closed without publishing a replacement. Gateway/off startup does not read or initialize an OS picker credential unless an applied picker -profile needs recovery. Its dormant macOS runtime/controller remains available for later explicit +profile needs recovery. Its dormant macOS or Windows runtime/controller remains available for later explicit activation, which uses the same persistent authority path. -Trust is added without a policy string: Chromium +On macOS trust is added without a policy string: Chromium skips host-scoped trust settings, so `inspectPickerTrust` treats a current CA whose exported user trust settings carry `kSecTrustSettingsPolicyString` as untrusted and an explicit trust step replaces it; an -export it cannot read makes trust `unknown`, which never arms. A rotated-out picker certificate is -removed from the login keychain as its replacement is published, and a failed removal stops the -picker arming. Publication of `ca.pem` and `ca-owner.json` happens only inside the +export it cannot read makes trust `unknown`, which never arms. On Windows trust is the current-user Root store entry matching the exact fingerprint with a verified leaf. The PowerShell runner terminates its child on timeout or output-read failure and preserves the failure category. A rotated-out picker certificate is removed from the login keychain on macOS or the current-user Root store on Windows as its replacement is published, and a failed removal stops the picker arming. Publication of `ca.pem` and `ca-owner.json` happens only inside the `ca.lock.sqlite` lock (`picker-ca.ts`): lock acquisition is reported separately from the callback, so a busy lock publishes nothing, and a missing or mismatched owner record for our own certificate is rewritten under the lock so a second process cannot rotate out a live owner's @@ -341,11 +342,9 @@ enable attempt added trust and a later check or profile write fails, it removes an earlier successful picker profile keeps the trust it needs. The owned profile helpers in `src/claude/desktop-picker-profile.ts` use the standard row `opencodex-picker`, whose file contains only `egressProxyUrl`. The previous Desktop selection is stored in -`/claude-picker/profile-state.json`, never in Desktop's `_meta.json`. +`/claude-picker/profile-state.json`, never in Desktop's `_meta.json`. On Windows the same row is written through the existing `%LOCALAPPDATA%` configLibrary writer. Full `ocx uninstall` (`src/cli/uninstall-client-state.ts`) runs the same picker cleanup after Desktop disconnect and before config removal: profile release, current-root untrust, then the pending-predecessor drain. Any residual refuses removal, so `ca.pem` and the pending journal still name the root a retry must untrust. -The local controls are `ocx claude desktop picker on|off|status|trust`. With a live server, `on`, -`off`, and transition cleanup use the controller; `trust` performs the operator's local keychain -step and then reports the result to the server. Before installing the root, the CLI independently +The local controls are `ocx claude desktop picker on|off|status|trust`; CLI help, toggle messages and GUI trust states use OS-neutral certificate-trust wording. With a live server, `on`, `off`, and transition cleanup use the controller; `trust` performs the operator's local OS trust step (login keychain on macOS, current-user Root confirmation on Windows) and then reports the result to the server. Before installing the root, the CLI independently requires the picker common name on a self-signed CA and the exact critical `claude.ai`-only DNS and all-IP exclusion constraints, plus the full minted extension profile — critical `CA:TRUE` basicConstraints, a `keyCertSign|cRLSign`-only keyUsage, a non-critical subjectKeyIdentifier, and diff --git a/structure/runtime.md b/structure/runtime.md index 13c0097ced6..28ca9567a42 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -252,7 +252,7 @@ still believes it talks to Anthropic. The proxy splices `CONNECT api.anthropic.c listener, relays every other CONNECT target blind, and refuses unauthenticated clients, plain proxied HTTP (a 405 whose body names the NO_PROXY fix), and loopback targets. Because it can never serve loopback, the same env carries a `NO_PROXY` loopback bypass (`127.0.0.1,localhost,::1,0.0.0.0`); without it Claude Code sent local HTTP MCP traffic to the proxy and every such server failed (#6936). Apply appends only the missing entries to a user value and leaves a covering value (including `*`) alone; an owned block without the bypass classifies stale so the startup migrate repairs it, and a non-string value other than null is refused as `foreign_env` rather than rewritten. Because the key is shared, apply records `{ hadKey, previous, written }` in `/opencodex-no-proxy.json` (`src/claude/intercept/settings-no-proxy.ts`, no token), and removal restores `previous` only while NO_PROXY still equals `written`, preserving anything else. Restoration needs that receipt: one that could not be written leaves none, and removal then keeps the appended entries (the harmless direction); a removal whose settings write fails before committing tries to put the receipt back. The receipt lives exactly as long as its ownership episode: an apply from an absent block and every removal invalidate it first and fail closed (`unreadable`) when they cannot, and rollback restores it with the captured value. The per-install proxy token is stored owner-only under `/claude-intercept/` (0600 plus a real per-user NTFS ACL on Windows, via `src/lib/windows-secret-acl.ts`, and re-pinned on every read-through `ensure`), and the settings file carrying it is written through the same hardened atomic writer. Every start runs `migrateClaudeInterceptSettings` (`src/claude/intercept/settings.ts`), which rewrites an owned env that no longer matches — e.g. a pre-auth URL left by an upgrade — while never creating an absent env or touching a foreign one, so a service restart cannot strand clients on 407s. Status/inspection reads the token without minting it; only apply and intercept startup create it. The TLS listener rewrites `POST /v1/messages` and `POST /v1/messages/count_tokens` to a loopback origin and dispatches them under the `claude-intercept` ingress; other paths relay to the configured upstream. One relayed path is extended: for the standalone CLI's picker catalog, the listener's `cliCatalog` hook (`src/claude/intercept/cli-catalog.ts`) merges opencodex rows into `GET /api/organizations//model_selector/cc` (or `/api/model_selector/cc`), touching only the `cc` surface and leaving Desktop's `ccd`/`code` surfaces to picker mode, and into the fallback `additional_model_options` of `GET /api/claude_cli/bootstrap`. Eligibility: the `cc` catalog is rewritten only for a CLI-classified `User-Agent` with `claudeCode.cliFirstParty` on, because Claude Code caches it on disk for every process and a Desktop-spawned process must not seed rows a plain `claude` cannot route; the bootstrap request carries no entrypoint and follows CLI intent alone. Rows come from `buildCliPickerModels` (`src/claude/intercept/picker-models.ts`), which uses Desktop 3P registry aliases because the CLI offers only Claude-shaped ids (an `ocx-claude-*` row renders "Update Claude Code"), and advertises only an alias the registry decodes back to the same route. `src/claude/intercept/cli-picker.ts` supplies them lazily on the first eligible request, persists the snapshot at `/claude-intercept/cli-picker-models.json`, re-checks each persisted row against the live registry (its alias must still decode to its recorded route; a cold registry after a restart is awaited first, and a snapshot whose rows still do not decode, for example after a provider change, is rebuilt within the same bound), and bounds a cold build at 2.5 s while awaiting the shared registry build in `src/claude/desktop-3p-startup.ts` that startup also uses (a failed or empty on-demand build is not retried for 30 s). Both CLI catalogs obey the shared [picker rewrite bounds](clients/claude-desktop.md#picker-catalog-rewrite-bounds), preflighting row growth before copying and keeping the final JSON within 16 MiB. The rewrite fails open: a provider error, non-2xx status, unparsable body, or no added row relays the upstream bytes unchanged; a body over the 16 MiB cap is cut off while streaming and answered 502 rather than buffered. Claude Code trusts its cached `/cache/model-catalog/*-cc.json` for about an hour, so `writeSettings` in `src/claude/intercept/settings.ts` and every successful `reconcileClaudeFirstPartySettings` drop those files (leaving Desktop's `*-ccd.json`); running sessions keep their list until restarted. The pair is on by default on a hub (`claudeCode.intercept.enabled`); its proxy port defaults to public port + 100 (`claudeCode.intercept.port`). The lifecycle records bind failure, and stop joins both sockets. Its serialized `ensure()` joins pending startup, reuses the live config and dispatch wrapper, and can start a previously unbound pair. `stop()` refuses future ensure calls and awaits an in-flight start. Local management applies and `ocx claude intercept start` (`POST /api/claude-intercept/start`) use this seam through `src/server/index/optional-listeners.ts`; hub-management ingress and data-plane credentials cannot start interception. Status reports `interceptReason` (with `interceptFailurePort` for a CONNECT bind failure) and optional `pickerReason` (with `pickerFailurePort` when the Desktop picker relay port is occupied), distinguishing disabled routing, client role, ephemeral public ports, occupied CONNECT ports and other failures. A changed configured port never rebinds a running pair: apply reports `port_mismatch` with both ports. With an ephemeral public port (`startServer(0)`), an explicit intercept port is required. The lifecycle makes a best-effort legacy picker-key cleanup attempt before checking client role, disabled routing/interception, or an ephemeral public port. Each new unbound lifecycle `ensure()` attempt retries cleanup; management requests rejected before that seam do not invoke it. Cleanup failures do not change the eligibility result or block startup. Direct `startClaudeIntercept` callers retain the same cleanup defense. Mock-only regression coverage lives in `tests/claude-integration/claude-intercept-cleanup.test.ts`. -This ingress honours first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). Picker mode and the primitive's optional `allowedTargets` restriction are described under [Desktop egress proxy](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). The macOS picker reuses an exportable signing identity protected by the OS credential store across normal restarts, without a plaintext key in the config directory. Restore verifies the constrained CA and current OS trust without installing trust; explicit picker `on` or `trust` grants missing or revoked trust. Legacy migration cleanup may still require consent, and native keychain unlock or application-access prompts are separate. Windows and Linux start no picker CA, credential-store or proxy work; the main intercept pair and best-effort legacy-key cleanup remain available. +This ingress honours first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). Picker mode and the primitive's optional `allowedTargets` restriction are described under [Desktop egress proxy](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). The macOS and Windows picker reuses an exportable signing identity protected by the OS credential store across normal restarts, without a plaintext key in the config directory. Restore verifies the constrained CA and current OS trust without installing trust; explicit picker `on` or `trust` completes the missing trust step, including the Windows confirmation when needed. Windows trust subprocesses are terminated on timeout or output-read failure; those failures remain distinguishable. Legacy migration cleanup may still require consent, and native macOS keychain unlock or application-access prompts are separate. Linux starts no picker CA, credential-store or proxy work; the main intercept pair and best-effort legacy-key cleanup remain available. `src/claude/intercept/client-class.ts` classifies each request by its Claude Code `User-Agent` entrypoint: `claude-desktop`, `claude-desktop-3p`, and `local-agent` are Desktop; other well-formed `claude-cli/ (external, )` values are CLI; absent or malformed values are unknown. Only a client with its own first-party intent enabled (Desktop mode or `claudeCode.cliFirstParty`) enters the router for Messages paths; other paths use the configured upstream relay. diff --git a/tests/claude-integration/claude-desktop-picker-routes.test.ts b/tests/claude-integration/claude-desktop-picker-routes.test.ts index fd60e6f2b0f..3db427d6b29 100644 --- a/tests/claude-integration/claude-desktop-picker-routes.test.ts +++ b/tests/claude-integration/claude-desktop-picker-routes.test.ts @@ -234,7 +234,7 @@ describe("without a running picker controller", () => { // Off macOS the offline status says why first: picker mode is macOS-only. expect(status.body.picker).toMatchObject({ effective: false, - reason: process.platform === "darwin" ? "proxy_unavailable" : "unsupported_platform", + reason: process.platform === "darwin" || process.platform === "win32" ? "proxy_unavailable" : "unsupported_platform", }); const on = await put({ enabled: true, persist: true }); expect(on.status).toBe(503); diff --git a/tests/claude-integration/claude-desktop-system-proxy.test.ts b/tests/claude-integration/claude-desktop-system-proxy.test.ts index ca0cf51d34f..c9b43d1c4bf 100644 --- a/tests/claude-integration/claude-desktop-system-proxy.test.ts +++ b/tests/claude-integration/claude-desktop-system-proxy.test.ts @@ -15,6 +15,11 @@ import { type WindowsProxyBypassValues, } from "../../src/lib/windows-system-proxy"; import type { OcxConfig } from "../../src/types"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inspectDesktopPickerProfile } from "../../src/claude/desktop-picker-profile"; +import { startConnectProxy } from "../../src/claude/intercept/connect-proxy"; const CLASH = parseWindowsProxyServer("127.0.0.1:7897"); const CLASH_BYPASS = "localhost;127.*;192.168.*;10.*;"; @@ -191,27 +196,63 @@ describe("collectDesktopSystemProxy", () => { claudeCode: { desktopMode: "first-party" } } as Pick; const untouchable = () => { throw new Error("registry read off the applicable path"); }; - test("never touches the registry off Windows", () => { + test("a selected Windows picker profile with a blind relay bypasses the system proxy without in-process runtime state", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-doctor-picker-")); + const library = join(root, "configLibrary"); + const options = { platform: "win32" as const, env: { OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR: library }, homeDir: root }; + const relay = await startConnectProxy(0, { + interceptPort: 1, interceptHosts: [], selectTunnel: () => ({ kind: "blind" }), + dialUpstream: () => { throw new Error("doctor must not dial an upstream"); }, + }); + try { + mkdirSync(library, { recursive: true }); + writeFileSync(join(library, "_meta.json"), JSON.stringify({ + appliedId: "picker", entries: [{ id: "picker", name: "opencodex-picker" }], + })); + writeFileSync(join(library, "picker.json"), JSON.stringify({ egressProxyUrl: `http://127.0.0.1:${relay.port}` })); + const result = await collectDesktopSystemProxy(config, { + platform: "win32", firstPartyState: () => "applied", + inspectPickerProfile: () => inspectDesktopPickerProfile(options), + readSystemProxy: () => CLASH, readBypass: () => NO_WPAD, + }); + expect(result).toEqual({ kind: "not-applicable" }); + expect(formatDesktopSystemProxyLines(result)).toEqual([]); + await relay.close(); + expect(await collectDesktopSystemProxy(config, { + platform: "win32", firstPartyState: () => "applied", + inspectPickerProfile: () => inspectDesktopPickerProfile(options), + readSystemProxy: () => CLASH, readBypass: () => NO_WPAD, + })).toEqual(verdict("conflict")); + } finally { + await relay.close(); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("never touches the registry off Windows", async () => { for (const platform of ["linux", "darwin"] as const) { - expect(collectDesktopSystemProxy(config, { platform, readSystemProxy: untouchable, readBypass: untouchable })) + expect(await collectDesktopSystemProxy(config, { platform, readSystemProxy: untouchable, readBypass: untouchable, + inspectPickerProfile: untouchable, pickerRelayAvailable: untouchable })) .toEqual({ kind: "not-applicable" }); } }); - test("never touches the registry on Windows without a Desktop first-party env", () => { - const deps: DesktopSystemProxyDeps = { platform: "win32", firstPartyState: () => "off", readSystemProxy: untouchable, readBypass: untouchable }; - expect(collectDesktopSystemProxy(config, deps)).toEqual({ kind: "not-applicable" }); + test("never touches the registry on Windows without a Desktop first-party env", async () => { + const deps: DesktopSystemProxyDeps = { platform: "win32", firstPartyState: () => "off", readSystemProxy: untouchable, readBypass: untouchable, + inspectPickerProfile: untouchable, pickerRelayAvailable: untouchable }; + expect(await collectDesktopSystemProxy(config, deps)).toEqual({ kind: "not-applicable" }); }); - test("a stale first-party env on Windows never prints ok", () => { + test("a stale first-party env on Windows never prints ok", async () => { const stale = (systemProxy: DesktopSystemProxyInput["systemProxy"], bypass: WindowsProxyBypassValues): DesktopSystemProxyDeps => - ({ platform: "win32", firstPartyState: () => "stale", readSystemProxy: () => systemProxy, readBypass: () => bypass }); + ({ platform: "win32", firstPartyState: () => "stale", readSystemProxy: () => systemProxy, readBypass: () => bypass, + inspectPickerProfile: untouchable, pickerRelayAvailable: untouchable }); for (const [systemProxy, bypass, kind] of [ [{ kind: "disabled" }, NO_WPAD, "no-proxy"], [CLASH, BYPASSED, "bypassed"], ] as const) { - const result = collectDesktopSystemProxy(config, stale(systemProxy, bypass)); + const result = await collectDesktopSystemProxy(config, stale(systemProxy, bypass)); expect(result).toEqual(verdict(kind, { settingsStale: true })); const lines = formatDesktopSystemProxyLines(result); expect(lines.join("\n")).not.toContain(" ok"); @@ -219,15 +260,77 @@ describe("collectDesktopSystemProxy", () => { expect(lines.join("\n")).toContain("ocx ensure"); } - const conflict = collectDesktopSystemProxy(config, stale(CLASH, NO_WPAD)); + const conflict = await collectDesktopSystemProxy(config, stale(CLASH, NO_WPAD)); expect(conflict).toEqual(verdict("conflict", { settingsStale: true })); const text = formatDesktopSystemProxyLines(conflict).join("\n"); expect(text).toContain("!!"); expect(text).toContain("ocx ensure"); }); - test("an applied env on Windows reads both registry surfaces", () => { - const deps: DesktopSystemProxyDeps = { platform: "win32", firstPartyState: () => "applied", readSystemProxy: () => CLASH, readBypass: () => BYPASSED }; - expect(collectDesktopSystemProxy(config, deps)).toEqual(verdict("bypassed")); + test("an applied env on Windows reads both registry surfaces", async () => { + const deps: DesktopSystemProxyDeps = { platform: "win32", firstPartyState: () => "applied", readSystemProxy: () => CLASH, readBypass: () => BYPASSED, + inspectPickerProfile: () => ({ kind: "absent" }) }; + expect(await collectDesktopSystemProxy(config, deps)).toEqual(verdict("bypassed")); + }); + + test("registry diagnostics remain for absent, unselected, unsafe or unavailable picker profiles", async () => { + let registryReads = 0; + const deps: DesktopSystemProxyDeps = { + platform: "win32", firstPartyState: () => "applied", + readSystemProxy: () => { registryReads++; return CLASH; }, + readBypass: () => { registryReads++; return NO_WPAD; }, + pickerRelayAvailable: untouchable, + }; + for (const kind of ["absent", "not_selected", "unsafe"] as const) { + const inspectPickerProfile: NonNullable = () => kind === "absent" + ? { kind } : kind === "not_selected" ? { kind, entryId: "picker" } : { kind, reason: "profile_missing" }; + expect(await collectDesktopSystemProxy(config, { ...deps, inspectPickerProfile })).toEqual(verdict("conflict")); + } + const inspectPickerProfile: NonNullable = () => + ({ kind: "applied", entryId: "picker", proxyUrl: "http://127.0.0.1:10201" }); + for (const pickerRelayAvailable of [() => false, async () => { throw new Error("relay unavailable"); }]) { + expect(await collectDesktopSystemProxy(config, { ...deps, inspectPickerProfile, pickerRelayAvailable })).toEqual(verdict("conflict")); + } + expect(await collectDesktopSystemProxy(config, { ...deps, inspectPickerProfile: untouchable })).toEqual(verdict("conflict")); + expect(registryReads).toBe(12); + }); + + test("picker off does not hide registry diagnostics behind a leftover selected profile", async () => { + expect(await collectDesktopSystemProxy({ ...config, claudeCode: { ...config.claudeCode, intercept: { picker: false } } }, { + platform: "win32", firstPartyState: () => "applied", + inspectPickerProfile: untouchable, pickerRelayAvailable: untouchable, + readSystemProxy: () => CLASH, readBypass: () => NO_WPAD, + })).toEqual(verdict("conflict")); + }); + + test("available picker relay skips both registry reads without checking picker trust or effectiveness", async () => { + const urls: string[] = []; + expect(await collectDesktopSystemProxy(config, { + platform: "win32", firstPartyState: () => "applied", + inspectPickerProfile: () => ({ kind: "applied", entryId: "picker", proxyUrl: "http://127.0.0.1:10201" }), + pickerRelayAvailable: async url => { urls.push(url); return true; }, + readSystemProxy: untouchable, readBypass: untouchable, + })).toEqual({ kind: "not-applicable" }); + expect(urls).toEqual(["http://127.0.0.1:10201"]); + }); + + test("a foreign HTTP listener or credential-bearing intercept proxy is not a Desktop picker relay", async () => { + const foreign = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("Only CONNECT", { + status: 405, headers: { Allow: "CONNECT" }, + }) }); + const intercept = await startConnectProxy(0, { interceptPort: 1, authToken: "synthetic-test-token", + dialUpstream: () => { throw new Error("doctor must not dial an upstream"); } }); + try { + for (const port of [foreign.port, intercept.port]) { + expect(await collectDesktopSystemProxy(config, { + platform: "win32", firstPartyState: () => "applied", + inspectPickerProfile: () => ({ kind: "applied", entryId: "picker", proxyUrl: `http://127.0.0.1:${port}` }), + readSystemProxy: () => CLASH, readBypass: () => NO_WPAD, + })).toEqual(verdict("conflict")); + } + } finally { + foreign.stop(true); + await intercept.close(); + } }); }); diff --git a/tests/claude-integration/claude-picker-runtime.test.ts b/tests/claude-integration/claude-picker-runtime.test.ts index 75c90ae72b4..0bf6303053f 100644 --- a/tests/claude-integration/claude-picker-runtime.test.ts +++ b/tests/claude-integration/claude-picker-runtime.test.ts @@ -102,11 +102,11 @@ afterEach(async () => { }); describe("pickerDesired", () => { - test("needs macOS, first-party, Desktop intent and no explicit picker off", () => { + test("needs a supported platform, first-party, Desktop intent and no explicit picker off", () => { expect(pickerDesired(firstParty(), "first-party", "darwin")).toBe(true); expect(pickerDesired(firstParty(), "gateway", "darwin")).toBe(false); expect(pickerDesired(firstParty(), "first-party", "linux")).toBe(false); - expect(pickerDesired(firstParty(), "first-party", "win32")).toBe(false); + expect(pickerDesired(firstParty(), "first-party", "win32")).toBe(true); expect(pickerDesired(firstParty({ claudeCode: { desktopMode: "first-party", intercept: { picker: false } } }), "first-party", "darwin")).toBe(false); expect(pickerDesired(firstParty({ claudeCode: { desktopMode: "first-party", intercept: { picker: true } } }), "first-party", "darwin")).toBe(true); }); diff --git a/tests/claude-integration/claude-picker-startup.test.ts b/tests/claude-integration/claude-picker-startup.test.ts index 9e4d420ebb5..ce90a5a581b 100644 --- a/tests/claude-integration/claude-picker-startup.test.ts +++ b/tests/claude-integration/claude-picker-startup.test.ts @@ -125,8 +125,8 @@ test("fixture cleanup restores env and attempts later roots when a removal throw removeTreeWithRetry(first); }); -test("Windows and Linux skip all picker effects while still discarding legacy key", async () => { - for (const platform of ["linux", "win32"] as const) { +test("Linux skips all picker effects while still discarding legacy key", async () => { + for (const platform of ["linux"] as const) { const { root, config } = setup(); config.claudeCode!.desktopMode = "first-party"; mkdirSync(join(root, "claude-picker")); diff --git a/tests/claude-integration/claude-picker-trust-windows.test.ts b/tests/claude-integration/claude-picker-trust-windows.test.ts new file mode 100644 index 00000000000..91d5b02b660 --- /dev/null +++ b/tests/claude-integration/claude-picker-trust-windows.test.ts @@ -0,0 +1,342 @@ +import { expect, spyOn, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTrustedWindowsElevationExecutablesForTests } from "../../src/lib/windows-elevation"; +import { createCertificateAuthority } from "../../src/claude/intercept/local-ca"; +import { + PICKER_CA_COMMON_NAME, + PICKER_HOST, + pickerCaFingerprints, +} from "../../src/claude/intercept/picker-ca"; +import { + inspectPickerTrust, + trustPickerCa, + untrustPickerCa, +} from "../../src/claude/intercept/picker-trust"; +import { + buildWindowsTrustInspectScript, + buildWindowsTrustInstallScript, + buildWindowsTrustRemoveScript, + defaultWindowsPowerShellRunner, + inspectPickerTrustWindows, + normalizePickerTrustSha1, + sanitizeWindowsPowerShellEnv, + trustPickerCaWindows, + untrustPickerCaWindows, + WINDOWS_TRUST_IMPORT_TIMEOUT_MS, + WINDOWS_TRUST_INSPECT_TIMEOUT_MS, + WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX, + WINDOWS_TRUST_POWERSHELL_PREFIX, + WINDOWS_TRUST_UNTRUST_TIMEOUT_MS, + type WindowsTrustResult, + type WindowsTrustRunner, +} from "../../src/claude/intercept/picker-trust-windows"; + +// Mutations use fake runners; the native parser case only parses scripts, never executing them. +const SHA1 = "AB".repeat(20); +const LEAF = "C:\\ocx-test\\leaf.pem"; + +function result(stdout: string, code: number | null): WindowsTrustResult { + return { code, stdout, stderr: "" }; +} + +interface Captured { + run: WindowsTrustRunner; + argv: string[][]; + timeouts: Array; +} + +function capture(...results: WindowsTrustResult[]): Captured { + const argv: string[][] = []; + const timeouts: Array = []; + const run: WindowsTrustRunner = async (args, options) => { + argv.push([...args]); + timeouts.push(options?.timeoutMs); + return results.shift() ?? result("", 2); + }; + return { run, argv, timeouts }; +} + +function scriptOf(call: string[], prefix: readonly string[]): string { + expect(call.slice(0, -1)).toEqual([...prefix]); + const script = call.at(-1) ?? ""; + expect(script.length).toBeGreaterThan(0); + return script; +} + +function decodedPaths(script: string): string[] { + const found: string[] = []; + const pattern = /FromBase64String\(\x27([^\x27]+)\x27\)/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(script)) !== null) { + found.push(Buffer.from(match[1]!, "base64").toString("utf8")); + } + return found; +} + +function assertScriptShape(script: string, embeddedPaths: string[]): void { + expect(script).toContain("X509Store('Root','CurrentUser')"); + expect(script).not.toContain("LocalMachine"); + expect(script).not.toContain("Import-Certificate"); + expect(decodedPaths(script)).toEqual(embeddedPaths); +} + +test.skipIf(process.platform !== "win32")("generated trust scripts parse in Windows PowerShell without executing store changes", async () => { + const path = "C:\\한글 'quoted'\\$certificate.pem"; + const scripts = [buildWindowsTrustInspectScript(path, SHA1), buildWindowsTrustInstallScript(path, SHA1), buildWindowsTrustRemoveScript(SHA1)]; + const literals = scripts.map(script => `'${Buffer.from(script).toString("base64")}'`).join(","); + const parser = `$sources=@(${literals});foreach($source in $sources){$tokens=$null;$errors=$null;`+ + "$script=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($source));"+ + "[System.Management.Automation.Language.Parser]::ParseInput($script,[ref]$tokens,[ref]$errors) | Out-Null;"+ + "if($errors.Count -gt 0){exit 1}};exit 0"; + const parsed = await defaultWindowsPowerShellRunner([...WINDOWS_TRUST_POWERSHELL_PREFIX, parser]); + expect(parsed.code).toBe(0); +}, 30_000); + +test("windows inspection trusts only an exact thumbprint match with a chain marker", async () => { + const captured = capture(result("OCX_PICKER_TRUST=TRUSTED\n", 0)); + expect(await inspectPickerTrustWindows(LEAF, SHA1, captured.run)).toBe("trusted"); + expect(captured.argv).toHaveLength(1); + expect(captured.timeouts).toEqual([WINDOWS_TRUST_INSPECT_TIMEOUT_MS]); + const script = scriptOf(captured.argv[0]!, WINDOWS_TRUST_POWERSHELL_PREFIX); + assertScriptShape(script, [LEAF]); + expect(script).toContain(SHA1); + expect(script).toContain(PICKER_HOST); + expect(script).toContain("Thumbprint -eq"); +}); + +test("windows inspection is a single read-only call that never installs", async () => { + const captured = capture(result("OCX_PICKER_TRUST=UNTRUSTED\n", 1)); + expect(await inspectPickerTrustWindows(LEAF, SHA1, captured.run)).toBe("untrusted"); + expect(captured.argv).toHaveLength(1); + const script = scriptOf(captured.argv[0]!, WINDOWS_TRUST_POWERSHELL_PREFIX); + expect(script).not.toContain("ReadWrite"); + expect(script).toContain("ReadOnly"); +}); + +test("windows inspection maps errors and mismatched markers to unknown", async () => { + expect(await inspectPickerTrustWindows(LEAF, SHA1, capture(result("OCX_PICKER_TRUST=ERROR\n", 2)).run)).toBe("unknown"); + expect(await inspectPickerTrustWindows(LEAF, SHA1, capture(result("OCX_PICKER_TRUST=TRUSTED\n", 1)).run)).toBe("unknown"); + expect(await inspectPickerTrustWindows(LEAF, SHA1, capture(result("OCX_PICKER_TRUST=UNTRUSTED\n", 0)).run)).toBe("unknown"); + expect(await inspectPickerTrustWindows(LEAF, SHA1, capture(result("noise\n", 0)).run)).toBe("unknown"); + expect(await inspectPickerTrustWindows(LEAF, SHA1, async () => { throw new Error("spawn failed"); })).toBe("unknown"); +}); + +test("windows inspection rejects malformed fingerprints without spawning", async () => { + const run: WindowsTrustRunner = async () => { throw new Error("runner must stay idle"); }; + for (const bad of ["", "xyz", "AB".repeat(19), `${SHA1}ZZ`, "A".repeat(41)]) { + expect(await inspectPickerTrustWindows(LEAF, bad, run)).toBe("untrusted"); + } + expect(normalizePickerTrustSha1("ab:".repeat(20).slice(0, -1))).toBe(SHA1); + expect(normalizePickerTrustSha1("nope")).toBeNull(); +}); + +test("powershell environment drops module paths case-insensitively", () => { + const env = sanitizeWindowsPowerShellEnv({ + PSModulePath: "C:\\mods", + psmodulepath: "C:\\mods2", + PSMODULEPATH: "C:\\mods3", + Path: "C:\\bin", + } as NodeJS.ProcessEnv); + expect(env).toEqual({ Path: "C:\\bin" }); +}); + +function mintPickerCa(commonName: string): string { + return createCertificateAuthority({ commonName, permittedDnsNames: [PICKER_HOST] }).certPem; +} + +test("windows trust installs a private snapshot of the authorized bytes", async () => { + const pem = mintPickerCa(PICKER_CA_COMMON_NAME); + const { sha1 } = pickerCaFingerprints(pem); + const dir = mkdtempSync(join(tmpdir(), "ocx-picker-win-shared-")); + const shared = join(dir, "ca.pem"); + writeFileSync(shared, pem); + let script = ""; + let installedPath = ""; + let installedContent = ""; + const timeouts: Array = []; + const run: WindowsTrustRunner = async (args, options) => { + timeouts.push(options?.timeoutMs); + script = scriptOf(args, WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX); + const [snapshot] = decodedPaths(script); + installedPath = snapshot ?? ""; + installedContent = readFileSync(installedPath, "utf8"); + return result("OCX_PICKER_TRUST=INSTALLED\n", 0); + }; + try { + expect(await trustPickerCaWindows(shared, run, { pem })).toEqual({ ok: true }); + expect(timeouts).toEqual([WINDOWS_TRUST_IMPORT_TIMEOUT_MS]); + expect(installedPath).not.toBe(shared); + expect(installedContent).toBe(pem); + expect(script).toContain(sha1); + assertScriptShape(script, [installedPath]); + expect(existsSync(installedPath)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("windows trust falls back to the ca file when no snapshot bytes are given", async () => { + const pem = mintPickerCa(PICKER_CA_COMMON_NAME); + const dir = mkdtempSync(join(tmpdir(), "ocx-picker-win-fallback-")); + const caPath = join(dir, "ca.pem"); + writeFileSync(caPath, pem); + try { + const run: WindowsTrustRunner = async () => result("OCX_PICKER_TRUST=INSTALLED\n", 0); + expect(await trustPickerCaWindows(caPath, run)).toEqual({ ok: true }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("windows trust refuses foreign authorities and malformed input without spawning", async () => { + const run: WindowsTrustRunner = async () => { throw new Error("runner must stay idle"); }; + expect(await trustPickerCaWindows(LEAF, run, { pem: mintPickerCa("unrelated root") })) + .toEqual({ ok: false, reason: "declined_or_failed" }); + expect(await trustPickerCaWindows(LEAF, run, { pem: "not a certificate" })) + .toEqual({ ok: false, reason: "declined_or_failed" }); + expect(await trustPickerCaWindows("C:\\definitely\\missing\\ca.pem", run)) + .toEqual({ ok: false, reason: "declined_or_failed" }); + expect(await trustPickerCaWindows(LEAF, async () => result("", 1), { pem: mintPickerCa(PICKER_CA_COMMON_NAME) })) + .toEqual({ ok: false, reason: "declined_or_failed" }); +}); + +test("windows untrust removes by exact thumbprint and reports failures", async () => { + const removed = capture(result("OCX_PICKER_TRUST=REMOVED\n", 0)); + expect(await untrustPickerCaWindows(SHA1, removed.run)).toEqual({ ok: true }); + expect(removed.timeouts).toEqual([WINDOWS_TRUST_UNTRUST_TIMEOUT_MS]); + const script = scriptOf(removed.argv[0]!, WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX); + assertScriptShape(script, []); + expect(script).toContain(SHA1); + expect(script).toContain("Thumbprint -eq"); + expect(script).toContain(`$pickerName='${PICKER_CA_COMMON_NAME}'`); + expect(script).toContain("GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName,$false) -ne $pickerName"); + expect(script.indexOf("GetNameInfo")).toBeLessThan(script.indexOf("$store.Remove")); + expect(await untrustPickerCaWindows(SHA1, capture(result("OCX_PICKER_TRUST=ERROR\n", 1)).run)) + .toEqual({ ok: false }); + const idle: WindowsTrustRunner = async () => { throw new Error("runner must stay idle"); }; + expect(await untrustPickerCaWindows("garbage", idle)).toEqual({ ok: false }); +}); + +test("generated scripts carry no module dependency and reject bad inputs", () => { + assertScriptShape(buildWindowsTrustInspectScript(LEAF, SHA1), [LEAF]); + assertScriptShape(buildWindowsTrustInstallScript(LEAF, SHA1), [LEAF]); + assertScriptShape(buildWindowsTrustRemoveScript(SHA1), []); + expect(() => buildWindowsTrustInspectScript("", SHA1)).toThrow(); + expect(() => buildWindowsTrustInspectScript(LEAF, "bad")).toThrow(); + expect(() => buildWindowsTrustInstallScript(LEAF, "bad")).toThrow(); + expect(() => buildWindowsTrustRemoveScript("bad")).toThrow(); +}); + +test("the shared entry point dispatches win32 to the windows adapter", async () => { + const trusted = capture(result("OCX_PICKER_TRUST=TRUSTED\n", 0)); + expect(await inspectPickerTrust(LEAF, SHA1, trusted.run, "win32")).toBe("trusted"); + expect(trusted.argv[0]!.slice(0, 4)).toEqual([...WINDOWS_TRUST_POWERSHELL_PREFIX]); + const installed = capture(result("OCX_PICKER_TRUST=INSTALLED\n", 0)); + expect(await trustPickerCa(LEAF, installed.run, "win32", { pem: mintPickerCa(PICKER_CA_COMMON_NAME) })) + .toEqual({ ok: true }); + expect(installed.argv[0]!.slice(0, -1)).toEqual([...WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX]); + const removed = capture(result("OCX_PICKER_TRUST=REMOVED\n", 0)); + const dir = mkdtempSync(join(tmpdir(), "ocx-picker-win-remove-")); + try { + const pem = mintPickerCa(PICKER_CA_COMMON_NAME), path = join(dir, "ca.pem"); + writeFileSync(path, pem); + expect(await untrustPickerCa(path, pickerCaFingerprints(pem).sha1, removed.run, "win32")).toEqual({ ok: true }); + } finally { rmSync(dir, { recursive: true, force: true }); } + expect(removed.argv[0]!.slice(0, -1)).toEqual([...WINDOWS_TRUST_POWERSHELL_INTERACTIVE_PREFIX]); +}); + +test("Windows removal refuses foreign recovery certificates but supports legacy picker roots", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-picker-win-remove-identity-")); + const path = join(dir, "ca.pem"); + const foreign = createCertificateAuthority({ commonName: "Foreign application root" }).certPem; + const legacy = createCertificateAuthority({ commonName: PICKER_CA_COMMON_NAME }).certPem; + const idle = capture(result("OCX_PICKER_TRUST=REMOVED\n", 0)); + try { + writeFileSync(path, foreign); + expect(await untrustPickerCa(path, pickerCaFingerprints(foreign).sha1, idle.run, "win32")).toEqual({ ok: false }); + expect(idle.argv).toHaveLength(0); + writeFileSync(path, legacy); + expect(await untrustPickerCa(path, SHA1, idle.run, "win32")).toEqual({ ok: false }); + expect(idle.argv).toHaveLength(0); + expect(await untrustPickerCa(path, pickerCaFingerprints(legacy).sha1, idle.run, "win32")).toEqual({ ok: true }); + expect(idle.argv).toHaveLength(1); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +const FAKE_TRUSTED_POWERSHELL = + "C:\\trusted-system32\\WindowsPowerShell\\v1.0\\powershell.exe"; + +test("default runner kills the child and surfaces stdout read failures instead of timing out", async () => { + setTrustedWindowsElevationExecutablesForTests({ powershell: FAKE_TRUSTED_POWERSHELL }); + const stdout = new ReadableStream({ + start(controller) { + controller.error(new Error("boom-stdout")); + }, + }); + const stderr = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + let resolveExited!: (code: number) => void; + const exited = new Promise(resolve => { + resolveExited = resolve; + }); + let kills = 0; + const spawn = spyOn(Bun, "spawn").mockReturnValue({ + stdout, + stderr, + exited, + kill() { + kills += 1; + resolveExited(1); + }, + } as unknown as ReturnType); + try { + const started = Date.now(); + const outcome = await defaultWindowsPowerShellRunner([...WINDOWS_TRUST_POWERSHELL_PREFIX, "exit 0"], { + timeoutMs: 5_000, + }); + expect(kills).toBe(1); + expect(outcome.code).toBeNull(); + expect(outcome.stderr).toContain("boom-stdout"); + expect(outcome.stderr).not.toContain("timed out"); + expect(Date.now() - started).toBeLessThan(5_000); + } finally { + spawn.mockRestore(); + setTrustedWindowsElevationExecutablesForTests(null); + } +}); + +test("default runner kills a hung child and reports a timeout", async () => { + setTrustedWindowsElevationExecutablesForTests({ powershell: FAKE_TRUSTED_POWERSHELL }); + let resolveExited!: (code: number) => void; + const exited = new Promise(resolve => { + resolveExited = resolve; + }); + const stdout = new ReadableStream({}); + const stderr = new ReadableStream({}); + let kills = 0; + const spawn = spyOn(Bun, "spawn").mockReturnValue({ + stdout, + stderr, + exited, + kill() { + kills += 1; + resolveExited(1); + }, + } as unknown as ReturnType); + try { + const outcome = await defaultWindowsPowerShellRunner([...WINDOWS_TRUST_POWERSHELL_PREFIX, "exit 0"], { + timeoutMs: 20, + }); + expect(kills).toBe(1); + expect(outcome.code).toBeNull(); + expect(outcome.stderr).toContain("timed out after 20ms"); + } finally { + spawn.mockRestore(); + setTrustedWindowsElevationExecutablesForTests(null); + } +}); diff --git a/tests/claude-integration/claude-picker-windows.test.ts b/tests/claude-integration/claude-picker-windows.test.ts new file mode 100644 index 00000000000..00fe3d9cc7d --- /dev/null +++ b/tests/claude-integration/claude-picker-windows.test.ts @@ -0,0 +1,501 @@ +import { afterEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { createServer as createHttpsServer } from "node:https"; +import { connect } from "node:net"; +import { connect as tlsConnect, type TLSSocket } from "node:tls"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { flushConfigDirHardeningAndReaps, hardenConfigDir } from "../../src/config/paths"; +import { resetHardenedStateForTests, setAsyncIcaclsRunnerForTests, setPlatformForTests } from "../../src/lib/windows-secret-acl"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { supportsDesktopPicker } from "../../src/claude/desktop-picker-platform"; +import { inspectDesktopPickerProfile } from "../../src/claude/desktop-picker-profile"; +import { createPickerRuntime, pickerDesired } from "../../src/claude/intercept/picker-runtime"; +import { startPickerListener } from "../../src/claude/intercept/picker-listener"; +import { claudeInterceptCaCertPath, createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; +import { startConnectProxy } from "../../src/claude/intercept/connect-proxy"; +import { isBrowserConnect } from "../../src/claude/intercept/connect-proxy"; +import { pickerCaCertPath, pickerCaFingerprints } from "../../src/claude/intercept/picker-ca"; +import { + getClaudePickerController, + getClaudePickerRuntime, + startClaudeIntercept, +} from "../../src/claude/intercept/runtime"; +import type { SecurityResult, SecurityRunner } from "../../src/claude/intercept/picker-trust"; +import { filePickerCaStore, memoryPickerCaStore } from "../helpers/picker-ca-store"; +import type { OcxConfig } from "../../src/types"; + +const roots: string[] = []; +const handles: Array>> = []; +const priorHome = process.env.OPENCODEX_HOME; +const priorDesktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; +const priorClaude = process.env.CLAUDE_CONFIG_DIR; + +// Config reads may leave an ACL child holding a root open after intercept stop. +// Drain hardening and reaps before retrying removal; retain failures and restore env. +async function cleanupPickerWindows(remove: (dir: string) => void = removeTreeWithRetry): Promise { + const failures: unknown[] = []; + for (const handle of handles.splice(0)) { + try { await handle?.stop(); } catch (error) { failures.push(error); } + } + for (const dir of roots.splice(0)) { + try { + await flushConfigDirHardeningAndReaps(dir); + remove(dir); + } catch (error) { failures.push(error); } + } + if (priorHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = priorHome; + if (priorDesktop === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = priorDesktop; + if (priorClaude === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = priorClaude; + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AggregateError(failures, "picker-windows cleanup failed"); +} +afterEach(() => cleanupPickerWindows()); + +const routes = async () => ({ nativeSlugs: [], routedModels: [] }); + +function setup(firstParty = true) { + const root = mkdtempSync(join(tmpdir(), "ocx-picker-win-")); + roots.push(root); + process.env.OPENCODEX_HOME = root; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(root, "desktop"); + process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); + const config = { + port: 10100, + providers: {}, + defaultProvider: "openai", + claudeCode: { desktopMode: firstParty ? "first-party" : "gateway", intercept: { port: 10200 } }, + } as OcxConfig; + saveConfig(config); + return { root, config }; +} + +test("Windows fixture cleanup waits for pending config-directory ACL hardening", async () => { + let release!: () => void; + let started!: () => void; + const blocked = new Promise(resolve => { release = resolve; }); + const entered = new Promise(resolve => { started = resolve; }); + const { root } = setup(); + resetHardenedStateForTests(); + setPlatformForTests("win32"); + setAsyncIcaclsRunnerForTests(async () => { + started(); + await blocked; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + let cleanup: Promise | undefined; + try { + hardenConfigDir(); + await entered; + cleanup = cleanupPickerWindows(); + await Promise.resolve(); + expect(existsSync(root)).toBe(true); + release(); + await cleanup; + expect(existsSync(root)).toBe(false); + expect(process.env.OPENCODEX_HOME).toBe(priorHome); + expect(process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR).toBe(priorDesktop); + expect(process.env.CLAUDE_CONFIG_DIR).toBe(priorClaude); + } finally { + release(); + try { + await flushConfigDirHardeningAndReaps(root); + await cleanup; + } finally { + setAsyncIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + } + } +}); + +test("Windows fixture cleanup attempts every stop and root and preserves both failures", async () => { + const first = setup().root, second = setup().root; + let laterStops = 0; + const attempted: string[] = []; + const stopError = new Error("stop failed"); + const removeError = new Error("EBUSY: resource busy or locked"); + type Handle = Awaited>; + handles.push({ stop: async () => { throw stopError; } } as unknown as Handle); + handles.push({ stop: async () => { laterStops++; } } as unknown as Handle); + try { + let failure: unknown; + try { + await cleanupPickerWindows(dir => { + attempted.push(dir); + if (dir === first) throw removeError; + removeTreeWithRetry(dir); + }); + } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors).toEqual([stopError, removeError]); + expect(laterStops).toBe(1); + expect(attempted).toEqual([first, second]); + expect(existsSync(second)).toBe(false); + expect(process.env.OPENCODEX_HOME).toBe(priorHome); + expect(process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR).toBe(priorDesktop); + expect(process.env.CLAUDE_CONFIG_DIR).toBe(priorClaude); + } finally { + await flushConfigDirHardeningAndReaps(first); + await flushConfigDirHardeningAndReaps(second); + removeTreeWithRetry(first); + removeTreeWithRetry(second); + } +}); + +// Synthetic Windows trust matching src/claude/intercept/picker-trust-windows.ts. +// The runner receives powershell argv (exe resolved by the default runner, so +// argv begins with -NoLogo): inspect uses the read-only prefix plus a script +// built by buildWindowsTrustInspectScript (X509Store CurrentUser Root exact +// thumbprint lookup, OCX_PICKER_TRUST=TRUSTED with exit 0 or UNTRUSTED with +// exit 1); install uses the interactive prefix plus $store.Add with +// OCX_PICKER_TRUST=INSTALLED; removal uses $store.Remove with +// OCX_PICKER_TRUST=REMOVED. The fake answers exactly that format and never +// spawns a process or touches a real store. +function windowsTrust(state: { trusted: boolean }) { + const calls: string[][] = []; + const mutations: string[] = []; + const run: SecurityRunner = async (args: readonly string[]) => { + calls.push([...args]); + if (args[0] !== "-NoLogo") throw new Error("win32 trust argv must begin with -NoLogo, got: " + String(args[0])); + if (!args.includes("-Command")) throw new Error("win32 trust argv must include -Command"); + const script = String(args[args.length - 1] ?? ""); + if (!script.includes("OCX_PICKER_TRUST=")) throw new Error("win32 trust script must emit OCX_PICKER_TRUST="); + if (!script.includes("X509Store")) throw new Error("win32 trust script must use X509Store"); + if (script.includes("$store.Add")) { + state.trusted = true; + mutations.push("add"); + return { code: 0, stdout: "OCX_PICKER_TRUST=INSTALLED\n", stderr: "" } as SecurityResult; + } + if (script.includes("$store.Remove")) { + state.trusted = false; + mutations.push("remove"); + return { code: 0, stdout: "OCX_PICKER_TRUST=REMOVED\n", stderr: "" } as SecurityResult; + } + if (state.trusted) { + return { code: 0, stdout: "OCX_PICKER_TRUST=TRUSTED\n", stderr: "" } as SecurityResult; + } + return { code: 1, stdout: "OCX_PICKER_TRUST=UNTRUSTED\n", stderr: "" } as SecurityResult; + }; + return { run, calls, mutations, state }; +} + +test("win32 is a supported picker platform with first-party desired", () => { + expect(supportsDesktopPicker("win32")).toBe(true); + expect(supportsDesktopPicker("darwin")).toBe(true); + expect(supportsDesktopPicker("linux")).toBe(false); + const firstParty = { claudeCode: { desktopMode: "first-party" } } as OcxConfig; + expect(pickerDesired(firstParty, "first-party", "win32")).toBe(true); + expect(pickerDesired(firstParty, "first-party", "linux")).toBe(false); + expect(pickerDesired(firstParty, "gateway", "win32")).toBe(false); +}); + +test("Windows startup wires the picker proxy and controller while discarding legacy key", async () => { + const { root, config } = setup(true); + mkdirSync(join(root, "claude-picker"), { recursive: true }); + writeFileSync(join(root, "claude-picker", "ca.key"), "legacy-test-key"); + const trust = windowsTrust({ trusted: true }); + const fake = memoryPickerCaStore(); + let binds = 0; + const handle = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async () => new Response(), + loadPickerRoutes: routes, + pickerPlatform: "win32", + pickerCaStore: fake.store, + pickerSecurity: trust.run, + startProxy: async (_port, options) => { binds++; return startConnectProxy(0, options); }, + }); + handles.push(handle); + expect(existsSync(join(root, "claude-picker", "ca.key"))).toBe(false); + expect(binds).toBe(2); + expect(handle?.pickerProxyPort).not.toBeNull(); + expect(getClaudePickerController()).not.toBeNull(); + expect(getClaudePickerRuntime()).not.toBeNull(); + const runtime = getClaudePickerRuntime()!; + expect(runtime.status()).toMatchObject({ supported: true, desired: true }); + for (const call of trust.calls) { + expect(call[0]).toBe("-NoLogo"); + expect(call).toContain("-Command"); + expect(String(call[call.length - 1])).toContain("OCX_PICKER_TRUST="); + } +}, 30_000); + +test("explicit enable on Windows activates the picker profile once trusted", async () => { + const { root, config } = setup(true); + const trust = windowsTrust({ trusted: false }); + const fake = memoryPickerCaStore(); + const handle = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async () => new Response(), + loadPickerRoutes: routes, + pickerPlatform: "win32", + pickerCaStore: fake.store, + pickerSecurity: trust.run, + startProxy: async (_port, options) => startConnectProxy(0, options), + }); + handles.push(handle); + const controller = getClaudePickerController()!; + const enabled = await controller.enable({ persist: false, context: "server" }); + expect(enabled.supported).toBe(true); + expect(enabled.trust).toBe("trusted"); + expect(enabled.profile).toBe("applied"); + expect(enabled.effective).toBe(true); + expect(trust.mutations).toContain("add"); + const inspected = inspectDesktopPickerProfile({ configDir: root, platform: "win32" }); + expect(inspected.kind).toBe("applied"); + if (inspected.kind === "applied") { + expect(inspected.proxyUrl).toBe("http://127.0.0.1:" + String(handle?.pickerProxyPort)); + } +}, 30_000); + +test("Windows reload retains fingerprint and issues no trust mutations", async () => { + const { root, config } = setup(true); + const storePath = join(root, "test-only-store"); + const trust = windowsTrust({ trusted: false }); + const first = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async () => new Response(), + loadPickerRoutes: routes, + pickerPlatform: "win32", + pickerCaStore: filePickerCaStore(storePath), + pickerSecurity: trust.run, + startProxy: async (_port, options) => startConnectProxy(0, options), + }); + handles.push(first); + await getClaudePickerController()!.enable({ persist: false, context: "server" }); + const fingerprint = pickerCaFingerprints(readFileSync(pickerCaCertPath(root), "utf8")).sha256; + await first.stop(); + handles.splice(0, handles.length); + trust.mutations.splice(0, trust.mutations.length); + trust.calls.splice(0, trust.calls.length); + const second = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async () => new Response(), + loadPickerRoutes: routes, + pickerPlatform: "win32", + pickerCaStore: filePickerCaStore(storePath), + pickerSecurity: trust.run, + startProxy: async (_port, options) => startConnectProxy(0, options), + }); + handles.push(second); + expect(pickerCaFingerprints(readFileSync(pickerCaCertPath(root), "utf8")).sha256).toBe(fingerprint); + expect(trust.mutations).toEqual([]); + const status = await getClaudePickerController()!.status(); + expect(status.supported).toBe(true); +}, 30_000); + +test("Windows off removes picker trust and profile but keeps unrelated entries", async () => { + const { root, config } = setup(true); + const desktop = join(root, "desktop"); + mkdirSync(desktop, { recursive: true }); + const foreignId = "foreign-personal"; + writeFileSync(join(desktop, foreignId + ".json"), '{"foreign":true}\n'); + writeFileSync(join(desktop, "_meta.json"), JSON.stringify({ appliedId: foreignId, entries: [{ id: foreignId, name: "Personal" }] }, null, 2) + "\n"); + const trust = windowsTrust({ trusted: false }); + const fake = memoryPickerCaStore(); + const handle = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async () => new Response(), + loadPickerRoutes: routes, + pickerPlatform: "win32", + pickerCaStore: fake.store, + pickerSecurity: trust.run, + startProxy: async (_port, options) => startConnectProxy(0, options), + }); + handles.push(handle); + const controller = getClaudePickerController()!; + await controller.enable({ persist: false, context: "server" }); + expect(trust.state.trusted).toBe(true); + const off = await controller.disable({ persist: false }); + expect(trust.state.trusted).toBe(false); + expect(off.effective).toBe(false); + const meta = JSON.parse(readFileSync(join(desktop, "_meta.json"), "utf8")) as { appliedId?: string; entries: Array<{ id: string; name: string }> }; + expect(meta.entries.some(e => e.id === foreignId)).toBe(true); + expect(meta.entries.some(e => e.name === "opencodex-picker")).toBe(false); + expect(existsSync(join(desktop, foreignId + ".json"))).toBe(true); + expect(inspectDesktopPickerProfile({ configDir: root, platform: "win32" }).kind).not.toBe("applied"); +}, 30_000); + +test("Windows CONNECT chooser separates browser claude.ai from CLI api.anthropic.com", async () => { + const { root, config } = setup(true); + const trust = windowsTrust({ trusted: true }); + const fake = memoryPickerCaStore(); + let pickerSelect: ((host: string, port: number, req: { userAgent: string | null }) => unknown) | null = null; + const handle = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async () => new Response(), + loadPickerRoutes: routes, + pickerPlatform: "win32", + pickerCaStore: fake.store, + pickerSecurity: trust.run, + startProxy: async (_port, options) => { + if (options.selectTunnel) pickerSelect = options.selectTunnel as never; + return startConnectProxy(0, options); + }, + }); + handles.push(handle); + expect(pickerSelect).not.toBeNull(); + const browser = { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" }; + const cli = { userAgent: null }; + expect(isBrowserConnect(browser)).toBe(true); + expect(isBrowserConnect(cli)).toBe(false); + const pick = pickerSelect!; + expect(await pick("claude.ai", 443, browser)).toMatchObject({ kind: "intercept" }); + expect(await pick("claude.ai", 443, cli)).toMatchObject({ kind: "blind" }); + expect(await pick("api.anthropic.com", 443, cli)).toMatchObject({ kind: "intercept" }); + const browserApi = await pick("api.anthropic.com", 443, browser) as unknown; + expect(browserApi === null || (browserApi as { kind: string }).kind === "blind").toBe(true); +}, 30_000); + +function tlsViaPicker(port: number, authority: string, servername: string, caPem: string, userAgent: string | null): Promise { + return new Promise((resolve, reject) => { + const plain = connect({ host: "127.0.0.1", port }, () => { + const ua = userAgent ? "User-Agent: " + userAgent + "\r\n" : ""; + plain.write("CONNECT " + authority + " HTTP/1.1\r\nHost: " + authority + "\r\n" + ua + "\r\n"); + }); + let head = ""; + const timer = setTimeout(() => { plain.destroy(); reject(new Error("CONNECT timed out")); }, 10_000); + const onData = (chunk: Buffer) => { + head += chunk.toString("latin1"); + if (!head.includes("\r\n\r\n")) return; + const status = Number(/HTTP\/1\.1 (\d{3})/.exec(head)?.[1]); + if (status !== 200) { + clearTimeout(timer); + plain.destroy(); + reject(new Error("CONNECT refused with " + status)); + return; + } + clearTimeout(timer); + plain.off("data", onData); + plain.removeAllListeners("error"); + const tls = tlsConnect({ socket: plain, servername, ca: caPem, rejectUnauthorized: true }); + tls.once("secureConnect", () => resolve(tls)); + tls.once("error", reject); + }; + plain.on("data", onData); + plain.once("error", err => { clearTimeout(timer); reject(err); }); + }); +} + +function readHttp(socket: TLSSocket, payload: string): Promise<{ status: number; body: Buffer }> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const timer = setTimeout(() => { socket.destroy(); reject(new Error("HTTP exchange timed out")); }, 10_000); + socket.on("data", chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + socket.once("close", () => { + clearTimeout(timer); + const raw = Buffer.concat(chunks).toString("binary"); + const split = raw.indexOf("\r\n\r\n"); + if (split < 0) { reject(new Error("HTTP response without headers")); return; } + const status = Number(/HTTP\/1\.1 (\d{3})/.exec(raw)?.[1]); + resolve({ status, body: Buffer.from(raw.slice(split + 4), "binary") }); + }); + socket.once("error", err => { clearTimeout(timer); reject(err); }); + socket.write(payload); + }); +} + +async function httpsGet(socket: TLSSocket, host: string, path: string): Promise<{ status: number; body: Buffer }> { + return readHttp(socket, "GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n"); +} + +async function httpsPost(socket: TLSSocket, host: string, path: string, json: string): Promise<{ status: number; body: Buffer }> { + return readHttp(socket, + "POST " + path + " HTTP/1.1\r\nHost: " + host + "\r\nContent-Type: application/json\r\nContent-Length: " + String(Buffer.byteLength(json)) + "\r\nConnection: close\r\n\r\n" + json); +} + +test("Windows picker carries real CONNECT+TLS end to end for browser bootstrap and CLI messages", async () => { + const { root, config } = setup(true); + const upstreamCa = createLocalInterceptCa(); + const upstreamLeaf = issueLocalInterceptLeaf(upstreamCa, ["claude.ai"]); + const nativeBootstrap = { model_selector_config: [{ id: "code", models: [{ id: "claude-native", name: "Native", section: "main" }] }] }; + const upstreamSockets = new Set(); + const upstream = createHttpsServer({ cert: upstreamLeaf.certPem, key: upstreamLeaf.keyPem }, (req, res) => { + if (req.method === "GET" && (req.url === "/api/bootstrap" || req.url?.startsWith("/api/bootstrap?"))) { + const body = Buffer.from(JSON.stringify(nativeBootstrap)); + res.writeHead(200, { "Content-Type": "application/json", "Content-Length": String(body.length) }); + res.end(body); + return; + } + if (req.method === "GET" && req.url === "/v1/other") { + const body = Buffer.from(JSON.stringify({ native: "passthrough" })); + res.writeHead(200, { "Content-Type": "application/json", "Content-Length": String(body.length) }); + res.end(body); + return; + } + res.writeHead(404, { "Content-Length": "0" }); + res.end(); + }); + upstream.on("secureConnection", socket => { + upstreamSockets.add(socket); + socket.once("close", () => upstreamSockets.delete(socket)); + }); + await new Promise(resolve => upstream.listen(0, "127.0.0.1", () => resolve())); + const upstreamPort = (upstream.address() as { port: number }).port; + const trust = windowsTrust({ trusted: true }); + const fake = memoryPickerCaStore(); + const routed = async () => ({ nativeSlugs: [], routedModels: [{ provider: "xai", id: "grok-test", contextWindow: 256_000 }] }); + const handle = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async req => { + const url = new URL(req.url); + if (url.pathname === "/v1/messages") return Response.json({ dispatched: true }); + return new Response("unexpected", { status: 500 }); + }, + loadPickerRoutes: routed, + pickerPlatform: "win32", + pickerCaStore: fake.store, + pickerSecurity: trust.run, + createPicker: options => createPickerRuntime({ + ...options, + readConfig: () => config, + resolveMode: () => "first-party", + startListener: listenerOptions => startPickerListener({ + ...listenerOptions, + upstream: { host: "127.0.0.1", port: upstreamPort, servername: "claude.ai", ca: upstreamCa.certPem }, + }), + }), + startProxy: async (_port, options) => startConnectProxy(0, options), + }); + handles.push(handle); + try { + await getClaudePickerController()!.enable({ persist: false, context: "server" }); + const pickerPort = handle?.pickerProxyPort!; + expect(pickerPort).toBeGreaterThan(0); + const browserUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; + const pickerCa = readFileSync(pickerCaCertPath(root), "utf8"); + const bootstrapTls = await tlsViaPicker(pickerPort, "claude.ai:443", "claude.ai", pickerCa, browserUA); + try { + const bootstrap = await httpsGet(bootstrapTls, "claude.ai", "/api/bootstrap"); + expect(bootstrap.status).toBe(200); + const catalog = JSON.parse(bootstrap.body.toString("utf8")) as { model_selector_config: Array<{ models: Array<{ id: string }> }> }; + const ids = catalog.model_selector_config.flatMap(surface => surface.models.map(entry => entry.id)); + expect(ids).toContain("claude-native"); + expect(ids).toContain("ocx-claude-xai--grok-test"); + } finally { + bootstrapTls.destroy(); + } + const otherTls = await tlsViaPicker(pickerPort, "claude.ai:443", "claude.ai", pickerCa, browserUA); + try { + const other = await httpsGet(otherTls, "claude.ai", "/v1/other"); + expect(other.status).toBe(200); + expect(other.body.toString("utf8")).toBe(JSON.stringify({ native: "passthrough" })); + } finally { + otherTls.destroy(); + } + const mainCa = readFileSync(claudeInterceptCaCertPath(root), "utf8"); + const cli = await tlsViaPicker(pickerPort, "api.anthropic.com:443", "api.anthropic.com", mainCa, null); + try { + const posted = await httpsPost(cli, "api.anthropic.com", "/v1/messages", JSON.stringify({ model: "claude-x" })); + expect(posted.status).toBe(200); + expect((JSON.parse(posted.body.toString("utf8")) as { dispatched: boolean }).dispatched).toBe(true); + } finally { + cli.destroy(); + } + } finally { + for (const socket of upstreamSockets) socket.destroy(); + await new Promise(resolve => upstream.close(() => resolve())); + } +}, 60_000); diff --git a/tests/cli/uninstall.test.ts b/tests/cli/uninstall.test.ts index 74d99e1429a..632f457f83f 100644 --- a/tests/cli/uninstall.test.ts +++ b/tests/cli/uninstall.test.ts @@ -332,11 +332,14 @@ function uninstallFixture() { finishCleanup: boolean; lease?: ClientLifecycleHeld; aclReapPending: boolean; + picker: { ok: boolean; residual?: string[] }; + pickerCleanup: Array<{ disconnectsBefore: number; finalLocksBefore: number }>; calls: { read: number; cleanup: number; integrations: number; remove: number; finalLock: number }; cleanupOptions: Array[0]>; } = { connection: { kind: "disconnected" }, desktop: { kind: "absent" }, receipt: { kind: "absent" }, finishCleanup: true, aclReapPending: false, + picker: { ok: true }, pickerCleanup: [], calls: { read: 0, cleanup: 0, integrations: 0, remove: 0, finalLock: 0 }, cleanupOptions: [], }; const deps: UninstallClientStateDeps = { @@ -387,6 +390,12 @@ function uninstallFixture() { return { status: "removed", residualPaths: [] }; }, aclReapPending: () => fixture.aclReapPending, + removePickerArtifacts: async () => { + // Picker cleanup takes the same L; reaching it here proves uninstall does not hold L yet. + withClientLifecycleSync(held => assertClientLifecycleHeld(held), { lockPath }); + fixture.pickerCleanup.push({ disconnectsBefore: fixture.calls.cleanup, finalLocksBefore: fixture.calls.finalLock }); + return fixture.picker; + }, }; const bytes = () => sentinels.map(path => readFileSync(join(configDir, path), "utf8")); return { fixtureRoot, configDir, lockPath, fixture, deps, bytes }; @@ -487,6 +496,28 @@ describe("uninstall client cleanup before owner-state deletion", () => { }); }); + test("picker trust is retired after disconnect and outside L, before config removal", async () => { + await withUninstallFixture(async f => { + f.fixture.connection = connectedFixture; + f.fixture.desktop = { kind: "active", owner: cleanupOwner }; + await removeOwnedConfigAfterDesktopCleanup(safeTeardown, f.deps); + expect(f.fixture.pickerCleanup).toEqual([{ disconnectsBefore: 1, finalLocksBefore: 0 }]); + expect(f.fixture.calls.remove).toBe(1); + }); + }); + + test("a picker trust residual preserves the records naming the root and skips removal", async () => { + await withUninstallFixture(async f => { + f.fixture.picker = { ok: false, residual: ["trust"] }; + const before = f.bytes(); + await expect(removeOwnedConfigAfterDesktopCleanup(safeTeardown, f.deps)) + .rejects.toThrow("Claude Desktop picker cleanup left trust behind"); + expect(f.fixture.calls.integrations).toBe(0); + expect(f.fixture.calls.remove).toBe(0); + expect(f.bytes()).toEqual(before); + }); + }); + test("a replacement connection before disconnect claims L survives uninstall", async () => { await withUninstallFixture(async f => { f.fixture.connection = connectedFixture; @@ -629,6 +660,7 @@ describe("uninstall restores recorded third-party integrations before deleting r return { status: "removed", residualPaths: [] }; }, aclReapPending: () => false, + removePickerArtifacts: async () => ({ ok: true }), }; return { root, home, env, configDir, clientConfig, store, deps }; } diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 5e817d539f2..7aec5d2d7d7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,6 @@ { + "claude-picker-trust-windows.test.ts": "claude-integration", + "claude-picker-windows.test.ts": "claude-integration", "messages-native-empty-inline.test.ts": "claude-integration", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", diff --git a/tests/server/claude-intercept-integration.test.ts b/tests/server/claude-intercept-integration.test.ts index c5cece69dfc..1cf92fed9e3 100644 --- a/tests/server/claude-intercept-integration.test.ts +++ b/tests/server/claude-intercept-integration.test.ts @@ -50,6 +50,12 @@ afterEach(() => { // because an unknown one is relayed to the real Anthropic host instead of the configured upstream. const CLI_UA = { "user-agent": "claude-cli/2.1.282 (external, cli)" }; +// These cases exercise local request routing. Installed service ownership is covered +// separately; querying the real Windows service manager can outlast the server watchdog. +function startInterceptServer(port: number) { + return startServer(port, { inspectNativeCodexOwnership: () => ({ ownership: "owned", reason: "isolated test home" }) }); +} + async function waitForIntercept(): Promise>> { for (let i = 0; i < 100; i++) { const state = getClaudeInterceptState(); @@ -84,7 +90,7 @@ test("Messages through CONNECT reach the router; other paths relay to the config intercept: { port: interceptPort }, }, } as unknown as OcxConfig); - const server = startServer(publicPort); + const server = startInterceptServer(publicPort); try { const state = await waitForIntercept(); expect(state.proxyPort).toBe(interceptPort); @@ -163,7 +169,7 @@ test("a first-party binding routes a picker id on the intercept only; the public intercept: { port: interceptPort, modelMap: { "claude-sonnet-4-6": "bindtarget/fake-model" } }, }, } as unknown as OcxConfig); - const server = startServer(publicPort); + const server = startInterceptServer(publicPort); try { const state = await waitForIntercept(); const ca = readFileSync(state.caCertPath, "utf8"); @@ -216,7 +222,7 @@ test("an owned legacy unauthenticated env is migrated on start; nothing else is }, claudeCode: { intercept: { port: interceptPort } }, } as unknown as OcxConfig); - const server = startServer(publicPort); + const server = startInterceptServer(publicPort); try { const state = await waitForIntercept(); const token = readClaudeInterceptProxyToken(testDir); @@ -238,7 +244,7 @@ test("an ephemeral public port starts no proxy unless intercept.port is explicit }, }; saveConfig({ ...base, port: 10100 } as unknown as OcxConfig); - const implicit = startServer(0); + const implicit = startInterceptServer(0); try { await Bun.sleep(100); expect(getClaudeInterceptState()).toBeNull(); @@ -248,7 +254,7 @@ test("an ephemeral public port starts no proxy unless intercept.port is explicit const proxyPort = await findAvailablePort(0, "127.0.0.1"); saveConfig({ ...base, port: 10100, claudeCode: { intercept: { port: proxyPort } } } as unknown as OcxConfig); - const explicit = startServer(0); + const explicit = startInterceptServer(0); try { const state = await waitForIntercept(); expect(state.proxyPort).toBe(proxyPort); @@ -268,7 +274,7 @@ test("intercept.enabled=false starts no proxy", async () => { }, claudeCode: { intercept: { enabled: false } }, } as unknown as OcxConfig); - const server = startServer(publicPort); + const server = startInterceptServer(publicPort); try { await Bun.sleep(100); expect(getClaudeInterceptState()).toBeNull();