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 797f9d3554..9899797ea6 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -67,6 +67,32 @@ ignore les identifiants Anthropic introduits uniquement par un fichier dotenv du dans votre shell reste toujours prioritaire, quel que soit le mode d'authentification. Pour utiliser volontairement une clé API, exportez-la (`export ANTHROPIC_API_KEY=...`) au lieu de la laisser dans un fichier de projet. +### Lancement natif de repli quand le routage Claude est désactivé + +`ocx claude` échouait auparavant avec une erreur lorsque le routage Claude était désactivé. Il lance +désormais le binaire natif `claude` à la place, de sorte que la commande reste utile routage coupé : + +| Où le routage est désactivé | Ce qui se passe | +| --- | --- | +| `claudeCode.enabled: false` dans la configuration | Lancement natif, avec un avis indiquant que le routage est désactivé | +| Le proxy en cours renvoie `enabled: false` depuis `GET /api/claude-code` | Lancement natif, avec un avis de redémarrer le service après réactivation | +| `claudeCode.enabled` absent ou `true` | Routage par le proxy, inchangé | + +Seul un `false` explicite déclenche le repli : un proxy antérieur à ce champ reste donc routé. Un proxy +absent n'est pas non plus un déclencheur — routage activé, `ocx claude` démarre toujours le proxy. + +Une session native ne doit pas hériter de l'état du proxy. Le repli supprime donc uniquement les valeurs +dont OpenCodex peut **prouver** la propriété : `ANTHROPIC_BASE_URL` seulement lorsqu'elle pointe vers +l'adresse de bouclage et le port configuré de ce proxy *et* que le jeton d'admission associé a bien été +émis par lui ; les leviers `CLAUDE_CODE_*` de découverte et d'auto-contexte ; et les emplacements de +modèle qui ne se résolvent qu'à travers le proxy (alias routés et identifiants `provider/model`). Tout +le reste vous appartient et est préservé — une passerelle `http://localhost:8080` sans rapport et vos +propres identifiants `sk-ant-` survivent tous les deux. + +Si le modèle par défaut enregistré dans le sélecteur `/model` est réservé au proxy, la session native +bascule sur `claudeCode.model` lorsque celui-ci est utilisable nativement, et vous avertit sinon de +passer `--model `. Un argument `--model` explicite l'emporte toujours. + ## Mode d'authentification Claude Code a besoin d'un jeton dans `ANTHROPIC_AUTH_TOKEN` pour communiquer avec une passerelle, mais définir cette diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 264d5d6fea..9dafc37c21 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -72,6 +72,31 @@ ignores Anthropic credentials that only a project dotenv introduced. A value you your shell still wins, in every auth mode. To use an API key deliberately, export it (`export ANTHROPIC_API_KEY=...`) rather than leaving it in a project file. +### Native fallback when Claude routing is off + +`ocx claude` used to exit with an error when Claude routing was disabled. It now launches the +native `claude` binary instead, so the command stays useful with routing off: + +| Where routing is off | What happens | +| --- | --- | +| `claudeCode.enabled: false` in config | Native launch, with a notice that routing is disabled | +| The running proxy reports `enabled: false` from `GET /api/claude-code` | Native launch, with a notice to restart the service after re-enabling | +| `claudeCode.enabled` absent or `true` | Routed through the proxy, unchanged | + +Only an explicit `false` triggers the fallback, so a proxy predating the field stays routed. A +missing proxy is not a trigger either — with routing on, `ocx claude` still starts the proxy. + +A native session must not inherit proxy state, so the fallback removes values it can **prove** +OpenCodex owns: `ANTHROPIC_BASE_URL` only when it points at this proxy's own loopback address +and configured port *and* the paired admission token is one the proxy issued; the +`CLAUDE_CODE_*` discovery and auto-context levers; and model slots that only resolve through the +proxy (routed aliases and `provider/model` ids). Anything else is yours and is preserved — an +unrelated `http://localhost:8080` gateway and your own `sk-ant-` credential both survive. + +If your saved `/model` picker default is a proxy-only model, the native session falls back to +`claudeCode.model` when that is natively usable, and otherwise warns you to pass +`--model `. An explicit `--model` argument always wins. + ## Auth mode Claude Code needs a token in `ANTHROPIC_AUTH_TOKEN` to talk to a gateway, but setting that 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 bc7e84e39d..6d45bf63af 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -28,6 +28,33 @@ ocx claude | `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | `maxContextTokens` が設定された場合の従来コンテキスト上書き値 (条件付き) | 直接 export した変数が常に優先します。追加引数はそのまま渡されます: `ocx claude -p "hello"`。 +### Claude ルーティングが無効なときのネイティブフォールバック + +以前は Claude ルーティングが無効だと `ocx claude` はエラーで終了していました。現在は代わりに +ネイティブの `claude` バイナリを起動するため、ルーティングを切ったままでもこのコマンドを使えます。 + +| ルーティングが無効な場所 | 動作 | +| --- | --- | +| 設定の `claudeCode.enabled: false` | ルーティングが無効である旨の通知とともにネイティブ起動 | +| 実行中のプロキシが `GET /api/claude-code` で `enabled: false` を返す | ネイティブ起動 + 有効化後にサービスを再起動する案内 | +| `claudeCode.enabled` が無い、または `true` | 従来どおりプロキシ経由でルーティング | + +明示的な `false` のみがフォールバックの条件なので、このフィールドを持たない古いプロキシは +ルーティングのままです。プロキシが無いことも条件ではありません — ルーティングが有効なら +`ocx claude` はこれまでどおりプロキシを起動します。 + +ネイティブセッションがプロキシの状態を引き継いではならないため、フォールバックは OpenCodex の +所有だと**証明できる**値だけを削除します。`ANTHROPIC_BASE_URL` はこのプロキシ自身のループバック +アドレスと設定済みポートを指し、かつ対になる admission トークンがプロキシの発行したものである場合 +のみ削除します。加えて `CLAUDE_CODE_*` の検出・自動コンテキスト用スイッチと、プロキシ経由でしか +解決しないモデルスロット(ルーティング用エイリアスと `provider/model` 形式)も削除します。それ以外 +はあなたの値なので保持されます — 無関係な `http://localhost:8080` ゲートウェイと自分の +`sk-ant-` 資格情報はどちらも残ります。 + +保存された `/model` ピッカーの既定値がプロキシ専用モデルの場合、`claudeCode.model` がネイティブ +で使えるならそれにフォールバックし、使えなければ `--model ` を渡すよう警告します。 +明示的な `--model` 引数が常に優先します。 + ## システム環境統合(macOS) `claudeCode.systemEnv` を `true` に設定すると(デフォルト: **オフ`)`ocx start` が `launchctl setenv` を 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 0183b5b72f..a7ed659c59 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -28,6 +28,31 @@ ocx claude | `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | `maxContextTokens`가 설정된 경우 기존 컨텍스트 재정의 값 (조건부) | 직접 내보낸 변수가 항상 우선해요. 추가 인자는 그대로 전달돼요: `ocx claude -p "hello"`. +### Claude 라우팅이 꺼져 있을 때의 네이티브 폴백 + +예전에는 Claude 라우팅이 꺼져 있으면 `ocx claude`가 오류를 내고 종료했어요. 이제는 네이티브 +`claude` 실행 파일을 대신 실행하므로, 라우팅을 꺼 둔 상태에서도 이 명령을 그대로 쓸 수 있어요. + +| 라우팅이 꺼진 위치 | 동작 | +| --- | --- | +| 설정의 `claudeCode.enabled: false` | 라우팅이 비활성화되었다는 안내와 함께 네이티브 실행 | +| 실행 중인 프록시가 `GET /api/claude-code`에서 `enabled: false`를 보고 | 네이티브 실행 + 라우팅을 켠 뒤 서비스를 재시작하라는 안내 | +| `claudeCode.enabled`가 없거나 `true` | 기존과 동일하게 프록시로 라우팅 | + +명시적인 `false`만 폴백을 유발하므로, 이 필드를 모르는 예전 프록시는 계속 라우팅돼요. 프록시가 +없는 것도 폴백 조건이 아니에요 — 라우팅이 켜져 있으면 `ocx claude`가 프록시를 그대로 띄워요. + +네이티브 세션이 프록시 상태를 물려받으면 안 되므로, 폴백은 OpenCodex 소유임을 **증명할 수 있는** +값만 제거해요. `ANTHROPIC_BASE_URL`은 이 프록시의 루프백 주소와 설정된 포트를 정확히 가리키고 +짝이 되는 admission 토큰도 프록시가 발급한 것일 때만 제거하고, `CLAUDE_CODE_*` 검색·자동 컨텍스트 +레버와 프록시를 거쳐야만 해석되는 모델 슬롯(라우팅 별칭과 `provider/model` 형식)도 제거해요. +그 밖의 값은 사용자 것이라 그대로 유지돼요 — 관련 없는 `http://localhost:8080` 게이트웨이와 +직접 설정한 `sk-ant-` 자격 증명은 둘 다 살아남아요. + +저장된 `/model` 선택기 기본값이 프록시 전용 모델이면, `claudeCode.model`이 네이티브에서 쓸 수 +있을 때 그 값으로 대체하고, 그렇지 않으면 `--model `을 넘기라고 경고해요. +명시적인 `--model` 인자가 항상 우선해요. + ## 인증 모드 Claude Code가 게이트웨이와 통신하려면 `ANTHROPIC_AUTH_TOKEN`에 토큰이 필요해요. 그런데 이 변수를 diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index b04612625b..3f4285cfe6 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -29,6 +29,34 @@ ocx claude | `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | Устаревшее переопределение контекста, когда задан `maxContextTokens` (условно) | Переменные, которые вы экспортируете сами, всегда имеют приоритет. Дополнительные аргументы передаются как есть: `ocx claude -p "hello"`. +### Нативный запасной запуск, когда маршрутизация Claude выключена + +Раньше `ocx claude` завершался с ошибкой, если маршрутизация Claude была выключена. Теперь вместо +этого запускается нативный бинарник `claude`, поэтому команда остаётся полезной и с выключенной +маршрутизацией: + +| Где выключена маршрутизация | Что происходит | +| --- | --- | +| `claudeCode.enabled: false` в конфигурации | Нативный запуск с уведомлением, что маршрутизация выключена | +| Запущенный прокси возвращает `enabled: false` из `GET /api/claude-code` | Нативный запуск и совет перезапустить службу после включения | +| `claudeCode.enabled` отсутствует или равен `true` | Маршрутизация через прокси, без изменений | + +Запасной запуск включает только явное `false`, поэтому прокси, не знающий об этом поле, остаётся +маршрутизируемым. Отсутствие прокси тоже не является триггером — при включённой маршрутизации +`ocx claude` по-прежнему запускает прокси. + +Нативная сессия не должна наследовать состояние прокси, поэтому запасной запуск удаляет только те +значения, принадлежность которых OpenCodex может **доказать**: `ANTHROPIC_BASE_URL` — лишь когда он +указывает на собственный локальный адрес и настроенный порт этого прокси, а парный admission-токен +выдан самим прокси; переключатели обнаружения и автоконтекста `CLAUDE_CODE_*`; и слоты моделей, +которые разрешаются только через прокси (маршрутные псевдонимы и форма `provider/model`). Всё +остальное — ваше и сохраняется: посторонний шлюз `http://localhost:8080` и ваши собственные +учётные данные `sk-ant-` остаются на месте. + +Если сохранённый выбор в селекторе `/model` — модель только для прокси, нативная сессия перейдёт на +`claudeCode.model`, когда та доступна нативно, а иначе предупредит передать +`--model <модель Anthropic>`. Явный аргумент `--model` всегда имеет приоритет. + ## Интеграция с системным окружением (macOS) Когда `claudeCode.systemEnv` установлен в `true` (по умолчанию: **выключено**), `ocx start` diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index bf955fba97..2b092dc621 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -80,6 +80,37 @@ kimlik doğrulama modunda her zaman geçerlidir. Bir API anahtarını kasıtlı kullanmak için, onu bir proje dosyasında bırakmak yerine dışa aktarın (`export ANTHROPIC_API_KEY=...`). +### Claude yönlendirmesi kapalıyken yerel geri dönüş + +`ocx claude` eskiden Claude yönlendirmesi kapalıyken hata vererek çıkardı. +Artık bunun yerine yerel `claude` ikili dosyasını başlatır; böylece komut, +yönlendirme kapalıyken de kullanışlı kalır: + +| Yönlendirmenin kapalı olduğu yer | Ne olur | +| --- | --- | +| Yapılandırmada `claudeCode.enabled: false` | Yönlendirmenin kapalı olduğunu bildiren bir uyarıyla yerel başlatma | +| Çalışan vekil `GET /api/claude-code` üzerinden `enabled: false` bildiriyor | Yerel başlatma ve yeniden etkinleştirdikten sonra servisi yeniden başlatma önerisi | +| `claudeCode.enabled` yok veya `true` | Değişmeden vekil üzerinden yönlendirme | + +Geri dönüşü yalnızca açık bir `false` tetikler; bu alandan önceki bir vekil +yönlendirilmiş kalır. Vekilin bulunmaması da bir tetikleyici değildir — +yönlendirme açıkken `ocx claude` vekili yine başlatır. + +Yerel bir oturum vekil durumunu devralmamalıdır; bu nedenle geri dönüş yalnızca +OpenCodex'in sahipliğini **kanıtlayabildiği** değerleri kaldırır: +`ANTHROPIC_BASE_URL` yalnızca bu vekilin kendi geri döngü adresini ve +yapılandırılmış bağlantı noktasını gösteriyorsa *ve* eşlenmiş kabul belirteci +vekilin verdiği bir belirteçse; `CLAUDE_CODE_*` keşif ve otomatik bağlam +anahtarları; ve yalnızca vekil üzerinden çözülen model yuvaları (yönlendirme +takma adları ve `provider/model` kimlikleri). Geri kalan her şey sizindir ve +korunur — ilgisiz bir `http://localhost:8080` ağ geçidi ve kendi `sk-ant-` +kimlik bilginiz birlikte hayatta kalır. + +Kaydedilmiş `/model` seçici varsayılanınız yalnızca vekile özgü bir modelse, +yerel oturum `claudeCode.model` yerel olarak kullanılabildiğinde ona döner; +aksi hâlde `--model ` geçmeniz için uyarır. Açık bir +`--model` argümanı her zaman kazanır. + ## Kimlik doğrulama modu (Auth mode) Claude Code'un bir ağ geçidiyle konuşabilmesi için `ANTHROPIC_AUTH_TOKEN` içinde diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index e5140cb8a7..e2db5ddec4 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -28,6 +28,29 @@ ocx claude | `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | 设置 `maxContextTokens` 时使用的旧版上下文覆盖项(条件注入) | 你自行导出的变量始终优先。额外参数会直接透传:`ocx claude -p "hello"`。 +### Claude 路由关闭时的原生回退 + +以前当 Claude 路由被关闭时,`ocx claude` 会直接报错退出。现在它会改为启动原生 `claude` +可执行文件,因此在关闭路由的情况下该命令依然可用: + +| 路由关闭的位置 | 行为 | +| --- | --- | +| 配置中的 `claudeCode.enabled: false` | 原生启动,并提示路由已被禁用 | +| 运行中的代理在 `GET /api/claude-code` 中返回 `enabled: false` | 原生启动,并提示重新启用后重启服务 | +| `claudeCode.enabled` 缺失或为 `true` | 与以往一致,经代理路由 | + +只有显式的 `false` 才会触发回退,因此早于该字段的旧代理仍会保持路由。代理缺失同样不是触发条件 +——只要路由是开启的,`ocx claude` 仍会照常启动代理。 + +原生会话不应继承代理状态,因此回退只移除能够**证明**属于 OpenCodex 的值:仅当 +`ANTHROPIC_BASE_URL` 指向本代理自身的回环地址与配置端口、且配对的 admission 令牌确实由代理签发 +时才移除;此外还会移除 `CLAUDE_CODE_*` 的发现与自动上下文开关,以及只能经由代理解析的模型槽位 +(路由别名与 `provider/model` 形式)。其余都属于你自己的配置并被保留——无关的 +`http://localhost:8080` 网关和你自己的 `sk-ant-` 凭据都会保留。 + +如果保存的 `/model` 选择器默认值是仅限代理的模型,当 `claudeCode.model` 可在原生环境使用时会 +回退到它,否则会警告你传入 `--model `。显式的 `--model` 参数始终优先。 + ## 系统环境集成(macOS) 当 `claudeCode.systemEnv` 设置为 `true`(默认:**关闭**)时,`ocx start` 会使用 `launchctl setenv` diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 7aea715953..f07b2f97c6 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -54,6 +54,29 @@ ocx claude | `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | 設定 `maxContextTokens` 時使用的舊版上下文覆蓋項(條件注入) | 你自行匯出的變數始終優先。額外引數會直接透傳:`ocx claude -p "hello"`。 +### Claude 路由關閉時的原生回退 + +以前當 Claude 路由被關閉時,`ocx claude` 會直接報錯結束。現在它會改為啟動原生 `claude` +執行檔,因此在關閉路由的情況下該指令仍然可用: + +| 路由關閉的位置 | 行為 | +| --- | --- | +| 設定中的 `claudeCode.enabled: false` | 原生啟動,並提示路由已停用 | +| 執行中的代理在 `GET /api/claude-code` 回傳 `enabled: false` | 原生啟動,並提示重新啟用後重啟服務 | +| `claudeCode.enabled` 缺少或為 `true` | 與以往一致,經代理路由 | + +只有明確的 `false` 才會觸發回退,因此早於該欄位的舊代理仍會維持路由。代理不存在同樣不是觸發 +條件——只要路由是開啟的,`ocx claude` 仍會照常啟動代理。 + +原生工作階段不應繼承代理狀態,因此回退只移除能夠**證明**屬於 OpenCodex 的值:僅當 +`ANTHROPIC_BASE_URL` 指向本代理自身的回送位址與設定連接埠、且配對的 admission token 確實由代理 +簽發時才移除;此外還會移除 `CLAUDE_CODE_*` 的探索與自動上下文開關,以及只能經由代理解析的模型 +槽位(路由別名與 `provider/model` 形式)。其餘都屬於你自己的設定並會保留——無關的 +`http://localhost:8080` 閘道器和你自己的 `sk-ant-` 憑證都會保留。 + +若儲存的 `/model` 選擇器預設值是僅限代理的模型,當 `claudeCode.model` 可在原生環境使用時會 +回退到它,否則會警告你傳入 `--model `。明確的 `--model` 引數始終優先。 + ## 認證模式 Claude Code 需要在 `ANTHROPIC_AUTH_TOKEN` 中有 token 才能與閘道器通訊,但設定該變數也會停用 diff --git a/src/cli/claude.ts b/src/cli/claude.ts index a9e64fc1af..446485e2b7 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -1,5 +1,6 @@ /** - * `ocx claude [claude args...]` — launch Claude Code wired to the local proxy. + * `ocx claude [claude args...]` — launch Claude Code through the local proxy, + * or natively when Claude routing is explicitly disabled. * * Mirrors `ccr code` UX (devlog/260711_claude_inbound/020, 003 E1/E2/E5/G1): * ensures the proxy is running, injects the Anthropic env slots, then execs the @@ -9,8 +10,9 @@ import { spawn } from "node:child_process"; import { loadConfig } from "../config"; import { injectClaudeAgentDefs } from "../claude/agents-inject"; +import { CLAUDE_ALIAS_PREFIX_V1, CLAUDE_ALIAS_PREFIX_V2 } from "../claude/alias"; import { effectiveModelEnv, resolveAutoContext } from "../claude/context-windows"; -import { refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache"; +import { claudeConfigDir, refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache"; import { commandInvocation } from "../lib/win-exec"; import { isProxyAdmissionSecret } from "../server/auth-cors"; import { findLiveProxy } from "../server/proxy-liveness"; @@ -21,10 +23,11 @@ import { resolveClaudeAuthMode } from "../claude/auth-mode"; import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; import { selfLaunchArgv } from "../lib/self-launch-argv"; import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "./launcher-context"; -import { readClientConnectionState } from "../client/state"; -import { readServiceApiTokenState } from "../lib/service-secrets"; +import { readClientConnectionState, type ClientConnectionState } from "../client/state"; +import { readServiceApiTokenState, type ServiceApiTokenState } from "../lib/service-secrets"; import { DEFAULT_CATALOG_PATH } from "../codex/paths"; import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { aliasForNative, aliasForRoute } from "../claude/alias"; import { desktop3pAlias } from "../claude/desktop-3p"; @@ -49,6 +52,48 @@ export type ClaudeEnvDeps = { allowRootSkipPermissions?: boolean; }; +function deleteUntrustedAnthropicSlots(env: ClaudeLaunchEnv, deps: ClaudeEnvDeps): void { + const explicitSlots = deps.preBunAnthropicSlots; + const trustedSlots = explicitSlots === undefined + ? trustedNodeLauncherContext()?.anthropicEnvSlots ?? [] + : explicitSlots ?? []; + const exported = new Set(trustedSlots); + for (const name of ANTHROPIC_PARENT_ENV_SLOTS) { + const value = env[name]; + if (value !== undefined && value !== "" && !exported.has(name)) delete env[name]; + } + delete env.OCX_PRE_BUN_ANTHROPIC_ENV; + delete env.OCX_NODE_LAUNCH_CONTEXT; +} + +/** + * Read Claude Code's own persisted `/model` picker default. + * + * An absent `settings.json` is the ordinary fresh-install case and stays silent. A + * present-but-unparseable one is not: swallowing it would drop the "saved model requires + * the proxy" warning exactly when the file is broken, so the native session would start + * on a model the user never chose with no explanation. Name the file, never its contents. + */ +export function readPickerDefaultModel(configDir: string): string | null { + const file = join(configDir, "settings.json"); + let raw: string; + try { + raw = readFileSync(file, "utf8"); + } catch (err) { // no-excuse-ok: catch -- an absent picker settings file is the default install state. + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + console.warn(`⚠ Could not read Claude Code settings at ${file}; the saved model check is skipped this run.`); + } + return null; + } + try { + const parsed = JSON.parse(raw) as Record; + return typeof parsed.model === "string" && parsed.model.trim() !== "" ? parsed.model.trim() : null; + } catch { // no-excuse-ok: catch -- a corrupt picker file must warn, not abort the launch. + console.warn(`⚠ Claude Code settings at ${file} are not valid JSON; the saved model check is skipped this run.`); + return null; + } +} + function isClaudeLoopbackHostname(hostname: string): boolean { const normalized = hostname.toLowerCase().replace(/\.$/, ""); return normalized === "localhost" @@ -128,18 +173,7 @@ export function buildClaudeEnv( // Direct `bun src/cli/index.ts` therefore loses ambient Anthropic values. That is a // real cost to a documented entry point, and the escape hatch is the launcher: run // through `ocx` (the published bin) and genuine shell exports are preserved by proof. - const explicitSlots = deps.preBunAnthropicSlots; - const trustedSlots = explicitSlots === undefined - ? trustedNodeLauncherContext()?.anthropicEnvSlots ?? [] - : explicitSlots ?? []; - const exported = new Set(trustedSlots); - for (const name of ANTHROPIC_PARENT_ENV_SLOTS) { - const value = env[name]; - if (value !== undefined && value !== "" && !exported.has(name)) delete env[name]; - } - // Never forward old or current provenance seams to Claude Code. - delete env.OCX_PRE_BUN_ANTHROPIC_ENV; - delete env.OCX_NODE_LAUNCH_CONTEXT; + deleteUntrustedAnthropicSlots(env, deps); const setDefault = (name: string, value: string | undefined) => { if (value === undefined || value.length === 0) return; if (env[name] !== undefined && env[name] !== "") return; // user wins @@ -302,7 +336,12 @@ export function buildClaudeEnv( * daemon registers every selector form — audit R3#1). 3s bound + management auth header. * (no [1m] marking, conservative). */ -export async function fetchClaudeContextWindows(config: OcxConfig, port: number, timeoutMs = 3_000): Promise> { +export interface ClaudeCodeLiveState { + contextWindows: Record; + enabled?: boolean; +} + +export async function fetchClaudeCodeState(config: OcxConfig, port: number, timeoutMs = 3_000): Promise { try { const headers = new Headers(); const token = configuredAdminToken(); @@ -311,15 +350,22 @@ export async function fetchClaudeContextWindows(config: OcxConfig, port: number, headers, signal: AbortSignal.timeout(timeoutMs), }); - if (!res.ok) return {}; - const body = await res.json() as { contextWindows?: Record }; - return body.contextWindows && typeof body.contextWindows === "object" ? body.contextWindows : {}; + if (!res.ok) return { contextWindows: {} }; + const body = await res.json() as { contextWindows?: Record; enabled?: boolean }; + return { + contextWindows: body.contextWindows && typeof body.contextWindows === "object" ? body.contextWindows : {}, + ...(typeof body.enabled === "boolean" ? { enabled: body.enabled } : {}), + }; } catch { console.error("⚠ 모델 컨텍스트 정보를 불러오지 못했습니다 — 1M 자동 표시는 이번 실행에서 생략됩니다."); - return {}; + return { contextWindows: {} }; } } +export async function fetchClaudeContextWindows(config: OcxConfig, port: number, timeoutMs = 3_000): Promise> { + return (await fetchClaudeCodeState(config, port, timeoutMs)).contextWindows; +} + export function readConnectedClaudeContextWindows(path = DEFAULT_CATALOG_PATH): Record { try { const parsed = JSON.parse(readFileSync(path, "utf8")) as { models?: unknown }; @@ -384,6 +430,139 @@ export async function ensureProxyForClaude(deps: ClaudeProxyEnsureDeps = {}): Pr return null; } +export const CLAUDE_NATIVE_ROUTING_OFF = + "ℹ️ Claude Code routing is disabled in OpenCodex. Launching Claude Code natively. Enable Claude routing to use the proxy again."; + +export const CLAUDE_NATIVE_LIVE_DISABLED = + "ℹ️ The running OpenCodex proxy has Claude Code routing disabled. Launching Claude Code natively. Restart the service after enabling routing."; + +export type ClaudeLaunchPlan = + | { kind: "routed" } + | { kind: "native"; notice: string }; + +export function claudeLaunchPlan( + configuredEnabled: boolean, + liveEnabled: boolean | undefined, +): ClaudeLaunchPlan { + if (!configuredEnabled) return { kind: "native", notice: CLAUDE_NATIVE_ROUTING_OFF }; + if (liveEnabled === false) return { kind: "native", notice: CLAUDE_NATIVE_LIVE_DISABLED }; + return { kind: "routed" }; +} + +export type ClaudeLaunchPreflight = + | { kind: "continue" } + | { kind: "native"; notice: string } + | { kind: "error"; message: string }; + +/** Validate connected-client ownership before any native fallback can run. */ +export function claudeLaunchPreflight( + configuredEnabled: boolean, + clientState: ClientConnectionState, + tokenState?: ServiceApiTokenState, +): ClaudeLaunchPreflight { + if (clientState.kind === "invalid" || clientState.kind === "mismatched") { + return { kind: "error", message: `Client state is ${clientState.kind}: ${clientState.reason}` }; + } + if (clientState.kind === "connected") { + if (!clientState.value.selectedClients.includes("claude")) { + return { kind: "error", message: "Claude is not selected for this remote hub connection." }; + } + if (tokenState?.kind !== "present" || tokenState.fingerprint !== clientState.value.tokenFingerprint) { + return { + kind: "error", + message: tokenState?.kind === "absent" + ? "Connected service token is missing." + : "Connected service token ownership changed.", + }; + } + } + return configuredEnabled + ? { kind: "continue" } + : { kind: "native", notice: CLAUDE_NATIVE_ROUTING_OFF }; +} + +const NATIVE_STRIPPED_LEVERS = [ + "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", + "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", + "CLAUDE_CODE_MAX_CONTEXT_TOKENS", + "CLAUDE_CODE_AUTO_COMPACT_WINDOW", + "CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", + "DISABLE_COMPACT", +] as const; + +const MODEL_ENV_SLOT_NAMES = [ + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", +] as const; + +const DESKTOP_3P_ALIAS = /^claude-opus-4(?:-8)?-[a-z][0-9a-z]{2}$/; + +export function isProxyOnlyModelId(value: string, providerNames: readonly string[] = []): boolean { + const id = value.trim().replace(/\[1m\]$/, ""); + if (!id) return false; + if (id.startsWith(CLAUDE_ALIAS_PREFIX_V1) || id.startsWith(CLAUDE_ALIAS_PREFIX_V2) || DESKTOP_3P_ALIAS.test(id)) { + return true; + } + const slash = id.indexOf("/"); + return slash > 0 && providerNames.includes(id.slice(0, slash)); +} + +export function buildNativeClaudeEnv( + config: OcxConfig, + base: ClaudeLaunchEnv, + deps: ClaudeEnvDeps = {}, +): ClaudeLaunchEnv { + const env: ClaudeLaunchEnv = { ...base }; + deleteUntrustedAnthropicSlots(env, deps); + + const admissionSlots = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const; + const hasOwnedAdmission = admissionSlots.some(name => { + const value = env[name]?.trim(); + return Boolean(value && (value === PROXY_MARKER || isProxyAdmissionSecret(value, config))); + }); + const baseUrl = env.ANTHROPIC_BASE_URL; + if (hasOwnedAdmission && targetsLocalClaudeProxy(baseUrl, config.port)) { + delete env.ANTHROPIC_BASE_URL; + } + for (const name of admissionSlots) { + const value = env[name]?.trim(); + if (value && (value === PROXY_MARKER || isProxyAdmissionSecret(value, config))) delete env[name]; + } + + for (const name of NATIVE_STRIPPED_LEVERS) delete env[name]; + const providerNames = Object.keys(config.providers); + for (const name of MODEL_ENV_SLOT_NAMES) { + const value = env[name]; + if (value && isProxyOnlyModelId(value, providerNames)) delete env[name]; + } + if (deps.allowRootSkipPermissions === true && !env.IS_SANDBOX) env.IS_SANDBOX = "1"; + return env; +} + +export function nativeModelOverride( + pickedModel: string | null, + configuredModel: string | undefined, + args: readonly string[], + providerNames: readonly string[] = [], +): { flag?: string[]; warning?: string } { + if (!pickedModel || !isProxyOnlyModelId(pickedModel, providerNames)) return {}; + if (args.some(arg => arg === "--model" || arg.startsWith("--model="))) return {}; + const fallback = configuredModel?.trim(); + if (fallback && !isProxyOnlyModelId(fallback, providerNames)) { + return { + flag: ["--model", fallback], + warning: `ℹ️ The saved model (${pickedModel}) requires the proxy. This native session will use ${fallback}.`, + }; + } + return { + warning: `⚠ The saved model (${pickedModel}) requires the proxy. Use \`--model \` or select a native model in this session.`, + }; +} + const CLAUDE_INSTALL_HINT = "❌ `claude` CLI not found. Install it first: npm install -g @anthropic-ai/claude-code"; /** @@ -417,28 +596,19 @@ export function rootSkipPermissionsNotice(env: ClaudeLaunchEnv): string { export async function cmdClaude(args: string[]): Promise { const config = loadConfig(); - if (config.claudeCode?.enabled === false) { - console.error("Claude inbound is disabled (config.claudeCode.enabled=false — flip the Claude ON toggle in the GUI or edit config)."); - return 1; - } const clientState = readClientConnectionState(); - if (clientState.kind === "invalid" || clientState.kind === "mismatched") { - console.error(`Client state is ${clientState.kind}: ${clientState.reason}`); + const tokenState = clientState.kind === "connected" ? readServiceApiTokenState() : undefined; + const preflight = claudeLaunchPreflight(config.claudeCode?.enabled !== false, clientState, tokenState); + if (preflight.kind === "error") { + console.error(preflight.message); return 1; } + if (preflight.kind === "native") return launchNativeClaude(config, args, preflight.notice); let route: number | ClaudeRoutingTarget; let contextWindows: Record; if (clientState.kind === "connected") { - if (!clientState.value.selectedClients.includes("claude")) { - console.error("Claude is not selected for this remote hub connection."); - return 1; - } - const token = readServiceApiTokenState(); - if (token.kind !== "present" || token.fingerprint !== clientState.value.tokenFingerprint) { - console.error(token.kind === "absent" ? "Connected service token is missing." : "Connected service token ownership changed."); - return 1; - } - route = { baseUrl: clientState.value.serverUrl, admissionToken: token.token }; + if (tokenState?.kind !== "present") return 1; + route = { baseUrl: clientState.value.serverUrl, admissionToken: tokenState.token }; contextWindows = readConnectedClaudeContextWindows(); } else { const port = await ensureProxyForClaude(); @@ -446,8 +616,11 @@ export async function cmdClaude(args: string[]): Promise { console.error("❌ Proxy did not become healthy after starting."); return 1; } + const liveState = await fetchClaudeCodeState(config, port); + const plan = claudeLaunchPlan(true, liveState.enabled); + if (plan.kind === "native") return launchNativeClaude(config, args, plan.notice); route = port; - contextWindows = await fetchClaudeContextWindows(config, port); + contextWindows = liveState.contextWindows; } const allowRootSkipPermissions = shouldAllowRootSkipPermissions(args); const env = buildClaudeEnv(config, route, process.env, contextWindows, { allowRootSkipPermissions }); @@ -479,7 +652,27 @@ export async function cmdClaude(args: string[]): Promise { console.error(`⚠ Claude agent definitions could not be synced: ${message}`); } } - return await new Promise(resolve => { + return spawnClaude(args, env); +} + +async function launchNativeClaude(config: OcxConfig, args: string[], notice: string): Promise { + console.error(notice); + const providerNames = Object.keys(config.providers); + const override = nativeModelOverride( + readPickerDefaultModel(claudeConfigDir()), + config.claudeCode?.model, + args, + providerNames, + ); + if (override.warning) console.error(override.warning); + const allowRootSkipPermissions = shouldAllowRootSkipPermissions(args); + const env = buildNativeClaudeEnv(config, process.env, { allowRootSkipPermissions }); + if (allowRootSkipPermissions) console.error(rootSkipPermissionsNotice(env)); + return spawnClaude([...(override.flag ?? []), ...args], env); +} + +function spawnClaude(args: string[], env: ClaudeLaunchEnv): Promise { + return new Promise(resolve => { const inv = commandInvocation("claude", args); const child = spawn(inv.file, inv.args, { stdio: "inherit", env: env as NodeJS.ProcessEnv, ...inv.options }); child.on("error", (err: NodeJS.ErrnoException) => { diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 5d6cd4391c..32d7481606 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -315,13 +315,14 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "claude", usage: "ocx claude [claude args...]", - summary: "Launch Claude Code wired to the proxy (env injection + gateway model discovery).", + summary: "Launch Claude Code through the proxy, with native fallback when Claude routing is disabled.", details: [ "Ensures the proxy is running, then execs `claude` with ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 and model slots from config.claudeCode.", + "When Claude routing is explicitly disabled, it launches natively after removing proven OpenCodex-owned proxy state.", "Routed models appear in the native /model picker with stable claude-opus-4-8-2026MMDD slot aliases (Claude Code >= 2.1.129).", "Older versions: pick models via ANTHROPIC_MODEL or /model directly (any string passes through).", - "User-exported ANTHROPIC_* variables always take precedence.", + "User-exported ANTHROPIC_* variables take precedence for routed launches; native fallback removes only proven OpenCodex-owned proxy values.", "", "Claude Desktop profile:", " ocx claude desktop [apply] Save and apply the four-family profile", diff --git a/tests/claude-integration/claude-cli.test.ts b/tests/claude-integration/claude-cli.test.ts index 2e94faca5c..9bbe7cff95 100644 --- a/tests/claude-integration/claude-cli.test.ts +++ b/tests/claude-integration/claude-cli.test.ts @@ -1,6 +1,21 @@ import { describe, expect, test } from "bun:test"; -import { buildClaudeEnv, claudeNotFoundHint, ensureProxyForClaude, rootSkipPermissionsNotice, shouldAllowRootSkipPermissions } from "../../src/cli/claude"; +import { + buildClaudeEnv, + buildNativeClaudeEnv, + claudeLaunchPlan, + claudeLaunchPreflight, + claudeNotFoundHint, + ensureProxyForClaude, + isProxyOnlyModelId, + nativeModelOverride, + readPickerDefaultModel, + rootSkipPermissionsNotice, + shouldAllowRootSkipPermissions, +} from "../../src/cli/claude"; import { commandInvocation } from "../../src/lib/win-exec"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { LivenessIo, LiveProxy } from "../../src/server/proxy-liveness"; import type { OcxConfig } from "../../src/types"; @@ -40,6 +55,122 @@ describe("ocx claude proxy liveness", () => { }); }); +describe("ocx claude native fallback", () => { + test("routes unless configured or live Claude routing is explicitly disabled", () => { + expect(claudeLaunchPlan(true, true)).toEqual({ kind: "routed" }); + expect(claudeLaunchPlan(true, undefined)).toEqual({ kind: "routed" }); + expect(claudeLaunchPlan(false, true)).toMatchObject({ kind: "native" }); + expect(claudeLaunchPlan(true, false)).toMatchObject({ kind: "native" }); + }); + + test("rejects an invalid connected client before configuration-disabled fallback", () => { + expect(claudeLaunchPreflight(false, { kind: "invalid", reason: "bad client state" })) + .toEqual({ kind: "error", message: "Client state is invalid: bad client state" }); + expect(claudeLaunchPreflight(false, { + kind: "connected", + value: { + serverUrl: "https://hub.example.test", + apiKeyId: "remote", + tokenFingerprint: "expected", + selectedClients: ["claude"], + }, + }, { kind: "present", token: "secret", fingerprint: "changed" })) + .toEqual({ kind: "error", message: "Connected service token ownership changed." }); + }); + + test("removes proxy-owned state while preserving user credentials and native model ids", () => { + const config = cfg({ + apiKeys: [{ id: "local", name: "local", key: "ocx_data_local_key", createdAt: "2026-01-01" }], + providers: { mock: { adapter: "openai-chat", baseUrl: "http://x/v1" } }, + }); + const env = buildNativeClaudeEnv(config, { + PATH: "/usr/bin", + ANTHROPIC_BASE_URL: "http://127.0.0.1:10100", + ANTHROPIC_AUTH_TOKEN: "ocx_data_local_key", + ANTHROPIC_API_KEY: "sk-ant-user-key", + ANTHROPIC_MODEL: "claude-ocx-mock--model", + ANTHROPIC_DEFAULT_OPUS_MODEL: "mock/model", + ANTHROPIC_DEFAULT_SONNET_MODEL: "sonnet", + CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1", + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1", + CLAUDE_CODE_AUTO_COMPACT_WINDOW: "829800", + }, { + preBunAnthropicSlots: ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"], + }); + + expect(env.PATH).toBe("/usr/bin"); + expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user-key"); + expect(env.ANTHROPIC_MODEL).toBeUndefined(); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeUndefined(); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("sonnet"); + expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBeUndefined(); + expect(env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY).toBeUndefined(); + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined(); + }); + + test("preserves an unrelated loopback gateway and its user credential", () => { + for (const baseUrl of ["http://localhost:8080", "http://127.0.0.1:10100"]) { + const env = buildNativeClaudeEnv(cfg({ port: 10100 }), { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_API_KEY: "sk-ant-user-key", + }, { + preBunAnthropicSlots: ["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"], + }); + + expect(env.ANTHROPIC_BASE_URL).toBe(baseUrl); + expect(env.ANTHROPIC_API_KEY).toBe("sk-ant-user-key"); + } + }); + + test("keeps unrelated slash model ids and recognizes configured provider routes", () => { + expect(isProxyOnlyModelId("mock/model", ["mock"])).toBe(true); + expect(isProxyOnlyModelId("claude-ocx2-abcd")).toBe(true); + expect(isProxyOnlyModelId("arn:aws:bedrock:region:acct:inference-profile/us.anthropic.model", ["mock"])).toBe(false); + expect(isProxyOnlyModelId("claude-opus-5")).toBe(false); + }); + + test("overrides a persisted proxy model only with a configured native model", () => { + expect(nativeModelOverride("claude-ocx2-abcd", "opus", [], ["mock"])) + .toMatchObject({ flag: ["--model", "opus"] }); + expect(nativeModelOverride("claude-ocx2-abcd", "mock/model", [], ["mock"]).flag).toBeUndefined(); + expect(nativeModelOverride("claude-ocx2-abcd", "opus", ["--model", "sonnet"], ["mock"])) + .toEqual({}); + }); + + test("preserves the root opt-in on native fallback", () => { + const env = buildNativeClaudeEnv(cfg(), {}, { allowRootSkipPermissions: true }); + expect(env.IS_SANDBOX).toBe("1"); + }); + + // A corrupt settings.json used to be indistinguishable from an absent one, so the + // "saved model requires the proxy" warning vanished exactly when the file was broken. + test("an absent picker settings file is silent, a corrupt one warns and names the file", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-claude-picker-")); + const warnings: string[] = []; + const realWarn = console.warn; + console.warn = (...parts: unknown[]) => { warnings.push(parts.join(" ")); }; + try { + expect(readPickerDefaultModel(dir)).toBeNull(); + expect(warnings).toEqual([]); + + writeFileSync(join(dir, "settings.json"), '{"model": "claude-ocx2-abcd"'); + expect(readPickerDefaultModel(dir)).toBeNull(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain(join(dir, "settings.json")); + expect(warnings[0]).not.toContain("claude-ocx2-abcd"); + + writeFileSync(join(dir, "settings.json"), '{"model": "claude-ocx2-abcd"}'); + expect(readPickerDefaultModel(dir)).toBe("claude-ocx2-abcd"); + expect(warnings).toHaveLength(1); + } finally { + console.warn = realWarn; + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe("ocx claude env assembly", () => { test("connected target injects only the hub base and client admission token", () => { const env = buildClaudeEnv(cfg(), {