diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index f70461f82f..159886a85e 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -315,6 +315,8 @@ entries, is kept. Claude Desktop first-party routes its Code tab and subagents through OpenCodex. The standalone Claude Code CLI has a separate first-party switch. Both clients read the same `~/.claude/settings.json` proxy and CA settings: if only one switch is on, the other client still transits the local proxy, where TLS terminates, but its Messages requests relay to Anthropic unchanged. Other Anthropic paths relay unchanged and unrelated hosts remain blind tunnels. With the CLI switch on, the standalone `claude` also lists routed models in `/model`; see [Model picker in the CLI](#model-picker-in-the-cli). :::note[Windows system proxy (Clash, v2rayN, corporate proxies)] +When picker intent is off at OpenCodex startup, its Windows relay stays unbound. Turning picker +mode on later starts it within the running service; OpenCodex does not need a restart. For picker-off installs or pending installs with no applied picker profile, a Windows system proxy is handed to the Code tab as `HTTPS_PROXY`, and that value takes precedence over the OpenCodex proxy in `~/.claude/settings.json`. The Code tab diff --git a/src/claude/desktop-picker.ts b/src/claude/desktop-picker.ts index 6826899d2e..6c00058c42 100644 --- a/src/claude/desktop-picker.ts +++ b/src/claude/desktop-picker.ts @@ -78,6 +78,8 @@ export interface DesktopPickerControllerDeps { persistPreference: (value: boolean) => boolean; /** Bound picker CONNECT proxy port (Desktop's egress), or null when it is not running. */ proxyPort: () => number | null; + /** Bind a dormant relay on explicit enable, under the controller's mutation lock. */ + ensureProxy?: () => Promise; configDir: string; persistentAuthority?: { store?: PickerCaStore }; security?: SecurityRunner; @@ -212,7 +214,7 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) if (resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party") return "mode_not_committed"; if (!claudeDesktopIntegrationEnabled(config)) return "integration_off"; if (includePreference && config.claudeCode?.intercept?.picker === false) return "disabled"; - if (deps.proxyPort() === null) return "proxy_unavailable"; + if (deps.proxyPort() === null && !deps.ensureProxy) return "proxy_unavailable"; return null; }; @@ -225,6 +227,10 @@ export function createDesktopPickerController(deps: DesktopPickerControllerDeps) } const second = check(deps.readConfig(), true); if (second) return refuse(second); + if (deps.proxyPort() === null) { + try { await deps.ensureProxy?.(); } catch { return refuse("proxy_unavailable"); } + } + if (deps.proxyPort() === null) return refuse("proxy_unavailable"); let caPath: string; let caSha1: string; diff --git a/src/claude/intercept/runtime.ts b/src/claude/intercept/runtime.ts index 25d98f693f..4e6866731a 100644 --- a/src/claude/intercept/runtime.ts +++ b/src/claude/intercept/runtime.ts @@ -189,6 +189,7 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio let pickerProxy = null as ConnectProxyHandle | null; let controller = null as DesktopPickerController | null; let pickerProxyLive = false; + let stopped = false; let pickerReason: ClaudeInterceptState["pickerReason"] = null; let pickerFailurePort: number | undefined; try { @@ -212,7 +213,7 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio const fresh = options.config; const platform = options.pickerPlatform ?? process.platform; const pickerRequired = pickerProfileApplied || pickerDesired(fresh, resolveClaudeDesktopMode(fresh, observeClaudeDesktopMode(fresh)), platform); - // Preserve macOS's dormant wiring; Windows binds only for intent or selected-profile recovery. + // Preserve macOS's dormant relay; Windows defers binding until enable or profile recovery. pickerWired = platform !== "win32" || pickerRequired; if (pickerRequired) { await preparePersistentPickerAuthority({ configDir, store: options.pickerCaStore, security: options.pickerSecurity, platform }); @@ -245,7 +246,7 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio } } } - picker = pickerBlocked || !pickerWired ? null : (options.createPicker ?? createPickerRuntime)({ + picker = pickerBlocked ? null : (options.createPicker ?? createPickerRuntime)({ config: options.config, configDir, persistentAuthority: { store: options.pickerCaStore }, @@ -261,63 +262,76 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio const runtime = picker; const interceptPort = listener.port!; const pickerPort = claudePickerProxyPort(options.config, options.publicPort); - try { - 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 - // one. The intercept proxy above is the credential-bearing hop. - // No host list here: the choice below depends on which client opened the tunnel. - interceptHosts: [], - selectTunnel: (host, port, request) => { - // Desktop hands its pinned egress proxy to the Claude Code processes it spawns, so their - // api.anthropic.com traffic arrives here too and gets the same intercept as on the Claude - // Code proxy. Those processes trust only the intercept CA, so the picker never terminates - // their claude.ai tunnels; only the app's own (browser) CONNECTs reach the picker. - if (!isBrowserConnect(request)) { - return port === 443 && (CLAUDE_INTERCEPT_HOSTS as readonly string[]).includes(host.toLowerCase()) - ? { kind: "intercept", port: interceptPort } - : { kind: "blind" }; - } - return runtime.selectTunnel(host, port); + const bindPickerProxy = async (): Promise => { + if (stopped || pickerProxyLive) return; + try { + 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 + // one. The intercept proxy above is the credential-bearing hop. + // No host list here: the choice below depends on which client opened the tunnel. + interceptHosts: [], + selectTunnel: (host, port, request) => { + // Desktop hands its pinned egress proxy to the Claude Code processes it spawns, so their + // api.anthropic.com traffic arrives here too and gets the same intercept as on the Claude + // Code proxy. Those processes trust only the intercept CA, so the picker never terminates + // their claude.ai tunnels; only the app's own (browser) CONNECTs reach the picker. + if (!isBrowserConnect(request)) { + return port === 443 && (CLAUDE_INTERCEPT_HOSTS as readonly string[]).includes(host.toLowerCase()) + ? { kind: "intercept", port: interceptPort } + : { kind: "blind" }; + } + return runtime.selectTunnel(host, port); + }, + }); + if (stopped) { await pickerProxy.close(); return; } + pickerProxyLive = true; + pickerReason = null; + pickerFailurePort = undefined; + } catch (error) { + // Picker mode is optional: a busy port leaves the intercept pair running without it. + // The selected profile row survives; a dormant enable can retry after the port is free. + 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)}`); + throw error; + } + }; + if (pickerWired) { + try { await bindPickerProxy(); } + catch { await runtime.stop(); picker = null; } + } + if (picker) { + // Dynamic: the controller reaches desktop-first-party, which imports this module. + const [{ createDesktopPickerController }, { loadConfig }, persistPreference] = await Promise.all([ + import("../desktop-picker"), + import("../../config"), + createPickerPreferenceWriter(options.config), + ]); + controller = createDesktopPickerController({ + runtime: picker, + readConfig: loadConfig, + persistPreference, + proxyPort: () => (pickerProxyLive ? pickerProxy?.port ?? null : null), + ensureProxy: async () => { + await bindPickerProxy(); + if (!pickerProxyLive) return; + await runtime.start(); + if (!stopped && activeController === controller) activePicker = runtime; }, + configDir, + persistentAuthority: { store: options.pickerCaStore }, + ...(options.pickerSecurity ? { security: options.pickerSecurity } : {}), + ...(options.pickerPlatform ? { platform: options.pickerPlatform } : {}), }); - pickerProxyLive = true; - } catch (error) { - // 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; - } - } - if (picker) { - // Dynamic: the controller reaches desktop-first-party, which imports this module. - const [{ createDesktopPickerController }, { loadConfig }, persistPreference] = await Promise.all([ - import("../desktop-picker"), - import("../../config"), - createPickerPreferenceWriter(options.config), - ]); - const boundProxy = pickerProxy; - controller = createDesktopPickerController({ - runtime: picker, - readConfig: loadConfig, - persistPreference, - proxyPort: () => (pickerProxyLive && boundProxy ? boundProxy.port : null), - configDir, - persistentAuthority: { store: options.pickerCaStore }, - ...(options.pickerSecurity ? { security: options.pickerSecurity } : {}), - ...(options.pickerPlatform ? { platform: options.pickerPlatform } : {}), - }); - await picker.start(); - // Restore inspects the retained identity without installing trust. Explicit enable/trust - // owns consent when trust was revoked or legacy migration introduced a new authority. - if (pickerProfileApplied && controller) { - void controller.enable({ persist: false, context: "server", allowTrustPrompt: false }) - .catch(error => console.warn(`⚠ Claude Desktop picker restore failed: ${error instanceof Error ? error.message : String(error)}`)); + if (pickerWired) await picker.start(); + // Restore inspects the retained identity without installing trust. Explicit enable/trust + // owns consent when trust was revoked or legacy migration introduced a new authority. + if (pickerProfileApplied && controller) { + void controller.enable({ persist: false, context: "server", allowTrustPrompt: false }) + .catch(error => console.warn(`⚠ Claude Desktop picker restore failed: ${error instanceof Error ? error.message : String(error)}`)); + } } } } @@ -333,21 +347,25 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio throw error; } const state: ClaudeInterceptState = { - pickerReason, - pickerFailurePort, + get pickerReason() { return pickerReason; }, + get pickerFailurePort() { return pickerFailurePort; }, proxyPort: proxy.port, caCertPath: claudeInterceptCaCertPath(configDir), - pickerProxyPort: pickerProxy?.port ?? null, + get pickerProxyPort() { return pickerProxyLive ? pickerProxy?.port ?? null : null; }, }; activeState = state; - activePicker = picker; + activePicker = pickerProxyLive ? picker : null; activeController = controller; const ownPicker = picker; const ownController = controller; return { ...state, + get pickerProxyPort() { return state.pickerProxyPort; }, + get pickerReason() { return state.pickerReason; }, + get pickerFailurePort() { return state.pickerFailurePort; }, listener, stop: async () => { + stopped = true; if (activeState === state) activeState = null; if (activePicker === ownPicker) activePicker = null; if (activeController === ownController) activeController = null; diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index ab648166ba..32f81b346b 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -275,8 +275,8 @@ predecessor before writing the credential, verifies readback, then commits metad it removes the journal last. Recovery requires matching journal/store identity; missing initialized credentials, unavailable storage or inconsistent metadata fail closed without publishing a replacement. Gateway/off startup does not read or initialize an OS picker credential unless an applied picker -profile needs recovery. macOS retains its dormant runtime/controller for later explicit activation; -Windows without picker intent or an applied profile constructs neither picker runtime/controller nor relay. Activation uses the same persistent authority path. +profile needs recovery. Both macOS and Windows retain a dormant runtime/controller for explicit activation; +Windows without picker intent or an applied profile defers relay binding and refresh. Activation uses the same persistent authority path. On macOS trust is added without a policy string: Chromium skips host-scoped trust settings, so `inspectPickerTrust` treats a current CA whose exported user trust settings carry `kSecTrustSettingsPolicyString` as untrusted and an explicit trust step replaces it; an @@ -335,6 +335,7 @@ cleanup, mode/profile commit, and the optional picker enable cannot race. `runDe that controller when one exists. With no controller (intercept disabled, client role, or a failed picker-proxy bind), its offline operations remove leftover picker artifacts without creating a terminator, and refuse enable with `proxy_unavailable`. +Windows with picker intent off retains a dormant controller without binding the relay or starting refresh. Enable checks mode, integration and preference, then binds and starts under the lock. Concurrent enables share one relay; failed lazy binds can retry. State and handle publish the bound port, and shutdown closes it. The controller disarms the picker runtime before disable or cleanup. The disarm latch makes new `claude.ai` CONNECTs blind immediately and is cleared only by a completed, checked enable. If an diff --git a/tests/claude-integration/claude-picker-windows.test.ts b/tests/claude-integration/claude-picker-windows.test.ts index 00fe3d9cc7..e67da03ab1 100644 --- a/tests/claude-integration/claude-picker-windows.test.ts +++ b/tests/claude-integration/claude-picker-windows.test.ts @@ -6,6 +6,7 @@ import { connect as tlsConnect, type TLSSocket } from "node:tls"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; +import { handleManagementAPI } from "../../src/server/management-api"; import { flushConfigDirHardeningAndReaps, hardenConfigDir } from "../../src/config/paths"; import { resetHardenedStateForTests, setAsyncIcaclsRunnerForTests, setPlatformForTests } from "../../src/lib/windows-secret-acl"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -250,6 +251,71 @@ test("explicit enable on Windows activates the picker profile once trusted", asy } }, 30_000); +for (const disabledBy of ["preference", "desktop-integration", "gateway-mode"] as const) { + test(`Windows management enable binds the dormant picker after ${disabledBy} changes`, async () => { + const { root, config } = setup(disabledBy !== "gateway-mode"); + if (disabledBy === "preference") config.claudeCode!.intercept!.picker = false; + if (disabledBy === "desktop-integration") config.clientIntegrations = { "claude-desktop": false }; + saveConfig(config); + const trust = windowsTrust({ trusted: false }); + const fake = memoryPickerCaStore(); + const proxies: Array>> = []; + let failBind = false; + const handle = await startClaudeIntercept({ + config, configDir: root, publicPort: 10100, + dispatch: async () => new Response(), loadPickerRoutes: routes, + pickerPlatform: "win32", pickerCaStore: fake.store, pickerSecurity: trust.run, + startProxy: async (_port, options) => { + if (failBind) throw Object.assign(new Error("test occupied port"), { code: "EADDRINUSE" }); + const proxy = await startConnectProxy(0, options); + proxies.push(proxy); + return proxy; + }, + }); + handles.push(handle); + expect(proxies).toHaveLength(1); + expect(handle?.pickerProxyPort).toBeNull(); + expect(trust.calls).toEqual([]); + expect(existsSync(pickerCaCertPath(root))).toBe(false); + expect(await getClaudePickerController()!.status()).toMatchObject({ listenerReady: false, effective: false }); + expect(await getClaudePickerController()!.enable({ persist: false, context: "server" })).toMatchObject({ effective: false }); + expect(proxies).toHaveLength(1); + expect(trust.calls).toEqual([]); + + config.claudeCode!.desktopMode = "first-party"; + config.clientIntegrations = { "claude-desktop": true }; + saveConfig(config); + const url = new URL("http://127.0.0.1:10100/api/claude-desktop/picker"); + const enable = async () => handleManagementAPI(new Request(url, { + method: "PUT", headers: { Host: url.host, "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: true, persist: true }), + }), url, config, {}, "admin-token", undefined, { trustedLoopback: true }); + if (disabledBy === "preference") { + failBind = true; + expect((await enable())?.status).toBe(409); + expect(handle?.pickerReason).toBe("port_in_use"); + expect(handle?.pickerProxyPort).toBeNull(); + expect(trust.calls).toEqual([]); + failBind = false; + } + const [response, repeated] = await Promise.all([enable(), enable()]); + expect(response?.status).toBe(200); + expect(repeated?.status).toBe(200); + expect(await response!.json()).toMatchObject({ ok: true, picker: { effective: true, profile: "applied", trust: "trusted" } }); + expect(proxies).toHaveLength(2); + expect(handle?.pickerProxyPort).toBe(proxies[1]!.port); + expect(handle?.pickerReason).toBeNull(); + expect(inspectDesktopPickerProfile({ configDir: root, platform: "win32" })).toMatchObject({ + kind: "applied", proxyUrl: `http://127.0.0.1:${proxies[1]!.port}`, + }); + expect((await enable())?.status).toBe(200); + expect(proxies).toHaveLength(2); + await handle!.stop(); + handles.splice(handles.indexOf(handle), 1); + await expect(fetch(`http://127.0.0.1:${proxies[1]!.port}`)).rejects.toThrow(); + }, 30_000); +} + test("Windows reload retains fingerprint and issues no trust mutations", async () => { const { root, config } = setup(true); const storePath = join(root, "test-only-store");