From 7df1f0a543261fd50815b4fe799bd7c8d6b1b657 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:02:53 -0700 Subject: [PATCH 01/17] Add Claude intercept pair: local CA, CONNECT proxy, TLS listener, server wiring Claude Code honours HTTPS_PROXY/NODE_EXTRA_CA_CERTS from its settings env, so a loopback CONNECT proxy plus a locally-signed TLS listener for api.anthropic.com lets the router see Claude Code's Messages traffic without any ANTHROPIC_BASE_URL rewrite and without touching the Desktop app's own (first-party) configuration. - src/claude/intercept/local-ca.ts: ECDSA P-256 CA + leaf issuance with a hand-rolled DER encoder (node:crypto only); CA persisted under /claude-intercept with a 0600 key, never installed in an OS store. - src/claude/intercept/connect-proxy.ts: CONNECT-only loopback proxy; splices api.anthropic.com:443 onto the TLS listener, relays other targets blind, refuses plain HTTP, loopback targets and oversized heads. - src/claude/intercept/listener.ts: TLS listener; POST /v1/messages and /v1/messages/count_tokens are rewritten onto a loopback origin and dispatched to the route table under the new claude-intercept ingress (loopback policy); every other path is relayed verbatim to the configured Anthropic upstream. - src/claude/intercept/settings.ts: ownership-aware apply/inspect/remove of the two env keys in Claude Code settings.json (anchored on the CA path). - src/claude/intercept/runtime.ts + server wiring: enabled by default on a hub, proxy port = public port + 100 unless claudeCode.intercept.port is set; bind failure degrades to a warning; stop joins both sockets. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/test-layout/layout.json | 6 +- src/claude/intercept/connect-proxy.ts | 167 +++++++++++ src/claude/intercept/listener.ts | 122 ++++++++ src/claude/intercept/local-ca.ts | 278 ++++++++++++++++++ src/claude/intercept/runtime.ts | 90 ++++++ src/claude/intercept/settings.ts | 161 ++++++++++ src/server/index.ts | 30 +- src/server/index/serve-options.ts | 25 +- src/types/config.ts | 7 + structure/runtime.md | 18 ++ .../claude-intercept-local-ca.test.ts | 75 +++++ .../claude-intercept-proxy.test.ts | 174 +++++++++++ .../claude-intercept-settings.test.ts | 100 +++++++ tests/fixtures/test-layout-expected.json | 6 +- .../claude-intercept-integration.test.ts | 129 ++++++++ 15 files changed, 1383 insertions(+), 5 deletions(-) create mode 100644 src/claude/intercept/connect-proxy.ts create mode 100644 src/claude/intercept/listener.ts create mode 100644 src/claude/intercept/local-ca.ts create mode 100644 src/claude/intercept/runtime.ts create mode 100644 src/claude/intercept/settings.ts create mode 100644 tests/claude-integration/claude-intercept-local-ca.test.ts create mode 100644 tests/claude-integration/claude-intercept-proxy.test.ts create mode 100644 tests/claude-integration/claude-intercept-settings.test.ts create mode 100644 tests/server/claude-intercept-integration.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 80f0a028142..76451289314 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1583,7 +1583,11 @@ "chat-inline-document-bytes.test.ts": "responses", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server", - "docs-provider-discovery-limits.test.ts": "ci-workflows" + "docs-provider-discovery-limits.test.ts": "ci-workflows", + "claude-intercept-local-ca.test.ts": "claude-integration", + "claude-intercept-proxy.test.ts": "claude-integration", + "claude-intercept-settings.test.ts": "claude-integration", + "claude-intercept-integration.test.ts": "server" }, "migrated": [ "adapters", diff --git a/src/claude/intercept/connect-proxy.ts b/src/claude/intercept/connect-proxy.ts new file mode 100644 index 00000000000..9a748a30614 --- /dev/null +++ b/src/claude/intercept/connect-proxy.ts @@ -0,0 +1,167 @@ +import { createServer, connect, type Server, type Socket } from "node:net"; + +/** + * Loopback HTTP CONNECT proxy for Claude Code. + * + * Claude Code honours `HTTPS_PROXY` and opens `CONNECT :443` for every upstream. This + * proxy splices tunnels for the intercepted hosts onto the local TLS listener (which holds a + * leaf certificate for them) and blindly relays every other tunnel to its real destination, + * so telemetry, OAuth refresh and claude.ai traffic stay native and opaque to opencodex. + * + * Only CONNECT is served. Plain proxied HTTP requests are refused: Claude Code never sends + * them, and answering them would turn this socket into a generic forward proxy. + */ + +export const CLAUDE_INTERCEPT_HOSTS = ["api.anthropic.com"] as const; + +const MAX_HEAD_BYTES = 8 * 1024; +const HEAD_TIMEOUT_MS = 10_000; +const UPSTREAM_CONNECT_TIMEOUT_MS = 15_000; + +export interface ConnectProxyOptions { + /** Loopback port of the TLS listener that terminates intercepted tunnels. */ + interceptPort: number; + /** Hostnames (lowercase) whose 443 tunnels are spliced onto `interceptPort`. */ + interceptHosts?: readonly string[]; + /** Test seam: dial the real destination for a blind tunnel. */ + dialUpstream?: (host: string, port: number) => Socket; +} + +export interface ConnectProxyHandle { + port: number; + close(): Promise; +} + +interface ConnectTarget { + host: string; + port: number; +} + +/** Parse `CONNECT host:port HTTP/1.1` from a request head; `null` for anything else. */ +export function parseConnectRequestLine(head: string): ConnectTarget | null { + const requestLine = head.split("\r\n", 1)[0] ?? ""; + const match = /^CONNECT\s+(\S+)\s+HTTP\/1\.[01]$/.exec(requestLine); + if (!match) return null; + const authority = match[1]!; + // Bracketed IPv6 (`[::1]:443`) and plain `host:port`. + const ipv6 = /^\[([^\]]+)\]:(\d{1,5})$/.exec(authority); + const hostPort = ipv6 ?? /^([^:]+):(\d{1,5})$/.exec(authority); + if (!hostPort) return null; + const port = Number(hostPort[2]); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + return { host: hostPort[1]!.toLowerCase().replace(/\.$/, ""), port }; +} + +function isLoopbackTarget(host: string): boolean { + return host === "localhost" || host === "127.0.0.1" || host === "::1" || host.startsWith("127."); +} + +function respond(socket: Socket, status: number, reason: string): void { + if (socket.destroyed) return; + socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); +} + +function splice(client: Socket, upstream: Socket, pending: Uint8Array): void { + const teardown = () => { + client.destroy(); + upstream.destroy(); + }; + client.on("error", teardown); + upstream.on("error", teardown); + client.on("close", () => upstream.destroy()); + upstream.on("close", () => client.destroy()); + client.setTimeout(0); + client.setNoDelay(true); + upstream.setNoDelay(true); + if (pending.length > 0) upstream.write(pending); + client.pipe(upstream); + upstream.pipe(client); +} + +function handleConnection(socket: Socket, options: Required>): void { + let head: Buffer = Buffer.alloc(0); + socket.on("error", () => socket.destroy()); + socket.setTimeout(HEAD_TIMEOUT_MS, () => respond(socket, 408, "Request Timeout")); + + const onData = (chunk: Buffer) => { + head = head.length === 0 ? chunk : Buffer.concat([head, chunk]); + const end = head.indexOf("\r\n\r\n"); + if (end === -1) { + if (head.length > MAX_HEAD_BYTES) { + socket.off("data", onData); + respond(socket, 431, "Request Header Fields Too Large"); + } + return; + } + socket.off("data", onData); + socket.pause(); + const target = parseConnectRequestLine(head.subarray(0, end).toString("latin1")); + // Bytes after the head belong to the tunnel (a client may pipeline its TLS ClientHello). + const pending = head.subarray(end + 4); + if (!target) { + respond(socket, 405, "Method Not Allowed"); + return; + } + if (isLoopbackTarget(target.host)) { + respond(socket, 403, "Forbidden"); + return; + } + const intercept = target.port === 443 && options.interceptHosts.includes(target.host); + const upstream = intercept + ? connect({ host: "127.0.0.1", port: options.interceptPort }) + : options.dialUpstream(target.host, target.port); + let established = false; + const connectTimer = setTimeout(() => { + if (!established) { + upstream.destroy(); + respond(socket, 504, "Gateway Timeout"); + } + }, UPSTREAM_CONNECT_TIMEOUT_MS); + upstream.once("error", () => { + clearTimeout(connectTimer); + if (!established) respond(socket, 502, "Bad Gateway"); + }); + upstream.once("connect", () => { + established = true; + clearTimeout(connectTimer); + if (socket.destroyed) { + upstream.destroy(); + return; + } + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + splice(socket, upstream, pending); + socket.resume(); + }); + }; + socket.on("data", onData); +} + +/** Bind the CONNECT proxy on 127.0.0.1. Rejects when the port is unavailable. */ +export function startConnectProxy(port: number, options: ConnectProxyOptions): Promise { + const resolved = { + interceptPort: options.interceptPort, + interceptHosts: options.interceptHosts ?? CLAUDE_INTERCEPT_HOSTS, + dialUpstream: options.dialUpstream ?? ((host: string, targetPort: number) => connect({ host, port: targetPort })), + }; + return new Promise((resolve, reject) => { + const server: Server = createServer(socket => handleConnection(socket, resolved)); + const sockets = new Set(); + server.on("connection", socket => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + server.once("error", reject); + server.listen({ port, host: "127.0.0.1", exclusive: true }, () => { + server.off("error", reject); + const address = server.address(); + const boundPort = address && typeof address === "object" ? address.port : port; + resolve({ + port: boundPort, + close: () => new Promise(done => { + for (const socket of sockets) socket.destroy(); + server.close(() => done()); + }), + }); + }); + }); +} diff --git a/src/claude/intercept/listener.ts b/src/claude/intercept/listener.ts new file mode 100644 index 00000000000..8b4cdbb1409 --- /dev/null +++ b/src/claude/intercept/listener.ts @@ -0,0 +1,122 @@ +import type { Server } from "bun"; +import type { PemKeyPair } from "./local-ca"; + +/** + * TLS listener that terminates intercepted `api.anthropic.com` tunnels. + * + * The CONNECT proxy splices a Claude Code tunnel onto this socket; the client then speaks + * HTTPS believing it reached Anthropic. Messages traffic is rewritten to a loopback URL and + * handed to the router's own request handler, which already knows how to route mapped models + * to providers and pass genuine Claude models through with the caller's subscription + * credential. Every other path (usage, feedback, model listings, …) is relayed verbatim to + * the real upstream so the client keeps behaving like a first-party install. + */ + +export const CLAUDE_INTERCEPT_UPSTREAM = "https://api.anthropic.com"; + +const INTERCEPTED_PATHS = new Set(["/v1/messages", "/v1/messages/count_tokens"]); + +const HOP_BY_HOP_HEADERS = new Set([ + "connection", "keep-alive", "transfer-encoding", "upgrade", "te", "trailer", + "proxy-authenticate", "proxy-authorization", "proxy-connection", "host", "content-length", + "accept-encoding", +]); + +// fetch() transparently decodes the body, so the encoding headers would describe bytes the +// client never sees. +const RESPONSE_STRIP_HEADERS = new Set([ + "connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length", +]); + +export function isClaudeInterceptedPath(pathname: string, method: string): boolean { + return method === "POST" && INTERCEPTED_PATHS.has(pathname); +} + +/** + * Rebuild the request as if the client had dialled the router directly on loopback: the + * admission and Host checks then take the same path a plain loopback bind always has. + */ +export function rewriteInterceptedRequest(req: Request, loopbackOrigin: string): Request { + const url = new URL(req.url); + const headers = new Headers(req.headers); + headers.set("host", loopbackOrigin.replace(/^https?:\/\//, "")); + return new Request(`${loopbackOrigin}${url.pathname}${url.search}`, { + method: req.method, + headers, + body: req.body, + signal: req.signal, + redirect: "manual", + // @ts-expect-error -- streaming request bodies require half duplex under the fetch spec. + duplex: "half", + }); +} + +export function forwardHeadersForUpstream(source: Headers): Headers { + const headers = new Headers(); + source.forEach((value, name) => { + if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase())) headers.append(name, value); + }); + return headers; +} + +export async function relayToUpstream(req: Request, upstreamBase: string, fetchImpl: typeof fetch = fetch): Promise { + const url = new URL(req.url); + const target = `${upstreamBase.replace(/\/$/, "")}${url.pathname}${url.search}`; + const hasBody = req.method !== "GET" && req.method !== "HEAD"; + let upstream: Response; + try { + upstream = await fetchImpl(target, { + method: req.method, + headers: forwardHeadersForUpstream(req.headers), + body: hasBody ? req.body : undefined, + signal: req.signal, + redirect: "manual", + // @ts-expect-error -- streaming request bodies require half duplex under the fetch spec. + duplex: "half", + }); + } catch (error) { + return Response.json( + { type: "error", error: { type: "api_error", message: `intercept relay failed: ${error instanceof Error ? error.message : String(error)}` } }, + { status: 502 }, + ); + } + const headers = new Headers(); + upstream.headers.forEach((value, name) => { + if (!RESPONSE_STRIP_HEADERS.has(name.toLowerCase())) headers.append(name, value); + }); + return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers }); +} + +export interface ClaudeInterceptListenerOptions { + leaf: PemKeyPair; + /** Router request handler; receives the loopback-rewritten request and the intercept server. */ + dispatch: (req: Request, server: Server) => Promise; + upstreamBase?: string; + maxRequestBodySize?: number; + idleTimeout?: number; + fetchImpl?: typeof fetch; + /** Test seam: bind a fixed port instead of an ephemeral one. */ + port?: number; +} + +/** Bind the intercept TLS listener on an ephemeral loopback port. */ +export function startClaudeInterceptListener(options: ClaudeInterceptListenerOptions): Server { + const upstreamBase = options.upstreamBase ?? CLAUDE_INTERCEPT_UPSTREAM; + let loopbackOrigin = ""; + const server = Bun.serve({ + port: options.port ?? 0, + hostname: "127.0.0.1", + tls: { cert: options.leaf.certPem, key: options.leaf.keyPem }, + idleTimeout: options.idleTimeout ?? 255, + ...(options.maxRequestBodySize !== undefined ? { maxRequestBodySize: options.maxRequestBodySize } : {}), + async fetch(req, requestServer) { + const url = new URL(req.url); + if (isClaudeInterceptedPath(url.pathname, req.method)) { + return options.dispatch(rewriteInterceptedRequest(req, loopbackOrigin), requestServer); + } + return relayToUpstream(req, upstreamBase, options.fetchImpl); + }, + }); + loopbackOrigin = `http://127.0.0.1:${server.port}`; + return server; +} diff --git a/src/claude/intercept/local-ca.ts b/src/claude/intercept/local-ca.ts new file mode 100644 index 00000000000..d3baf4a6c5e --- /dev/null +++ b/src/claude/intercept/local-ca.ts @@ -0,0 +1,278 @@ +import { createHash, generateKeyPairSync, createPrivateKey, createPublicKey, sign, type KeyObject } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Local certificate authority for the Claude intercept listener. + * + * Claude Code opens `CONNECT api.anthropic.com:443` through `HTTPS_PROXY` and then expects a + * TLS server that presents a certificate for that name. This module mints that authority and + * its leaves with nothing but `node:crypto`: no OpenSSL shell-out, no third-party ASN.1 + * library. The CA is trusted by the Claude Code process alone via `NODE_EXTRA_CA_CERTS`; it is + * never installed into an OS trust store. + * + * Only the subset of X.509 needed for a v3 CA and a serverAuth leaf is encoded. ECDSA P-256 is + * used for both, which Node's TLS stack accepts without any additional configuration. + */ + +export const CLAUDE_INTERCEPT_CA_COMMON_NAME = "opencodex Claude Intercept CA"; +const ORGANIZATION = "opencodex"; +const CA_VALIDITY_DAYS = 3650; +const LEAF_VALIDITY_DAYS = 365; + +// ── DER encoding ──────────────────────────────────────────────────────────────── + +function derLength(length: number): Uint8Array { + if (length < 0x80) return Uint8Array.of(length); + const bytes: number[] = []; + let rest = length; + while (rest > 0) { + bytes.unshift(rest & 0xff); + rest >>= 8; + } + return Uint8Array.of(0x80 | bytes.length, ...bytes); +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function tlv(tag: number, body: Uint8Array): Uint8Array { + return concat(Uint8Array.of(tag), derLength(body.length), body); +} + +const sequence = (...parts: Uint8Array[]) => tlv(0x30, concat(...parts)); +const set = (...parts: Uint8Array[]) => tlv(0x31, concat(...parts)); +const boolean = (value: boolean) => tlv(0x01, Uint8Array.of(value ? 0xff : 0x00)); +const octetString = (body: Uint8Array) => tlv(0x04, body); +const utf8String = (text: string) => tlv(0x0c, new TextEncoder().encode(text)); +const contextTag = (n: number, body: Uint8Array, constructed = true) => tlv((constructed ? 0xa0 : 0x80) | n, body); + +function integer(bytes: Uint8Array): Uint8Array { + return tlv(0x02, (bytes[0]! & 0x80) !== 0 ? concat(Uint8Array.of(0), bytes) : bytes); +} + +function bitString(bytes: Uint8Array, unusedBits = 0): Uint8Array { + return tlv(0x03, concat(Uint8Array.of(unusedBits), bytes)); +} + +function objectIdentifier(dotted: string): Uint8Array { + const arcs = dotted.split(".").map(Number); + const bytes: number[] = [arcs[0]! * 40 + arcs[1]!]; + for (const arc of arcs.slice(2)) { + const encoded: number[] = [arc & 0x7f]; + let rest = arc >> 7; + while (rest > 0) { + encoded.unshift((rest & 0x7f) | 0x80); + rest >>= 7; + } + bytes.push(...encoded); + } + return tlv(0x06, Uint8Array.from(bytes)); +} + +function utcTime(date: Date): Uint8Array { + const pad = (n: number) => String(n).padStart(2, "0"); + const text = `${pad(date.getUTCFullYear() % 100)}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` + + `${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`; + return tlv(0x17, new TextEncoder().encode(text)); +} + +const OID = { + commonName: "2.5.4.3", + organization: "2.5.4.10", + ecdsaWithSha256: "1.2.840.10045.4.3.2", + basicConstraints: "2.5.29.19", + keyUsage: "2.5.29.15", + subjectAltName: "2.5.29.17", + extendedKeyUsage: "2.5.29.37", + subjectKeyIdentifier: "2.5.29.14", + authorityKeyIdentifier: "2.5.29.35", + serverAuth: "1.3.6.1.5.5.7.3.1", +} as const; + +function distinguishedName(commonName: string): Uint8Array { + return sequence( + set(sequence(objectIdentifier(OID.organization), utf8String(ORGANIZATION))), + set(sequence(objectIdentifier(OID.commonName), utf8String(commonName))), + ); +} + +function extension(oid: string, critical: boolean, value: Uint8Array): Uint8Array { + return critical + ? sequence(objectIdentifier(oid), boolean(true), octetString(value)) + : sequence(objectIdentifier(oid), octetString(value)); +} + +function subjectPublicKeyInfo(key: KeyObject): Uint8Array { + return new Uint8Array(key.export({ type: "spki", format: "der" })); +} + +/** RFC 5280 permits any unique octet string; a truncated SHA-256 of the SPKI is stable and collision-safe. */ +function keyIdentifier(key: KeyObject): Uint8Array { + return new Uint8Array(createHash("sha256").update(subjectPublicKeyInfo(key)).digest()).subarray(0, 20); +} + +function randomSerial(): Uint8Array { + const serial = crypto.getRandomValues(new Uint8Array(16)); + serial[0]! &= 0x7f; + if (serial[0] === 0) serial[0] = 1; + return serial; +} + +function toPem(label: string, der: Uint8Array): string { + const base64 = Buffer.from(der).toString("base64").replace(/(.{64})/g, "$1\n").trimEnd(); + return `-----BEGIN ${label}-----\n${base64}\n-----END ${label}-----\n`; +} + +interface IssueOptions { + subject: Uint8Array; + issuer: Uint8Array; + subjectKey: KeyObject; + signingKey: KeyObject; + validityDays: number; + extensions: Uint8Array[]; +} + +function issueCertificate(options: IssueOptions): Uint8Array { + // Back-date slightly so a client whose clock trails ours by a few minutes still accepts it. + const notBefore = new Date(Date.now() - 5 * 60_000); + const notAfter = new Date(notBefore.getTime() + options.validityDays * 86_400_000); + const algorithm = sequence(objectIdentifier(OID.ecdsaWithSha256)); + const tbsCertificate = sequence( + contextTag(0, tlv(0x02, Uint8Array.of(2))), + integer(randomSerial()), + algorithm, + options.issuer, + sequence(utcTime(notBefore), utcTime(notAfter)), + options.subject, + subjectPublicKeyInfo(options.subjectKey), + contextTag(3, sequence(...options.extensions)), + ); + const signature = new Uint8Array(sign("sha256", tbsCertificate, { key: options.signingKey, dsaEncoding: "der" })); + return sequence(tbsCertificate, algorithm, bitString(signature)); +} + +// ── Authority + leaves ────────────────────────────────────────────────────────── + +export interface PemKeyPair { + certPem: string; + keyPem: string; +} + +export interface LocalInterceptCa extends PemKeyPair { + publicKey: KeyObject; + privateKey: KeyObject; +} + +export function createLocalInterceptCa(): LocalInterceptCa { + const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const name = distinguishedName(CLAUDE_INTERCEPT_CA_COMMON_NAME); + const der = issueCertificate({ + subject: name, + issuer: name, + subjectKey: publicKey, + signingKey: privateKey, + validityDays: CA_VALIDITY_DAYS, + extensions: [ + extension(OID.basicConstraints, true, sequence(boolean(true), tlv(0x02, Uint8Array.of(0)))), + // keyCertSign | cRLSign + extension(OID.keyUsage, true, bitString(Uint8Array.of(0x06), 1)), + extension(OID.subjectKeyIdentifier, false, octetString(keyIdentifier(publicKey))), + ], + }); + return { + certPem: toPem("CERTIFICATE", der), + keyPem: privateKey.export({ type: "pkcs8", format: "pem" }) as string, + publicKey, + privateKey, + }; +} + +/** Issue a serverAuth leaf for `hosts` (first entry becomes the CN; all become SAN dNSNames). */ +export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly string[]): PemKeyPair { + if (hosts.length === 0) throw new Error("intercept leaf requires at least one host"); + const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const der = issueCertificate({ + subject: distinguishedName(hosts[0]!), + issuer: distinguishedName(CLAUDE_INTERCEPT_CA_COMMON_NAME), + subjectKey: publicKey, + signingKey: ca.privateKey, + validityDays: LEAF_VALIDITY_DAYS, + extensions: [ + extension(OID.basicConstraints, true, sequence()), + // digitalSignature + extension(OID.keyUsage, true, bitString(Uint8Array.of(0x80), 7)), + extension(OID.extendedKeyUsage, false, sequence(objectIdentifier(OID.serverAuth))), + extension(OID.subjectAltName, false, sequence( + ...hosts.map(host => contextTag(2, new TextEncoder().encode(host), false)), + )), + extension(OID.authorityKeyIdentifier, false, sequence(contextTag(0, keyIdentifier(ca.publicKey), false))), + ], + }); + return { + certPem: toPem("CERTIFICATE", der), + keyPem: privateKey.export({ type: "pkcs8", format: "pem" }) as string, + }; +} + +// ── Persistence ───────────────────────────────────────────────────────────────── + +export const CLAUDE_INTERCEPT_STATE_DIR = "claude-intercept"; +export const CLAUDE_INTERCEPT_CA_CERT_FILE = "ca.pem"; +const CA_KEY_FILE = "ca.key"; + +export function claudeInterceptStateDir(configDir: string): string { + return join(configDir, CLAUDE_INTERCEPT_STATE_DIR); +} + +/** Path Claude Code must see in `NODE_EXTRA_CA_CERTS`. Stable across restarts. */ +export function claudeInterceptCaCertPath(configDir: string): string { + return join(claudeInterceptStateDir(configDir), CLAUDE_INTERCEPT_CA_CERT_FILE); +} + +function writeFileAtomic(path: string, contents: string, mode: number): void { + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, contents, { mode }); + try { chmodSync(tmp, mode); } catch { /* best-effort on platforms without POSIX modes */ } + renameSync(tmp, path); +} + +function loadPersistedCa(dir: string): LocalInterceptCa | null { + const certPath = join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE); + const keyPath = join(dir, CA_KEY_FILE); + if (!existsSync(certPath) || !existsSync(keyPath)) return null; + try { + const certPem = readFileSync(certPath, "utf8"); + const keyPem = readFileSync(keyPath, "utf8"); + const privateKey = createPrivateKey(keyPem); + const publicKey = createPublicKey(keyPem); + if (!certPem.includes("BEGIN CERTIFICATE")) return null; + return { certPem, keyPem, publicKey, privateKey }; + } catch { // no-excuse-ok: catch -- an unreadable or corrupt authority is regenerated below. + return null; + } +} + +/** + * Load the persisted authority under `/claude-intercept/`, minting one when absent + * or unreadable. The private key is written 0600; the certificate is world-readable because + * `NODE_EXTRA_CA_CERTS` only needs the public half. + */ +export function ensureLocalInterceptCa(configDir: string): LocalInterceptCa { + const dir = claudeInterceptStateDir(configDir); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const existing = loadPersistedCa(dir); + if (existing) return existing; + const ca = createLocalInterceptCa(); + writeFileAtomic(join(dir, CA_KEY_FILE), ca.keyPem, 0o600); + writeFileAtomic(join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE), ca.certPem, 0o644); + return ca; +} diff --git a/src/claude/intercept/runtime.ts b/src/claude/intercept/runtime.ts new file mode 100644 index 00000000000..147194af83a --- /dev/null +++ b/src/claude/intercept/runtime.ts @@ -0,0 +1,90 @@ +import type { Server } from "bun"; +import type { OcxConfig } from "../../types"; +import { getConfigDir } from "../../config/paths"; +import { CLAUDE_INTERCEPT_HOSTS, startConnectProxy, type ConnectProxyHandle } from "./connect-proxy"; +import { startClaudeInterceptListener } from "./listener"; +import { claudeInterceptCaCertPath, ensureLocalInterceptCa, issueLocalInterceptLeaf } from "./local-ca"; + +/** + * Lifecycle for the Claude intercept pair (CONNECT proxy + TLS listener). + * + * Started next to the public listener, torn down with it. The proxy port is derived from the + * public port unless configured, because Claude Code's `settings.json` must name a port that + * survives restarts; the TLS listener is ephemeral and only ever reached through the proxy. + */ + +export const CLAUDE_INTERCEPT_PORT_OFFSET = 100; + +export function claudeInterceptEnabled(config: Pick): boolean { + if (config.runtimeRole === "client") return false; + if (config.claudeCode?.enabled === false) return false; + return config.claudeCode?.intercept?.enabled !== false; +} + +export function claudeInterceptProxyPort(config: Pick, publicPort: number): number { + const configured = config.claudeCode?.intercept?.port; + if (typeof configured === "number" && Number.isInteger(configured) && configured >= 1 && configured <= 65535) return configured; + return publicPort + CLAUDE_INTERCEPT_PORT_OFFSET; +} + +export interface ClaudeInterceptState { + proxyPort: number; + caCertPath: string; +} + +export interface ClaudeInterceptHandle extends ClaudeInterceptState { + listener: Server; + stop(): Promise; +} + +let activeState: ClaudeInterceptState | null = null; + +/** Live intercept endpoints, or `null` when the pair is not running in this process. */ +export function getClaudeInterceptState(): ClaudeInterceptState | null { + return activeState; +} + +export interface StartClaudeInterceptOptions { + config: OcxConfig; + publicPort: number; + dispatch: (req: Request, server: Server) => Promise; + maxRequestBodySize?: number; + configDir?: string; +} + +/** + * Bind both halves. Resolves `null` when intercept is disabled. A bind failure is reported by + * rejecting; callers treat it as a degraded optional integration, never as a startup failure. + */ +export async function startClaudeIntercept(options: StartClaudeInterceptOptions): Promise | null> { + if (!claudeInterceptEnabled(options.config)) return null; + const configDir = options.configDir ?? getConfigDir(); + const ca = ensureLocalInterceptCa(configDir); + const leaf = issueLocalInterceptLeaf(ca, CLAUDE_INTERCEPT_HOSTS); + const listener = startClaudeInterceptListener({ + leaf, + dispatch: options.dispatch, + upstreamBase: options.config.claudeCode?.anthropicBaseUrl, + ...(options.maxRequestBodySize !== undefined ? { maxRequestBodySize: options.maxRequestBodySize } : {}), + }); + let proxy: ConnectProxyHandle; + try { + proxy = await startConnectProxy(claudeInterceptProxyPort(options.config, options.publicPort), { + interceptPort: listener.port!, + }); + } catch (error) { + await listener.stop(true); + throw error; + } + const state: ClaudeInterceptState = { proxyPort: proxy.port, caCertPath: claudeInterceptCaCertPath(configDir) }; + activeState = state; + return { + ...state, + listener, + stop: async () => { + if (activeState === state) activeState = null; + await proxy.close(); + await listener.stop(true); + }, + }; +} diff --git a/src/claude/intercept/settings.ts b/src/claude/intercept/settings.ts new file mode 100644 index 00000000000..5b291a7b401 --- /dev/null +++ b/src/claude/intercept/settings.ts @@ -0,0 +1,161 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { claudeConfigDir } from "../auth-detect"; + +/** + * Claude Code `settings.json` env block for intercept mode. + * + * Both the Claude Desktop Code tab and the standalone `claude` CLI read the user + * `~/.claude/settings.json` and export its `env` map into the process before any network + * call. Two keys make every Claude Code process route through the local CONNECT proxy while + * the app itself stays a first-party install: + * + * env.HTTPS_PROXY = http://127.0.0.1: + * env.NODE_EXTRA_CA_CERTS = /claude-intercept/ca.pem + * + * Ownership is tracked by value, never by a marker key. The CA path is the anchor: it lives + * under opencodex's own config directory, so only a block whose `NODE_EXTRA_CA_CERTS` names + * that file is treated as ours. A user's own proxy or CA setting is left alone. + */ + +export const CLAUDE_INTERCEPT_MANAGED_ENV = ["HTTPS_PROXY", "NODE_EXTRA_CA_CERTS"] as const; +export type ClaudeInterceptManagedEnv = typeof CLAUDE_INTERCEPT_MANAGED_ENV[number]; + +export interface ClaudeInterceptEnv { + HTTPS_PROXY: string; + NODE_EXTRA_CA_CERTS: string; +} + +export function claudeInterceptProxyUrl(port: number): string { + return `http://127.0.0.1:${port}`; +} + +export function buildClaudeInterceptEnv(proxyPort: number, caCertPath: string): ClaudeInterceptEnv { + return { HTTPS_PROXY: claudeInterceptProxyUrl(proxyPort), NODE_EXTRA_CA_CERTS: caCertPath }; +} + +export type ClaudeInterceptSettingsState = + | { kind: "absent" } + | { kind: "applied"; env: ClaudeInterceptEnv } + | { kind: "stale"; env: Partial } + | { kind: "foreign"; env: Partial> } + | { kind: "unreadable"; path: string }; + +type SettingsDoc = Record & { env?: Record }; + +function settingsPath(configDir: string): string { + return join(configDir, "settings.json"); +} + +function readSettings(path: string): { doc: SettingsDoc } | { error: "missing" | "unreadable" } { + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch (error) { // no-excuse-ok: catch -- an absent settings file is the fresh-install state. + return { error: (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unreadable" }; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { error: "unreadable" }; + return { doc: parsed as SettingsDoc }; + } catch { // no-excuse-ok: catch -- a corrupt settings file must be reported, not overwritten. + return { error: "unreadable" }; + } +} + +function envRecord(doc: SettingsDoc): Record { + return doc.env && typeof doc.env === "object" && !Array.isArray(doc.env) ? doc.env : {}; +} + +/** Loopback proxy URLs are the only shape opencodex ever writes. */ +export function isClaudeInterceptProxyUrl(value: unknown): value is string { + return typeof value === "string" && /^http:\/\/127\.0\.0\.1:\d{1,5}\/?$/.test(value.trim()); +} + +function isOwnedCaPath(value: unknown, ownedCaPath: string): value is string { + return typeof value === "string" && value.trim() === ownedCaPath; +} + +/** Classify the current settings env against the values this router would write. */ +export function inspectClaudeInterceptSettings( + expected: ClaudeInterceptEnv, + configDir = claudeConfigDir(), +): ClaudeInterceptSettingsState { + const path = settingsPath(configDir); + const read = readSettings(path); + if ("error" in read) return read.error === "missing" ? { kind: "absent" } : { kind: "unreadable", path }; + const env = envRecord(read.doc); + const proxy = env.HTTPS_PROXY; + const ca = env.NODE_EXTRA_CA_CERTS; + if (proxy === undefined && ca === undefined) return { kind: "absent" }; + if (proxy === expected.HTTPS_PROXY && ca === expected.NODE_EXTRA_CA_CERTS) return { kind: "applied", env: expected }; + const caOurs = isOwnedCaPath(ca, expected.NODE_EXTRA_CA_CERTS); + const proxyOurs = proxy === undefined || isClaudeInterceptProxyUrl(proxy); + if (caOurs && proxyOurs) { + return { + kind: "stale", + env: { + ...(typeof proxy === "string" ? { HTTPS_PROXY: proxy } : {}), + ...(typeof ca === "string" ? { NODE_EXTRA_CA_CERTS: ca } : {}), + }, + }; + } + return { + kind: "foreign", + env: { + ...(typeof proxy === "string" ? { HTTPS_PROXY: proxy } : {}), + ...(typeof ca === "string" ? { NODE_EXTRA_CA_CERTS: ca } : {}), + }, + }; +} + +function writeSettings(path: string, doc: SettingsDoc): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, "utf8"); + renameSync(tmp, path); +} + +export type ClaudeInterceptSettingsWrite = + | { ok: true; changed: boolean; path: string } + | { ok: false; reason: "unreadable" | "foreign_env"; path: string }; + +/** + * Write the intercept env into `settings.json`. Refuses when a managed key already holds a + * value opencodex did not write (a user-configured corporate proxy, for instance). + */ +export function applyClaudeInterceptSettings( + env: ClaudeInterceptEnv, + configDir = claudeConfigDir(), +): ClaudeInterceptSettingsWrite { + const path = settingsPath(configDir); + const state = inspectClaudeInterceptSettings(env, configDir); + if (state.kind === "unreadable") return { ok: false, reason: "unreadable", path }; + if (state.kind === "foreign") return { ok: false, reason: "foreign_env", path }; + if (state.kind === "applied") return { ok: true, changed: false, path }; + const read = readSettings(path); + const doc: SettingsDoc = "doc" in read ? read.doc : {}; + doc.env = { ...envRecord(doc), ...env }; + writeSettings(path, doc); + return { ok: true, changed: true, path }; +} + +/** Remove the managed keys, but only the values opencodex owns. */ +export function removeClaudeInterceptSettings( + ownedCaPath: string, + configDir = claudeConfigDir(), +): ClaudeInterceptSettingsWrite { + const path = settingsPath(configDir); + const read = readSettings(path); + if ("error" in read) { + return read.error === "missing" ? { ok: true, changed: false, path } : { ok: false, reason: "unreadable", path }; + } + const env = envRecord(read.doc); + if (!isOwnedCaPath(env.NODE_EXTRA_CA_CERTS, ownedCaPath)) return { ok: true, changed: false, path }; + delete env.NODE_EXTRA_CA_CERTS; + if (isClaudeInterceptProxyUrl(env.HTTPS_PROXY)) delete env.HTTPS_PROXY; + if (Object.keys(env).length === 0) delete read.doc.env; + else read.doc.env = env; + writeSettings(path, read.doc); + return { ok: true, changed: true, path }; +} diff --git a/src/server/index.ts b/src/server/index.ts index af20051d9eb..2d5a1b31910 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -203,6 +203,7 @@ import { } from "../lib/package-tree-integrity"; import { detectInstall } from "../update/index"; import { createServeOptions, type ServerIngress } from "./index/serve-options"; +import { startClaudeIntercept, type ClaudeInterceptHandle } from "../claude/intercept/runtime"; import { inspectStartupOwnership, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; import { acquireSpendLedgerServerLifecycle, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle"; @@ -627,6 +628,8 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe let server: Server; let loopbackServer: Server | null = null; let managementIngressServer: Server | null = null; + let claudeInterceptServer: Server | null = null; + let serveOptions: ReturnType; // Resolved once, before any listener binds. The clamp is silent inside the resolver so it // stays pure and per-request cheap; the operator is told here instead, once, because a @@ -647,6 +650,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe function ingressForServer(requestServer: Server): ServerIngress { if (requestServer === loopbackServer) return "unauthenticated-loopback"; if (requestServer === managementIngressServer) return "hub-management"; + if (claudeInterceptServer && requestServer === claudeInterceptServer) return "claude-intercept"; return "public"; } let backgroundLifecycle: ReturnType | null = null; @@ -678,7 +682,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe // Started inside the guarded startup transaction so the catch below can // release the owner-scoped lease on any listener failure. userCostOverlayReconciler = startUserCostOverlayReconciler({ liveConfig: config }); - const serveOptions = createServeOptions({ + serveOptions = createServeOptions({ drainingResponse, ingressForServer, loopbackRouteAllowed, @@ -757,6 +761,29 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe } bindNativeMainStartupLifecycle(server, nativeMainLifecycle); + + const interceptDispatch = serveOptions; + // Claude intercept pair (CONNECT proxy + TLS listener). Optional: a bind failure degrades to + // a warning, never to a startup failure, because every other client keeps working without it. + const claudeInterceptStart: Promise | null> = startClaudeIntercept({ + config, + publicPort: server.port ?? listenPort, + maxRequestBodySize: inboundBodyLimitBytes, + dispatch: (req, requestServer) => { + claudeInterceptServer ??= requestServer; + return interceptDispatch.fetch(req, requestServer); + }, + }).then(handle => { + if (handle) { + claudeInterceptServer = 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; + }); + const nativeStop = server.stop.bind(server); const loopbackListenerRef = loopbackServer; const managementIngressRef = managementIngressServer; @@ -776,6 +803,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe ...(managementIngressRef ? [() => managementIngressRef.stop(closeActiveConnections)] : []), + async () => { await (await claudeInterceptStart)?.stop(); }, async () => { await remoteWorkspaceShutdown?.(); }, async () => { try { diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 4bd411a1ce6..6a1b0def278 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -197,7 +197,16 @@ import { readyProtocolMetadata } from "../../remote/protocol"; import { modelCapabilityFields } from "../models-capabilities"; import { createWebsocketHandler } from "./websocket-handler"; -export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management"; +export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management" | "claude-intercept"; + +/** + * Routes the Claude intercept TLS listener may reach. Everything else on that socket is relayed + * to the real upstream by the listener itself, so a request that lands here with another path + * is a bug, not a client — refuse it. + */ +export function claudeInterceptRouteAllowed(url: URL, req: Request): boolean { + return req.method === "POST" && (url.pathname === "/v1/messages" || url.pathname === "/v1/messages/count_tokens"); +} export interface ServeOptionsContext { readonly server: Server; @@ -301,12 +310,24 @@ export function createServeOptions(ctx: ServeOptionsContext) { config, ); } + if (ingress === "claude-intercept" && !claudeInterceptRouteAllowed(codexCompatibleUrl(req.url), req)) { + return withCors( + formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`), + req, + loopbackPolicy(), + ); + } // Auth and CORS decisions below read `policy`, not `config`. For the public listener the // two are the same object, so its behaviour is unchanged; for the loopback listener the // view substitutes 127.0.0.1 as the bind address, which is what routes it through the // same code path a plain loopback bind has always taken — Host-header check included. // Routing, provider selection and response bodies keep using `config`. - const policy: RequestPolicyView = ingress === "unauthenticated-loopback" ? loopbackPolicy() : config; + // The Claude intercept listener is loopback by construction (the CONNECT proxy binds + // 127.0.0.1 and the request was rewritten onto a loopback origin), and its callers carry + // Anthropic credentials, not opencodex admission tokens — so it takes the loopback view too. + const policy: RequestPolicyView = ingress === "unauthenticated-loopback" || ingress === "claude-intercept" + ? loopbackPolicy() + : config; const url = codexCompatibleUrl(req.url); markActivity(`${req.method} ${url.pathname}`); diff --git a/src/types/config.ts b/src/types/config.ts index bd6959ce5ab..7087b484d2a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -137,6 +137,13 @@ export interface OcxClaudeCodeConfig { autoContext?: boolean; /** Compact-window tokens for auto-context. Default 829_800 (AUTO_COMPACT_WINDOW_DEFAULT). */ autoCompactWindow?: number; + /** + * Local CONNECT proxy + TLS listener that intercepts Claude Code's own `api.anthropic.com` + * traffic without any `ANTHROPIC_BASE_URL` rewrite (src/claude/intercept). Claude Code reaches + * it via `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` in its settings env. Default: enabled on a + * hub; the proxy port defaults to the public port + 100. + */ + intercept?: { enabled?: boolean; port?: number }; /** * Bundled-skill content elision for ROUTED (non-Anthropic) models (devlog 260712 * 060): Skill-tool results whose skill name matches an entry here are replaced diff --git a/structure/runtime.md b/structure/runtime.md index 9d7316cfc4e..55ccaaa4966 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -215,6 +215,24 @@ The hub-management socket is enabled only by `runtimeRole: "hub"` plus `hub.managementIngress.enabled`, always binds `127.0.0.1`, and default-denies everything except GUI, session bootstrap/exchange, and `/api/*`. +### Claude intercept pair + +After the startup transaction, `startServer` also starts the optional Claude intercept pair from +`src/claude/intercept/runtime.ts`: a loopback HTTP CONNECT proxy (`src/claude/intercept/connect-proxy.ts`) +and a loopback TLS listener (`src/claude/intercept/listener.ts`) that presents a leaf for +`api.anthropic.com` signed by a per-install authority (`src/claude/intercept/local-ca.ts`, persisted +under `/claude-intercept/` with a 0600 key; never installed into an OS trust store). +Claude Code reaches the pair through `HTTPS_PROXY` plus `NODE_EXTRA_CA_CERTS` in its settings env +(`src/claude/intercept/settings.ts`), so no `ANTHROPIC_BASE_URL` rewrite is involved and the client +still believes it talks to Anthropic. The proxy splices `CONNECT api.anthropic.com:443` onto the TLS +listener, relays every other CONNECT target blind, and refuses plain proxied HTTP and loopback targets. +The TLS listener rewrites `POST /v1/messages` and `POST /v1/messages/count_tokens` onto a loopback +origin and dispatches them to the same route table under the `claude-intercept` ingress, which takes +the loopback request policy; every other path on the intercepted host is relayed verbatim to the +configured Anthropic upstream. The pair is on by default on a hub (`claudeCode.intercept.enabled`), +its proxy port defaults to the public port + 100 (`claudeCode.intercept.port`), and a bind failure +degrades to a startup warning rather than a startup failure; stop joins both sockets. + Auxiliary listener bind failures carry the listener key and effective address through `AuxiliaryListenerBindError` in `src/server/ports.ts`. `src/cli/index.ts` reports them without retrying the public port. Startup still rolls back every earlier socket synchronously. A failed optional bind initiates rollback of every earlier socket; normal stop joins all bound diff --git a/tests/claude-integration/claude-intercept-local-ca.test.ts b/tests/claude-integration/claude-intercept-local-ca.test.ts new file mode 100644 index 00000000000..474b25a1f7e --- /dev/null +++ b/tests/claude-integration/claude-intercept-local-ca.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { X509Certificate } from "node:crypto"; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + CLAUDE_INTERCEPT_CA_COMMON_NAME, + claudeInterceptCaCertPath, + claudeInterceptStateDir, + createLocalInterceptCa, + ensureLocalInterceptCa, + issueLocalInterceptLeaf, +} from "../../src/claude/intercept/local-ca"; + +function tmpConfigDir(): string { + return mkdtempSync(join(tmpdir(), "ocx-intercept-ca-")); +} + +test("CA certificate is a self-signed X.509 v3 authority", () => { + const ca = createLocalInterceptCa(); + const cert = new X509Certificate(ca.certPem); + expect(cert.ca).toBe(true); + expect(cert.subject).toContain(`CN=${CLAUDE_INTERCEPT_CA_COMMON_NAME}`); + expect(cert.issuer).toBe(cert.subject); + expect(cert.verify(ca.publicKey)).toBe(true); + expect(cert.checkIssued(cert)).toBe(true); + expect(new Date(cert.validFrom).getTime()).toBeLessThan(Date.now()); + expect(new Date(cert.validTo).getTime()).toBeGreaterThan(Date.now() + 365 * 24 * 3600 * 1000); +}); + +test("leaf is issued by the CA and names every requested host in SAN", () => { + const ca = createLocalInterceptCa(); + const leaf = issueLocalInterceptLeaf(ca, ["api.anthropic.com", "example.test"]); + const cert = new X509Certificate(leaf.certPem); + const caCert = new X509Certificate(ca.certPem); + expect(cert.ca).toBe(false); + expect(cert.checkIssued(caCert)).toBe(true); + expect(cert.verify(ca.publicKey)).toBe(true); + expect(cert.checkHost("api.anthropic.com")).toBe("api.anthropic.com"); + expect(cert.checkHost("example.test")).toBe("example.test"); + expect(cert.checkHost("other.example")).toBeUndefined(); + expect(cert.keyUsage).toContain("1.3.6.1.5.5.7.3.1"); + expect(leaf.keyPem).toContain("PRIVATE KEY"); +}); + +test("two CAs never share a serial or key", () => { + const a = createLocalInterceptCa(); + const b = createLocalInterceptCa(); + expect(new X509Certificate(a.certPem).serialNumber).not.toBe(new X509Certificate(b.certPem).serialNumber); + expect(a.keyPem).not.toBe(b.keyPem); +}); + +test("ensureLocalInterceptCa persists once and reloads the same authority", () => { + const configDir = tmpConfigDir(); + const first = ensureLocalInterceptCa(configDir); + const second = ensureLocalInterceptCa(configDir); + expect(second.certPem).toBe(first.certPem); + expect(second.keyPem).toBe(first.keyPem); + expect(readFileSync(claudeInterceptCaCertPath(configDir), "utf8")).toBe(first.certPem); + if (process.platform !== "win32") { + expect(statSync(join(claudeInterceptStateDir(configDir), "ca.key")).mode & 0o777).toBe(0o600); + expect(statSync(claudeInterceptCaCertPath(configDir)).mode & 0o777).toBe(0o644); + expect(statSync(claudeInterceptStateDir(configDir)).mode & 0o777).toBe(0o700); + } +}); + +test("a corrupt private key regenerates the authority instead of throwing", () => { + const configDir = tmpConfigDir(); + const first = ensureLocalInterceptCa(configDir); + writeFileSync(join(claudeInterceptStateDir(configDir), "ca.key"), "not a key\n"); + const regenerated = ensureLocalInterceptCa(configDir); + expect(regenerated.certPem).not.toBe(first.certPem); + expect(new X509Certificate(regenerated.certPem).ca).toBe(true); + expect(readFileSync(claudeInterceptCaCertPath(configDir), "utf8")).toBe(regenerated.certPem); +}); diff --git a/tests/claude-integration/claude-intercept-proxy.test.ts b/tests/claude-integration/claude-intercept-proxy.test.ts new file mode 100644 index 00000000000..8a5db1bc685 --- /dev/null +++ b/tests/claude-integration/claude-intercept-proxy.test.ts @@ -0,0 +1,174 @@ +import { afterAll, expect, test } from "bun:test"; +import { connect, createServer } from "node:net"; +import { CLAUDE_INTERCEPT_HOSTS, parseConnectRequestLine, startConnectProxy, type ConnectProxyHandle } from "../../src/claude/intercept/connect-proxy"; +import { startClaudeInterceptListener, rewriteInterceptedRequest } from "../../src/claude/intercept/listener"; +import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; + +/** + * End-to-end shape of the intercept pair: a client that only knows `HTTPS_PROXY` and trusts the + * local CA reaches the router's Messages handler for `api.anthropic.com`, while every other + * CONNECT target is relayed blind. No real network: the "upstream" for blind tunnels is a local + * echo socket and the relay target for non-Messages paths is a local Bun server. + */ + +const cleanups: Array<() => Promise | void> = []; +afterAll(async () => { + for (const cleanup of cleanups.reverse()) await cleanup(); +}); + +test("parseConnectRequestLine accepts host:port and bracketed IPv6, rejects the rest", () => { + expect(parseConnectRequestLine("CONNECT api.anthropic.com:443 HTTP/1.1\r\nHost: x")).toEqual({ host: "api.anthropic.com", port: 443 }); + expect(parseConnectRequestLine("CONNECT API.Anthropic.COM.:443 HTTP/1.0")).toEqual({ host: "api.anthropic.com", port: 443 }); + expect(parseConnectRequestLine("CONNECT [::1]:8443 HTTP/1.1")).toEqual({ host: "::1", port: 8443 }); + expect(parseConnectRequestLine("GET http://example.com/ HTTP/1.1")).toBeNull(); + expect(parseConnectRequestLine("CONNECT example.com HTTP/1.1")).toBeNull(); + expect(parseConnectRequestLine("CONNECT example.com:99999 HTTP/1.1")).toBeNull(); +}); + +function rawRequest(port: number, payload: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect({ host: "127.0.0.1", port }, () => socket.write(payload)); + let out = ""; + socket.on("data", chunk => { out += chunk.toString("latin1"); }); + socket.on("end", () => resolve(out)); + socket.on("close", () => resolve(out)); + socket.on("error", reject); + }); +} + +async function startEchoUpstream(): Promise<{ port: number; close(): Promise }> { + const server = createServer(socket => socket.on("data", chunk => socket.write(`echo:${chunk.toString()}`))); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = address && typeof address === "object" ? address.port : 0; + return { port, close: () => new Promise(resolve => server.close(() => resolve())) }; +} + +async function startPair(): Promise<{ proxy: ConnectProxyHandle; ca: ReturnType; seen: Request[]; relayHits: string[] }> { + const ca = createLocalInterceptCa(); + const leaf = issueLocalInterceptLeaf(ca, CLAUDE_INTERCEPT_HOSTS); + const seen: Request[] = []; + const relayHits: string[] = []; + const fakeUpstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + relayHits.push(`${req.method} ${new URL(req.url).pathname} host=${req.headers.get("host")}`); + return new Response(JSON.stringify({ relayed: true }), { headers: { "content-type": "application/json", "x-upstream": "fake" } }); + }, + }); + cleanups.push(() => fakeUpstream.stop(true)); + const listener = startClaudeInterceptListener({ + leaf, + upstreamBase: `http://127.0.0.1:${fakeUpstream.port}`, + dispatch: async req => { + seen.push(req); + const body = await req.text(); + return Response.json({ dispatched: true, url: req.url, host: req.headers.get("host"), body }); + }, + }); + cleanups.push(() => listener.stop(true)); + const echo = await startEchoUpstream(); + cleanups.push(echo.close); + const proxy = await startConnectProxy(0, { + interceptPort: listener.port!, + dialUpstream: (host, port) => { + expect(host).toBe("telemetry.example"); + expect(port).toBe(443); + return connect({ host: "127.0.0.1", port: echo.port }); + }, + }); + cleanups.push(proxy.close); + return { proxy, ca, seen, relayHits }; +} + +/** Speak HTTPS to `api.anthropic.com` through the CONNECT proxy, trusting only the local CA. */ +async function viaProxy(proxyPort: number, caPem: string, method: string, path: string, body?: string): Promise<{ status: number; headers: Headers; body: string }> { + const res = await fetch(`https://api.anthropic.com${path}`, { + method, + headers: { "content-type": "application/json", "x-api-key": "sk-ant-test" }, + body, + proxy: `http://127.0.0.1:${proxyPort}`, + tls: { ca: caPem }, + }); + return { status: res.status, headers: res.headers, body: await res.text() }; +} + +test("CONNECT api.anthropic.com terminates TLS locally and dispatches Messages to the router", async () => { + const { proxy, ca, seen } = await startPair(); + const res = await viaProxy(proxy.port, ca.certPem, "POST", "/v1/messages?beta=true", JSON.stringify({ model: "claude-x" })); + expect(res.status).toBe(200); + const json = JSON.parse(res.body) as { dispatched: boolean; url: string; host: string; body: string }; + expect(json.dispatched).toBe(true); + expect(json.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/v1\/messages\?beta=true$/); + expect(json.host).toMatch(/^127\.0\.0\.1:\d+$/); + expect(json.body).toBe(JSON.stringify({ model: "claude-x" })); + expect(seen).toHaveLength(1); + expect(seen[0]!.headers.get("x-api-key")).toBe("sk-ant-test"); +}); + +test("non-Messages paths on the intercepted host are relayed to upstream, not dispatched", async () => { + const { proxy, ca, seen, relayHits } = await startPair(); + const res = await viaProxy(proxy.port, ca.certPem, "GET", "/v1/models?limit=1"); + expect(res.status).toBe(200); + expect(JSON.parse(res.body)).toEqual({ relayed: true }); + expect(res.headers.get("x-upstream")).toBe("fake"); + expect(relayHits).toEqual(["GET /v1/models host=127.0.0.1:" + relayHits[0]!.split(":").pop()]); + expect(seen).toHaveLength(0); +}); + +test("GET on /v1/messages is relayed rather than dispatched", async () => { + const { proxy, ca, seen, relayHits } = await startPair(); + await viaProxy(proxy.port, ca.certPem, "GET", "/v1/messages"); + expect(seen).toHaveLength(0); + expect(relayHits[0]).toStartWith("GET /v1/messages "); +}); + +test("other CONNECT targets are relayed blind, including pipelined bytes after the head", async () => { + const { proxy } = await startPair(); + const out = await new Promise((resolve, reject) => { + const socket = connect({ host: "127.0.0.1", port: proxy.port }, () => { + socket.write("CONNECT telemetry.example:443 HTTP/1.1\r\nHost: telemetry.example:443\r\n\r\nhello"); + }); + let buf = ""; + socket.on("data", chunk => { + buf += chunk.toString("latin1"); + if (buf.includes("echo:hello")) { socket.end(); resolve(buf); } + }); + socket.on("error", reject); + }); + expect(out.startsWith("HTTP/1.1 200 Connection Established\r\n\r\n")).toBe(true); + expect(out).toContain("echo:hello"); +}); + +test("plain proxied HTTP, loopback targets and oversized heads are refused", async () => { + const { proxy } = await startPair(); + expect(await rawRequest(proxy.port, "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n")).toStartWith("HTTP/1.1 405"); + expect(await rawRequest(proxy.port, "CONNECT 127.0.0.1:22 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); + expect(await rawRequest(proxy.port, "CONNECT localhost:443 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); + expect(await rawRequest(proxy.port, `CONNECT a:443 HTTP/1.1\r\nX: ${"y".repeat(9000)}`)).toStartWith("HTTP/1.1 431"); +}); + +test("a dead upstream yields 502 instead of a hung tunnel", async () => { + const dead = await startEchoUpstream(); + await dead.close(); + const proxy = await startConnectProxy(0, { + interceptPort: 1, + dialUpstream: () => connect({ host: "127.0.0.1", port: dead.port }), + }); + cleanups.push(proxy.close); + expect(await rawRequest(proxy.port, "CONNECT gone.example:443 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 502"); +}); + +test("rewriteInterceptedRequest moves the request onto the loopback origin and keeps path, query and headers", () => { + const original = new Request("https://api.anthropic.com/v1/messages?x=1", { + method: "POST", + headers: { "anthropic-version": "2023-06-01", host: "api.anthropic.com" }, + body: "{}", + }); + const rewritten = rewriteInterceptedRequest(original, "http://127.0.0.1:4567"); + expect(rewritten.url).toBe("http://127.0.0.1:4567/v1/messages?x=1"); + expect(rewritten.headers.get("host")).toBe("127.0.0.1:4567"); + expect(rewritten.headers.get("anthropic-version")).toBe("2023-06-01"); + expect(rewritten.method).toBe("POST"); +}); diff --git a/tests/claude-integration/claude-intercept-settings.test.ts b/tests/claude-integration/claude-intercept-settings.test.ts new file mode 100644 index 00000000000..66dd41b499f --- /dev/null +++ b/tests/claude-integration/claude-intercept-settings.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyClaudeInterceptSettings, + buildClaudeInterceptEnv, + inspectClaudeInterceptSettings, + removeClaudeInterceptSettings, +} from "../../src/claude/intercept/settings"; +import { claudeInterceptEnabled, claudeInterceptProxyPort } from "../../src/claude/intercept/runtime"; + +const CA = "/home/u/.opencodex/claude-intercept/ca.pem"; +const env = buildClaudeInterceptEnv(8846, CA); + +function dir(): string { + return mkdtempSync(join(tmpdir(), "ocx-intercept-settings-")); +} + +function readSettings(configDir: string): Record { + return JSON.parse(readFileSync(join(configDir, "settings.json"), "utf8")) as Record; +} + +test("env block shape", () => { + expect(env).toEqual({ HTTPS_PROXY: "http://127.0.0.1:8846", NODE_EXTRA_CA_CERTS: CA }); +}); + +test("apply creates settings.json when absent and is idempotent", () => { + const configDir = dir(); + expect(inspectClaudeInterceptSettings(env, configDir)).toEqual({ kind: "absent" }); + expect(applyClaudeInterceptSettings(env, configDir)).toMatchObject({ ok: true, changed: true }); + expect(readSettings(configDir)).toEqual({ env }); + expect(applyClaudeInterceptSettings(env, configDir)).toMatchObject({ ok: true, changed: false }); + expect(inspectClaudeInterceptSettings(env, configDir)).toEqual({ kind: "applied", env }); +}); + +test("apply preserves unrelated settings and env keys", () => { + const configDir = dir(); + writeFileSync(join(configDir, "settings.json"), JSON.stringify({ model: "opus", env: { ANTHROPIC_MODEL: "gpt-x", FOO: "1" }, permissions: { allow: [] } })); + applyClaudeInterceptSettings(env, configDir); + expect(readSettings(configDir)).toEqual({ + model: "opus", + env: { ANTHROPIC_MODEL: "gpt-x", FOO: "1", ...env }, + permissions: { allow: [] }, + }); +}); + +test("a previous port is stale and gets rewritten; a foreign proxy is left alone", () => { + const configDir = dir(); + writeFileSync(join(configDir, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://127.0.0.1:9000", NODE_EXTRA_CA_CERTS: CA } })); + expect(inspectClaudeInterceptSettings(env, configDir)).toEqual({ kind: "stale", env: { HTTPS_PROXY: "http://127.0.0.1:9000", NODE_EXTRA_CA_CERTS: CA } }); + expect(applyClaudeInterceptSettings(env, configDir)).toMatchObject({ ok: true, changed: true }); + expect(readSettings(configDir)).toEqual({ env }); + + const foreign = dir(); + writeFileSync(join(foreign, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://corp-proxy:3128" } })); + expect(inspectClaudeInterceptSettings(env, foreign).kind).toBe("foreign"); + expect(applyClaudeInterceptSettings(env, foreign)).toMatchObject({ ok: false, reason: "foreign_env" }); + expect(readSettings(foreign)).toEqual({ env: { HTTPS_PROXY: "http://corp-proxy:3128" } }); + + // A loopback proxy with someone else's CA is not ours either. + const otherCa = dir(); + writeFileSync(join(otherCa, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://127.0.0.1:8080", NODE_EXTRA_CA_CERTS: "/etc/mitm/ca.pem" } })); + expect(inspectClaudeInterceptSettings(env, otherCa).kind).toBe("foreign"); +}); + +test("remove deletes only owned values and drops an emptied env block", () => { + const configDir = dir(); + writeFileSync(join(configDir, "settings.json"), JSON.stringify({ model: "opus", env: { ...env } })); + expect(removeClaudeInterceptSettings(CA, configDir)).toMatchObject({ ok: true, changed: true }); + expect(readSettings(configDir)).toEqual({ model: "opus" }); + expect(removeClaudeInterceptSettings(CA, configDir)).toMatchObject({ ok: true, changed: false }); + + const foreign = dir(); + writeFileSync(join(foreign, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://127.0.0.1:8080", NODE_EXTRA_CA_CERTS: "/etc/mitm/ca.pem" } })); + expect(removeClaudeInterceptSettings(CA, foreign)).toMatchObject({ ok: true, changed: false }); + expect(readSettings(foreign)).toEqual({ env: { HTTPS_PROXY: "http://127.0.0.1:8080", NODE_EXTRA_CA_CERTS: "/etc/mitm/ca.pem" } }); + + const missing = dir(); + expect(removeClaudeInterceptSettings(CA, missing)).toMatchObject({ ok: true, changed: false }); +}); + +test("corrupt settings.json is reported, never overwritten", () => { + const configDir = dir(); + writeFileSync(join(configDir, "settings.json"), "{ not json"); + expect(inspectClaudeInterceptSettings(env, configDir).kind).toBe("unreadable"); + expect(applyClaudeInterceptSettings(env, configDir)).toMatchObject({ ok: false, reason: "unreadable" }); + expect(removeClaudeInterceptSettings(CA, configDir)).toMatchObject({ ok: false, reason: "unreadable" }); + expect(readFileSync(join(configDir, "settings.json"), "utf8")).toBe("{ not json"); +}); + +test("intercept is on by default for a hub, off for clients and when Claude Code is disabled", () => { + expect(claudeInterceptEnabled({})).toBe(true); + expect(claudeInterceptEnabled({ claudeCode: { intercept: { enabled: false } } })).toBe(false); + expect(claudeInterceptEnabled({ claudeCode: { enabled: false } })).toBe(false); + expect(claudeInterceptEnabled({ runtimeRole: "client" })).toBe(false); + expect(claudeInterceptProxyPort({}, 8746)).toBe(8846); + expect(claudeInterceptProxyPort({ claudeCode: { intercept: { port: 9100 } } }, 8746)).toBe(9100); + expect(claudeInterceptProxyPort({ claudeCode: { intercept: { port: 0 } } }, 8746)).toBe(8846); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index bcc3905de72..7076cf8bf17 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1415,5 +1415,9 @@ "chat-inline-document-bytes.test.ts": "responses", "devin-output-budget.test.ts": "providers", "api-key-model-scope.test.ts": "server", - "docs-provider-discovery-limits.test.ts": "ci-workflows" + "docs-provider-discovery-limits.test.ts": "ci-workflows", + "claude-intercept-local-ca.test.ts": "claude-integration", + "claude-intercept-proxy.test.ts": "claude-integration", + "claude-intercept-settings.test.ts": "claude-integration", + "claude-intercept-integration.test.ts": "server" } diff --git a/tests/server/claude-intercept-integration.test.ts b/tests/server/claude-intercept-integration.test.ts new file mode 100644 index 00000000000..9328a99e1e4 --- /dev/null +++ b/tests/server/claude-intercept-integration.test.ts @@ -0,0 +1,129 @@ +/** + * The Claude intercept pair wired into a real `startServer`: a client configured with nothing + * but `HTTPS_PROXY` and the local CA reaches the router's Messages handler under the loopback + * policy, while every other path on the intercepted host is relayed to the configured upstream + * and never touches the router's own routes. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { findAvailablePort } from "../../src/server/ports"; +import { claudeInterceptCaCertPath } from "../../src/claude/intercept/local-ca"; +import { getClaudeInterceptState } from "../../src/claude/intercept/runtime"; +import type { OcxConfig } from "../../src/types"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const previousApiToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const previousHome = process.env.OPENCODEX_HOME; +let testDir = ""; + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "ocx-claude-intercept-")); + process.env.OPENCODEX_HOME = testDir; + process.env.OPENCODEX_API_AUTH_TOKEN = "public-secret"; +}); + +afterEach(() => { + if (previousApiToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiToken; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir && existsSync(testDir)) removeTreeWithRetry(testDir); + testDir = ""; +}); + +async function waitForIntercept(): Promise>> { + for (let i = 0; i < 100; i++) { + const state = getClaudeInterceptState(); + if (state) return state; + await Bun.sleep(20); + } + throw new Error("intercept pair did not start"); +} + +test("Messages through CONNECT reach the router; other paths relay to the configured upstream", async () => { + const upstreamHits: string[] = []; + const fakeUpstream = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + upstreamHits.push(`${req.method} ${new URL(req.url).pathname}`); + return Response.json({ upstream: true }); + }, + }); + const interceptPort = await findAvailablePort(0, "127.0.0.1"); + const publicPort = await findAvailablePort(0, "127.0.0.1", { reservedPort: interceptPort }); + saveConfig({ + port: publicPort, + hostname: "127.0.0.1", + defaultProvider: "chatgpt", + providers: { + chatgpt: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + claudeCode: { + anthropicBaseUrl: `http://127.0.0.1:${fakeUpstream.port}`, + intercept: { port: interceptPort }, + }, + } as unknown as OcxConfig); + const server = startServer(publicPort); + try { + const state = await waitForIntercept(); + expect(state.proxyPort).toBe(interceptPort); + expect(state.caCertPath).toBe(claudeInterceptCaCertPath(testDir)); + const ca = readFileSync(state.caCertPath, "utf8"); + const proxy = `http://127.0.0.1:${state.proxyPort}`; + + // No opencodex admission token is sent: the intercept ingress takes the loopback policy, so + // the request is judged by the Messages handler (which fails on routing, since the test + // config has no usable provider credential) rather than refused at admission. + const messages = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + proxy, + tls: { ca }, + headers: { "content-type": "application/json", "anthropic-version": "2023-06-01", "x-api-key": "sk-ant-not-real" }, + body: JSON.stringify({ model: "no-such-model-for-intercept-test", max_tokens: 8, messages: [{ role: "user", content: "hi" }] }), + }); + const messagesBody = await messages.json() as { type: string; error: { message: string } }; + expect(messages.headers.get("content-type")).toContain("application/json"); + expect(messagesBody.type).toBe("error"); + expect(messagesBody.error.message).not.toContain("opencodex API key required"); + expect(messagesBody.error.message).not.toContain("Unknown endpoint"); + expect(upstreamHits).toEqual([]); + + // Anything else on the intercepted host is the client's own business with Anthropic. + const models = await fetch("https://api.anthropic.com/v1/models", { proxy, tls: { ca } }); + expect(await models.json()).toEqual({ upstream: true }); + const health = await fetch("https://api.anthropic.com/healthz", { proxy, tls: { ca } }); + expect(await health.json()).toEqual({ upstream: true }); + expect(upstreamHits).toEqual(["GET /v1/models", "GET /healthz"]); + } finally { + await server.stop(true); + fakeUpstream.stop(true); + } + expect(getClaudeInterceptState()).toBeNull(); +}, SERVER_BUDGET_MS); + +test("intercept.enabled=false starts no proxy", async () => { + const publicPort = await findAvailablePort(0, "127.0.0.1"); + saveConfig({ + port: publicPort, + hostname: "127.0.0.1", + defaultProvider: "chatgpt", + providers: { + chatgpt: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + claudeCode: { intercept: { enabled: false } }, + } as unknown as OcxConfig); + const server = startServer(publicPort); + try { + await Bun.sleep(100); + expect(getClaudeInterceptState()).toBeNull(); + expect(existsSync(join(testDir, "claude-intercept"))).toBe(false); + } finally { + await server.stop(true); + } +}, SERVER_BUDGET_MS); From 733029eee6d7fdbd3ef06bc0359ffb1fcc25dcb4 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:07:07 -0700 Subject: [PATCH 02/17] Validate claudeCode.intercept in the config schema Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/config/schema/config-schema.ts | 16 +++++++++++++++- .../claude-intercept-settings.test.ts | 12 ++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 18a45a68412..5a7273e5a3a 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -261,7 +261,21 @@ export const configSchema = z.object({ if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) { ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" }); } else if (claudeCode) { - const claude = claudeCode as { desktopProfile?: unknown }; + const claude = claudeCode as { desktopProfile?: unknown; intercept?: unknown }; + if (claude.intercept !== undefined) { + const intercept = claude.intercept; + if (!intercept || typeof intercept !== "object" || Array.isArray(intercept)) { + ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept"], message: "intercept must be an object" }); + } else { + const { enabled, port } = intercept as { enabled?: unknown; port?: unknown }; + if (enabled !== undefined && typeof enabled !== "boolean") { + ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "enabled"], message: "intercept.enabled must be a boolean" }); + } + if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) { + ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "port"], message: "intercept.port must be an integer between 1 and 65535" }); + } + } + } if (claude.desktopProfile !== undefined) { try { parseDesktopProfile(claude.desktopProfile); diff --git a/tests/claude-integration/claude-intercept-settings.test.ts b/tests/claude-integration/claude-intercept-settings.test.ts index 66dd41b499f..81ad3cb6b9a 100644 --- a/tests/claude-integration/claude-intercept-settings.test.ts +++ b/tests/claude-integration/claude-intercept-settings.test.ts @@ -9,6 +9,7 @@ import { removeClaudeInterceptSettings, } from "../../src/claude/intercept/settings"; import { claudeInterceptEnabled, claudeInterceptProxyPort } from "../../src/claude/intercept/runtime"; +import { configSchema } from "../../src/config/schema/config-schema"; const CA = "/home/u/.opencodex/claude-intercept/ca.pem"; const env = buildClaudeInterceptEnv(8846, CA); @@ -98,3 +99,14 @@ test("intercept is on by default for a hub, off for clients and when Claude Code expect(claudeInterceptProxyPort({ claudeCode: { intercept: { port: 9100 } } }, 8746)).toBe(9100); expect(claudeInterceptProxyPort({ claudeCode: { intercept: { port: 0 } } }, 8746)).toBe(8846); }); + +test("claudeCode.intercept is validated by the config schema", () => { + const base = { + port: 0, defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" } }, + }; + expect(configSchema.safeParse({ ...base, claudeCode: { intercept: { enabled: false, port: 9100 } } }).success).toBe(true); + expect(configSchema.safeParse({ ...base, claudeCode: { intercept: "off" } }).success).toBe(false); + expect(configSchema.safeParse({ ...base, claudeCode: { intercept: { enabled: "no" } } }).success).toBe(false); + expect(configSchema.safeParse({ ...base, claudeCode: { intercept: { port: 70000 } } }).success).toBe(false); +}); From 84d70e5f7434d3d39154a71c39b73fcef7982140 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:56:49 -0700 Subject: [PATCH 03/17] fix(claude-intercept): keep ephemeral-port servers proxy-free and lift wiring out of index.ts startServer(0) has no stable port to derive the CONNECT proxy from, so the intercept pair now stays off unless claudeCode.intercept.port is explicit; this also keeps in-process test fixtures at their expected listener count. The wiring moves into src/server/index/claude-intercept-lifecycle.ts and the inbound-body-limit warning into startup-warnings.ts so src/server/index.ts stays under its file-size cap. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/claude/intercept/runtime.ts | 8 +++ src/server/index.ts | 62 ++++--------------- .../index/claude-intercept-lifecycle.ts | 49 +++++++++++++++ src/server/index/startup-warnings.ts | 24 +++++++ structure/runtime.md | 9 ++- tests/lab/core-lab-boundary.test.ts | 1 + .../claude-intercept-integration.test.ts | 28 +++++++++ 7 files changed, 127 insertions(+), 54 deletions(-) create mode 100644 src/server/index/claude-intercept-lifecycle.ts diff --git a/src/claude/intercept/runtime.ts b/src/claude/intercept/runtime.ts index 147194af83a..a1452117d8c 100644 --- a/src/claude/intercept/runtime.ts +++ b/src/claude/intercept/runtime.ts @@ -46,7 +46,13 @@ export function getClaudeInterceptState(): ClaudeInterceptState | null { export interface StartClaudeInterceptOptions { config: OcxConfig; + /** Bound public port; the derived proxy port is offset from it. */ publicPort: number; + /** + * Port the operator asked for. `0` (ephemeral) gives the derived proxy port no stable value + * to write into `settings.json`, so intercept stays off unless `intercept.port` is explicit. + */ + requestedPort?: number; dispatch: (req: Request, server: Server) => Promise; maxRequestBodySize?: number; configDir?: string; @@ -58,6 +64,8 @@ export interface StartClaudeInterceptOptions { */ export async function startClaudeIntercept(options: StartClaudeInterceptOptions): Promise | null> { if (!claudeInterceptEnabled(options.config)) return null; + const explicitPort = typeof options.config.claudeCode?.intercept?.port === "number"; + if (options.requestedPort === 0 && !explicitPort) return null; const configDir = options.configDir ?? getConfigDir(); const ca = ensureLocalInterceptCa(configDir); const leaf = issueLocalInterceptLeaf(ca, CLAUDE_INTERCEPT_HOSTS); diff --git a/src/server/index.ts b/src/server/index.ts index 2d5a1b31910..dc3462d638a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -69,11 +69,6 @@ import { runDevinProviderMergeStartupMigration } from "../providers/devin-provid import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; import { providerCodexAccountMode } from "../providers/registry"; import type { StorageCleanupPolicy } from "../types"; -import { - MAX_CONFIGURABLE_INBOUND_BODY_BYTES, - MIN_CONFIGURABLE_INBOUND_BODY_BYTES, - resolveInboundBodyLimitBytes, -} from "./request-decompress"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; export { clearThreadAccountMap, @@ -203,8 +198,8 @@ import { } from "../lib/package-tree-integrity"; import { detectInstall } from "../update/index"; import { createServeOptions, type ServerIngress } from "./index/serve-options"; -import { startClaudeIntercept, type ClaudeInterceptHandle } from "../claude/intercept/runtime"; -import { inspectStartupOwnership, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; +import { createClaudeInterceptLifecycle } from "./index/claude-intercept-lifecycle"; +import { inspectStartupOwnership, resolveInboundBodyLimitWithWarning, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; import { acquireSpendLedgerServerLifecycle, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle"; export function startServer(port?: number, deps: StartServerDeps = {}): Server { @@ -628,29 +623,13 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe let server: Server; let loopbackServer: Server | null = null; let managementIngressServer: Server | null = null; - let claudeInterceptServer: Server | null = null; - let serveOptions: ReturnType; - - // Resolved once, before any listener binds. The clamp is silent inside the resolver so it - // stays pure and per-request cheap; the operator is told here instead, once, because a - // config value that was quietly reduced is exactly the thing they would otherwise debug - // against the wrong limit. - const inboundBodyLimitBytes = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes); - const requestedInboundBodyLimit = config.maxInboundBodyBytes; - if (requestedInboundBodyLimit !== undefined - && requestedInboundBodyLimit > 0 - && requestedInboundBodyLimit !== inboundBodyLimitBytes) { - console.warn( - `[server] maxInboundBodyBytes=${requestedInboundBodyLimit} is outside the supported range ` - + `[${MIN_CONFIGURABLE_INBOUND_BODY_BYTES}, ${MAX_CONFIGURABLE_INBOUND_BODY_BYTES}]; ` - + `using ${inboundBodyLimitBytes} bytes.`, - ); - } + const claudeIntercept = createClaudeInterceptLifecycle(); + const inboundBodyLimitBytes = resolveInboundBodyLimitWithWarning(config); function ingressForServer(requestServer: Server): ServerIngress { if (requestServer === loopbackServer) return "unauthenticated-loopback"; if (requestServer === managementIngressServer) return "hub-management"; - if (claudeInterceptServer && requestServer === claudeInterceptServer) return "claude-intercept"; + if (claudeIntercept.ownsListener(requestServer)) return "claude-intercept"; return "public"; } let backgroundLifecycle: ReturnType | null = null; @@ -682,7 +661,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe // Started inside the guarded startup transaction so the catch below can // release the owner-scoped lease on any listener failure. userCostOverlayReconciler = startUserCostOverlayReconciler({ liveConfig: config }); - serveOptions = createServeOptions({ + const serveOptions = createServeOptions({ drainingResponse, ingressForServer, loopbackRouteAllowed, @@ -752,6 +731,10 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe throw new AuxiliaryListenerBindError("hub.managementIngress", managementIngressPort, "127.0.0.1", error); } } + claudeIntercept.start({ + config, publicPort: server.port ?? listenPort, requestedPort: listenPort, maxRequestBodySize: inboundBodyLimitBytes, + dispatch: (req, requestServer) => serveOptions.fetch(req, requestServer), + }); } catch (error) { unregisterQuotaAutoRefresh?.(); userCostOverlayReconciler?.stop(); @@ -761,29 +744,6 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe } bindNativeMainStartupLifecycle(server, nativeMainLifecycle); - - const interceptDispatch = serveOptions; - // Claude intercept pair (CONNECT proxy + TLS listener). Optional: a bind failure degrades to - // a warning, never to a startup failure, because every other client keeps working without it. - const claudeInterceptStart: Promise | null> = startClaudeIntercept({ - config, - publicPort: server.port ?? listenPort, - maxRequestBodySize: inboundBodyLimitBytes, - dispatch: (req, requestServer) => { - claudeInterceptServer ??= requestServer; - return interceptDispatch.fetch(req, requestServer); - }, - }).then(handle => { - if (handle) { - claudeInterceptServer = 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; - }); - const nativeStop = server.stop.bind(server); const loopbackListenerRef = loopbackServer; const managementIngressRef = managementIngressServer; @@ -803,7 +763,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe ...(managementIngressRef ? [() => managementIngressRef.stop(closeActiveConnections)] : []), - async () => { await (await claudeInterceptStart)?.stop(); }, + () => claudeIntercept.stop(), async () => { await remoteWorkspaceShutdown?.(); }, async () => { try { diff --git a/src/server/index/claude-intercept-lifecycle.ts b/src/server/index/claude-intercept-lifecycle.ts new file mode 100644 index 00000000000..f49bc49b988 --- /dev/null +++ b/src/server/index/claude-intercept-lifecycle.ts @@ -0,0 +1,49 @@ +import type { Server } from "bun"; +import { + startClaudeIntercept, + type ClaudeInterceptHandle, + type StartClaudeInterceptOptions, +} from "../../claude/intercept/runtime"; + +/** + * Owns the Claude intercept pair (CONNECT proxy + TLS listener) on behalf of `startServer`. + * The pair is an optional integration: a bind failure degrades to a warning, never to a + * startup failure, because every other client keeps working without it. `startServer` stays + * synchronous, so the start is fire-and-forget and `stop()` awaits whatever it produced. + */ +export interface ClaudeInterceptLifecycle { + /** True once the TLS listener has bound and `requestServer` is it. */ + ownsListener(requestServer: Server): boolean; + start(options: StartClaudeInterceptOptions): void; + stop(): Promise; +} + +export function createClaudeInterceptLifecycle(): ClaudeInterceptLifecycle { + let listener: Server | null = null; + let pending: Promise | null> = Promise.resolve(null); + return { + ownsListener: requestServer => listener !== null && requestServer === listener, + start(options) { + const dispatch = options.dispatch; + pending = startClaudeIntercept({ + ...options, + dispatch: (req, requestServer) => { + listener ??= requestServer; + return dispatch(req, requestServer); + }, + }).then(handle => { + if (handle) { + listener = handle.listener; + console.log(`🔐 Claude intercept proxy active on http://127.0.0.1:${handle.proxyPort} (CONNECT api.anthropic.com → local TLS)`); + } + return handle; + }).catch((error: unknown) => { + console.warn(`⚠ Claude intercept proxy could not start: ${error instanceof Error ? error.message : String(error)}`); + return null; + }); + }, + async stop() { + await (await pending)?.stop(); + }, + }; +} diff --git a/src/server/index/startup-warnings.ts b/src/server/index/startup-warnings.ts index a29d3f6fcbf..76b9119f735 100644 --- a/src/server/index/startup-warnings.ts +++ b/src/server/index/startup-warnings.ts @@ -56,6 +56,11 @@ import { type PackageTreeIntegrityGuard, } from "../../lib/package-tree-integrity"; import type { LiveSidebandWebSocketFactory } from "./live-sideband"; +import { + MAX_CONFIGURABLE_INBOUND_BODY_BYTES, + MIN_CONFIGURABLE_INBOUND_BODY_BYTES, + resolveInboundBodyLimitBytes, +} from "../request-decompress"; // GUI static serving extracted to ./server/gui-static. Re-exported below to keep the // "../src/server" import surface stable for tests/callers. @@ -205,6 +210,25 @@ export function warnAgentTaskRecoveryStartup(config: { console.warn(" Recovered plaintext assignment data is retained only in a bounded, process-local in-memory cache; exact fidelity is not guaranteed and the path depends on undocumented backend behavior."); } +/** + * Resolved once, before any listener binds. The clamp is silent inside the resolver so it + * stays pure and per-request cheap; the operator is told here instead, once, because a + * config value that was quietly reduced is exactly the thing they would otherwise debug + * against the wrong limit. + */ +export function resolveInboundBodyLimitWithWarning(config: { maxInboundBodyBytes?: number }): number { + const limit = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes); + const requested = config.maxInboundBodyBytes; + if (requested !== undefined && requested > 0 && requested !== limit) { + console.warn( + `[server] maxInboundBodyBytes=${requested} is outside the supported range ` + + `[${MIN_CONFIGURABLE_INBOUND_BODY_BYTES}, ${MAX_CONFIGURABLE_INBOUND_BODY_BYTES}]; ` + + `using ${limit} bytes.`, + ); + } + return limit; +} + export function warnPlaintextV2AgentMessagesStartup(config: { plaintextV2AgentMessages?: boolean }): void { if (config.plaintextV2AgentMessages !== true) return; console.warn("⚠️ Experimental plaintext V2 agent messages are enabled."); diff --git a/structure/runtime.md b/structure/runtime.md index 55ccaaa4966..ec03516b8cc 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -217,8 +217,9 @@ GUI, session bootstrap/exchange, and `/api/*`. ### Claude intercept pair -After the startup transaction, `startServer` also starts the optional Claude intercept pair from -`src/claude/intercept/runtime.ts`: a loopback HTTP CONNECT proxy (`src/claude/intercept/connect-proxy.ts`) +At the end of the startup transaction, `startServer` also starts the optional Claude intercept pair +through `src/server/index/claude-intercept-lifecycle.ts` (fire-and-forget start, `ownsListener` for +the ingress decision, `stop` joined into the listener shutdown) from `src/claude/intercept/runtime.ts`: a loopback HTTP CONNECT proxy (`src/claude/intercept/connect-proxy.ts`) and a loopback TLS listener (`src/claude/intercept/listener.ts`) that presents a leaf for `api.anthropic.com` signed by a per-install authority (`src/claude/intercept/local-ca.ts`, persisted under `/claude-intercept/` with a 0600 key; never installed into an OS trust store). @@ -231,7 +232,9 @@ origin and dispatches them to the same route table under the `claude-intercept` the loopback request policy; every other path on the intercepted host is relayed verbatim to the configured Anthropic upstream. The pair is on by default on a hub (`claudeCode.intercept.enabled`), its proxy port defaults to the public port + 100 (`claudeCode.intercept.port`), and a bind failure -degrades to a startup warning rather than a startup failure; stop joins both sockets. +degrades to a startup warning rather than a startup failure; stop joins both sockets. A server asked +for an ephemeral public port (`startServer(0)`, the shape every in-process test fixture uses) has no +stable port to derive from, so the pair stays off unless `claudeCode.intercept.port` is explicit. Auxiliary listener bind failures carry the listener key and effective address through `AuxiliaryListenerBindError` in `src/server/ports.ts`. `src/cli/index.ts` reports them without retrying the public port. Startup still rolls back every earlier socket synchronously. diff --git a/tests/lab/core-lab-boundary.test.ts b/tests/lab/core-lab-boundary.test.ts index 32a5a9758fc..3f928d27a83 100644 --- a/tests/lab/core-lab-boundary.test.ts +++ b/tests/lab/core-lab-boundary.test.ts @@ -1021,6 +1021,7 @@ describe("activation window stays synchronous", () => { "(...).then()": "Promise.then on the fire-and-forget `import('../codex/plan-from-token')` chain. then() registers a callback and returns immediately; the callback is a nested function this scan skips. Awaiting the import would already fail Guard 3.", "(...).catch()": "Promise.catch on that same dynamic-import chain. Same fire-and-forget: it cannot suspend startServer.", "backgroundLifecycle.scheduleStartupRun()": "src/server/background-lifecycle.ts owns this object method. The call site cannot resolve the declaration statically; scheduleStartupRun is declared `(): void` and is documented as never blocking listen.", + "claudeIntercept.start()": "Instance method on the lifecycle from createClaudeInterceptLifecycle in src/server/index/claude-intercept-lifecycle.ts. Declared `(options): void`; it stores the fire-and-forget promise from startClaudeIntercept and never awaits it, so the intercept pair binding late (or failing) cannot suspend startServer. Its stop() runs inside the async stop wrapper, which this scan skips.", "spendLedgerLifecycle.track()": "Instance method on the lifecycle from acquireSpendLedgerServerLifecycle in src/server/index/spend-ledger-lifecycle.ts, called on each listener as it is created. It binds the listener's stop, records a rollback closure and returns the same server; it is declared `(server: T): T` and contains no await. An `await spendLedgerLifecycle.track(...)` would already fail Guard 3. The lifecycle's release() is not here because it is called inside the async stop wrapper, which this scan skips as a nested function.", }; diff --git a/tests/server/claude-intercept-integration.test.ts b/tests/server/claude-intercept-integration.test.ts index 9328a99e1e4..6dc046fe1eb 100644 --- a/tests/server/claude-intercept-integration.test.ts +++ b/tests/server/claude-intercept-integration.test.ts @@ -107,6 +107,34 @@ test("Messages through CONNECT reach the router; other paths relay to the config expect(getClaudeInterceptState()).toBeNull(); }, SERVER_BUDGET_MS); +test("an ephemeral public port starts no proxy unless intercept.port is explicit", async () => { + const base = { + hostname: "127.0.0.1", + defaultProvider: "chatgpt", + providers: { + chatgpt: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + }; + saveConfig({ ...base, port: 10100 } as unknown as OcxConfig); + const implicit = startServer(0); + try { + await Bun.sleep(100); + expect(getClaudeInterceptState()).toBeNull(); + } finally { + await implicit.stop(true); + } + + const proxyPort = await findAvailablePort(0, "127.0.0.1"); + saveConfig({ ...base, port: 10100, claudeCode: { intercept: { port: proxyPort } } } as unknown as OcxConfig); + const explicit = startServer(0); + try { + const state = await waitForIntercept(); + expect(state.proxyPort).toBe(proxyPort); + } finally { + await explicit.stop(true); + } +}, SERVER_BUDGET_MS); + test("intercept.enabled=false starts no proxy", async () => { const publicPort = await findAvailablePort(0, "127.0.0.1"); saveConfig({ From 117d2579077ba797eee151dfe81a0328c17771bb Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 02:16:19 -0700 Subject: [PATCH 04/17] fix(claude-intercept): refuse mapped/unspecified loopback CONNECT targets, share the intercepted-path predicate isLoopbackTarget now checks 127/8, ::1, 0.0.0.0 and :: through a BlockList (which also matches IPv4-mapped IPv6), *.localhost, and numeric resolver shorthands like 127.1. serve-options.ts reuses isClaudeInterceptedPath from listener.ts so the two route lists cannot drift apart. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/claude/intercept/connect-proxy.ts | 18 +++++++++++++++--- src/server/index/serve-options.ts | 3 ++- .../claude-intercept-proxy.test.ts | 12 +++++++++++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/claude/intercept/connect-proxy.ts b/src/claude/intercept/connect-proxy.ts index 9a748a30614..86c9375513f 100644 --- a/src/claude/intercept/connect-proxy.ts +++ b/src/claude/intercept/connect-proxy.ts @@ -1,4 +1,4 @@ -import { createServer, connect, type Server, type Socket } from "node:net"; +import { BlockList, createServer, connect, isIP, type Server, type Socket } from "node:net"; /** * Loopback HTTP CONNECT proxy for Claude Code. @@ -52,8 +52,20 @@ export function parseConnectRequestLine(head: string): ConnectTarget | null { return { host: hostPort[1]!.toLowerCase().replace(/\.$/, ""), port }; } -function isLoopbackTarget(host: string): boolean { - return host === "localhost" || host === "127.0.0.1" || host === "::1" || host.startsWith("127."); +// 127/8 and ::1, which `BlockList` also matches in IPv4-mapped form (`::ffff:127.0.0.1`, +// `::ffff:7f00:1`). The unspecified addresses dial the local host too. +const LOCAL_TARGETS = new BlockList(); +LOCAL_TARGETS.addSubnet("127.0.0.0", 8, "ipv4"); +LOCAL_TARGETS.addAddress("0.0.0.0", "ipv4"); +LOCAL_TARGETS.addAddress("::1", "ipv6"); +LOCAL_TARGETS.addAddress("::", "ipv6"); + +export function isLoopbackTarget(host: string): boolean { + if (host === "localhost" || host.endsWith(".localhost")) return true; + const family = isIP(host); + if (family !== 0) return LOCAL_TARGETS.check(host, family === 6 ? "ipv6" : "ipv4"); + // `127.1`, `0x7f000001`, `2130706433`: resolver shorthand for a loopback literal, not a name. + return /^(0x[0-9a-f]+|\d+)(\.(0x[0-9a-f]+|\d+))*$/.test(host); } function respond(socket: Socket, status: number, reason: string): void { diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 6a1b0def278..ba095964b55 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -24,6 +24,7 @@ import { } from "./startup-warnings"; import { remoteWorkspaceEnabled } from "../../remote-control/workspace-activation"; +import { isClaudeInterceptedPath } from "../../claude/intercept/listener"; import { markActivity } from "../../lib/sidecar-tracker"; import { knownModelIdsForProvider } from "../../router"; import { @@ -205,7 +206,7 @@ export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-managem * is a bug, not a client — refuse it. */ export function claudeInterceptRouteAllowed(url: URL, req: Request): boolean { - return req.method === "POST" && (url.pathname === "/v1/messages" || url.pathname === "/v1/messages/count_tokens"); + return isClaudeInterceptedPath(url.pathname, req.method); } export interface ServeOptionsContext { diff --git a/tests/claude-integration/claude-intercept-proxy.test.ts b/tests/claude-integration/claude-intercept-proxy.test.ts index 8a5db1bc685..c9b6db3fb33 100644 --- a/tests/claude-integration/claude-intercept-proxy.test.ts +++ b/tests/claude-integration/claude-intercept-proxy.test.ts @@ -1,6 +1,6 @@ import { afterAll, expect, test } from "bun:test"; import { connect, createServer } from "node:net"; -import { CLAUDE_INTERCEPT_HOSTS, parseConnectRequestLine, startConnectProxy, type ConnectProxyHandle } from "../../src/claude/intercept/connect-proxy"; +import { CLAUDE_INTERCEPT_HOSTS, isLoopbackTarget, parseConnectRequestLine, startConnectProxy, type ConnectProxyHandle } from "../../src/claude/intercept/connect-proxy"; import { startClaudeInterceptListener, rewriteInterceptedRequest } from "../../src/claude/intercept/listener"; import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca"; @@ -146,9 +146,19 @@ test("plain proxied HTTP, loopback targets and oversized heads are refused", asy expect(await rawRequest(proxy.port, "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n")).toStartWith("HTTP/1.1 405"); expect(await rawRequest(proxy.port, "CONNECT 127.0.0.1:22 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); expect(await rawRequest(proxy.port, "CONNECT localhost:443 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); + expect(await rawRequest(proxy.port, "CONNECT [::ffff:127.0.0.1]:22 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); expect(await rawRequest(proxy.port, `CONNECT a:443 HTTP/1.1\r\nX: ${"y".repeat(9000)}`)).toStartWith("HTTP/1.1 431"); }); +test("isLoopbackTarget covers mapped, unspecified and shorthand loopback literals", () => { + for (const host of ["localhost", "foo.localhost", "127.0.0.1", "127.255.0.9", "::1", "::ffff:127.0.0.1", "::ffff:7f00:1", "0.0.0.0", "::", "127.1", "0x7f000001", "2130706433"]) { + expect(isLoopbackTarget(host)).toBe(true); + } + for (const host of ["api.anthropic.com", "10.0.0.1", "::ffff:10.0.0.1", "2606:4700::1", "1.example"]) { + expect(isLoopbackTarget(host)).toBe(false); + } +}); + test("a dead upstream yields 502 instead of a hung tunnel", async () => { const dead = await startEchoUpstream(); await dead.close(); From 7e5542d1a9af7346b5bb77e7dcd1b950991d9ece Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:16:59 -0700 Subject: [PATCH 05/17] feat(claude-desktop): first-party mode as default, gateway profile as explicit opt-in Add claudeCode.desktopMode (first-party | gateway). First-party keeps Claude Desktop on claude.ai and writes only HTTPS_PROXY/NODE_EXTRA_CA_CERTS into Claude Code's settings.json so the Code tab, subagents and the claude CLI go through the local intercept pair. Gateway keeps the existing 3P profile writer. - resolveClaudeDesktopMode: explicit > applied gateway fingerprint > first-party, so existing gateway installs do not flip on update while new installs get 1P - resolveClaudeDesktopApplyMode: implied 1P falls back to gateway where the intercept pair cannot run (client role / intercept disabled) - CLI: ocx claude desktop apply [--first-party|--gateway]; legacy shape flags imply --gateway; connected clients default to gateway - API: /api/claude-desktop/apply accepts first-party|gateway (+ legacy shapes), status reports mode + firstParty block; native toggle applies resolved mode and disable removes both gateway profile and 1P env - ocx ensure: refresh stale 1P env when ON, remove it when OFF - modes are mutually exclusive; foreign proxy/CA env is never overwritten Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/test-layout/layout.json | 1 + src/claude/desktop-first-party.ts | 148 +++++++++++++ src/cli/claude-desktop.ts | 151 +++++++++++-- src/cli/ensure-desired-integrations.ts | 43 +++- src/config/schema/config-schema.ts | 5 +- .../management/agent-settings-routes.ts | 108 ++++++++- .../management/native-integration-routes.ts | 61 +++++- src/types/config.ts | 8 + structure/clients/claude-desktop.md | 40 ++++ .../claude-desktop-first-party.test.ts | 206 ++++++++++++++++++ .../claude-management-api.test.ts | 14 +- .../native-claude-desktop-toggle.test.ts | 12 +- tests/fixtures/test-layout-expected.json | 1 + 13 files changed, 762 insertions(+), 36 deletions(-) create mode 100644 src/claude/desktop-first-party.ts create mode 100644 tests/claude-integration/claude-desktop-first-party.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 76451289314..a15b35cfd50 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1587,6 +1587,7 @@ "claude-intercept-local-ca.test.ts": "claude-integration", "claude-intercept-proxy.test.ts": "claude-integration", "claude-intercept-settings.test.ts": "claude-integration", + "claude-desktop-first-party.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server" }, "migrated": [ diff --git a/src/claude/desktop-first-party.ts b/src/claude/desktop-first-party.ts new file mode 100644 index 00000000000..04027915ddd --- /dev/null +++ b/src/claude/desktop-first-party.ts @@ -0,0 +1,148 @@ +/** + * Claude Desktop first-party mode. + * + * Desktop has two ways to reach opencodex: + * + * - `first-party` (default): the app keeps its ordinary claude.ai login, Chat tab, connectors + * and remote control. Only the Claude Code process it spawns for the Code tab (and that + * process's subagents) is redirected, through the `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` env + * in `~/.claude/settings.json` (src/claude/intercept/settings.ts) and the server's intercept + * pair (src/claude/intercept/runtime.ts). Nothing is written under Desktop's config library. + * - `gateway`: the historical third-party deployment profile (src/claude/desktop-3p.ts). The + * whole app is switched to a gateway build; picker entries are opencodex aliases. + * + * The two are mutually exclusive on disk: applying one removes the other. The mode is persisted + * in `claudeCode.desktopMode`; installs that predate the field but already carry an applied + * gateway profile keep `gateway` until they explicitly re-apply, so an update never flips a + * working Desktop under the operator. + */ +import { getConfigDir } from "../config/paths"; +import type { OcxConfig } from "../types"; +import { claudeInterceptCaCertPath, ensureLocalInterceptCa } from "./intercept/local-ca"; +import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/runtime"; +import { + applyClaudeInterceptSettings, + buildClaudeInterceptEnv, + inspectClaudeInterceptSettings, + removeClaudeInterceptSettings, + type ClaudeInterceptEnv, + type ClaudeInterceptSettingsState, + type ClaudeInterceptSettingsWrite, +} from "./intercept/settings"; + +export const CLAUDE_DESKTOP_MODES = ["first-party", "gateway"] as const; +export type ClaudeDesktopMode = typeof CLAUDE_DESKTOP_MODES[number]; +export const DEFAULT_CLAUDE_DESKTOP_MODE: ClaudeDesktopMode = "first-party"; + +export function isClaudeDesktopMode(value: unknown): value is ClaudeDesktopMode { + return typeof value === "string" && (CLAUDE_DESKTOP_MODES as readonly string[]).includes(value); +} + +type DesktopModeConfig = Pick; + +/** + * Effective Desktop mode. An explicit `claudeCode.desktopMode` wins; otherwise a persisted + * gateway apply marker (`desktopProfile.appliedFingerprint`) means a pre-existing gateway + * install and keeps `gateway`; everything else is the first-party default. + */ +export function resolveClaudeDesktopMode(config: DesktopModeConfig): ClaudeDesktopMode { + const explicit = config.claudeCode?.desktopMode; + if (isClaudeDesktopMode(explicit)) return explicit; + if (config.claudeCode?.desktopProfile?.appliedFingerprint) return "gateway"; + return DEFAULT_CLAUDE_DESKTOP_MODE; +} + +/** + * Mode an *apply* without an explicit choice should use. The first-party default only holds + * where the intercept proxy actually runs; with it disabled (or on a client role) an implied + * first-party apply would point Claude Code at a proxy that never starts, so fall back to the + * gateway profile. An explicit `desktopMode: "first-party"` is still honoured (and refused + * later with `intercept_disabled`, which names the fix). + */ +export function resolveClaudeDesktopApplyMode( + config: Pick, +): ClaudeDesktopMode { + const resolved = resolveClaudeDesktopMode(config); + if (resolved === "gateway" || isClaudeDesktopMode(config.claudeCode?.desktopMode)) return resolved; + return claudeInterceptEnabled(config) ? "first-party" : "gateway"; +} + +export interface DesktopFirstPartyTarget { + proxyPort: number; + caCertPath: string; + env: ClaudeInterceptEnv; +} + +/** The settings env a first-party apply on this machine writes (CA is created on demand). */ +export function desktopFirstPartyTarget( + config: Pick, + opencodexConfigDir = getConfigDir(), +): DesktopFirstPartyTarget { + const proxyPort = claudeInterceptProxyPort(config, config.port ?? 10100); + const caCertPath = claudeInterceptCaCertPath(opencodexConfigDir); + return { proxyPort, caCertPath, env: buildClaudeInterceptEnv(proxyPort, caCertPath) }; +} + +export interface DesktopFirstPartyInspection { + /** False when the server will not run the intercept pair (client role, intercept disabled). */ + interceptEnabled: boolean; + proxyPort: number; + caCertPath: string; + settings: ClaudeInterceptSettingsState; + /** settings.json carries exactly the env the current config would write. */ + applied: boolean; + /** Ours, but for an older port/config directory. Re-apply refreshes it. */ + stale: boolean; +} + +export interface DesktopFirstPartyOptions { + opencodexConfigDir?: string; + claudeConfigDir?: string; +} + +export function inspectDesktopFirstParty( + config: Pick, + options: DesktopFirstPartyOptions = {}, +): DesktopFirstPartyInspection { + const target = desktopFirstPartyTarget(config, options.opencodexConfigDir); + const settings = inspectClaudeInterceptSettings(target.env, options.claudeConfigDir); + return { + interceptEnabled: claudeInterceptEnabled(config), + proxyPort: target.proxyPort, + caCertPath: target.caCertPath, + settings, + applied: settings.kind === "applied", + stale: settings.kind === "stale", + }; +} + +export type DesktopFirstPartyApplyResult = + | { ok: true; changed: boolean; path: string; env: ClaudeInterceptEnv; proxyPort: number } + | { ok: false; reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env"; path: string }; + +/** + * Write the first-party env into Claude Code's settings. Creates the local CA first so the + * path we point `NODE_EXTRA_CA_CERTS` at exists before Claude Code ever reads it. + */ +export function applyDesktopFirstParty( + config: Pick, + options: DesktopFirstPartyOptions = {}, +): DesktopFirstPartyApplyResult { + const opencodexConfigDir = options.opencodexConfigDir ?? getConfigDir(); + const target = desktopFirstPartyTarget(config, opencodexConfigDir); + if (!claudeInterceptEnabled(config)) return { ok: false, reason: "intercept_disabled", path: "" }; + try { + ensureLocalInterceptCa(opencodexConfigDir); + } catch { + return { ok: false, reason: "ca_unavailable", path: target.caCertPath }; + } + const written = applyClaudeInterceptSettings(target.env, options.claudeConfigDir); + if (!written.ok) return { ok: false, reason: written.reason, path: written.path }; + return { ok: true, changed: written.changed, path: written.path, env: target.env, proxyPort: target.proxyPort }; +} + +/** Remove the first-party env. Only values anchored on our CA path are touched. */ +export function removeDesktopFirstParty(options: DesktopFirstPartyOptions = {}): ClaudeInterceptSettingsWrite { + const caCertPath = claudeInterceptCaCertPath(options.opencodexConfigDir ?? getConfigDir()); + return removeClaudeInterceptSettings(caCertPath, options.claudeConfigDir); +} diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 7ff04dfe4e4..66bd7731c03 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -15,13 +15,22 @@ import { type DesktopFamily, type DesktopProfile, } from "../claude/desktop-profile"; -import { writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; +import { inspectDesktop3pConfigLibrary, writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; +import { + applyDesktopFirstParty, + removeDesktopFirstParty, + resolveClaudeDesktopApplyMode, + type ClaudeDesktopMode, +} from "../claude/desktop-first-party"; import { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } from "../claude/desktop-policy"; import { filterCatalogVisibleModels, desktopVisibleNativeSlugs, nativeContextLimits } from "../codex/catalog"; import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api"; import { findLiveProxy } from "../server/proxy-liveness"; import { CliUsageError, runtimeRequest, takeJsonFlag } from "./runtime-api"; import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import type { OcxConfig } from "../types"; + +const APPLY_FLAGS = ["--first-party", "--gateway", "--static", "--hybrid", "--discovery-only"]; function isFamily(value: string | undefined): value is DesktopFamily { return !!value && (DESKTOP_FAMILIES as readonly string[]).includes(value); @@ -29,7 +38,10 @@ function isFamily(value: string | undefined): value is DesktopFamily { function printDesktopHelp(): void { console.log(`Usage: - ocx claude desktop [apply] [--static|--hybrid|--discovery-only] + ocx claude desktop [apply] [--first-party | --gateway [--static|--hybrid|--discovery-only]] + --first-party (default) keep Desktop on claude.ai; route only the Code tab's Claude Code + through the local intercept proxy via ~/.claude/settings.json env + --gateway install the third-party gateway profile for the whole app ocx claude desktop show [--json] ocx claude desktop status [--json] ocx claude desktop move [--default] @@ -135,6 +147,103 @@ async function applyConnectedDesktopProfile( } } +/** Persist the operator's Desktop mode choice; a failed marker save never undoes the apply. */ +function saveDesktopMode(mode: ClaudeDesktopMode, deps: ApplyProfileDeps): boolean { + try { + return withClientLifecycleSync(() => { + const outcome = mutatePersistedConfig(current => { + if (current.claudeCode?.desktopMode === mode) return { changed: false, value: true }; + current.claudeCode = { ...(current.claudeCode ?? {}), desktopMode: mode }; + return { changed: true, value: true }; + }); + return outcome.status !== "unavailable"; + }, deps.lifecycleLockDeps); + } catch { + return false; + } +} + +export type DesktopApplyTarget = + | { kind: "first-party" } + | { kind: "gateway"; mode: Desktop3pConfigMode }; + +/** Parse `ocx claude desktop apply` flags into a target; legacy gateway shape flags imply --gateway. */ +/** + * Default mode when no flag is given: first-party wherever the local intercept proxy can + * run; a connected client (proxy lives on the hub) or a disabled intercept falls back to + * the gateway profile rather than pointing Claude Code at a proxy that does not exist. + */ +export function defaultDesktopApplyMode( + config: Pick, + connection: ClientConnectionState = readClientConnectionState(), +): ClaudeDesktopMode { + const resolved = resolveClaudeDesktopApplyMode(config); + if (resolved === "gateway") return resolved; + return connection.kind === "connected" ? "gateway" : "first-party"; +} + +export function parseDesktopApplyArgs( + flags: string[], + config: Pick, +): { target: DesktopApplyTarget } | { error: string } { + const shapeFlags = flags.filter(arg => ["--static", "--hybrid", "--discovery-only"].includes(arg)); + const wantsFirstParty = flags.includes("--first-party"); + const wantsGateway = flags.includes("--gateway") || shapeFlags.length > 0; + if (wantsFirstParty && wantsGateway) return { error: "--first-party cannot be combined with --gateway or gateway shape flags." }; + const unknown = flags.filter(arg => !["--first-party", "--gateway", "--static", "--hybrid", "--discovery-only"].includes(arg)); + if (unknown.length > 0) return { error: `알 수 없는 인자: ${unknown.join(" ")}` }; + const kind: ClaudeDesktopMode = wantsFirstParty ? "first-party" : wantsGateway ? "gateway" : defaultDesktopApplyMode(config); + if (kind === "first-party") return { target: { kind } }; + const parsedMode = parseDesktop3pModeArgs(shapeFlags); + if ("error" in parsedMode) return parsedMode; + return { target: { kind, mode: parsedMode.mode } }; +} + +/** + * First-party apply: settings.json env only. The intercept pair the env points at runs inside + * the hub process, so this is a local-hub operation — a connected client machine cannot reach + * a loopback proxy on the hub and is refused rather than left with a dead `HTTPS_PROXY`. + */ +async function applyFirstPartyDesktop( + deps: ApplyProfileDeps, +): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { + try { assertNoClientDisconnectPending(); } catch { return { ok: false, path: "", reason: "client_disconnect_pending" }; } + const connection = readClientConnectionState(); + if (connection.kind === "connected") return { ok: false, path: "", reason: "first_party_requires_local_hub" }; + if (connection.kind !== "disconnected") return { ok: false, path: "", reason: "client_connection_invalid" }; + const config = loadConfig(); + const library = inspectDesktop3pConfigLibrary({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null }); + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + return { ok: false, path: library.selectedProfilePath ?? "", reason: "gateway_profile_active" }; + } + const desired = setIntegrationEnabled("claude-desktop", true); + if (!desired.ok) return { ok: false, path: "", reason: desired.message }; + const applied = applyDesktopFirstParty(config); + if (!applied.ok) return { ok: false, path: applied.path, reason: applied.reason }; + const saved = saveDesktopMode("first-party", deps); + return { + ok: true, + path: applied.path, + ...(saved ? {} : { warning: "desktop mode marker was not saved" }), + }; +} + +export async function applyDesktop( + profile: DesktopProfile | undefined, + target: DesktopApplyTarget, + deps: ApplyProfileDeps = {}, +): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> { + if (target.kind === "first-party") return applyFirstPartyDesktop(deps); + // Gateway replaces first-party; the two must never be active together. + const removed = removeDesktopFirstParty(); + if (!removed.ok) return { ok: false, path: removed.path, reason: "first_party_settings_unreadable" }; + const result = await applyProfile(profile, target.mode, deps); + if (result.ok && !saveDesktopMode("gateway", deps)) { + return { ...result, warning: [result.warning, "desktop mode marker was not saved"].filter(Boolean).join(" ") }; + } + return result; +} + export async function applyProfile( profile: DesktopProfile | undefined, mode: Desktop3pConfigMode, @@ -222,24 +331,35 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf return 0; } - // Legacy mode flags remain apply aliases and are parsed before subcommands. - const legacyFlags = argv.filter(arg => ["--static", "--hybrid", "--discovery-only"].includes(arg)); - const applyInvocation = argv.length === 0 || command === "apply" || legacyFlags.length > 0; + // Mode flags remain apply aliases and are parsed before subcommands. + const applyFlags = argv.filter(arg => APPLY_FLAGS.includes(arg)); + const applyInvocation = argv.length === 0 || command === "apply" || applyFlags.length > 0; if (applyInvocation) { - const nonMode = argv.filter(arg => !["apply", "--static", "--hybrid", "--discovery-only"].includes(arg)); - if (nonMode.length > 0) { - console.error(`알 수 없는 인자: ${nonMode.join(" ")}`); - return 2; - } - const parsedMode = parseDesktop3pModeArgs(legacyFlags); - if ("error" in parsedMode) { console.error(parsedMode.error); return 2; } + const rest = argv.filter(arg => arg !== "apply"); + const parsedTarget = parseDesktopApplyArgs(rest, loadConfig()); + if ("error" in parsedTarget) { console.error(parsedTarget.error); return 2; } + const { target } = parsedTarget; try { - const result = await applyProfile(undefined, parsedMode.mode, deps); + const result = await applyDesktop(undefined, target, deps); if (!result.ok) { console.error(`설정 적용 실패: ${result.reason ?? "unknown error"}`); + if (result.reason === "gateway_profile_active") { + console.error("Claude Desktop is running the gateway profile. Turn the integration off first (dashboard toggle removes the profile), or keep it with `ocx claude desktop apply --gateway`."); + } else if (result.reason === "foreign_env") { + console.error(`~/.claude/settings.json already sets HTTPS_PROXY or NODE_EXTRA_CA_CERTS to a value opencodex does not own (${result.path}). Remove them or use --gateway.`); + } else if (result.reason === "intercept_disabled") { + console.error("First-party mode needs the Claude intercept proxy (claudeCode.intercept.enabled on a hub). Use --gateway instead."); + } else if (result.reason === "first_party_requires_local_hub") { + console.error("First-party mode runs on the hub machine only; on a connected client use --gateway."); + } return 1; } - console.log(`Claude Desktop 설정을 적용했습니다: ${result.path}`); + if (target.kind === "first-party") { + console.log(`Claude Desktop first-party 설정을 적용했습니다: ${result.path}`); + console.log("Desktop 앱 설정은 그대로이며, Code 탭의 Claude Code만 로컬 프록시를 거칩니다."); + } else { + console.log(`Claude Desktop gateway 설정을 적용했습니다: ${result.path}`); + } // The write landed; only the bookkeeping marker did not. Saying nothing // would leave the saved-vs-applied display wrong with no explanation. if (result.warning) console.warn(`⚠️ ${result.warning}`); @@ -336,7 +456,8 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf } saveLocalDesktopProfile(reconciled, config.claudeCode?.desktopProfile, connection, deps); if (flags.includes("--apply")) { - const result = await applyProfile(reconciled, "static", deps); + // A routing profile is a gateway-mode artifact: importing with --apply installs it there. + const result = await applyDesktop(reconciled, { kind: "gateway", mode: "static" }, deps); if (!result.ok) { console.error(`프로필은 저장했지만 Desktop 적용에 실패했습니다: ${result.reason ?? "unknown error"}`); return 1; } if (result.warning) console.warn(`⚠️ ${result.warning}`); } diff --git a/src/cli/ensure-desired-integrations.ts b/src/cli/ensure-desired-integrations.ts index fe94a0b76e2..a8f6a3a259d 100644 --- a/src/cli/ensure-desired-integrations.ts +++ b/src/cli/ensure-desired-integrations.ts @@ -10,7 +10,13 @@ */ import { loadConfig } from "../config"; import { stripGrokConfig, type GrokInjectResult } from "../grok/inject"; -import { removeDesktop3pStandardPivot } from "../claude/desktop-3p"; +import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } from "../claude/desktop-3p"; +import { + applyDesktopFirstParty, + inspectDesktopFirstParty, + removeDesktopFirstParty, + resolveClaudeDesktopMode, +} from "../claude/desktop-first-party"; import { claudeDesktopIntegrationEnabled, grokIntegrationEnabled, @@ -35,6 +41,10 @@ export interface EnsureDesiredIntegrationsDeps { opts?: { hostname?: string }, ) => Promise; removeDesktop3pStandardPivot: typeof removeDesktop3pStandardPivot; + removeDesktopFirstParty?: typeof removeDesktopFirstParty; + applyDesktopFirstParty?: typeof applyDesktopFirstParty; + inspectDesktopFirstParty?: typeof inspectDesktopFirstParty; + inspectDesktop3pConfigLibrary?: typeof inspectDesktop3pConfigLibrary; log?: (message: string) => void; error?: (message: string) => void; } @@ -113,16 +123,39 @@ export async function ensureGrokFenceMatchesDesired( } /** - * When Claude Desktop is durably OFF, clear any leftover owned gateway profile. - * ensure/update used to leave Claude-3p residue in place after a failed disable - * (drifted fingerprint), so the Integrations card kept looking applied/stale. + * When Claude Desktop is durably OFF, clear any leftover owned gateway profile and the + * first-party settings env. ensure/update used to leave Claude-3p residue in place after a + * failed disable (drifted fingerprint), so the Integrations card kept looking applied/stale. + * + * When it is ON in first-party mode, refresh a stale env (the intercept port follows the + * public port, so a port change would otherwise leave Claude Code pointed at a dead proxy). */ export function ensureClaudeDesktopMatchesDesired( deps: EnsureDesiredIntegrationsDeps = productionDeps, ): void { const config = deps.loadConfig(); const { log, error } = io(deps); - if (claudeDesktopIntegrationEnabled(config)) return; + if (claudeDesktopIntegrationEnabled(config)) { + if (resolveClaudeDesktopMode(config) !== "first-party") return; + const library = (deps.inspectDesktop3pConfigLibrary ?? inspectDesktop3pConfigLibrary)({ + appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, + }); + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") return; + const seen = (deps.inspectDesktopFirstParty ?? inspectDesktopFirstParty)(config); + if (!seen.stale) return; + const applied = (deps.applyDesktopFirstParty ?? applyDesktopFirstParty)(config); + if (applied.ok && applied.changed) log(` + Claude Desktop first-party env refreshed (${applied.path})`); + else if (!applied.ok) error(`⚠️ Claude Desktop first-party env refresh skipped: ${applied.reason}.`); + return; + } + try { + const env = (deps.removeDesktopFirstParty ?? removeDesktopFirstParty)(); + if (env.ok && env.changed) log(" ↩️ Claude Desktop first-party env removed."); + else if (!env.ok) error(`⚠️ Claude Desktop first-party env cleanup skipped: ${env.reason} (${env.path}).`); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + error(`⚠️ Claude Desktop first-party env cleanup failed: ${detail}.`); + } try { const removed = deps.removeDesktop3pStandardPivot({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 5a7273e5a3a..1711b8b314a 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -261,7 +261,10 @@ export const configSchema = z.object({ if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) { ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" }); } else if (claudeCode) { - const claude = claudeCode as { desktopProfile?: unknown; intercept?: unknown }; + const claude = claudeCode as { desktopProfile?: unknown; desktopMode?: unknown; intercept?: unknown }; + if (claude.desktopMode !== undefined && claude.desktopMode !== "first-party" && claude.desktopMode !== "gateway") { + ctx.addIssue({ code: "custom", path: ["claudeCode", "desktopMode"], message: "desktopMode must be \"first-party\" or \"gateway\"" }); + } if (claude.intercept !== undefined) { const intercept = claude.intercept; if (!intercept || typeof intercept !== "object" || Array.isArray(intercept)) { diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index ef553702087..97d56c824af 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -131,6 +131,20 @@ function persistDesktopProfileField( return { ok: true }; } +function persistDesktopModeField( + config: OcxConfig, + desktopMode: NonNullable["desktopMode"], +): { ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" } { + const outcome = mutatePersistedConfig(persisted => { + if (persisted.claudeCode?.desktopMode === desktopMode) return { changed: false, value: true }; + persisted.claudeCode = { ...(persisted.claudeCode ?? {}), desktopMode }; + return { changed: true, value: true }; + }); + if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; + config.claudeCode = { ...(config.claudeCode ?? {}), desktopMode }; + return { ok: true }; +} + export function grokApplyFlightSnapshot(): { currentBytes: number; highWaterBytes: number; active: number } { return { currentBytes: grokApplyFlight?.bytes ?? 0, @@ -1002,7 +1016,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") { try { // #859: the CLI delegates here so the registry is built in the serving - // process. Accept an optional mode; default stays static for back-compat. + // process. `mode` selects the Desktop mode: `first-party` (default when the + // install has no gateway profile) or `gateway`; the legacy gateway shapes + // (static/hybrid/discovery) still select gateway with that library layout. let mode: "static" | "hybrid" | "discovery" = "static"; let parsed: unknown; try { @@ -1011,12 +1027,17 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + const { resolveClaudeDesktopApplyMode, applyDesktopFirstParty, removeDesktopFirstParty } = await import("../../claude/desktop-first-party"); const requested = (parsed as { mode?: unknown } | null)?.mode; + let desktopMode: "first-party" | "gateway" = resolveClaudeDesktopApplyMode(config); if (requested !== undefined) { if (requested === "static" || requested === "hybrid" || requested === "discovery") { mode = requested; + desktopMode = "gateway"; + } else if (requested === "gateway" || requested === "first-party") { + desktopMode = requested; } else { - return jsonResponse({ error: "mode must be static, hybrid, or discovery" }, 400); + return jsonResponse({ error: "mode must be first-party, gateway, static, hybrid, or discovery" }, 400); } } // #859: a delegated CLI apply carries the profile it just saved — the @@ -1032,6 +1053,52 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); } } + if (desktopMode === "first-party") { + const { inspectDesktop3pConfigLibrary } = await import("../../claude/desktop-3p"); + const library = inspectDesktop3pConfigLibrary({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null }); + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + return jsonResponse({ + error: "Claude Desktop is currently running the gateway profile. Turn the Desktop integration off (which removes that profile) and apply again, or apply with mode \"gateway\" to keep it.", + code: "claude_desktop_gateway_active", + reason: "gateway_profile_active", + }, 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); + mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true); + const applied = applyDesktopFirstParty(config); + if (!applied.ok) { + const { firstPartyRefusalMessage } = await import("./native-integration-routes"); + return jsonResponse({ + error: firstPartyRefusalMessage(applied.reason, applied.path), + code: "claude_desktop_first_party_refused", + reason: applied.reason, + path: applied.path, + }, applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? 409 : 500); + } + const modeSaved = persistDesktopModeField(config, "first-party"); + return jsonResponse({ + ok: true, + mode: "first-party", + saved: modeSaved.ok, + applied: true, + changed: applied.changed, + path: applied.path, + proxyPort: applied.proxyPort, + caCertPath: applied.env.NODE_EXTRA_CA_CERTS, + ...(modeSaved.ok ? {} : { warning: `First-party env was applied, but the mode marker was not saved (${modeSaved.reason}).` }), + }); + } + // Gateway mode replaces first-party: the two must never be active together. + const firstPartyRemoved = removeDesktopFirstParty(); + if (!firstPartyRemoved.ok) { + return jsonResponse({ + error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); the first-party proxy env could not be removed before switching to gateway mode.`, + code: "claude_desktop_first_party_refused", + reason: firstPartyRemoved.reason, + }, 500); + } const { setIntegrationEnabled, claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); const desired = setIntegrationEnabled("claude-desktop", true); if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500); @@ -1083,6 +1150,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise nativeContextLimits(latest), ); if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500); + persistDesktopModeField(config, "gateway"); const { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy"); const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({ platform: deps.platform ?? process.platform, @@ -1113,6 +1181,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } return jsonResponse({ ok: true, + mode: "gateway", saved: true, applied: true, path: result.path, @@ -1131,21 +1200,39 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise try { const { claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state"); const { inspectDesktop3pConfigLibrary } = await import("../../claude/desktop-3p"); + const { resolveClaudeDesktopApplyMode, inspectDesktopFirstParty } = await import("../../claude/desktop-first-party"); + const { getClaudeInterceptState } = await import("../../claude/intercept/runtime"); const persisted = loadConfig(); const savedFingerprint = persisted.claudeCode?.desktopProfile?.appliedFingerprint ?? null; const observed = inspectDesktop3pConfigLibrary({ appliedFingerprint: savedFingerprint }); const desiredEnabled = claudeDesktopIntegrationEnabled(persisted); - const applied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted"; + const gatewayApplied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted"; + // A gateway profile on disk is what Desktop actually runs, whatever the saved mode says. + const mode = gatewayApplied ? "gateway" : resolveClaudeDesktopApplyMode(persisted); + const firstPartySeen = inspectDesktopFirstParty(persisted); + const intercept = getClaudeInterceptState(); + const firstParty = { + settingsState: firstPartySeen.settings.kind, + applied: firstPartySeen.applied, + stale: firstPartySeen.stale, + interceptEnabled: firstPartySeen.interceptEnabled, + interceptRunning: intercept !== null, + proxyPort: intercept?.proxyPort ?? firstPartySeen.proxyPort, + caCertPath: firstPartySeen.caCertPath, + }; + const applied = mode === "first-party" ? firstPartySeen.applied : gatewayApplied; // "Needs update" is only meaningful while the integration is wanted. When the // durable switch is OFF, a leftover drifted profile is residue to clear — not // a stale apply the operator should refresh. - const stale = desiredEnabled && observed.kind === "gateway_drifted"; + const stale = desiredEnabled && (mode === "first-party" ? firstPartySeen.stale : observed.kind === "gateway_drifted"); const { getDesktopHealth } = await import("../../claude/desktop-health"); const { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy"); const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({ platform: deps.platform ?? process.platform, }); - const policy = claudeDesktopPolicyHealth(policyState); + // Managed-policy conflicts only matter for the gateway profile; first-party mode never + // touches Desktop's own configuration. + const policy = claudeDesktopPolicyHealth(mode === "first-party" ? "absent" : policyState); const health = { ...getDesktopHealth(), ok: policy.ok, @@ -1153,14 +1240,19 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise policy, }; const policyConflict = desiredEnabled && !policy.ok; - const baseDrift = desiredEnabled ? !applied || stale : applied || observed.kind === "unsafe"; + const firstPartyResidue = mode === "gateway" && (firstPartySeen.applied || firstPartySeen.stale); + const baseDrift = desiredEnabled + ? !applied || stale || firstPartyResidue + : applied || gatewayApplied || firstPartySeen.applied || firstPartySeen.stale || observed.kind === "unsafe"; const driftReason = policyConflict ? policy.state === "present" ? "managed_policy_present" : "managed_policy_unknown" : desiredEnabled - ? !applied ? "desired_on_not_current" : stale ? "profile_drift" : null - : applied ? "desired_off_gateway_selected" : null; + ? !applied ? "desired_on_not_current" : stale ? "profile_drift" : firstPartyResidue ? "first_party_residue" : null + : gatewayApplied ? "desired_off_gateway_selected" : firstPartySeen.applied || firstPartySeen.stale ? "desired_off_first_party_env" : null; return jsonResponse({ desiredEnabled, + mode, + firstParty, installed: observed.kind !== "not_installed", observedKind: observed.kind, applied, diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 56158bd162e..dcd1b09afe6 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -25,6 +25,12 @@ import { getCodexHome } from "../../codex/paths"; import { providerContextCap } from "../../providers/context-cap"; import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; +import { + applyDesktopFirstParty, + inspectDesktopFirstParty, + removeDesktopFirstParty, + resolveClaudeDesktopApplyMode, +} from "../../claude/desktop-first-party"; import { projectGrokCatalog } from "../../grok/catalog"; import { injectGrokConfig, stripGrokConfig } from "../../grok/inject"; import { inspectGrokConfig } from "../../grok/inspect"; @@ -47,7 +53,9 @@ export type NativeRefusalReason = | "write_failed" | "metadata_unreadable" | "cleanup_incomplete" - | "desired_state_changed"; + | "desired_state_changed" + | "foreign_env" + | "intercept_disabled"; export interface NativeStatus { clientId: NativeIntegrationClientId; @@ -126,7 +134,7 @@ function desktopStatus(config: ManagementContext["config"]): NativeStatus { const seen = inspectDesktop3pConfigLibrary({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, }); - const state: NativeStatus["state"] = seen.kind === "gateway_ours" + const gatewayState: NativeStatus["state"] = seen.kind === "gateway_ours" ? "current" : seen.kind === "unsafe" || seen.kind === "broken" ? "unsafe" : "absent"; const disableBlocked = seen.kind === "unsafe" || seen.kind === "broken" || seen.kind === "foreign" @@ -135,6 +143,14 @@ function desktopStatus(config: ManagementContext["config"]): NativeStatus { message: "Claude Desktop configuration cannot be changed safely.", } : null; + // First-party mode lives in Claude Code's settings.json, not in Desktop's library. A leftover + // gateway profile still counts as current (it is what Desktop is actually running). + const firstParty = resolveClaudeDesktopApplyMode(config) === "first-party" && gatewayState === "absent" + ? inspectDesktopFirstParty(config) + : null; + const state: NativeStatus["state"] = firstParty + ? firstParty.settings.kind === "unreadable" ? "unsafe" : firstParty.applied ? "current" : "absent" + : gatewayState; return { clientId: "claude-desktop", state, @@ -599,6 +615,22 @@ async function handleGrokToggle(ctx: ManagementContext): Promise { } } +export function firstPartyRefusalMessage( + reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env", + path: string, +): string { + switch (reason) { + case "intercept_disabled": + return "First-party mode needs the Claude intercept proxy, which is off in this configuration (claudeCode.intercept.enabled / client role). Use gateway mode instead."; + case "ca_unavailable": + return `The local intercept certificate could not be created (${path}).`; + case "unreadable": + return `Claude Code settings could not be parsed (${path}); nothing was written.`; + case "foreign_env": + return `Claude Code settings already set HTTPS_PROXY or NODE_EXTRA_CA_CERTS to a value opencodex does not own (${path}); remove them first or use gateway mode.`; + } +} + let claudeDesktopToggleFlight: Promise | null = null; async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { @@ -626,6 +658,11 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise` and `NODE_EXTRA_CA_CERTS=/claude-intercept/ca.pem` + into the `env` block of Claude Code's `settings.json` (via `src/claude/intercept/settings.ts`), + creating the local authority first. Only the Claude Code process Desktop spawns for the Code tab + (and its subagents, and any standalone `claude` CLI) reads that env, so only their + `api.anthropic.com` traffic reaches the [Claude intercept pair](../runtime.md#claude-intercept-pair). +- **gateway**: the existing third-party profile written by `src/claude/desktop-3p.ts`; the whole + app switches to the local gateway. It is selected explicitly (`--gateway`, dashboard, or the + legacy `--static|--hybrid|--discovery-only` shape flags, which imply it). + +`resolveClaudeDesktopMode` returns the explicit `claudeCode.desktopMode` when set; otherwise a +persisted `desktopProfile.appliedFingerprint` (an existing gateway install) keeps `gateway`, and a +fresh install resolves to `first-party`. Updates therefore never flip a working gateway install +silently, while new installs land on first-party. `resolveClaudeDesktopApplyMode` narrows an +*implied* first-party to gateway where the intercept pair cannot run (client role or +`claudeCode.intercept.enabled: false`); an explicit `first-party` is refused with +`intercept_disabled` instead of being rewritten. + +Switching is ownership-aware in both directions: a gateway apply first removes the first-party env +(only values anchored on OpenCodex's own CA path); a first-party apply refuses (`409`, +`gateway_profile_active`) while an owned gateway profile is still selected, and refuses +(`foreign_env`) when `settings.json` already carries a proxy/CA value OpenCodex does not own. +Disabling the integration (native toggle, `ocx ensure` with the durable switch OFF) removes both the +gateway profile and the first-party env. With the switch ON in first-party mode, `ocx ensure` +re-applies a stale env (the proxy port follows the public port). + +Surfaces: `ocx claude desktop apply [--first-party|--gateway]` in `src/cli/claude-desktop.ts`; +`POST /api/claude-desktop/apply` with `mode` ∈ `first-party|gateway|static|hybrid|discovery` and +`GET /api/claude-desktop/status` (`mode`, `firstParty.{applied,stale,interceptEnabled,interceptRunning,proxyPort,caCertPath}`) +in `src/server/management/agent-settings-routes.ts`; the native toggle in +`src/server/management/native-integration-routes.ts` applies the resolved mode on enable. Managed +Windows policy health only applies in gateway mode, because first-party never touches Desktop's own +configuration. Ordinary Chat-tab traffic is out of scope for both modes. + ## Connected Claude Desktop profiles The connection's local Codex readiness check follows the [selected-runtime probe contract](../runtime.md#remote-hub-hardening-ownership); general status hands its resolved command to this check instead of probing the version twice. diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts new file mode 100644 index 00000000000..e283531d13f --- /dev/null +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyDesktopFirstParty, + inspectDesktopFirstParty, + removeDesktopFirstParty, + resolveClaudeDesktopApplyMode, + resolveClaudeDesktopMode, +} from "../../src/claude/desktop-first-party"; +import { parseDesktopApplyArgs } from "../../src/cli/claude-desktop"; +import { ensureClaudeDesktopMatchesDesired } from "../../src/cli/ensure-desired-integrations"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { setIntegrationEnabled } from "../../src/codex/desired-state"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root = ""; +let library = ""; +let claudeDir = ""; +const previous: Record = {}; +const ENV_KEYS = ["OPENCODEX_HOME", "OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR", "CLAUDE_CONFIG_DIR"] as const; + +function config(extra: Partial = {}): OcxConfig { + return { port: 10100, providers: {}, defaultProvider: "openai", ...extra } as OcxConfig; +} + +function settings(): { env?: Record; [key: string]: unknown } { + return JSON.parse(readFileSync(join(claudeDir, "settings.json"), "utf8")) as { env?: Record }; +} + +async function dispatch(path: string, init?: RequestInit, inputConfig: OcxConfig = config()) { + const url = new URL(`http://127.0.0.1:10100${path}`); + const response = await handleManagementAPI(new Request(url, { + ...init, + headers: { Host: url.host, "Content-Type": "application/json", ...(init?.headers ?? {}) }, + }), url, inputConfig, {}); + return { status: response!.status, body: await response!.json() as Record }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-desktop-1p-")); + library = join(root, "desktop-library"); + claudeDir = join(root, "claude"); + for (const key of ENV_KEYS) previous[key] = process.env[key]; + process.env.OPENCODEX_HOME = root; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + process.env.CLAUDE_CONFIG_DIR = claudeDir; + writeFileSync(join(root, "config.json"), JSON.stringify(config())); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + removeTreeWithRetry(root); +}); + +test("mode resolution: explicit wins, applied gateway fingerprint keeps gateway, otherwise first-party", () => { + expect(resolveClaudeDesktopMode(config())).toBe("first-party"); + expect(resolveClaudeDesktopMode(config({ claudeCode: { desktopMode: "gateway" } }))).toBe("gateway"); + expect(resolveClaudeDesktopMode(config({ + claudeCode: { desktopProfile: { version: 1, assignments: {}, defaults: { opus: null, fable: null, sonnet: null, haiku: null }, appliedFingerprint: "abc" } }, + }))).toBe("gateway"); + expect(resolveClaudeDesktopMode(config({ + claudeCode: { + desktopMode: "first-party", + desktopProfile: { version: 1, assignments: {}, defaults: { opus: null, fable: null, sonnet: null, haiku: null }, appliedFingerprint: "abc" }, + }, + }))).toBe("first-party"); +}); + +test("implied apply mode falls back to gateway where the intercept proxy cannot run", () => { + expect(resolveClaudeDesktopApplyMode(config())).toBe("first-party"); + expect(resolveClaudeDesktopApplyMode(config({ runtimeRole: "client" }))).toBe("gateway"); + expect(resolveClaudeDesktopApplyMode(config({ claudeCode: { intercept: { enabled: false } } }))).toBe("gateway"); + // An explicit choice is never silently rewritten. + expect(resolveClaudeDesktopApplyMode(config({ runtimeRole: "client", claudeCode: { desktopMode: "first-party" } }))).toBe("first-party"); +}); + +test("CLI apply flags: default first-party, legacy shape flags imply gateway, conflicts rejected", () => { + expect(parseDesktopApplyArgs([], config())).toEqual({ target: { kind: "first-party" } }); + expect(parseDesktopApplyArgs(["--first-party"], config())).toEqual({ target: { kind: "first-party" } }); + expect(parseDesktopApplyArgs(["--gateway"], config())).toEqual({ target: { kind: "gateway", mode: "static" } }); + expect(parseDesktopApplyArgs(["--hybrid"], config())).toEqual({ target: { kind: "gateway", mode: "hybrid" } }); + expect(parseDesktopApplyArgs(["--gateway", "--discovery-only"], config())).toEqual({ target: { kind: "gateway", mode: "discovery" } }); + expect(parseDesktopApplyArgs([], config({ claudeCode: { desktopMode: "gateway" } }))).toEqual({ target: { kind: "gateway", mode: "static" } }); + expect("error" in parseDesktopApplyArgs(["--first-party", "--gateway"], config())).toBe(true); + expect("error" in parseDesktopApplyArgs(["--first-party", "--static"], config())).toBe(true); + expect("error" in parseDesktopApplyArgs(["--bogus"], config())).toBe(true); +}); + +test("first-party apply writes only the proxy env, creates the CA, and removes cleanly", () => { + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ theme: "dark", env: { FOO: "bar" } })); + const applied = applyDesktopFirstParty(config()); + expect(applied.ok).toBe(true); + if (!applied.ok) return; + expect(applied.proxyPort).toBe(10200); + expect(existsSync(applied.env.NODE_EXTRA_CA_CERTS)).toBe(true); + expect(applied.env.NODE_EXTRA_CA_CERTS.startsWith(root)).toBe(true); + const written = settings(); + expect(written.theme).toBe("dark"); + expect(written.env).toEqual({ + FOO: "bar", + HTTPS_PROXY: "http://127.0.0.1:10200", + NODE_EXTRA_CA_CERTS: applied.env.NODE_EXTRA_CA_CERTS, + }); + expect(inspectDesktopFirstParty(config()).applied).toBe(true); + // Desktop's own library is untouched: first-party never installs a gateway profile. + expect(existsSync(library)).toBe(false); + + // A port change makes the env stale; re-apply refreshes it. + expect(inspectDesktopFirstParty(config({ port: 10300 })).stale).toBe(true); + const refreshed = applyDesktopFirstParty(config({ port: 10300 })); + expect(refreshed.ok && refreshed.changed).toBe(true); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10400"); + + const removed = removeDesktopFirstParty(); + expect(removed).toMatchObject({ ok: true, changed: true }); + expect(settings()).toEqual({ theme: "dark", env: { FOO: "bar" } }); +}); + +test("first-party apply refuses foreign proxy env and disabled intercept", () => { + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://corp-proxy:3128" } })); + expect(applyDesktopFirstParty(config())).toMatchObject({ ok: false, reason: "foreign_env" }); + expect(settings().env).toEqual({ HTTPS_PROXY: "http://corp-proxy:3128" }); + expect(removeDesktopFirstParty()).toMatchObject({ ok: true, changed: false }); + expect(applyDesktopFirstParty(config({ runtimeRole: "client" }))).toMatchObject({ ok: false, reason: "intercept_disabled" }); +}); + +test("POST /api/claude-desktop/apply defaults to first-party and gateway mode replaces it", async () => { + const first = await dispatch("/api/claude-desktop/apply", { method: "POST" }); + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ ok: true, mode: "first-party", applied: true, changed: true, proxyPort: 10200 }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode?.desktopMode).toBe("first-party"); + expect(saved.clientIntegrations?.["claude-desktop"]).not.toBe(false); + + const status = await dispatch("/api/claude-desktop/status"); + expect(status.body).toMatchObject({ + mode: "first-party", + applied: true, + stale: false, + drift: false, + firstParty: { applied: true, interceptEnabled: true, proxyPort: 10200 }, + }); + expect(status.body.health.ok).toBe(true); + + const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }); + expect(gateway.status).toBe(200); + expect(settings().env?.HTTPS_PROXY).toBeUndefined(); + expect(settings().env?.NODE_EXTRA_CA_CERTS).toBeUndefined(); + const afterGateway = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(afterGateway.claudeCode?.desktopMode).toBe("gateway"); + expect(afterGateway.claudeCode?.desktopProfile?.appliedFingerprint).toBeTruthy(); + + // With the gateway profile installed, first-party is refused until it is removed. + const conflict = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, afterGateway); + expect(conflict.status).toBe(409); + expect(conflict.body.reason).toBe("gateway_profile_active"); +}); + +test("native toggle: enable applies first-party by default and disable removes the env", async () => { + const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }); + expect(enabled.status).toBe(200); + expect(enabled.body).toMatchObject({ ok: true, changed: true, state: "current", desiredEnabled: true }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + + const list = await dispatch("/api/native-integrations"); + const desktop = (list.body.clients as Array<{ clientId: string; state: string }>).find(client => client.clientId === "claude-desktop"); + expect(desktop?.state).toBe("current"); + + const disabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: false }) }); + expect(disabled.status).toBe(200); + expect(disabled.body).toMatchObject({ ok: true, changed: true, state: "absent", desiredEnabled: false }); + expect(settings().env?.HTTPS_PROXY).toBeUndefined(); +}); + +test("ensure reconciles first-party env: refreshes when ON and stale, removes when OFF", () => { + const applied = applyDesktopFirstParty(config({ port: 10300 })); + expect(applied.ok).toBe(true); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10400"); + + const logs: string[] = []; + const deps = { + loadConfig: () => config(), + stripGrokConfig: () => ({ ok: true, changed: false, message: "" }), + syncGrokConfig: async () => ({ ok: true, changed: false, message: "" }), + removeDesktop3pStandardPivot: () => ({ ok: true as const, changed: false, kind: "noop" as const, libraryPath: library }), + log: (message: string) => { logs.push(message); }, + error: (message: string) => { logs.push(message); }, + }; + ensureClaudeDesktopMatchesDesired(deps); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + expect(logs.some(line => line.includes("first-party env refreshed"))).toBe(true); + + expect(setIntegrationEnabled("claude-desktop", false).ok).toBe(true); + ensureClaudeDesktopMatchesDesired({ ...deps, loadConfig: () => config({ clientIntegrations: { "claude-desktop": false } }) }); + expect(settings().env?.HTTPS_PROXY).toBeUndefined(); + expect(settings().env?.NODE_EXTRA_CA_CERTS).toBeUndefined(); +}); diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index 60bc012fa25..751b838ee99 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -707,7 +707,11 @@ test("Claude Desktop profile GET, PUT and apply round-trip four-family assignmen const discovery = await fetch(new URL("/v1/models?flavor=anthropic", server.url)).then(r => r.json()) as { data: Array<{ id: string }> }; expect(discovery.data.some(model => model.id === alias)).toBe(true); - const apply = await fetch(new URL("/api/claude-desktop/apply", server.url), { method: "POST" }); + const apply = await fetch(new URL("/api/claude-desktop/apply", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "gateway" }), + }); expect(apply.status).toBe(200); const result = await apply.json() as { path: string; applied: boolean }; expect(result.applied).toBe(true); @@ -815,6 +819,14 @@ test("Claude Desktop apply validates the mode body", async () => { expect(badProfile.status).toBe(400); expect(loadConfig()).toEqual(beforeBadProfile); + const badGatewayProfile = await fetch(new URL("/api/claude-desktop/apply", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "gateway", profile: { version: 2 } }), + }); + expect(badGatewayProfile.status).toBe(400); + expect(loadConfig()).toEqual(beforeBadProfile); + const hybrid = await fetch(new URL("/api/claude-desktop/apply", server.url), { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/tests/codex-integration/native-claude-desktop-toggle.test.ts b/tests/codex-integration/native-claude-desktop-toggle.test.ts index bc579cdffbf..56c28ca34da 100644 --- a/tests/codex-integration/native-claude-desktop-toggle.test.ts +++ b/tests/codex-integration/native-claude-desktop-toggle.test.ts @@ -14,12 +14,16 @@ let root = ""; let library = ""; let previousHome: string | undefined; let previousLibrary: string | undefined; +let previousClaudeConfigDir: string | undefined; +// These tests pin the gateway (third-party profile) path; first-party is covered by +// tests/claude-integration/claude-desktop-first-party.test.ts. function config(): OcxConfig { return { port: 10100, providers: {}, defaultProvider: "openai", + claudeCode: { desktopMode: "gateway" }, } as OcxConfig; } @@ -84,8 +88,10 @@ beforeEach(() => { library = join(root, "desktop-library"); previousHome = process.env.OPENCODEX_HOME; previousLibrary = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; process.env.OPENCODEX_HOME = root; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = library; + process.env.CLAUDE_CONFIG_DIR = join(root, "claude"); writeFileSync(join(root, "config.json"), JSON.stringify(config())); }); @@ -94,6 +100,8 @@ afterEach(() => { else process.env.OPENCODEX_HOME = previousHome; if (previousLibrary === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousLibrary; + if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir; removeTreeWithRetry(root); }); @@ -237,7 +245,7 @@ test("auto-apply re-reads desired state after catalog fetch and skips a concurre assignments: {}, defaults: { opus: null, fable: null, sonnet: null, haiku: null }, }; - const persisted = { ...config(), claudeCode: { desktopProfile: profile, injectAgents: false } }; + const persisted = { ...config(), claudeCode: { desktopMode: "gateway" as const, desktopProfile: profile, injectAgents: false } }; writeFileSync(join(root, "config.json"), JSON.stringify(persisted)); writeFileSync(join(root, "config.json.bak"), JSON.stringify(persisted)); writeFileSync(join(root, "config.json"), JSON.stringify(persisted)); @@ -302,7 +310,7 @@ test("explicit enable re-reads desired state after catalog fetch and skips a con }); test("explicit enable honors the Claude Desktop native-model opt-out", async () => { - const persisted = { ...config(), claudeCode: { desktopNativeModels: false } }; + const persisted = { ...config(), claudeCode: { desktopMode: "gateway" as const, desktopNativeModels: false } }; writeFileSync(join(root, "config.json"), JSON.stringify(persisted)); let nativeSlugs: string[] | undefined; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7076cf8bf17..d40a7a47718 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1419,5 +1419,6 @@ "claude-intercept-local-ca.test.ts": "claude-integration", "claude-intercept-proxy.test.ts": "claude-integration", "claude-intercept-settings.test.ts": "claude-integration", + "claude-desktop-first-party.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server" } From 6b0b94597468be2b5917a6708527eccf8994d169 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:47:10 -0700 Subject: [PATCH 06/17] fix(claude-desktop): first-party apply replaces an active gateway profile Switching gateway -> first-party from the GUI/CLI/API previously refused with gateway_profile_active and required turning the integration off first, while the docs and the GUI switch note promise a direct replacement in both directions. The first-party branch now pivots the owned gateway profile back to standard (removeDesktop3pStandardPivot with replaceWhileEnabled, since the durable switch stays ON) before writing the env, and fails without writing when the pivot cannot complete. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/claude/desktop-3p.ts | 8 ++++- src/cli/claude-desktop.ts | 19 +++++++----- .../management/agent-settings-routes.ts | 31 +++++++++++++------ structure/clients/claude-desktop.md | 9 ++++-- .../claude-desktop-first-party.test.ts | 12 ++++--- 5 files changed, 54 insertions(+), 25 deletions(-) diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index ec50f765ab0..e7ffcfb72ba 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -473,6 +473,12 @@ export function removeDesktop3pStandardPivot( options: Desktop3pConfigLibraryOptions & { appliedFingerprint?: string | null; unlink?: (path: string) => void; lifecycleLockDeps?: ClientLifecycleLockDeps; + /** + * The desired-state guard below exists for OFF flows racing a concurrent enable. A + * mode switch (gateway → first-party) removes the profile while the integration stays + * ON on purpose, so the caller opts out of that guard. + */ + replaceWhileEnabled?: boolean; } = {}, ): Desktop3pRemovalResult { const libraryPath = resolveDesktop3pConfigLibraryPath(options); @@ -488,7 +494,7 @@ export function removeDesktop3pStandardPivot( } const latest = readConfigDiagnostics(); if (latest.source === "fallback") return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: "desktop_config_invalid" }; - if (claudeDesktopIntegrationEnabled(latest.config)) { + if (!options.replaceWhileEnabled && claudeDesktopIntegrationEnabled(latest.config)) { const observed = inspectDesktop3pConfigLibrary(options); if (observed.kind === "not_installed" || observed.kind === "no_owned_state") { return { ok: true, changed: false, kind: "noop", libraryPath }; diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 66bd7731c03..38fb95b08f7 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -15,7 +15,7 @@ import { type DesktopFamily, type DesktopProfile, } from "../claude/desktop-profile"; -import { inspectDesktop3pConfigLibrary, writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; +import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; import { applyDesktopFirstParty, removeDesktopFirstParty, @@ -212,12 +212,17 @@ async function applyFirstPartyDesktop( if (connection.kind === "connected") return { ok: false, path: "", reason: "first_party_requires_local_hub" }; if (connection.kind !== "disconnected") return { ok: false, path: "", reason: "client_connection_invalid" }; const config = loadConfig(); - const library = inspectDesktop3pConfigLibrary({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null }); - if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { - return { ok: false, path: library.selectedProfilePath ?? "", reason: "gateway_profile_active" }; - } const desired = setIntegrationEnabled("claude-desktop", true); if (!desired.ok) return { ok: false, path: "", reason: desired.message }; + // First-party replaces gateway; the two must never be active together. + const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; + const library = inspectDesktop3pConfigLibrary({ appliedFingerprint }); + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + const removed = removeDesktop3pStandardPivot({ appliedFingerprint, replaceWhileEnabled: true }); + if (!removed.ok) { + return { ok: false, path: library.selectedProfilePath ?? "", reason: removed.kind === "cleanup_incomplete" ? "gateway_cleanup_incomplete" : `gateway_profile_active:${removed.reason ?? removed.kind}` }; + } + } const applied = applyDesktopFirstParty(config); if (!applied.ok) return { ok: false, path: applied.path, reason: applied.reason }; const saved = saveDesktopMode("first-party", deps); @@ -343,8 +348,8 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf const result = await applyDesktop(undefined, target, deps); if (!result.ok) { console.error(`설정 적용 실패: ${result.reason ?? "unknown error"}`); - if (result.reason === "gateway_profile_active") { - console.error("Claude Desktop is running the gateway profile. Turn the integration off first (dashboard toggle removes the profile), or keep it with `ocx claude desktop apply --gateway`."); + if (result.reason?.startsWith("gateway_")) { + console.error("The gateway profile could not be removed safely, so first-party mode was not applied. Turn the integration off (dashboard toggle) and retry, or keep gateway with `ocx claude desktop apply --gateway`."); } else if (result.reason === "foreign_env") { console.error(`~/.claude/settings.json already sets HTTPS_PROXY or NODE_EXTRA_CA_CERTS to a value opencodex does not own (${result.path}). Remove them or use --gateway.`); } else if (result.reason === "intercept_disabled") { diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 97d56c824af..cee370df101 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1054,19 +1054,29 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } } if (desktopMode === "first-party") { - const { inspectDesktop3pConfigLibrary } = await import("../../claude/desktop-3p"); - const library = inspectDesktop3pConfigLibrary({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null }); - if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { - return jsonResponse({ - error: "Claude Desktop is currently running the gateway profile. Turn the Desktop integration off (which removes that profile) and apply again, or apply with mode \"gateway\" to keep it.", - code: "claude_desktop_gateway_active", - reason: "gateway_profile_active", - }, 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); mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true); + // First-party replaces gateway: the two must never be active together. + const { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot } = await import("../../claude/desktop-3p"); + const appliedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; + const library = inspectDesktop3pConfigLibrary({ appliedFingerprint }); + let gatewayRemoved = false; + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + const removed = (deps.removeDesktop3pStandardPivot ?? removeDesktop3pStandardPivot)({ appliedFingerprint, replaceWhileEnabled: true }); + if (!removed.ok) { + return jsonResponse({ + error: removed.kind === "cleanup_incomplete" + ? "Claude Desktop now points at standard mode, but gateway credential cleanup is incomplete; first-party env was not applied." + : "The gateway profile could not be removed safely, so first-party mode was not applied.", + code: "claude_desktop_gateway_removal_failed", + reason: removed.reason ?? removed.kind, + ...(removed.residualPaths ? { residualPaths: removed.residualPaths } : {}), + }, removed.kind === "cleanup_incomplete" ? 500 : 409); + } + gatewayRemoved = removed.changed; + } const applied = applyDesktopFirstParty(config); if (!applied.ok) { const { firstPartyRefusalMessage } = await import("./native-integration-routes"); @@ -1083,7 +1093,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise mode: "first-party", saved: modeSaved.ok, applied: true, - changed: applied.changed, + changed: applied.changed || gatewayRemoved, + gatewayRemoved, path: applied.path, proxyPort: applied.proxyPort, caCertPath: applied.env.NODE_EXTRA_CA_CERTS, diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index c99320890ba..8e73c82187c 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -54,9 +54,12 @@ silently, while new installs land on first-party. `resolveClaudeDesktopApplyMode `intercept_disabled` instead of being rewritten. Switching is ownership-aware in both directions: a gateway apply first removes the first-party env -(only values anchored on OpenCodex's own CA path); a first-party apply refuses (`409`, -`gateway_profile_active`) while an owned gateway profile is still selected, and refuses -(`foreign_env`) when `settings.json` already carries a proxy/CA value OpenCodex does not own. +(only values anchored on OpenCodex's own CA path); a first-party apply first pivots an owned +gateway profile (`gateway_ours`/`gateway_drifted`) back to standard through +`removeDesktop3pStandardPivot({ replaceWhileEnabled: true })` — the durable switch stays ON, so the +OFF-flow desired-state guard is bypassed on purpose — and fails (`claude_desktop_gateway_removal_failed`) +without writing the env if that pivot cannot complete. It refuses (`foreign_env`) when +`settings.json` already carries a proxy/CA value OpenCodex does not own. Disabling the integration (native toggle, `ocx ensure` with the durable switch OFF) removes both the gateway profile and the first-party env. With the switch ON in first-party mode, `ocx ensure` re-applies a stale env (the proxy port follows the public port). diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index e283531d13f..518248cb923 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -159,10 +159,14 @@ test("POST /api/claude-desktop/apply defaults to first-party and gateway mode re expect(afterGateway.claudeCode?.desktopMode).toBe("gateway"); expect(afterGateway.claudeCode?.desktopProfile?.appliedFingerprint).toBeTruthy(); - // With the gateway profile installed, first-party is refused until it is removed. - const conflict = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, afterGateway); - expect(conflict.status).toBe(409); - expect(conflict.body.reason).toBe("gateway_profile_active"); + // Switching back replaces the gateway profile with the first-party env in one apply. + const back = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "first-party" }) }, afterGateway); + expect(back.status).toBe(200); + expect(back.body).toMatchObject({ ok: true, mode: "first-party", applied: true, gatewayRemoved: true }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + const afterBack = await dispatch("/api/claude-desktop/status", {}, afterGateway); + expect(afterBack.body).toMatchObject({ mode: "first-party", applied: true, stale: false, drift: false, desiredEnabled: true }); + expect(["not_installed", "no_owned_state", "standard"]).toContain(afterBack.body.observedKind); }); test("native toggle: enable applies first-party by default and disable removes the env", async () => { From c9ed363dfeca7585abc7c5b98fa28126daa6cda1 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 02:21:18 -0700 Subject: [PATCH 07/17] fix(claude-desktop): native toggle enable follows the apply contract for first-party - The native ON toggle pivots an owned gateway profile to standard (replaceWhileEnabled) before writing the first-party env, and persists the desktopMode marker, matching POST /api/claude-desktop/apply. - Gateway apply now reports a failed mode-marker save as saved:false plus a warning instead of dropping the result. - ensure/update warns when an explicit first-party marker contradicts a gateway profile still on disk rather than returning silently. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/cli/ensure-desired-integrations.ts | 7 +++- .../management/agent-settings-routes.ts | 9 ++-- .../management/native-integration-routes.ts | 42 ++++++++++++++++--- .../claude-desktop-first-party.test.ts | 36 ++++++++++++++++ 4 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/cli/ensure-desired-integrations.ts b/src/cli/ensure-desired-integrations.ts index a8f6a3a259d..6a23b5a00c5 100644 --- a/src/cli/ensure-desired-integrations.ts +++ b/src/cli/ensure-desired-integrations.ts @@ -140,7 +140,12 @@ export function ensureClaudeDesktopMatchesDesired( const library = (deps.inspectDesktop3pConfigLibrary ?? inspectDesktop3pConfigLibrary)({ appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, }); - if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") return; + if (library.kind === "gateway_ours" || library.kind === "gateway_drifted") { + // The mode marker and the disk disagree. Replacing a live Desktop profile is an operator + // action, not something an update hook should do silently. + error("⚠️ Claude Desktop mode is first-party but a gateway profile is still applied; run `ocx claude desktop apply --first-party` (or `--gateway`) to reconcile."); + return; + } const seen = (deps.inspectDesktopFirstParty ?? inspectDesktopFirstParty)(config); if (!seen.stale) return; const applied = (deps.applyDesktopFirstParty ?? applyDesktopFirstParty)(config); diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index cee370df101..34678f8f02f 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1161,7 +1161,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise nativeContextLimits(latest), ); if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500); - persistDesktopModeField(config, "gateway"); + const modeSaved = persistDesktopModeField(config, "gateway"); + const modeWarning = modeSaved.ok ? undefined : `Claude Desktop was applied, but the gateway mode marker was not saved (${modeSaved.reason}).`; const { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy"); const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({ platform: deps.platform ?? process.platform, @@ -1185,20 +1186,22 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise fingerprint: result.fingerprint, warning: [ `Claude Desktop was applied, but the applied marker was not saved (${marked.reason}).`, + modeWarning, policyWarning, ].filter(Boolean).join(" "), }); } } + const warning = [modeWarning, policyWarning].filter(Boolean).join(" "); return jsonResponse({ ok: true, mode: "gateway", - saved: true, + saved: modeSaved.ok, applied: true, path: result.path, fingerprint: result.fingerprint, policyState, - ...(policyWarning ? { warning: policyWarning } : {}), + ...(warning ? { warning } : {}), }); } catch (error) { rethrowManagementBodyTooLarge(error); diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index dcd1b09afe6..03081bac5bb 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -18,7 +18,7 @@ * 011 (Claude Code), 012 (Grok). */ import { join } from "node:path"; -import { loadConfig, saveConfigPreservingClaudeCode } from "../../config"; +import { loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../../config"; import { readRuntimePort } from "../../config/process-state"; import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits } from "../../codex/catalog"; import { getCodexHome } from "../../codex/paths"; @@ -631,6 +631,16 @@ export function firstPartyRefusalMessage( } } +/** Record which Desktop mode is applied; `false` when the config file could not be updated. */ +function persistDesktopModeMarker(desktopMode: NonNullable["desktopMode"]): boolean { + const outcome = mutatePersistedConfig(persisted => { + if (persisted.claudeCode?.desktopMode === desktopMode) return { changed: false, value: true }; + persisted.claudeCode = { ...(persisted.claudeCode ?? {}), desktopMode }; + return { changed: true, value: true }; + }); + return outcome.status !== "unavailable"; +} + let claudeDesktopToggleFlight: Promise | null = null; async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { @@ -681,17 +691,39 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { + const gateway = await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }); + expect(gateway.status).toBe(200); + const afterGateway = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(afterGateway.claudeCode?.desktopProfile?.appliedFingerprint).toBeTruthy(); + // The operator chose first-party in config while the gateway profile is still on disk. + const chosen = { ...afterGateway, claudeCode: { ...afterGateway.claudeCode, desktopMode: "first-party" as const } }; + writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); + + const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, chosen); + expect(enabled.status).toBe(200); + expect(enabled.body).toMatchObject({ ok: true, changed: true, state: "current", desiredEnabled: true }); + expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode?.desktopMode).toBe("first-party"); + const status = await dispatch("/api/claude-desktop/status", {}, chosen); + expect(status.body).toMatchObject({ mode: "first-party", applied: true, drift: false }); + expect(["not_installed", "no_owned_state", "standard"]).toContain(status.body.observedKind); +}); + +test("ensure warns instead of touching a gateway profile that contradicts an explicit first-party marker", () => { + const logs: string[] = []; + const deps = { + loadConfig: () => config({ claudeCode: { desktopMode: "first-party" } }), + stripGrokConfig: () => ({ ok: true, changed: false, message: "" }), + syncGrokConfig: async () => ({ ok: true, changed: false, message: "" }), + removeDesktop3pStandardPivot: () => { throw new Error("must not pivot from ensure"); }, + inspectDesktop3pConfigLibrary: () => ({ kind: "gateway_ours" as const, libraryPath: library, activeProfilePath: null, ownedFiles: [] }), + applyDesktopFirstParty: () => { throw new Error("must not apply over a live gateway"); }, + log: (message: string) => { logs.push(message); }, + error: (message: string) => { logs.push(message); }, + }; + ensureClaudeDesktopMatchesDesired(deps as unknown as Parameters[0]); + expect(logs.some(line => line.includes("gateway profile is still applied"))).toBe(true); +}); + test("ensure reconciles first-party env: refreshes when ON and stale, removes when OFF", () => { const applied = applyDesktopFirstParty(config({ port: 10300 })); expect(applied.ok).toBe(true); From f777c7f6ee408f8ea49fcb183a980cfabd5b3471 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 02:50:08 -0700 Subject: [PATCH 08/17] fix(claude-desktop): drop the gateway apply marker on first-party and record gateway mode from the native toggle All three mode-marker writers (CLI apply, /api/claude-desktop/apply, native toggle) now share recordClaudeDesktopMode. Switching to first-party clears desktopProfile.appliedFingerprint/appliedAt so a lost explicit marker can no longer resolve back to gateway while first-party env is on disk; the profile assignments stay for a later gateway apply. The native toggle's gateway enable branch saves desktopMode="gateway" like the apply route does. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/claude/desktop-first-party.ts | 24 +++++++++++++++++++ src/cli/claude-desktop.ts | 7 ++---- .../management/agent-settings-routes.ts | 19 +++++++-------- .../management/native-integration-routes.ts | 16 +++++++------ .../claude-desktop-first-party.test.ts | 19 +++++++++++++++ 5 files changed, 62 insertions(+), 23 deletions(-) diff --git a/src/claude/desktop-first-party.ts b/src/claude/desktop-first-party.ts index 04027915ddd..0946aafddd5 100644 --- a/src/claude/desktop-first-party.ts +++ b/src/claude/desktop-first-party.ts @@ -52,6 +52,30 @@ export function resolveClaudeDesktopMode(config: DesktopModeConfig): ClaudeDeskt return DEFAULT_CLAUDE_DESKTOP_MODE; } +/** + * Config mutation that records the applied Desktop mode. Switching to first-party also drops + * the gateway apply marker: the profile assignments stay for a later gateway apply, but a + * stale `appliedFingerprint` must not make `resolveClaudeDesktopMode` read `gateway` again + * should the explicit marker ever go missing. + */ +export function recordClaudeDesktopMode( + config: DesktopModeConfig, + mode: ClaudeDesktopMode, +): { changed: boolean; value: true } { + const claudeCode = config.claudeCode ?? {}; + const profile = claudeCode.desktopProfile; + const dropMarker = mode === "first-party" && profile !== undefined + && (profile.appliedFingerprint !== undefined || profile.appliedAt !== undefined); + if (claudeCode.desktopMode === mode && !dropMarker) return { changed: false, value: true }; + if (dropMarker) { + const { appliedFingerprint: _fingerprint, appliedAt: _at, ...rest } = profile; + config.claudeCode = { ...claudeCode, desktopMode: mode, desktopProfile: rest }; + } else { + config.claudeCode = { ...claudeCode, desktopMode: mode }; + } + return { changed: true, value: true }; +} + /** * Mode an *apply* without an explicit choice should use. The first-party default only holds * where the intercept proxy actually runs; with it disabled (or on a client role) an implied diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 38fb95b08f7..92efabad4d0 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -18,6 +18,7 @@ import { import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; import { applyDesktopFirstParty, + recordClaudeDesktopMode, removeDesktopFirstParty, resolveClaudeDesktopApplyMode, type ClaudeDesktopMode, @@ -151,11 +152,7 @@ async function applyConnectedDesktopProfile( function saveDesktopMode(mode: ClaudeDesktopMode, deps: ApplyProfileDeps): boolean { try { return withClientLifecycleSync(() => { - const outcome = mutatePersistedConfig(current => { - if (current.claudeCode?.desktopMode === mode) return { changed: false, value: true }; - current.claudeCode = { ...(current.claudeCode ?? {}), desktopMode: mode }; - return { changed: true, value: true }; - }); + const outcome = mutatePersistedConfig(current => recordClaudeDesktopMode(current, mode)); return outcome.status !== "unavailable"; }, deps.lifecycleLockDeps); } catch { diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 34678f8f02f..91976c144fd 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -131,17 +131,14 @@ function persistDesktopProfileField( return { ok: true }; } -function persistDesktopModeField( +async function persistDesktopModeField( config: OcxConfig, - desktopMode: NonNullable["desktopMode"], -): { ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" } { - const outcome = mutatePersistedConfig(persisted => { - if (persisted.claudeCode?.desktopMode === desktopMode) return { changed: false, value: true }; - persisted.claudeCode = { ...(persisted.claudeCode ?? {}), desktopMode }; - return { changed: true, value: true }; - }); + desktopMode: "first-party" | "gateway", +): Promise<{ ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" }> { + const { recordClaudeDesktopMode } = await import("../../claude/desktop-first-party"); + const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode)); if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; - config.claudeCode = { ...(config.claudeCode ?? {}), desktopMode }; + recordClaudeDesktopMode(config, desktopMode); return { ok: true }; } @@ -1087,7 +1084,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise path: applied.path, }, applied.reason === "foreign_env" || applied.reason === "intercept_disabled" ? 409 : 500); } - const modeSaved = persistDesktopModeField(config, "first-party"); + const modeSaved = await persistDesktopModeField(config, "first-party"); return jsonResponse({ ok: true, mode: "first-party", @@ -1161,7 +1158,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise nativeContextLimits(latest), ); if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500); - const modeSaved = persistDesktopModeField(config, "gateway"); + const modeSaved = await persistDesktopModeField(config, "gateway"); const modeWarning = modeSaved.ok ? undefined : `Claude Desktop was applied, but the gateway mode marker was not saved (${modeSaved.reason}).`; const { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy"); const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({ diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index 03081bac5bb..ae20731777a 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -28,8 +28,10 @@ import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDeskt import { applyDesktopFirstParty, inspectDesktopFirstParty, + recordClaudeDesktopMode, removeDesktopFirstParty, resolveClaudeDesktopApplyMode, + type ClaudeDesktopMode, } from "../../claude/desktop-first-party"; import { projectGrokCatalog } from "../../grok/catalog"; import { injectGrokConfig, stripGrokConfig } from "../../grok/inject"; @@ -632,12 +634,8 @@ export function firstPartyRefusalMessage( } /** Record which Desktop mode is applied; `false` when the config file could not be updated. */ -function persistDesktopModeMarker(desktopMode: NonNullable["desktopMode"]): boolean { - const outcome = mutatePersistedConfig(persisted => { - if (persisted.claudeCode?.desktopMode === desktopMode) return { changed: false, value: true }; - persisted.claudeCode = { ...(persisted.claudeCode ?? {}), desktopMode }; - return { changed: true, value: true }; - }); +function persistDesktopModeMarker(desktopMode: ClaudeDesktopMode): boolean { + const outcome = mutatePersistedConfig(persisted => recordClaudeDesktopMode(persisted, desktopMode)); return outcome.status !== "unavailable"; } @@ -750,9 +748,13 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { @@ -200,11 +207,23 @@ test("native toggle: enabling into explicit first-party pivots an applied gatewa expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; expect(saved.claudeCode?.desktopMode).toBe("first-party"); + expect(saved.claudeCode?.desktopProfile?.appliedFingerprint).toBeUndefined(); const status = await dispatch("/api/claude-desktop/status", {}, chosen); expect(status.body).toMatchObject({ mode: "first-party", applied: true, drift: false }); expect(["not_installed", "no_owned_state", "standard"]).toContain(status.body.observedKind); }); +test("native toggle: enabling into gateway saves the gateway mode marker like the apply route", async () => { + const chosen = config({ claudeCode: { desktopMode: "gateway" } }); + writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); + const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, chosen); + expect(enabled.status).toBe(200); + expect(enabled.body).toMatchObject({ ok: true, state: "current", message: "Claude Desktop integration enabled." }); + expect(existsSync(join(claudeDir, "settings.json"))).toBe(false); + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + expect(saved.claudeCode?.desktopMode).toBe("gateway"); +}); + test("ensure warns instead of touching a gateway profile that contradicts an explicit first-party marker", () => { const logs: string[] = []; const deps = { From c21865ced016d9d3afa06893189bddc7232774a0 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 03:07:38 -0700 Subject: [PATCH 09/17] test(claude-desktop): prove the native toggle writes the gateway marker from an unmarked config Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/claude-integration/claude-desktop-first-party.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index ef4892ece85..7b391f98dae 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -214,7 +214,9 @@ test("native toggle: enabling into explicit first-party pivots an applied gatewa }); test("native toggle: enabling into gateway saves the gateway mode marker like the apply route", async () => { - const chosen = config({ claudeCode: { desktopMode: "gateway" } }); + // No explicit mode: the disabled intercept is what implies gateway, so the saved marker + // can only come from the toggle itself. + const chosen = config({ claudeCode: { intercept: { enabled: false } } }); writeFileSync(join(root, "config.json"), JSON.stringify(chosen)); const enabled = await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, chosen); expect(enabled.status).toBe(200); @@ -222,6 +224,7 @@ test("native toggle: enabling into gateway saves the gateway mode marker like th expect(existsSync(join(claudeDir, "settings.json"))).toBe(false); const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; expect(saved.claudeCode?.desktopMode).toBe("gateway"); + expect(resolveClaudeDesktopMode(saved)).toBe("gateway"); }); test("ensure warns instead of touching a gateway profile that contradicts an explicit first-party marker", () => { From 6bf533ffc029e6849e5c5190e2de5a80ac0d5013 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:24:26 -0700 Subject: [PATCH 10/17] feat(gui,docs): Claude Desktop connection-mode selector and first-party docs Dashboard Desktop tab gains a Connection mode picker (first-party default, gateway opt-in) that sends the chosen mode with /api/claude-desktop/apply and shows the intercept proxy state in first-party mode. Strings added to every locale. Docs describe both modes, the settings.json env the first-party apply writes, intercepted routes, the local CA trust boundary, update behavior for existing gateway installs, and Claude Code CLI compatibility/limitations. Translated guides get a summary section pointing at the canonical English text. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/content/docs/fr/guides/claude-code.md | 29 ++++++- .../src/content/docs/guides/claude-code.md | 77 ++++++++++++++++-- .../src/content/docs/ja/guides/claude-code.md | 23 ++++++ .../src/content/docs/ko/guides/claude-code.md | 21 +++++ gui/src/i18n/de.ts | 13 +++ gui/src/i18n/en.ts | 13 +++ gui/src/i18n/fr.ts | 13 +++ gui/src/i18n/ja.ts | 13 +++ gui/src/i18n/ko.ts | 13 +++ gui/src/i18n/ru.ts | 13 +++ gui/src/i18n/tr.ts | 13 +++ gui/src/i18n/vi.ts | 13 +++ gui/src/i18n/zh-TW.ts | 13 +++ gui/src/i18n/zh.ts | 13 +++ gui/src/pages/ClaudeDesktop.tsx | 80 +++++++++++++++++-- gui/src/styles.css | 23 ++++++ structure/gui-and-management-api.md | 2 +- 17 files changed, 372 insertions(+), 13 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 3e8decdd978..268c5623261 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -121,7 +121,34 @@ Sur macOS, l'intégration automatique (`claudeCode.systemEnv`) suit la même ré `claude` lancée sans passer par `ocx` se comporte donc de la même manière. Le fichier d'environnement est un instantané actualisé au démarrage du proxy ou lors de l'enregistrement des paramètres, tandis que `ocx claude` effectue toujours une résolution immédiate. -## Profil Claude Desktop +## Modes Claude Desktop : first-party (par défaut) et passerelle + +Claude Desktop utilise OpenCodex dans l'un de deux modes mutuellement exclusifs. Choisissez-le dans +**Claude → Bureau → Mode de connexion** du tableau de bord ou avec +`ocx claude desktop apply --first-party|--gateway`. + +- **First-party (par défaut)** : Desktop lui-même n'est pas reconfiguré. La connexion claude.ai, + l'onglet Chat, les connecteurs et le contrôle à distance continuent de fonctionner. OpenCodex + n'écrit que deux valeurs dans le bloc `env` de `~/.claude/settings.json` : + `HTTPS_PROXY=http://127.0.0.1:` et + `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem`. Seuls Claude Code lancé par Desktop + pour l'onglet Code (sous-agents compris) et la CLI `claude` du terminal les lisent et passent par le + proxy d'interception local ; seuls `POST /v1/messages` et `count_tokens` sont traités par OpenCodex, + les autres chemins de `api.anthropic.com` sont relayés tels quels vers Anthropic. L'AC n'est jamais + installée dans le magasin de confiance du système. +- **Passerelle (tiers)** : l'ancien mode ; le profil ci-dessous fait basculer toute l'application sur + OpenCodex comme passerelle. Sélectionnez-le explicitement (`--gateway`, ou les anciens + `--static`/`--hybrid`/`--discovery-only`). + +Le mode est enregistré dans `claudeCode.desktopMode`. Les installations ayant déjà appliqué un profil +passerelle le conservent après mise à jour ; seules les nouvelles installations démarrent en +first-party. Changer de mode supprime la configuration de l'autre mode (uniquement les valeurs +écrites par OpenCodex) ; un `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` étranger (proxy d'entreprise) n'est +jamais écrasé et l'application est refusée. Quittez complètement Desktop puis rouvrez-le après un +changement. Les détails et la compatibilité de la CLI Claude Code sont décrits dans la documentation +anglaise. + +## Profil Claude Desktop (mode passerelle) Claude Desktop utilise un profil distinct de Claude Code. Ouvrez **Claude → Bureau** dans le tableau de bord afin de placer chaque route disponible dans l'une des quatre familles : Opus, Fable, Sonnet ou Haiku. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 5f04d0f41b9..d29cd1496a2 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -135,9 +135,73 @@ the proxy starts or you save settings, while `ocx claude` always resolves live. ## System environment integration (macOS) -## Claude Desktop profile +## Claude Desktop modes: first-party (default) and gateway -Claude Desktop uses a separate profile from Claude Code. Open **Claude → Desktop** in the +Claude Desktop can use OpenCodex in one of two mutually exclusive modes. Pick it in +**Claude → Desktop → Connection mode** in the dashboard or with `ocx claude desktop apply +--first-party|--gateway`. + +**First-party** is the default for new installs. Desktop itself is not reconfigured: it stays +signed in to claude.ai, and the Chat tab, connectors, cloud sessions and remote control keep +working. OpenCodex only writes two variables into the `env` block of `~/.claude/settings.json` +(honoured by `CLAUDE_CONFIG_DIR`): + +```json +{ + "env": { + "HTTPS_PROXY": "http://127.0.0.1:10200", + "NODE_EXTRA_CA_CERTS": "/Users/you/.opencodex/claude-intercept/ca.pem" + } +} +``` + +Claude Code — the process Desktop spawns for its Code tab, every subagent it launches, and the +standalone `claude` CLI — reads that env and sends its `api.anthropic.com` traffic through the +local intercept proxy. The proxy listens on the public port + 100 (`claudeCode.intercept.port` +overrides it), terminates TLS with a per-install CA stored under `~/.opencodex/claude-intercept/` +(never installed into the OS trust store; only Node processes that read `NODE_EXTRA_CA_CERTS` +trust it), and hands `POST /v1/messages` and `POST /v1/messages/count_tokens` to the same +Messages handler `ocx claude` uses. Every other path on `api.anthropic.com` (OAuth, profile, +usage) is relayed byte-for-byte to Anthropic, and unrelated hosts are tunnelled untouched, so your +subscription login keeps working. Existing OpenCodex features — `modelMap`, aliases, native +passthrough, sidecars, auto-context — apply the same way they do for `ocx claude`. + +**Gateway** is the previous third-party mode: the profile described in the next section switches +the whole app to OpenCodex as its inference gateway. Chat runs locally through OpenCodex and the +claude.ai-only features are unavailable. Select it explicitly (`--gateway`, the dashboard +selector, or the legacy `--static` / `--hybrid` / `--discovery-only` flags, which imply it). + +Mode is persisted as `claudeCode.desktopMode`. Installs that already applied a gateway profile +keep gateway after updating; nothing is switched silently. Switching in either direction removes +the other mode's configuration (only values OpenCodex wrote — a foreign `HTTPS_PROXY` or +`NODE_EXTRA_CA_CERTS`, for example a corporate proxy, is never overwritten and the apply is +refused instead). Fully quit and reopen Desktop after switching. `ocx ensure` refreshes a stale +first-party env when the integration is ON and removes it when OFF. Set +`claudeCode.intercept.enabled: false` to disable the proxy entirely; first-party then cannot be +applied and an implicit apply falls back to gateway. On a connected client the proxy runs on the +hub, so `ocx claude desktop apply` there uses the gateway profile. + +### Claude Code CLI compatibility + +The same `settings.json` env drives the standalone `claude` CLI, so a first-party apply also +covers terminal sessions, `claude -p`, and subagents without `ocx claude`'s +`ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` shell env. Differences from `ocx claude`: + +- Model discovery (`/model` → "From gateway") is not available; Claude Code only queries + `GET /v1/models` on a configured gateway. Use `modelMap` to route the built-in Anthropic model + ids, or type an alias directly. +- `ANTHROPIC_SMALL_FAST_MODEL` and `CLAUDE_CODE_SUBAGENT_MODEL` are chosen by the CLI before the + request is sent; set them in `settings.json` yourself if a sidecar or subagent should use a + mapped id. +- `ocx claude` and first-party coexist: a session started with `ocx claude` talks to + `ANTHROPIC_BASE_URL` (plain HTTP on loopback), which `HTTPS_PROXY` does not cover, so that + process reaches OpenCodex directly and the proxy simply sees no traffic from it. +- Claude Code honours `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` as documented for corporate proxies; + a CLI release that stops doing so would stop routing, not break login. + +## Claude Desktop profile (gateway mode) + +The profile below is written only in gateway mode. Claude Desktop uses a separate profile from Claude Code. Open **Claude → Desktop** in the dashboard to place each available route in one of four families: Opus, Fable, Sonnet, or Haiku. All routes start in Opus on a new profile. The first Opus route becomes the initial overall default, and every non-empty family always has one family default. @@ -161,9 +225,10 @@ ocx claude desktop export ocx claude desktop import [--apply] ``` -`ocx claude desktop` and `apply` both write the current profile to Claude Desktop. `show` gives a -readable summary; `status` reports the applied profile, drift, request activity, and Windows -managed-policy health. Add `--json` for scripts. `export -` writes versioned JSON to standard output. +`ocx claude desktop` and `apply` apply the selected mode: first-party writes the Claude Code proxy +env, gateway writes the current profile to Claude Desktop. `show` gives a +readable summary; `status` reports the effective mode, the applied profile or proxy env, drift, +request activity, and (gateway mode only) Windows managed-policy health. Add `--json` for scripts. `export -` writes versioned JSON to standard output. Import validates the complete file before saving, so an invalid file leaves the current profile unchanged. Add `--apply` to write a valid imported profile to Desktop immediately. Use `none` only for an empty family; every non-empty family must keep one default. @@ -637,6 +702,8 @@ Claude debug immediately clears the ring. The dashboard sidebar has a dedicated **Claude** page (below API) and a **Claude ON** toggle (label intentionally identical in every language). The page shows: +- Desktop tab: **Connection mode** selector — first-party (default) or gateway — with the + running proxy port in first-party mode; **Save & apply** switches modes - Inbound kill switch (enabled toggle) - Quickstart (`ocx claude`) and manual env block - Fast Mode selector (Auto / ON / OFF) diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index b797f5a69e0..2a15039bc91 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -95,6 +95,29 @@ hook を削除します。Claude Desktop は独立した profile を使用し、 `claudeCode.nativePassthrough: false` でオフにでき、`claudeCode.anthropicBaseUrl` で別のアドレスを 指定できます。 +## Claude Desktop のモード: 1P(デフォルト)とゲートウェイ + +Claude Desktop は排他的な 2 つのモードのどちらかで OpenCodex を使います。ダッシュボードの +**Claude → Desktop → 接続モード**、または `ocx claude desktop apply --first-party|--gateway` で選びます。 + +- **1P(ファーストパーティ、デフォルト)**: Desktop 本体は変更しません。claude.ai のログイン、 + チャットタブ、コネクタ、リモート操作はそのまま動きます。OpenCodex は `~/.claude/settings.json` の + `env` に `HTTPS_PROXY=http://127.0.0.1:<公開ポート+100>` と + `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem` の 2 つだけを書きます。Desktop が + Code タブ用に起動する Claude Code(サブエージェント含む)とターミナルの `claude` CLI だけがこれを読み、 + ローカルのインターセプトプロキシを通ります。`POST /v1/messages` と `count_tokens` のみ OpenCodex が + 処理し、他の `api.anthropic.com` パスはそのまま Anthropic に中継されます。CA は OS の信頼ストアには + インストールされません。 +- **ゲートウェイ(3P)**: 従来の方式で、下記のプロファイルによりアプリ全体が OpenCodex を + ゲートウェイとして使います。`--gateway`(または従来の `--static`/`--hybrid`/`--discovery-only`)で + 明示的に選びます。 + +モードは `claudeCode.desktopMode` に保存されます。すでにゲートウェイプロファイルを適用済みの環境は +更新後もゲートウェイのままで、新規インストールだけが 1P になります。切り替えると他方のモードの設定 +(OpenCodex が書いた値のみ)が削除され、社内プロキシなど外部の `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` +は上書きせず適用を拒否します。切り替え後は Desktop を完全に終了して再起動してください。詳細と +Claude Code CLI 互換性は英語版ドキュメントを参照してください。 + ## リモートハブに接続した Claude Desktop 接続中のマシンで `ocx claude desktop apply` または `ocx claude desktop` を実行すると、 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 9dbce2e526d..ca71508d4e1 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -118,6 +118,27 @@ hook을 제거해요. Claude Desktop은 별도 profile을 사용하며 shell hoo `claudeCode.nativePassthrough: false`로 끌 수 있고, `claudeCode.anthropicBaseUrl`로 다른 주소를 지정할 수 있어요. +## Claude Desktop 모드: 1P(기본값)와 게이트웨이 + +Claude Desktop은 서로 배타적인 두 모드 중 하나로 OpenCodex를 사용해요. 대시보드의 +**Claude → Desktop → 연결 모드** 또는 `ocx claude desktop apply --first-party|--gateway`로 선택합니다. + +- **1P(퍼스트파티, 기본값)**: Desktop 자체는 건드리지 않아요. claude.ai 로그인, 채팅 탭, 커넥터, + 원격 제어가 그대로 유지됩니다. OpenCodex는 `~/.claude/settings.json`의 `env`에 + `HTTPS_PROXY=http://127.0.0.1:<공개 포트+100>`과 `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem` + 두 값만 씁니다. Desktop이 Code 탭용으로 실행하는 Claude Code(서브에이전트 포함)와 터미널의 + `claude` CLI만 이 값을 읽어 로컬 인터셉트 프록시를 거치고, `POST /v1/messages`·`count_tokens`만 + OpenCodex가 처리하며 나머지 `api.anthropic.com` 경로는 그대로 Anthropic으로 전달돼요. CA는 OS + 신뢰 저장소에 설치되지 않습니다. +- **게이트웨이(3P)**: 기존 방식으로, 아래 프로필을 써서 앱 전체가 OpenCodex를 게이트웨이로 + 사용해요. `--gateway`(또는 기존 `--static`/`--hybrid`/`--discovery-only`)로 명시적으로 선택합니다. + +모드는 `claudeCode.desktopMode`에 저장돼요. 이미 게이트웨이 프로필을 적용한 설치는 업데이트 후에도 +게이트웨이를 유지하고, 새 설치만 1P가 기본이에요. 모드를 바꾸면 다른 모드의 설정(OpenCodex가 쓴 +값만)이 제거되며, 회사 프록시 같은 외부 `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` 값은 덮어쓰지 않고 적용을 +거부해요. 전환 후에는 Desktop을 완전히 종료하고 다시 열어 주세요. 자세한 내용과 Claude Code CLI +호환성은 영어 문서를 참고하세요. + ## 원격 허브에 연결된 Claude Desktop 허브에 연결된 컴퓨터에서 `ocx claude desktop apply` 또는 `ocx claude desktop`을 실행하면 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index bb936060c12..7f92859e4f9 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2622,6 +2622,19 @@ export const de: Record = { "claudeDesktop.status.notActiveProfile": "Desktop nutzt ein anderes Profil — erneut anwenden", "claudeDesktop.status.disabled": "Die Claude-Desktop-Integration ist deaktiviert. Beende Desktop nach dem Aktivieren vollständig und öffne es erneut.", "claudeDesktop.enableApply": "Aktivieren und anwenden", + "claudeDesktop.switchModeApply": "Modus wechseln & anwenden", + "claudeDesktop.status.appliedFirstParty": "First-Party: Code-Tab läuft über den lokalen Proxy", + "claudeDesktop.mode.legend": "Verbindungsmodus", + "claudeDesktop.mode.firstParty": "First-Party", + "claudeDesktop.mode.gateway": "Gateway (Drittanbieter)", + "claudeDesktop.mode.defaultBadge": "Standard", + "claudeDesktop.mode.current": "aktuell", + "claudeDesktop.mode.firstPartyHint": "Desktop bleibt bei claude.ai angemeldet (Chat, Connectors, Remote Control). Nur der Code-Tab, seine Subagenten und die Claude Code CLI laufen über OpenCodex.", + "claudeDesktop.mode.gatewayHint": "Die ganze App wechselt auf OpenCodex als Gateway. Chat läuft lokal; claude.ai-Funktionen sind nicht verfügbar.", + "claudeDesktop.mode.switchNote": "Der Wechsel ersetzt die Konfiguration des anderen Modus. Desktop danach vollständig beenden und neu öffnen.", + "claudeDesktop.firstParty.proxyRunning": "Lokaler Proxy auf 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "Lokaler Proxy 127.0.0.1:{port} läuft nicht — OpenCodex neu starten", + "claudeDesktop.firstParty.interceptDisabled": "Intercept-Proxy ist in der Konfiguration deaktiviert (claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "Letzte Anfrage", "claudeDesktop.health.stats": "{count} Anf. / {errors} Fehl.", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 93af425073b..2c30be237bd 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2722,6 +2722,19 @@ export const en = { "claudeDesktop.status.notActiveProfile": "Desktop is serving another profile — re-apply", "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", "claudeDesktop.enableApply": "Enable and apply", + "claudeDesktop.switchModeApply": "Switch mode & apply", + "claudeDesktop.status.appliedFirstParty": "First-party: Code tab routed through the local proxy", + "claudeDesktop.mode.legend": "Connection mode", + "claudeDesktop.mode.firstParty": "First-party", + "claudeDesktop.mode.gateway": "Gateway (third-party)", + "claudeDesktop.mode.defaultBadge": "default", + "claudeDesktop.mode.current": "current", + "claudeDesktop.mode.firstPartyHint": "Desktop stays signed in to claude.ai (chat, connectors, remote control). Only the Code tab, its subagents and the Claude Code CLI go through OpenCodex.", + "claudeDesktop.mode.gatewayHint": "The whole app switches to OpenCodex as its gateway. Chat runs locally; claude.ai features are unavailable.", + "claudeDesktop.mode.switchNote": "Switching replaces the other mode's configuration. Fully quit and reopen Desktop afterwards.", + "claudeDesktop.firstParty.proxyRunning": "Local proxy on 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "Local proxy 127.0.0.1:{port} is not running — restart OpenCodex", + "claudeDesktop.firstParty.interceptDisabled": "Intercept proxy is disabled in config (claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "Last request", "claudeDesktop.health.stats": "{count} req / {errors} err", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b81652bacd2..4183d385517 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2639,6 +2639,19 @@ export const fr: Record = { "claudeDesktop.status.notActiveProfile": "Desktop utilise un autre profil — appliquer de nouveau", "claudeDesktop.status.disabled": "L’intégration Claude Desktop est désactivée. Quittez complètement Desktop et rouvrez-le après l’avoir activée.", "claudeDesktop.enableApply": "Activer et appliquer", + "claudeDesktop.switchModeApply": "Changer de mode et appliquer", + "claudeDesktop.status.appliedFirstParty": "Première partie : l'onglet Code passe par le proxy local", + "claudeDesktop.mode.legend": "Mode de connexion", + "claudeDesktop.mode.firstParty": "Première partie (first-party)", + "claudeDesktop.mode.gateway": "Passerelle (tiers)", + "claudeDesktop.mode.defaultBadge": "par défaut", + "claudeDesktop.mode.current": "actuel", + "claudeDesktop.mode.firstPartyHint": "Desktop reste connecté à claude.ai (chat, connecteurs, contrôle à distance). Seuls l'onglet Code, ses sous-agents et la CLI Claude Code passent par OpenCodex.", + "claudeDesktop.mode.gatewayHint": "Toute l'application bascule sur OpenCodex comme passerelle. Le chat tourne en local ; les fonctions claude.ai sont indisponibles.", + "claudeDesktop.mode.switchNote": "Le changement remplace la configuration de l'autre mode. Quittez complètement Desktop puis rouvrez-le.", + "claudeDesktop.firstParty.proxyRunning": "Proxy local sur 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "Le proxy local 127.0.0.1:{port} ne tourne pas — redémarrez OpenCodex", + "claudeDesktop.firstParty.interceptDisabled": "Le proxy d'interception est désactivé dans la config (claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "Dernière requête", "claudeDesktop.health.stats": "{count} req. / {errors} err.", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 7565f3c2d9a..6d5bea4b0d4 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2444,6 +2444,19 @@ export const ja: Record = { "claudeDesktop.status.notActiveProfile": "Desktop は別のプロファイルを使用中 — 再適用してください", "claudeDesktop.status.disabled": "Claude Desktop 連携はオフです。有効にした後、Desktop を完全に終了して再起動してください。", "claudeDesktop.enableApply": "有効にして適用", + "claudeDesktop.switchModeApply": "モードを切り替えて適用", + "claudeDesktop.status.appliedFirstParty": "1P: Code タブはローカルプロキシ経由", + "claudeDesktop.mode.legend": "接続モード", + "claudeDesktop.mode.firstParty": "1P(ファーストパーティ)", + "claudeDesktop.mode.gateway": "ゲートウェイ(3P)", + "claudeDesktop.mode.defaultBadge": "デフォルト", + "claudeDesktop.mode.current": "現在", + "claudeDesktop.mode.firstPartyHint": "Desktop は claude.ai にログインしたまま(チャット・コネクタ・リモート操作)。Code タブとそのサブエージェント、Claude Code CLI だけが OpenCodex を通ります。", + "claudeDesktop.mode.gatewayHint": "アプリ全体が OpenCodex をゲートウェイとして使います。チャットはローカル実行になり、claude.ai の機能は使えません。", + "claudeDesktop.mode.switchNote": "切り替えると他方のモードの設定は削除されます。適用後は Desktop を完全に終了して再起動してください。", + "claudeDesktop.firstParty.proxyRunning": "ローカルプロキシ 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "ローカルプロキシ 127.0.0.1:{port} が停止中 — OpenCodex を再起動", + "claudeDesktop.firstParty.interceptDisabled": "設定でインターセプトプロキシが無効です(claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "最終リクエスト", "claudeDesktop.health.stats": "{count} リクエスト / {errors} エラー", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 94bd6fcab74..f4f552ca63a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2661,6 +2661,19 @@ export const ko: Record = { "claudeDesktop.status.notActiveProfile": "Desktop이 다른 프로필을 사용 중 — 재적용 필요", "claudeDesktop.status.disabled": "Claude Desktop 통합이 꺼져 있습니다. 켠 뒤 Desktop을 완전히 종료하고 다시 여세요.", "claudeDesktop.enableApply": "켜고 적용", + "claudeDesktop.switchModeApply": "모드 전환 후 적용", + "claudeDesktop.status.appliedFirstParty": "1P: Code 탭이 로컬 프록시를 통해 연결됨", + "claudeDesktop.mode.legend": "연결 모드", + "claudeDesktop.mode.firstParty": "1P (퍼스트파티)", + "claudeDesktop.mode.gateway": "게이트웨이 (3P)", + "claudeDesktop.mode.defaultBadge": "기본값", + "claudeDesktop.mode.current": "현재", + "claudeDesktop.mode.firstPartyHint": "Desktop은 claude.ai에 로그인된 상태를 유지합니다(채팅·커넥터·원격 제어). Code 탭과 서브에이전트, Claude Code CLI만 OpenCodex를 거칩니다.", + "claudeDesktop.mode.gatewayHint": "앱 전체가 OpenCodex를 게이트웨이로 사용합니다. 채팅은 로컬로 실행되고 claude.ai 기능은 사용할 수 없습니다.", + "claudeDesktop.mode.switchNote": "전환하면 다른 모드의 설정은 제거됩니다. 적용 후 Desktop을 완전히 종료하고 다시 열어주세요.", + "claudeDesktop.firstParty.proxyRunning": "로컬 프록시 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "로컬 프록시 127.0.0.1:{port}가 실행 중이 아님 — OpenCodex 재시작", + "claudeDesktop.firstParty.interceptDisabled": "설정에서 인터셉트 프록시가 비활성화됨 (claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "마지막 요청", "claudeDesktop.health.stats": "{count} 요청 / {errors} 에러", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index bf7c4fcd783..e8db7075692 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2515,6 +2515,19 @@ export const ru: Record = { "claudeDesktop.status.notActiveProfile": "Desktop использует другой профиль — примените заново", "claudeDesktop.status.disabled": "Интеграция Claude Desktop отключена. После включения полностью закройте и снова откройте Desktop.", "claudeDesktop.enableApply": "Включить и применить", + "claudeDesktop.switchModeApply": "Сменить режим и применить", + "claudeDesktop.status.appliedFirstParty": "First-party: вкладка Code идёт через локальный прокси", + "claudeDesktop.mode.legend": "Режим подключения", + "claudeDesktop.mode.firstParty": "First-party", + "claudeDesktop.mode.gateway": "Шлюз (сторонний)", + "claudeDesktop.mode.defaultBadge": "по умолчанию", + "claudeDesktop.mode.current": "текущий", + "claudeDesktop.mode.firstPartyHint": "Desktop остаётся в claude.ai (чат, коннекторы, удалённое управление). Через OpenCodex идут только вкладка Code, её субагенты и CLI Claude Code.", + "claudeDesktop.mode.gatewayHint": "Всё приложение переключается на OpenCodex как шлюз. Чат работает локально; функции claude.ai недоступны.", + "claudeDesktop.mode.switchNote": "Переключение удаляет настройки другого режима. После применения полностью закройте и заново откройте Desktop.", + "claudeDesktop.firstParty.proxyRunning": "Локальный прокси на 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "Локальный прокси 127.0.0.1:{port} не запущен — перезапустите OpenCodex", + "claudeDesktop.firstParty.interceptDisabled": "Перехватывающий прокси отключён в конфигурации (claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "Последний запрос", "claudeDesktop.health.stats": "{count} запр. / {errors} ошиб.", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index eb989bb3ba2..117b67cd9b5 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2664,6 +2664,19 @@ export const tr: Record = { "claudeDesktop.status.notActiveProfile": "Desktop başka bir profil kullanıyor", "claudeDesktop.status.disabled": "Claude Desktop entegrasyonu kapalı.", "claudeDesktop.enableApply": "Etkinleştir ve uygula", + "claudeDesktop.switchModeApply": "Modu değiştir ve uygula", + "claudeDesktop.status.appliedFirstParty": "First-party: Code sekmesi yerel proxy üzerinden", + "claudeDesktop.mode.legend": "Bağlantı modu", + "claudeDesktop.mode.firstParty": "First-party", + "claudeDesktop.mode.gateway": "Ağ geçidi (üçüncü taraf)", + "claudeDesktop.mode.defaultBadge": "varsayılan", + "claudeDesktop.mode.current": "geçerli", + "claudeDesktop.mode.firstPartyHint": "Desktop claude.ai'de oturum açık kalır (sohbet, bağlayıcılar, uzaktan kontrol). Yalnızca Code sekmesi, alt ajanları ve Claude Code CLI OpenCodex üzerinden geçer.", + "claudeDesktop.mode.gatewayHint": "Uygulamanın tamamı OpenCodex'i ağ geçidi olarak kullanır. Sohbet yerelde çalışır; claude.ai özellikleri kullanılamaz.", + "claudeDesktop.mode.switchNote": "Geçiş diğer modun yapılandırmasını kaldırır. Ardından Desktop'ı tamamen kapatıp yeniden açın.", + "claudeDesktop.firstParty.proxyRunning": "Yerel proxy 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "Yerel proxy 127.0.0.1:{port} çalışmıyor — OpenCodex'i yeniden başlatın", + "claudeDesktop.firstParty.interceptDisabled": "Yakalama proxy'si yapılandırmada kapalı (claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "Son istek", "claudeDesktop.health.stats": "{count} istek / {errors} hata", "claudeDesktop.effort.supported": "çaba", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index d5b6a149f53..feccccbaa57 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2653,6 +2653,19 @@ export const vi: Record = { "claudeDesktop.status.notActiveProfile": "Desktop đang phục vụ một hồ sơ khác — hãy áp dụng lại", "claudeDesktop.status.disabled": "Tiện ích tích hợp Claude Desktop đang tắt. Hãy thoát hoàn toàn và mở lại Desktop sau khi bật nó.", "claudeDesktop.enableApply": "Bật và áp dụng", + "claudeDesktop.switchModeApply": "Đổi chế độ & áp dụng", + "claudeDesktop.status.appliedFirstParty": "First-party: tab Code đi qua proxy cục bộ", + "claudeDesktop.mode.legend": "Chế độ kết nối", + "claudeDesktop.mode.firstParty": "First-party", + "claudeDesktop.mode.gateway": "Gateway (bên thứ ba)", + "claudeDesktop.mode.defaultBadge": "mặc định", + "claudeDesktop.mode.current": "hiện tại", + "claudeDesktop.mode.firstPartyHint": "Desktop vẫn đăng nhập claude.ai (chat, connector, điều khiển từ xa). Chỉ tab Code, các subagent và Claude Code CLI đi qua OpenCodex.", + "claudeDesktop.mode.gatewayHint": "Toàn bộ ứng dụng chuyển sang dùng OpenCodex làm gateway. Chat chạy cục bộ; các tính năng claude.ai không khả dụng.", + "claudeDesktop.mode.switchNote": "Chuyển chế độ sẽ xóa cấu hình của chế độ kia. Sau đó hãy thoát hẳn Desktop rồi mở lại.", + "claudeDesktop.firstParty.proxyRunning": "Proxy cục bộ tại 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "Proxy cục bộ 127.0.0.1:{port} không chạy — khởi động lại OpenCodex", + "claudeDesktop.firstParty.interceptDisabled": "Proxy chặn bắt đã bị tắt trong cấu hình (claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "Yêu cầu cuối cùng (Last request)", "claudeDesktop.health.stats": "{count} req / {errors} err", "claudeDesktop.effort.supported": "suy luận (effort)", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index fd1f07dd55e..a98cb69aad1 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2631,6 +2631,19 @@ export const zhTW: Record = { "claudeDesktop.appliedMarkerUnsaved": "已套用到 Claude Desktop,但套用標記未能儲存 — 在你再次套用之前,下方的已儲存/已套用狀態可能顯示過時資訊。", "claudeDesktop.status.disabled": "Claude Desktop 整合已關閉。啟用後請完全結束並重新開啟 Desktop。", "claudeDesktop.enableApply": "啟用並套用", + "claudeDesktop.switchModeApply": "切換模式並套用", + "claudeDesktop.status.appliedFirstParty": "第一方:Code 分頁經由本機代理", + "claudeDesktop.mode.legend": "連線模式", + "claudeDesktop.mode.firstParty": "第一方", + "claudeDesktop.mode.gateway": "閘道(第三方)", + "claudeDesktop.mode.defaultBadge": "預設", + "claudeDesktop.mode.current": "目前", + "claudeDesktop.mode.firstPartyHint": "Desktop 維持登入 claude.ai(聊天、連接器、遠端控制)。僅 Code 分頁、其子代理與 Claude Code CLI 經由 OpenCodex。", + "claudeDesktop.mode.gatewayHint": "整個應用程式改用 OpenCodex 作為閘道。聊天在本機執行;claude.ai 功能無法使用。", + "claudeDesktop.mode.switchNote": "切換會移除另一模式的設定。套用後請完全結束並重新開啟 Desktop。", + "claudeDesktop.firstParty.proxyRunning": "本機代理 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "本機代理 127.0.0.1:{port} 未執行 — 請重新啟動 OpenCodex", + "claudeDesktop.firstParty.interceptDisabled": "設定中已停用攔截代理(claudeCode.intercept.enabled)", "lab.title": "相容性實驗室", "lab.subtitle": "以實驗室投影證據為基礎的唯讀相容性判定矩陣。", "lab.loadFailed": "無法載入相容性實驗室資料", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 3189179e3fe..9e9eb8f4ffa 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2642,6 +2642,19 @@ export const zh: Record = { "claudeDesktop.status.notActiveProfile": "Desktop 正在使用其他配置 — 请重新应用", "claudeDesktop.status.disabled": "Claude Desktop 集成已关闭。开启后请完全退出并重新打开 Desktop。", "claudeDesktop.enableApply": "开启并应用", + "claudeDesktop.switchModeApply": "切换模式并应用", + "claudeDesktop.status.appliedFirstParty": "第一方:Code 标签页经由本地代理", + "claudeDesktop.mode.legend": "连接模式", + "claudeDesktop.mode.firstParty": "第一方", + "claudeDesktop.mode.gateway": "网关(第三方)", + "claudeDesktop.mode.defaultBadge": "默认", + "claudeDesktop.mode.current": "当前", + "claudeDesktop.mode.firstPartyHint": "Desktop 保持登录 claude.ai(聊天、连接器、远程控制)。仅 Code 标签页、其子代理和 Claude Code CLI 经由 OpenCodex。", + "claudeDesktop.mode.gatewayHint": "整个应用改用 OpenCodex 作为网关。聊天在本地运行;claude.ai 功能不可用。", + "claudeDesktop.mode.switchNote": "切换会移除另一模式的配置。应用后请完全退出并重新打开 Desktop。", + "claudeDesktop.firstParty.proxyRunning": "本地代理 127.0.0.1:{port}", + "claudeDesktop.firstParty.proxyStopped": "本地代理 127.0.0.1:{port} 未运行 — 请重启 OpenCodex", + "claudeDesktop.firstParty.interceptDisabled": "配置中已禁用拦截代理(claudeCode.intercept.enabled)", "claudeDesktop.health.lastRequest": "最后请求", "claudeDesktop.health.stats": "{count} 请求 / {errors} 错误", "claudeDesktop.effort.supported": "effort", diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index 3878f7d5b42..9788d4464de 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -42,8 +42,23 @@ interface DesktopModel { assignment: Assignment; } +type DesktopMode = "first-party" | "gateway"; +const DESKTOP_MODES: readonly DesktopMode[] = ["first-party", "gateway"]; + +interface DesktopFirstPartyStatus { + applied: boolean; + stale: boolean; + interceptEnabled: boolean; + interceptRunning: boolean; + proxyPort: number; + caCertPath: string; +} + interface DesktopStatus { desiredEnabled: boolean; + /** Effective mode: a gateway profile on disk wins; otherwise the saved/default mode. */ + mode?: DesktopMode; + firstParty?: DesktopFirstPartyStatus; applied: boolean; appliedAt: string | null; stale: boolean; @@ -65,6 +80,14 @@ function isDesktopStatus(value: unknown): value is DesktopStatus { if (typeof value !== "object" || value === null) return false; const v = value as Record; const health = v.health as Record | null | undefined; + if (v.mode !== undefined && !DESKTOP_MODES.includes(v.mode as DesktopMode)) return false; + if (v.firstParty !== undefined) { + const fp = v.firstParty as Record | null; + if (typeof fp !== "object" || fp === null + || typeof fp.applied !== "boolean" || typeof fp.stale !== "boolean" + || typeof fp.interceptEnabled !== "boolean" || typeof fp.interceptRunning !== "boolean" + || typeof fp.proxyPort !== "number" || typeof fp.caCertPath !== "string") return false; + } return typeof v.desiredEnabled === "boolean" && typeof v.applied === "boolean" && (v.appliedAt === null || typeof v.appliedAt === "string") @@ -201,6 +224,9 @@ export default function ClaudeDesktop({ // a family's fold is a durable preference, but which single model you were inspecting // is not, and restoring five open rows on reload would rebuild the wall this removes. const [openRows, setOpenRows] = useState>({}); + // The mode the user wants the next apply to use. null = follow whatever /status reports + // (first-party by default), so a page load never silently changes an existing install. + const [chosenMode, setChosenMode] = useState(null); const importRef = useRef(null); const fetchDesktop = useCallback(async (signal: AbortSignal): Promise => { const response = await fetch(`${apiBase}/api/claude-desktop`, { signal }); @@ -309,6 +335,9 @@ export default function ClaudeDesktop({ const statusState = statusResource.state; const status = statusState.data ?? cachedStatus; const statusFailed = statusState.showError; + const effectiveMode: DesktopMode = status?.mode ?? "first-party"; + const selectedMode: DesktopMode = chosenMode ?? effectiveMode; + const modeDirty = selectedMode !== effectiveMode; const moveModel = (route: string, family: Family) => { if (!profile || profile.assignments[route]?.family === family) return; @@ -353,7 +382,11 @@ export default function ClaudeDesktop({ if (applyAfter) { setPending("apply"); - const applyResponse = await fetch(`${apiBase}/api/claude-desktop/apply`, { method: "POST" }); + const applyResponse = await fetch(`${apiBase}/api/claude-desktop/apply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: selectedMode }), + }); const applyBody = await readJsonOrThrow<{ error?: string; saved?: boolean; warning?: string }>( applyResponse, t("claudeDesktop.applyFailed"), @@ -368,6 +401,7 @@ export default function ClaudeDesktop({ setMessage({ tone: "ok", text: t("claudeDesktop.savedApplied") }); setAnnouncement(t("claudeDesktop.savedAppliedAnnounce")); } + setChosenMode(null); } else { setMessage({ tone: "ok", text: t("claudeDesktop.saved") }); setAnnouncement(t("claudeDesktop.savedAnnounce")); @@ -445,6 +479,30 @@ export default function ClaudeDesktop({ +
+ {t("claudeDesktop.mode.legend")} + {DESKTOP_MODES.map(mode => ( + + ))} + {modeDirty && {t("claudeDesktop.mode.switchNote")}} +
+ {/* Always mount the bar (pending strut when status is still cold) so a late /status response cannot insert a full row under the title and shove the lanes down. */}
{/* Desktop serving another profile outranks content drift: stale config that is - read still works, a config that is never read does not. */} + read still works, a config that is never read does not. First-party never + writes a Desktop profile, so that check only applies in gateway mode. */} {statusFailed && !status ? t("claudeDesktop.loadFail") @@ -475,14 +534,23 @@ export default function ClaudeDesktop({ ? t("claudeDesktop.loading") : !status.desiredEnabled ? t("claudeDesktop.status.disabled") - : status.activeProfile === false + : effectiveMode === "gateway" && status.activeProfile === false ? t("claudeDesktop.status.notActiveProfile") : status.stale ? t("claudeDesktop.status.stale") : status.applied - ? t("claudeDesktop.status.applied") + ? effectiveMode === "first-party" ? t("claudeDesktop.status.appliedFirstParty") : t("claudeDesktop.status.applied") : t("claudeDesktop.status.notApplied")} + {status?.firstParty && effectiveMode === "first-party" && status.desiredEnabled && ( + + {status.firstParty.interceptRunning + ? t("claudeDesktop.firstParty.proxyRunning", { port: status.firstParty.proxyPort }) + : status.firstParty.interceptEnabled + ? t("claudeDesktop.firstParty.proxyStopped", { port: status.firstParty.proxyPort }) + : t("claudeDesktop.firstParty.interceptDisabled")} + + )} {status?.health.lastRequestAt && ( {t("claudeDesktop.health.lastRequest")}:{" "} @@ -508,7 +576,7 @@ export default function ClaudeDesktop({ {pending === "save" ? t("claudeDesktop.saving") : t("common.save")}
diff --git a/gui/src/styles.css b/gui/src/styles.css index 28c9277c1ed..d21dfac0d70 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2934,6 +2934,29 @@ button.prov-account-row.active { cursor: default; } .claude-move-row .input, .claude-move-row .btn { min-height: 44px; } } +/* ── Desktop connection mode picker (first-party default / gateway opt-in) ── */ +.claude-mode-picker { + display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; + margin: 0 0 14px; padding: 10px 14px 12px; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); +} +.claude-mode-picker legend { padding: 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); } +.claude-mode-picker:disabled { opacity: 0.6; } +.claude-mode-option { + display: grid; grid-template-columns: auto 1fr; column-gap: 10px; align-items: start; + padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; +} +.claude-mode-option.active { border-color: var(--accent); } +.claude-mode-option input { margin-top: 3px; } +.claude-mode-title { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; } +.claude-mode-hint { grid-column: 2; margin-top: 4px; font-size: 12px; color: var(--muted); line-height: 1.4; } +.claude-mode-default, .claude-mode-current { + padding: 1px 6px; border-radius: var(--radius-xs); font-size: 10px; font-weight: 600; letter-spacing: 0.02em; +} +.claude-mode-default { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); } +.claude-mode-current { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); } +.claude-mode-switch-note { grid-column: 1 / -1; font-size: 12px; color: var(--amber); } + /* ── Desktop status bar + effort badges (hardening WP3) ── */ .claude-status-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 8px 14px; diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 218df3838d5..809269fafc5 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -152,7 +152,7 @@ this document owns is which module holds which area and what invariant that area | Provider quotas and tests | `src/server/management/provider-routes.ts` — `GET /api/provider-quotas`, `POST /api/providers/test`, `GET/PUT /api/provider-context-caps`, `GET /api/provider-presets`. A quota read may be served from cache or force-refreshed; absent quota data is reported as unknown rather than as a measured zero. | | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`. Visibility writes trigger catalog sync through the owning server path. | | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | -| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply`, `GET /api/claude-desktop/status`, `GET/PUT /api/claude-code`. Apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)). | +| Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply` (`mode`: `first-party` default, `gateway`, or legacy shapes), `GET /api/claude-desktop/status` (`mode`, `firstParty`), `GET/PUT /api/claude-code`. Gateway apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)); first-party apply writes only the Claude Code proxy env, see [`clients/claude-desktop.md`](clients/claude-desktop.md#desktop-modes-first-party-and-gateway). `gui/src/pages/ClaudeDesktop.tsx` renders the mode selector and sends the chosen `mode` with apply. | | File-integration plans | `src/server/management/integration-routes.ts` and `aside-profile-routes.ts` — `POST /api/client-integrations/preview`, `POST /api/client-integrations/restore/preview`, and `POST /api/client-integrations/aside/profiles/{profileId}/preview`. Management-authenticated, declared non-mutating, and they write nothing: no snapshot, no lock, no maintenance, no recovery. They answer `409 integration_preview_unavailable` rather than gathering a model roster, because discovery refreshes credentials and writes the provider cache. Responses carry only declared managed schema paths, closed change kinds and an opaque fingerprint; no value, filesystem location or selected member identity appears. Mutation routes accept `operation` and `planFingerprint` together or not at all, reject a half-bound request and an operation that disagrees with the change, and answer `409 integration_preview_stale` with a freshly computed plan. Binding is an optimistic token, never authorization. [The integration contract](clients/integrations.md) owns the ordering. | | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. | From c44d3e8c7070ae196b68569604d7452cb1b61030 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:24:47 -0700 Subject: [PATCH 11/17] docs: avoid literal home path in first-party settings example Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs-site/src/content/docs/guides/claude-code.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index d29cd1496a2..ff2908cf9a0 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -150,7 +150,7 @@ working. OpenCodex only writes two variables into the `env` block of `~/.claude/ { "env": { "HTTPS_PROXY": "http://127.0.0.1:10200", - "NODE_EXTRA_CA_CERTS": "/Users/you/.opencodex/claude-intercept/ca.pem" + "NODE_EXTRA_CA_CERTS": "/.opencodex/claude-intercept/ca.pem" } } ``` From 479f47e6c61c05c687de16e78a25d4e4e3f86326 Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:46:59 -0700 Subject: [PATCH 12/17] test(gui): cover Claude Desktop connection-mode picker Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gui/tests/claude-desktop-mode-picker.test.tsx | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 gui/tests/claude-desktop-mode-picker.test.tsx diff --git a/gui/tests/claude-desktop-mode-picker.test.tsx b/gui/tests/claude-desktop-mode-picker.test.tsx new file mode 100644 index 00000000000..35f86ae1188 --- /dev/null +++ b/gui/tests/claude-desktop-mode-picker.test.tsx @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import ClaudeDesktop from "../src/pages/ClaudeDesktop"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; + +/** + * The connection-mode picker decides which of two mutually exclusive Desktop + * configurations the apply request asks for. Mounted tests because the + * failures that matter are wiring: the radio must follow /status, a changed + * selection must reach the POST body, and the gateway-only "not active + * profile" check must not leak into first-party status. + */ + +const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; +let requests: { url: string; init?: RequestInit }[] = []; + +const MODEL = { + route: "prov/opus-0", + label: "Opus Model", + available: true, + contextWindow: 200_000, + effortSupported: true, + assignment: { family: "opus", alias: "alias-opus" }, +}; + +function profilePayload() { + return { + profile: { + version: 1, + assignments: { [MODEL.route]: MODEL.assignment }, + defaults: { opus: MODEL.route, fable: null, sonnet: null, haiku: null }, + }, + models: [MODEL], + rendered: [], + port: 10100, + }; +} + +function statusPayload(overrides: Record = {}) { + return { + desiredEnabled: true, + applied: true, + appliedAt: null, + stale: false, + health: { lastRequestAt: null, requestCount: 0, errorCount: 0 }, + mode: "first-party", + firstParty: { + applied: true, + stale: false, + interceptEnabled: true, + interceptRunning: true, + proxyPort: 10200, + caCertPath: "/tmp/ocx/claude-intercept/ca.pem", + }, + ...overrides, + }; +} + +function installFetch(status: Record) { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string, init?: RequestInit) => { + requests.push({ url: String(url), init }); + const path = String(url); + const body = path.includes("/status") + ? status + : path.endsWith("/apply") + ? { ok: true, mode: JSON.parse(String(init?.body ?? "{}")).mode } + : init?.method === "PUT" + ? { ok: true } + : profilePayload(); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + }, + }); +} + +beforeEach(() => { + clearClientResourceStoresForTests(); + requests = []; + previousGlobals = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + installFetch(statusPayload()); + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + clearClientResourceStoresForTests(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function mount() { + await act(async () => { + root = createRoot(container); + root.render(); + }); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); +} + +function radio(mode: "first-party" | "gateway"): HTMLInputElement { + const found = container.querySelector(`input[name="claude-desktop-mode"][value="${mode}"]`); + if (!found) throw new Error(`mode radio not found: ${mode}`); + return found as unknown as HTMLInputElement; +} + +function applyButton(): HTMLButtonElement { + const found = Array.from(container.querySelectorAll("button.btn-primary")) + .find(button => /apply/i.test(button.textContent ?? "")); + if (!found) throw new Error("apply button not found"); + return found as unknown as HTMLButtonElement; +} + +async function click(element: HTMLElement) { + await act(async () => { element.click(); }); +} + +test("the picker follows the effective mode reported by /status and shows the proxy port", async () => { + await mount(); + expect(radio("first-party").checked).toBe(true); + expect(radio("gateway").checked).toBe(false); + const firstPartyOption = radio("first-party").closest("label")!; + expect(firstPartyOption.querySelector(".claude-mode-default")).not.toBeNull(); + expect(firstPartyOption.querySelector(".claude-mode-current")).not.toBeNull(); + expect(container.querySelector(".claude-mode-switch-note")).toBeNull(); + + const bar = container.querySelector(".claude-status-bar")!; + expect(bar.className).toContain("applied"); + expect(bar.textContent ?? "").toContain("First-party: Code tab routed through the local proxy"); + expect(bar.textContent ?? "").toContain("127.0.0.1:10200"); + expect(applyButton().textContent).toBe("Save & apply"); +}); + +test("a stopped intercept proxy is surfaced in first-party mode", async () => { + installFetch(statusPayload({ + firstParty: { applied: true, stale: false, interceptEnabled: true, interceptRunning: false, proxyPort: 10200, caCertPath: "/tmp/ca.pem" }, + })); + await mount(); + expect(container.querySelector(".claude-status-bar")?.textContent ?? "").toContain("is not running"); +}); + +test("selecting the other mode flips the apply label and sends that mode in the POST body", async () => { + await mount(); + await act(async () => { + radio("gateway").click(); + }); + expect(radio("gateway").checked).toBe(true); + expect(container.querySelector(".claude-mode-switch-note")).not.toBeNull(); + expect(applyButton().textContent).toBe("Switch mode & apply"); + + await click(applyButton()); + await act(async () => { await new Promise(r => setTimeout(r, 20)); }); + + const apply = requests.find(r => r.url.endsWith("/api/claude-desktop/apply")); + expect(apply).toBeDefined(); + expect(apply!.init?.method).toBe("POST"); + expect(JSON.parse(String(apply!.init?.body))).toEqual({ mode: "gateway" }); +}); + +test("the default apply keeps the effective mode when nothing was chosen", async () => { + installFetch(statusPayload({ mode: "gateway", activeProfile: true, firstParty: undefined })); + await mount(); + expect(radio("gateway").checked).toBe(true); + await click(applyButton()); + await act(async () => { await new Promise(r => setTimeout(r, 20)); }); + const apply = requests.find(r => r.url.endsWith("/api/claude-desktop/apply")); + expect(JSON.parse(String(apply!.init?.body))).toEqual({ mode: "gateway" }); +}); + +test("activeProfile=false only demotes the status bar in gateway mode", async () => { + // First-party never writes a Desktop profile, so Desktop serving some other + // profile is irrelevant and must not paint the bar as not-applied. + installFetch(statusPayload({ activeProfile: false })); + await mount(); + expect(container.querySelector(".claude-status-bar")!.className).toContain("applied"); + expect(container.querySelector(".claude-status-bar")!.className).not.toContain("not-applied"); + + await act(async () => { root!.unmount(); root = null; }); + clearClientResourceStoresForTests(); + installFetch(statusPayload({ mode: "gateway", activeProfile: false, firstParty: undefined })); + await mount(); + expect(container.querySelector(".claude-status-bar")!.className).toContain("not-applied"); +}); + +test("an unknown mode in /status is rejected as malformed", async () => { + installFetch(statusPayload({ mode: "proxy" })); + await mount(); + expect(container.textContent ?? "").toContain("Failed to load Claude Desktop profile."); +}); From e67a1ad9eb9cb917c66764ba60703b416533857d Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 01:59:18 -0700 Subject: [PATCH 13/17] style(gui): move Claude Desktop mode-picker CSS into its own stylesheet gui/src/styles.css sits at its file-size cap; the picker rules move byte-for-byte into gui/src/styles/claude-desktop-mode-picker.css, imported from main.tsx. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gui/src/main.tsx | 1 + gui/src/styles.css | 23 ------------------- gui/src/styles/claude-desktop-mode-picker.css | 22 ++++++++++++++++++ 3 files changed, 23 insertions(+), 23 deletions(-) create mode 100644 gui/src/styles/claude-desktop-mode-picker.css diff --git a/gui/src/main.tsx b/gui/src/main.tsx index 362c5bb351d..a5228663be1 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -6,6 +6,7 @@ import "./styles.css"; import "./styles/usage-chart-accessibility.css"; import "./styles/sidebar-brand.css"; import "./styles/fast-rows-setting.css"; +import "./styles/claude-desktop-mode-picker.css"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/gui/src/styles.css b/gui/src/styles.css index d21dfac0d70..28c9277c1ed 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2934,29 +2934,6 @@ button.prov-account-row.active { cursor: default; } .claude-move-row .input, .claude-move-row .btn { min-height: 44px; } } -/* ── Desktop connection mode picker (first-party default / gateway opt-in) ── */ -.claude-mode-picker { - display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; - margin: 0 0 14px; padding: 10px 14px 12px; border: 1px solid var(--border); border-radius: var(--radius); - background: var(--surface); -} -.claude-mode-picker legend { padding: 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); } -.claude-mode-picker:disabled { opacity: 0.6; } -.claude-mode-option { - display: grid; grid-template-columns: auto 1fr; column-gap: 10px; align-items: start; - padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; -} -.claude-mode-option.active { border-color: var(--accent); } -.claude-mode-option input { margin-top: 3px; } -.claude-mode-title { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; } -.claude-mode-hint { grid-column: 2; margin-top: 4px; font-size: 12px; color: var(--muted); line-height: 1.4; } -.claude-mode-default, .claude-mode-current { - padding: 1px 6px; border-radius: var(--radius-xs); font-size: 10px; font-weight: 600; letter-spacing: 0.02em; -} -.claude-mode-default { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); } -.claude-mode-current { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); } -.claude-mode-switch-note { grid-column: 1 / -1; font-size: 12px; color: var(--amber); } - /* ── Desktop status bar + effort badges (hardening WP3) ── */ .claude-status-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 8px 14px; diff --git a/gui/src/styles/claude-desktop-mode-picker.css b/gui/src/styles/claude-desktop-mode-picker.css new file mode 100644 index 00000000000..ee6db473520 --- /dev/null +++ b/gui/src/styles/claude-desktop-mode-picker.css @@ -0,0 +1,22 @@ +/* ── Desktop connection mode picker (first-party default / gateway opt-in) ── */ +.claude-mode-picker { + display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; + margin: 0 0 14px; padding: 10px 14px 12px; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--surface); +} +.claude-mode-picker legend { padding: 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); } +.claude-mode-picker:disabled { opacity: 0.6; } +.claude-mode-option { + display: grid; grid-template-columns: auto 1fr; column-gap: 10px; align-items: start; + padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; +} +.claude-mode-option.active { border-color: var(--accent); } +.claude-mode-option input { margin-top: 3px; } +.claude-mode-title { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; } +.claude-mode-hint { grid-column: 2; margin-top: 4px; font-size: 12px; color: var(--muted); line-height: 1.4; } +.claude-mode-default, .claude-mode-current { + padding: 1px 6px; border-radius: var(--radius-xs); font-size: 10px; font-weight: 600; letter-spacing: 0.02em; +} +.claude-mode-default { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); } +.claude-mode-current { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); } +.claude-mode-switch-note { grid-column: 1 / -1; font-size: 12px; color: var(--amber); } From 93ddd9fc7e4c4d1fb72651145221cfdc380544bb Mon Sep 17 00:00:00 2001 From: jun Date: Sun, 20 Sep 2026 02:13:42 -0700 Subject: [PATCH 14/17] fix(gui): keep the Desktop mode picker unresolved until /status answers The picker no longer pre-checks the first-party default while the status request is in flight, so a gateway install does not see the wrong radio and badge flash. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gui/src/pages/ClaudeDesktop.tsx | 13 +++++---- gui/tests/claude-desktop-mode-picker.test.tsx | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index 9788d4464de..a5d9f1c2535 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -335,9 +335,12 @@ export default function ClaudeDesktop({ const statusState = statusResource.state; const status = statusState.data ?? cachedStatus; const statusFailed = statusState.showError; + // Until /status answers, the effective mode is unknown: the picker renders unchecked and + // disabled instead of flashing the first-party default at a gateway install. + const modeKnown = status !== null; const effectiveMode: DesktopMode = status?.mode ?? "first-party"; const selectedMode: DesktopMode = chosenMode ?? effectiveMode; - const modeDirty = selectedMode !== effectiveMode; + const modeDirty = modeKnown && selectedMode !== effectiveMode; const moveModel = (route: string, family: Family) => { if (!profile || profile.assignments[route]?.family === family) return; @@ -479,21 +482,21 @@ export default function ClaudeDesktop({ -
+
{t("claudeDesktop.mode.legend")} {DESKTOP_MODES.map(mode => ( -