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 3e8decdd978..268c5623261 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -121,7 +121,34 @@ Sur macOS, l'intégration automatique (`claudeCode.systemEnv`) suit la même ré `claude` lancée sans passer par `ocx` se comporte donc de la même manière. Le fichier d'environnement est un instantané actualisé au démarrage du proxy ou lors de l'enregistrement des paramètres, tandis que `ocx claude` effectue toujours une résolution immédiate. -## Profil Claude Desktop +## Modes Claude Desktop : first-party (par défaut) et passerelle + +Claude Desktop utilise OpenCodex dans l'un de deux modes mutuellement exclusifs. Choisissez-le dans +**Claude → Bureau → Mode de connexion** du tableau de bord ou avec +`ocx claude desktop apply --first-party|--gateway`. + +- **First-party (par défaut)** : Desktop lui-même n'est pas reconfiguré. La connexion claude.ai, + l'onglet Chat, les connecteurs et le contrôle à distance continuent de fonctionner. OpenCodex + n'écrit que deux valeurs dans le bloc `env` de `~/.claude/settings.json` : + `HTTPS_PROXY=http://127.0.0.1:` et + `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem`. Seuls Claude Code lancé par Desktop + pour l'onglet Code (sous-agents compris) et la CLI `claude` du terminal les lisent et passent par le + proxy d'interception local ; seuls `POST /v1/messages` et `count_tokens` sont traités par OpenCodex, + les autres chemins de `api.anthropic.com` sont relayés tels quels vers Anthropic. L'AC n'est jamais + installée dans le magasin de confiance du système. +- **Passerelle (tiers)** : l'ancien mode ; le profil ci-dessous fait basculer toute l'application sur + OpenCodex comme passerelle. Sélectionnez-le explicitement (`--gateway`, ou les anciens + `--static`/`--hybrid`/`--discovery-only`). + +Le mode est enregistré dans `claudeCode.desktopMode`. Les installations ayant déjà appliqué un profil +passerelle le conservent après mise à jour ; seules les nouvelles installations démarrent en +first-party. Changer de mode supprime la configuration de l'autre mode (uniquement les valeurs +écrites par OpenCodex) ; un `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` étranger (proxy d'entreprise) n'est +jamais écrasé et l'application est refusée. Quittez complètement Desktop puis rouvrez-le après un +changement. Les détails et la compatibilité de la CLI Claude Code sont décrits dans la documentation +anglaise. + +## Profil Claude Desktop (mode passerelle) Claude Desktop utilise un profil distinct de Claude Code. Ouvrez **Claude → Bureau** dans le tableau de bord afin de placer chaque route disponible dans l'une des quatre familles : Opus, Fable, Sonnet ou Haiku. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 5f04d0f41b9..5190ca1da94 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -135,9 +135,73 @@ the proxy starts or you save settings, while `ocx claude` always resolves live. ## System environment integration (macOS) -## Claude Desktop profile +## Claude Desktop modes: first-party (default) and gateway -Claude Desktop uses a separate profile from Claude Code. Open **Claude → Desktop** in the +Claude Desktop can use OpenCodex in one of two mutually exclusive modes. Pick it in +**Claude → Desktop → Connection mode** in the dashboard or with `ocx claude desktop apply +--first-party|--gateway`. + +**First-party** is the default for new installs. Desktop itself is not reconfigured: it stays +signed in to claude.ai, and the Chat tab, connectors, cloud sessions and remote control keep +working. OpenCodex only writes two variables into the `env` block of `~/.claude/settings.json` +(honoured by `CLAUDE_CONFIG_DIR`): + +```json +{ + "env": { + "HTTPS_PROXY": "http://127.0.0.1:10200", + "NODE_EXTRA_CA_CERTS": "/.opencodex/claude-intercept/ca.pem" + } +} +``` + +Claude Code — the process Desktop spawns for its Code tab, every subagent it launches, and the +standalone `claude` CLI — reads that env and sends its `api.anthropic.com` traffic through the +local intercept proxy. The proxy listens on the public port + 100 (`claudeCode.intercept.port` +overrides it), terminates TLS with a per-install CA stored under `~/.opencodex/claude-intercept/` +(never installed into the OS trust store; only Node processes that read `NODE_EXTRA_CA_CERTS` +trust it), and hands `POST /v1/messages` and `POST /v1/messages/count_tokens` to the same +Messages handler `ocx claude` uses. Every other path on `api.anthropic.com` (OAuth, profile, +usage) is relayed byte-for-byte to Anthropic, and unrelated hosts are tunnelled untouched, so your +subscription login keeps working. Existing OpenCodex features — `modelMap`, aliases, native +passthrough, sidecars, auto-context — apply the same way they do for `ocx claude`. + +**Gateway** is the previous third-party mode: the profile described in the next section switches +the whole app to OpenCodex as its inference gateway. Chat runs locally through OpenCodex and the +claude.ai-only features are unavailable. Select it explicitly (`--gateway`, the dashboard +selector, or the legacy `--static` / `--hybrid` / `--discovery-only` flags, which imply it). + +Mode is persisted as `claudeCode.desktopMode`. Installs that already applied a gateway profile +keep gateway after updating; nothing is switched silently. Switching in either direction removes +the other mode's configuration (only values OpenCodex wrote — a foreign `HTTPS_PROXY` or +`NODE_EXTRA_CA_CERTS`, for example a corporate proxy, is never overwritten and the apply is +refused instead). Fully quit and reopen Desktop after switching. `ocx ensure` refreshes a stale +first-party env when the integration is ON and removes it when OFF. Set +`claudeCode.intercept.enabled: false` to disable the proxy entirely; first-party then cannot be +applied and an implicit apply falls back to gateway. On a connected client the proxy runs on the +hub, so `ocx claude desktop apply` there uses the gateway profile. + +### Claude Code CLI compatibility + +The same `settings.json` env drives the standalone `claude` CLI, so a first-party apply also +covers terminal sessions, `claude -p`, and subagents without `ocx claude`'s +`ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` shell env. Differences from `ocx claude`: + +- Model discovery (`/model` → "From gateway") is not available; Claude Code only queries + `GET /v1/models` on a configured gateway. Use `modelMap` to route the built-in Anthropic model + ids, or type an alias directly. +- `ANTHROPIC_SMALL_FAST_MODEL` and `CLAUDE_CODE_SUBAGENT_MODEL` are chosen by the CLI before the + request is sent; set them in `settings.json` yourself if a sidecar or subagent should use a + mapped id. +- `ocx claude` and first-party coexist: a session started with `ocx claude` talks to + `ANTHROPIC_BASE_URL` (plain HTTP on loopback), which `HTTPS_PROXY` does not cover, so that + process reaches OpenCodex directly and the proxy simply sees no traffic from it. +- Claude Code honours `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` as documented for corporate proxies; + a CLI release that stops doing so would stop routing, not break login. + +## Claude Desktop profile (gateway mode) + +The profile below is written only in gateway mode. Claude Desktop uses a separate profile from Claude Code. Open **Claude → Desktop** in the dashboard to place each available route in one of four families: Opus, Fable, Sonnet, or Haiku. All routes start in Opus on a new profile. The first Opus route becomes the initial overall default, and every non-empty family always has one family default. @@ -161,9 +225,10 @@ ocx claude desktop export ocx claude desktop import [--apply] ``` -`ocx claude desktop` and `apply` both write the current profile to Claude Desktop. `show` gives a -readable summary; `status` reports the applied profile, drift, request activity, and Windows -managed-policy health. Add `--json` for scripts. `export -` writes versioned JSON to standard output. +`ocx claude desktop` and `apply` apply the selected mode: first-party writes the Claude Code proxy +env, gateway writes the current profile to Claude Desktop. `show` gives a +readable summary; `status` reports the effective mode, the applied profile or proxy env, drift, +request activity, and (gateway mode only) Windows managed-policy health. Add `--json` for scripts. `export -` writes versioned JSON to standard output. Import validates the complete file before saving, so an invalid file leaves the current profile unchanged. Add `--apply` to write a valid imported profile to Desktop immediately. Use `none` only for an empty family; every non-empty family must keep one default. @@ -637,6 +702,9 @@ Claude debug immediately clears the ring. The dashboard sidebar has a dedicated **Claude** page (below API) and a **Claude ON** toggle (label intentionally identical in every language). The page shows: +- Desktop tab: **Connection mode** selector — first-party (default) or gateway — with the + running proxy port in first-party mode. Only **Save & apply** switches modes; **Save** alone + stores the gateway profile lanes for a later gateway apply and leaves the current mode as is - Inbound kill switch (enabled toggle) - Quickstart (`ocx claude`) and manual env block - Fast Mode selector (Auto / ON / OFF) 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 b797f5a69e0..2a15039bc91 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -95,6 +95,29 @@ hook を削除します。Claude Desktop は独立した profile を使用し、 `claudeCode.nativePassthrough: false` でオフにでき、`claudeCode.anthropicBaseUrl` で別のアドレスを 指定できます。 +## Claude Desktop のモード: 1P(デフォルト)とゲートウェイ + +Claude Desktop は排他的な 2 つのモードのどちらかで OpenCodex を使います。ダッシュボードの +**Claude → Desktop → 接続モード**、または `ocx claude desktop apply --first-party|--gateway` で選びます。 + +- **1P(ファーストパーティ、デフォルト)**: Desktop 本体は変更しません。claude.ai のログイン、 + チャットタブ、コネクタ、リモート操作はそのまま動きます。OpenCodex は `~/.claude/settings.json` の + `env` に `HTTPS_PROXY=http://127.0.0.1:<公開ポート+100>` と + `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem` の 2 つだけを書きます。Desktop が + Code タブ用に起動する Claude Code(サブエージェント含む)とターミナルの `claude` CLI だけがこれを読み、 + ローカルのインターセプトプロキシを通ります。`POST /v1/messages` と `count_tokens` のみ OpenCodex が + 処理し、他の `api.anthropic.com` パスはそのまま Anthropic に中継されます。CA は OS の信頼ストアには + インストールされません。 +- **ゲートウェイ(3P)**: 従来の方式で、下記のプロファイルによりアプリ全体が OpenCodex を + ゲートウェイとして使います。`--gateway`(または従来の `--static`/`--hybrid`/`--discovery-only`)で + 明示的に選びます。 + +モードは `claudeCode.desktopMode` に保存されます。すでにゲートウェイプロファイルを適用済みの環境は +更新後もゲートウェイのままで、新規インストールだけが 1P になります。切り替えると他方のモードの設定 +(OpenCodex が書いた値のみ)が削除され、社内プロキシなど外部の `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` +は上書きせず適用を拒否します。切り替え後は Desktop を完全に終了して再起動してください。詳細と +Claude Code CLI 互換性は英語版ドキュメントを参照してください。 + ## リモートハブに接続した Claude Desktop 接続中のマシンで `ocx claude desktop apply` または `ocx claude desktop` を実行すると、 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 9dbce2e526d..ca71508d4e1 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -118,6 +118,27 @@ hook을 제거해요. Claude Desktop은 별도 profile을 사용하며 shell hoo `claudeCode.nativePassthrough: false`로 끌 수 있고, `claudeCode.anthropicBaseUrl`로 다른 주소를 지정할 수 있어요. +## Claude Desktop 모드: 1P(기본값)와 게이트웨이 + +Claude Desktop은 서로 배타적인 두 모드 중 하나로 OpenCodex를 사용해요. 대시보드의 +**Claude → Desktop → 연결 모드** 또는 `ocx claude desktop apply --first-party|--gateway`로 선택합니다. + +- **1P(퍼스트파티, 기본값)**: Desktop 자체는 건드리지 않아요. claude.ai 로그인, 채팅 탭, 커넥터, + 원격 제어가 그대로 유지됩니다. OpenCodex는 `~/.claude/settings.json`의 `env`에 + `HTTPS_PROXY=http://127.0.0.1:<공개 포트+100>`과 `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem` + 두 값만 씁니다. Desktop이 Code 탭용으로 실행하는 Claude Code(서브에이전트 포함)와 터미널의 + `claude` CLI만 이 값을 읽어 로컬 인터셉트 프록시를 거치고, `POST /v1/messages`·`count_tokens`만 + OpenCodex가 처리하며 나머지 `api.anthropic.com` 경로는 그대로 Anthropic으로 전달돼요. CA는 OS + 신뢰 저장소에 설치되지 않습니다. +- **게이트웨이(3P)**: 기존 방식으로, 아래 프로필을 써서 앱 전체가 OpenCodex를 게이트웨이로 + 사용해요. `--gateway`(또는 기존 `--static`/`--hybrid`/`--discovery-only`)로 명시적으로 선택합니다. + +모드는 `claudeCode.desktopMode`에 저장돼요. 이미 게이트웨이 프로필을 적용한 설치는 업데이트 후에도 +게이트웨이를 유지하고, 새 설치만 1P가 기본이에요. 모드를 바꾸면 다른 모드의 설정(OpenCodex가 쓴 +값만)이 제거되며, 회사 프록시 같은 외부 `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` 값은 덮어쓰지 않고 적용을 +거부해요. 전환 후에는 Desktop을 완전히 종료하고 다시 열어 주세요. 자세한 내용과 Claude Code CLI +호환성은 영어 문서를 참고하세요. + ## 원격 허브에 연결된 Claude Desktop 허브에 연결된 컴퓨터에서 `ocx claude desktop apply` 또는 `ocx claude desktop`을 실행하면 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index bb936060c12..7f92859e4f9 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2622,6 +2622,19 @@ export const de: Record = { "claudeDesktop.status.notActiveProfile": "Desktop nutzt ein anderes Profil — erneut anwenden", "claudeDesktop.status.disabled": "Die Claude-Desktop-Integration ist deaktiviert. Beende Desktop nach dem Aktivieren vollständig und öffne es erneut.", "claudeDesktop.enableApply": "Aktivieren und anwenden", + "claudeDesktop.switchModeApply": "Modus wechseln & anwenden", + "claudeDesktop.status.appliedFirstParty": "First-Party: Code-Tab läuft über den lokalen Proxy", + "claudeDesktop.mode.legend": "Verbindungsmodus", + "claudeDesktop.mode.firstParty": "First-Party", + "claudeDesktop.mode.gateway": "Gateway (Drittanbieter)", + "claudeDesktop.mode.defaultBadge": "Standard", + "claudeDesktop.mode.current": "aktuell", + "claudeDesktop.mode.firstPartyHint": "Desktop bleibt bei claude.ai angemeldet (Chat, Connectors, Remote Control). Nur der Code-Tab, seine Subagenten und die Claude Code CLI laufen über OpenCodex.", + "claudeDesktop.mode.gatewayHint": "Die ganze App wechselt auf OpenCodex als Gateway. Chat läuft lokal; claude.ai-Funktionen sind nicht verfügbar.", + "claudeDesktop.mode.switchNote": "Der Wechsel ersetzt die Konfiguration des anderen Modus. Desktop danach vollständig beenden und neu öffnen.", + "claudeDesktop.firstParty.proxyRunning": "Lokaler Proxy auf 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "Lokaler Proxy 127.0.0.1:{port} läuft nicht — OpenCodex neu starten", + "claudeDesktop.firstParty.interceptDisabled": "Intercept-Proxy ist in der Konfiguration deaktiviert (claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "Letzte Anfrage", "claudeDesktop.health.stats": "{count} Anf. / {errors} Fehl.", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 93af425073b..2c30be237bd 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2722,6 +2722,19 @@ export const en = { "claudeDesktop.status.notActiveProfile": "Desktop is serving another profile — re-apply", "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", "claudeDesktop.enableApply": "Enable and apply", + "claudeDesktop.switchModeApply": "Switch mode & apply", + "claudeDesktop.status.appliedFirstParty": "First-party: Code tab routed through the local proxy", + "claudeDesktop.mode.legend": "Connection mode", + "claudeDesktop.mode.firstParty": "First-party", + "claudeDesktop.mode.gateway": "Gateway (third-party)", + "claudeDesktop.mode.defaultBadge": "default", + "claudeDesktop.mode.current": "current", + "claudeDesktop.mode.firstPartyHint": "Desktop stays signed in to claude.ai (chat, connectors, remote control). Only the Code tab, its subagents and the Claude Code CLI go through OpenCodex.", + "claudeDesktop.mode.gatewayHint": "The whole app switches to OpenCodex as its gateway. Chat runs locally; claude.ai features are unavailable.", + "claudeDesktop.mode.switchNote": "Switching replaces the other mode's configuration. Fully quit and reopen Desktop afterwards.", + "claudeDesktop.firstParty.proxyRunning": "Local proxy on 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "Local proxy 127.0.0.1:{port} is not running — restart OpenCodex", + "claudeDesktop.firstParty.interceptDisabled": "Intercept proxy is disabled in config (claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "Last request", "claudeDesktop.health.stats": "{count} req / {errors} err", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b81652bacd2..4183d385517 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2639,6 +2639,19 @@ export const fr: Record = { "claudeDesktop.status.notActiveProfile": "Desktop utilise un autre profil — appliquer de nouveau", "claudeDesktop.status.disabled": "L’intégration Claude Desktop est désactivée. Quittez complètement Desktop et rouvrez-le après l’avoir activée.", "claudeDesktop.enableApply": "Activer et appliquer", + "claudeDesktop.switchModeApply": "Changer de mode et appliquer", + "claudeDesktop.status.appliedFirstParty": "Première partie : l'onglet Code passe par le proxy local", + "claudeDesktop.mode.legend": "Mode de connexion", + "claudeDesktop.mode.firstParty": "Première partie (first-party)", + "claudeDesktop.mode.gateway": "Passerelle (tiers)", + "claudeDesktop.mode.defaultBadge": "par défaut", + "claudeDesktop.mode.current": "actuel", + "claudeDesktop.mode.firstPartyHint": "Desktop reste connecté à claude.ai (chat, connecteurs, contrôle à distance). Seuls l'onglet Code, ses sous-agents et la CLI Claude Code passent par OpenCodex.", + "claudeDesktop.mode.gatewayHint": "Toute l'application bascule sur OpenCodex comme passerelle. Le chat tourne en local ; les fonctions claude.ai sont indisponibles.", + "claudeDesktop.mode.switchNote": "Le changement remplace la configuration de l'autre mode. Quittez complètement Desktop puis rouvrez-le.", + "claudeDesktop.firstParty.proxyRunning": "Proxy local sur 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "Le proxy local 127.0.0.1:{port} ne tourne pas — redémarrez OpenCodex", + "claudeDesktop.firstParty.interceptDisabled": "Le proxy d'interception est désactivé dans la config (claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "Dernière requête", "claudeDesktop.health.stats": "{count} req. / {errors} err.", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 7565f3c2d9a..6d5bea4b0d4 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2444,6 +2444,19 @@ export const ja: Record = { "claudeDesktop.status.notActiveProfile": "Desktop は別のプロファイルを使用中 — 再適用してください", "claudeDesktop.status.disabled": "Claude Desktop 連携はオフです。有効にした後、Desktop を完全に終了して再起動してください。", "claudeDesktop.enableApply": "有効にして適用", + "claudeDesktop.switchModeApply": "モードを切り替えて適用", + "claudeDesktop.status.appliedFirstParty": "1P: Code タブはローカルプロキシ経由", + "claudeDesktop.mode.legend": "接続モード", + "claudeDesktop.mode.firstParty": "1P(ファーストパーティ)", + "claudeDesktop.mode.gateway": "ゲートウェイ(3P)", + "claudeDesktop.mode.defaultBadge": "デフォルト", + "claudeDesktop.mode.current": "現在", + "claudeDesktop.mode.firstPartyHint": "Desktop は claude.ai にログインしたまま(チャット・コネクタ・リモート操作)。Code タブとそのサブエージェント、Claude Code CLI だけが OpenCodex を通ります。", + "claudeDesktop.mode.gatewayHint": "アプリ全体が OpenCodex をゲートウェイとして使います。チャットはローカル実行になり、claude.ai の機能は使えません。", + "claudeDesktop.mode.switchNote": "切り替えると他方のモードの設定は削除されます。適用後は Desktop を完全に終了して再起動してください。", + "claudeDesktop.firstParty.proxyRunning": "ローカルプロキシ 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "ローカルプロキシ 127.0.0.1:{port} が停止中 — OpenCodex を再起動", + "claudeDesktop.firstParty.interceptDisabled": "設定でインターセプトプロキシが無効です(claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "最終リクエスト", "claudeDesktop.health.stats": "{count} リクエスト / {errors} エラー", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 94bd6fcab74..f4f552ca63a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2661,6 +2661,19 @@ export const ko: Record = { "claudeDesktop.status.notActiveProfile": "Desktop이 다른 프로필을 사용 중 — 재적용 필요", "claudeDesktop.status.disabled": "Claude Desktop 통합이 꺼져 있습니다. 켠 뒤 Desktop을 완전히 종료하고 다시 여세요.", "claudeDesktop.enableApply": "켜고 적용", + "claudeDesktop.switchModeApply": "모드 전환 후 적용", + "claudeDesktop.status.appliedFirstParty": "1P: Code 탭이 로컬 프록시를 통해 연결됨", + "claudeDesktop.mode.legend": "연결 모드", + "claudeDesktop.mode.firstParty": "1P (퍼스트파티)", + "claudeDesktop.mode.gateway": "게이트웨이 (3P)", + "claudeDesktop.mode.defaultBadge": "기본값", + "claudeDesktop.mode.current": "현재", + "claudeDesktop.mode.firstPartyHint": "Desktop은 claude.ai에 로그인된 상태를 유지합니다(채팅·커넥터·원격 제어). Code 탭과 서브에이전트, Claude Code CLI만 OpenCodex를 거칩니다.", + "claudeDesktop.mode.gatewayHint": "앱 전체가 OpenCodex를 게이트웨이로 사용합니다. 채팅은 로컬로 실행되고 claude.ai 기능은 사용할 수 없습니다.", + "claudeDesktop.mode.switchNote": "전환하면 다른 모드의 설정은 제거됩니다. 적용 후 Desktop을 완전히 종료하고 다시 열어주세요.", + "claudeDesktop.firstParty.proxyRunning": "로컬 프록시 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "로컬 프록시 127.0.0.1:{port}가 실행 중이 아님 — OpenCodex 재시작", + "claudeDesktop.firstParty.interceptDisabled": "설정에서 인터셉트 프록시가 비활성화됨 (claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "마지막 요청", "claudeDesktop.health.stats": "{count} 요청 / {errors} 에러", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index bf7c4fcd783..e8db7075692 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2515,6 +2515,19 @@ export const ru: Record = { "claudeDesktop.status.notActiveProfile": "Desktop использует другой профиль — примените заново", "claudeDesktop.status.disabled": "Интеграция Claude Desktop отключена. После включения полностью закройте и снова откройте Desktop.", "claudeDesktop.enableApply": "Включить и применить", + "claudeDesktop.switchModeApply": "Сменить режим и применить", + "claudeDesktop.status.appliedFirstParty": "First-party: вкладка Code идёт через локальный прокси", + "claudeDesktop.mode.legend": "Режим подключения", + "claudeDesktop.mode.firstParty": "First-party", + "claudeDesktop.mode.gateway": "Шлюз (сторонний)", + "claudeDesktop.mode.defaultBadge": "по умолчанию", + "claudeDesktop.mode.current": "текущий", + "claudeDesktop.mode.firstPartyHint": "Desktop остаётся в claude.ai (чат, коннекторы, удалённое управление). Через OpenCodex идут только вкладка Code, её субагенты и CLI Claude Code.", + "claudeDesktop.mode.gatewayHint": "Всё приложение переключается на OpenCodex как шлюз. Чат работает локально; функции claude.ai недоступны.", + "claudeDesktop.mode.switchNote": "Переключение удаляет настройки другого режима. После применения полностью закройте и заново откройте Desktop.", + "claudeDesktop.firstParty.proxyRunning": "Локальный прокси на 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "Локальный прокси 127.0.0.1:{port} не запущен — перезапустите OpenCodex", + "claudeDesktop.firstParty.interceptDisabled": "Перехватывающий прокси отключён в конфигурации (claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "Последний запрос", "claudeDesktop.health.stats": "{count} запр. / {errors} ошиб.", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index eb989bb3ba2..117b67cd9b5 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2664,6 +2664,19 @@ export const tr: Record = { "claudeDesktop.status.notActiveProfile": "Desktop başka bir profil kullanıyor", "claudeDesktop.status.disabled": "Claude Desktop entegrasyonu kapalı.", "claudeDesktop.enableApply": "Etkinleştir ve uygula", + "claudeDesktop.switchModeApply": "Modu değiştir ve uygula", + "claudeDesktop.status.appliedFirstParty": "First-party: Code sekmesi yerel proxy üzerinden", + "claudeDesktop.mode.legend": "Bağlantı modu", + "claudeDesktop.mode.firstParty": "First-party", + "claudeDesktop.mode.gateway": "Ağ geçidi (üçüncü taraf)", + "claudeDesktop.mode.defaultBadge": "varsayılan", + "claudeDesktop.mode.current": "geçerli", + "claudeDesktop.mode.firstPartyHint": "Desktop claude.ai'de oturum açık kalır (sohbet, bağlayıcılar, uzaktan kontrol). Yalnızca Code sekmesi, alt ajanları ve Claude Code CLI OpenCodex üzerinden geçer.", + "claudeDesktop.mode.gatewayHint": "Uygulamanın tamamı OpenCodex'i ağ geçidi olarak kullanır. Sohbet yerelde çalışır; claude.ai özellikleri kullanılamaz.", + "claudeDesktop.mode.switchNote": "Geçiş diğer modun yapılandırmasını kaldırır. Ardından Desktop'ı tamamen kapatıp yeniden açın.", + "claudeDesktop.firstParty.proxyRunning": "Yerel proxy 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "Yerel proxy 127.0.0.1:{port} çalışmıyor — OpenCodex'i yeniden başlatın", + "claudeDesktop.firstParty.interceptDisabled": "Yakalama proxy'si yapılandırmada kapalı (claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "Son istek", "claudeDesktop.health.stats": "{count} istek / {errors} hata", "claudeDesktop.effort.supported": "çaba", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index d5b6a149f53..feccccbaa57 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2653,6 +2653,19 @@ export const vi: Record = { "claudeDesktop.status.notActiveProfile": "Desktop đang phục vụ một hồ sơ khác — hãy áp dụng lại", "claudeDesktop.status.disabled": "Tiện ích tích hợp Claude Desktop đang tắt. Hãy thoát hoàn toàn và mở lại Desktop sau khi bật nó.", "claudeDesktop.enableApply": "Bật và áp dụng", + "claudeDesktop.switchModeApply": "Đổi chế độ & áp dụng", + "claudeDesktop.status.appliedFirstParty": "First-party: tab Code đi qua proxy cục bộ", + "claudeDesktop.mode.legend": "Chế độ kết nối", + "claudeDesktop.mode.firstParty": "First-party", + "claudeDesktop.mode.gateway": "Gateway (bên thứ ba)", + "claudeDesktop.mode.defaultBadge": "mặc định", + "claudeDesktop.mode.current": "hiện tại", + "claudeDesktop.mode.firstPartyHint": "Desktop vẫn đăng nhập claude.ai (chat, connector, điều khiển từ xa). Chỉ tab Code, các subagent và Claude Code CLI đi qua OpenCodex.", + "claudeDesktop.mode.gatewayHint": "Toàn bộ ứng dụng chuyển sang dùng OpenCodex làm gateway. Chat chạy cục bộ; các tính năng claude.ai không khả dụng.", + "claudeDesktop.mode.switchNote": "Chuyển chế độ sẽ xóa cấu hình của chế độ kia. Sau đó hãy thoát hẳn Desktop rồi mở lại.", + "claudeDesktop.firstParty.proxyRunning": "Proxy cục bộ tại 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "Proxy cục bộ 127.0.0.1:{port} không chạy — khởi động lại OpenCodex", + "claudeDesktop.firstParty.interceptDisabled": "Proxy chặn bắt đã bị tắt trong cấu hình (claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "Yêu cầu cuối cùng (Last request)", "claudeDesktop.health.stats": "{count} req / {errors} err", "claudeDesktop.effort.supported": "suy luận (effort)", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index fd1f07dd55e..a98cb69aad1 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2631,6 +2631,19 @@ export const zhTW: Record = { "claudeDesktop.appliedMarkerUnsaved": "已套用到 Claude Desktop,但套用標記未能儲存 — 在你再次套用之前,下方的已儲存/已套用狀態可能顯示過時資訊。", "claudeDesktop.status.disabled": "Claude Desktop 整合已關閉。啟用後請完全結束並重新開啟 Desktop。", "claudeDesktop.enableApply": "啟用並套用", + "claudeDesktop.switchModeApply": "切換模式並套用", + "claudeDesktop.status.appliedFirstParty": "第一方:Code 分頁經由本機代理", + "claudeDesktop.mode.legend": "連線模式", + "claudeDesktop.mode.firstParty": "第一方", + "claudeDesktop.mode.gateway": "閘道(第三方)", + "claudeDesktop.mode.defaultBadge": "預設", + "claudeDesktop.mode.current": "目前", + "claudeDesktop.mode.firstPartyHint": "Desktop 維持登入 claude.ai(聊天、連接器、遠端控制)。僅 Code 分頁、其子代理與 Claude Code CLI 經由 OpenCodex。", + "claudeDesktop.mode.gatewayHint": "整個應用程式改用 OpenCodex 作為閘道。聊天在本機執行;claude.ai 功能無法使用。", + "claudeDesktop.mode.switchNote": "切換會移除另一模式的設定。套用後請完全結束並重新開啟 Desktop。", + "claudeDesktop.firstParty.proxyRunning": "本機代理 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "本機代理 127.0.0.1:{port} 未執行 — 請重新啟動 OpenCodex", + "claudeDesktop.firstParty.interceptDisabled": "設定中已停用攔截代理(claudeCode.intercept.enabled)", "lab.title": "相容性實驗室", "lab.subtitle": "以實驗室投影證據為基礎的唯讀相容性判定矩陣。", "lab.loadFailed": "無法載入相容性實驗室資料", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 3189179e3fe..9e9eb8f4ffa 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2642,6 +2642,19 @@ export const zh: Record = { "claudeDesktop.status.notActiveProfile": "Desktop 正在使用其他配置 — 请重新应用", "claudeDesktop.status.disabled": "Claude Desktop 集成已关闭。开启后请完全退出并重新打开 Desktop。", "claudeDesktop.enableApply": "开启并应用", + "claudeDesktop.switchModeApply": "切换模式并应用", + "claudeDesktop.status.appliedFirstParty": "第一方:Code 标签页经由本地代理", + "claudeDesktop.mode.legend": "连接模式", + "claudeDesktop.mode.firstParty": "第一方", + "claudeDesktop.mode.gateway": "网关(第三方)", + "claudeDesktop.mode.defaultBadge": "默认", + "claudeDesktop.mode.current": "当前", + "claudeDesktop.mode.firstPartyHint": "Desktop 保持登录 claude.ai(聊天、连接器、远程控制)。仅 Code 标签页、其子代理和 Claude Code CLI 经由 OpenCodex。", + "claudeDesktop.mode.gatewayHint": "整个应用改用 OpenCodex 作为网关。聊天在本地运行;claude.ai 功能不可用。", + "claudeDesktop.mode.switchNote": "切换会移除另一模式的配置。应用后请完全退出并重新打开 Desktop。", + "claudeDesktop.firstParty.proxyRunning": "本地代理 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "本地代理 127.0.0.1:{port} 未运行 — 请重启 OpenCodex", + "claudeDesktop.firstParty.interceptDisabled": "配置中已禁用拦截代理(claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "最后请求", "claudeDesktop.health.stats": "{count} 请求 / {errors} 错误", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/main.tsx b/gui/src/main.tsx index 362c5bb351d..a5228663be1 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -6,6 +6,7 @@ import "./styles.css"; import "./styles/usage-chart-accessibility.css"; import "./styles/sidebar-brand.css"; import "./styles/fast-rows-setting.css"; +import "./styles/claude-desktop-mode-picker.css"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index 3878f7d5b42..f360a48ade5 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -42,8 +42,23 @@ interface DesktopModel { assignment: Assignment; } +type DesktopMode = "first-party" | "gateway"; +const DESKTOP_MODES: readonly DesktopMode[] = ["first-party", "gateway"]; + +interface DesktopFirstPartyStatus { + applied: boolean; + stale: boolean; + interceptEnabled: boolean; + interceptRunning: boolean; + proxyPort: number; + caCertPath: string; +} + interface DesktopStatus { desiredEnabled: boolean; + /** Effective mode: a gateway profile on disk wins; otherwise the saved/default mode. */ + mode?: DesktopMode; + firstParty?: DesktopFirstPartyStatus; applied: boolean; appliedAt: string | null; stale: boolean; @@ -65,6 +80,14 @@ function isDesktopStatus(value: unknown): value is DesktopStatus { if (typeof value !== "object" || value === null) return false; const v = value as Record; const health = v.health as Record | null | undefined; + if (v.mode !== undefined && !DESKTOP_MODES.includes(v.mode as DesktopMode)) return false; + if (v.firstParty !== undefined) { + const fp = v.firstParty as Record | null; + if (typeof fp !== "object" || fp === null + || typeof fp.applied !== "boolean" || typeof fp.stale !== "boolean" + || typeof fp.interceptEnabled !== "boolean" || typeof fp.interceptRunning !== "boolean" + || typeof fp.proxyPort !== "number" || typeof fp.caCertPath !== "string") return false; + } return typeof v.desiredEnabled === "boolean" && typeof v.applied === "boolean" && (v.appliedAt === null || typeof v.appliedAt === "string") @@ -201,6 +224,9 @@ export default function ClaudeDesktop({ // a family's fold is a durable preference, but which single model you were inspecting // is not, and restoring five open rows on reload would rebuild the wall this removes. const [openRows, setOpenRows] = useState>({}); + // The mode the user wants the next apply to use. null = follow whatever /status reports + // (first-party by default), so a page load never silently changes an existing install. + const [chosenMode, setChosenMode] = useState(null); const importRef = useRef(null); const fetchDesktop = useCallback(async (signal: AbortSignal): Promise => { const response = await fetch(`${apiBase}/api/claude-desktop`, { signal }); @@ -309,6 +335,15 @@ export default function ClaudeDesktop({ const statusState = statusResource.state; const status = statusState.data ?? cachedStatus; const statusFailed = statusState.showError; + // Until /status answers, the effective mode is unknown: the picker renders unchecked and + // disabled instead of flashing the first-party default at a gateway install. A confirmed + // /status failure unlocks it (the status bar already shows the error) so an apply can still + // be attempted, but no "current" badge is claimed. + const modeKnown = status !== null; + const modePickable = modeKnown || statusFailed; + const effectiveMode: DesktopMode = status?.mode ?? "first-party"; + const selectedMode: DesktopMode = chosenMode ?? effectiveMode; + const modeDirty = modeKnown && selectedMode !== effectiveMode; const moveModel = (route: string, family: Family) => { if (!profile || profile.assignments[route]?.family === family) return; @@ -353,7 +388,11 @@ export default function ClaudeDesktop({ if (applyAfter) { setPending("apply"); - const applyResponse = await fetch(`${apiBase}/api/claude-desktop/apply`, { method: "POST" }); + const applyResponse = await fetch(`${apiBase}/api/claude-desktop/apply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: selectedMode }), + }); const applyBody = await readJsonOrThrow<{ error?: string; saved?: boolean; warning?: string }>( applyResponse, t("claudeDesktop.applyFailed"), @@ -368,6 +407,7 @@ export default function ClaudeDesktop({ setMessage({ tone: "ok", text: t("claudeDesktop.savedApplied") }); setAnnouncement(t("claudeDesktop.savedAppliedAnnounce")); } + setChosenMode(null); } else { setMessage({ tone: "ok", text: t("claudeDesktop.saved") }); setAnnouncement(t("claudeDesktop.savedAnnounce")); @@ -445,6 +485,30 @@ export default function ClaudeDesktop({ +
+ {t("claudeDesktop.mode.legend")} + {DESKTOP_MODES.map(mode => ( + + ))} + {modeDirty && {t("claudeDesktop.mode.switchNote")}} +
+ {/* Always mount the bar (pending strut when status is still cold) so a late /status response cannot insert a full row under the title and shove the lanes down. */}
{/* Desktop serving another profile outranks content drift: stale config that is - read still works, a config that is never read does not. */} + read still works, a config that is never read does not. First-party never + writes a Desktop profile, so that check only applies in gateway mode. */} {statusFailed && !status ? t("claudeDesktop.loadFail") @@ -475,14 +540,23 @@ export default function ClaudeDesktop({ ? t("claudeDesktop.loading") : !status.desiredEnabled ? t("claudeDesktop.status.disabled") - : status.activeProfile === false + : effectiveMode === "gateway" && status.activeProfile === false ? t("claudeDesktop.status.notActiveProfile") : status.stale ? t("claudeDesktop.status.stale") : status.applied - ? t("claudeDesktop.status.applied") + ? effectiveMode === "first-party" ? t("claudeDesktop.status.appliedFirstParty") : t("claudeDesktop.status.applied") : t("claudeDesktop.status.notApplied")} + {status?.firstParty && effectiveMode === "first-party" && status.desiredEnabled && ( + + {status.firstParty.interceptRunning + ? t("claudeDesktop.firstParty.proxyRunning", { port: status.firstParty.proxyPort }) + : status.firstParty.interceptEnabled + ? t("claudeDesktop.firstParty.proxyStopped", { port: status.firstParty.proxyPort }) + : t("claudeDesktop.firstParty.interceptDisabled")} + + )} {status?.health.lastRequestAt && ( {t("claudeDesktop.health.lastRequest")}:{" "} @@ -508,7 +582,7 @@ export default function ClaudeDesktop({ {pending === "save" ? t("claudeDesktop.saving") : t("common.save")}
diff --git a/gui/src/styles/claude-desktop-mode-picker.css b/gui/src/styles/claude-desktop-mode-picker.css new file mode 100644 index 00000000000..ee6db473520 --- /dev/null +++ b/gui/src/styles/claude-desktop-mode-picker.css @@ -0,0 +1,22 @@ +/* ── Desktop connection mode picker (first-party default / gateway opt-in) ── */ +.claude-mode-picker { + display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; + margin: 0 0 14px; padding: 10px 14px 12px; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); +} +.claude-mode-picker legend { padding: 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); } +.claude-mode-picker:disabled { opacity: 0.6; } +.claude-mode-option { + display: grid; grid-template-columns: auto 1fr; column-gap: 10px; align-items: start; + padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; +} +.claude-mode-option.active { border-color: var(--accent); } +.claude-mode-option input { margin-top: 3px; } +.claude-mode-title { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; } +.claude-mode-hint { grid-column: 2; margin-top: 4px; font-size: 12px; color: var(--muted); line-height: 1.4; } +.claude-mode-default, .claude-mode-current { + padding: 1px 6px; border-radius: var(--radius-xs); font-size: 10px; font-weight: 600; letter-spacing: 0.02em; +} +.claude-mode-default { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); } +.claude-mode-current { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); } +.claude-mode-switch-note { grid-column: 1 / -1; font-size: 12px; color: var(--amber); } diff --git a/gui/tests/claude-desktop-mode-picker.test.tsx b/gui/tests/claude-desktop-mode-picker.test.tsx new file mode 100644 index 00000000000..68215092244 --- /dev/null +++ b/gui/tests/claude-desktop-mode-picker.test.tsx @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import ClaudeDesktop from "../src/pages/ClaudeDesktop"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; + +/** + * The connection-mode picker decides which of two mutually exclusive Desktop + * configurations the apply request asks for. Mounted tests because the + * failures that matter are wiring: the radio must follow /status, a changed + * selection must reach the POST body, and the gateway-only "not active + * profile" check must not leak into first-party status. + */ + +const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; +let requests: { url: string; init?: RequestInit }[] = []; + +const MODEL = { + route: "prov/opus-0", + label: "Opus Model", + available: true, + contextWindow: 200_000, + effortSupported: true, + assignment: { family: "opus", alias: "alias-opus" }, +}; + +function profilePayload() { + return { + profile: { + version: 1, + assignments: { [MODEL.route]: MODEL.assignment }, + defaults: { opus: MODEL.route, fable: null, sonnet: null, haiku: null }, + }, + models: [MODEL], + rendered: [], + port: 10100, + }; +} + +function statusPayload(overrides: Record = {}) { + return { + desiredEnabled: true, + applied: true, + appliedAt: null, + stale: false, + health: { lastRequestAt: null, requestCount: 0, errorCount: 0 }, + mode: "first-party", + firstParty: { + applied: true, + stale: false, + interceptEnabled: true, + interceptRunning: true, + proxyPort: 10200, + caCertPath: "/tmp/ocx/claude-intercept/ca.pem", + }, + ...overrides, + }; +} + +function installFetch(status: Record) { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string, init?: RequestInit) => { + requests.push({ url: String(url), init }); + const path = String(url); + const body = path.includes("/status") + ? status + : path.endsWith("/apply") + ? { ok: true, mode: JSON.parse(String(init?.body ?? "{}")).mode } + : init?.method === "PUT" + ? { ok: true } + : profilePayload(); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + }, + }); +} + +beforeEach(() => { + clearClientResourceStoresForTests(); + requests = []; + previousGlobals = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + installFetch(statusPayload()); + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + clearClientResourceStoresForTests(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function mount() { + await act(async () => { + root = createRoot(container); + root.render(); + }); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); +} + +function radio(mode: "first-party" | "gateway"): HTMLInputElement { + const found = container.querySelector(`input[name="claude-desktop-mode"][value="${mode}"]`); + if (!found) throw new Error(`mode radio not found: ${mode}`); + return found as unknown as HTMLInputElement; +} + +function applyButton(): HTMLButtonElement { + const found = Array.from(container.querySelectorAll("button.btn-primary")) + .find(button => /apply/i.test(button.textContent ?? "")); + if (!found) throw new Error("apply button not found"); + return found as unknown as HTMLButtonElement; +} + +async function click(element: HTMLElement) { + await act(async () => { element.click(); }); +} + +test("a failed /status unlocks the picker on the default without claiming a current mode", async () => { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string) => { + const path = String(url); + if (path.includes("/status")) { + return { ok: false, status: 503, json: async () => ({ error: "down" }), text: async () => "down" } as unknown as Response; + } + const body = profilePayload(); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + }, + }); + + await mount(); + await act(async () => { await new Promise(r => setTimeout(r, 200)); }); + + expect((container.querySelector(".claude-mode-picker") as HTMLFieldSetElement).disabled).toBe(false); + expect(radio("first-party").checked).toBe(true); + expect(radio("gateway").checked).toBe(false); + expect(container.querySelector(".claude-mode-current")).toBeNull(); + expect(container.querySelector(".claude-status-bar")?.textContent ?? "").toContain("Failed to load"); +}); + +test("the picker follows the effective mode reported by /status and shows the proxy port", async () => { + await mount(); + expect(radio("first-party").checked).toBe(true); + expect(radio("gateway").checked).toBe(false); + const firstPartyOption = radio("first-party").closest("label")!; + expect(firstPartyOption.querySelector(".claude-mode-default")).not.toBeNull(); + expect(firstPartyOption.querySelector(".claude-mode-current")).not.toBeNull(); + expect(container.querySelector(".claude-mode-switch-note")).toBeNull(); + + const bar = container.querySelector(".claude-status-bar")!; + expect(bar.className).toContain("applied"); + expect(bar.textContent ?? "").toContain("First-party: Code tab routed through the local proxy"); + expect(bar.textContent ?? "").toContain("127.0.0.1:10200"); + expect(applyButton().textContent).toBe("Save & apply"); +}); + +test("a stopped intercept proxy is surfaced in first-party mode", async () => { + installFetch(statusPayload({ + firstParty: { applied: true, stale: false, interceptEnabled: true, interceptRunning: false, proxyPort: 10200, caCertPath: "/tmp/ca.pem" }, + })); + await mount(); + expect(container.querySelector(".claude-status-bar")?.textContent ?? "").toContain("is not running"); +}); + +test("selecting the other mode flips the apply label and sends that mode in the POST body", async () => { + await mount(); + await act(async () => { + radio("gateway").click(); + }); + expect(radio("gateway").checked).toBe(true); + expect(container.querySelector(".claude-mode-switch-note")).not.toBeNull(); + expect(applyButton().textContent).toBe("Switch mode & apply"); + + await click(applyButton()); + await act(async () => { await new Promise(r => setTimeout(r, 20)); }); + + const apply = requests.find(r => r.url.endsWith("/api/claude-desktop/apply")); + expect(apply).toBeDefined(); + expect(apply!.init?.method).toBe("POST"); + expect(JSON.parse(String(apply!.init?.body))).toEqual({ mode: "gateway" }); +}); + +test("the default apply keeps the effective mode when nothing was chosen", async () => { + installFetch(statusPayload({ mode: "gateway", activeProfile: true, firstParty: undefined })); + await mount(); + expect(radio("gateway").checked).toBe(true); + await click(applyButton()); + await act(async () => { await new Promise(r => setTimeout(r, 20)); }); + const apply = requests.find(r => r.url.endsWith("/api/claude-desktop/apply")); + expect(JSON.parse(String(apply!.init?.body))).toEqual({ mode: "gateway" }); +}); + +test("activeProfile=false only demotes the status bar in gateway mode", async () => { + // First-party never writes a Desktop profile, so Desktop serving some other + // profile is irrelevant and must not paint the bar as not-applied. + installFetch(statusPayload({ activeProfile: false })); + await mount(); + expect(container.querySelector(".claude-status-bar")!.className).toContain("applied"); + expect(container.querySelector(".claude-status-bar")!.className).not.toContain("not-applied"); + + await act(async () => { root!.unmount(); root = null; }); + clearClientResourceStoresForTests(); + installFetch(statusPayload({ mode: "gateway", activeProfile: false, firstParty: undefined })); + await mount(); + expect(container.querySelector(".claude-status-bar")!.className).toContain("not-applied"); +}); + +test("no radio is checked and the picker is disabled until /status answers", async () => { + // A gateway install must never see the first-party default flash while /status is in flight. + let releaseStatus: () => void = () => {}; + const gate = new Promise(resolve => { releaseStatus = resolve; }); + const gatewayStatus = statusPayload({ mode: "gateway", activeProfile: true, firstParty: undefined }); + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string, init?: RequestInit) => { + const path = String(url); + if (path.includes("/status")) await gate; + const body = path.includes("/status") ? gatewayStatus : profilePayload(); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + }, + }); + await mount(); + expect(radio("first-party").checked).toBe(false); + expect(radio("gateway").checked).toBe(false); + expect((container.querySelector(".claude-mode-picker") as HTMLFieldSetElement).disabled).toBe(true); + expect(container.querySelector(".claude-mode-current")).toBeNull(); + expect(container.querySelector(".claude-mode-switch-note")).toBeNull(); + + releaseStatus(); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); + expect(radio("gateway").checked).toBe(true); + expect(radio("first-party").checked).toBe(false); + expect((container.querySelector(".claude-mode-picker") as HTMLFieldSetElement).disabled).toBe(false); +}); + +test("an unknown mode in /status is rejected as malformed", async () => { + installFetch(statusPayload({ mode: "proxy" })); + await mount(); + expect(container.textContent ?? "").toContain("Failed to load Claude Desktop profile."); +}); diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 218df3838d5..809269fafc5 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -152,7 +152,7 @@ this document owns is which module holds which area and what invariant that area | Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. | | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`. Visibility writes trigger catalog sync through the owning server path. | | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | -| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply`, `GET /api/claude-desktop/status`, `GET/PUT /api/claude-code`. Apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)). | +| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply` (`mode`: `first-party` default, `gateway`, or legacy shapes), `GET /api/claude-desktop/status` (`mode`, `firstParty`), `GET/PUT /api/claude-code`. Gateway apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)); first-party apply writes only the Claude Code proxy env, see [`clients/claude-desktop.md`](clients/claude-desktop.md#desktop-modes-first-party-and-gateway). `gui/src/pages/ClaudeDesktop.tsx` renders the mode selector and sends the chosen `mode` with apply. | | File-integration plans | `src/server/management/integration-routes.ts` and `aside-profile-routes.ts` — `POST /api/client-integrations/preview`, `POST /api/client-integrations/restore/preview`, and `POST /api/client-integrations/aside/profiles/{profileId}/preview`. Management-authenticated, declared non-mutating, and they write nothing: no snapshot, no lock, no maintenance, no recovery. They answer `409 integration_preview_unavailable` rather than gathering a model roster, because discovery refreshes credentials and writes the provider cache. Responses carry only declared managed schema paths, closed change kinds and an opaque fingerprint; no value, filesystem location or selected member identity appears. Mutation routes accept `operation` and `planFingerprint` together or not at all, reject a half-bound request and an operation that disagrees with the change, and answer `409 integration_preview_stale` with a freshly computed plan. Binding is an optimistic token, never authorization. [The integration contract](clients/integrations.md) owns the ordering. | | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. |