From 7c4c4e266b7408bb508fe0487ddbfab5426ca925 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 18:35:22 +0900 Subject: [PATCH 1/4] test(claude): cover Windows picker activation after dormant startup Co-authored-by: hyuntae park --- .../claude-picker-windows.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/claude-integration/claude-picker-windows.test.ts b/tests/claude-integration/claude-picker-windows.test.ts index 00fe3d9cc7d..8ed098266e0 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,55 @@ 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>> = []; + 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) => { + 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); + + 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 }); + const response = await enable(); + expect(response?.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(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"); From 575e6a6e2dcb7be9fd09a5833262c835af73b617 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 18:37:25 +0900 Subject: [PATCH 2/4] fix(claude): bind dormant Windows picker relay on activation Co-authored-by: hyuntae park --- .../src/content/docs/guides/claude-code.md | 2 + src/claude/desktop-picker.ts | 8 +- src/claude/intercept/runtime.ts | 140 ++++++++++-------- structure/clients/claude-desktop.md | 6 + .../claude-picker-windows.test.ts | 18 ++- 5 files changed, 111 insertions(+), 63 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index f70461f82fe..159886a85e0 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 6826899d2e7..6c00058c423 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 25d98f693f9..4e6866731a0 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 ab648166baf..8404400b9a5 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -336,6 +336,12 @@ that controller when one exists. With no controller (intercept disabled, client picker-proxy bind), its offline operations remove leftover picker artifacts without creating a terminator, and refuse enable with `proxy_unavailable`. +Windows startup with picker intent off retains a dormant controller without binding the picker +relay or starting its refresh timer. A later enable validates mode, integration and preference, +then binds the relay under that controller's lock and starts the runtime. Concurrent enables +share one relay; a failed lazy bind reports `proxy_unavailable` and can be retried. Live intercept +state and its handle expose the bound port after activation, and shutdown closes that relay. + 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 enable attempt added trust and a later check or profile write fails, it removes that trust again; diff --git a/tests/claude-integration/claude-picker-windows.test.ts b/tests/claude-integration/claude-picker-windows.test.ts index 8ed098266e0..e67da03ab17 100644 --- a/tests/claude-integration/claude-picker-windows.test.ts +++ b/tests/claude-integration/claude-picker-windows.test.ts @@ -260,11 +260,13 @@ for (const disabledBy of ["preference", "desktop-integration", "gateway-mode"] a 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; @@ -275,6 +277,10 @@ for (const disabledBy of ["preference", "desktop-integration", "gateway-mode"] a 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 }; @@ -284,11 +290,21 @@ for (const disabledBy of ["preference", "desktop-integration", "gateway-mode"] a method: "PUT", headers: { Host: url.host, "Content-Type": "application/json" }, body: JSON.stringify({ enabled: true, persist: true }), }), url, config, {}, "admin-token", undefined, { trustedLoopback: true }); - const response = await enable(); + 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}`, }); From 2fafae911d1637229b3a019e744180200a4b966d Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 18:39:08 +0900 Subject: [PATCH 3/4] docs(claude): keep picker lifecycle contract within line budget Co-authored-by: hyuntae park --- structure/clients/claude-desktop.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 8404400b9a5..3e70cec3b98 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -335,12 +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 startup with picker intent off retains a dormant controller without binding the picker -relay or starting its refresh timer. A later enable validates mode, integration and preference, -then binds the relay under that controller's lock and starts the runtime. Concurrent enables -share one relay; a failed lazy bind reports `proxy_unavailable` and can be retried. Live intercept -state and its handle expose the bound port after activation, and shutdown closes that relay. +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 From e71c0cf7f6630c3585aa561329b3da83d7f34bb1 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 16 Oct 2026 18:46:01 +0900 Subject: [PATCH 4/4] docs(claude): align dormant Windows controller contract Co-authored-by: hyuntae park --- structure/clients/claude-desktop.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 3e70cec3b98..32f81b346b2 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