diff --git a/devlog/_plan/261002_claude_ux/010_roadmap.md b/devlog/_plan/261002_claude_ux/010_roadmap.md new file mode 100644 index 00000000000..e35e74d8a48 --- /dev/null +++ b/devlog/_plan/261002_claude_ux/010_roadmap.md @@ -0,0 +1,27 @@ +# 261002 Claude UX — roadmap (010) + +Three outcomes, four work-phases (wp1 = this roadmap). + +| wp | Unit | Doc | Branch | +| --- | --- | --- | --- | +| wp2 | Start the Claude intercept pair on demand instead of asking for a restart | 020 | `codex/claude-intercept-on-demand` | +| wp3 | Top-level Claude page under Codex: Account / Code / Desktop / Settings | 030 | `codex/gui-claude-page` | +| wp4 | Local OpenCodex.app rebuild from merged dev, install handed to the user | 040 | none | + +## Evidence + +- `startClaudeIntercept` (src/claude/intercept/runtime.ts) runs once, from `createClaudeInterceptLifecycle().start` in + src/server/index/optional-listeners.ts at `startServer`. It resolves `null` when Claude routing is off + (`claudeCode.enabled === false`), the intercept is disabled, the role is `client`, or the public port is ephemeral, and a + bind failure only warns. Nothing starts it later, so `getClaudeInterceptState()` stays `null` for the life of the process. +- The CLI first-party toggle (`PUT /api/claude-code {cliFirstParty}`, agent-settings-routes.ts:1557) then refuses with + `intercept_unavailable` ("…restart needed"), and the Desktop panel shows `claudeDesktop.firstParty.proxyStopped`: + "로컬 프록시 127.0.0.1:{port}가 실행 중이 아님 — OpenCodex 재시작". `claude.firstParty.disabled` also tells the user to restart. + The live user config has `claudeCode.enabled: false`, `desktopMode: first-party`, which reproduces it. +- The Desktop picker controller and runtime are created inside `startClaudeIntercept` only, so the picker is also absent. + +## Order + +wp2 and wp3 are independent and run in parallel lanes (separate worktrees). wp3 rebases after wp2 merges if both touch +ClaudeCode/ClaudeDesktop copy. wp4 needs both merged. + diff --git a/devlog/_plan/261002_claude_ux/020_intercept_on_demand.md b/devlog/_plan/261002_claude_ux/020_intercept_on_demand.md new file mode 100644 index 00000000000..39f15bdef99 --- /dev/null +++ b/devlog/_plan/261002_claude_ux/020_intercept_on_demand.md @@ -0,0 +1,31 @@ +# 020 Intercept on demand (wp2) + +## Behaviour + +- A serialized, idempotent `ensureClaudeIntercept()` starts the intercept pair (CONNECT proxy, TLS listener, CLI catalog hook, + Desktop picker runtime/controller when wired) in the running process using the persisted config, reusing the options the + lifecycle got at `startServer` (dispatch, ports, picker routes). A second call while one is in flight awaits the same start; + a call when already bound returns the bound state. +- Result is a discriminated outcome: `{ok:true,state}` or `{ok:false, reason}` with reason + `disabled` (Claude routing off or `claudeCode.intercept.enabled=false`), `client_role`, `ephemeral_port`, + `port_in_use` (with the port), `failed` (message without secrets). No reason means "restart". +- Callers: the `cliFirstParty` toggle and every Desktop first-party/picker apply route call it before refusing; turning Claude + routing on (`PUT /api/native-integrations/claude {enabled:true}`) calls it; a new `POST /api/claude-intercept/start` + (dashboard session) lets the GUI retry explicitly. Status payloads gain `interceptReason` so the GUI can say why. +- Turning routing off does not tear the pair down (existing relay-native behaviour stays). +- The startup path keeps its synchronous contract (AGENTS.md: no await between `Bun.serve` and lab activation); the lifecycle + only records its options and a starter, it does not change the startup order. + +## GUI + +- Replace every "restart OpenCodex" instruction for the intercept (ko/en/... `claudeDesktop.firstParty.proxyStopped`, + `claude.firstParty.disabled`, `claude.firstParty.refusal.interceptUnavailable`) with the concrete reason plus a + "Start" action that calls the new endpoint, and a port hint for `port_in_use` + (`claudeCode.intercept.port`). All locales updated. + +## Tests + +Lifecycle: ensure starts when startup returned null, is idempotent and serialized, maps EADDRINUSE to `port_in_use`. +Route: `cliFirstParty:true` with no bound intercept starts it and succeeds; disabled routing returns `disabled`. +Desktop status reports `interceptReason`. File-size: keep agent-settings-routes.ts under 2000 lines (move to a sibling). + diff --git a/devlog/_plan/261002_claude_ux/030_claude_page.md b/devlog/_plan/261002_claude_ux/030_claude_page.md new file mode 100644 index 00000000000..e5dd4b2da5d --- /dev/null +++ b/devlog/_plan/261002_claude_ux/030_claude_page.md @@ -0,0 +1,26 @@ +# 030 Claude page (wp3) + +## IA + +Sidebar: Dashboard, Codex, **Claude** (new, directly under Codex), Providers, … Integrations keeps its other clients; the Claude +tab inside Integrations is removed and its hashes redirect: `#integrations/claude` → `#claude/code`, +`#integrations/claude/desktop` → `#claude/desktop`. + +Page shaped like Codex Set (page-tabs, hash-routed, panels mounted on first visit and kept mounted while inactive, keyboard arrows): + +| Tab | Hash | Content | +| --- | --- | --- | +| Account | `#claude` / `#claude/account` | Anthropic accounts: the existing provider auth + AnthropicAccountPoolSettings (pool on/off, auto-switch threshold), reset grants | +| Code | `#claude/code` | existing ClaudeCode page (CLI first-party, models, sidecars) | +| Desktop | `#claude/desktop` | existing ClaudeDesktop page (mode, picker, bindings) | +| Settings | `#claude/settings` | Claude routing on/off, intercept status/port and start action (from wp2 when merged), compatibility mode, inject agents, auto-context — only settings that exist today, no new behaviour | + +## Design read + +Dashboard tool surface (D4, variance 3, motion 1): reuse existing page-tabs, panels and tokens; no new visual language. +One primary action per tab. Empty/disabled states explain why and link to the action. + +## Checks + +lint:gui, build:gui, GUI tests for tab routing/redirects, i18n completeness (all locales), screenshots of each tab in light/dark +for the PR and a UI review by an inherited-model subagent. diff --git a/devlog/_plan/261002_claude_ux/040_app_rebuild.md b/devlog/_plan/261002_claude_ux/040_app_rebuild.md new file mode 100644 index 00000000000..7894db481d4 --- /dev/null +++ b/devlog/_plan/261002_claude_ux/040_app_rebuild.md @@ -0,0 +1,6 @@ +# 040 Local app rebuild (wp4) + +From the merged dev head: `bun install`, `(cd gui && bun install && bun run build)`, `cd desktop && bun run prepare-sidecar && bun run prepare-widget && bun run build:local`. +Output: `desktop/src-tauri/target/release/bundle/macos/OpenCodex.app` and dmg. Do not replace /Applications/OpenCodex.app or +restart the running proxy; hand the path to the user to install. Unsigned local build: Gatekeeper may need right-click → Open. + diff --git a/devlog/_plan/261002_claude_ux/050_audit.md b/devlog/_plan/261002_claude_ux/050_audit.md new file mode 100644 index 00000000000..9847a9836c2 --- /dev/null +++ b/devlog/_plan/261002_claude_ux/050_audit.md @@ -0,0 +1,24 @@ +# 050 Audit fold (wp1) + +Independent inherited-model reviewer: NEAR-PASS. Folded into 020/030 as binding requirements: + +1. `ensureClaudeIntercept` is a method of `ClaudeInterceptLifecycle` (claude-intercept-lifecycle.ts), so a later start goes + through the same dispatch wrapper (`listener ??= requestServer`) and `ownsListener` classifies it as the intercept ingress; + expose it through `OptionalListenerSet` and the existing `linkListener: () => optionalListeners` seam (index.ts gains no lines). +2. One `inflight` promise; `stopped` flag refuses ensure after stop; `stop()` awaits the in-flight start; ensure awaits a + pending startup start first and only starts a new pair if it resolved to null. +3. Record the last start outcome: pure precheck (disabled / client role / ephemeral port) plus bind error mapping + (EADDRINUSE on the CONNECT proxy → `port_in_use` with port). Picker proxy bind failure stays non-fatal and is reported as + `pickerReason`. +4. Port mismatch (bound port ≠ configured) gets reason `port_mismatch`: rebind is out of scope; the copy explains the bound port + is in use for this run and how to change it, never "restart". +5. Use startServer's live config object; recompute `observeClaudeDesktopMode` on each ensure. +6. Security: ensure (POST /api/claude-intercept/start and the triggering PUTs) is refused on the hub-management ingress and for + data-plane API keys; same gate everywhere; tests prove it. +7. GUI routing list for wp3: app-routing.ts (Page union, page list, `#claude` redirect inverted), App.tsx title map + NAV, + `nav.claude` and all copy in all locales, INTEGRATION_TAB_HASHES old hashes kept as redirects, integration-tabs.ts:34, + overview-clients.ts:318/367, api-surface-cards.tsx:20, Integrations.tsx:20, Claude.tsx:10-11. + +Notes kept: agent-settings-routes.ts sibling move up front (1943/2000); a later start may run picker CA drain/keychain → GUI +pending state; routing off keeps the pair bound (relay-native). + diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 23ad6e4b09d..930b4f94b67 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -123,7 +123,7 @@ 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 | +| The running proxy reports `enabled: false` from `GET /api/claude-code` | Native launch; re-enabling starts interception on demand | | `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 @@ -298,7 +298,7 @@ next request; Desktop does not need a restart. Turn on the CLI switch in Claude → Code, or run `ocx claude config set --first-party on`; use `off` to disable it. The switch is immediate and refuses `{enabled:false, cliFirstParty:true}` before any field is saved; it may also refuse to turn on if the local intercept is unavailable, the CA cannot be prepared, settings cannot be read, or a foreign proxy setting owns the keys. Off persists even when the intercept is unavailable; disabling Claude routing alone leaves an owned settings env untouched. For fully native terminal traffic with only Desktop first-party on, set `NO_PROXY='*'` in the shell. This still carries the first-party account risk stated above. Disabling Claude routing leaves the owned settings env untouched. While the bound listener still runs, every Messages request relays unchanged; after it stops, plain `claude` cannot connect until OpenCodex runs or Desktop/CLI first-party is turned off. Native `ocx claude` sets `NO_PROXY=*` only for an owned env without a foreign inherited `HTTPS_PROXY`/`https_proxy`. With a foreign proxy it preserves that value and warns that the settings-owned intercept still applies; turn Desktop/CLI first-party off or unset the setting. -The UI distinguishes uncertainty about whether settings still point at its proxy (unknown), a token-bearing opencodex proxy with a foreign CA (foreign: fix HTTPS_PROXY / NODE_EXTRA_CA_CERTS manually), and a tokenless loopback proxy beside a foreign CA (local: ownership is unconfirmed; remove HTTPS_PROXY if unused). With matching applied settings and a bound listener but Claude routing off, disabled means requests relay unchanged until restart; turn first-party off to remove settings. An owned URL with no listener is stopped; a bound listener with an owned CA but mismatched port or token is broken even when routing is off. With an intent on, stopped or broken plus ineligible interception displays routingOff: Claude routing or the intercept is off, or this machine is a client of another opencodex hub; enable interception on this machine or turn first-party off to remove the settings. Only when interception is eligible does stopped advise starting opencodex and broken advise `ocx ensure` or restart. CLI intent with no proxy is not applied; one intent with a live proxy gets the shared-relay notice; any remaining proxy with neither intent is residual, unless unknown, foreign, or local takes precedence. +The UI distinguishes uncertainty about whether settings still point at its proxy (unknown), a token-bearing opencodex proxy with a foreign CA (foreign: fix HTTPS_PROXY / NODE_EXTRA_CA_CERTS manually), and a tokenless loopback proxy beside a foreign CA (local: ownership is unconfirmed; remove HTTPS_PROXY if unused). With matching applied settings and a bound listener but Claude routing off, disabled means requests relay unchanged while it is bound; turn first-party off to remove settings. An owned URL with no listener is stopped; a bound listener with an owned CA but mismatched port or token is broken even when routing is off. With an intent on, stopped or broken plus ineligible interception displays routingOff: Claude routing or the intercept is off, or this machine is a client of another opencodex hub; enable interception on this machine or turn first-party off to remove the settings. When interception is stopped, use **Start interception** or `ocx claude intercept start` to retry within the running OpenCodex service. Saving first-party settings also starts it automatically. A refusal reports disabled routing, client role, an ephemeral public port, an occupied port, or a startup failure. For an occupied port, free it or set `claudeCode.intercept.port`. If a pair is already bound on a different port, the response names both ports; use the bound port or restore the configured value. CLI intent with no proxy is not applied; one intent with a live proxy gets the shared-relay notice; any remaining proxy with neither intent is residual, unless unknown, foreign, or local takes precedence. - Gateway discovery (`/model` → "From gateway") is not used, because Claude Code only queries `GET /v1/models` on a configured gateway. Routed models reach the picker another way; see diff --git a/gui/src/components/ClaudeDesktopPicker.tsx b/gui/src/components/ClaudeDesktopPicker.tsx index f8147b88927..e44f77986d5 100644 --- a/gui/src/components/ClaudeDesktopPicker.tsx +++ b/gui/src/components/ClaudeDesktopPicker.tsx @@ -1,3 +1,5 @@ +import ClaudeInterceptStart from "./ClaudeInterceptStart"; +import { interceptReasonKey } from "../pages/claude-code-first-party"; import { useState } from "react"; import { useI18n, type TKey } from "../i18n/shared"; import { readJsonOrThrow } from "../fetch-json"; @@ -72,8 +74,12 @@ export default function ClaudeDesktopPicker({ apiBase, picker, onUpdated, + pickerReason, + pickerFailurePort, }: { apiBase: string; + pickerReason?: string | null; + pickerFailurePort?: number; picker: DesktopPickerStatus; onUpdated?: (picker: DesktopPickerStatus) => void; }) { @@ -93,7 +99,7 @@ export default function ClaudeDesktopPicker({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: !current.desired, persist: true }), }); - let payload: { ok?: boolean; picker?: DesktopPickerStatus } | undefined; + let payload: { ok?: boolean; reason?: string; port?: number; picker?: DesktopPickerStatus } | undefined; if (response.ok) { payload = await readJsonOrThrow<{ ok?: boolean; picker?: DesktopPickerStatus }>( response, @@ -103,11 +109,11 @@ export default function ClaudeDesktopPicker({ // A refused enable is a useful status response (not a transport failure); 503 // carries the controller's reason so the card can explain the missing proxy. try { - payload = await response.json() as { ok?: boolean; picker?: DesktopPickerStatus }; + payload = await response.json() as { ok?: boolean; reason?: string; port?: number; picker?: DesktopPickerStatus }; } catch { throw new Error(t("claudeDesktop.updateFailed")); } - if (!payload.picker) throw new Error(t("claudeDesktop.updateFailed")); + if (!payload.picker) throw new Error(t(interceptReasonKey(payload.reason), { port: payload.port ?? "" })); } if (!payload || payload.ok !== true && !payload.picker || !payload.picker || !isDesktopPickerStatus(payload.picker)) { throw new Error(t("claudeDesktop.updateFailed")); @@ -123,6 +129,8 @@ export default function ClaudeDesktopPicker({ return (
+ {!current.listenerReady && pickerReason && {t(interceptReasonKey(pickerReason === "port_in_use" && !pickerFailurePort ? "failed" : pickerReason), { port: pickerFailurePort ?? "" })}} + {!current.listenerReady && !pickerReason && { setLocalPicker(null); onUpdated?.(current); }} />}

{t("claudeDesktop.picker.title")}

