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 c09ccbbb5e5..a46c0f89fa0 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2709,6 +2709,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 2c5b9f92a01..061019df6c3 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2809,6 +2809,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 348914135d4..b2e00370208 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2726,6 +2726,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 d5920d7015e..7ab8054d2ed 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2531,6 +2531,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 6913c903170..31d5c4af19c 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2748,6 +2748,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 c0002fc2aca..9a0f4fc0920 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2602,6 +2602,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 528387f39da..f8a086ac3ab 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2751,6 +2751,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 337335fdbd9..f9eabd994c7 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2740,6 +2740,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 28a7650bc16..25abb6135b4 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2718,6 +2718,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 7caef604372..cf4b6e94055 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2729,6 +2729,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/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index dea3270ec8d..cc24f755ee1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1600,7 +1600,12 @@ "api-key-scope-images.test.ts": "server", "api-key-scope-audio.test.ts": "server", "api-key-scope-live.test.ts": "server", - "api-key-scope-alpha-search.test.ts": "server" + "api-key-scope-alpha-search.test.ts": "server", + "claude-intercept-local-ca.test.ts": "claude-integration", + "claude-intercept-proxy.test.ts": "claude-integration", + "claude-intercept-settings.test.ts": "claude-integration", + "claude-desktop-first-party.test.ts": "claude-integration", + "claude-intercept-integration.test.ts": "server" }, "migrated": [ "adapters", diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index ec50f765ab0..e7ffcfb72ba 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -473,6 +473,12 @@ export function removeDesktop3pStandardPivot( options: Desktop3pConfigLibraryOptions & { appliedFingerprint?: string | null; unlink?: (path: string) => void; lifecycleLockDeps?: ClientLifecycleLockDeps; + /** + * The desired-state guard below exists for OFF flows racing a concurrent enable. A + * mode switch (gateway → first-party) removes the profile while the integration stays + * ON on purpose, so the caller opts out of that guard. + */ + replaceWhileEnabled?: boolean; } = {}, ): Desktop3pRemovalResult { const libraryPath = resolveDesktop3pConfigLibraryPath(options); @@ -488,7 +494,7 @@ export function removeDesktop3pStandardPivot( } const latest = readConfigDiagnostics(); if (latest.source === "fallback") return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: "desktop_config_invalid" }; - if (claudeDesktopIntegrationEnabled(latest.config)) { + if (!options.replaceWhileEnabled && claudeDesktopIntegrationEnabled(latest.config)) { const observed = inspectDesktop3pConfigLibrary(options); if (observed.kind === "not_installed" || observed.kind === "no_owned_state") { return { ok: true, changed: false, kind: "noop", libraryPath }; diff --git a/src/claude/desktop-first-party.ts b/src/claude/desktop-first-party.ts new file mode 100644 index 00000000000..0946aafddd5 --- /dev/null +++ b/src/claude/desktop-first-party.ts @@ -0,0 +1,172 @@ +/** + * Claude Desktop first-party mode. + * + * Desktop has two ways to reach opencodex: + * + * - `first-party` (default): the app keeps its ordinary claude.ai login, Chat tab, connectors + * and remote control. Only the Claude Code process it spawns for the Code tab (and that + * process's subagents) is redirected, through the `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` env + * in `~/.claude/settings.json` (src/claude/intercept/settings.ts) and the server's intercept + * pair (src/claude/intercept/runtime.ts). Nothing is written under Desktop's config library. + * - `gateway`: the historical third-party deployment profile (src/claude/desktop-3p.ts). The + * whole app is switched to a gateway build; picker entries are opencodex aliases. + * + * The two are mutually exclusive on disk: applying one removes the other. The mode is persisted + * in `claudeCode.desktopMode`; installs that predate the field but already carry an applied + * gateway profile keep `gateway` until they explicitly re-apply, so an update never flips a + * working Desktop under the operator. + */ +import { getConfigDir } from "../config/paths"; +import type { OcxConfig } from "../types"; +import { claudeInterceptCaCertPath, ensureLocalInterceptCa } from "./intercept/local-ca"; +import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/runtime"; +import { + applyClaudeInterceptSettings, + buildClaudeInterceptEnv, + inspectClaudeInterceptSettings, + removeClaudeInterceptSettings, + type ClaudeInterceptEnv, + type ClaudeInterceptSettingsState, + type ClaudeInterceptSettingsWrite, +} from "./intercept/settings"; + +export const CLAUDE_DESKTOP_MODES = ["first-party", "gateway"] as const; +export type ClaudeDesktopMode = typeof CLAUDE_DESKTOP_MODES[number]; +export const DEFAULT_CLAUDE_DESKTOP_MODE: ClaudeDesktopMode = "first-party"; + +export function isClaudeDesktopMode(value: unknown): value is ClaudeDesktopMode { + return typeof value === "string" && (CLAUDE_DESKTOP_MODES as readonly string[]).includes(value); +} + +type DesktopModeConfig = Pick; + +/** + * Effective Desktop mode. An explicit `claudeCode.desktopMode` wins; otherwise a persisted + * gateway apply marker (`desktopProfile.appliedFingerprint`) means a pre-existing gateway + * install and keeps `gateway`; everything else is the first-party default. + */ +export function resolveClaudeDesktopMode(config: DesktopModeConfig): ClaudeDesktopMode { + const explicit = config.claudeCode?.desktopMode; + if (isClaudeDesktopMode(explicit)) return explicit; + if (config.claudeCode?.desktopProfile?.appliedFingerprint) return "gateway"; + return DEFAULT_CLAUDE_DESKTOP_MODE; +} + +/** + * Config mutation that records the applied Desktop mode. Switching to first-party also drops + * the gateway apply marker: the profile assignments stay for a later gateway apply, but a + * stale `appliedFingerprint` must not make `resolveClaudeDesktopMode` read `gateway` again + * should the explicit marker ever go missing. + */ +export function recordClaudeDesktopMode( + config: DesktopModeConfig, + mode: ClaudeDesktopMode, +): { changed: boolean; value: true } { + const claudeCode = config.claudeCode ?? {}; + const profile = claudeCode.desktopProfile; + const dropMarker = mode === "first-party" && profile !== undefined + && (profile.appliedFingerprint !== undefined || profile.appliedAt !== undefined); + if (claudeCode.desktopMode === mode && !dropMarker) return { changed: false, value: true }; + if (dropMarker) { + const { appliedFingerprint: _fingerprint, appliedAt: _at, ...rest } = profile; + config.claudeCode = { ...claudeCode, desktopMode: mode, desktopProfile: rest }; + } else { + config.claudeCode = { ...claudeCode, desktopMode: mode }; + } + return { changed: true, value: true }; +} + +/** + * Mode an *apply* without an explicit choice should use. The first-party default only holds + * where the intercept proxy actually runs; with it disabled (or on a client role) an implied + * first-party apply would point Claude Code at a proxy that never starts, so fall back to the + * gateway profile. An explicit `desktopMode: "first-party"` is still honoured (and refused + * later with `intercept_disabled`, which names the fix). + */ +export function resolveClaudeDesktopApplyMode( + config: Pick, +): ClaudeDesktopMode { + const resolved = resolveClaudeDesktopMode(config); + if (resolved === "gateway" || isClaudeDesktopMode(config.claudeCode?.desktopMode)) return resolved; + return claudeInterceptEnabled(config) ? "first-party" : "gateway"; +} + +export interface DesktopFirstPartyTarget { + proxyPort: number; + caCertPath: string; + env: ClaudeInterceptEnv; +} + +/** The settings env a first-party apply on this machine writes (CA is created on demand). */ +export function desktopFirstPartyTarget( + config: Pick, + opencodexConfigDir = getConfigDir(), +): DesktopFirstPartyTarget { + const proxyPort = claudeInterceptProxyPort(config, config.port ?? 10100); + const caCertPath = claudeInterceptCaCertPath(opencodexConfigDir); + return { proxyPort, caCertPath, env: buildClaudeInterceptEnv(proxyPort, caCertPath) }; +} + +export interface DesktopFirstPartyInspection { + /** False when the server will not run the intercept pair (client role, intercept disabled). */ + interceptEnabled: boolean; + proxyPort: number; + caCertPath: string; + settings: ClaudeInterceptSettingsState; + /** settings.json carries exactly the env the current config would write. */ + applied: boolean; + /** Ours, but for an older port/config directory. Re-apply refreshes it. */ + stale: boolean; +} + +export interface DesktopFirstPartyOptions { + opencodexConfigDir?: string; + claudeConfigDir?: string; +} + +export function inspectDesktopFirstParty( + config: Pick, + options: DesktopFirstPartyOptions = {}, +): DesktopFirstPartyInspection { + const target = desktopFirstPartyTarget(config, options.opencodexConfigDir); + const settings = inspectClaudeInterceptSettings(target.env, options.claudeConfigDir); + return { + interceptEnabled: claudeInterceptEnabled(config), + proxyPort: target.proxyPort, + caCertPath: target.caCertPath, + settings, + applied: settings.kind === "applied", + stale: settings.kind === "stale", + }; +} + +export type DesktopFirstPartyApplyResult = + | { ok: true; changed: boolean; path: string; env: ClaudeInterceptEnv; proxyPort: number } + | { ok: false; reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env"; path: string }; + +/** + * Write the first-party env into Claude Code's settings. Creates the local CA first so the + * path we point `NODE_EXTRA_CA_CERTS` at exists before Claude Code ever reads it. + */ +export function applyDesktopFirstParty( + config: Pick, + options: DesktopFirstPartyOptions = {}, +): DesktopFirstPartyApplyResult { + const opencodexConfigDir = options.opencodexConfigDir ?? getConfigDir(); + const target = desktopFirstPartyTarget(config, opencodexConfigDir); + if (!claudeInterceptEnabled(config)) return { ok: false, reason: "intercept_disabled", path: "" }; + try { + ensureLocalInterceptCa(opencodexConfigDir); + } catch { + return { ok: false, reason: "ca_unavailable", path: target.caCertPath }; + } + const written = applyClaudeInterceptSettings(target.env, options.claudeConfigDir); + if (!written.ok) return { ok: false, reason: written.reason, path: written.path }; + return { ok: true, changed: written.changed, path: written.path, env: target.env, proxyPort: target.proxyPort }; +} + +/** Remove the first-party env. Only values anchored on our CA path are touched. */ +export function removeDesktopFirstParty(options: DesktopFirstPartyOptions = {}): ClaudeInterceptSettingsWrite { + const caCertPath = claudeInterceptCaCertPath(options.opencodexConfigDir ?? getConfigDir()); + return removeClaudeInterceptSettings(caCertPath, options.claudeConfigDir); +} diff --git a/src/claude/intercept/connect-proxy.ts b/src/claude/intercept/connect-proxy.ts new file mode 100644 index 00000000000..86c9375513f --- /dev/null +++ b/src/claude/intercept/connect-proxy.ts @@ -0,0 +1,179 @@ +import { BlockList, createServer, connect, isIP, type Server, type Socket } from "node:net"; + +/** + * Loopback HTTP CONNECT proxy for Claude Code. + * + * Claude Code honours `HTTPS_PROXY` and opens `CONNECT :443` for every upstream. This + * proxy splices tunnels for the intercepted hosts onto the local TLS listener (which holds a + * leaf certificate for them) and blindly relays every other tunnel to its real destination, + * so telemetry, OAuth refresh and claude.ai traffic stay native and opaque to opencodex. + * + * Only CONNECT is served. Plain proxied HTTP requests are refused: Claude Code never sends + * them, and answering them would turn this socket into a generic forward proxy. + */ + +export const CLAUDE_INTERCEPT_HOSTS = ["api.anthropic.com"] as const; + +const MAX_HEAD_BYTES = 8 * 1024; +const HEAD_TIMEOUT_MS = 10_000; +const UPSTREAM_CONNECT_TIMEOUT_MS = 15_000; + +export interface ConnectProxyOptions { + /** Loopback port of the TLS listener that terminates intercepted tunnels. */ + interceptPort: number; + /** Hostnames (lowercase) whose 443 tunnels are spliced onto `interceptPort`. */ + interceptHosts?: readonly string[]; + /** Test seam: dial the real destination for a blind tunnel. */ + dialUpstream?: (host: string, port: number) => Socket; +} + +export interface ConnectProxyHandle { + port: number; + close(): Promise; +} + +interface ConnectTarget { + host: string; + port: number; +} + +/** Parse `CONNECT host:port HTTP/1.1` from a request head; `null` for anything else. */ +export function parseConnectRequestLine(head: string): ConnectTarget | null { + const requestLine = head.split("\r\n", 1)[0] ?? ""; + const match = /^CONNECT\s+(\S+)\s+HTTP\/1\.[01]$/.exec(requestLine); + if (!match) return null; + const authority = match[1]!; + // Bracketed IPv6 (`[::1]:443`) and plain `host:port`. + const ipv6 = /^\[([^\]]+)\]:(\d{1,5})$/.exec(authority); + const hostPort = ipv6 ?? /^([^:]+):(\d{1,5})$/.exec(authority); + if (!hostPort) return null; + const port = Number(hostPort[2]); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + return { host: hostPort[1]!.toLowerCase().replace(/\.$/, ""), port }; +} + +// 127/8 and ::1, which `BlockList` also matches in IPv4-mapped form (`::ffff:127.0.0.1`, +// `::ffff:7f00:1`). The unspecified addresses dial the local host too. +const LOCAL_TARGETS = new BlockList(); +LOCAL_TARGETS.addSubnet("127.0.0.0", 8, "ipv4"); +LOCAL_TARGETS.addAddress("0.0.0.0", "ipv4"); +LOCAL_TARGETS.addAddress("::1", "ipv6"); +LOCAL_TARGETS.addAddress("::", "ipv6"); + +export function isLoopbackTarget(host: string): boolean { + if (host === "localhost" || host.endsWith(".localhost")) return true; + const family = isIP(host); + if (family !== 0) return LOCAL_TARGETS.check(host, family === 6 ? "ipv6" : "ipv4"); + // `127.1`, `0x7f000001`, `2130706433`: resolver shorthand for a loopback literal, not a name. + return /^(0x[0-9a-f]+|\d+)(\.(0x[0-9a-f]+|\d+))*$/.test(host); +} + +function respond(socket: Socket, status: number, reason: string): void { + if (socket.destroyed) return; + socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); +} + +function splice(client: Socket, upstream: Socket, pending: Uint8Array): void { + const teardown = () => { + client.destroy(); + upstream.destroy(); + }; + client.on("error", teardown); + upstream.on("error", teardown); + client.on("close", () => upstream.destroy()); + upstream.on("close", () => client.destroy()); + client.setTimeout(0); + client.setNoDelay(true); + upstream.setNoDelay(true); + if (pending.length > 0) upstream.write(pending); + client.pipe(upstream); + upstream.pipe(client); +} + +function handleConnection(socket: Socket, options: Required>): void { + let head: Buffer = Buffer.alloc(0); + socket.on("error", () => socket.destroy()); + socket.setTimeout(HEAD_TIMEOUT_MS, () => respond(socket, 408, "Request Timeout")); + + const onData = (chunk: Buffer) => { + head = head.length === 0 ? chunk : Buffer.concat([head, chunk]); + const end = head.indexOf("\r\n\r\n"); + if (end === -1) { + if (head.length > MAX_HEAD_BYTES) { + socket.off("data", onData); + respond(socket, 431, "Request Header Fields Too Large"); + } + return; + } + socket.off("data", onData); + socket.pause(); + const target = parseConnectRequestLine(head.subarray(0, end).toString("latin1")); + // Bytes after the head belong to the tunnel (a client may pipeline its TLS ClientHello). + const pending = head.subarray(end + 4); + if (!target) { + respond(socket, 405, "Method Not Allowed"); + return; + } + if (isLoopbackTarget(target.host)) { + respond(socket, 403, "Forbidden"); + return; + } + const intercept = target.port === 443 && options.interceptHosts.includes(target.host); + const upstream = intercept + ? connect({ host: "127.0.0.1", port: options.interceptPort }) + : options.dialUpstream(target.host, target.port); + let established = false; + const connectTimer = setTimeout(() => { + if (!established) { + upstream.destroy(); + respond(socket, 504, "Gateway Timeout"); + } + }, UPSTREAM_CONNECT_TIMEOUT_MS); + upstream.once("error", () => { + clearTimeout(connectTimer); + if (!established) respond(socket, 502, "Bad Gateway"); + }); + upstream.once("connect", () => { + established = true; + clearTimeout(connectTimer); + if (socket.destroyed) { + upstream.destroy(); + return; + } + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + splice(socket, upstream, pending); + socket.resume(); + }); + }; + socket.on("data", onData); +} + +/** Bind the CONNECT proxy on 127.0.0.1. Rejects when the port is unavailable. */ +export function startConnectProxy(port: number, options: ConnectProxyOptions): Promise { + const resolved = { + interceptPort: options.interceptPort, + interceptHosts: options.interceptHosts ?? CLAUDE_INTERCEPT_HOSTS, + dialUpstream: options.dialUpstream ?? ((host: string, targetPort: number) => connect({ host, port: targetPort })), + }; + return new Promise((resolve, reject) => { + const server: Server = createServer(socket => handleConnection(socket, resolved)); + const sockets = new Set(); + server.on("connection", socket => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + server.once("error", reject); + server.listen({ port, host: "127.0.0.1", exclusive: true }, () => { + server.off("error", reject); + const address = server.address(); + const boundPort = address && typeof address === "object" ? address.port : port; + resolve({ + port: boundPort, + close: () => new Promise(done => { + for (const socket of sockets) socket.destroy(); + server.close(() => done()); + }), + }); + }); + }); +} diff --git a/src/claude/intercept/listener.ts b/src/claude/intercept/listener.ts new file mode 100644 index 00000000000..8b4cdbb1409 --- /dev/null +++ b/src/claude/intercept/listener.ts @@ -0,0 +1,122 @@ +import type { Server } from "bun"; +import type { PemKeyPair } from "./local-ca"; + +/** + * TLS listener that terminates intercepted `api.anthropic.com` tunnels. + * + * The CONNECT proxy splices a Claude Code tunnel onto this socket; the client then speaks + * HTTPS believing it reached Anthropic. Messages traffic is rewritten to a loopback URL and + * handed to the router's own request handler, which already knows how to route mapped models + * to providers and pass genuine Claude models through with the caller's subscription + * credential. Every other path (usage, feedback, model listings, …) is relayed verbatim to + * the real upstream so the client keeps behaving like a first-party install. + */ + +export const CLAUDE_INTERCEPT_UPSTREAM = "https://api.anthropic.com"; + +const INTERCEPTED_PATHS = new Set(["/v1/messages", "/v1/messages/count_tokens"]); + +const HOP_BY_HOP_HEADERS = new Set([ + "connection", "keep-alive", "transfer-encoding", "upgrade", "te", "trailer", + "proxy-authenticate", "proxy-authorization", "proxy-connection", "host", "content-length", + "accept-encoding", +]); + +// fetch() transparently decodes the body, so the encoding headers would describe bytes the +// client never sees. +const RESPONSE_STRIP_HEADERS = new Set([ + "connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length", +]); + +export function isClaudeInterceptedPath(pathname: string, method: string): boolean { + return method === "POST" && INTERCEPTED_PATHS.has(pathname); +} + +/** + * Rebuild the request as if the client had dialled the router directly on loopback: the + * admission and Host checks then take the same path a plain loopback bind always has. + */ +export function rewriteInterceptedRequest(req: Request, loopbackOrigin: string): Request { + const url = new URL(req.url); + const headers = new Headers(req.headers); + headers.set("host", loopbackOrigin.replace(/^https?:\/\//, "")); + return new Request(`${loopbackOrigin}${url.pathname}${url.search}`, { + method: req.method, + headers, + body: req.body, + signal: req.signal, + redirect: "manual", + // @ts-expect-error -- streaming request bodies require half duplex under the fetch spec. + duplex: "half", + }); +} + +export function forwardHeadersForUpstream(source: Headers): Headers { + const headers = new Headers(); + source.forEach((value, name) => { + if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase())) headers.append(name, value); + }); + return headers; +} + +export async function relayToUpstream(req: Request, upstreamBase: string, fetchImpl: typeof fetch = fetch): Promise { + const url = new URL(req.url); + const target = `${upstreamBase.replace(/\/$/, "")}${url.pathname}${url.search}`; + const hasBody = req.method !== "GET" && req.method !== "HEAD"; + let upstream: Response; + try { + upstream = await fetchImpl(target, { + method: req.method, + headers: forwardHeadersForUpstream(req.headers), + body: hasBody ? req.body : undefined, + signal: req.signal, + redirect: "manual", + // @ts-expect-error -- streaming request bodies require half duplex under the fetch spec. + duplex: "half", + }); + } catch (error) { + return Response.json( + { type: "error", error: { type: "api_error", message: `intercept relay failed: ${error instanceof Error ? error.message : String(error)}` } }, + { status: 502 }, + ); + } + const headers = new Headers(); + upstream.headers.forEach((value, name) => { + if (!RESPONSE_STRIP_HEADERS.has(name.toLowerCase())) headers.append(name, value); + }); + return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers }); +} + +export interface ClaudeInterceptListenerOptions { + leaf: PemKeyPair; + /** Router request handler; receives the loopback-rewritten request and the intercept server. */ + dispatch: (req: Request, server: Server) => Promise; + upstreamBase?: string; + maxRequestBodySize?: number; + idleTimeout?: number; + fetchImpl?: typeof fetch; + /** Test seam: bind a fixed port instead of an ephemeral one. */ + port?: number; +} + +/** Bind the intercept TLS listener on an ephemeral loopback port. */ +export function startClaudeInterceptListener(options: ClaudeInterceptListenerOptions): Server { + const upstreamBase = options.upstreamBase ?? CLAUDE_INTERCEPT_UPSTREAM; + let loopbackOrigin = ""; + const server = Bun.serve({ + port: options.port ?? 0, + hostname: "127.0.0.1", + tls: { cert: options.leaf.certPem, key: options.leaf.keyPem }, + idleTimeout: options.idleTimeout ?? 255, + ...(options.maxRequestBodySize !== undefined ? { maxRequestBodySize: options.maxRequestBodySize } : {}), + async fetch(req, requestServer) { + const url = new URL(req.url); + if (isClaudeInterceptedPath(url.pathname, req.method)) { + return options.dispatch(rewriteInterceptedRequest(req, loopbackOrigin), requestServer); + } + return relayToUpstream(req, upstreamBase, options.fetchImpl); + }, + }); + loopbackOrigin = `http://127.0.0.1:${server.port}`; + return server; +} diff --git a/src/claude/intercept/local-ca.ts b/src/claude/intercept/local-ca.ts new file mode 100644 index 00000000000..d3baf4a6c5e --- /dev/null +++ b/src/claude/intercept/local-ca.ts @@ -0,0 +1,278 @@ +import { createHash, generateKeyPairSync, createPrivateKey, createPublicKey, sign, type KeyObject } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Local certificate authority for the Claude intercept listener. + * + * Claude Code opens `CONNECT api.anthropic.com:443` through `HTTPS_PROXY` and then expects a + * TLS server that presents a certificate for that name. This module mints that authority and + * its leaves with nothing but `node:crypto`: no OpenSSL shell-out, no third-party ASN.1 + * library. The CA is trusted by the Claude Code process alone via `NODE_EXTRA_CA_CERTS`; it is + * never installed into an OS trust store. + * + * Only the subset of X.509 needed for a v3 CA and a serverAuth leaf is encoded. ECDSA P-256 is + * used for both, which Node's TLS stack accepts without any additional configuration. + */ + +export const CLAUDE_INTERCEPT_CA_COMMON_NAME = "opencodex Claude Intercept CA"; +const ORGANIZATION = "opencodex"; +const CA_VALIDITY_DAYS = 3650; +const LEAF_VALIDITY_DAYS = 365; + +// ── DER encoding ──────────────────────────────────────────────────────────────── + +function derLength(length: number): Uint8Array { + if (length < 0x80) return Uint8Array.of(length); + const bytes: number[] = []; + let rest = length; + while (rest > 0) { + bytes.unshift(rest & 0xff); + rest >>= 8; + } + return Uint8Array.of(0x80 | bytes.length, ...bytes); +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function tlv(tag: number, body: Uint8Array): Uint8Array { + return concat(Uint8Array.of(tag), derLength(body.length), body); +} + +const sequence = (...parts: Uint8Array[]) => tlv(0x30, concat(...parts)); +const set = (...parts: Uint8Array[]) => tlv(0x31, concat(...parts)); +const boolean = (value: boolean) => tlv(0x01, Uint8Array.of(value ? 0xff : 0x00)); +const octetString = (body: Uint8Array) => tlv(0x04, body); +const utf8String = (text: string) => tlv(0x0c, new TextEncoder().encode(text)); +const contextTag = (n: number, body: Uint8Array, constructed = true) => tlv((constructed ? 0xa0 : 0x80) | n, body); + +function integer(bytes: Uint8Array): Uint8Array { + return tlv(0x02, (bytes[0]! & 0x80) !== 0 ? concat(Uint8Array.of(0), bytes) : bytes); +} + +function bitString(bytes: Uint8Array, unusedBits = 0): Uint8Array { + return tlv(0x03, concat(Uint8Array.of(unusedBits), bytes)); +} + +function objectIdentifier(dotted: string): Uint8Array { + const arcs = dotted.split(".").map(Number); + const bytes: number[] = [arcs[0]! * 40 + arcs[1]!]; + for (const arc of arcs.slice(2)) { + const encoded: number[] = [arc & 0x7f]; + let rest = arc >> 7; + while (rest > 0) { + encoded.unshift((rest & 0x7f) | 0x80); + rest >>= 7; + } + bytes.push(...encoded); + } + return tlv(0x06, Uint8Array.from(bytes)); +} + +function utcTime(date: Date): Uint8Array { + const pad = (n: number) => String(n).padStart(2, "0"); + const text = `${pad(date.getUTCFullYear() % 100)}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` + + `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`; + return tlv(0x17, new TextEncoder().encode(text)); +} + +const OID = { + commonName: "2.5.4.3", + organization: "2.5.4.10", + ecdsaWithSha256: "1.2.840.10045.4.3.2", + basicConstraints: "2.5.29.19", + keyUsage: "2.5.29.15", + subjectAltName: "2.5.29.17", + extendedKeyUsage: "2.5.29.37", + subjectKeyIdentifier: "2.5.29.14", + authorityKeyIdentifier: "2.5.29.35", + serverAuth: "1.3.6.1.5.5.7.3.1", +} as const; + +function distinguishedName(commonName: string): Uint8Array { + return sequence( + set(sequence(objectIdentifier(OID.organization), utf8String(ORGANIZATION))), + set(sequence(objectIdentifier(OID.commonName), utf8String(commonName))), + ); +} + +function extension(oid: string, critical: boolean, value: Uint8Array): Uint8Array { + return critical + ? sequence(objectIdentifier(oid), boolean(true), octetString(value)) + : sequence(objectIdentifier(oid), octetString(value)); +} + +function subjectPublicKeyInfo(key: KeyObject): Uint8Array { + return new Uint8Array(key.export({ type: "spki", format: "der" })); +} + +/** RFC 5280 permits any unique octet string; a truncated SHA-256 of the SPKI is stable and collision-safe. */ +function keyIdentifier(key: KeyObject): Uint8Array { + return new Uint8Array(createHash("sha256").update(subjectPublicKeyInfo(key)).digest()).subarray(0, 20); +} + +function randomSerial(): Uint8Array { + const serial = crypto.getRandomValues(new Uint8Array(16)); + serial[0]! &= 0x7f; + if (serial[0] === 0) serial[0] = 1; + return serial; +} + +function toPem(label: string, der: Uint8Array): string { + const base64 = Buffer.from(der).toString("base64").replace(/(.{64})/g, "$1\n").trimEnd(); + return `-----BEGIN ${label}-----\n${base64}\n-----END ${label}-----\n`; +} + +interface IssueOptions { + subject: Uint8Array; + issuer: Uint8Array; + subjectKey: KeyObject; + signingKey: KeyObject; + validityDays: number; + extensions: Uint8Array[]; +} + +function issueCertificate(options: IssueOptions): Uint8Array { + // Back-date slightly so a client whose clock trails ours by a few minutes still accepts it. + const notBefore = new Date(Date.now() - 5 * 60_000); + const notAfter = new Date(notBefore.getTime() + options.validityDays * 86_400_000); + const algorithm = sequence(objectIdentifier(OID.ecdsaWithSha256)); + const tbsCertificate = sequence( + contextTag(0, tlv(0x02, Uint8Array.of(2))), + integer(randomSerial()), + algorithm, + options.issuer, + sequence(utcTime(notBefore), utcTime(notAfter)), + options.subject, + subjectPublicKeyInfo(options.subjectKey), + contextTag(3, sequence(...options.extensions)), + ); + const signature = new Uint8Array(sign("sha256", tbsCertificate, { key: options.signingKey, dsaEncoding: "der" })); + return sequence(tbsCertificate, algorithm, bitString(signature)); +} + +// ── Authority + leaves ────────────────────────────────────────────────────────── + +export interface PemKeyPair { + certPem: string; + keyPem: string; +} + +export interface LocalInterceptCa extends PemKeyPair { + publicKey: KeyObject; + privateKey: KeyObject; +} + +export function createLocalInterceptCa(): LocalInterceptCa { + const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const name = distinguishedName(CLAUDE_INTERCEPT_CA_COMMON_NAME); + const der = issueCertificate({ + subject: name, + issuer: name, + subjectKey: publicKey, + signingKey: privateKey, + validityDays: CA_VALIDITY_DAYS, + extensions: [ + extension(OID.basicConstraints, true, sequence(boolean(true), tlv(0x02, Uint8Array.of(0)))), + // keyCertSign | cRLSign + extension(OID.keyUsage, true, bitString(Uint8Array.of(0x06), 1)), + extension(OID.subjectKeyIdentifier, false, octetString(keyIdentifier(publicKey))), + ], + }); + return { + certPem: toPem("CERTIFICATE", der), + keyPem: privateKey.export({ type: "pkcs8", format: "pem" }) as string, + publicKey, + privateKey, + }; +} + +/** Issue a serverAuth leaf for `hosts` (first entry becomes the CN; all become SAN dNSNames). */ +export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly string[]): PemKeyPair { + if (hosts.length === 0) throw new Error("intercept leaf requires at least one host"); + const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const der = issueCertificate({ + subject: distinguishedName(hosts[0]!), + issuer: distinguishedName(CLAUDE_INTERCEPT_CA_COMMON_NAME), + subjectKey: publicKey, + signingKey: ca.privateKey, + validityDays: LEAF_VALIDITY_DAYS, + extensions: [ + extension(OID.basicConstraints, true, sequence()), + // digitalSignature + extension(OID.keyUsage, true, bitString(Uint8Array.of(0x80), 7)), + extension(OID.extendedKeyUsage, false, sequence(objectIdentifier(OID.serverAuth))), + extension(OID.subjectAltName, false, sequence( + ...hosts.map(host => contextTag(2, new TextEncoder().encode(host), false)), + )), + extension(OID.authorityKeyIdentifier, false, sequence(contextTag(0, keyIdentifier(ca.publicKey), false))), + ], + }); + return { + certPem: toPem("CERTIFICATE", der), + keyPem: privateKey.export({ type: "pkcs8", format: "pem" }) as string, + }; +} + +// ── Persistence ───────────────────────────────────────────────────────────────── + +export const CLAUDE_INTERCEPT_STATE_DIR = "claude-intercept"; +export const CLAUDE_INTERCEPT_CA_CERT_FILE = "ca.pem"; +const CA_KEY_FILE = "ca.key"; + +export function claudeInterceptStateDir(configDir: string): string { + return join(configDir, CLAUDE_INTERCEPT_STATE_DIR); +} + +/** Path Claude Code must see in `NODE_EXTRA_CA_CERTS`. Stable across restarts. */ +export function claudeInterceptCaCertPath(configDir: string): string { + return join(claudeInterceptStateDir(configDir), CLAUDE_INTERCEPT_CA_CERT_FILE); +} + +function writeFileAtomic(path: string, contents: string, mode: number): void { + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, contents, { mode }); + try { chmodSync(tmp, mode); } catch { /* best-effort on platforms without POSIX modes */ } + renameSync(tmp, path); +} + +function loadPersistedCa(dir: string): LocalInterceptCa | null { + const certPath = join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE); + const keyPath = join(dir, CA_KEY_FILE); + if (!existsSync(certPath) || !existsSync(keyPath)) return null; + try { + const certPem = readFileSync(certPath, "utf8"); + const keyPem = readFileSync(keyPath, "utf8"); + const privateKey = createPrivateKey(keyPem); + const publicKey = createPublicKey(keyPem); + if (!certPem.includes("BEGIN CERTIFICATE")) return null; + return { certPem, keyPem, publicKey, privateKey }; + } catch { // no-excuse-ok: catch -- an unreadable or corrupt authority is regenerated below. + return null; + } +} + +/** + * Load the persisted authority under `/claude-intercept/`, minting one when absent + * or unreadable. The private key is written 0600; the certificate is world-readable because + * `NODE_EXTRA_CA_CERTS` only needs the public half. + */ +export function ensureLocalInterceptCa(configDir: string): LocalInterceptCa { + const dir = claudeInterceptStateDir(configDir); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const existing = loadPersistedCa(dir); + if (existing) return existing; + const ca = createLocalInterceptCa(); + writeFileAtomic(join(dir, CA_KEY_FILE), ca.keyPem, 0o600); + writeFileAtomic(join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE), ca.certPem, 0o644); + return ca; +} diff --git a/src/claude/intercept/runtime.ts b/src/claude/intercept/runtime.ts new file mode 100644 index 00000000000..a1452117d8c --- /dev/null +++ b/src/claude/intercept/runtime.ts @@ -0,0 +1,98 @@ +import type { Server } from "bun"; +import type { OcxConfig } from "../../types"; +import { getConfigDir } from "../../config/paths"; +import { CLAUDE_INTERCEPT_HOSTS, startConnectProxy, type ConnectProxyHandle } from "./connect-proxy"; +import { startClaudeInterceptListener } from "./listener"; +import { claudeInterceptCaCertPath, ensureLocalInterceptCa, issueLocalInterceptLeaf } from "./local-ca"; + +/** + * Lifecycle for the Claude intercept pair (CONNECT proxy + TLS listener). + * + * Started next to the public listener, torn down with it. The proxy port is derived from the + * public port unless configured, because Claude Code's `settings.json` must name a port that + * survives restarts; the TLS listener is ephemeral and only ever reached through the proxy. + */ + +export const CLAUDE_INTERCEPT_PORT_OFFSET = 100; + +export function claudeInterceptEnabled(config: Pick): boolean { + if (config.runtimeRole === "client") return false; + if (config.claudeCode?.enabled === false) return false; + return config.claudeCode?.intercept?.enabled !== false; +} + +export function claudeInterceptProxyPort(config: Pick, publicPort: number): number { + const configured = config.claudeCode?.intercept?.port; + if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured; + return publicPort + CLAUDE_INTERCEPT_PORT_OFFSET; +} + +export interface ClaudeInterceptState { + proxyPort: number; + caCertPath: string; +} + +export interface ClaudeInterceptHandle extends ClaudeInterceptState { + listener: Server; + stop(): Promise; +} + +let activeState: ClaudeInterceptState | null = null; + +/** Live intercept endpoints, or `null` when the pair is not running in this process. */ +export function getClaudeInterceptState(): ClaudeInterceptState | null { + return activeState; +} + +export interface StartClaudeInterceptOptions { + config: OcxConfig; + /** Bound public port; the derived proxy port is offset from it. */ + publicPort: number; + /** + * Port the operator asked for. `0` (ephemeral) gives the derived proxy port no stable value + * to write into `settings.json`, so intercept stays off unless `intercept.port` is explicit. + */ + requestedPort?: number; + dispatch: (req: Request, server: Server) => Promise; + maxRequestBodySize?: number; + configDir?: string; +} + +/** + * Bind both halves. Resolves `null` when intercept is disabled. A bind failure is reported by + * rejecting; callers treat it as a degraded optional integration, never as a startup failure. + */ +export async function startClaudeIntercept(options: StartClaudeInterceptOptions): Promise | null> { + if (!claudeInterceptEnabled(options.config)) return null; + const explicitPort = typeof options.config.claudeCode?.intercept?.port === "number"; + if (options.requestedPort === 0 && !explicitPort) return null; + const configDir = options.configDir ?? getConfigDir(); + const ca = ensureLocalInterceptCa(configDir); + const leaf = issueLocalInterceptLeaf(ca, CLAUDE_INTERCEPT_HOSTS); + const listener = startClaudeInterceptListener({ + leaf, + dispatch: options.dispatch, + upstreamBase: options.config.claudeCode?.anthropicBaseUrl, + ...(options.maxRequestBodySize !== undefined ? { maxRequestBodySize: options.maxRequestBodySize } : {}), + }); + let proxy: ConnectProxyHandle; + try { + proxy = await startConnectProxy(claudeInterceptProxyPort(options.config, options.publicPort), { + interceptPort: listener.port!, + }); + } catch (error) { + await listener.stop(true); + throw error; + } + const state: ClaudeInterceptState = { proxyPort: proxy.port, caCertPath: claudeInterceptCaCertPath(configDir) }; + activeState = state; + return { + ...state, + listener, + stop: async () => { + if (activeState === state) activeState = null; + await proxy.close(); + await listener.stop(true); + }, + }; +} diff --git a/src/claude/intercept/settings.ts b/src/claude/intercept/settings.ts new file mode 100644 index 00000000000..5b291a7b401 --- /dev/null +++ b/src/claude/intercept/settings.ts @@ -0,0 +1,161 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { claudeConfigDir } from "../auth-detect"; + +/** + * Claude Code `settings.json` env block for intercept mode. + * + * Both the Claude Desktop Code tab and the standalone `claude` CLI read the user + * `~/.claude/settings.json` and export its `env` map into the process before any network + * call. Two keys make every Claude Code process route through the local CONNECT proxy while + * the app itself stays a first-party install: + * + * env.HTTPS_PROXY = http://127.0.0.1: + * env.NODE_EXTRA_CA_CERTS = /claude-intercept/ca.pem + * + * Ownership is tracked by value, never by a marker key. The CA path is the anchor: it lives + * under opencodex's own config directory, so only a block whose `NODE_EXTRA_CA_CERTS` names + * that file is treated as ours. A user's own proxy or CA setting is left alone. + */ + +export const CLAUDE_INTERCEPT_MANAGED_ENV = ["HTTPS_PROXY", "NODE_EXTRA_CA_CERTS"] as const; +export type ClaudeInterceptManagedEnv = typeof CLAUDE_INTERCEPT_MANAGED_ENV[number]; + +export interface ClaudeInterceptEnv { + HTTPS_PROXY: string; + NODE_EXTRA_CA_CERTS: string; +} + +export function claudeInterceptProxyUrl(port: number): string { + return `http://127.0.0.1:${port}`; +} + +export function buildClaudeInterceptEnv(proxyPort: number, caCertPath: string): ClaudeInterceptEnv { + return { HTTPS_PROXY: claudeInterceptProxyUrl(proxyPort), NODE_EXTRA_CA_CERTS: caCertPath }; +} + +export type ClaudeInterceptSettingsState = + | { kind: "absent" } + | { kind: "applied"; env: ClaudeInterceptEnv } + | { kind: "stale"; env: Partial } + | { kind: "foreign"; env: Partial> } + | { kind: "unreadable"; path: string }; + +type SettingsDoc = Record & { env?: Record }; + +function settingsPath(configDir: string): string { + return join(configDir, "settings.json"); +} + +function readSettings(path: string): { doc: SettingsDoc } | { error: "missing" | "unreadable" } { + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch (error) { // no-excuse-ok: catch -- an absent settings file is the fresh-install state. + return { error: (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unreadable" }; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { error: "unreadable" }; + return { doc: parsed as SettingsDoc }; + } catch { // no-excuse-ok: catch -- a corrupt settings file must be reported, not overwritten. + return { error: "unreadable" }; + } +} + +function envRecord(doc: SettingsDoc): Record { + return doc.env && typeof doc.env === "object" && !Array.isArray(doc.env) ? doc.env : {}; +} + +/** Loopback proxy URLs are the only shape opencodex ever writes. */ +export function isClaudeInterceptProxyUrl(value: unknown): value is string { + return typeof value === "string" && /^http:\/\/127\.0\.0\.1:\d{1,5}\/?$/.test(value.trim()); +} + +function isOwnedCaPath(value: unknown, ownedCaPath: string): value is string { + return typeof value === "string" && value.trim() === ownedCaPath; +} + +/** Classify the current settings env against the values this router would write. */ +export function inspectClaudeInterceptSettings( + expected: ClaudeInterceptEnv, + configDir = claudeConfigDir(), +): ClaudeInterceptSettingsState { + const path = settingsPath(configDir); + const read = readSettings(path); + if ("error" in read) return read.error === "missing" ? { kind: "absent" } : { kind: "unreadable", path }; + const env = envRecord(read.doc); + const proxy = env.HTTPS_PROXY; + const ca = env.NODE_EXTRA_CA_CERTS; + if (proxy === undefined && ca === undefined) return { kind: "absent" }; + if (proxy === expected.HTTPS_PROXY && ca === expected.NODE_EXTRA_CA_CERTS) return { kind: "applied", env: expected }; + const caOurs = isOwnedCaPath(ca, expected.NODE_EXTRA_CA_CERTS); + const proxyOurs = proxy === undefined || isClaudeInterceptProxyUrl(proxy); + if (caOurs && proxyOurs) { + return { + kind: "stale", + env: { + ...(typeof proxy === "string" ? { HTTPS_PROXY: proxy } : {}), + ...(typeof ca === "string" ? { NODE_EXTRA_CA_CERTS: ca } : {}), + }, + }; + } + return { + kind: "foreign", + env: { + ...(typeof proxy === "string" ? { HTTPS_PROXY: proxy } : {}), + ...(typeof ca === "string" ? { NODE_EXTRA_CA_CERTS: ca } : {}), + }, + }; +} + +function writeSettings(path: string, doc: SettingsDoc): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, "utf8"); + renameSync(tmp, path); +} + +export type ClaudeInterceptSettingsWrite = + | { ok: true; changed: boolean; path: string } + | { ok: false; reason: "unreadable" | "foreign_env"; path: string }; + +/** + * Write the intercept env into `settings.json`. Refuses when a managed key already holds a + * value opencodex did not write (a user-configured corporate proxy, for instance). + */ +export function applyClaudeInterceptSettings( + env: ClaudeInterceptEnv, + configDir = claudeConfigDir(), +): ClaudeInterceptSettingsWrite { + const path = settingsPath(configDir); + const state = inspectClaudeInterceptSettings(env, configDir); + if (state.kind === "unreadable") return { ok: false, reason: "unreadable", path }; + if (state.kind === "foreign") return { ok: false, reason: "foreign_env", path }; + if (state.kind === "applied") return { ok: true, changed: false, path }; + const read = readSettings(path); + const doc: SettingsDoc = "doc" in read ? read.doc : {}; + doc.env = { ...envRecord(doc), ...env }; + writeSettings(path, doc); + return { ok: true, changed: true, path }; +} + +/** Remove the managed keys, but only the values opencodex owns. */ +export function removeClaudeInterceptSettings( + ownedCaPath: string, + configDir = claudeConfigDir(), +): ClaudeInterceptSettingsWrite { + const path = settingsPath(configDir); + const read = readSettings(path); + if ("error" in read) { + return read.error === "missing" ? { ok: true, changed: false, path } : { ok: false, reason: "unreadable", path }; + } + const env = envRecord(read.doc); + if (!isOwnedCaPath(env.NODE_EXTRA_CA_CERTS, ownedCaPath)) return { ok: true, changed: false, path }; + delete env.NODE_EXTRA_CA_CERTS; + if (isClaudeInterceptProxyUrl(env.HTTPS_PROXY)) delete env.HTTPS_PROXY; + if (Object.keys(env).length === 0) delete read.doc.env; + else read.doc.env = env; + writeSettings(path, read.doc); + return { ok: true, changed: true, path }; +} diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 7ff04dfe4e4..92efabad4d0 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -15,13 +15,23 @@ import { type DesktopFamily, type DesktopProfile, } from "../claude/desktop-profile"; -import { writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; +import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; +import { + applyDesktopFirstParty, + recordClaudeDesktopMode, + removeDesktopFirstParty, + resolveClaudeDesktopApplyMode, + type ClaudeDesktopMode, +} from "../claude/desktop-first-party"; import { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } from "../claude/desktop-policy"; import { filterCatalogVisibleModels, desktopVisibleNativeSlugs, nativeContextLimits } from "../codex/catalog"; import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api"; import { findLiveProxy } from "../server/proxy-liveness"; import { CliUsageError, runtimeRequest, takeJsonFlag } from "./runtime-api"; import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import type { OcxConfig } from "../types"; + +const APPLY_FLAGS = ["--first-party", "--gateway", "--static", "--hybrid", "--discovery-only"]; function isFamily(value: string | undefined): value is DesktopFamily { return !!value && (DESKTOP_FAMILIES as readonly string[]).includes(value); @@ -29,7 +39,10 @@ function isFamily(value: string | undefined): value is DesktopFamily { function printDesktopHelp(): void { console.log(`Usage: - ocx claude desktop [apply] [--static|--hybrid|--discovery-only] + ocx claude desktop [apply] [--first-party | --gateway [--static|--hybrid|--discovery-only]] + --first-party (default) keep Desktop on claude.ai; route only the Code tab's Claude Code + through the local intercept proxy via ~/.claude/settings.json env + --gateway install the third-party gateway profile for the whole app ocx claude desktop show [--json] ocx claude desktop status [--json] ocx claude desktop move [--default] @@ -135,6 +148,104 @@ async function applyConnectedDesktopProfile( } } +/** Persist the operator's Desktop mode choice; a failed marker save never undoes the apply. */ +function saveDesktopMode(mode: ClaudeDesktopMode, deps: ApplyProfileDeps): boolean { + try { + return withClientLifecycleSync(() => { + const outcome = mutatePersistedConfig(current => recordClaudeDesktopMode(current, mode)); + return outcome.status !== "unavailable"; + }, deps.lifecycleLockDeps); + } catch { + return false; + } +} + +export type DesktopApplyTarget = + | { kind: "first-party" } + | { kind: "gateway"; mode: Desktop3pConfigMode }; + +/** Parse `ocx claude desktop apply` flags into a target; legacy gateway shape flags imply --gateway. */ +/** + * Default mode when no flag is given: first-party wherever the local intercept proxy can + * run; a connected client (proxy lives on the hub) or a disabled intercept falls back to + * the gateway profile rather than pointing Claude Code at a proxy that does not exist. + */ +export function defaultDesktopApplyMode( + config: Pick, + connection: ClientConnectionState = readClientConnectionState(), +): ClaudeDesktopMode { + const resolved = resolveClaudeDesktopApplyMode(config); + if (resolved === "gateway") return resolved; + return connection.kind === "connected" ? "gateway" : "first-party"; +} + +export function parseDesktopApplyArgs( + flags: string[], + config: Pick, +): { target: DesktopApplyTarget } | { error: string } { + const shapeFlags = flags.filter(arg => ["--static", "--hybrid", "--discovery-only"].includes(arg)); + const wantsFirstParty = flags.includes("--first-party"); + const wantsGateway = flags.includes("--gateway") || shapeFlags.length > 0; + if (wantsFirstParty && wantsGateway) return { error: "--first-party cannot be combined with --gateway or gateway shape flags." }; + const unknown = flags.filter(arg => !["--first-party", "--gateway", "--static", "--hybrid", "--discovery-only"].includes(arg)); + if (unknown.length > 0) return { error: `알 수 없는 인자: ${unknown.join(" ")}` }; + const kind: ClaudeDesktopMode = wantsFirstParty ? "first-party" : wantsGateway ? "gateway" : defaultDesktopApplyMode(config); + if (kind === "first-party") return { target: { kind } }; + const parsedMode = parseDesktop3pModeArgs(shapeFlags); + if ("error" in parsedMode) return parsedMode; + return { target: { kind, mode: parsedMode.mode } }; +} + +/** + * First-party apply: settings.json env only. The intercept pair the env points at runs inside + * the hub process, so this is a local-hub operation — a connected client machine cannot reach + * a loopback proxy on the hub and is refused rather than left with a dead `HTTPS_PROXY`. + */ +async function applyFirstPartyDesktop( + deps: ApplyProfileDeps, +): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { + try { assertNoClientDisconnectPending(); } catch { return { ok: false, path: "", reason: "client_disconnect_pending" }; } + const connection = readClientConnectionState(); + if (connection.kind === "connected") return { ok: false, path: "", reason: "first_party_requires_local_hub" }; + if (connection.kind !== "disconnected") return { ok: false, path: "", reason: "client_connection_invalid" }; + const config = loadConfig(); + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) return { ok: false, path: "", reason: desired.message }; + // First-party replaces gateway; the two must never be active together. + const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; + const library = inspectDesktop3pConfigLibrary({ appliedFingerprint }); + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + const removed = removeDesktop3pStandardPivot({ appliedFingerprint, replaceWhileEnabled: true }); + if (!removed.ok) { + return { ok: false, path: library.selectedProfilePath ?? "", reason: removed.kind === "cleanup_incomplete" ? "gateway_cleanup_incomplete" : `gateway_profile_active:${removed.reason ?? removed.kind}` }; + } + } + const applied = applyDesktopFirstParty(config); + if (!applied.ok) return { ok: false, path: applied.path, reason: applied.reason }; + const saved = saveDesktopMode("first-party", deps); + return { + ok: true, + path: applied.path, + ...(saved ? {} : { warning: "desktop mode marker was not saved" }), + }; +} + +export async function applyDesktop( + profile: DesktopProfile | undefined, + target: DesktopApplyTarget, + deps: ApplyProfileDeps = {}, +): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { + if (target.kind === "first-party") return applyFirstPartyDesktop(deps); + // Gateway replaces first-party; the two must never be active together. + const removed = removeDesktopFirstParty(); + if (!removed.ok) return { ok: false, path: removed.path, reason: "first_party_settings_unreadable" }; + const result = await applyProfile(profile, target.mode, deps); + if (result.ok && !saveDesktopMode("gateway", deps)) { + return { ...result, warning: [result.warning, "desktop mode marker was not saved"].filter(Boolean).join(" ") }; + } + return result; +} + export async function applyProfile( profile: DesktopProfile | undefined, mode: Desktop3pConfigMode, @@ -222,24 +333,35 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf return 0; } - // Legacy mode flags remain apply aliases and are parsed before subcommands. - const legacyFlags = argv.filter(arg => ["--static", "--hybrid", "--discovery-only"].includes(arg)); - const applyInvocation = argv.length === 0 || command === "apply" || legacyFlags.length > 0; + // Mode flags remain apply aliases and are parsed before subcommands. + const applyFlags = argv.filter(arg => APPLY_FLAGS.includes(arg)); + const applyInvocation = argv.length === 0 || command === "apply" || applyFlags.length > 0; if (applyInvocation) { - const nonMode = argv.filter(arg => !["apply", "--static", "--hybrid", "--discovery-only"].includes(arg)); - if (nonMode.length > 0) { - console.error(`알 수 없는 인자: ${nonMode.join(" ")}`); - return 2; - } - const parsedMode = parseDesktop3pModeArgs(legacyFlags); - if ("error" in parsedMode) { console.error(parsedMode.error); return 2; } + const rest = argv.filter(arg => arg !== "apply"); + const parsedTarget = parseDesktopApplyArgs(rest, loadConfig()); + if ("error" in parsedTarget) { console.error(parsedTarget.error); return 2; } + const { target } = parsedTarget; try { - const result = await applyProfile(undefined, parsedMode.mode, deps); + const result = await applyDesktop(undefined, target, deps); if (!result.ok) { console.error(`설정 적용 실패: ${result.reason ?? "unknown error"}`); + if (result.reason?.startsWith("gateway_")) { + console.error("The gateway profile could not be removed safely, so first-party mode was not applied. Turn the integration off (dashboard toggle) and retry, or keep gateway with `ocx claude desktop apply --gateway`."); + } else if (result.reason === "foreign_env") { + console.error(`~/.claude/settings.json already sets HTTPS_PROXY or NODE_EXTRA_CA_CERTS to a value opencodex does not own (${result.path}). Remove them or use --gateway.`); + } else if (result.reason === "intercept_disabled") { + console.error("First-party mode needs the Claude intercept proxy (claudeCode.intercept.enabled on a hub). Use --gateway instead."); + } else if (result.reason === "first_party_requires_local_hub") { + console.error("First-party mode runs on the hub machine only; on a connected client use --gateway."); + } return 1; } - console.log(`Claude Desktop 설정을 적용했습니다: ${result.path}`); + if (target.kind === "first-party") { + console.log(`Claude Desktop first-party 설정을 적용했습니다: ${result.path}`); + console.log("Desktop 앱 설정은 그대로이며, Code 탭의 Claude Code만 로컬 프록시를 거칩니다."); + } else { + console.log(`Claude Desktop gateway 설정을 적용했습니다: ${result.path}`); + } // The write landed; only the bookkeeping marker did not. Saying nothing // would leave the saved-vs-applied display wrong with no explanation. if (result.warning) console.warn(`⚠️ ${result.warning}`); @@ -336,7 +458,8 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf } saveLocalDesktopProfile(reconciled, config.claudeCode?.desktopProfile, connection, deps); if (flags.includes("--apply")) { - const result = await applyProfile(reconciled, "static", deps); + // A routing profile is a gateway-mode artifact: importing with --apply installs it there. + const result = await applyDesktop(reconciled, { kind: "gateway", mode: "static" }, deps); if (!result.ok) { console.error(`프로필은 저장했지만 Desktop 적용에 실패했습니다: ${result.reason ?? "unknown error"}`); return 1; } if (result.warning) console.warn(`⚠️ ${result.warning}`); } diff --git a/src/cli/ensure-desired-integrations.ts b/src/cli/ensure-desired-integrations.ts index fe94a0b76e2..6a23b5a00c5 100644 --- a/src/cli/ensure-desired-integrations.ts +++ b/src/cli/ensure-desired-integrations.ts @@ -10,7 +10,13 @@ */ import { loadConfig } from "../config"; import { stripGrokConfig, type GrokInjectResult } from "../grok/inject"; -import { removeDesktop3pStandardPivot } from "../claude/desktop-3p"; +import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } from "../claude/desktop-3p"; +import { + applyDesktopFirstParty, + inspectDesktopFirstParty, + removeDesktopFirstParty, + resolveClaudeDesktopMode, +} from "../claude/desktop-first-party"; import { claudeDesktopIntegrationEnabled, grokIntegrationEnabled, @@ -35,6 +41,10 @@ export interface EnsureDesiredIntegrationsDeps { opts?: { hostname?: string }, ) => Promise; removeDesktop3pStandardPivot: typeof removeDesktop3pStandardPivot; + removeDesktopFirstParty?: typeof removeDesktopFirstParty; + applyDesktopFirstParty?: typeof applyDesktopFirstParty; + inspectDesktopFirstParty?: typeof inspectDesktopFirstParty; + inspectDesktop3pConfigLibrary?: typeof inspectDesktop3pConfigLibrary; log?: (message: string) => void; error?: (message: string) => void; } @@ -113,16 +123,44 @@ export async function ensureGrokFenceMatchesDesired( } /** - * When Claude Desktop is durably OFF, clear any leftover owned gateway profile. - * ensure/update used to leave Claude-3p residue in place after a failed disable - * (drifted fingerprint), so the Integrations card kept looking applied/stale. + * When Claude Desktop is durably OFF, clear any leftover owned gateway profile and the + * first-party settings env. ensure/update used to leave Claude-3p residue in place after a + * failed disable (drifted fingerprint), so the Integrations card kept looking applied/stale. + * + * When it is ON in first-party mode, refresh a stale env (the intercept port follows the + * public port, so a port change would otherwise leave Claude Code pointed at a dead proxy). */ export function ensureClaudeDesktopMatchesDesired( deps: EnsureDesiredIntegrationsDeps = productionDeps, ): void { const config = deps.loadConfig(); const { log, error } = io(deps); - if (claudeDesktopIntegrationEnabled(config)) return; + if (claudeDesktopIntegrationEnabled(config)) { + if (resolveClaudeDesktopMode(config) !== "first-party") return; + const library = (deps.inspectDesktop3pConfigLibrary ?? inspectDesktop3pConfigLibrary)({ + appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, + }); + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + // The mode marker and the disk disagree. Replacing a live Desktop profile is an operator + // action, not something an update hook should do silently. + error("⚠️ Claude Desktop mode is first-party but a gateway profile is still applied; run `ocx claude desktop apply --first-party` (or `--gateway`) to reconcile."); + return; + } + const seen = (deps.inspectDesktopFirstParty ?? inspectDesktopFirstParty)(config); + if (!seen.stale) return; + const applied = (deps.applyDesktopFirstParty ?? applyDesktopFirstParty)(config); + if (applied.ok && applied.changed) log(` + Claude Desktop first-party env refreshed (${applied.path})`); + else if (!applied.ok) error(`⚠️ Claude Desktop first-party env refresh skipped: ${applied.reason}.`); + return; + } + try { + const env = (deps.removeDesktopFirstParty ?? removeDesktopFirstParty)(); + if (env.ok && env.changed) log(" ↩️ Claude Desktop first-party env removed."); + else if (!env.ok) error(`⚠️ Claude Desktop first-party env cleanup skipped: ${env.reason} (${env.path}).`); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + error(`⚠️ Claude Desktop first-party env cleanup failed: ${detail}.`); + } try { const removed = deps.removeDesktop3pStandardPivot({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 18a45a68412..1711b8b314a 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -261,7 +261,24 @@ export const configSchema = z.object({ if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) { ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" }); } else if (claudeCode) { - const claude = claudeCode as { desktopProfile?: unknown }; + const claude = claudeCode as { desktopProfile?: unknown; desktopMode?: unknown; intercept?: unknown }; + if (claude.desktopMode !== undefined && claude.desktopMode !== "first-party" && claude.desktopMode !== "gateway") { + ctx.addIssue({ code: "custom", path: ["claudeCode", "desktopMode"], message: "desktopMode must be \"first-party\" or \"gateway\"" }); + } + if (claude.intercept !== undefined) { + const intercept = claude.intercept; + if (!intercept || typeof intercept !== "object" || Array.isArray(intercept)) { + ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept"], message: "intercept must be an object" }); + } else { + const { enabled, port } = intercept as { enabled?: unknown; port?: unknown }; + if (enabled !== undefined && typeof enabled !== "boolean") { + ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "enabled"], message: "intercept.enabled must be a boolean" }); + } + if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) { + ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "port"], message: "intercept.port must be an integer between 1 and 65535" }); + } + } + } if (claude.desktopProfile !== undefined) { try { parseDesktopProfile(claude.desktopProfile); diff --git a/src/server/index.ts b/src/server/index.ts index af20051d9eb..dc3462d638a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -69,11 +69,6 @@ import { runDevinProviderMergeStartupMigration } from "../providers/devin-provid import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { providerCodexAccountMode } from "../providers/registry"; import type { StorageCleanupPolicy } from "../types"; -import { - MAX_CONFIGURABLE_INBOUND_BODY_BYTES, - MIN_CONFIGURABLE_INBOUND_BODY_BYTES, - resolveInboundBodyLimitBytes, -} from "./request-decompress"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; export { clearThreadAccountMap, @@ -203,7 +198,8 @@ import { } from "../lib/package-tree-integrity"; import { detectInstall } from "../update/index"; import { createServeOptions, type ServerIngress } from "./index/serve-options"; -import { inspectStartupOwnership, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; +import { createClaudeInterceptLifecycle } from "./index/claude-intercept-lifecycle"; +import { inspectStartupOwnership, resolveInboundBodyLimitWithWarning, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; import { acquireSpendLedgerServerLifecycle, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle"; export function startServer(port?: number, deps: StartServerDeps = {}): Server { @@ -627,26 +623,13 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe let server: Server; let loopbackServer: Server | null = null; let managementIngressServer: Server | null = null; - - // Resolved once, before any listener binds. The clamp is silent inside the resolver so it - // stays pure and per-request cheap; the operator is told here instead, once, because a - // config value that was quietly reduced is exactly the thing they would otherwise debug - // against the wrong limit. - const inboundBodyLimitBytes = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes); - const requestedInboundBodyLimit = config.maxInboundBodyBytes; - if (requestedInboundBodyLimit !== undefined - && requestedInboundBodyLimit > 0 - && requestedInboundBodyLimit !== inboundBodyLimitBytes) { - console.warn( - `[server] maxInboundBodyBytes=${requestedInboundBodyLimit} is outside the supported range ` - + `[${MIN_CONFIGURABLE_INBOUND_BODY_BYTES}, ${MAX_CONFIGURABLE_INBOUND_BODY_BYTES}]; ` - + `using ${inboundBodyLimitBytes} bytes.`, - ); - } + const claudeIntercept = createClaudeInterceptLifecycle(); + const inboundBodyLimitBytes = resolveInboundBodyLimitWithWarning(config); function ingressForServer(requestServer: Server): ServerIngress { if (requestServer === loopbackServer) return "unauthenticated-loopback"; if (requestServer === managementIngressServer) return "hub-management"; + if (claudeIntercept.ownsListener(requestServer)) return "claude-intercept"; return "public"; } let backgroundLifecycle: ReturnType | null = null; @@ -748,6 +731,10 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe throw new AuxiliaryListenerBindError("hub.managementIngress", managementIngressPort, "127.0.0.1", error); } } + claudeIntercept.start({ + config, publicPort: server.port ?? listenPort, requestedPort: listenPort, maxRequestBodySize: inboundBodyLimitBytes, + dispatch: (req, requestServer) => serveOptions.fetch(req, requestServer), + }); } catch (error) { unregisterQuotaAutoRefresh?.(); userCostOverlayReconciler?.stop(); @@ -776,6 +763,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe ...(managementIngressRef ? [() => managementIngressRef.stop(closeActiveConnections)] : []), + () => claudeIntercept.stop(), async () => { await remoteWorkspaceShutdown?.(); }, async () => { try { diff --git a/src/server/index/claude-intercept-lifecycle.ts b/src/server/index/claude-intercept-lifecycle.ts new file mode 100644 index 00000000000..f49bc49b988 --- /dev/null +++ b/src/server/index/claude-intercept-lifecycle.ts @@ -0,0 +1,49 @@ +import type { Server } from "bun"; +import { + startClaudeIntercept, + type ClaudeInterceptHandle, + type StartClaudeInterceptOptions, +} from "../../claude/intercept/runtime"; + +/** + * Owns the Claude intercept pair (CONNECT proxy + TLS listener) on behalf of `startServer`. + * The pair is an optional integration: a bind failure degrades to a warning, never to a + * startup failure, because every other client keeps working without it. `startServer` stays + * synchronous, so the start is fire-and-forget and `stop()` awaits whatever it produced. + */ +export interface ClaudeInterceptLifecycle { + /** True once the TLS listener has bound and `requestServer` is it. */ + ownsListener(requestServer: Server): boolean; + start(options: StartClaudeInterceptOptions): void; + stop(): Promise; +} + +export function createClaudeInterceptLifecycle(): ClaudeInterceptLifecycle { + let listener: Server | null = null; + let pending: Promise | null> = Promise.resolve(null); + return { + ownsListener: requestServer => listener !== null && requestServer === listener, + start(options) { + const dispatch = options.dispatch; + pending = startClaudeIntercept({ + ...options, + dispatch: (req, requestServer) => { + listener ??= requestServer; + return dispatch(req, requestServer); + }, + }).then(handle => { + if (handle) { + listener = handle.listener; + console.log(`🔐 Claude intercept proxy active on http://127.0.0.1:${handle.proxyPort} (CONNECT api.anthropic.com → local TLS)`); + } + return handle; + }).catch((error: unknown) => { + console.warn(`⚠ Claude intercept proxy could not start: ${error instanceof Error ? error.message : String(error)}`); + return null; + }); + }, + async stop() { + await (await pending)?.stop(); + }, + }; +} diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 053a5943919..4e4eab84bc7 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -24,6 +24,7 @@ import { } from "./startup-warnings"; import { remoteWorkspaceEnabled } from "../../remote-control/workspace-activation"; +import { isClaudeInterceptedPath } from "../../claude/intercept/listener"; import { markActivity } from "../../lib/sidecar-tracker"; import { knownModelIdsForProvider } from "../../router"; import { @@ -197,7 +198,16 @@ import { readyProtocolMetadata } from "../../remote/protocol"; import { modelCapabilityFields } from "../models-capabilities"; import { createWebsocketHandler } from "./websocket-handler"; -export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management"; +export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management" | "claude-intercept"; + +/** + * Routes the Claude intercept TLS listener may reach. Everything else on that socket is relayed + * to the real upstream by the listener itself, so a request that lands here with another path + * is a bug, not a client — refuse it. + */ +export function claudeInterceptRouteAllowed(url: URL, req: Request): boolean { + return isClaudeInterceptedPath(url.pathname, req.method); +} export interface ServeOptionsContext { readonly server: Server; @@ -301,12 +311,24 @@ export function createServeOptions(ctx: ServeOptionsContext) { config, ); } + if (ingress === "claude-intercept" && !claudeInterceptRouteAllowed(codexCompatibleUrl(req.url), req)) { + return withCors( + formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`), + req, + loopbackPolicy(), + ); + } // Auth and CORS decisions below read `policy`, not `config`. For the public listener the // two are the same object, so its behaviour is unchanged; for the loopback listener the // view substitutes 127.0.0.1 as the bind address, which is what routes it through the // same code path a plain loopback bind has always taken — Host-header check included. // Routing, provider selection and response bodies keep using `config`. - const policy: RequestPolicyView = ingress === "unauthenticated-loopback" ? loopbackPolicy() : config; + // The Claude intercept listener is loopback by construction (the CONNECT proxy binds + // 127.0.0.1 and the request was rewritten onto a loopback origin), and its callers carry + // Anthropic credentials, not opencodex admission tokens — so it takes the loopback view too. + const policy: RequestPolicyView = ingress === "unauthenticated-loopback" || ingress === "claude-intercept" + ? loopbackPolicy() + : config; const url = codexCompatibleUrl(req.url); markActivity(`${req.method} ${url.pathname}`); diff --git a/src/server/index/startup-warnings.ts b/src/server/index/startup-warnings.ts index a29d3f6fcbf..76b9119f735 100644 --- a/src/server/index/startup-warnings.ts +++ b/src/server/index/startup-warnings.ts @@ -56,6 +56,11 @@ import { type PackageTreeIntegrityGuard, } from "../../lib/package-tree-integrity"; import type { LiveSidebandWebSocketFactory } from "./live-sideband"; +import { + MAX_CONFIGURABLE_INBOUND_BODY_BYTES, + MIN_CONFIGURABLE_INBOUND_BODY_BYTES, + resolveInboundBodyLimitBytes, +} from "../request-decompress"; // GUI static serving extracted to ./server/gui-static. Re-exported below to keep the // "../src/server" import surface stable for tests/callers. @@ -205,6 +210,25 @@ export function warnAgentTaskRecoveryStartup(config: { console.warn(" Recovered plaintext assignment data is retained only in a bounded, process-local in-memory cache; exact fidelity is not guaranteed and the path depends on undocumented backend behavior."); } +/** + * Resolved once, before any listener binds. The clamp is silent inside the resolver so it + * stays pure and per-request cheap; the operator is told here instead, once, because a + * config value that was quietly reduced is exactly the thing they would otherwise debug + * against the wrong limit. + */ +export function resolveInboundBodyLimitWithWarning(config: { maxInboundBodyBytes?: number }): number { + const limit = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes); + const requested = config.maxInboundBodyBytes; + if (requested !== undefined && requested > 0 && requested !== limit) { + console.warn( + `[server] maxInboundBodyBytes=${requested} is outside the supported range ` + + `[${MIN_CONFIGURABLE_INBOUND_BODY_BYTES}, ${MAX_CONFIGURABLE_INBOUND_BODY_BYTES}]; ` + + `using ${limit} bytes.`, + ); + } + return limit; +} + export function warnPlaintextV2AgentMessagesStartup(config: { plaintextV2AgentMessages?: boolean }): void { if (config.plaintextV2AgentMessages !== true) return; console.warn("⚠️ Experimental plaintext V2 agent messages are enabled."); diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index ef553702087..91976c144fd 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -131,6 +131,17 @@ function persistDesktopProfileField( return { ok: true }; } +async function persistDesktopModeField( + config: OcxConfig, + desktopMode: "first-party" | "gateway", +): Promise<{ ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" }> { + const { recordClaudeDesktopMode } = await import("../../claude/desktop-first-party"); + const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode)); + if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; + recordClaudeDesktopMode(config, desktopMode); + return { ok: true }; +} + export function grokApplyFlightSnapshot(): { currentBytes: number; highWaterBytes: number; active: number } { return { currentBytes: grokApplyFlight?.bytes ?? 0, @@ -1002,7 +1013,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") { try { // #859: the CLI delegates here so the registry is built in the serving - // process. Accept an optional mode; default stays static for back-compat. + // process. `mode` selects the Desktop mode: `first-party` (default when the + // install has no gateway profile) or `gateway`; the legacy gateway shapes + // (static/hybrid/discovery) still select gateway with that library layout. let mode: "static" | "hybrid" | "discovery" = "static"; let parsed: unknown; try { @@ -1011,12 +1024,17 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + const { resolveClaudeDesktopApplyMode, applyDesktopFirstParty, removeDesktopFirstParty } = await import("../../claude/desktop-first-party"); const requested = (parsed as { mode?: unknown } | null)?.mode; + let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config); if (requested !== undefined) { if (requested === "static" || requested === "hybrid" || requested === "discovery") { mode = requested; + desktopMode = "gateway"; + } else if (requested === "gateway" || requested === "first-party") { + desktopMode = requested; } else { - return jsonResponse({ error: "mode must be static, hybrid, or discovery" }, 400); + return jsonResponse({ error: "mode must be first-party, gateway, static, hybrid, or discovery" }, 400); } } // #859: a delegated CLI apply carries the profile it just saved — the @@ -1032,6 +1050,63 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); } } + if (desktopMode === "first-party") { + const { setIntegrationEnabled } = await import("../../codex/desired-state"); + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500); + mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true); + // First-party replaces gateway: the two must never be active together. + const { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } = await import("../../claude/desktop-3p"); + const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; + const library = inspectDesktop3pConfigLibrary({ appliedFingerprint }); + let gatewayRemoved = false; + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + const removed = (deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint, replaceWhileEnabled: true }); + if (!removed.ok) { + return jsonResponse({ + error: removed.kind === "cleanup_incomplete" + ? "Claude Desktop now points at standard mode, but gateway credential cleanup is incomplete; first-party env was not applied." + : "The gateway profile could not be removed safely, so first-party mode was not applied.", + code: "claude_desktop_gateway_removal_failed", + reason: removed.reason ?? removed.kind, + ...(removed.residualPaths ? { residualPaths: removed.residualPaths } : {}), + }, removed.kind === "cleanup_incomplete" ? 500 : 409); + } + gatewayRemoved = removed.changed; + } + const applied = applyDesktopFirstParty(config); + if (!applied.ok) { + const { firstPartyRefusalMessage } = await import("./native-integration-routes"); + return jsonResponse({ + error: firstPartyRefusalMessage(applied.reason, applied.path), + code: "claude_desktop_first_party_refused", + reason: applied.reason, + path: applied.path, + }, applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? 409 : 500); + } + const modeSaved = await persistDesktopModeField(config, "first-party"); + return jsonResponse({ + ok: true, + mode: "first-party", + saved: modeSaved.ok, + applied: true, + changed: applied.changed || gatewayRemoved, + gatewayRemoved, + path: applied.path, + proxyPort: applied.proxyPort, + caCertPath: applied.env.NODE_EXTRA_CA_CERTS, + ...(modeSaved.ok ? {} : { warning: `First-party env was applied, but the mode marker was not saved (${modeSaved.reason}).` }), + }); + } + // Gateway mode replaces first-party: the two must never be active together. + const firstPartyRemoved = removeDesktopFirstParty(); + if (!firstPartyRemoved.ok) { + return jsonResponse({ + error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); the first-party proxy env could not be removed before switching to gateway mode.`, + code: "claude_desktop_first_party_refused", + reason: firstPartyRemoved.reason, + }, 500); + } const { setIntegrationEnabled, claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); const desired = setIntegrationEnabled("claude-desktop", true); if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500); @@ -1083,6 +1158,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise nativeContextLimits(latest), ); if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500); + const modeSaved = await persistDesktopModeField(config, "gateway"); + const modeWarning = modeSaved.ok ? undefined : `Claude Desktop was applied, but the gateway mode marker was not saved (${modeSaved.reason}).`; const { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy"); const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({ platform: deps.platform ?? process.platform, @@ -1106,19 +1183,22 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise fingerprint: result.fingerprint, warning: [ `Claude Desktop was applied, but the applied marker was not saved (${marked.reason}).`, + modeWarning, policyWarning, ].filter(Boolean).join(" "), }); } } + const warning = [modeWarning, policyWarning].filter(Boolean).join(" "); return jsonResponse({ ok: true, - saved: true, + mode: "gateway", + saved: modeSaved.ok, applied: true, path: result.path, fingerprint: result.fingerprint, policyState, - ...(policyWarning ? { warning: policyWarning } : {}), + ...(warning ? { warning } : {}), }); } catch (error) { rethrowManagementBodyTooLarge(error); @@ -1131,21 +1211,39 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise try { const { claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); const { inspectDesktop3pConfigLibrary } = await import("../../claude/desktop-3p"); + const { resolveClaudeDesktopApplyMode, inspectDesktopFirstParty } = await import("../../claude/desktop-first-party"); + const { getClaudeInterceptState } = await import("../../claude/intercept/runtime"); const persisted = loadConfig(); const savedFingerprint = persisted.claudeCode?.desktopProfile?.appliedFingerprint ?? null; const observed = inspectDesktop3pConfigLibrary({ appliedFingerprint: savedFingerprint }); const desiredEnabled = claudeDesktopIntegrationEnabled(persisted); - const applied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted"; + const gatewayApplied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted"; + // A gateway profile on disk is what Desktop actually runs, whatever the saved mode says. + const mode = gatewayApplied ? "gateway" : resolveClaudeDesktopApplyMode(persisted); + const firstPartySeen = inspectDesktopFirstParty(persisted); + const intercept = getClaudeInterceptState(); + const firstParty = { + settingsState: firstPartySeen.settings.kind, + applied: firstPartySeen.applied, + stale: firstPartySeen.stale, + interceptEnabled: firstPartySeen.interceptEnabled, + interceptRunning: intercept !== null, + proxyPort: intercept?.proxyPort ?? firstPartySeen.proxyPort, + caCertPath: firstPartySeen.caCertPath, + }; + const applied = mode === "first-party" ? firstPartySeen.applied : gatewayApplied; // "Needs update" is only meaningful while the integration is wanted. When the // durable switch is OFF, a leftover drifted profile is residue to clear — not // a stale apply the operator should refresh. - const stale = desiredEnabled && observed.kind === "gateway_drifted"; + const stale = desiredEnabled && (mode === "first-party" ? firstPartySeen.stale : observed.kind === "gateway_drifted"); const { getDesktopHealth } = await import("../../claude/desktop-health"); const { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy"); const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({ platform: deps.platform ?? process.platform, }); - const policy = claudeDesktopPolicyHealth(policyState); + // Managed-policy conflicts only matter for the gateway profile; first-party mode never + // touches Desktop's own configuration. + const policy = claudeDesktopPolicyHealth(mode === "first-party" ? "absent" : policyState); const health = { ...getDesktopHealth(), ok: policy.ok, @@ -1153,14 +1251,19 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise policy, }; const policyConflict = desiredEnabled && !policy.ok; - const baseDrift = desiredEnabled ? !applied || stale : applied || observed.kind === "unsafe"; + const firstPartyResidue = mode === "gateway" && (firstPartySeen.applied || firstPartySeen.stale); + const baseDrift = desiredEnabled + ? !applied || stale || firstPartyResidue + : applied || gatewayApplied || firstPartySeen.applied || firstPartySeen.stale || observed.kind === "unsafe"; const driftReason = policyConflict ? policy.state === "present" ? "managed_policy_present" : "managed_policy_unknown" : desiredEnabled - ? !applied ? "desired_on_not_current" : stale ? "profile_drift" : null - : applied ? "desired_off_gateway_selected" : null; + ? !applied ? "desired_on_not_current" : stale ? "profile_drift" : firstPartyResidue ? "first_party_residue" : null + : gatewayApplied ? "desired_off_gateway_selected" : firstPartySeen.applied || firstPartySeen.stale ? "desired_off_first_party_env" : null; return jsonResponse({ desiredEnabled, + mode, + firstParty, installed: observed.kind !== "not_installed", observedKind: observed.kind, applied, diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 56158bd162e..ae20731777a 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -18,13 +18,21 @@ * 011 (Claude Code), 012 (Grok). */ import { join } from "node:path"; -import { loadConfig, saveConfigPreservingClaudeCode } from "../../config"; +import { loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../../config"; import { readRuntimePort } from "../../config/process-state"; import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits } from "../../codex/catalog"; import { getCodexHome } from "../../codex/paths"; import { providerContextCap } from "../../providers/context-cap"; import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; +import { + applyDesktopFirstParty, + inspectDesktopFirstParty, + recordClaudeDesktopMode, + removeDesktopFirstParty, + resolveClaudeDesktopApplyMode, + type ClaudeDesktopMode, +} from "../../claude/desktop-first-party"; import { projectGrokCatalog } from "../../grok/catalog"; import { injectGrokConfig, stripGrokConfig } from "../../grok/inject"; import { inspectGrokConfig } from "../../grok/inspect"; @@ -47,7 +55,9 @@ export type NativeRefusalReason = | "write_failed" | "metadata_unreadable" | "cleanup_incomplete" - | "desired_state_changed"; + | "desired_state_changed" + | "foreign_env" + | "intercept_disabled"; export interface NativeStatus { clientId: NativeIntegrationClientId; @@ -126,7 +136,7 @@ function desktopStatus(config: ManagementContext["config"]): NativeStatus { const seen = inspectDesktop3pConfigLibrary({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, }); - const state: NativeStatus["state"] = seen.kind === "gateway_ours" + const gatewayState: NativeStatus["state"] = seen.kind === "gateway_ours" ? "current" : seen.kind === "unsafe" || seen.kind === "broken" ? "unsafe" : "absent"; const disableBlocked = seen.kind === "unsafe" || seen.kind === "broken" || seen.kind === "foreign" @@ -135,6 +145,14 @@ function desktopStatus(config: ManagementContext["config"]): NativeStatus { message: "Claude Desktop configuration cannot be changed safely.", } : null; + // First-party mode lives in Claude Code's settings.json, not in Desktop's library. A leftover + // gateway profile still counts as current (it is what Desktop is actually running). + const firstParty = resolveClaudeDesktopApplyMode(config) === "first-party" && gatewayState === "absent" + ? inspectDesktopFirstParty(config) + : null; + const state: NativeStatus["state"] = firstParty + ? firstParty.settings.kind === "unreadable" ? "unsafe" : firstParty.applied ? "current" : "absent" + : gatewayState; return { clientId: "claude-desktop", state, @@ -599,6 +617,28 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { } } +export function firstPartyRefusalMessage( + reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env", + path: string, +): string { + switch (reason) { + case "intercept_disabled": + return "First-party mode needs the Claude intercept proxy, which is off in this configuration (claudeCode.intercept.enabled / client role). Use gateway mode instead."; + case "ca_unavailable": + return `The local intercept certificate could not be created (${path}).`; + case "unreadable": + return `Claude Code settings could not be parsed (${path}); nothing was written.`; + case "foreign_env": + return `Claude Code settings already set HTTPS_PROXY or NODE_EXTRA_CA_CERTS to a value opencodex does not own (${path}); remove them first or use gateway mode.`; + } +} + +/** Record which Desktop mode is applied; `false` when the config file could not be updated. */ +function persistDesktopModeMarker(desktopMode: ClaudeDesktopMode): boolean { + const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode)); + return outcome.status !== "unavailable"; +} + let claudeDesktopToggleFlight: Promise | null = null; async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { @@ -626,6 +666,11 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise` and `NODE_EXTRA_CA_CERTS=/claude-intercept/ca.pem` + into the `env` block of Claude Code's `settings.json` (via `src/claude/intercept/settings.ts`), + creating the local authority first. Only the Claude Code process Desktop spawns for the Code tab + (and its subagents, and any standalone `claude` CLI) reads that env, so only their + `api.anthropic.com` traffic reaches the [Claude intercept pair](../runtime.md#claude-intercept-pair). +- **gateway**: the existing third-party profile written by `src/claude/desktop-3p.ts`; the whole + app switches to the local gateway. It is selected explicitly (`--gateway`, dashboard, or the + legacy `--static|--hybrid|--discovery-only` shape flags, which imply it). + +`resolveClaudeDesktopMode` returns the explicit `claudeCode.desktopMode` when set; otherwise a +persisted `desktopProfile.appliedFingerprint` (an existing gateway install) keeps `gateway`, and a +fresh install resolves to `first-party`. Updates therefore never flip a working gateway install +silently, while new installs land on first-party. `resolveClaudeDesktopApplyMode` narrows an +*implied* first-party to gateway where the intercept pair cannot run (client role or +`claudeCode.intercept.enabled: false`); an explicit `first-party` is refused with +`intercept_disabled` instead of being rewritten. + +Switching is ownership-aware in both directions: a gateway apply first removes the first-party env +(only values anchored on OpenCodex's own CA path); a first-party apply first pivots an owned +gateway profile (`gateway_ours`/`gateway_drifted`) back to standard through +`removeDesktop3pStandardPivot({ replaceWhileEnabled: true })` — the durable switch stays ON, so the +OFF-flow desired-state guard is bypassed on purpose — and fails (`claude_desktop_gateway_removal_failed`) +without writing the env if that pivot cannot complete. It refuses (`foreign_env`) when +`settings.json` already carries a proxy/CA value OpenCodex does not own. +Disabling the integration (native toggle, `ocx ensure` with the durable switch OFF) removes both the +gateway profile and the first-party env. With the switch ON in first-party mode, `ocx ensure` +re-applies a stale env (the proxy port follows the public port). + +Surfaces: `ocx claude desktop apply [--first-party|--gateway]` in `src/cli/claude-desktop.ts`; +`POST /api/claude-desktop/apply` with `mode` ∈ `first-party|gateway|static|hybrid|discovery` and +`GET /api/claude-desktop/status` (`mode`, `firstParty.{applied,stale,interceptEnabled,interceptRunning,proxyPort,caCertPath}`) +in `src/server/management/agent-settings-routes.ts`; the native toggle in +`src/server/management/native-integration-routes.ts` applies the resolved mode on enable. Managed +Windows policy health only applies in gateway mode, because first-party never touches Desktop's own +configuration. Ordinary Chat-tab traffic is out of scope for both modes. + ## Connected Claude Desktop profiles The connection's local Codex readiness check follows the [selected-runtime probe contract](../runtime.md#remote-hub-hardening-ownership); general status hands its resolved command to this check instead of probing the version twice. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 86881ab7bdc..de51d06c3c4 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -156,7 +156,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. | diff --git a/structure/runtime.md b/structure/runtime.md index 9d7316cfc4e..ec03516b8cc 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -215,6 +215,27 @@ The hub-management socket is enabled only by `runtimeRole: "hub"` plus `hub.managementIngress.enabled`, always binds `127.0.0.1`, and default-denies everything except GUI, session bootstrap/exchange, and `/api/*`. +### Claude intercept pair + +At the end of the startup transaction, `startServer` also starts the optional Claude intercept pair +through `src/server/index/claude-intercept-lifecycle.ts` (fire-and-forget start, `ownsListener` for +the ingress decision, `stop` joined into the listener shutdown) from `src/claude/intercept/runtime.ts`: a loopback HTTP CONNECT proxy (`src/claude/intercept/connect-proxy.ts`) +and a loopback TLS listener (`src/claude/intercept/listener.ts`) that presents a leaf for +`api.anthropic.com` signed by a per-install authority (`src/claude/intercept/local-ca.ts`, persisted +under `/claude-intercept/` with a 0600 key; never installed into an OS trust store). +Claude Code reaches the pair through `HTTPS_PROXY` plus `NODE_EXTRA_CA_CERTS` in its settings env +(`src/claude/intercept/settings.ts`), so no `ANTHROPIC_BASE_URL` rewrite is involved and the client +still believes it talks to Anthropic. The proxy splices `CONNECT api.anthropic.com:443` onto the TLS +listener, relays every other CONNECT target blind, and refuses plain proxied HTTP and loopback targets. +The TLS listener rewrites `POST /v1/messages` and `POST /v1/messages/count_tokens` onto a loopback +origin and dispatches them to the same route table under the `claude-intercept` ingress, which takes +the loopback request policy; every other path on the intercepted host is relayed verbatim to the +configured Anthropic upstream. The pair is on by default on a hub (`claudeCode.intercept.enabled`), +its proxy port defaults to the public port + 100 (`claudeCode.intercept.port`), and a bind failure +degrades to a startup warning rather than a startup failure; stop joins both sockets. A server asked +for an ephemeral public port (`startServer(0)`, the shape every in-process test fixture uses) has no +stable port to derive from, so the pair stays off unless `claudeCode.intercept.port` is explicit. + Auxiliary listener bind failures carry the listener key and effective address through `AuxiliaryListenerBindError` in `src/server/ports.ts`. `src/cli/index.ts` reports them without retrying the public port. Startup still rolls back every earlier socket synchronously. A failed optional bind initiates rollback of every earlier socket; normal stop joins all bound diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts new file mode 100644 index 00000000000..7b391f98dae --- /dev/null +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyDesktopFirstParty, + inspectDesktopFirstParty, + removeDesktopFirstParty, + resolveClaudeDesktopApplyMode, + resolveClaudeDesktopMode, +} from "../../src/claude/desktop-first-party"; +import { parseDesktopApplyArgs } from "../../src/cli/claude-desktop"; +import { ensureClaudeDesktopMatchesDesired } from "../../src/cli/ensure-desired-integrations"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { setIntegrationEnabled } from "../../src/codex/desired-state"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root = ""; +let library = ""; +let claudeDir = ""; +const previous: Record = {}; +const ENV_KEYS = ["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "CLAUDE_CONFIG_DIR"] as const; + +function config(extra: Partial = {}): OcxConfig { + return { port: 10100, providers: {}, defaultProvider: "openai", ...extra } as OcxConfig; +} + +function settings(): { env?: Record; [key: string]: unknown } { + return JSON.parse(readFileSync(join(claudeDir, "settings.json"), "utf8")) as { env?: Record }; +} + +async function dispatch(path: string, init?: RequestInit, inputConfig: OcxConfig = config()) { + const url = new URL(`http://127.0.0.1:10100${path}`); + const response = await handleManagementAPI(new Request(url, { + ...init, + headers: { Host: url.host, "Content-Type": "application/json", ...(init?.headers ?? {}) }, + }), url, inputConfig, {}); + return { status: response!.status, body: await response!.json() as Record }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-desktop-1p-")); + library = join(root, "desktop-library"); + claudeDir = join(root, "claude"); + for (const key of ENV_KEYS) previous[key] = process.env[key]; + process.env.OPENCODEX_HOME = root; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + process.env.CLAUDE_CONFIG_DIR = claudeDir; + writeFileSync(join(root, "config.json"), JSON.stringify(config())); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + removeTreeWithRetry(root); +}); + +test("mode resolution: explicit wins, applied gateway fingerprint keeps gateway, otherwise first-party", () => { + expect(resolveClaudeDesktopMode(config())).toBe("first-party"); + expect(resolveClaudeDesktopMode(config({ claudeCode: { desktopMode: "gateway" } }))).toBe("gateway"); + expect(resolveClaudeDesktopMode(config({ + claudeCode: { desktopProfile: { version: 1, assignments: {}, defaults: { opus: null, fable: null, sonnet: null, haiku: null }, appliedFingerprint: "abc" } }, + }))).toBe("gateway"); + expect(resolveClaudeDesktopMode(config({ + claudeCode: { + desktopMode: "first-party", + desktopProfile: { version: 1, assignments: {}, defaults: { opus: null, fable: null, sonnet: null, haiku: null }, appliedFingerprint: "abc" }, + }, + }))).toBe("first-party"); +}); + +test("implied apply mode falls back to gateway where the intercept proxy cannot run", () => { + expect(resolveClaudeDesktopApplyMode(config())).toBe("first-party"); + expect(resolveClaudeDesktopApplyMode(config({ runtimeRole: "client" }))).toBe("gateway"); + expect(resolveClaudeDesktopApplyMode(config({ claudeCode: { intercept: { enabled: false } } }))).toBe("gateway"); + // An explicit choice is never silently rewritten. + expect(resolveClaudeDesktopApplyMode(config({ runtimeRole: "client", claudeCode: { desktopMode: "first-party" } }))).toBe("first-party"); +}); + +test("CLI apply flags: default first-party, legacy shape flags imply gateway, conflicts rejected", () => { + expect(parseDesktopApplyArgs([], config())).toEqual({ target: { kind: "first-party" } }); + expect(parseDesktopApplyArgs(["--first-party"], config())).toEqual({ target: { kind: "first-party" } }); + expect(parseDesktopApplyArgs(["--gateway"], config())).toEqual({ target: { kind: "gateway", mode: "static" } }); + expect(parseDesktopApplyArgs(["--hybrid"], config())).toEqual({ target: { kind: "gateway", mode: "hybrid" } }); + expect(parseDesktopApplyArgs(["--gateway", "--discovery-only"], config())).toEqual({ target: { kind: "gateway", mode: "discovery" } }); + expect(parseDesktopApplyArgs([], config({ claudeCode: { desktopMode: "gateway" } }))).toEqual({ target: { kind: "gateway", mode: "static" } }); + expect("error" in parseDesktopApplyArgs(["--first-party", "--gateway"], config())).toBe(true); + expect("error" in parseDesktopApplyArgs(["--first-party", "--static"], config())).toBe(true); + expect("error" in parseDesktopApplyArgs(["--bogus"], config())).toBe(true); +}); + +test("first-party apply writes only the proxy env, creates the CA, and removes cleanly", () => { + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ theme: "dark", env: { FOO: "bar" } })); + const applied = applyDesktopFirstParty(config()); + expect(applied.ok).toBe(true); + if (!applied.ok) return; + expect(applied.proxyPort).toBe(10200); + expect(existsSync(applied.env.NODE_EXTRA_CA_CERTS)).toBe(true); + expect(applied.env.NODE_EXTRA_CA_CERTS.startsWith(root)).toBe(true); + const written = settings(); + expect(written.theme).toBe("dark"); + expect(written.env).toEqual({ + FOO: "bar", + HTTPS_PROXY: "http://127.0.0.1:10200", + NODE_EXTRA_CA_CERTS: applied.env.NODE_EXTRA_CA_CERTS, + }); + expect(inspectDesktopFirstParty(config()).applied).toBe(true); + // Desktop's own library is untouched: first-party never installs a gateway profile. + expect(existsSync(library)).toBe(false); + + // A port change makes the env stale; re-apply refreshes it. + expect(inspectDesktopFirstParty(config({ port: 10300 })).stale).toBe(true); + const refreshed = applyDesktopFirstParty(config({ port: 10300 })); + expect(refreshed.ok && refreshed.changed).toBe(true); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10400"); + + const removed = removeDesktopFirstParty(); + expect(removed).toMatchObject({ ok: true, changed: true }); + expect(settings()).toEqual({ theme: "dark", env: { FOO: "bar" } }); +}); + +test("first-party apply refuses foreign proxy env and disabled intercept", () => { + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://corp-proxy:3128" } })); + expect(applyDesktopFirstParty(config())).toMatchObject({ ok: false, reason: "foreign_env" }); + expect(settings().env).toEqual({ HTTPS_PROXY: "http://corp-proxy:3128" }); + expect(removeDesktopFirstParty()).toMatchObject({ ok: true, changed: false }); + expect(applyDesktopFirstParty(config({ runtimeRole: "client" }))).toMatchObject({ ok: false, reason: "intercept_disabled" }); +}); + +test("POST /api/claude-desktop/apply defaults to first-party and gateway mode replaces it", async () => { + const first = await dispatch("/api/claude-desktop/apply", { method: "POST" }); + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ ok: true, mode: "first-party", applied: true, changed: true, proxyPort: 10200 }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode?.desktopMode).toBe("first-party"); + expect(saved.clientIntegrations?.["claude-desktop"]).not.toBe(false); + + const status = await dispatch("/api/claude-desktop/status"); + expect(status.body).toMatchObject({ + mode: "first-party", + applied: true, + stale: false, + drift: false, + firstParty: { applied: true, interceptEnabled: true, proxyPort: 10200 }, + }); + expect(status.body.health.ok).toBe(true); + + const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }); + expect(gateway.status).toBe(200); + expect(settings().env?.HTTPS_PROXY).toBeUndefined(); + expect(settings().env?.NODE_EXTRA_CA_CERTS).toBeUndefined(); + const afterGateway = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(afterGateway.claudeCode?.desktopMode).toBe("gateway"); + expect(afterGateway.claudeCode?.desktopProfile?.appliedFingerprint).toBeTruthy(); + + // Switching back replaces the gateway profile with the first-party env in one apply. + const back = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, afterGateway); + expect(back.status).toBe(200); + expect(back.body).toMatchObject({ ok: true, mode: "first-party", applied: true, gatewayRemoved: true }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + const afterBack = await dispatch("/api/claude-desktop/status", {}, afterGateway); + expect(afterBack.body).toMatchObject({ mode: "first-party", applied: true, stale: false, drift: false, desiredEnabled: true }); + expect(["not_installed", "no_owned_state", "standard"]).toContain(afterBack.body.observedKind); + // The gateway apply marker goes with the profile: without the explicit mode field the + // saved config must still resolve to first-party, not to the gateway it just replaced. + const savedBack = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(savedBack.claudeCode?.desktopProfile?.appliedFingerprint).toBeUndefined(); + expect(savedBack.claudeCode?.desktopProfile?.appliedAt).toBeUndefined(); + expect(savedBack.claudeCode?.desktopProfile?.assignments).toBeDefined(); + expect(resolveClaudeDesktopMode({ claudeCode: { ...savedBack.claudeCode, desktopMode: undefined } })).toBe("first-party"); +}); + +test("native toggle: enable applies first-party by default and disable removes the env", async () => { + const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }); + expect(enabled.status).toBe(200); + expect(enabled.body).toMatchObject({ ok: true, changed: true, state: "current", desiredEnabled: true }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + + const list = await dispatch("/api/native-integrations"); + const desktop = (list.body.clients as Array<{ clientId: string; state: string }>).find(client => client.clientId === "claude-desktop"); + expect(desktop?.state).toBe("current"); + + const disabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: false }) }); + expect(disabled.status).toBe(200); + expect(disabled.body).toMatchObject({ ok: true, changed: true, state: "absent", desiredEnabled: false }); + expect(settings().env?.HTTPS_PROXY).toBeUndefined(); +}); + +test("native toggle: enabling into explicit first-party pivots an applied gateway profile and saves the mode marker", async () => { + const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }); + expect(gateway.status).toBe(200); + const afterGateway = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(afterGateway.claudeCode?.desktopProfile?.appliedFingerprint).toBeTruthy(); + // The operator chose first-party in config while the gateway profile is still on disk. + const chosen = { ...afterGateway, claudeCode: { ...afterGateway.claudeCode, desktopMode: "first-party" as const } }; + writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); + + const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, chosen); + expect(enabled.status).toBe(200); + expect(enabled.body).toMatchObject({ ok: true, changed: true, state: "current", desiredEnabled: true }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode?.desktopMode).toBe("first-party"); + expect(saved.claudeCode?.desktopProfile?.appliedFingerprint).toBeUndefined(); + const status = await dispatch("/api/claude-desktop/status", {}, chosen); + expect(status.body).toMatchObject({ mode: "first-party", applied: true, drift: false }); + expect(["not_installed", "no_owned_state", "standard"]).toContain(status.body.observedKind); +}); + +test("native toggle: enabling into gateway saves the gateway mode marker like the apply route", async () => { + // No explicit mode: the disabled intercept is what implies gateway, so the saved marker + // can only come from the toggle itself. + const chosen = config({ claudeCode: { intercept: { enabled: false } } }); + writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); + const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, chosen); + expect(enabled.status).toBe(200); + expect(enabled.body).toMatchObject({ ok: true, state: "current", message: "Claude Desktop integration enabled." }); + expect(existsSync(join(claudeDir, "settings.json"))).toBe(false); + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode?.desktopMode).toBe("gateway"); + expect(resolveClaudeDesktopMode(saved)).toBe("gateway"); +}); + +test("ensure warns instead of touching a gateway profile that contradicts an explicit first-party marker", () => { + const logs: string[] = []; + const deps = { + loadConfig: () => config({ claudeCode: { desktopMode: "first-party" } }), + stripGrokConfig: () => ({ ok: true, changed: false, message: "" }), + syncGrokConfig: async () => ({ ok: true, changed: false, message: "" }), + removeDesktop3pStandardPivot: () => { throw new Error("must not pivot from ensure"); }, + inspectDesktop3pConfigLibrary: () => ({ kind: "gateway_ours" as const, libraryPath: library, activeProfilePath: null, ownedFiles: [] }), + applyDesktopFirstParty: () => { throw new Error("must not apply over a live gateway"); }, + log: (message: string) => { logs.push(message); }, + error: (message: string) => { logs.push(message); }, + }; + ensureClaudeDesktopMatchesDesired(deps as unknown as Parameters[0]); + expect(logs.some(line => line.includes("gateway profile is still applied"))).toBe(true); +}); + +test("ensure reconciles first-party env: refreshes when ON and stale, removes when OFF", () => { + const applied = applyDesktopFirstParty(config({ port: 10300 })); + expect(applied.ok).toBe(true); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10400"); + + const logs: string[] = []; + const deps = { + loadConfig: () => config(), + stripGrokConfig: () => ({ ok: true, changed: false, message: "" }), + syncGrokConfig: async () => ({ ok: true, changed: false, message: "" }), + removeDesktop3pStandardPivot: () => ({ ok: true as const, changed: false, kind: "noop" as const, libraryPath: library }), + log: (message: string) => { logs.push(message); }, + error: (message: string) => { logs.push(message); }, + }; + ensureClaudeDesktopMatchesDesired(deps); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + expect(logs.some(line => line.includes("first-party env refreshed"))).toBe(true); + + expect(setIntegrationEnabled("claude-desktop", false).ok).toBe(true); + ensureClaudeDesktopMatchesDesired({ ...deps, loadConfig: () => config({ clientIntegrations: { "claude-desktop": false } }) }); + expect(settings().env?.HTTPS_PROXY).toBeUndefined(); + expect(settings().env?.NODE_EXTRA_CA_CERTS).toBeUndefined(); +}); diff --git a/tests/claude-integration/claude-intercept-local-ca.test.ts b/tests/claude-integration/claude-intercept-local-ca.test.ts new file mode 100644 index 00000000000..474b25a1f7e --- /dev/null +++ b/tests/claude-integration/claude-intercept-local-ca.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { X509Certificate } from "node:crypto"; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + CLAUDE_INTERCEPT_CA_COMMON_NAME, + claudeInterceptCaCertPath, + claudeInterceptStateDir, + createLocalInterceptCa, + ensureLocalInterceptCa, + issueLocalInterceptLeaf, +} from "../../src/claude/intercept/local-ca"; + +function tmpConfigDir(): string { + return mkdtempSync(join(tmpdir(), "ocx-intercept-ca-")); +} + +test("CA certificate is a self-signed X.509 v3 authority", () => { + const ca = createLocalInterceptCa(); + const cert = new X509Certificate(ca.certPem); + expect(cert.ca).toBe(true); + expect(cert.subject).toContain(`CN=${CLAUDE_INTERCEPT_CA_COMMON_NAME}`); + expect(cert.issuer).toBe(cert.subject); + expect(cert.verify(ca.publicKey)).toBe(true); + expect(cert.checkIssued(cert)).toBe(true); + expect(new Date(cert.validFrom).getTime()).toBeLessThan(Date.now()); + expect(new Date(cert.validTo).getTime()).toBeGreaterThan(Date.now() + 365 * 24 * 3600 * 1000); +}); + +test("leaf is issued by the CA and names every requested host in SAN", () => { + const ca = createLocalInterceptCa(); + const leaf = issueLocalInterceptLeaf(ca, ["api.anthropic.com", "example.test"]); + const cert = new X509Certificate(leaf.certPem); + const caCert = new X509Certificate(ca.certPem); + expect(cert.ca).toBe(false); + expect(cert.checkIssued(caCert)).toBe(true); + expect(cert.verify(ca.publicKey)).toBe(true); + expect(cert.checkHost("api.anthropic.com")).toBe("api.anthropic.com"); + expect(cert.checkHost("example.test")).toBe("example.test"); + expect(cert.checkHost("other.example")).toBeUndefined(); + expect(cert.keyUsage).toContain("1.3.6.1.5.5.7.3.1"); + expect(leaf.keyPem).toContain("PRIVATE KEY"); +}); + +test("two CAs never share a serial or key", () => { + const a = createLocalInterceptCa(); + const b = createLocalInterceptCa(); + expect(new X509Certificate(a.certPem).serialNumber).not.toBe(new X509Certificate(b.certPem).serialNumber); + expect(a.keyPem).not.toBe(b.keyPem); +}); + +test("ensureLocalInterceptCa persists once and reloads the same authority", () => { + const configDir = tmpConfigDir(); + const first = ensureLocalInterceptCa(configDir); + const second = ensureLocalInterceptCa(configDir); + expect(second.certPem).toBe(first.certPem); + expect(second.keyPem).toBe(first.keyPem); + expect(readFileSync(claudeInterceptCaCertPath(configDir), "utf8")).toBe(first.certPem); + if (process.platform !== "win32") { + expect(statSync(join(claudeInterceptStateDir(configDir), "ca.key")).mode & 0o777).toBe(0o600); + expect(statSync(claudeInterceptCaCertPath(configDir)).mode & 0o777).toBe(0o644); + expect(statSync(claudeInterceptStateDir(configDir)).mode & 0o777).toBe(0o700); + } +}); + +test("a corrupt private key regenerates the authority instead of throwing", () => { + const configDir = tmpConfigDir(); + const first = ensureLocalInterceptCa(configDir); + writeFileSync(join(claudeInterceptStateDir(configDir), "ca.key"), "not a key\n"); + const regenerated = ensureLocalInterceptCa(configDir); + expect(regenerated.certPem).not.toBe(first.certPem); + expect(new X509Certificate(regenerated.certPem).ca).toBe(true); + expect(readFileSync(claudeInterceptCaCertPath(configDir), "utf8")).toBe(regenerated.certPem); +}); diff --git a/tests/claude-integration/claude-intercept-proxy.test.ts b/tests/claude-integration/claude-intercept-proxy.test.ts new file mode 100644 index 00000000000..c9b6db3fb33 --- /dev/null +++ b/tests/claude-integration/claude-intercept-proxy.test.ts @@ -0,0 +1,184 @@ +import { afterAll, expect, test } from "bun:test"; +import { connect, createServer } from "node:net"; +import { CLAUDE_INTERCEPT_HOSTS, isLoopbackTarget, parseConnectRequestLine, startConnectProxy, type ConnectProxyHandle } from "../../src/claude/intercept/connect-proxy"; +import { startClaudeInterceptListener, rewriteInterceptedRequest } from "../../src/claude/intercept/listener"; +import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; + +/** + * End-to-end shape of the intercept pair: a client that only knows `HTTPS_PROXY` and trusts the + * local CA reaches the router's Messages handler for `api.anthropic.com`, while every other + * CONNECT target is relayed blind. No real network: the "upstream" for blind tunnels is a local + * echo socket and the relay target for non-Messages paths is a local Bun server. + */ + +const cleanups: Array<() => Promise | void> = []; +afterAll(async () => { + for (const cleanup of cleanups.reverse()) await cleanup(); +}); + +test("parseConnectRequestLine accepts host:port and bracketed IPv6, rejects the rest", () => { + expect(parseConnectRequestLine("CONNECT api.anthropic.com:443 HTTP/1.1\r\nHost: x")).toEqual({ host: "api.anthropic.com", port: 443 }); + expect(parseConnectRequestLine("CONNECT API.Anthropic.COM.:443 HTTP/1.0")).toEqual({ host: "api.anthropic.com", port: 443 }); + expect(parseConnectRequestLine("CONNECT [::1]:8443 HTTP/1.1")).toEqual({ host: "::1", port: 8443 }); + expect(parseConnectRequestLine("GET http://example.com/ HTTP/1.1")).toBeNull(); + expect(parseConnectRequestLine("CONNECT example.com HTTP/1.1")).toBeNull(); + expect(parseConnectRequestLine("CONNECT example.com:99999 HTTP/1.1")).toBeNull(); +}); + +function rawRequest(port: number, payload: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect({ host: "127.0.0.1", port }, () => socket.write(payload)); + let out = ""; + socket.on("data", chunk => { out += chunk.toString("latin1"); }); + socket.on("end", () => resolve(out)); + socket.on("close", () => resolve(out)); + socket.on("error", reject); + }); +} + +async function startEchoUpstream(): Promise<{ port: number; close(): Promise }> { + const server = createServer(socket => socket.on("data", chunk => socket.write(`echo:${chunk.toString()}`))); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = address && typeof address === "object" ? address.port : 0; + return { port, close: () => new Promise(resolve => server.close(() => resolve())) }; +} + +async function startPair(): Promise<{ proxy: ConnectProxyHandle; ca: ReturnType; seen: Request[]; relayHits: string[] }> { + const ca = createLocalInterceptCa(); + const leaf = issueLocalInterceptLeaf(ca, CLAUDE_INTERCEPT_HOSTS); + const seen: Request[] = []; + const relayHits: string[] = []; + const fakeUpstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + relayHits.push(`${req.method} ${new URL(req.url).pathname} host=${req.headers.get("host")}`); + return new Response(JSON.stringify({ relayed: true }), { headers: { "content-type": "application/json", "x-upstream": "fake" } }); + }, + }); + cleanups.push(() => fakeUpstream.stop(true)); + const listener = startClaudeInterceptListener({ + leaf, + upstreamBase: `http://127.0.0.1:${fakeUpstream.port}`, + dispatch: async req => { + seen.push(req); + const body = await req.text(); + return Response.json({ dispatched: true, url: req.url, host: req.headers.get("host"), body }); + }, + }); + cleanups.push(() => listener.stop(true)); + const echo = await startEchoUpstream(); + cleanups.push(echo.close); + const proxy = await startConnectProxy(0, { + interceptPort: listener.port!, + dialUpstream: (host, port) => { + expect(host).toBe("telemetry.example"); + expect(port).toBe(443); + return connect({ host: "127.0.0.1", port: echo.port }); + }, + }); + cleanups.push(proxy.close); + return { proxy, ca, seen, relayHits }; +} + +/** Speak HTTPS to `api.anthropic.com` through the CONNECT proxy, trusting only the local CA. */ +async function viaProxy(proxyPort: number, caPem: string, method: string, path: string, body?: string): Promise<{ status: number; headers: Headers; body: string }> { + const res = await fetch(`https://api.anthropic.com${path}`, { + method, + headers: { "content-type": "application/json", "x-api-key": "sk-ant-test" }, + body, + proxy: `http://127.0.0.1:${proxyPort}`, + tls: { ca: caPem }, + }); + return { status: res.status, headers: res.headers, body: await res.text() }; +} + +test("CONNECT api.anthropic.com terminates TLS locally and dispatches Messages to the router", async () => { + const { proxy, ca, seen } = await startPair(); + const res = await viaProxy(proxy.port, ca.certPem, "POST", "/v1/messages?beta=true", JSON.stringify({ model: "claude-x" })); + expect(res.status).toBe(200); + const json = JSON.parse(res.body) as { dispatched: boolean; url: string; host: string; body: string }; + expect(json.dispatched).toBe(true); + expect(json.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/v1\/messages\?beta=true$/); + expect(json.host).toMatch(/^127\.0\.0\.1:\d+$/); + expect(json.body).toBe(JSON.stringify({ model: "claude-x" })); + expect(seen).toHaveLength(1); + expect(seen[0]!.headers.get("x-api-key")).toBe("sk-ant-test"); +}); + +test("non-Messages paths on the intercepted host are relayed to upstream, not dispatched", async () => { + const { proxy, ca, seen, relayHits } = await startPair(); + const res = await viaProxy(proxy.port, ca.certPem, "GET", "/v1/models?limit=1"); + expect(res.status).toBe(200); + expect(JSON.parse(res.body)).toEqual({ relayed: true }); + expect(res.headers.get("x-upstream")).toBe("fake"); + expect(relayHits).toEqual(["GET /v1/models host=127.0.0.1:" + relayHits[0]!.split(":").pop()]); + expect(seen).toHaveLength(0); +}); + +test("GET on /v1/messages is relayed rather than dispatched", async () => { + const { proxy, ca, seen, relayHits } = await startPair(); + await viaProxy(proxy.port, ca.certPem, "GET", "/v1/messages"); + expect(seen).toHaveLength(0); + expect(relayHits[0]).toStartWith("GET /v1/messages "); +}); + +test("other CONNECT targets are relayed blind, including pipelined bytes after the head", async () => { + const { proxy } = await startPair(); + const out = await new Promise((resolve, reject) => { + const socket = connect({ host: "127.0.0.1", port: proxy.port }, () => { + socket.write("CONNECT telemetry.example:443 HTTP/1.1\r\nHost: telemetry.example:443\r\n\r\nhello"); + }); + let buf = ""; + socket.on("data", chunk => { + buf += chunk.toString("latin1"); + if (buf.includes("echo:hello")) { socket.end(); resolve(buf); } + }); + socket.on("error", reject); + }); + expect(out.startsWith("HTTP/1.1 200 Connection Established\r\n\r\n")).toBe(true); + expect(out).toContain("echo:hello"); +}); + +test("plain proxied HTTP, loopback targets and oversized heads are refused", async () => { + const { proxy } = await startPair(); + expect(await rawRequest(proxy.port, "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n")).toStartWith("HTTP/1.1 405"); + expect(await rawRequest(proxy.port, "CONNECT 127.0.0.1:22 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); + expect(await rawRequest(proxy.port, "CONNECT localhost:443 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); + expect(await rawRequest(proxy.port, "CONNECT [::ffff:127.0.0.1]:22 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); + expect(await rawRequest(proxy.port, `CONNECT a:443 HTTP/1.1\r\nX: ${"y".repeat(9000)}`)).toStartWith("HTTP/1.1 431"); +}); + +test("isLoopbackTarget covers mapped, unspecified and shorthand loopback literals", () => { + for (const host of ["localhost", "foo.localhost", "127.0.0.1", "127.255.0.9", "::1", "::ffff:127.0.0.1", "::ffff:7f00:1", "0.0.0.0", "::", "127.1", "0x7f000001", "2130706433"]) { + expect(isLoopbackTarget(host)).toBe(true); + } + for (const host of ["api.anthropic.com", "10.0.0.1", "::ffff:10.0.0.1", "2606:4700::1", "1.example"]) { + expect(isLoopbackTarget(host)).toBe(false); + } +}); + +test("a dead upstream yields 502 instead of a hung tunnel", async () => { + const dead = await startEchoUpstream(); + await dead.close(); + const proxy = await startConnectProxy(0, { + interceptPort: 1, + dialUpstream: () => connect({ host: "127.0.0.1", port: dead.port }), + }); + cleanups.push(proxy.close); + expect(await rawRequest(proxy.port, "CONNECT gone.example:443 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 502"); +}); + +test("rewriteInterceptedRequest moves the request onto the loopback origin and keeps path, query and headers", () => { + const original = new Request("https://api.anthropic.com/v1/messages?x=1", { + method: "POST", + headers: { "anthropic-version": "2023-06-01", host: "api.anthropic.com" }, + body: "{}", + }); + const rewritten = rewriteInterceptedRequest(original, "http://127.0.0.1:4567"); + expect(rewritten.url).toBe("http://127.0.0.1:4567/v1/messages?x=1"); + expect(rewritten.headers.get("host")).toBe("127.0.0.1:4567"); + expect(rewritten.headers.get("anthropic-version")).toBe("2023-06-01"); + expect(rewritten.method).toBe("POST"); +}); diff --git a/tests/claude-integration/claude-intercept-settings.test.ts b/tests/claude-integration/claude-intercept-settings.test.ts new file mode 100644 index 00000000000..81ad3cb6b9a --- /dev/null +++ b/tests/claude-integration/claude-intercept-settings.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyClaudeInterceptSettings, + buildClaudeInterceptEnv, + inspectClaudeInterceptSettings, + removeClaudeInterceptSettings, +} from "../../src/claude/intercept/settings"; +import { claudeInterceptEnabled, claudeInterceptProxyPort } from "../../src/claude/intercept/runtime"; +import { configSchema } from "../../src/config/schema/config-schema"; + +const CA = "/home/u/.opencodex/claude-intercept/ca.pem"; +const env = buildClaudeInterceptEnv(8846, CA); + +function dir(): string { + return mkdtempSync(join(tmpdir(), "ocx-intercept-settings-")); +} + +function readSettings(configDir: string): Record { + return JSON.parse(readFileSync(join(configDir, "settings.json"), "utf8")) as Record; +} + +test("env block shape", () => { + expect(env).toEqual({ HTTPS_PROXY: "http://127.0.0.1:8846", NODE_EXTRA_CA_CERTS: CA }); +}); + +test("apply creates settings.json when absent and is idempotent", () => { + const configDir = dir(); + expect(inspectClaudeInterceptSettings(env, configDir)).toEqual({ kind: "absent" }); + expect(applyClaudeInterceptSettings(env, configDir)).toMatchObject({ ok: true, changed: true }); + expect(readSettings(configDir)).toEqual({ env }); + expect(applyClaudeInterceptSettings(env, configDir)).toMatchObject({ ok: true, changed: false }); + expect(inspectClaudeInterceptSettings(env, configDir)).toEqual({ kind: "applied", env }); +}); + +test("apply preserves unrelated settings and env keys", () => { + const configDir = dir(); + writeFileSync(join(configDir, "settings.json"), JSON.stringify({ model: "opus", env: { ANTHROPIC_MODEL: "gpt-x", FOO: "1" }, permissions: { allow: [] } })); + applyClaudeInterceptSettings(env, configDir); + expect(readSettings(configDir)).toEqual({ + model: "opus", + env: { ANTHROPIC_MODEL: "gpt-x", FOO: "1", ...env }, + permissions: { allow: [] }, + }); +}); + +test("a previous port is stale and gets rewritten; a foreign proxy is left alone", () => { + const configDir = dir(); + writeFileSync(join(configDir, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://127.0.0.1:9000", NODE_EXTRA_CA_CERTS: CA } })); + expect(inspectClaudeInterceptSettings(env, configDir)).toEqual({ kind: "stale", env: { HTTPS_PROXY: "http://127.0.0.1:9000", NODE_EXTRA_CA_CERTS: CA } }); + expect(applyClaudeInterceptSettings(env, configDir)).toMatchObject({ ok: true, changed: true }); + expect(readSettings(configDir)).toEqual({ env }); + + const foreign = dir(); + writeFileSync(join(foreign, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://corp-proxy:3128" } })); + expect(inspectClaudeInterceptSettings(env, foreign).kind).toBe("foreign"); + expect(applyClaudeInterceptSettings(env, foreign)).toMatchObject({ ok: false, reason: "foreign_env" }); + expect(readSettings(foreign)).toEqual({ env: { HTTPS_PROXY: "http://corp-proxy:3128" } }); + + // A loopback proxy with someone else's CA is not ours either. + const otherCa = dir(); + writeFileSync(join(otherCa, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://127.0.0.1:8080", NODE_EXTRA_CA_CERTS: "/etc/mitm/ca.pem" } })); + expect(inspectClaudeInterceptSettings(env, otherCa).kind).toBe("foreign"); +}); + +test("remove deletes only owned values and drops an emptied env block", () => { + const configDir = dir(); + writeFileSync(join(configDir, "settings.json"), JSON.stringify({ model: "opus", env: { ...env } })); + expect(removeClaudeInterceptSettings(CA, configDir)).toMatchObject({ ok: true, changed: true }); + expect(readSettings(configDir)).toEqual({ model: "opus" }); + expect(removeClaudeInterceptSettings(CA, configDir)).toMatchObject({ ok: true, changed: false }); + + const foreign = dir(); + writeFileSync(join(foreign, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://127.0.0.1:8080", NODE_EXTRA_CA_CERTS: "/etc/mitm/ca.pem" } })); + expect(removeClaudeInterceptSettings(CA, foreign)).toMatchObject({ ok: true, changed: false }); + expect(readSettings(foreign)).toEqual({ env: { HTTPS_PROXY: "http://127.0.0.1:8080", NODE_EXTRA_CA_CERTS: "/etc/mitm/ca.pem" } }); + + const missing = dir(); + expect(removeClaudeInterceptSettings(CA, missing)).toMatchObject({ ok: true, changed: false }); +}); + +test("corrupt settings.json is reported, never overwritten", () => { + const configDir = dir(); + writeFileSync(join(configDir, "settings.json"), "{ not json"); + expect(inspectClaudeInterceptSettings(env, configDir).kind).toBe("unreadable"); + expect(applyClaudeInterceptSettings(env, configDir)).toMatchObject({ ok: false, reason: "unreadable" }); + expect(removeClaudeInterceptSettings(CA, configDir)).toMatchObject({ ok: false, reason: "unreadable" }); + expect(readFileSync(join(configDir, "settings.json"), "utf8")).toBe("{ not json"); +}); + +test("intercept is on by default for a hub, off for clients and when Claude Code is disabled", () => { + expect(claudeInterceptEnabled({})).toBe(true); + expect(claudeInterceptEnabled({ claudeCode: { intercept: { enabled: false } } })).toBe(false); + expect(claudeInterceptEnabled({ claudeCode: { enabled: false } })).toBe(false); + expect(claudeInterceptEnabled({ runtimeRole: "client" })).toBe(false); + expect(claudeInterceptProxyPort({}, 8746)).toBe(8846); + expect(claudeInterceptProxyPort({ claudeCode: { intercept: { port: 9100 } } }, 8746)).toBe(9100); + expect(claudeInterceptProxyPort({ claudeCode: { intercept: { port: 0 } } }, 8746)).toBe(8846); +}); + +test("claudeCode.intercept is validated by the config schema", () => { + const base = { + port: 0, defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" } }, + }; + expect(configSchema.safeParse({ ...base, claudeCode: { intercept: { enabled: false, port: 9100 } } }).success).toBe(true); + expect(configSchema.safeParse({ ...base, claudeCode: { intercept: "off" } }).success).toBe(false); + expect(configSchema.safeParse({ ...base, claudeCode: { intercept: { enabled: "no" } } }).success).toBe(false); + expect(configSchema.safeParse({ ...base, claudeCode: { intercept: { port: 70000 } } }).success).toBe(false); +}); diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index 60bc012fa25..751b838ee99 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -707,7 +707,11 @@ test("Claude Desktop profile GET, PUT and apply round-trip four-family assignmen const discovery = await fetch(new URL("/v1/models?flavor=anthropic", server.url)).then(r => r.json()) as { data: Array<{ id: string }> }; expect(discovery.data.some(model => model.id === alias)).toBe(true); - const apply = await fetch(new URL("/api/claude-desktop/apply", server.url), { method: "POST" }); + const apply = await fetch(new URL("/api/claude-desktop/apply", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "gateway" }), + }); expect(apply.status).toBe(200); const result = await apply.json() as { path: string; applied: boolean }; expect(result.applied).toBe(true); @@ -815,6 +819,14 @@ test("Claude Desktop apply validates the mode body", async () => { expect(badProfile.status).toBe(400); expect(loadConfig()).toEqual(beforeBadProfile); + const badGatewayProfile = await fetch(new URL("/api/claude-desktop/apply", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "gateway", profile: { version: 2 } }), + }); + expect(badGatewayProfile.status).toBe(400); + expect(loadConfig()).toEqual(beforeBadProfile); + const hybrid = await fetch(new URL("/api/claude-desktop/apply", server.url), { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/tests/codex-integration/native-claude-desktop-toggle.test.ts b/tests/codex-integration/native-claude-desktop-toggle.test.ts index bc579cdffbf..56c28ca34da 100644 --- a/tests/codex-integration/native-claude-desktop-toggle.test.ts +++ b/tests/codex-integration/native-claude-desktop-toggle.test.ts @@ -14,12 +14,16 @@ let root = ""; let library = ""; let previousHome: string | undefined; let previousLibrary: string | undefined; +let previousClaudeConfigDir: string | undefined; +// These tests pin the gateway (third-party profile) path; first-party is covered by +// tests/claude-integration/claude-desktop-first-party.test.ts. function config(): OcxConfig { return { port: 10100, providers: {}, defaultProvider: "openai", + claudeCode: { desktopMode: "gateway" }, } as OcxConfig; } @@ -84,8 +88,10 @@ beforeEach(() => { library = join(root, "desktop-library"); previousHome = process.env.OPENCODEX_HOME; previousLibrary = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; process.env.OPENCODEX_HOME = root; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); writeFileSync(join(root, "config.json"), JSON.stringify(config())); }); @@ -94,6 +100,8 @@ afterEach(() => { else process.env.OPENCODEX_HOME = previousHome; if (previousLibrary === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousLibrary; + if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir; removeTreeWithRetry(root); }); @@ -237,7 +245,7 @@ test("auto-apply re-reads desired state after catalog fetch and skips a concurre assignments: {}, defaults: { opus: null, fable: null, sonnet: null, haiku: null }, }; - const persisted = { ...config(), claudeCode: { desktopProfile: profile, injectAgents: false } }; + const persisted = { ...config(), claudeCode: { desktopMode: "gateway" as const, desktopProfile: profile, injectAgents: false } }; writeFileSync(join(root, "config.json"), JSON.stringify(persisted)); writeFileSync(join(root, "config.json.bak"), JSON.stringify(persisted)); writeFileSync(join(root, "config.json"), JSON.stringify(persisted)); @@ -302,7 +310,7 @@ test("explicit enable re-reads desired state after catalog fetch and skips a con }); test("explicit enable honors the Claude Desktop native-model opt-out", async () => { - const persisted = { ...config(), claudeCode: { desktopNativeModels: false } }; + const persisted = { ...config(), claudeCode: { desktopMode: "gateway" as const, desktopNativeModels: false } }; writeFileSync(join(root, "config.json"), JSON.stringify(persisted)); let nativeSlugs: string[] | undefined; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e72ed9991e9..454504049af 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1432,5 +1432,10 @@ "api-key-scope-images.test.ts": "server", "api-key-scope-audio.test.ts": "server", "api-key-scope-live.test.ts": "server", - "api-key-scope-alpha-search.test.ts": "server" + "api-key-scope-alpha-search.test.ts": "server", + "claude-intercept-local-ca.test.ts": "claude-integration", + "claude-intercept-proxy.test.ts": "claude-integration", + "claude-intercept-settings.test.ts": "claude-integration", + "claude-desktop-first-party.test.ts": "claude-integration", + "claude-intercept-integration.test.ts": "server" } diff --git a/tests/lab/core-lab-boundary.test.ts b/tests/lab/core-lab-boundary.test.ts index 32a5a9758fc..3f928d27a83 100644 --- a/tests/lab/core-lab-boundary.test.ts +++ b/tests/lab/core-lab-boundary.test.ts @@ -1021,6 +1021,7 @@ describe("activation window stays synchronous", () => { "(...).then()": "Promise.then on the fire-and-forget `import('../codex/plan-from-token')` chain. then() registers a callback and returns immediately; the callback is a nested function this scan skips. Awaiting the import would already fail Guard 3.", "(...).catch()": "Promise.catch on that same dynamic-import chain. Same fire-and-forget: it cannot suspend startServer.", "backgroundLifecycle.scheduleStartupRun()": "src/server/background-lifecycle.ts owns this object method. The call site cannot resolve the declaration statically; scheduleStartupRun is declared `(): void` and is documented as never blocking listen.", + "claudeIntercept.start()": "Instance method on the lifecycle from createClaudeInterceptLifecycle in src/server/index/claude-intercept-lifecycle.ts. Declared `(options): void`; it stores the fire-and-forget promise from startClaudeIntercept and never awaits it, so the intercept pair binding late (or failing) cannot suspend startServer. Its stop() runs inside the async stop wrapper, which this scan skips.", "spendLedgerLifecycle.track()": "Instance method on the lifecycle from acquireSpendLedgerServerLifecycle in src/server/index/spend-ledger-lifecycle.ts, called on each listener as it is created. It binds the listener's stop, records a rollback closure and returns the same server; it is declared `(server: T): T` and contains no await. An `await spendLedgerLifecycle.track(...)` would already fail Guard 3. The lifecycle's release() is not here because it is called inside the async stop wrapper, which this scan skips as a nested function.", }; diff --git a/tests/server/claude-intercept-integration.test.ts b/tests/server/claude-intercept-integration.test.ts new file mode 100644 index 00000000000..6dc046fe1eb --- /dev/null +++ b/tests/server/claude-intercept-integration.test.ts @@ -0,0 +1,157 @@ +/** + * The Claude intercept pair wired into a real `startServer`: a client configured with nothing + * but `HTTPS_PROXY` and the local CA reaches the router's Messages handler under the loopback + * policy, while every other path on the intercepted host is relayed to the configured upstream + * and never touches the router's own routes. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { findAvailablePort } from "../../src/server/ports"; +import { claudeInterceptCaCertPath } from "../../src/claude/intercept/local-ca"; +import { getClaudeInterceptState } from "../../src/claude/intercept/runtime"; +import type { OcxConfig } from "../../src/types"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const previousHome = process.env.OPENCODEX_HOME; +let testDir = ""; + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "ocx-claude-intercept-")); + process.env.OPENCODEX_HOME = testDir; + process.env.OPENCODEX_API_AUTH_TOKEN = "public-secret"; +}); + +afterEach(() => { + if (previousApiToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiToken; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir && existsSync(testDir)) removeTreeWithRetry(testDir); + testDir = ""; +}); + +async function waitForIntercept(): Promise>> { + for (let i = 0; i < 100; i++) { + const state = getClaudeInterceptState(); + if (state) return state; + await Bun.sleep(20); + } + throw new Error("intercept pair did not start"); +} + +test("Messages through CONNECT reach the router; other paths relay to the configured upstream", async () => { + const upstreamHits: string[] = []; + const fakeUpstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + upstreamHits.push(`${req.method} ${new URL(req.url).pathname}`); + return Response.json({ upstream: true }); + }, + }); + const interceptPort = await findAvailablePort(0, "127.0.0.1"); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: interceptPort }); + saveConfig({ + port: publicPort, + hostname: "127.0.0.1", + defaultProvider: "chatgpt", + providers: { + chatgpt: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + claudeCode: { + anthropicBaseUrl: `http://127.0.0.1:${fakeUpstream.port}`, + intercept: { port: interceptPort }, + }, + } as unknown as OcxConfig); + const server = startServer(publicPort); + try { + const state = await waitForIntercept(); + expect(state.proxyPort).toBe(interceptPort); + expect(state.caCertPath).toBe(claudeInterceptCaCertPath(testDir)); + const ca = readFileSync(state.caCertPath, "utf8"); + const proxy = `http://127.0.0.1:${state.proxyPort}`; + + // No opencodex admission token is sent: the intercept ingress takes the loopback policy, so + // the request is judged by the Messages handler (which fails on routing, since the test + // config has no usable provider credential) rather than refused at admission. + const messages = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + proxy, + tls: { ca }, + headers: { "content-type": "application/json", "anthropic-version": "2023-06-01", "x-api-key": "sk-ant-not-real" }, + body: JSON.stringify({ model: "no-such-model-for-intercept-test", max_tokens: 8, messages: [{ role: "user", content: "hi" }] }), + }); + const messagesBody = await messages.json() as { type: string; error: { message: string } }; + expect(messages.headers.get("content-type")).toContain("application/json"); + expect(messagesBody.type).toBe("error"); + expect(messagesBody.error.message).not.toContain("opencodex API key required"); + expect(messagesBody.error.message).not.toContain("Unknown endpoint"); + expect(upstreamHits).toEqual([]); + + // Anything else on the intercepted host is the client's own business with Anthropic. + const models = await fetch("https://api.anthropic.com/v1/models", { proxy, tls: { ca } }); + expect(await models.json()).toEqual({ upstream: true }); + const health = await fetch("https://api.anthropic.com/healthz", { proxy, tls: { ca } }); + expect(await health.json()).toEqual({ upstream: true }); + expect(upstreamHits).toEqual(["GET /v1/models", "GET /healthz"]); + } finally { + await server.stop(true); + fakeUpstream.stop(true); + } + expect(getClaudeInterceptState()).toBeNull(); +}, SERVER_BUDGET_MS); + +test("an ephemeral public port starts no proxy unless intercept.port is explicit", async () => { + const base = { + hostname: "127.0.0.1", + defaultProvider: "chatgpt", + providers: { + chatgpt: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + }; + saveConfig({ ...base, port: 10100 } as unknown as OcxConfig); + const implicit = startServer(0); + try { + await Bun.sleep(100); + expect(getClaudeInterceptState()).toBeNull(); + } finally { + await implicit.stop(true); + } + + 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); + try { + const state = await waitForIntercept(); + expect(state.proxyPort).toBe(proxyPort); + } finally { + await explicit.stop(true); + } +}, SERVER_BUDGET_MS); + +test("intercept.enabled=false starts no proxy", async () => { + const publicPort = await findAvailablePort(0, "127.0.0.1"); + saveConfig({ + port: publicPort, + hostname: "127.0.0.1", + defaultProvider: "chatgpt", + providers: { + chatgpt: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + claudeCode: { intercept: { enabled: false } }, + } as unknown as OcxConfig); + const server = startServer(publicPort); + try { + await Bun.sleep(100); + expect(getClaudeInterceptState()).toBeNull(); + expect(existsSync(join(testDir, "claude-intercept"))).toBe(false); + } finally { + await server.stop(true); + } +}, SERVER_BUDGET_MS);