diff --git a/gui/src/components/ClaudeInterceptStart.tsx b/gui/src/components/ClaudeInterceptStart.tsx new file mode 100644 index 00000000000..4513db68916 --- /dev/null +++ b/gui/src/components/ClaudeInterceptStart.tsx @@ -0,0 +1,31 @@ +import { useState } from "react"; +import { useT } from "../i18n/shared"; +import { interceptReasonKey } from "../pages/claude-code-first-party"; + +const FAILED_REASON = "failed"; + +export default function ClaudeInterceptStart({ apiBase, reason, port, onStarted }: { + apiBase: string; reason?: string | null; port?: number; onStarted: () => void; +}) { + const t = useT(); + const [pending, setPending] = useState(false); + const [failure, setFailure] = useState<{ reason: string; port?: number } | null>(null); + const start = async () => { + if (pending) return; + setPending(true); + setFailure(null); + try { + const response = await fetch(`${apiBase}/api/claude-intercept/start`, { method: "POST" }); + const result = await response.json() as { ok?: boolean; reason?: string; port?: number }; + if (!response.ok || !result.ok) setFailure({ reason: result.reason ?? FAILED_REASON, port: result.port }); + else onStarted(); + } catch { setFailure({ reason: FAILED_REASON }); } + finally { setPending(false); } + }; + return + {t(interceptReasonKey(failure?.reason ?? reason), { port: failure?.port ?? port ?? "" })}{" "} + + ; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 11cbe22b5cf..c88d76a3575 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3164,21 +3164,30 @@ export const de: Record = { "claudeDesktop.mode.defaultBadge": "Standard", "claudeDesktop.mode.current": "aktuell", "claudeDesktop.mode.firstPartyHint": "Desktop bleibt bei claude.ai angemeldet. Sein Code-Tab nutzt OpenCodex, wenn Desktop First-Party aktiv ist; die eigenständige CLI hat einen eigenen Schalter. Beide lesen dieselbe Einstellungsumgebung.", + "claude.intercept.start": "Abfangen starten", + "claude.intercept.starting": "Abfangen wird gestartet…", + "claude.intercept.reason.disabled": "Aktivieren Sie zuerst Claude-Routing und Abfangen.", + "claude.intercept.reason.client_role": "Dieser Prozess ist ein Hub-Client. Aktivieren Sie das Abfangen auf einem lokalen Server.", + "claude.intercept.reason.ephemeral_port": "Setzen Sie claudeCode.intercept.port bei einem temporären öffentlichen Port.", + "claude.intercept.reason.port_in_use": "Port {port} ist belegt. Geben Sie ihn frei oder ändern Sie claudeCode.intercept.port.", + "claude.intercept.reason.port_mismatch": "Dieser Lauf nutzt Port {bound}, konfiguriert ist {configured}. Nutzen Sie den gebundenen Port oder stellen Sie die Konfiguration wieder her.", + "claude.intercept.reason.failed": "Abfangen konnte nicht starten. Prüfen Sie die lokale Proxy-Konfiguration und versuchen Sie es erneut.", + "claude.intercept.reason.stopped": "Abfangen wurde beendet. Starten Sie den OpenCodex-Dienst.", "claude.firstParty.label": "Claude Code CLI First-Party", "claude.firstParty.aria": "Claude Code CLI First-Party umschalten", "claude.firstParty.risk": "Kontorisiko: First-Party leitet Claude-Abonnementverkehr über einen lokalen Abfangproxy. Anthropic kann dies als Verstoß gegen die Nutzungsbedingungen werten und das Konto sperren.", "claude.firstParty.shared": "Wenn die gemeinsame Proxy-Umgebung angewendet ist, läuft auch der andere Claude-Client unverändert über den lokalen Proxy; TLS endet lokal. Für nativen Terminalverkehr NO_PROXY='*' in der Shell setzen.", "claude.firstParty.deadProxy": "Die Claude-Einstellungen zeigen auf einen Proxy, der nicht läuft. Einfaches claude kann erst verbinden, wenn OpenCodex läuft oder Desktop/CLI First-Party ausgeschaltet wird.", "claude.firstParty.notApplied": "CLI-First-Party ist aktiv, aber die Proxy-Einstellungen sind nicht angewendet. Prüfen Sie die Claude-Einstellungen vor dem Aufruf von claude.", - "claude.firstParty.brokenProxy": "Die Claude-Einstellungen zeigen auf einen OpenCodex-Proxy, der nicht zum laufenden Listener passt. Führen Sie `ocx ensure` aus oder starten Sie OpenCodex neu.", + "claude.firstParty.brokenProxy": "Abfangen konnte nicht starten. Prüfen Sie die lokale Proxy-Konfiguration und versuchen Sie es erneut.", "claude.firstParty.residual": "Die Claude-Einstellungen zeigen weiterhin auf einen OpenCodex-Proxy, obwohl weder Desktop noch CLI First-Party aktiv ist. Entfernen Sie den verbliebenen Proxy-Eintrag oder führen Sie `ocx ensure` aus.", "claude.firstParty.routingOff": "opencodex stellt den Claude-Intercept in der aktuellen Konfiguration nicht bereit (Claude-Routing oder Intercept ist ausgeschaltet, oder dieser Rechner ist Client eines anderen opencodex-Hubs). Diese Einstellungen zeigen daher auf einen Proxy, der die Anfragen nicht bedienen wird. Aktivieren Sie ihn auf diesem Rechner wieder oder deaktivieren Sie First-Party, um die Einstellungen zu entfernen.", - "claude.firstParty.disabled": "Claude-Routing in opencodex ist aus. Der lokale Proxy leitet diese Anfragen bis zum Neustart von opencodex unverändert weiter; danach kann sich claude mit den verbliebenen Einstellungen nicht verbinden. Schalten Sie First-Party aus, um sie zu entfernen.", + "claude.firstParty.disabled": "Aktivieren Sie zuerst Claude-Routing und Abfangen.", "claude.firstParty.foreign": "~/.claude/settings.json verweist auf den opencodex-Proxy, vertraut aber einer nicht von opencodex verwalteten CA. Anfragen schlagen fehl. Korrigieren Sie HTTPS_PROXY / NODE_EXTRA_CA_CERTS dort von Hand.", "claude.firstParty.local": "~/.claude/settings.json leitet Claude Code über einen lokalen Proxy auf 127.0.0.1, den opencodex nicht als eigenen bestätigen kann. Wenn Sie ihn nicht mehr verwenden, entfernen Sie dort HTTPS_PROXY.", "claude.firstParty.unknown": "opencodex konnte nicht feststellen, ob ~/.claude/settings.json noch auf seinen Proxy verweist.", "claude.firstParty.refusal.interceptDisabled": "Der Abfangproxy ist deaktiviert.", - "claude.firstParty.refusal.interceptUnavailable": "Der Abfangproxy läuft in diesem Prozess nicht.", + "claude.firstParty.refusal.interceptUnavailable": "Abfangen konnte nicht starten. Prüfen Sie die lokale Proxy-Konfiguration und versuchen Sie es erneut.", "claude.firstParty.refusal.foreignEnv": "Ein anderes Programm verwaltet die Claude-Proxy-Einstellungen.", "claude.firstParty.refusal.caUnavailable": "Die lokale Zertifizierungsstelle ist nicht verfügbar.", "claude.firstParty.refusal.unreadable": "Die Claude-Einstellungen konnten nicht gelesen werden.", @@ -3187,7 +3196,7 @@ export const de: Record = { "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.proxyStopped": "Abfangen konnte nicht starten. Prüfen Sie die lokale Proxy-Konfiguration und versuchen Sie es erneut. ({port})", "claudeDesktop.firstParty.interceptDisabled": "Intercept-Proxy ist in der Konfiguration deaktiviert (claudeCode.intercept.enabled)", "claudeDesktop.picker.title": "Claude-Desktop-Auswahl", "claudeDesktop.picker.hint": "OpenCodex-Modelle über den dedizierten lokalen Proxy in der Code-Tab-Auswahl von Desktop anzeigen.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0731c6aabd6..5ab1cb70988 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3278,21 +3278,30 @@ export const en = { "claudeDesktop.mode.defaultBadge": "default", "claudeDesktop.mode.current": "current", "claudeDesktop.mode.firstPartyHint": "Desktop stays signed in to claude.ai. Its Code tab uses OpenCodex when Desktop first-party is on; the standalone CLI has its own switch. Both read the same settings env.", + "claude.intercept.start": "Start interception", + "claude.intercept.starting": "Starting interception…", + "claude.intercept.reason.disabled": "Enable Claude routing and interception first.", + "claude.intercept.reason.client_role": "This process is a hub client. Enable interception on a local server.", + "claude.intercept.reason.ephemeral_port": "Set claudeCode.intercept.port when the public port is ephemeral.", + "claude.intercept.reason.port_in_use": "Port {port} is in use. Free it or change claudeCode.intercept.port.", + "claude.intercept.reason.port_mismatch": "This run uses port {bound}; configured port {configured} differs. Use the bound port or restore the configured value.", + "claude.intercept.reason.failed": "Interception could not start. Retry after checking the local proxy configuration.", + "claude.intercept.reason.stopped": "The interception lifecycle has stopped. Start a running OpenCodex service.", "claude.firstParty.label": "Claude Code CLI first-party", "claude.firstParty.aria": "Toggle Claude Code CLI first-party", "claude.firstParty.risk": "Account risk: first-party routes Claude subscription traffic through a local interception proxy. Anthropic may treat this as a terms violation and suspend the account.", "claude.firstParty.shared": "When the shared proxy env is applied, the other Claude client also transits the local proxy as an unchanged relay; TLS terminates locally. Set NO_PROXY='*' in the shell for fully native terminal use.", "claude.firstParty.deadProxy": "The Claude settings env points at a proxy that is not running. Start opencodex, or turn Desktop/CLI first-party off to remove the settings before using plain claude.", "claude.firstParty.notApplied": "CLI first-party is on, but its proxy settings are not applied. Check the Claude settings env before using plain claude.", - "claude.firstParty.brokenProxy": "Claude settings point to an OpenCodex proxy that does not match the running listener. Run `ocx ensure` or restart OpenCodex.", + "claude.firstParty.brokenProxy": "Interception could not start. Retry after checking the local proxy configuration.", "claude.firstParty.residual": "Claude settings still point to an OpenCodex proxy although neither Desktop nor CLI first-party is on. Remove the remaining proxy setting or run `ocx ensure`.", "claude.firstParty.routingOff": "opencodex is not serving the Claude intercept in its current configuration (Claude routing or the intercept is turned off, or this machine is a client of another opencodex hub), so these settings point at a proxy that will not serve them. Turn it back on on this machine, or turn first-party off to remove the settings.", - "claude.firstParty.disabled": "Claude routing in opencodex is off. The local proxy still passes these requests through unchanged until opencodex restarts; after that, plain claude cannot connect while the settings remain. Turn first-party off to remove them.", + "claude.firstParty.disabled": "Enable Claude routing and interception first.", "claude.firstParty.foreign": "~/.claude/settings.json points at the opencodex proxy but trusts a certificate opencodex does not manage, so requests fail. Fix HTTPS_PROXY / NODE_EXTRA_CA_CERTS there by hand.", "claude.firstParty.local": "~/.claude/settings.json sends Claude Code through a local proxy at 127.0.0.1 that opencodex cannot confirm as its own. If you no longer use it, remove HTTPS_PROXY there.", "claude.firstParty.unknown": "opencodex could not determine whether ~/.claude/settings.json still points at its proxy.", "claude.firstParty.refusal.interceptDisabled": "The interception proxy is disabled.", - "claude.firstParty.refusal.interceptUnavailable": "The interception proxy is not running in this process.", + "claude.firstParty.refusal.interceptUnavailable": "Interception could not start. Retry after checking the local proxy configuration.", "claude.firstParty.refusal.foreignEnv": "Another program owns the Claude proxy settings.", "claude.firstParty.refusal.caUnavailable": "The local certificate authority is unavailable.", "claude.firstParty.refusal.unreadable": "Claude settings could not be read.", @@ -3301,7 +3310,7 @@ export const en = { "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.proxyStopped": "Interception could not start. Retry after checking the local proxy configuration. ({port})", "claudeDesktop.firstParty.interceptDisabled": "Intercept proxy is disabled in config (claudeCode.intercept.enabled)", "claudeDesktop.picker.title": "Claude Desktop picker", "claudeDesktop.picker.hint": "Show OpenCodex models in Desktop's Code tab picker through the dedicated local proxy.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 238ae04362f..31c2c5be4ee 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3191,21 +3191,30 @@ export const fr: Record = { "claudeDesktop.mode.defaultBadge": "par défaut", "claudeDesktop.mode.current": "actuel", "claudeDesktop.mode.firstPartyHint": "Desktop reste connecté à claude.ai. Son onglet Code utilise OpenCodex lorsque le mode first-party Desktop est actif ; la CLI autonome dispose de son propre interrupteur. Les deux lisent le même environnement de réglages.", + "claude.intercept.start": "Démarrer l’interception", + "claude.intercept.starting": "Démarrage de l’interception…", + "claude.intercept.reason.disabled": "Activez d’abord le routage Claude et l’interception.", + "claude.intercept.reason.client_role": "Ce processus est un client du hub. Activez l’interception sur un serveur local.", + "claude.intercept.reason.ephemeral_port": "Définissez claudeCode.intercept.port si le port public est temporaire.", + "claude.intercept.reason.port_in_use": "Le port {port} est occupé. Libérez-le ou modifiez claudeCode.intercept.port.", + "claude.intercept.reason.port_mismatch": "Cette exécution utilise le port {bound}, différent du port configuré {configured}. Utilisez le port lié ou rétablissez la configuration.", + "claude.intercept.reason.failed": "L’interception n’a pas pu démarrer. Vérifiez le proxy local et réessayez.", + "claude.intercept.reason.stopped": "L’interception est arrêtée. Démarrez le service OpenCodex.", "claude.firstParty.label": "First-party de la CLI Claude Code", "claude.firstParty.aria": "Activer ou désactiver le first-party de la CLI Claude Code", "claude.firstParty.risk": "Risque pour le compte : le mode first-party fait passer le trafic de l'abonnement Claude par un proxy local d'interception. Anthropic peut y voir une violation de ses conditions et suspendre le compte.", "claude.firstParty.shared": "Si les réglages du proxy partagé sont appliqués, l’autre client Claude traverse aussi le proxy local sans modification ; TLS se termine localement. Définissez NO_PROXY='*' dans le shell pour un terminal entièrement natif.", "claude.firstParty.deadProxy": "Les réglages Claude pointent vers un proxy arrêté. La commande claude ne peut pas se connecter tant qu’OpenCodex ne fonctionne pas ou que le first-party Desktop/CLI n’est pas désactivé.", "claude.firstParty.notApplied": "Le mode first-party de la CLI est actif, mais ses réglages de proxy ne sont pas appliqués. Vérifiez les réglages Claude avant de lancer claude.", - "claude.firstParty.brokenProxy": "Les réglages Claude pointent vers un proxy OpenCodex différent du listener actif. Exécutez `ocx ensure` ou redémarrez OpenCodex.", + "claude.firstParty.brokenProxy": "L’interception n’a pas pu démarrer. Vérifiez le proxy local et réessayez.", "claude.firstParty.residual": "Les réglages Claude pointent encore vers un proxy OpenCodex alors que ni Desktop ni la CLI ne sont en mode first-party. Supprimez ce réglage ou exécutez `ocx ensure`.", "claude.firstParty.routingOff": "opencodex ne sert pas l’interception Claude dans sa configuration actuelle (le routage Claude ou l’interception est désactivé, ou cette machine est cliente d’un autre hub opencodex). Ces réglages pointent donc vers un proxy qui ne traitera pas les requêtes. Réactivez-la sur cette machine ou désactivez le mode first-party pour supprimer ces réglages.", - "claude.firstParty.disabled": "Le routage Claude dans opencodex est désactivé. Le proxy local relaie ces requêtes sans modification jusqu’au redémarrage d’opencodex ; ensuite, claude ne pourra plus se connecter tant que ces réglages restent en place. Désactivez le mode first-party pour les retirer.", + "claude.firstParty.disabled": "Activez d’abord le routage Claude et l’interception.", "claude.firstParty.foreign": "~/.claude/settings.json pointe vers le proxy opencodex, mais fait confiance à une AC qu’opencodex ne gère pas : les requêtes échouent. Corrigez HTTPS_PROXY / NODE_EXTRA_CA_CERTS manuellement dans ce fichier.", "claude.firstParty.local": "~/.claude/settings.json fait passer Claude Code par un proxy local sur 127.0.0.1 dont opencodex ne peut pas confirmer la propriété. Si vous ne l’utilisez plus, supprimez HTTPS_PROXY dans ce fichier.", "claude.firstParty.unknown": "opencodex ne peut pas déterminer si ~/.claude/settings.json pointe encore vers son proxy.", "claude.firstParty.refusal.interceptDisabled": "Le proxy d'interception est désactivé.", - "claude.firstParty.refusal.interceptUnavailable": "Le proxy d'interception ne fonctionne pas dans ce processus.", + "claude.firstParty.refusal.interceptUnavailable": "L’interception n’a pas pu démarrer. Vérifiez le proxy local et réessayez.", "claude.firstParty.refusal.foreignEnv": "Un autre programme contrôle les réglages de proxy Claude.", "claude.firstParty.refusal.caUnavailable": "L'autorité de certification locale est indisponible.", "claude.firstParty.refusal.unreadable": "Impossible de lire les réglages Claude.", @@ -3214,7 +3223,7 @@ export const fr: Record = { "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.proxyStopped": "L’interception n’a pas pu démarrer. Vérifiez le proxy local et réessayez. ({port})", "claudeDesktop.firstParty.interceptDisabled": "Le proxy d'interception est désactivé dans la config (claudeCode.intercept.enabled)", "claudeDesktop.picker.title": "Sélecteur Claude Desktop", "claudeDesktop.picker.hint": "Afficher les modèles OpenCodex dans le sélecteur de l’onglet Code de Desktop via le proxy local dédié.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index bd7704fdd24..a95df715772 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2880,21 +2880,30 @@ export const ja: Record = { "claudeDesktop.mode.defaultBadge": "デフォルト", "claudeDesktop.mode.current": "現在", "claudeDesktop.mode.firstPartyHint": "Desktop は claude.ai にログインしたままです。Desktop の 1P がオンなら Code タブは OpenCodex を使い、単独の CLI は別のスイッチで制御します。どちらも同じ設定環境変数を読みます。", + "claude.intercept.start": "傍受を開始", + "claude.intercept.starting": "傍受を開始中…", + "claude.intercept.reason.disabled": "先にClaudeのルーティングと傍受を有効にしてください。", + "claude.intercept.reason.client_role": "このプロセスはハブクライアントです。ローカルサーバーで傍受を有効にしてください。", + "claude.intercept.reason.ephemeral_port": "公開ポートが一時的な場合はclaudeCode.intercept.portを指定してください。", + "claude.intercept.reason.port_in_use": "ポート{port}は使用中です。解放するかclaudeCode.intercept.portを変更してください。", + "claude.intercept.reason.port_mismatch": "現在のポートは{bound}、設定は{configured}です。現在のポートを使用するか設定を戻してください。", + "claude.intercept.reason.failed": "傍受を開始できませんでした。ローカルプロキシの設定を確認して再試行してください。", + "claude.intercept.reason.stopped": "傍受は停止しました。OpenCodexサービスを起動してください。", "claude.firstParty.label": "Claude Code CLI の 1P", "claude.firstParty.aria": "Claude Code CLI の 1P を切り替え", "claude.firstParty.risk": "アカウントのリスク:1P では Claude のサブスクリプション通信がローカルの傍受プロキシを通ります。Anthropic が規約違反と判断し、アカウントを停止する可能性があります。", "claude.firstParty.shared": "共有プロキシ設定が適用されている場合、もう一方の Claude クライアントもローカルプロキシを通りますが、要求は変更されずに中継されます。TLS はローカルで終端します。ターミナルから直接接続するにはシェルで NO_PROXY='*' を設定してください。", "claude.firstParty.deadProxy": "Claude の設定は停止中のプロキシを指しています。OpenCodex を起動するか Desktop/CLI の 1P をオフにするまで、通常の claude は接続できません。", "claude.firstParty.notApplied": "CLI の 1P はオンですが、プロキシ設定は適用されていません。通常の claude を使う前に Claude の設定を確認してください。", - "claude.firstParty.brokenProxy": "Claude の設定が指す OpenCodex プロキシは実行中のリスナーと一致しません。`ocx ensure` を実行するか OpenCodex を再起動してください。", + "claude.firstParty.brokenProxy": "傍受を開始できませんでした。ローカルプロキシの設定を確認して再試行してください。", "claude.firstParty.residual": "Desktop と CLI の 1P は両方オフですが、Claude の設定には OpenCodex プロキシが残っています。設定を削除するか `ocx ensure` を実行してください。", "claude.firstParty.routingOff": "現在の設定では opencodex が Claude インターセプトを提供していません(Claude ルーティングまたはインターセプトがオフか、この端末が別の opencodex ハブのクライアントです)。そのため、この設定は要求を処理しないプロキシを指しています。この端末で再度有効にするか、1P をオフにして設定を削除してください。", - "claude.firstParty.disabled": "opencodex の Claude ルーティングはオフです。opencodex を再起動するまではローカルプロキシが要求を変更せず中継します。再起動後も設定が残ると通常の claude は接続できません。設定を消すには 1P をオフにしてください。", + "claude.firstParty.disabled": "先にClaudeのルーティングと傍受を有効にしてください。", "claude.firstParty.foreign": "~/.claude/settings.json は opencodex プロキシを指していますが、opencodex が管理しない証明書を信頼しているため要求は失敗します。HTTPS_PROXY / NODE_EXTRA_CA_CERTS を手動で修正してください。", "claude.firstParty.local": "~/.claude/settings.json により Claude Code は 127.0.0.1 のローカルプロキシを通りますが、opencodex はそれが自身のプロキシか確認できません。使っていない場合は、そのファイルから HTTPS_PROXY を削除してください。", "claude.firstParty.unknown": "opencodex は ~/.claude/settings.json がまだ自身のプロキシを指しているか確認できません。", "claude.firstParty.refusal.interceptDisabled": "傍受プロキシは無効です。", - "claude.firstParty.refusal.interceptUnavailable": "このプロセスでは傍受プロキシが稼働していません。", + "claude.firstParty.refusal.interceptUnavailable": "傍受を開始できませんでした。ローカルプロキシの設定を確認して再試行してください。", "claude.firstParty.refusal.foreignEnv": "別のプログラムが Claude のプロキシ設定を管理しています。", "claude.firstParty.refusal.caUnavailable": "ローカル認証局を利用できません。", "claude.firstParty.refusal.unreadable": "Claude の設定を読み取れません。", @@ -2903,7 +2912,7 @@ export const ja: Record = { "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.proxyStopped": "傍受を開始できませんでした。ローカルプロキシの設定を確認して再試行してください。 ({port})", "claudeDesktop.firstParty.interceptDisabled": "設定でインターセプトプロキシが無効です(claudeCode.intercept.enabled)", "claudeDesktop.picker.title": "Claude Desktop ピッカー", "claudeDesktop.picker.hint": "専用ローカルプロキシ経由で、Desktop の Code タブのピッカーに OpenCodex モデルを表示します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 236a2b2d168..3cbca942faa 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3211,21 +3211,30 @@ export const ko: Record = { "claudeDesktop.mode.defaultBadge": "기본값", "claudeDesktop.mode.current": "현재", "claudeDesktop.mode.firstPartyHint": "Desktop은 claude.ai에 로그인된 상태를 유지합니다. Desktop 1P를 켜면 Code 탭이 OpenCodex를 사용하고, 독립 실행 CLI는 별도 스위치로 제어합니다. 둘 다 같은 설정 환경 변수를 읽습니다.", + "claude.intercept.start": "가로채기 시작", + "claude.intercept.starting": "가로채기 시작 중…", + "claude.intercept.reason.disabled": "먼저 Claude 라우팅과 가로채기를 켜세요.", + "claude.intercept.reason.client_role": "이 프로세스는 허브 클라이언트입니다. 로컬 서버에서 가로채기를 켜세요.", + "claude.intercept.reason.ephemeral_port": "공개 포트가 임시 포트이면 claudeCode.intercept.port를 지정하세요.", + "claude.intercept.reason.port_in_use": "포트 {port}가 사용 중입니다. 포트를 비우거나 claudeCode.intercept.port를 바꾸세요.", + "claude.intercept.reason.port_mismatch": "현재 실행은 {bound} 포트를 사용하며 설정된 {configured} 포트와 다릅니다. 실행 중인 포트를 사용하거나 설정값을 복원하세요.", + "claude.intercept.reason.failed": "가로채기를 시작하지 못했습니다. 로컬 프록시 설정을 확인한 뒤 다시 시도하세요.", + "claude.intercept.reason.stopped": "가로채기 수명주기가 종료됐습니다. OpenCodex 서비스를 실행하세요.", "claude.firstParty.label": "Claude Code CLI 1P", "claude.firstParty.aria": "Claude Code CLI 1P 켜기/끄기", "claude.firstParty.risk": "계정 위험: 1P를 켜면 Claude 구독 트래픽이 로컬 가로채기 프록시를 거칩니다. Anthropic이 약관 위반으로 판단해 계정을 정지할 수 있습니다.", "claude.firstParty.shared": "공유 프록시 설정이 적용되어 있으면 다른 Claude 클라이언트도 로컬 프록시를 거치지만 요청은 그대로 전달됩니다. TLS는 로컬에서 종료됩니다. 터미널에서 직접 연결하려면 셸에 NO_PROXY='*'를 설정하세요.", "claude.firstParty.deadProxy": "Claude 설정이 실행 중이지 않은 프록시를 가리킵니다. OpenCodex를 실행하거나 Desktop/CLI 1P를 끄기 전에는 일반 claude가 연결할 수 없습니다.", "claude.firstParty.notApplied": "CLI 1P가 켜져 있지만 프록시 설정이 적용되지 않았습니다. 일반 claude를 사용하기 전에 Claude 설정을 확인하세요.", - "claude.firstParty.brokenProxy": "Claude 설정의 OpenCodex 프록시가 실행 중인 리스너와 일치하지 않습니다. `ocx ensure`를 실행하거나 OpenCodex를 다시 시작하세요.", + "claude.firstParty.brokenProxy": "가로채기를 시작하지 못했습니다. 로컬 프록시 설정을 확인한 뒤 다시 시도하세요.", "claude.firstParty.residual": "Desktop과 CLI의 1P가 모두 꺼져 있는데도 Claude 설정에 OpenCodex 프록시가 남아 있습니다. 남은 프록시 설정을 지우거나 `ocx ensure`를 실행하세요.", "claude.firstParty.routingOff": "현재 설정에서는 opencodex가 Claude 인터셉트를 제공하지 않습니다. Claude 라우팅이나 인터셉트가 꺼져 있거나, 이 기기가 다른 opencodex 허브의 클라이언트일 수 있습니다. 그래서 이 설정은 요청을 처리하지 않는 프록시를 가리킵니다. 이 기기에서 다시 켜거나 1P를 꺼서 설정을 제거하세요.", - "claude.firstParty.disabled": "opencodex에서 Claude 라우팅이 꺼져 있습니다. 로컬 프록시는 opencodex를 다시 시작하기 전까지 요청을 바꾸지 않고 전달합니다. 재시작 후에도 설정이 남아 있으면 일반 claude는 연결할 수 없습니다. 설정을 지우려면 1P를 끄세요.", + "claude.firstParty.disabled": "먼저 Claude 라우팅과 가로채기를 켜세요.", "claude.firstParty.foreign": "~/.claude/settings.json이 opencodex 프록시를 가리키지만 opencodex가 관리하지 않는 인증서를 신뢰하고 있어 요청이 실패합니다. 파일에서 HTTPS_PROXY / NODE_EXTRA_CA_CERTS를 직접 수정하세요.", "claude.firstParty.local": "~/.claude/settings.json을 통해 Claude Code가 127.0.0.1의 로컬 프록시를 사용하고 있습니다. opencodex가 소유한 프록시인지는 확인할 수 없습니다. 더 이상 사용하지 않는다면 해당 파일에서 HTTPS_PROXY를 지우세요.", "claude.firstParty.unknown": "opencodex가 ~/.claude/settings.json이 아직 자체 프록시를 가리키는지 확인하지 못했습니다.", "claude.firstParty.refusal.interceptDisabled": "가로채기 프록시가 꺼져 있습니다.", - "claude.firstParty.refusal.interceptUnavailable": "이 프로세스에서 가로채기 프록시가 실행 중이지 않습니다.", + "claude.firstParty.refusal.interceptUnavailable": "가로채기를 시작하지 못했습니다. 로컬 프록시 설정을 확인한 뒤 다시 시도하세요.", "claude.firstParty.refusal.foreignEnv": "다른 프로그램이 Claude 프록시 설정을 관리하고 있습니다.", "claude.firstParty.refusal.caUnavailable": "로컬 인증 기관을 사용할 수 없습니다.", "claude.firstParty.refusal.unreadable": "Claude 설정을 읽을 수 없습니다.", @@ -3234,7 +3243,7 @@ export const ko: Record = { "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.proxyStopped": "가로채기를 시작하지 못했습니다. 로컬 프록시 설정을 확인한 뒤 다시 시도하세요. ({port})", "claudeDesktop.firstParty.interceptDisabled": "설정에서 인터셉트 프록시가 비활성화됨 (claudeCode.intercept.enabled)", "claudeDesktop.picker.title": "Claude Desktop 선택기", "claudeDesktop.picker.hint": "전용 로컬 프록시를 통해 Desktop Code 탭 선택기에 OpenCodex 모델을 표시합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b4042ab2cd6..070513d59b9 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2970,21 +2970,30 @@ export const ru: Record = { "claudeDesktop.mode.defaultBadge": "по умолчанию", "claudeDesktop.mode.current": "текущий", "claudeDesktop.mode.firstPartyHint": "Desktop остаётся подключённым к claude.ai. Его вкладка Code использует OpenCodex при включённом first-party Desktop; у отдельного CLI свой переключатель. Оба читают одну среду настроек.", + "claude.intercept.start": "Запустить перехват", + "claude.intercept.starting": "Запуск перехвата…", + "claude.intercept.reason.disabled": "Сначала включите маршрутизацию Claude и перехват.", + "claude.intercept.reason.client_role": "Этот процесс — клиент хаба. Включите перехват на локальном сервере.", + "claude.intercept.reason.ephemeral_port": "Для временного публичного порта задайте claudeCode.intercept.port.", + "claude.intercept.reason.port_in_use": "Порт {port} занят. Освободите его или измените claudeCode.intercept.port.", + "claude.intercept.reason.port_mismatch": "Используется порт {bound}, в настройках — {configured}. Используйте текущий порт или восстановите настройку.", + "claude.intercept.reason.failed": "Не удалось запустить перехват. Проверьте настройки локального прокси и повторите попытку.", + "claude.intercept.reason.stopped": "Перехват остановлен. Запустите службу OpenCodex.", "claude.firstParty.label": "First-party для Claude Code CLI", "claude.firstParty.aria": "Переключить first-party для Claude Code CLI", "claude.firstParty.risk": "Риск для аккаунта: режим first-party направляет трафик подписки Claude через локальный перехватывающий прокси. Anthropic может счесть это нарушением условий и заблокировать аккаунт.", "claude.firstParty.shared": "Если общие настройки прокси применены, другой клиент Claude также проходит через локальный прокси без изменения запросов; TLS завершается локально. Для нативной работы терминала задайте NO_PROXY='*' в оболочке.", "claude.firstParty.deadProxy": "Настройки Claude указывают на неработающий прокси. Обычный claude не сможет подключиться, пока не запущен OpenCodex или не выключен first-party Desktop/CLI.", "claude.firstParty.notApplied": "First-party CLI включён, но настройки прокси не применены. Проверьте настройки Claude перед запуском claude.", - "claude.firstParty.brokenProxy": "Настройки Claude указывают на прокси OpenCodex, не совпадающий с запущенным listener. Выполните `ocx ensure` или перезапустите OpenCodex.", + "claude.firstParty.brokenProxy": "Не удалось запустить перехват. Проверьте настройки локального прокси и повторите попытку.", "claude.firstParty.residual": "В настройках Claude остался прокси OpenCodex, хотя first-party Desktop и CLI выключен. Удалите этот параметр или выполните `ocx ensure`.", "claude.firstParty.routingOff": "В текущей конфигурации opencodex не обслуживает перехват Claude (маршрутизация Claude или перехват отключены либо этот компьютер является клиентом другого хаба opencodex), поэтому настройки указывают на прокси, который не обработает запросы. Включите его на этом компьютере или выключите first-party, чтобы удалить настройки.", - "claude.firstParty.disabled": "Маршрутизация Claude в opencodex выключена. До перезапуска opencodex локальный прокси передаёт эти запросы без изменений; после перезапуска обычный claude не подключится, пока настройки остаются. Выключите first-party, чтобы удалить их.", + "claude.firstParty.disabled": "Сначала включите маршрутизацию Claude и перехват.", "claude.firstParty.foreign": "~/.claude/settings.json указывает на прокси opencodex, но доверяет сертификату, которым opencodex не управляет, поэтому запросы не проходят. Исправьте HTTPS_PROXY / NODE_EXTRA_CA_CERTS вручную.", "claude.firstParty.local": "~/.claude/settings.json направляет Claude Code через локальный прокси на 127.0.0.1, но opencodex не может подтвердить, что это его прокси. Если он больше не нужен, удалите HTTPS_PROXY из этого файла.", "claude.firstParty.unknown": "opencodex не может определить, указывает ли ~/.claude/settings.json по-прежнему на его прокси.", "claude.firstParty.refusal.interceptDisabled": "Прокси перехвата отключён.", - "claude.firstParty.refusal.interceptUnavailable": "Прокси перехвата не запущен в этом процессе.", + "claude.firstParty.refusal.interceptUnavailable": "Не удалось запустить перехват. Проверьте настройки локального прокси и повторите попытку.", "claude.firstParty.refusal.foreignEnv": "Настройки прокси Claude принадлежат другой программе.", "claude.firstParty.refusal.caUnavailable": "Локальный центр сертификации недоступен.", "claude.firstParty.refusal.unreadable": "Не удалось прочитать настройки Claude.", @@ -2993,7 +3002,7 @@ export const ru: Record = { "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.proxyStopped": "Не удалось запустить перехват. Проверьте настройки локального прокси и повторите попытку. ({port})", "claudeDesktop.firstParty.interceptDisabled": "Перехватывающий прокси отключён в конфигурации (claudeCode.intercept.enabled)", "claudeDesktop.picker.title": "Выбор моделей Claude Desktop", "claudeDesktop.picker.hint": "Показывать модели OpenCodex в выборе вкладки Code в Desktop через выделенный локальный прокси.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b954a474aa5..0a80ffcd291 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3214,21 +3214,30 @@ export const tr: Record = { "claudeDesktop.mode.defaultBadge": "varsayılan", "claudeDesktop.mode.current": "geçerli", "claudeDesktop.mode.firstPartyHint": "Desktop claude.ai oturumunu korur. Desktop first-party açıksa Code sekmesi OpenCodex'i kullanır; bağımsız CLI'nin ayrı bir anahtarı vardır. İkisi de aynı ayar ortamını okur.", + "claude.intercept.start": "Yakalamayı başlat", + "claude.intercept.starting": "Yakalama başlatılıyor…", + "claude.intercept.reason.disabled": "Önce Claude yönlendirmesini ve yakalamayı etkinleştirin.", + "claude.intercept.reason.client_role": "Bu süreç bir hub istemcisidir. Yerel sunucuda yakalamayı etkinleştirin.", + "claude.intercept.reason.ephemeral_port": "Genel bağlantı noktası geçiciyse claudeCode.intercept.port değerini ayarlayın.", + "claude.intercept.reason.port_in_use": "{port} bağlantı noktası kullanımda. Boşaltın veya claudeCode.intercept.port değerini değiştirin.", + "claude.intercept.reason.port_mismatch": "Bu çalıştırma {bound} bağlantı noktasını kullanıyor; yapılandırılan değer {configured}. Mevcut noktayı kullanın veya yapılandırmayı geri yükleyin.", + "claude.intercept.reason.failed": "Yakalama başlatılamadı. Yerel proxy yapılandırmasını kontrol edip tekrar deneyin.", + "claude.intercept.reason.stopped": "Yakalama durdu. OpenCodex hizmetini başlatın.", "claude.firstParty.label": "Claude Code CLI first-party", "claude.firstParty.aria": "Claude Code CLI first-party modunu değiştir", "claude.firstParty.risk": "Hesap riski: first-party, Claude abonelik trafiğini yerel bir yakalama vekilinden geçirir. Anthropic bunu koşul ihlali sayıp hesabı askıya alabilir.", "claude.firstParty.shared": "Paylaşılan vekil ayarları uygulandıysa diğer Claude istemcisi de yerel vekilden geçer ancak istekleri değiştirilmeden iletilir; TLS yerelde sonlanır. Terminalde doğrudan bağlantı için kabukta NO_PROXY='*' ayarlayın.", "claude.firstParty.deadProxy": "Claude ayarları çalışmayan bir vekile işaret ediyor. OpenCodex çalışana veya Desktop/CLI first-party kapatılana kadar doğrudan claude bağlanamaz.", "claude.firstParty.notApplied": "CLI first-party açık ancak vekil ayarları uygulanmamış. Doğrudan claude kullanmadan önce Claude ayarlarını kontrol edin.", - "claude.firstParty.brokenProxy": "Claude ayarları çalışan dinleyiciyle eşleşmeyen bir OpenCodex vekiline işaret ediyor. `ocx ensure` çalıştırın veya OpenCodex’i yeniden başlatın.", + "claude.firstParty.brokenProxy": "Yakalama başlatılamadı. Yerel proxy yapılandırmasını kontrol edip tekrar deneyin.", "claude.firstParty.residual": "Desktop ve CLI first-party kapalı olsa da Claude ayarları hâlâ OpenCodex vekiline işaret ediyor. Kalan vekil ayarını kaldırın veya `ocx ensure` çalıştırın.", "claude.firstParty.routingOff": "opencodex, geçerli yapılandırmada Claude kesmesini sunmuyor (Claude yönlendirmesi veya kesme kapalı ya da bu makine başka bir opencodex merkezinin istemcisi). Bu nedenle ayarlar, istekleri karşılamayacak bir vekile işaret ediyor. Bu makinede yeniden etkinleştirin veya ayarları kaldırmak için first-party modunu kapatın.", - "claude.firstParty.disabled": "opencodex içinde Claude yönlendirmesi kapalı. Yerel vekil, opencodex yeniden başlatılana dek bu istekleri değiştirmeden iletir; sonrasında ayarlar kaldığı sürece doğrudan claude bağlanamaz. Ayarları kaldırmak için first-party modunu kapatın.", + "claude.firstParty.disabled": "Önce Claude yönlendirmesini ve yakalamayı etkinleştirin.", "claude.firstParty.foreign": "~/.claude/settings.json opencodex vekiline işaret ediyor ancak opencodex tarafından yönetilmeyen bir sertifikaya güveniyor; istekler başarısız olur. HTTPS_PROXY / NODE_EXTRA_CA_CERTS değerlerini elle düzeltin.", "claude.firstParty.local": "~/.claude/settings.json, Claude Code trafiğini 127.0.0.1 üzerindeki yerel bir vekile gönderiyor; opencodex bu vekilin kendisine ait olduğunu doğrulayamıyor. Artık kullanmıyorsanız HTTPS_PROXY değerini bu dosyadan kaldırın.", "claude.firstParty.unknown": "opencodex, ~/.claude/settings.json dosyasının hâlâ kendi vekiline işaret edip etmediğini belirleyemedi.", "claude.firstParty.refusal.interceptDisabled": "Yakalama vekili kapalı.", - "claude.firstParty.refusal.interceptUnavailable": "Yakalama vekili bu süreçte çalışmıyor.", + "claude.firstParty.refusal.interceptUnavailable": "Yakalama başlatılamadı. Yerel proxy yapılandırmasını kontrol edip tekrar deneyin.", "claude.firstParty.refusal.foreignEnv": "Claude vekil ayarları başka bir programa ait.", "claude.firstParty.refusal.caUnavailable": "Yerel sertifika yetkilisi kullanılamıyor.", "claude.firstParty.refusal.unreadable": "Claude ayarları okunamadı.", @@ -3237,7 +3246,7 @@ export const tr: Record = { "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.proxyStopped": "Yakalama başlatılamadı. Yerel proxy yapılandırmasını kontrol edip tekrar deneyin. ({port})", "claudeDesktop.firstParty.interceptDisabled": "Yakalama proxy'si yapılandırmada kapalı (claudeCode.intercept.enabled)", "claudeDesktop.picker.title": "Claude Desktop seçici", "claudeDesktop.picker.hint": "OpenCodex modellerini özel yerel proxy üzerinden Desktop Code sekmesi seçicisinde gösterin.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 0248bc02aed..edf4adbb122 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3209,21 +3209,30 @@ export const vi: Record = { "claudeDesktop.mode.defaultBadge": "mặc định", "claudeDesktop.mode.current": "hiện tại", "claudeDesktop.mode.firstPartyHint": "Desktop vẫn đăng nhập claude.ai. Tab Code dùng OpenCodex khi bật first-party của Desktop; CLI độc lập có công tắc riêng. Cả hai đọc cùng môi trường thiết lập.", + "claude.intercept.start": "Bắt đầu chặn", + "claude.intercept.starting": "Đang bắt đầu chặn…", + "claude.intercept.reason.disabled": "Hãy bật định tuyến Claude và chặn trước.", + "claude.intercept.reason.client_role": "Tiến trình này là máy khách hub. Bật chặn trên máy chủ cục bộ.", + "claude.intercept.reason.ephemeral_port": "Đặt claudeCode.intercept.port khi cổng công khai là cổng tạm thời.", + "claude.intercept.reason.port_in_use": "Cổng {port} đang được dùng. Giải phóng cổng hoặc đổi claudeCode.intercept.port.", + "claude.intercept.reason.port_mismatch": "Lần chạy này dùng cổng {bound}, khác cổng cấu hình {configured}. Dùng cổng hiện tại hoặc khôi phục cấu hình.", + "claude.intercept.reason.failed": "Không thể bắt đầu chặn. Kiểm tra cấu hình proxy cục bộ rồi thử lại.", + "claude.intercept.reason.stopped": "Chặn đã dừng. Hãy khởi chạy dịch vụ OpenCodex.", "claude.firstParty.label": "First-party cho Claude Code CLI", "claude.firstParty.aria": "Bật/tắt first-party cho Claude Code CLI", "claude.firstParty.risk": "Rủi ro tài khoản: first-party đưa lưu lượng thuê bao Claude qua proxy chặn bắt cục bộ. Anthropic có thể coi đây là vi phạm điều khoản và đình chỉ tài khoản.", "claude.firstParty.shared": "Khi thiết lập proxy dùng chung đã được áp dụng, ứng dụng Claude còn lại cũng đi qua proxy cục bộ và được chuyển tiếp nguyên trạng; TLS kết thúc tại máy. Để terminal kết nối trực tiếp, đặt NO_PROXY='*' trong shell.", "claude.firstParty.deadProxy": "Thiết lập Claude trỏ đến proxy không chạy. Lệnh claude thông thường không thể kết nối cho đến khi OpenCodex chạy hoặc first-party Desktop/CLI được tắt.", "claude.firstParty.notApplied": "First-party CLI đang bật nhưng thiết lập proxy chưa được áp dụng. Hãy kiểm tra thiết lập Claude trước khi chạy claude thông thường.", - "claude.firstParty.brokenProxy": "Thiết lập Claude trỏ tới proxy OpenCodex không khớp với bộ lắng nghe đang chạy. Chạy `ocx ensure` hoặc khởi động lại OpenCodex.", + "claude.firstParty.brokenProxy": "Không thể bắt đầu chặn. Kiểm tra cấu hình proxy cục bộ rồi thử lại.", "claude.firstParty.residual": "Cả first-party Desktop và CLI đều đã tắt nhưng thiết lập Claude vẫn trỏ tới proxy OpenCodex. Xóa thiết lập proxy còn lại hoặc chạy `ocx ensure`.", "claude.firstParty.routingOff": "Với cấu hình hiện tại, opencodex không phục vụ chức năng chặn Claude (định tuyến Claude hoặc chức năng chặn đã tắt, hoặc máy này là máy khách của một hub opencodex khác), nên các thiết lập này trỏ đến proxy không xử lý yêu cầu. Hãy bật lại trên máy này hoặc tắt first-party để xóa các thiết lập.", - "claude.firstParty.disabled": "Định tuyến Claude trong opencodex đã tắt. Proxy cục bộ vẫn chuyển tiếp nguyên trạng các yêu cầu này cho đến khi opencodex khởi động lại; sau đó, claude thông thường không thể kết nối nếu thiết lập vẫn còn. Tắt first-party để gỡ chúng.", + "claude.firstParty.disabled": "Hãy bật định tuyến Claude và chặn trước.", "claude.firstParty.foreign": "~/.claude/settings.json trỏ đến proxy opencodex nhưng tin một chứng chỉ không do opencodex quản lý nên yêu cầu sẽ thất bại. Hãy sửa HTTPS_PROXY / NODE_EXTRA_CA_CERTS thủ công trong tệp đó.", "claude.firstParty.local": "~/.claude/settings.json đưa Claude Code qua proxy cục bộ tại 127.0.0.1 nhưng opencodex không thể xác nhận proxy đó là của mình. Nếu không còn dùng, hãy xóa HTTPS_PROXY trong tệp này.", "claude.firstParty.unknown": "opencodex không xác định được ~/.claude/settings.json còn trỏ đến proxy của mình hay không.", "claude.firstParty.refusal.interceptDisabled": "Proxy chặn bắt đã tắt.", - "claude.firstParty.refusal.interceptUnavailable": "Proxy chặn bắt không chạy trong tiến trình này.", + "claude.firstParty.refusal.interceptUnavailable": "Không thể bắt đầu chặn. Kiểm tra cấu hình proxy cục bộ rồi thử lại.", "claude.firstParty.refusal.foreignEnv": "Một chương trình khác quản lý thiết lập proxy Claude.", "claude.firstParty.refusal.caUnavailable": "Không dùng được cơ quan chứng thực cục bộ.", "claude.firstParty.refusal.unreadable": "Không đọc được thiết lập Claude.", @@ -3232,7 +3241,7 @@ export const vi: Record = { "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.proxyStopped": "Không thể bắt đầu chặn. Kiểm tra cấu hình proxy cục bộ rồi thử lại. ({port})", "claudeDesktop.firstParty.interceptDisabled": "Proxy chặn bắt đã bị tắt trong cấu hình (claudeCode.intercept.enabled)", "claudeDesktop.picker.title": "Bộ chọn Claude Desktop", "claudeDesktop.picker.hint": "Hiển thị các model OpenCodex trong bộ chọn ở tab Code của Desktop qua proxy cục bộ chuyên dụng.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 6752777be49..4a484d1e68a 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3181,21 +3181,30 @@ export const zhTW: Record = { "claudeDesktop.mode.defaultBadge": "預設", "claudeDesktop.mode.current": "目前", "claudeDesktop.mode.firstPartyHint": "Desktop 維持登入 claude.ai。啟用 Desktop 第一方模式時,Code 分頁使用 OpenCodex;獨立 CLI 有自己的開關。兩者讀取同一份設定環境變數。", + "claude.intercept.start": "啟動攔截", + "claude.intercept.starting": "正在啟動攔截…", + "claude.intercept.reason.disabled": "請先啟用Claude路由和攔截。", + "claude.intercept.reason.client_role": "此程序是中心用戶端。請在本機伺服器啟用攔截。", + "claude.intercept.reason.ephemeral_port": "公開連接埠為臨時連接埠時,請設定claudeCode.intercept.port。", + "claude.intercept.reason.port_in_use": "連接埠{port}已被占用。請釋放或修改claudeCode.intercept.port。", + "claude.intercept.reason.port_mismatch": "本次執行使用連接埠{bound},與設定的{configured}不同。請使用目前連接埠或還原設定。", + "claude.intercept.reason.failed": "無法啟動攔截。請檢查本機代理設定後重試。", + "claude.intercept.reason.stopped": "攔截已停止。請啟動OpenCodex服務。", "claude.firstParty.label": "Claude Code CLI 第一方", "claude.firstParty.aria": "切換 Claude Code CLI 第一方模式", "claude.firstParty.risk": "帳號風險:第一方模式讓 Claude 訂閱流量經過本機攔截代理。Anthropic 可能視為違反條款並暫停帳號。", "claude.firstParty.shared": "如果共用代理設定已套用,另一個 Claude 用戶端也會經由本機代理原樣轉送;TLS 在本機終止。若要在終端機直連,請在 shell 設定 NO_PROXY='*'。", "claude.firstParty.deadProxy": "Claude 設定指向未執行的代理。執行 OpenCodex 或關閉 Desktop/CLI 第一方模式前,直接執行 claude 無法連線。", "claude.firstParty.notApplied": "CLI 第一方模式已開啟,但代理設定尚未生效。執行一般 claude 前請檢查 Claude 設定。", - "claude.firstParty.brokenProxy": "Claude 設定指向的 OpenCodex 代理與執行中的監聽器不符。請執行 `ocx ensure` 或重新啟動 OpenCodex。", + "claude.firstParty.brokenProxy": "無法啟動攔截。請檢查本機代理設定後重試。", "claude.firstParty.residual": "Desktop 與 CLI 第一方模式都已關閉,但 Claude 設定仍指向 OpenCodex 代理。請移除殘留代理設定或執行 `ocx ensure`。", "claude.firstParty.routingOff": "opencodex 目前未提供 Claude 攔截服務(Claude 路由或攔截功能已關閉,或這台裝置是另一個 opencodex 中樞的用戶端),因此這些設定指向的代理無法處理要求。請在這台裝置上重新啟用該服務,或關閉第一方模式以移除設定。", - "claude.firstParty.disabled": "opencodex 的 Claude 路由已關閉。在 opencodex 重新啟動前,本機代理仍會原樣轉送這些要求;重新啟動後,只要設定仍在,一般 claude 就無法連線。關閉第一方模式以移除設定。", + "claude.firstParty.disabled": "請先啟用Claude路由和攔截。", "claude.firstParty.foreign": "~/.claude/settings.json 指向 opencodex 代理,卻信任 opencodex 未管理的憑證,因此要求會失敗。請手動修正其中的 HTTPS_PROXY / NODE_EXTRA_CA_CERTS。", "claude.firstParty.local": "~/.claude/settings.json 讓 Claude Code 使用 127.0.0.1 上的本機代理,但 opencodex 無法確認該代理是否由自己管理。若不再使用,請刪除其中的 HTTPS_PROXY。", "claude.firstParty.unknown": "opencodex 無法確認 ~/.claude/settings.json 是否仍指向自己的代理。", "claude.firstParty.refusal.interceptDisabled": "攔截代理已關閉。", - "claude.firstParty.refusal.interceptUnavailable": "此程序未執行攔截代理。", + "claude.firstParty.refusal.interceptUnavailable": "無法啟動攔截。請檢查本機代理設定後重試。", "claude.firstParty.refusal.foreignEnv": "其他程式擁有 Claude 代理設定。", "claude.firstParty.refusal.caUnavailable": "本機憑證授權單位無法使用。", "claude.firstParty.refusal.unreadable": "無法讀取 Claude 設定。", @@ -3204,7 +3213,7 @@ export const zhTW: Record = { "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.proxyStopped": "無法啟動攔截。請檢查本機代理設定後重試。 ({port})", "claudeDesktop.firstParty.interceptDisabled": "設定中已停用攔截代理(claudeCode.intercept.enabled)", "claudeDesktop.picker.title": "Claude Desktop 模型選擇器", "claudeDesktop.picker.hint": "透過專用本機代理伺服器,將 OpenCodex 模型顯示在 Desktop 的 Code 分頁選擇器中。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 90d200bca96..82e01678b2f 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3184,21 +3184,30 @@ export const zh: Record = { "claudeDesktop.mode.defaultBadge": "默认", "claudeDesktop.mode.current": "当前", "claudeDesktop.mode.firstPartyHint": "Desktop 保持登录 claude.ai。开启 Desktop 第一方模式时,Code 标签页使用 OpenCodex;独立 CLI 有自己的开关。两者读取同一份设置环境变量。", + "claude.intercept.start": "启动拦截", + "claude.intercept.starting": "正在启动拦截…", + "claude.intercept.reason.disabled": "请先启用Claude路由和拦截。", + "claude.intercept.reason.client_role": "此进程是中心客户端。请在本地服务器启用拦截。", + "claude.intercept.reason.ephemeral_port": "公共端口为临时端口时,请设置claudeCode.intercept.port。", + "claude.intercept.reason.port_in_use": "端口{port}已被占用。请释放端口或修改claudeCode.intercept.port。", + "claude.intercept.reason.port_mismatch": "本次运行使用端口{bound},与配置端口{configured}不同。请使用当前端口或恢复配置。", + "claude.intercept.reason.failed": "无法启动拦截。请检查本地代理配置后重试。", + "claude.intercept.reason.stopped": "拦截已停止。请启动OpenCodex服务。", "claude.firstParty.label": "Claude Code CLI 第一方", "claude.firstParty.aria": "切换 Claude Code CLI 第一方模式", "claude.firstParty.risk": "账户风险:第一方模式让 Claude 订阅流量经过本地拦截代理。Anthropic 可能将其视为违反条款并暂停账户。", "claude.firstParty.shared": "如果共享代理设置已应用,另一个 Claude 客户端也会经由本地代理原样转发;TLS 在本地终止。若要在终端直连,请在 shell 中设置 NO_PROXY='*'。", "claude.firstParty.deadProxy": "Claude 设置指向未运行的代理。运行 OpenCodex 或关闭 Desktop/CLI 第一方模式之前,直接运行 claude 无法连接。", "claude.firstParty.notApplied": "CLI 第一方模式已开启,但代理设置尚未生效。运行普通 claude 前请检查 Claude 设置。", - "claude.firstParty.brokenProxy": "Claude 设置指向的 OpenCodex 代理与正在运行的监听器不匹配。请运行 `ocx ensure` 或重启 OpenCodex。", + "claude.firstParty.brokenProxy": "无法启动拦截。请检查本地代理配置后重试。", "claude.firstParty.residual": "Desktop 和 CLI 第一方模式均已关闭,但 Claude 设置仍指向 OpenCodex 代理。请删除残留代理设置或运行 `ocx ensure`。", "claude.firstParty.routingOff": "opencodex 当前未提供 Claude 拦截服务(Claude 路由或拦截功能已关闭,或此设备是另一台 opencodex 中枢的客户端),因此这些设置指向的代理无法处理请求。请在此设备上重新启用该服务,或关闭第一方模式以移除这些设置。", - "claude.firstParty.disabled": "opencodex 中的 Claude 路由已关闭。重启 opencodex 前,本地代理仍会原样转发这些请求;重启后,只要设置仍在,普通 claude 就无法连接。关闭第一方模式以移除设置。", + "claude.firstParty.disabled": "请先启用Claude路由和拦截。", "claude.firstParty.foreign": "~/.claude/settings.json 指向 opencodex 代理,却信任 opencodex 未管理的证书,因此请求会失败。请手动修正其中的 HTTPS_PROXY / NODE_EXTRA_CA_CERTS。", "claude.firstParty.local": "~/.claude/settings.json 让 Claude Code 使用 127.0.0.1 上的本地代理,但 opencodex 无法确认该代理是否由自己管理。如果不再使用,请删除其中的 HTTPS_PROXY。", "claude.firstParty.unknown": "opencodex 无法确认 ~/.claude/settings.json 是否仍指向它的代理。", "claude.firstParty.refusal.interceptDisabled": "拦截代理已关闭。", - "claude.firstParty.refusal.interceptUnavailable": "此进程未运行拦截代理。", + "claude.firstParty.refusal.interceptUnavailable": "无法启动拦截。请检查本地代理配置后重试。", "claude.firstParty.refusal.foreignEnv": "其他程序拥有 Claude 代理设置。", "claude.firstParty.refusal.caUnavailable": "本地证书颁发机构不可用。", "claude.firstParty.refusal.unreadable": "无法读取 Claude 设置。", @@ -3207,7 +3216,7 @@ export const zh: Record = { "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.proxyStopped": "无法启动拦截。请检查本地代理配置后重试。 ({port})", "claudeDesktop.firstParty.interceptDisabled": "配置中已禁用拦截代理(claudeCode.intercept.enabled)", "claudeDesktop.picker.title": "Claude Desktop 模型选择器", "claudeDesktop.picker.hint": "通过专用本地代理,将 OpenCodex 模型显示在 Desktop 的 Code 标签选择器中。", diff --git a/gui/src/pages/ClaudeCode.tsx b/gui/src/pages/ClaudeCode.tsx index 658b54e0ab9..2178f8e38a9 100644 --- a/gui/src/pages/ClaudeCode.tsx +++ b/gui/src/pages/ClaudeCode.tsx @@ -1,3 +1,4 @@ +import ClaudeInterceptStart from "../components/ClaudeInterceptStart"; import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import { Notice, Switch } from "../ui"; import { useI18n, useT, LOCALES, type TKey } from "../i18n/shared"; @@ -17,7 +18,7 @@ import { import { serializeSidecarOverride } from "./claude-code-sidecar"; import { AUTO_COMPACT_WINDOW_DEFAULT, formatCompactWindow, newClientId, type ClaudeCodeState, type MapRow } from "./claude-code-types"; import { SmallFastModelSetting } from "./claude-code-settings"; -import { normalizeSharedProxy, selectFirstPartyNotice, type FirstPartyNotice } from "./claude-code-first-party"; +import { interceptReasonKey, normalizeSharedProxy, selectFirstPartyNotice, type FirstPartyNotice } from "./claude-code-first-party"; export { AutoConnectSetting, SmallFastModelSetting } from "./claude-code-settings"; @@ -198,7 +199,7 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string body: JSON.stringify({ cliFirstParty: !state.cliFirstParty }), }); if (!response.ok) { - const payload = await response.json().catch(() => null) as { code?: string } | null; + const payload = await response.json().catch(() => null) as { code?: string; port?: number; bound?: number; configured?: number } | null; const refusalKeys = { intercept_disabled: "claude.firstParty.refusal.interceptDisabled", intercept_unavailable: "claude.firstParty.refusal.interceptUnavailable", @@ -210,7 +211,8 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string const key = payload?.code && payload.code in refusalKeys ? refusalKeys[payload.code as keyof typeof refusalKeys] : "claude.saveFailed"; - throw new Error(t(key)); + throw new Error(payload?.code && ["disabled", "client_role", "ephemeral_port", "port_in_use", "port_mismatch", "stopped", "failed"].includes(payload.code) + ? t(interceptReasonKey(payload.code), { port: payload.port ?? "", bound: payload.bound ?? "", configured: payload.configured ?? "" }) : t(key)); } await readJsonOrThrow(response, t("claude.saveFailed")); await fetchCode(new AbortController().signal); @@ -354,6 +356,7 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string label={t("claude.firstParty.aria")} />
+ {!state.interceptRunning && codeResource.refresh()} />} {state.cliFirstParty && ( {t("claude.firstParty.risk")} )} diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index e24aab1dfe2..e8a4f579194 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -1,3 +1,4 @@ +import ClaudeInterceptStart from "../components/ClaudeInterceptStart"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react"; import { LANE_PAGE, defaultCollapsedFamilies, laneView, rowStartsOpen } from "./claude-desktop-lane"; import { makeCollapseStore, toggleInSet } from "./collapse-store"; @@ -68,6 +69,9 @@ interface DesktopFirstPartyStatus { applied: boolean; stale: boolean; interceptEnabled: boolean; + interceptReason?: string | null; + pickerReason?: string | null; + pickerFailurePort?: number; interceptRunning: boolean; proxyPort: number; caCertPath: string; @@ -604,9 +608,7 @@ export default function ClaudeDesktop({ {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")} + : statusResource.refresh()} />} )} {status?.health.lastRequestAt && ( @@ -634,6 +636,8 @@ export default function ClaudeDesktop({ key={`${status.firstParty.picker.reason}:${status.firstParty.picker.desired}:${status.firstParty.picker.effective}:${status.firstParty.picker.models}:${status.firstParty.picker.hint ?? ""}`} apiBase={apiBase} picker={status.firstParty.picker} + pickerReason={status.firstParty.pickerReason} + pickerFailurePort={status.firstParty.pickerFailurePort} onUpdated={() => void statusResource.refresh()} /> ) diff --git a/gui/src/pages/claude-code-first-party.ts b/gui/src/pages/claude-code-first-party.ts index ec058243d7f..1a795908301 100644 --- a/gui/src/pages/claude-code-first-party.ts +++ b/gui/src/pages/claude-code-first-party.ts @@ -1,3 +1,4 @@ +import type { TKey } from "../i18n/shared"; import type { ClaudeCodeState } from "./claude-code-types"; export const FIRST_PARTY_PROXY_STATUSES = [ @@ -49,3 +50,15 @@ export function selectFirstPartyNotice( if (state.sharedProxy === "live" && state.desktopFirstParty !== state.cliFirstParty) return "shared"; return null; } + +export function interceptReasonKey(reason: string | null | undefined): TKey { + switch (reason) { + case "disabled": return "claude.intercept.reason.disabled"; + case "client_role": return "claude.intercept.reason.client_role"; + case "ephemeral_port": return "claude.intercept.reason.ephemeral_port"; + case "port_in_use": return "claude.intercept.reason.port_in_use"; + case "port_mismatch": return "claude.intercept.reason.port_mismatch"; + case "stopped": return "claude.intercept.reason.stopped"; + default: return "claude.intercept.reason.failed"; + } +} diff --git a/gui/src/pages/claude-code-types.ts b/gui/src/pages/claude-code-types.ts index 035b4af36c5..d3f07ce7418 100644 --- a/gui/src/pages/claude-code-types.ts +++ b/gui/src/pages/claude-code-types.ts @@ -32,6 +32,10 @@ export interface ClaudeCodeState { cliFirstParty: boolean; cliFirstPartyApplied: boolean; desktopFirstParty: boolean; + interceptReason?: string | null; + pickerReason?: string | null; + pickerFailurePort?: number; + interceptFailurePort?: number; interceptRunning: boolean; interceptEligible: boolean; sharedProxy: "none" | "live" | "stopped" | "disabled" | "broken" | "foreign" | "local" | "unknown"; diff --git a/gui/tests/claude-desktop-mode-picker.test.tsx b/gui/tests/claude-desktop-mode-picker.test.tsx index f739ad50ce1..a3ecb162d21 100644 --- a/gui/tests/claude-desktop-mode-picker.test.tsx +++ b/gui/tests/claude-desktop-mode-picker.test.tsx @@ -184,7 +184,11 @@ test("a stopped intercept proxy is surfaced in first-party mode", async () => { 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"); + // A stopped proxy names the problem and offers an in-place start; it never asks for a restart. + const bar = container.querySelector(".claude-status-bar")?.textContent ?? ""; + expect(bar).toContain("Interception could not start"); + expect(bar).toContain("Start interception"); + expect(bar.toLowerCase()).not.toContain("restart"); }); test("an applied first-party warning stays visible below status while gateway is selected", async () => { diff --git a/gui/tests/claude-desktop-picker.test.tsx b/gui/tests/claude-desktop-picker.test.tsx index 76cbfc5bbfc..f4ed7cd4caf 100644 --- a/gui/tests/claude-desktop-picker.test.tsx +++ b/gui/tests/claude-desktop-picker.test.tsx @@ -101,3 +101,43 @@ test("sends the persisted toggle and renders a reported proxy refusal", async () expect(container.querySelector(".claude-picker-state")?.textContent).toContain("Waiting for the keychain step"); expect(container.querySelector(".claude-picker-state code")?.textContent).toBe("ocx claude desktop picker trust"); }); + + +test("starting interception stays pending and reports the occupied port", async () => { + let resolve!: (value: Response) => void; + const response = new Promise(done => { resolve = done; }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: (url: string, init?: RequestInit) => { + requests.push({ url, init }); return response; + } }); + await mount({ ...basePicker, listenerReady: false, effective: false, reason: "proxy_unavailable" }); + const button = container.querySelector("button.btn") as HTMLButtonElement; + expect(button.textContent).toBe("Start interception"); + await act(async () => { button.click(); }); + expect(button.disabled).toBe(true); + expect(button.textContent).toBe("Starting interception…"); + expect(requests[0]).toMatchObject({ url: "/api/claude-intercept/start", init: { method: "POST" } }); + await act(async () => { resolve(Response.json({ ok: false, reason: "port_in_use", port: 10200 }, { status: 409 })); }); + expect(button.disabled).toBe(false); + expect(container.textContent).toContain("Port 10200 is in use"); +}); + +test("picker failure reports the reason without offering an ineffective main-pair start", async () => { + await act(async () => { + root = createRoot(container); + root.render(); + }); + expect(container.textContent).toContain("Port 10300 is in use"); + expect(container.textContent).not.toContain("Start interception"); + expect(container.querySelector("[role=status]")).not.toBeNull(); + expect(requests).toHaveLength(0); +}); + + +test("picker bind failure with unknown port uses a complete fallback message", async () => { + await act(async () => { + root = createRoot(container); + root.render(); + }); + expect(container.textContent).toContain("Interception could not start."); + expect(container.textContent).not.toContain("Port is in use"); +}); diff --git a/gui/tests/claude-toggle-race.test.tsx b/gui/tests/claude-toggle-race.test.tsx index e5a7ce5a6a9..422fece21a0 100644 --- a/gui/tests/claude-toggle-race.test.tsx +++ b/gui/tests/claude-toggle-race.test.tsx @@ -304,7 +304,7 @@ test("CLI first-party waits for one PUT and a confirmed GET", async () => { const refusalCases = [ ["intercept_disabled", "The interception proxy is disabled."], - ["intercept_unavailable", "The interception proxy is not running in this process."], + ["intercept_unavailable", "Interception could not start."], ["foreign_env", "Another program owns the Claude proxy settings."], ["ca_unavailable", "The local certificate authority is unavailable."], ["unreadable", "Claude settings could not be read."], diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 48a167a139a..dce0e7d90da 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,6 +2,7 @@ "version": 1, "root": "tests", "explicit": { + "claude-intercept-on-demand.test.ts": "claude-integration", "main-account-hard-lock-thresholds.test.ts": "codex-integration", "main-account-external-usage.test.ts": "codex-integration", "jev-decision-model-config.test.ts": "routing", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index bb109cba2b7..1257e2be387 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -1062,6 +1062,20 @@ JSON mode: `payload`. - `status` reads the route; `set` writes only submitted fields. Enabling first-party requires a running Claude intercept. +### `ocx claude intercept start` + +Start the local Claude interception pair on demand. + +| Method | Route | +|---|---| +| POST | `/api/claude-intercept/start` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the management response as JSON. | + +JSON mode: `payload`. + ### `ocx claude desktop bind` First-party: serve a Claude Desktop Code tab picker model with an opencodex route. @@ -1301,6 +1315,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 72 -- of those, state-changing: 41 +- declared capabilities: 73 +- of those, state-changing: 42 - head-resolved invocations: 2 diff --git a/src/claude/intercept/runtime.ts b/src/claude/intercept/runtime.ts index e65cb3a48dc..214343e31f0 100644 --- a/src/claude/intercept/runtime.ts +++ b/src/claude/intercept/runtime.ts @@ -51,7 +51,11 @@ export function claudePickerProxyPort(config: Pick, pub return interceptPort < 65535 ? interceptPort + 1 : interceptPort - 1; } +export type ClaudeInterceptOutcome = { ok: true; state: ClaudeInterceptState } | { ok: false; reason: "disabled" | "client_role" | "ephemeral_port" | "port_in_use" | "stopped" | "failed"; port?: number; message?: string }; + export interface ClaudeInterceptState { + pickerReason?: "port_in_use" | "failed" | null; + pickerFailurePort?: number; proxyPort: number; caCertPath: string; /** Desktop egress proxy for picker mode; null when the picker is not wired or could not bind. */ @@ -102,6 +106,14 @@ export async function createPickerPreferenceWriter(live: OcxConfig): Promise<(va }; } +export class ClaudeInterceptProxyBindError extends Error { + readonly code: unknown; + constructor(error: unknown, readonly port: number) { + super(error instanceof Error ? error.message : "CONNECT proxy bind failed", { cause: error }); + this.code = error && typeof error === "object" && "code" in error ? error.code : undefined; + } +} + export interface StartClaudeInterceptOptions { config: OcxConfig; /** Bound public port; the derived proxy port is offset from it. */ @@ -178,13 +190,15 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio }); } catch (error) { await listener.stop(true); - throw error; + throw new ClaudeInterceptProxyBindError(error, claudeInterceptProxyPort(options.config, options.publicPort)); } // Widened on purpose: assignments happen in nested awaits the catch below must still see. let picker = null as PickerRuntime | null; let pickerProxy = null as ConnectProxyHandle | null; let controller = null as DesktopPickerController | null; let pickerProxyLive = false; + let pickerReason: ClaudeInterceptState["pickerReason"] = null; + let pickerFailurePort: number | undefined; try { if (options.loadPickerRoutes) { // A picker authority is process-scoped, and older releases persisted an exportable ca.key @@ -213,9 +227,11 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio } } catch (error) { pickerBlocked = true; + pickerReason = "failed"; console.warn(`⚠ Claude Desktop picker CA cleanup deferred: ${error instanceof Error ? error.message : String(error)}`); } if (pickerBlocked) { + pickerReason = "failed"; console.warn("⚠ Claude Desktop picker disabled: the previous certificate could not be untrusted"); if (pickerProfile.kind === "applied") { // Keep Desktop's actual pinned egress alive without ever constructing a TLS terminator. @@ -228,6 +244,8 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio }); pickerProxyLive = true; } catch (error) { + pickerReason = error && typeof error === "object" && "code" in error && error.code === "EADDRINUSE" ? "port_in_use" : "failed"; + if (pickerReason === "port_in_use") pickerFailurePort = port; console.warn(`⚠ Claude Desktop blind egress relay could not start: ${error instanceof Error ? error.message : String(error)}`); } } @@ -246,8 +264,9 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio if (picker) { const runtime = picker; const interceptPort = listener.port!; + const pickerPort = claudePickerProxyPort(options.config, options.publicPort); try { - pickerProxy = await startProxy(claudePickerProxyPort(options.config, options.publicPort), { + pickerProxy = await startProxy(pickerPort, { interceptPort, // No authToken: Desktop's egressProxyUrl cannot present proxy credentials, so this // listener stays an unauthenticated loopback relay until the profile format can carry @@ -272,6 +291,8 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio // Picker mode is optional: a busy port leaves the intercept pair running without it. // The selected profile row survives, so the next startup retries the restore once the // port is free again. + pickerReason = error && typeof error === "object" && "code" in error && error.code === "EADDRINUSE" ? "port_in_use" : "failed"; + if (pickerReason === "port_in_use") pickerFailurePort = pickerPort; console.warn(`⚠ Claude Desktop picker proxy could not start: ${error instanceof Error ? error.message : String(error)}`); await runtime.stop(); picker = null; @@ -318,6 +339,8 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio throw error; } const state: ClaudeInterceptState = { + pickerReason, + pickerFailurePort, proxyPort: proxy.port, caCertPath: claudeInterceptCaCertPath(configDir), pickerProxyPort: pickerProxy?.port ?? null, diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 02f0a640c63..fd7bf3c745e 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -915,6 +915,14 @@ export const CAPABILITIES: readonly Capability[] = [ json: "payload", details: ["`status` reads the route; `set` writes only submitted fields. Enabling first-party requires a running Claude intercept."], }, + { + command: ["claude", "intercept", "start"], + summary: "Start the local Claude interception pair on demand.", + routes: [{ method: "POST", path: "/api/claude-intercept/start" }], + flags: [{ name: "--json", value: "boolean", summary: "Emit the management response as JSON." }], + mutates: true, + json: "payload", + }, { command: ["claude", "desktop", "status"], summary: "Applied-vs-desired Claude Desktop state, including staleness, drift, and health.", diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 0e9ef882b6d..5f8a91b9760 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -960,6 +960,10 @@ const commandRunners: Record = { if (exitCode !== 0) return exitCode; return 0; } + if (deps.args[1] === "intercept") { + const { handleClaudeInterceptCommand } = await import("./integrations"); + return await handleClaudeInterceptCommand(deps.args.slice(2)); + } if (deps.args[1] === "config") { const { handleClaudeConfigCommand } = await import("./integrations"); return await handleClaudeConfigCommand(deps.args.slice(2)); diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index 1caa2be0cb0..984c616c324 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -368,3 +368,16 @@ export async function handleZcodeCommand(argv: string[], deps: RuntimeApiDeps = } return code; } + +export async function handleClaudeInterceptCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + return runCliAction(async () => { + const args = [...argv]; + const action = args.shift(); + const wantsJson = takeFlag(args, "--json"); + const usage = "Usage: ocx claude intercept start [--json]"; + rejectArgs(args, usage); + if (action !== "start") throw new CliUsageError("Expected start", usage); + const result = await runtimeRequest("/api/claude-intercept/start", { method: "POST" }, deps); + printData(result, wantsJson, summaryLines(result)); + }); +} diff --git a/src/server/index/claude-intercept-lifecycle.ts b/src/server/index/claude-intercept-lifecycle.ts index 7e9336f591e..3494bd2d269 100644 --- a/src/server/index/claude-intercept-lifecycle.ts +++ b/src/server/index/claude-intercept-lifecycle.ts @@ -8,6 +8,8 @@ import { startClaudeIntercept, type ClaudeInterceptHandle, type StartClaudeInterceptOptions, + type ClaudeInterceptOutcome, + ClaudeInterceptProxyBindError, } from "../../claude/intercept/runtime"; /** @@ -20,6 +22,8 @@ export interface ClaudeInterceptLifecycle { /** True once the TLS listener has bound and `requestServer` is it. */ ownsListener(requestServer: Server): boolean; start(options: StartClaudeInterceptOptions): void; + ensure(): Promise; + lastOutcome(): ClaudeInterceptOutcome | null; stop(): Promise; } @@ -53,34 +57,76 @@ export async function loadPickerRoutesFromCatalog(): Promise { export function createClaudeInterceptLifecycle(): ClaudeInterceptLifecycle { let listener: Server | null = null; - let pending: Promise | null> = Promise.resolve(null); + let options: StartClaudeInterceptOptions | undefined; + let handle: ClaudeInterceptHandle | null = null; + let inflight: Promise | undefined; + let stopped = false; + let startupPending = false; + let retryAfterStartup = false; + let observed: ClaudeDesktopModeObservation | undefined; + const stateOf = (bound: ClaudeInterceptHandle) => ({ proxyPort: bound.proxyPort, caCertPath: bound.caCertPath, pickerProxyPort: bound.pickerProxyPort, pickerReason: bound.pickerReason ?? null, pickerFailurePort: bound.pickerFailurePort }); + let outcome: ClaudeInterceptOutcome | null = null; + const ensure = (): Promise => { + if (stopped) return Promise.resolve(outcome = { ok: false, reason: "stopped" }); + if (inflight) { + if (startupPending) retryAfterStartup = true; + return inflight; + } + if (handle) { + if (options) observed = observeClaudeDesktopMode(options.config); + return Promise.resolve(outcome = { ok: true, state: stateOf(handle) }); + } + if (!options) return Promise.resolve(outcome = { ok: false, reason: "failed" }); + const opts = options; + const run = async (): Promise => { + if (opts.config.runtimeRole === "client") return { ok: false, reason: "client_role" }; + if (!claudeInterceptEnabled(opts.config)) return { ok: false, reason: "disabled" }; + if (opts.requestedPort === 0 && typeof opts.config.claudeCode?.intercept?.port !== "number") + return { ok: false, reason: "ephemeral_port" }; + try { + observed = observeClaudeDesktopMode(opts.config); + handle = await startClaudeIntercept({ + ...opts, + desiredClients: () => buildInterceptDesiredClients(opts.config, observed!)(), + loadPickerRoutes: opts.loadPickerRoutes ?? loadPickerRoutesFromCatalog, + dispatch: (req, requestServer) => { + listener ??= requestServer; + return opts.dispatch(req, requestServer); + }, + }); + if (!handle) return { ok: false, reason: "disabled" }; + listener = handle.listener; + console.log(`🔐 Claude intercept proxy active on http://127.0.0.1:${handle.proxyPort} (CONNECT api.anthropic.com → local TLS)`); + return { ok: true, state: stateOf(handle) }; + } catch (error) { + listener = null; + console.warn(`⚠ Claude intercept proxy could not start: ${error instanceof Error ? error.message : String(error)}`); + return error instanceof ClaudeInterceptProxyBindError && error.code === "EADDRINUSE" + ? { ok: false, reason: "port_in_use", port: error.port } + : { ok: false, reason: "failed" }; + } + }; + inflight = run().then(async result => { + // An ensure arriving during startup first joins that attempt, then retries its null outcome. + if (!result.ok && retryAfterStartup && !stopped) return run(); + return result; + }).then(result => outcome = result).finally(() => { + inflight = undefined; startupPending = false; retryAfterStartup = false; + }); + return inflight; + }; return { ownsListener: requestServer => listener !== null && requestServer === listener, - start(options) { - const dispatch = options.dispatch; - const observed = observeClaudeDesktopMode(options.config); - pending = startClaudeIntercept({ - ...options, - // A disabled Claude surface relays everything while the bound listener lives until restart. - desiredClients: buildInterceptDesiredClients(options.config, observed), - loadPickerRoutes: options.loadPickerRoutes ?? loadPickerRoutesFromCatalog, - 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; - }); - }, + start(opts) { options = opts; startupPending = true; void ensure(); }, + ensure, + lastOutcome: () => outcome, async stop() { - await (await pending)?.stop(); + stopped = true; + await inflight; + await handle?.stop(); + handle = null; + listener = null; + outcome = { ok: false, reason: "stopped" }; }, }; } diff --git a/src/server/index/optional-listeners.ts b/src/server/index/optional-listeners.ts index 6df3225f35d..7fbf5b14470 100644 --- a/src/server/index/optional-listeners.ts +++ b/src/server/index/optional-listeners.ts @@ -28,6 +28,8 @@ export interface OptionalListenerStartContext { } export interface OptionalListenerSet { + ensureClaudeIntercept(): ReturnType["ensure"]>; + claudeInterceptOutcome(): ReturnType["lastOutcome"]>; ingressOf(server: Server): ServerIngress | undefined; linkRouteAllowed(url: URL, req: Request): boolean; linkAdmissionKeyIds(): ReadonlySet; @@ -68,6 +70,8 @@ export function createOptionalListenerSet(linkDeps: LinkListenerDeps = {}): O let supervisorStop: (() => Promise) | undefined; return { + ensureClaudeIntercept: () => claudeIntercept.ensure(), + claudeInterceptOutcome: () => claudeIntercept.lastOutcome(), ingressOf(server) { if (linkListener.ownsListener(server)) return "hub-link"; if (claudeIntercept.ownsListener(server)) return "claude-intercept"; diff --git a/src/server/management-api.ts b/src/server/management-api.ts index d4e45d33a4e..1f6b28e427d 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -66,6 +66,7 @@ import { handleRoutingAnalyticsRoutes } from "./management/routing-analytics-rou import { handleMetricsRoutes } from "./management/metrics-routes"; import { handleProviderRoutes } from "./management/provider-routes"; import { handleModelRoutes } from "./management/model-routes"; +import { handleClaudeInterceptRoutes } from "./management/claude-intercept-routes"; import { handleAgentSettingsRoutes } from "./management/agent-settings-routes"; import { handleOauthAccountRoutes } from "./management/oauth-account-routes"; import { handleComboRoutes } from "./management/combo-routes"; @@ -333,7 +334,8 @@ export async function handleManagementAPI( } let routed: Response | null | undefined; try { - routed = handleSessionRoutes(ctx) + routed = await handleClaudeInterceptRoutes(ctx) + ?? handleSessionRoutes(ctx) ?? (await handleLinkRoutesOnDemand(ctx)) ?? (await handleRemoteWorkspaceRoutesOnDemand(ctx)) ?? (await handleConfigRoutes(ctx)) diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index c35b83d3ebd..154b1e6dce2 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1,3 +1,4 @@ +import { ensureManagementClaudeIntercept, interceptStartRefusal, interceptStatus } from "./claude-intercept-routes"; import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state"; import { captureDesktopAppliedMarker, commitDesktopAppliedMarker } from "../../claude/desktop-applied-marker"; import { randomUUID } from "node:crypto"; @@ -1138,6 +1139,13 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } } if (desktopMode === "first-party") { + const refusal = interceptStartRefusal(ctx); + if (refusal) return refusal; + const started = await ensureManagementClaudeIntercept(ctx); + if (!started.ok) return jsonResponse({ ...started, code: started.reason }, 409); + const { claudeInterceptProxyPort } = await import("../../claude/intercept/runtime"); + const configured = claudeInterceptProxyPort(config, config.port ?? 10100); + if (started.state.proxyPort !== configured) return jsonResponse({ ok: false, code: "port_mismatch", bound: started.state.proxyPort, configured }, 409); 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); @@ -1323,6 +1331,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise stale: firstPartySeen.stale, interceptEnabled: firstPartySeen.interceptEnabled, interceptRunning: intercept !== null, + ...interceptStatus(ctx), proxyPort: intercept?.proxyPort ?? firstPartySeen.proxyPort, caCertPath: firstPartySeen.caCertPath, // What the running proxy routes with right now (live config), not the file on disk. @@ -1495,6 +1504,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise cliFirstParty: config.claudeCode?.cliFirstParty === true, cliFirstPartyApplied: config.claudeCode?.cliFirstParty === true && sharedProxy === "live", desktopFirstParty: desired.desktop, + ...interceptStatus(ctx), interceptEligible: eligible, interceptRunning: bound !== null && eligible, sharedProxy, @@ -1564,14 +1574,17 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const { captureDesktopFirstPartyRollback, inspectDesktopFirstParty, observeClaudeDesktopMode, resolveClaudeDesktopMode } = await import("../../claude/desktop-first-party"); const { firstPartyDesired, readFirstPartyProxyStatus, reconcileClaudeFirstPartySettings } = await import("../../claude/first-party-settings"); const { commitClaudeCodeBlock } = await import("../../claude/claude-code-block"); - const bound = (deps.getClaudeInterceptState ?? getClaudeInterceptState)(); + let bound = (deps.getClaudeInterceptState ?? getClaudeInterceptState)(); if (body.cliFirstParty) { - if (!claudeInterceptEnabled(config)) return jsonResponse({ error: "Claude intercept is disabled", code: "intercept_disabled" }, 409); - if (bound === null) return jsonResponse({ error: "Claude intercept is unavailable", code: "intercept_unavailable" }, 409); + const startRefusal = interceptStartRefusal(ctx); + if (startRefusal) return startRefusal; + const started = await ensureManagementClaudeIntercept(ctx); + if (!started.ok) return jsonResponse({ ...started, code: started.reason }, 409); + bound = started.state; const targetPort = claudeInterceptProxyPort(config, config.port ?? 10100); if (targetPort !== bound.proxyPort) return jsonResponse({ - error: `Claude intercept port mismatch (configured ${targetPort}, bound ${bound.proxyPort}); restart needed`, - code: "intercept_unavailable", + error: `Claude intercept port mismatch (configured ${targetPort}, bound ${bound.proxyPort})`, + code: "port_mismatch", bound: bound.proxyPort, configured: targetPort, }, 409); let inspection: ReturnType["settings"]; try { inspection = inspectDesktopFirstParty(config).settings; } @@ -1583,7 +1596,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise try { if (body.cliFirstParty) restoreSettings = captureDesktopFirstPartyRollback(config); } catch { return jsonResponse({ error: "Claude settings are unreadable", code: "unreadable" }, 500); } type FirstPartyMutation = - | { refusal: { error: string; code: "intercept_disabled" | "intercept_unavailable" } } + | { refusal: { error: string; code: "disabled" | "port_mismatch"; bound?: number; configured?: number } } | { claudeCode: OcxConfig["claudeCode"]; previous: { present: boolean; value: boolean }; pinnedMode: "first-party" | "gateway" | undefined; retainedAmbiguous: boolean }; let outcome: ReturnType>; @@ -1591,11 +1604,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise outcome = mutatePersistedConfig(persisted => { if (body.cliFirstParty) { if (!claudeInterceptEnabled(persisted)) return { changed: false, value: { - refusal: { error: "Claude intercept is disabled", code: "intercept_disabled" as const } } }; + refusal: { error: "Claude intercept is disabled", code: "disabled" as const } } }; const persistedPort = claudeInterceptProxyPort(persisted, persisted.port ?? 10100); if (bound === null || persistedPort !== bound.proxyPort) return { changed: false, value: { - refusal: { error: `Claude intercept port mismatch (configured ${persistedPort}, bound ${bound?.proxyPort ?? "none"}); restart needed`, - code: "intercept_unavailable" as const } } }; + refusal: { error: `Claude intercept port mismatch (configured ${persistedPort}, bound ${bound?.proxyPort ?? "none"})`, + code: "port_mismatch" as const, bound: bound?.proxyPort, configured: persistedPort } } }; } const before = structuredClone(persisted); const previous = { present: Object.hasOwn(persisted.claudeCode ?? {}, "cliFirstParty"), diff --git a/src/server/management/claude-desktop-picker-routes.ts b/src/server/management/claude-desktop-picker-routes.ts index bca4e6e0e8f..337746e8494 100644 --- a/src/server/management/claude-desktop-picker-routes.ts +++ b/src/server/management/claude-desktop-picker-routes.ts @@ -1,3 +1,4 @@ +import { ensureManagementClaudeIntercept, interceptStartRefusal } from "./claude-intercept-routes"; /** * Claude Desktop picker mode over the management API, and the transition helpers every Desktop * mode change uses. @@ -76,6 +77,12 @@ export async function handleClaudeDesktopPickerRoutes(ctx: ManagementContext): P } const body = parsePickerBody(raw); if (typeof body === "string") return jsonResponse({ error: body }, 400); + if (body.enabled) { + const refusal = interceptStartRefusal(ctx); + if (refusal) return refusal; + const started = await ensureManagementClaudeIntercept(ctx); + if (!started.ok) return jsonResponse({ ...started, code: started.reason }, 409); + } const controller = await currentPickerController(); if (body.enabled) { if (!controller) { diff --git a/src/server/management/claude-intercept-routes.ts b/src/server/management/claude-intercept-routes.ts new file mode 100644 index 00000000000..2d34aeac7aa --- /dev/null +++ b/src/server/management/claude-intercept-routes.ts @@ -0,0 +1,35 @@ +import type { ManagementContext } from "./context"; +import { jsonResponse } from "../auth-cors"; +import { claudeInterceptEnabled, getClaudeInterceptState, type ClaudeInterceptOutcome } from "../../claude/intercept/runtime"; + +/** Local management authority only; data-plane credentials never grant this capability. */ +export function interceptStartRefusal(ctx: ManagementContext): Response | null { + return ctx.trustedLoopbackIngress && (ctx.principal === "gui-session" || ctx.principal === "admin-token") + ? null : jsonResponse({ ok: false, code: "intercept_start_forbidden", error: "Local management authority is required." }, 403); +} + +export async function ensureManagementClaudeIntercept(ctx: ManagementContext): Promise { + if (ctx.config.runtimeRole === "client") return { ok: false, reason: "client_role" }; + if (!claudeInterceptEnabled(ctx.config)) return { ok: false, reason: "disabled" }; + if (ctx.deps.ensureClaudeIntercept) return ctx.deps.ensureClaudeIntercept(); + const injected = ctx.deps.getClaudeInterceptState?.(); + if (injected) return { ok: true, state: injected }; + const listener = ctx.deps.linkListener?.(); + if (listener?.ensureClaudeIntercept) return listener.ensureClaudeIntercept(); + const state = (ctx.deps.getClaudeInterceptState ?? getClaudeInterceptState)(); + return state ? { ok: true, state } : { ok: false, reason: "failed" }; +} + +export function interceptStatus(ctx: ManagementContext): { interceptReason: string | null; pickerReason: string | null; pickerFailurePort?: number; interceptFailurePort?: number } { + const outcome = ctx.deps.linkListener?.()?.claudeInterceptOutcome?.(); + const state = (ctx.deps.getClaudeInterceptState ?? getClaudeInterceptState)(); + return { ...(outcome && !outcome.ok ? { interceptFailurePort: outcome.port } : {}), interceptReason: outcome && !outcome.ok ? outcome.reason : null, pickerReason: state?.pickerReason ?? null, pickerFailurePort: state?.pickerFailurePort }; +} + +export async function handleClaudeInterceptRoutes(ctx: ManagementContext): Promise { + if (ctx.url.pathname !== "/api/claude-intercept/start" || ctx.req.method !== "POST") return null; + const refusal = interceptStartRefusal(ctx); + if (refusal) return refusal; + const outcome = await ensureManagementClaudeIntercept(ctx); + return jsonResponse(outcome, outcome.ok ? 200 : 409); +} diff --git a/src/server/management/context.ts b/src/server/management/context.ts index 5945f842d02..9162b855034 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -48,6 +48,7 @@ export interface ManagementApiDeps { /** Bound to this server's lifecycle owner; absent in direct route tests. */ listLowQuotaEvents?: (limit?: number) => LowQuotaEvent[]; /** Bound Claude intercept state, injectable for isolated management-route tests. */ + ensureClaudeIntercept?: () => Promise; getClaudeInterceptState?: typeof import("../../claude/intercept/runtime").getClaudeInterceptState; /** Reconciliation seam for field-scoped rollback tests. */ reconcileClaudeFirstPartySettings?: typeof import("../../claude/first-party-settings").reconcileClaudeFirstPartySettings; @@ -151,7 +152,7 @@ export interface ManagementApiDeps { codexPromptPaths?: CodexPromptPaths; /** Link seams are getters so the optional listener and supervisor are singletons. */ linkSupervisor?: () => LinkSupervisor; - linkListener?: () => Pick, "ensureStarted" | "status" | "close" | "onAuthenticatedCatalog">; + linkListener?: () => Pick, "ensureStarted" | "status" | "close" | "onAuthenticatedCatalog"> & Partial, "ensureClaudeIntercept" | "claudeInterceptOutcome">>; readLinkStore?: () => LinkStore; writeLinkStore?: (store: LinkStore) => void; linkKnownHostsPath?: () => string; diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index db53b59d695..7e8bf6069b4 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -1,3 +1,4 @@ +import { ensureManagementClaudeIntercept, interceptStartRefusal } from "./claude-intercept-routes"; import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state"; import { commitClaudeCodeBlock } from "../../claude/claude-code-block"; /** @@ -94,6 +95,7 @@ export interface NativeToggleEnvelope { desiredEnabled: boolean; /** Present when the outcome needs more than success/failure to be honest. */ reason?: string; + interceptReason?: string | null; artifacts?: CodexNativeRestoreResult["artifacts"]; } @@ -748,6 +750,13 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { const rollback = captureDesktopFirstPartyRollback(current); @@ -924,11 +933,18 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro throw error; } + let interceptReason = enabled && interceptStartRefusal(ctx) ? "intercept_start_forbidden" : null; + if (enabled && !interceptReason) { + const outcome = await ensureManagementClaudeIntercept(ctx); + if (!outcome.ok) interceptReason = outcome.reason; + } + return jsonResponse({ ok: true, clientId: "claude", changed: true, state: enabled ? "current" : "absent", desiredEnabled: enabled, message: enabled ? "Claude inbound enabled" : "Claude inbound disabled", + interceptReason, } satisfies NativeToggleEnvelope); } diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 0d2266e7646..702db41fc91 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -157,6 +157,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "PUT", path: "/api/claude-code", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/claude-desktop", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/claude-desktop/first-party-bindings", module: "server/management/agent-settings-routes", mutates: true }, + // server/management/claude-intercept-routes + { method: "POST", path: "/api/claude-intercept/start", module: "server/management/claude-intercept-routes", mutates: true }, + // server/management/claude-desktop-picker-routes { method: "GET", path: "/api/claude-desktop/picker", module: "server/management/claude-desktop-picker-routes", mutates: false }, { method: "PUT", path: "/api/claude-desktop/picker", module: "server/management/claude-desktop-picker-routes", mutates: true }, diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index db623554a9e..56b5a89deb0 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -80,6 +80,8 @@ only while CLI first-party intent is off. An owned env observed with `claudeCode.cliFirstParty === true` is not Desktop-mode evidence, even when the intercept is disabled; foreign proxy settings do not count. `resolveClaudeDesktopApplyMode` preserves the resolved mode. +First-party apply refuses `port_mismatch` before writing settings when the configured proxy port differs from the bound pair. Picker listener failures show their reason instead of offering a main-pair start that cannot repair them. + An apply for a first-party install with `claudeCode.intercept.enabled: false` is refused with `intercept_disabled` rather than switched to gateway. New installs apply gateway. `src/claude/desktop-risk.ts` owns the account-suspension warning: first-party sends subscription diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 675755e3af3..89c05c8a39d 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -284,7 +284,7 @@ effective window when absent, and offers a read-only Reload after an unknown sav confirmed save whose list reload failed; a failed or retryable Codex catalog refresh is a warning after the save, and a non-retryable skip (no managed catalog) is a clean save. Close returns focus to the opening row control. -`GET /api/claude-code` reports `cliFirstParty`, `desktopFirstParty`, `cliFirstPartyApplied`, `interceptEligible`, `interceptRunning`, and the eight-value `sharedProxy: FirstPartyProxyStatus` from observed settings and the bound listener. `interceptEligible = claudeInterceptEnabled(config)` uses the same GET snapshot as `sharedProxy` and `interceptRunning`; the latter remains bound listener present AND eligible. The ordered classifier gives unreadable → `unknown`, absent or non-loopback URL → `none`, foreign CA with an opencodex token → `foreign`, foreign CA with a tokenless loopback URL → `local`, no bound listener → `stopped`, ineligible applied settings at the bound port → `disabled`, other ineligible or stale/mismatched settings → `broken`, and eligible applied settings at the bound port → `live`. `cliFirstPartyApplied` requires CLI intent and `live`. `PUT /api/claude-code` accepts standalone `cliFirstParty`; CLI-on repeats eligibility and port checks inside the locked persisted mutation, reconciles, and conditionally rolls back its own fields on failure. CLI-off deletes intent, pins an absent `desktopMode` from the post-clear observation, and retains an env Desktop still desires; when that retained env's ownership was ambiguous (an owned shared proxy suppressed by the flag), the success response warns `shared_proxy_retained` so the operator can pin `gateway` explicitly to release it. A successful nothing-desired reconcile returns `settings_residual` for every status except `none`, including `local`; unreadable cleanup returns the coded 500. `enabled:false` alone leaves the env untouched, and a mixed body returns 400 before save. GUI normalization maps only missing `sharedProxy:undefined` to `none`, invalid statuses including `null` to `unknown`; `interceptEligible:undefined` from an older cache maps to `true`, while present values use `=== true`. The source coverage map checks all eight statuses. Notice order is unknown, foreign, local, residual when undesired, disabled, routingOff for stopped/broken with ineligible routing, stopped, broken, notApplied, shared, null. Unknown copy states uncertainty, local copy names the unconfirmed 127.0.0.1 proxy and manual HTTPS_PROXY removal, disabled copy retains the first-party-off remedy, foreign copy directs manual CA/proxy repair, routingOff says to restore Claude routing or turn first-party off, stopped says to start opencodex, and eligible broken advises `ocx ensure` or restart. +`GET /api/claude-code` reports `cliFirstParty`, `desktopFirstParty`, `cliFirstPartyApplied`, `interceptEligible`, `interceptRunning`, and the eight-value `sharedProxy: FirstPartyProxyStatus` from observed settings and the bound listener. `interceptEligible = claudeInterceptEnabled(config)` uses the same GET snapshot as `sharedProxy` and `interceptRunning`; the latter remains bound listener present AND eligible. The ordered classifier gives unreadable → `unknown`, absent or non-loopback URL → `none`, foreign CA with an opencodex token → `foreign`, foreign CA with a tokenless loopback URL → `local`, no bound listener → `stopped`, ineligible applied settings at the bound port → `disabled`, other ineligible or stale/mismatched settings → `broken`, and eligible applied settings at the bound port → `live`. `cliFirstPartyApplied` requires CLI intent and `live`. `PUT /api/claude-code` accepts standalone `cliFirstParty`; CLI-on repeats eligibility and port checks inside the locked persisted mutation, reconciles, and conditionally rolls back its own fields on failure. CLI-off deletes intent, pins an absent `desktopMode` from the post-clear observation, and retains an env Desktop still desires; when that retained env's ownership was ambiguous (an owned shared proxy suppressed by the flag), the success response warns `shared_proxy_retained` so the operator can pin `gateway` explicitly to release it. A successful nothing-desired reconcile returns `settings_residual` for every status except `none`, including `local`; unreadable cleanup returns the coded 500. Native Claude routing enable persists its setting and reports `interceptReason` when local start authority is absent; disable and no-op paths never attempt a start. `enabled:false` alone leaves the env untouched, and a mixed body returns 400 before save. GUI normalization maps only missing `sharedProxy:undefined` to `none`, invalid statuses including `null` to `unknown`; `interceptEligible:undefined` from an older cache maps to `true`, while present values use `=== true`. The source coverage map checks all eight statuses. Notice order is unknown, foreign, local, residual when undesired, disabled, routingOff for stopped/broken with ineligible routing, stopped, broken, notApplied, shared, null. Unknown copy states uncertainty, local copy names the unconfirmed 127.0.0.1 proxy and manual HTTPS_PROXY removal, disabled copy retains the first-party-off remedy, foreign copy directs manual CA/proxy repair, routingOff says to restore Claude routing or turn first-party off, stopped says to start opencodex, and eligible broken advises `ocx ensure` or restart. | 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). | diff --git a/structure/runtime.md b/structure/runtime.md index 714f0e144e5..c7866f42c85 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -249,7 +249,7 @@ Claude Code reaches the pair through an authenticated `HTTPS_PROXY` URL plus `NO 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 unauthenticated clients, plain proxied HTTP, and loopback targets. The per-install proxy token is stored owner-only under `/claude-intercept/` (0600 plus a real per-user NTFS ACL on Windows, via `src/lib/windows-secret-acl.ts`, and re-pinned on every read-through `ensure`), and the settings file carrying it is written through the same hardened atomic writer. Every start runs `migrateClaudeInterceptSettings` (`src/claude/intercept/settings.ts`), which rewrites an owned env that no longer matches — e.g. a pre-auth URL left by an upgrade — while never creating an absent env or touching a foreign one, so a service restart cannot strand clients on 407s. Status/inspection reads the token without minting it; only apply and intercept startup create it. The TLS listener rewrites `POST /v1/messages` and `POST /v1/messages/count_tokens` to a loopback origin and dispatches them under the `claude-intercept` ingress; other paths relay to the configured upstream. One relayed path is extended: for the standalone CLI's picker catalog, the listener's `cliCatalog` hook (`src/claude/intercept/cli-catalog.ts`) merges opencodex rows into `GET /api/organizations//model_selector/cc` (or `/api/model_selector/cc`), touching only the `cc` surface and leaving Desktop's `ccd`/`code` surfaces to picker mode, and into the fallback `additional_model_options` of `GET /api/claude_cli/bootstrap`. Eligibility: the `cc` catalog is rewritten only for a CLI-classified `User-Agent` with `claudeCode.cliFirstParty` on, because Claude Code caches it on disk for every process and a Desktop-spawned process must not seed rows a plain `claude` cannot route; the bootstrap request carries no entrypoint and follows CLI intent alone. Rows come from `buildCliPickerModels` (`src/claude/intercept/picker-models.ts`), which uses Desktop 3P registry aliases because the CLI offers only Claude-shaped ids (an `ocx-claude-*` row renders "Update Claude Code"), and advertises only an alias the registry decodes back to the same route. `src/claude/intercept/cli-picker.ts` supplies them lazily on the first eligible request, persists the snapshot at `/claude-intercept/cli-picker-models.json`, re-checks each persisted row against the live registry (its alias must still decode to its recorded route; a cold registry after a restart is awaited first, and a snapshot whose rows still do not decode, for example after a provider change, is rebuilt within the same bound), and bounds a cold build at 2.5 s while awaiting the shared registry build in `src/claude/desktop-3p-startup.ts` that startup also uses (a failed or empty on-demand build is not retried for 30 s). The rewrite fails open: a provider error, non-2xx status, unparsable body, or no added row relays the upstream bytes unchanged; a body over the 16 MiB cap is cut off while streaming and answered 502 rather than buffered. Claude Code trusts its cached `/cache/model-catalog/*-cc.json` for about an hour, so `writeSettings` in `src/claude/intercept/settings.ts` and every successful `reconcileClaudeFirstPartySettings` drop those files (leaving Desktop's `*-ccd.json`); running sessions keep their list until restarted. -The pair is on by default on a hub (`claudeCode.intercept.enabled`); its proxy port defaults to public port + 100 (`claudeCode.intercept.port`). Bind failure warns, and stop joins both sockets. With an ephemeral public port (`startServer(0)`), an explicit intercept port is required. +The pair is on by default on a hub (`claudeCode.intercept.enabled`); its proxy port defaults to public port + 100 (`claudeCode.intercept.port`). The lifecycle records bind failure, and stop joins both sockets. Its serialized `ensure()` joins pending startup, reuses the live config and dispatch wrapper, and can start a previously unbound pair. `stop()` refuses future ensure calls and awaits an in-flight start. Local management applies and `ocx claude intercept start` (`POST /api/claude-intercept/start`) use this seam through `src/server/index/optional-listeners.ts`; hub-management ingress and data-plane credentials cannot start interception. Status reports `interceptReason` (with `interceptFailurePort` for a CONNECT bind failure) and optional `pickerReason` (with `pickerFailurePort` when the Desktop picker relay port is occupied), distinguishing disabled routing, client role, ephemeral public ports, occupied CONNECT ports and other failures. A changed configured port never rebinds a running pair: apply reports `port_mismatch` with both ports. With an ephemeral public port (`startServer(0)`), an explicit intercept port is required. This ingress honours first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). Picker mode and the primitive's optional `allowedTargets` restriction are described under [Desktop egress proxy](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy). `src/claude/intercept/client-class.ts` classifies each request by its Claude Code `User-Agent` entrypoint: `claude-desktop`, `claude-desktop-3p`, and `local-agent` are Desktop; other well-formed `claude-cli/ (external, )` values are CLI; absent or malformed values are unknown. diff --git a/tests/claude-integration/claude-desktop-first-party-guards.test.ts b/tests/claude-integration/claude-desktop-first-party-guards.test.ts index 33574f7a06d..a0b97d9d18b 100644 --- a/tests/claude-integration/claude-desktop-first-party-guards.test.ts +++ b/tests/claude-integration/claude-desktop-first-party-guards.test.ts @@ -44,7 +44,7 @@ async function dispatch(path: string, init: RequestInit, inputConfig: OcxConfig, const response = await handleManagementAPI(new Request(url, { ...init, headers: { Host: url.host, "Content-Type": "application/json", ...(init.headers ?? {}) }, - }), url, inputConfig, deps); + }), url, inputConfig, { ensureClaudeIntercept: async () => ({ ok: true, state: { proxyPort: 10200, caCertPath: join(root, "claude-intercept", "ca.pem"), pickerProxyPort: null } }), ...deps }, "admin-token", undefined, { trustedLoopback: true }); return { status: response!.status, body: await response!.json() as Record }; } diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index c0340d9a840..b8e31beb3c7 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -48,7 +48,7 @@ async function dispatch(path: string, init?: RequestInit, inputConfig: OcxConfig const response = await handleManagementAPI(new Request(url, { ...init, headers: { Host: url.host, "Content-Type": "application/json", ...(init?.headers ?? {}) }, - }), url, inputConfig, deps); + }), url, inputConfig, { ensureClaudeIntercept: async () => ({ ok: true, state: { proxyPort: 10200, caCertPath: join(root, "claude-intercept", "ca.pem"), pickerProxyPort: null } }), ...deps }, "admin-token", undefined, { trustedLoopback: true }); return { status: response!.status, body: await response!.json() as Record }; } @@ -517,7 +517,7 @@ for (const surface of ["cli", "api"] as const) { expect(await applyDesktop(undefined, { kind: "first-party" }, { findLiveProxyImpl: async () => null })).toMatchObject({ ok: false, reason: failure }); } else { const reply = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, saved); - expect(reply.body.reason).toBe(failure); + expect(reply.body.reason).toBe(failure === "intercept_disabled" ? "disabled" : failure); } expect(inspectDesktop3pConfigLibrary({ appliedFingerprint })).toEqual(before); }); @@ -639,3 +639,16 @@ test("the status routes never mint the proxy token", async () => { expect(list.status).toBe(200); expect(existsSync(claudeInterceptProxyTokenPath(root))).toBe(false); }); + +test("first-party apply and native enable refuse a changed configured port before writing env", async () => { + const live = config({ claudeCode: { desktopMode: "first-party", intercept: { port: 10300 } } }); + writeFileSync(join(root, "config.json"), JSON.stringify(live)); + for (const [path, body] of [["/api/claude-desktop/apply", { mode: "first-party" }], ["/api/native-integrations/claude-desktop", { enabled: true }]] as const) { + const result = await dispatch(path, { method: path.endsWith("apply") ? "POST" : "PUT", body: JSON.stringify(body) }, live); + expect(result.status).toBe(409); + expect(result.body.code).toBe("port_mismatch"); + expect(result.body.bound).toBe(10200); + expect(result.body.configured).toBe(10300); + expect(existsSync(join(claudeDir, "settings.json"))).toBe(false); + } +}); diff --git a/tests/claude-integration/claude-desktop-picker-routes.test.ts b/tests/claude-integration/claude-desktop-picker-routes.test.ts index 2e395d53c43..fc0bf1e5a3b 100644 --- a/tests/claude-integration/claude-desktop-picker-routes.test.ts +++ b/tests/claude-integration/claude-desktop-picker-routes.test.ts @@ -84,6 +84,9 @@ async function startPicker(saved: OcxConfig, onDispatch?: (req: Request) => Resp const boundPorts = [handle.listener.port, handle.proxyPort, handle.pickerProxyPort]; expect(boundPorts.every(port => typeof port === "number" && Number.isInteger(port) && port > 0)).toBe(true); expect(new Set(boundPorts).size).toBe(boundPorts.length); + const committed = persisted(); + committed.claudeCode = { ...committed.claudeCode, intercept: { ...committed.claudeCode?.intercept, port: handle.proxyPort } }; + writeFileSync(join(root, "config.json"), JSON.stringify(committed)); return handle.pickerProxyPort; } @@ -92,7 +95,7 @@ async function dispatch(path: string, init: RequestInit = {}, deps: Parameters ({ ok: true, state: handle ?? { proxyPort: 10200, caCertPath: join(root, "claude-intercept", "ca.pem"), pickerProxyPort: null } }), ...deps }, "admin-token", undefined, { trustedLoopback: true }); return { status: response!.status, body: await response!.json() as Record }; } diff --git a/tests/claude-integration/claude-intercept-on-demand.test.ts b/tests/claude-integration/claude-intercept-on-demand.test.ts new file mode 100644 index 00000000000..4be3fe5e1f8 --- /dev/null +++ b/tests/claude-integration/claude-intercept-on-demand.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createClaudeInterceptLifecycle } from "../../src/server/index/claude-intercept-lifecycle"; +import { startConnectProxy } from "../../src/claude/intercept/connect-proxy"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { saveConfig, loadConfig } from "../../src/config"; +import { initializeManagementAuthState, requireManagementAuth } from "../../src/server/management-auth"; +import type { OcxConfig } from "../../src/types"; + +let root: string; +let previousHome: string | undefined; +let previousClaude: string | undefined; +let previousDesktop: string | undefined; +const owners: Array> = []; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-intercept-demand-")); + previousHome = process.env.OPENCODEX_HOME; + previousClaude = process.env.CLAUDE_CONFIG_DIR; + previousDesktop = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(root, "desktop"); + process.env.OPENCODEX_HOME = root; + process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); +}); +afterEach(async () => { + for (const owner of owners.splice(0)) await owner.stop(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + if (previousClaude === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = previousClaude; + if (previousDesktop === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousDesktop; + rmSync(root, { recursive: true, force: true }); +}); +function config(): OcxConfig { + return { port: 10100, providers: { mock: { adapter: "openai-chat", baseUrl: "https://example.test/v1", models: ["test"], liveModels: false } }, defaultProvider: "mock", claudeCode: { enabled: false } } as OcxConfig; +} +function owner() { const lifecycle = createClaudeInterceptLifecycle(); owners.push(lifecycle); return lifecycle; } + +test("late ensure reuses dispatch ownership, serializes and stays idempotent", async () => { + const live = config(); + const lifecycle = owner(); + let binds = 0; + let tlsPort = 0; + let owned = false; + lifecycle.start({ config: live, publicPort: 10100, configDir: root, + dispatch: async (_req, server) => { owned = lifecycle.ownsListener(server); return new Response("ok"); }, pickerPlatform: "linux", + loadPickerRoutes: async () => ({ nativeSlugs: [], routedModels: [] }), + startProxy: async (_port, options) => { binds++; tlsPort = options.interceptPort; return startConnectProxy(0, options); }, + }); + expect(await lifecycle.ensure()).toEqual({ ok: false, reason: "disabled" }); + live.claudeCode!.enabled = true; + live.claudeCode!.cliFirstParty = true; + const first = lifecycle.ensure(); + expect(lifecycle.ensure()).toBe(first); + const result = await first; + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("intercept failed"); + const response = await fetch(`https://127.0.0.1:${tlsPort}/v1/messages`, { method: "POST", body: "{}", + headers: { "User-Agent": "claude-cli/2.1.282 (external, cli)" }, tls: { rejectUnauthorized: false } }); + expect(await response.text()).toBe("ok"); + expect(owned).toBe(true); + expect(await lifecycle.ensure()).toEqual(result); + expect(binds).toBe(2); +}); + +test("stopped lifecycle refuses new work and joins a pending startup", async () => { + const lifecycle = owner(); + const live = config(); live.claudeCode!.enabled = true; + lifecycle.start({ config: live, publicPort: 10100, requestedPort: 0, dispatch: async () => new Response() }); + await lifecycle.stop(); + expect(await lifecycle.ensure()).toEqual({ ok: false, reason: "stopped" }); +}); + +test("CONNECT bind EADDRINUSE records its occupied port", async () => { + const blocker = await startConnectProxy(0, { interceptPort: 1 }); + try { + const live = config(); live.claudeCode = { enabled: true, intercept: { port: blocker.port } }; + const lifecycle = owner(); + lifecycle.start({ config: live, publicPort: 10100, configDir: root, dispatch: async () => new Response() }); + expect(await lifecycle.ensure()).toEqual({ ok: false, reason: "port_in_use", port: blocker.port }); + expect(lifecycle.lastOutcome()).toEqual({ ok: false, reason: "port_in_use", port: blocker.port }); + } finally { await blocker.close(); } +}); + +test("prechecks distinguish client role and ephemeral public ports", async () => { + for (const [runtimeRole, reason] of [["client", "client_role"], ["standalone", "ephemeral_port"]] as const) { + const live = config(); live.runtimeRole = runtimeRole; live.claudeCode!.enabled = true; + const lifecycle = owner(); + lifecycle.start({ config: live, publicPort: 30000, requestedPort: 0, dispatch: async () => new Response() }); + expect(await lifecycle.ensure()).toEqual({ ok: false, reason }); + } +}); + +test("manual start refuses hub ingress and data-plane authority before ensure", async () => { + let calls = 0; + const url = new URL("http://127.0.0.1:10100/api/claude-intercept/start"); + const live = config(); live.claudeCode!.enabled = true; + for (const [principal, loopback] of [["gui-session", false], [undefined, true]] as const) { + const response = await handleManagementAPI(new Request(url, { method: "POST", headers: { Host: url.host } }), url, live, + { ensureClaudeIntercept: async () => { calls++; return { ok: false, reason: "failed" }; } }, + principal, undefined, { trustedLoopback: loopback }); + expect(response!.status).toBe(403); + } + expect(calls).toBe(0); +}); + +test("manual start returns the concrete disabled reason", async () => { + const url = new URL("http://127.0.0.1:10100/api/claude-intercept/start"); + const response = await handleManagementAPI(new Request(url, { method: "POST", headers: { Host: url.host } }), url, config(), {}, + "admin-token", undefined, { trustedLoopback: true }); + expect(response!.status).toBe(409); + expect(await response!.json()).toEqual({ ok: false, reason: "disabled" }); +}); + + +test("stop waits for an in-flight bind and closes the late listener", async () => { + const live = config(); live.claudeCode!.enabled = true; + const lifecycle = owner(); + let release!: () => void; + let entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + lifecycle.start({ config: live, publicPort: 10100, configDir: root, pickerPlatform: "linux", + loadPickerRoutes: async () => ({ nativeSlugs: [], routedModels: [] }), + dispatch: async () => new Response(), startProxy: async (_port, options) => { + entered(); await gate; return startConnectProxy(0, options); + }, + }); + await started; + let closed = false; + const stopping = lifecycle.stop().then(() => { closed = true; }); + await Promise.resolve(); + expect(closed).toBe(false); + expect(await lifecycle.ensure()).toEqual({ ok: false, reason: "stopped" }); + release(); await stopping; + expect(closed).toBe(true); + expect(lifecycle.lastOutcome()).toEqual({ ok: false, reason: "stopped" }); +}); + +test("CLI first-party save starts a previously unbound pair", async () => { + const live = config(); live.claudeCode = { enabled: false, desktopMode: "gateway", intercept: { port: 10200 } }; + const lifecycle = owner(); + let binds = 0; + lifecycle.start({ config: live, publicPort: 10100, configDir: root, pickerPlatform: "linux", + loadPickerRoutes: async () => ({ nativeSlugs: [], routedModels: [] }), dispatch: async () => new Response(), + startProxy: async (_port, options) => { + const proxy = await startConnectProxy(0, options); + if (binds++ === 0) { live.claudeCode!.intercept!.port = proxy.port; saveConfig(live); } + return proxy; + }, + }); + expect(await lifecycle.ensure()).toEqual({ ok: false, reason: "disabled" }); + live.claudeCode.enabled = true; saveConfig(live); + const url = new URL("http://127.0.0.1:10100/api/claude-code"); + const response = await handleManagementAPI(new Request(url, { + method: "PUT", headers: { Host: url.host, "Content-Type": "application/json" }, body: JSON.stringify({ cliFirstParty: true }), + }), url, live, { ensureClaudeIntercept: () => lifecycle.ensure() }, "admin-token", undefined, { trustedLoopback: true }); + expect(response!.status).toBe(200); + expect(loadConfig().claudeCode?.cliFirstParty).toBe(true); + expect(binds).toBe(2); +}); + +test("remote management never starts interception; routing intent can still persist", async () => { + for (const [path, method, body] of [ + ["/api/claude-code", "PUT", { cliFirstParty: true }], + ["/api/claude-desktop/apply", "POST", { mode: "first-party" }], + ["/api/claude-desktop/picker", "PUT", { enabled: true, persist: true }], + ["/api/native-integrations/claude", "PUT", { enabled: true }], + ["/api/native-integrations/claude-desktop", "PUT", { enabled: true }], + ] as const) { + for (const [principal, loopback] of [["gui-session", false], [undefined, true]] as const) { + const live = config(); live.claudeCode!.desktopMode = "first-party"; saveConfig(live); + let calls = 0; + const url = new URL(`http://127.0.0.1:10100${path}`); + const response = await handleManagementAPI(new Request(url, { method, + headers: { Host: url.host, "Content-Type": "application/json" }, body: JSON.stringify(body), + }), url, live, { ensureClaudeIntercept: async () => { calls++; return { ok: false, reason: "failed" }; } }, + principal, undefined, { trustedLoopback: loopback }); + if (path === "/api/native-integrations/claude") { + expect(response!.status).toBe(200); + expect((await response!.json()).interceptReason).toBe("intercept_start_forbidden"); + expect(loadConfig().claudeCode?.enabled).toBe(true); + } else expect(response!.status).toBe(403); + expect(calls).toBe(0); + expect(loadConfig().claudeCode?.cliFirstParty).toBeUndefined(); + } + } +}); + +test("a data-plane API key fails the actual management authentication gate", () => { + const live = config(); live.apiKeys = [{ id: "data", name: "Data", key: "data-plane-only-key" }]; + const auth = initializeManagementAuthState(live); + const req = new Request("http://127.0.0.1:10100/api/claude-intercept/start", { + method: "POST", headers: { Authorization: "Bearer data-plane-only-key" }, + }); + expect(requireManagementAuth(req, auth, live)?.status).toBe(401); +}); + + +test("ensure arriving during null startup joins it before starting with the live config", async () => { + const live = config(); + const lifecycle = owner(); + let binds = 0; + lifecycle.start({ config: live, publicPort: 10100, configDir: root, pickerPlatform: "linux", + loadPickerRoutes: async () => ({ nativeSlugs: [], routedModels: [] }), dispatch: async () => new Response(), + startProxy: async (_port, options) => { binds++; return startConnectProxy(0, options); }, + }); + live.claudeCode!.enabled = true; + const pending = lifecycle.ensure(); + expect(lifecycle.ensure()).toBe(pending); + expect((await pending).ok).toBe(true); + expect(binds).toBe(2); +}); + + +test("native Claude toggle reports an intercept startup refusal", async () => { + const live = config(); saveConfig(live); + const url = new URL("http://127.0.0.1:10100/api/native-integrations/claude"); + const response = await handleManagementAPI(new Request(url, { + method: "PUT", headers: { Host: url.host, "Content-Type": "application/json" }, body: JSON.stringify({ enabled: true }), + }), url, live, { ensureClaudeIntercept: async () => ({ ok: false, reason: "port_in_use" }) }, + "admin-token", undefined, { trustedLoopback: true }); + expect(response!.status).toBe(200); + expect(await response!.json()).toMatchObject({ ok: true, desiredEnabled: true, interceptReason: "port_in_use" }); +}); + +test("Desktop status exposes the picker bind port separately from the CONNECT port", async () => { + const live = config(); saveConfig(live); + const url = new URL("http://127.0.0.1:10100/api/claude-desktop/status"); + const response = await handleManagementAPI(new Request(url, { headers: { Host: url.host } }), url, live, + { getClaudeInterceptState: () => ({ proxyPort: 10200, caCertPath: join(root, "ca.crt"), pickerProxyPort: null, pickerReason: "port_in_use", pickerFailurePort: 10300 }) }, + "admin-token", undefined, { trustedLoopback: true }); + expect(response!.status).toBe(200); + expect((await response!.json()).firstParty).toMatchObject({ pickerReason: "port_in_use", pickerFailurePort: 10300 }); +}); diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index 78f1981c97c..498759f3589 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -184,7 +184,7 @@ test("CLI-on refuses a disabled intercept without mutation", async () => { }); const first = await put(); expect(first.status).toBe(409); - expect(await first.json()).toMatchObject({ code: "intercept_disabled" }); + expect(await first.json()).toMatchObject({ code: "disabled" }); expect(loadConfig().claudeCode?.cliFirstParty).toBeUndefined(); const settingsPath = join(process.env.CLAUDE_CONFIG_DIR!, "settings.json"); expect(existsSync(settingsPath)).toBe(false); @@ -234,7 +234,7 @@ test("CLI-on rechecks a drifted persisted port under the config lock", async () method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cliFirstParty: true }), }); expect(response.status).toBe(409); - expect(await response.json()).toMatchObject({ code: "intercept_unavailable" }); + expect(await response.json()).toMatchObject({ code: "port_mismatch" }); expect(readFileSync(configPath, "utf8")).toBe(configBytes); expect(existsSync(settingsPath) ? readFileSync(settingsPath, "utf8") : null).toBe(settingsBefore); expect(existsSync(tokenPath) ? readFileSync(tokenPath, "utf8") : null).toBe(tokenBefore); diff --git a/tests/claude-integration/claude-picker-runtime.test.ts b/tests/claude-integration/claude-picker-runtime.test.ts index 1c00020e340..b2d6dda0927 100644 --- a/tests/claude-integration/claude-picker-runtime.test.ts +++ b/tests/claude-integration/claude-picker-runtime.test.ts @@ -618,6 +618,7 @@ describe("startClaudeIntercept wiring", () => { pickerPlatform: "darwin", }); expect(handle?.pickerProxyPort).toBeNull(); + expect(handle).toMatchObject({ pickerReason: "port_in_use", pickerFailurePort: pickerPort }); expect(getClaudePickerRuntime()).toBeNull(); expect(squatter.listening).toBe(true); expect(inspectDesktopPickerProfile({ configDir: root, platform: "darwin" })).toEqual(profileBefore); @@ -762,6 +763,7 @@ describe("startClaudeIntercept wiring", () => { try { first = await startClaudeIntercept(startOpts()); expect(first?.pickerProxyPort).toBeNull(); + expect(first).toMatchObject({ pickerReason: "port_in_use", pickerFailurePort: pickerPort }); // The main pair still serves, and the applied selection is durable evidence for a retry. expect(await connectStatusLine(port, "api.anthropic.com")).toContain("200"); expect(inspectDesktopPickerProfile({ configDir: root, platform: "darwin" }).kind).toBe("applied"); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index f3762c6f9e6..2d1177c2297 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -7,7 +7,7 @@ import { handleAccessCommand } from "../../src/cli/access"; import { handleAgentCommand } from "../../src/cli/agent"; import { handleComboCommand } from "../../src/cli/combo"; import { handleConfigCommand } from "../../src/cli/config-command"; -import { handleClientIntegrationCommand, handleGrokCommand } from "../../src/cli/integrations"; +import { handleClientIntegrationCommand, handleGrokCommand, handleClaudeInterceptCommand } from "../../src/cli/integrations"; import { handleModelsRuntimeCommand } from "../../src/cli/models-runtime"; import { handleProviderRuntimeCommand } from "../../src/cli/provider-runtime"; import { providerQuotaLine } from "../../src/cli/account-extended"; @@ -538,6 +538,7 @@ describe("headless GUI parity CLI", () => { // the Claude flag flips through `ocx claude config` — so a dedicated // `ocx integration native` verb would duplicate existing commands rather // than add a capability. Listed so the sweep stays exhaustive. + ["/api/claude-intercept/start", "ocx claude intercept start"], ["/api/native-integrations", "(none — GUI-only)"], // #3417: the dashboard's native main login disclosure reads and writes the same // routes as `ocx account main` — list/doctor, register, switch and recover — so the @@ -1700,3 +1701,12 @@ describe("ownership recovery advice does not assume injection is enabled", () => }); } }); + +test("Claude intercept start uses the management POST and preserves refusal exit status", async () => { + const success = fakeRuntime(); + expect(await handleClaudeInterceptCommand(["start", "--json"], success.deps)).toBe(0); + expect(success.requests).toEqual([{ path: "/api/claude-intercept/start", method: "POST", body: null }]); + const refused = fakeRuntime(() => Response.json({ ok: false, reason: "port_in_use" }, { status: 409 })); + expect(await handleClaudeInterceptCommand(["start", "--json"], refused.deps)).not.toBe(0); + expect(refused.requests).toHaveLength(1); +}); diff --git a/tests/codex-integration/native-claude-desktop-toggle.test.ts b/tests/codex-integration/native-claude-desktop-toggle.test.ts index 93262718fc2..7e13259cbaa 100644 --- a/tests/codex-integration/native-claude-desktop-toggle.test.ts +++ b/tests/codex-integration/native-claude-desktop-toggle.test.ts @@ -43,7 +43,7 @@ async function dispatch(path: string, init?: RequestInit, deps: ManagementApiDep writeDesktop3pConfig(port, slugs, models, key, mode, profile, cap, { lockPath: join(root, "lifecycle.sqlite") }), removeDesktop3pStandardPivot: options => removeDesktop3pStandardPivot({ ...options, lifecycleLockDeps: { lockPath: join(root, "lifecycle.sqlite") } }), ...deps, - }); + }, "admin-token", undefined, { trustedLoopback: true }); } async function toggle(enabled: boolean, deps: ManagementApiDeps = {}) { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 727a2556362..66db2637f24 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { + "claude-intercept-on-demand.test.ts": "claude-integration", "main-account-hard-lock-thresholds.test.ts": "codex-integration", "main-account-external-usage.test.ts": "codex-integration", "jev-decision-model-config.test.ts": "routing",