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 45851d1fc0d..7edd7301857 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -130,8 +130,9 @@ Claude Desktop utilise OpenCodex dans l'un de deux modes mutuellement exclusifs. - **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 + `HTTPS_PROXY=http://opencodex:@127.0.0.1:` et + `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem` ; le jeton est conservé en accès + propriétaire sous `~/.opencodex/claude-intercept/proxy-token`. 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 diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 83da2dad162..80b298d60e3 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -149,7 +149,7 @@ working. OpenCodex only writes two variables into the `env` block of `~/.claude/ ```json { "env": { - "HTTPS_PROXY": "http://127.0.0.1:10200", + "HTTPS_PROXY": "http://opencodex:@127.0.0.1:10200", "NODE_EXTRA_CA_CERTS": "/.opencodex/claude-intercept/ca.pem" } } @@ -160,7 +160,8 @@ standalone `claude` CLI — reads that env and sends its `api.anthropic.com` tra 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 +trust it), authenticates every CONNECT against a per-install token kept owner-only at +`~/.opencodex/claude-intercept/proxy-token`, 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 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 390fbccd054..fc3178fda8c 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -102,8 +102,9 @@ Claude Desktop は排他的な 2 つのモードのどちらかで OpenCodex を - **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 が + `env` に `HTTPS_PROXY=http://opencodex:<インストール毎のトークン>@127.0.0.1:<公開ポート+100>` と + `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem` の 2 つだけを書きます(トークンは + `~/.opencodex/claude-intercept/proxy-token` に所有者専用で保存されます)。Desktop が Code タブ用に起動する Claude Code(サブエージェント含む)とターミナルの `claude` CLI だけがこれを読み、 ローカルのインターセプトプロキシを通ります。`POST /v1/messages` と `count_tokens` のみ OpenCodex が 処理し、他の `api.anthropic.com` パスはそのまま Anthropic に中継されます。CA は OS の信頼ストアには 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 27759bbc709..edd2ecba0da 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -125,8 +125,8 @@ Claude Desktop은 서로 배타적인 두 모드 중 하나로 OpenCodex를 사 - **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(서브에이전트 포함)와 터미널의 + `HTTPS_PROXY=http://opencodex:<설치별 토큰>@127.0.0.1:<공개 포트+100>`과 `NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem` + 두 값만 씁니다(토큰은 `~/.opencodex/claude-intercept/proxy-token`에 소유자 전용으로 저장돼요). Desktop이 Code 탭용으로 실행하는 Claude Code(서브에이전트 포함)와 터미널의 `claude` CLI만 이 값을 읽어 로컬 인터셉트 프록시를 거치고, `POST /v1/messages`·`count_tokens`만 OpenCodex가 처리하며 나머지 `api.anthropic.com` 경로는 그대로 Anthropic으로 전달돼요. CA는 OS 신뢰 저장소에 설치되지 않습니다. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 6b8712e526f..c58f26bda35 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1624,6 +1624,7 @@ "api-key-scope-alpha-search.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", "claude-intercept-proxy.test.ts": "claude-integration", + "claude-intercept-proxy-auth.test.ts": "claude-integration", "claude-intercept-settings.test.ts": "claude-integration", "claude-desktop-first-party.test.ts": "claude-integration", "claude-desktop-mode-explanation.test.ts": "claude-integration", diff --git a/src/claude/desktop-first-party.ts b/src/claude/desktop-first-party.ts index 0946aafddd5..abf39316337 100644 --- a/src/claude/desktop-first-party.ts +++ b/src/claude/desktop-first-party.ts @@ -20,6 +20,7 @@ import { getConfigDir } from "../config/paths"; import type { OcxConfig } from "../types"; import { claudeInterceptCaCertPath, ensureLocalInterceptCa } from "./intercept/local-ca"; import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/runtime"; +import { ensureClaudeInterceptProxyToken, readClaudeInterceptProxyToken } from "./intercept/proxy-auth"; import { applyClaudeInterceptSettings, buildClaudeInterceptEnv, @@ -97,14 +98,22 @@ export interface DesktopFirstPartyTarget { env: ClaudeInterceptEnv; } -/** The settings env a first-party apply on this machine writes (CA is created on demand). */ -export function desktopFirstPartyTarget( +function firstPartyTarget( config: Pick, - opencodexConfigDir = getConfigDir(), + opencodexConfigDir: string, + authToken: string, ): DesktopFirstPartyTarget { const proxyPort = claudeInterceptProxyPort(config, config.port ?? 10100); const caCertPath = claudeInterceptCaCertPath(opencodexConfigDir); - return { proxyPort, caCertPath, env: buildClaudeInterceptEnv(proxyPort, caCertPath) }; + return { proxyPort, caCertPath, env: buildClaudeInterceptEnv(proxyPort, caCertPath, authToken) }; +} + +/** The settings env a first-party apply on this machine writes (CA and token are created on demand). */ +export function desktopFirstPartyTarget( + config: Pick, + opencodexConfigDir = getConfigDir(), +): DesktopFirstPartyTarget { + return firstPartyTarget(config, opencodexConfigDir, ensureClaudeInterceptProxyToken(opencodexConfigDir)); } export interface DesktopFirstPartyInspection { @@ -128,7 +137,11 @@ export function inspectDesktopFirstParty( config: Pick, options: DesktopFirstPartyOptions = {}, ): DesktopFirstPartyInspection { - const target = desktopFirstPartyTarget(config, options.opencodexConfigDir); + const opencodexConfigDir = options.opencodexConfigDir ?? getConfigDir(); + // Inspection is read-only: a missing token means no apply or runtime start produced one, + // so an owned env can never match the empty credential — it classifies stale, and a real + // apply is what refreshes it. + const target = firstPartyTarget(config, opencodexConfigDir, readClaudeInterceptProxyToken(opencodexConfigDir) ?? ""); const settings = inspectClaudeInterceptSettings(target.env, options.claudeConfigDir); return { interceptEnabled: claudeInterceptEnabled(config), diff --git a/src/claude/intercept/connect-proxy.ts b/src/claude/intercept/connect-proxy.ts index 86c9375513f..ee86ee86f52 100644 --- a/src/claude/intercept/connect-proxy.ts +++ b/src/claude/intercept/connect-proxy.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto"; import { BlockList, createServer, connect, isIP, type Server, type Socket } from "node:net"; /** @@ -21,6 +22,8 @@ const UPSTREAM_CONNECT_TIMEOUT_MS = 15_000; export interface ConnectProxyOptions { /** Loopback port of the TLS listener that terminates intercepted tunnels. */ interceptPort: number; + /** Per-install bearer carried as HTTP Basic proxy credentials. */ + authToken: string; /** Hostnames (lowercase) whose 443 tunnels are spliced onto `interceptPort`. */ interceptHosts?: readonly string[]; /** Test seam: dial the real destination for a blind tunnel. */ @@ -90,7 +93,15 @@ function splice(client: Socket, upstream: Socket, pending: Uint8Array): void { upstream.pipe(client); } -function handleConnection(socket: Socket, options: Required>): void { +function proxyAuthorized(head: string, token: string): boolean { + const header = head.split("\r\n").find(line => /^proxy-authorization:/i.test(line)); + const supplied = header?.slice(header.indexOf(":") + 1).trim(); + const expected = `Basic ${Buffer.from(`opencodex:${token}`).toString("base64")}`; + if (!supplied || supplied.length !== expected.length) return false; + return timingSafeEqual(Buffer.from(supplied), Buffer.from(expected)); +} + +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")); @@ -107,13 +118,18 @@ function handleConnection(socket: Socket, options: Required { const resolved = { interceptPort: options.interceptPort, + authToken: options.authToken, interceptHosts: options.interceptHosts ?? CLAUDE_INTERCEPT_HOSTS, dialUpstream: options.dialUpstream ?? ((host: string, targetPort: number) => connect({ host, port: targetPort })), }; diff --git a/src/claude/intercept/proxy-auth.ts b/src/claude/intercept/proxy-auth.ts new file mode 100644 index 00000000000..8c72c7b4fdb --- /dev/null +++ b/src/claude/intercept/proxy-auth.ts @@ -0,0 +1,87 @@ +import { randomBytes } from "node:crypto"; +import { chmodSync, linkSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { isMissingPathError } from "../../config/atomic-write"; +import { hardenSecretDir, hardenSecretPath } from "../../lib/windows-secret-acl"; + +const TOKEN_FILE = "proxy-token"; + +export function claudeInterceptProxyTokenPath(configDir: string): string { + return join(configDir, "claude-intercept", TOKEN_FILE); +} + +/** + * The persisted CONNECT credential, or `null` when none is usable. Never writes: + * read-only inspection paths call this, so missing, empty, and unreadable files + * all collapse to "no token" and classify as stale rather than throwing. + */ +export function readClaudeInterceptProxyToken(configDir: string): string | null { + try { + const token = readFileSync(claudeInterceptProxyTokenPath(configDir), "utf8").trim(); + return token.length > 0 ? token : null; + } catch { // no-excuse-ok: catch -- every read failure means no usable credential; write paths surface real errors when minting. + return null; + } +} + +/** + * Return the per-install CONNECT credential, creating it with owner-only permissions. + * The file publishes through an atomic no-replace link, so concurrent creators (a server + * start racing an apply) all return the single committed value instead of splitting a + * generated token from the one clients were told. On Windows, `chmod` is a no-op against + * inherited NTFS grants, so the directory and file go through the repo's icacls hardener. + */ +export function ensureClaudeInterceptProxyToken(configDir: string): string { + const path = claudeInterceptProxyTokenPath(configDir); + const existing = readClaudeInterceptProxyToken(configDir); + if (existing) { + try { + // A restored or hand-edited file may carry broader permissions than the + // writer left; re-pin owner-only before trusting the credential again. + if (process.platform === "win32") hardenSecretPath(path, { required: true }); + else if ((statSync(path).mode & 0o077) !== 0) chmodSync(path, 0o600); + return existing; + } catch (error) { + if (!isMissingPathError(error)) throw error; + // Vanished between read and stat — mint a fresh one below rather than + // returning a token that is no longer persisted anywhere. + } + } + const dir = join(configDir, "claude-intercept"); + mkdirSync(dir, { recursive: true }); + try { chmodSync(dir, 0o700); } catch { // no-excuse-ok: catch -- non-POSIX filesystems may ignore chmod. + } + if (process.platform === "win32") hardenSecretDir(dir, { required: true }); + const token = randomBytes(32).toString("base64url"); + const tmp = join(dir, `.${TOKEN_FILE}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`); + writeFileSync(tmp, `${token}\n`, { mode: 0o600 }); + try { chmodSync(tmp, 0o600); } catch { // no-excuse-ok: catch -- non-POSIX filesystems may ignore chmod. + } + if (process.platform === "win32") hardenSecretPath(tmp, { required: true, timeoutMemoKey: path }); + try { + try { + linkSync(tmp, path); // atomic no-replace publish; EEXIST means a peer committed first + return token; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST") { + const committed = readClaudeInterceptProxyToken(configDir); + if (committed) return committed; + renameSync(tmp, path); // an empty placeholder is corruption, not a winner + return token; + } + if (code === "EPERM" || code === "EXDEV" || code === "ENOSYS") { + // Filesystem without hard links: rename still publishes, then the reread + // resolves a concurrent overwrite to the committed file rather than our lost write. + renameSync(tmp, path); + return readClaudeInterceptProxyToken(configDir) ?? token; + } + throw error; + } + } finally { + try { + unlinkSync(tmp); + } catch { // no-excuse-ok: catch -- the publish above already consumed the temp file. + } + } +} diff --git a/src/claude/intercept/runtime.ts b/src/claude/intercept/runtime.ts index a1452117d8c..e7e69fe6c1e 100644 --- a/src/claude/intercept/runtime.ts +++ b/src/claude/intercept/runtime.ts @@ -4,6 +4,8 @@ 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"; +import { ensureClaudeInterceptProxyToken } from "./proxy-auth"; +import { buildClaudeInterceptEnv, migrateClaudeInterceptSettings } from "./settings"; /** * Lifecycle for the Claude intercept pair (CONNECT proxy + TLS listener). @@ -68,7 +70,17 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio if (options.requestedPort === 0 && !explicitPort) return null; const configDir = options.configDir ?? getConfigDir(); const ca = ensureLocalInterceptCa(configDir); + const authToken = ensureClaudeInterceptProxyToken(configDir); const leaf = issueLocalInterceptLeaf(ca, CLAUDE_INTERCEPT_HOSTS); + // Refresh an env we already own (e.g. a pre-auth proxy URL left by an upgrade) before the + // authenticated proxy takes over the port — a plain `ocx start` after an update would + // otherwise 407 every CONNECT until the next `ocx ensure` or apply. Only `stale` state is + // rewritten, so installs that never applied first-party are untouched. + try { + const proxyPort = claudeInterceptProxyPort(options.config, options.publicPort); + migrateClaudeInterceptSettings(buildClaudeInterceptEnv(proxyPort, claudeInterceptCaCertPath(configDir), authToken)); + } catch { // no-excuse-ok: catch -- a skipped rewrite degrades to the pre-migration behaviour, and ensure/apply retries it. + } const listener = startClaudeInterceptListener({ leaf, dispatch: options.dispatch, @@ -79,6 +91,7 @@ export async function startClaudeIntercept(options: StartClaudeInterceptOptio try { proxy = await startConnectProxy(claudeInterceptProxyPort(options.config, options.publicPort), { interceptPort: listener.port!, + authToken, }); } catch (error) { await listener.stop(true); diff --git a/src/claude/intercept/settings.ts b/src/claude/intercept/settings.ts index 5b291a7b401..686246a687b 100644 --- a/src/claude/intercept/settings.ts +++ b/src/claude/intercept/settings.ts @@ -1,5 +1,6 @@ -import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { atomicWriteFile } from "../../config/atomic-write"; import { claudeConfigDir } from "../auth-detect"; /** @@ -10,7 +11,7 @@ import { claudeConfigDir } from "../auth-detect"; * 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.HTTPS_PROXY = http://opencodex:@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 @@ -26,12 +27,12 @@ export interface ClaudeInterceptEnv { NODE_EXTRA_CA_CERTS: string; } -export function claudeInterceptProxyUrl(port: number): string { - return `http://127.0.0.1:${port}`; +export function claudeInterceptProxyUrl(port: number, authToken: string): string { + return `http://opencodex:${encodeURIComponent(authToken)}@127.0.0.1:${port}`; } -export function buildClaudeInterceptEnv(proxyPort: number, caCertPath: string): ClaudeInterceptEnv { - return { HTTPS_PROXY: claudeInterceptProxyUrl(proxyPort), NODE_EXTRA_CA_CERTS: caCertPath }; +export function buildClaudeInterceptEnv(proxyPort: number, caCertPath: string, authToken: string): ClaudeInterceptEnv { + return { HTTPS_PROXY: claudeInterceptProxyUrl(proxyPort, authToken), NODE_EXTRA_CA_CERTS: caCertPath }; } export type ClaudeInterceptSettingsState = @@ -69,7 +70,7 @@ function envRecord(doc: SettingsDoc): Record { /** 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()); + return typeof value === "string" && /^http:\/\/(?:opencodex:[^@/]+@)?127\.0\.0\.1:\d{1,5}\/?$/.test(value.trim()); } function isOwnedCaPath(value: unknown, ownedCaPath: string): value is string { @@ -111,9 +112,9 @@ export function inspectClaudeInterceptSettings( 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); + // The managed env embeds the proxy token, so the file must stay owner-only: + // atomicWriteFile applies the real NTFS ACL on Windows where chmod is a no-op. + atomicWriteFile(path, `${JSON.stringify(doc, null, 2)}\n`); } export type ClaudeInterceptSettingsWrite = @@ -140,6 +141,25 @@ export function applyClaudeInterceptSettings( return { ok: true, changed: true, path }; } +/** + * Rewrite an env block opencodex already owns when it no longer matches what this run + * would write — a pre-auth `http://127.0.0.1:` left behind by an upgrade would get + * a 407 from the now-authenticated proxy until `ocx ensure` or an apply ran. Unlike apply + * this never creates an absent env: only `stale` (owned) state is rewritten, so the + * runtime can call it on every start without enabling the integration for anyone else. + */ +export function migrateClaudeInterceptSettings( + 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 !== "stale") return { ok: true, changed: false, path }; + return applyClaudeInterceptSettings(env, configDir); +} + /** Remove the managed keys, but only the values opencodex owns. */ export function removeClaudeInterceptSettings( ownedCaPath: string, diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 8e73c82187c..634eef2b5be 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -36,7 +36,7 @@ mutually exclusive on one machine: - **first-party** (default): Claude Desktop itself is left on claude.ai — login, Chat tab, connectors and remote control are untouched and no config-library profile is written. The apply - writes only `HTTPS_PROXY=http://127.0.0.1:` and `NODE_EXTRA_CA_CERTS=/claude-intercept/ca.pem` + writes only an authenticated `HTTPS_PROXY=http://opencodex:@127.0.0.1:` 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 diff --git a/structure/runtime.md b/structure/runtime.md index ec03516b8cc..97bd550f7df 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -223,10 +223,10 @@ the ingress decision, `stop` joined into the listener shutdown) from `src/claude 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 +Claude Code reaches the pair through an authenticated `HTTPS_PROXY` URL 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. +listener, relays every other CONNECT target blind, and refuses unauthenticated clients, plain proxied HTTP, and loopback targets. The per-install proxy token is stored owner-only under `/claude-intercept/` (0600 plus a real per-user NTFS ACL on Windows, via `src/lib/windows-secret-acl.ts`, and re-pinned on every read-through `ensure`), and the settings file carrying it is written through the same hardened atomic writer. Every start runs `migrateClaudeInterceptSettings` (`src/claude/intercept/settings.ts`), which rewrites an owned env that no longer matches — e.g. a pre-auth URL left by an upgrade — while never creating an absent env or touching a foreign one, so a service restart cannot strand clients on 407s. Status/inspection reads the token without minting it; only apply and intercept startup create it. The TLS listener rewrites `POST /v1/messages` and `POST /v1/messages/count_tokens` 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 diff --git a/tests/claude-integration/claude-desktop-first-party.test.ts b/tests/claude-integration/claude-desktop-first-party.test.ts index 7b391f98dae..0d6e0975146 100644 --- a/tests/claude-integration/claude-desktop-first-party.test.ts +++ b/tests/claude-integration/claude-desktop-first-party.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -11,6 +11,9 @@ import { } from "../../src/claude/desktop-first-party"; import { parseDesktopApplyArgs } from "../../src/cli/claude-desktop"; import { ensureClaudeDesktopMatchesDesired } from "../../src/cli/ensure-desired-integrations"; +import { claudeInterceptCaCertPath } from "../../src/claude/intercept/local-ca"; +import { claudeInterceptProxyTokenPath, readClaudeInterceptProxyToken } from "../../src/claude/intercept/proxy-auth"; +import { claudeInterceptProxyUrl } from "../../src/claude/intercept/settings"; import { handleManagementAPI } from "../../src/server/management-api"; import { setIntegrationEnabled } from "../../src/codex/desired-state"; import type { OcxConfig } from "../../src/types"; @@ -30,6 +33,12 @@ function settings(): { env?: Record; [key: string]: unknown } { return JSON.parse(readFileSync(join(claudeDir, "settings.json"), "utf8")) as { env?: Record }; } +function expectedProxyUrl(port: number): string { + const token = readClaudeInterceptProxyToken(root); + if (token === null) throw new Error("expected the proxy token to exist"); + return claudeInterceptProxyUrl(port, token); +} + 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, { @@ -105,7 +114,7 @@ test("first-party apply writes only the proxy env, creates the CA, and removes c expect(written.theme).toBe("dark"); expect(written.env).toEqual({ FOO: "bar", - HTTPS_PROXY: "http://127.0.0.1:10200", + HTTPS_PROXY: expectedProxyUrl(10200), NODE_EXTRA_CA_CERTS: applied.env.NODE_EXTRA_CA_CERTS, }); expect(inspectDesktopFirstParty(config()).applied).toBe(true); @@ -116,7 +125,7 @@ test("first-party apply writes only the proxy env, creates the CA, and removes c 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"); + expect(settings().env?.HTTPS_PROXY).toBe(expectedProxyUrl(10400)); const removed = removeDesktopFirstParty(); expect(removed).toMatchObject({ ok: true, changed: true }); @@ -136,7 +145,7 @@ test("POST /api/claude-desktop/apply defaults to first-party and gateway mode re 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"); + expect(settings().env?.HTTPS_PROXY).toBe(expectedProxyUrl(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); @@ -163,7 +172,7 @@ test("POST /api/claude-desktop/apply defaults to first-party and gateway mode re 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"); + expect(settings().env?.HTTPS_PROXY).toBe(expectedProxyUrl(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); @@ -180,7 +189,7 @@ test("native toggle: enable applies first-party by default and disable removes t 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"); + expect(settings().env?.HTTPS_PROXY).toBe(expectedProxyUrl(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"); @@ -204,7 +213,7 @@ test("native toggle: enabling into explicit first-party pivots an applied gatewa 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"); + expect(settings().env?.HTTPS_PROXY).toBe(expectedProxyUrl(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(); @@ -246,7 +255,7 @@ test("ensure warns instead of touching a gateway profile that contradicts an exp 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"); + expect(settings().env?.HTTPS_PROXY).toBe(expectedProxyUrl(10400)); const logs: string[] = []; const deps = { @@ -258,7 +267,7 @@ test("ensure reconciles first-party env: refreshes when ON and stale, removes wh error: (message: string) => { logs.push(message); }, }; ensureClaudeDesktopMatchesDesired(deps); - expect(settings().env?.HTTPS_PROXY).toBe("http://127.0.0.1:10200"); + expect(settings().env?.HTTPS_PROXY).toBe(expectedProxyUrl(10200)); expect(logs.some(line => line.includes("first-party env refreshed"))).toBe(true); expect(setIntegrationEnabled("claude-desktop", false).ok).toBe(true); @@ -266,3 +275,35 @@ test("ensure reconciles first-party env: refreshes when ON and stale, removes wh expect(settings().env?.HTTPS_PROXY).toBeUndefined(); expect(settings().env?.NODE_EXTRA_CA_CERTS).toBeUndefined(); }); + +test("first-party inspection is read-only: an owned legacy env reads stale, no token is minted", () => { + // A pre-auth apply left a bare loopback URL anchored on our CA. Status must classify it + // stale without writing the credential file — inspection runs on read-only paths too. + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ + env: { HTTPS_PROXY: "http://127.0.0.1:10200", NODE_EXTRA_CA_CERTS: claudeInterceptCaCertPath(root) }, + })); + const seen = inspectDesktopFirstParty(config()); + expect(seen.applied).toBe(false); + expect(seen.stale).toBe(true); + expect(existsSync(claudeInterceptProxyTokenPath(root))).toBe(false); +}); + +test("a deleted token flips an applied env to stale and inspection does not recreate it", () => { + const applied = applyDesktopFirstParty(config()); + expect(applied.ok).toBe(true); + expect(inspectDesktopFirstParty(config()).applied).toBe(true); + rmSync(claudeInterceptProxyTokenPath(root)); + const seen = inspectDesktopFirstParty(config()); + expect(seen.applied).toBe(false); + expect(seen.stale).toBe(true); + expect(existsSync(claudeInterceptProxyTokenPath(root))).toBe(false); +}); + +test("the status routes never mint the proxy token", async () => { + const status = await dispatch("/api/claude-desktop/status"); + expect(status.status).toBe(200); + const list = await dispatch("/api/native-integrations"); + expect(list.status).toBe(200); + expect(existsSync(claudeInterceptProxyTokenPath(root))).toBe(false); +}); diff --git a/tests/claude-integration/claude-intercept-proxy-auth.test.ts b/tests/claude-integration/claude-intercept-proxy-auth.test.ts new file mode 100644 index 00000000000..8ae7274dcb2 --- /dev/null +++ b/tests/claude-integration/claude-intercept-proxy-auth.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from "bun:test"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + claudeInterceptProxyTokenPath, + ensureClaudeInterceptProxyToken, + readClaudeInterceptProxyToken, +} from "../../src/claude/intercept/proxy-auth"; + +function dir(): string { + return mkdtempSync(join(tmpdir(), "ocx-proxy-auth-")); +} + +test("read returns null without creating anything on disk", () => { + const configDir = dir(); + expect(readClaudeInterceptProxyToken(configDir)).toBeNull(); + expect(existsSync(join(configDir, "claude-intercept"))).toBe(false); +}); + +test("ensure creates the token once, returns it, and reuses it", () => { + const configDir = dir(); + const token = ensureClaudeInterceptProxyToken(configDir); + expect(token.length).toBeGreaterThanOrEqual(32); + expect(readFileSync(claudeInterceptProxyTokenPath(configDir), "utf8")).toBe(`${token}\n`); + expect(ensureClaudeInterceptProxyToken(configDir)).toBe(token); + expect(readClaudeInterceptProxyToken(configDir)).toBe(token); + // No temp or backup files linger next to the committed credential. + expect(readdirSync(join(configDir, "claude-intercept"))).toEqual(["proxy-token"]); +}); + +test("a committed token beats a concurrent creator's generated one", () => { + // Another process already published: the loser must converge on the committed credential + // rather than return a token that was never persisted. + const configDir = dir(); + mkdirSync(join(configDir, "claude-intercept"), { recursive: true }); + writeFileSync(claudeInterceptProxyTokenPath(configDir), "committed-token\n"); + expect(ensureClaudeInterceptProxyToken(configDir)).toBe("committed-token"); +}); + +test("an unreadable token path reads as null instead of throwing", () => { + // A directory (or otherwise unreadable entry) at the token path must degrade to + // "no credential" for read-only status routes rather than erroring the request. + const configDir = dir(); + mkdirSync(claudeInterceptProxyTokenPath(configDir), { recursive: true }); + expect(readClaudeInterceptProxyToken(configDir)).toBeNull(); +}); + +test.skipIf(process.platform === "win32")( + "an existing token with broad permissions is re-pinned owner-only", + () => { + const configDir = dir(); + mkdirSync(join(configDir, "claude-intercept"), { recursive: true }); + const path = claudeInterceptProxyTokenPath(configDir); + writeFileSync(path, "restored-token\n", { mode: 0o644 }); + expect(ensureClaudeInterceptProxyToken(configDir)).toBe("restored-token"); + expect(statSync(path).mode & 0o077).toBe(0); + }, +); + +test("an empty token file is treated as missing and republished", () => { + const configDir = dir(); + mkdirSync(join(configDir, "claude-intercept"), { recursive: true }); + writeFileSync(claudeInterceptProxyTokenPath(configDir), ""); + expect(readClaudeInterceptProxyToken(configDir)).toBeNull(); + const token = ensureClaudeInterceptProxyToken(configDir); + expect(token.length).toBeGreaterThanOrEqual(32); + expect(readClaudeInterceptProxyToken(configDir)).toBe(token); +}); diff --git a/tests/claude-integration/claude-intercept-proxy.test.ts b/tests/claude-integration/claude-intercept-proxy.test.ts index c9b6db3fb33..ca30d5e8b27 100644 --- a/tests/claude-integration/claude-intercept-proxy.test.ts +++ b/tests/claude-integration/claude-intercept-proxy.test.ts @@ -12,6 +12,8 @@ import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claud */ const cleanups: Array<() => Promise | void> = []; +const AUTH_TOKEN = "test-proxy-token"; +const AUTH_HEADER = `Proxy-Authorization: Basic ${Buffer.from(`opencodex:${AUTH_TOKEN}`).toString("base64")}\r\n`; afterAll(async () => { for (const cleanup of cleanups.reverse()) await cleanup(); }); @@ -72,6 +74,7 @@ async function startPair(): Promise<{ proxy: ConnectProxyHandle; ca: ReturnType< cleanups.push(echo.close); const proxy = await startConnectProxy(0, { interceptPort: listener.port!, + authToken: AUTH_TOKEN, dialUpstream: (host, port) => { expect(host).toBe("telemetry.example"); expect(port).toBe(443); @@ -88,7 +91,7 @@ async function viaProxy(proxyPort: number, caPem: string, method: string, path: method, headers: { "content-type": "application/json", "x-api-key": "sk-ant-test" }, body, - proxy: `http://127.0.0.1:${proxyPort}`, + proxy: `http://opencodex:${AUTH_TOKEN}@127.0.0.1:${proxyPort}`, tls: { ca: caPem }, }); return { status: res.status, headers: res.headers, body: await res.text() }; @@ -128,7 +131,7 @@ test("other CONNECT targets are relayed blind, including pipelined bytes after t 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"); + socket.write(`CONNECT telemetry.example:443 HTTP/1.1\r\nHost: telemetry.example:443\r\n${AUTH_HEADER}\r\nhello`); }); let buf = ""; socket.on("data", chunk => { @@ -144,12 +147,18 @@ test("other CONNECT targets are relayed blind, including pipelined bytes after t 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 [::ffff:127.0.0.1]:22 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403"); + expect(await rawRequest(proxy.port, `CONNECT 127.0.0.1:22 HTTP/1.1\r\n${AUTH_HEADER}\r\n`)).toStartWith("HTTP/1.1 403"); + expect(await rawRequest(proxy.port, `CONNECT localhost:443 HTTP/1.1\r\n${AUTH_HEADER}\r\n`)).toStartWith("HTTP/1.1 403"); + expect(await rawRequest(proxy.port, `CONNECT [::ffff:127.0.0.1]:22 HTTP/1.1\r\n${AUTH_HEADER}\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("CONNECT requires the per-install proxy credential", async () => { + const { proxy } = await startPair(); + expect(await rawRequest(proxy.port, "CONNECT api.anthropic.com:443 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 407"); + expect(await rawRequest(proxy.port, "CONNECT api.anthropic.com:443 HTTP/1.1\r\nProxy-Authorization: Basic bad\r\n\r\n")).toStartWith("HTTP/1.1 407"); +}); + 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); @@ -164,10 +173,11 @@ test("a dead upstream yields 502 instead of a hung tunnel", async () => { await dead.close(); const proxy = await startConnectProxy(0, { interceptPort: 1, + authToken: AUTH_TOKEN, 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"); + expect(await rawRequest(proxy.port, `CONNECT gone.example:443 HTTP/1.1\r\n${AUTH_HEADER}\r\n`)).toStartWith("HTTP/1.1 502"); }); test("rewriteInterceptedRequest moves the request onto the loopback origin and keeps path, query and headers", () => { diff --git a/tests/claude-integration/claude-intercept-settings.test.ts b/tests/claude-integration/claude-intercept-settings.test.ts index 81ad3cb6b9a..63c8bae2cd5 100644 --- a/tests/claude-integration/claude-intercept-settings.test.ts +++ b/tests/claude-integration/claude-intercept-settings.test.ts @@ -1,18 +1,19 @@ import { expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { applyClaudeInterceptSettings, buildClaudeInterceptEnv, inspectClaudeInterceptSettings, + migrateClaudeInterceptSettings, 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); +const env = buildClaudeInterceptEnv(8846, CA, "test-token"); function dir(): string { return mkdtempSync(join(tmpdir(), "ocx-intercept-settings-")); @@ -23,7 +24,7 @@ function readSettings(configDir: string): Record { } test("env block shape", () => { - expect(env).toEqual({ HTTPS_PROXY: "http://127.0.0.1:8846", NODE_EXTRA_CA_CERTS: CA }); + expect(env).toEqual({ HTTPS_PROXY: "http://opencodex:test-token@127.0.0.1:8846", NODE_EXTRA_CA_CERTS: CA }); }); test("apply creates settings.json when absent and is idempotent", () => { @@ -65,6 +66,26 @@ test("a previous port is stale and gets rewritten; a foreign proxy is left alone expect(inspectClaudeInterceptSettings(env, otherCa).kind).toBe("foreign"); }); +test("migrate rewrites an owned legacy env but never creates or touches foreign state", () => { + // A pre-auth apply left a bare loopback URL; the CA anchor still marks the env as ours. + const configDir = dir(); + writeFileSync(join(configDir, "settings.json"), JSON.stringify({ theme: "dark", env: { HTTPS_PROXY: "http://127.0.0.1:8846", NODE_EXTRA_CA_CERTS: CA } })); + expect(migrateClaudeInterceptSettings(env, configDir)).toMatchObject({ ok: true, changed: true }); + expect(readSettings(configDir)).toEqual({ theme: "dark", env }); + expect(migrateClaudeInterceptSettings(env, configDir)).toMatchObject({ ok: true, changed: false }); + + // Absent env stays absent — migration must not enable the integration by itself. + const missing = dir(); + expect(migrateClaudeInterceptSettings(env, missing)).toMatchObject({ ok: true, changed: false }); + expect(existsSync(join(missing, "settings.json"))).toBe(false); + + // Foreign env is never overwritten from the runtime path either. + const foreign = dir(); + writeFileSync(join(foreign, "settings.json"), JSON.stringify({ env: { HTTPS_PROXY: "http://corp-proxy:3128" } })); + expect(migrateClaudeInterceptSettings(env, foreign)).toMatchObject({ ok: false, reason: "foreign_env" }); + expect(readSettings(foreign)).toEqual({ env: { HTTPS_PROXY: "http://corp-proxy:3128" } }); +}); + 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 } })); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c27b6771458..9694f806ddf 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1456,6 +1456,7 @@ "api-key-scope-alpha-search.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", "claude-intercept-proxy.test.ts": "claude-integration", + "claude-intercept-proxy-auth.test.ts": "claude-integration", "claude-intercept-settings.test.ts": "claude-integration", "claude-desktop-first-party.test.ts": "claude-integration", "claude-desktop-mode-explanation.test.ts": "claude-integration", diff --git a/tests/server/claude-intercept-integration.test.ts b/tests/server/claude-intercept-integration.test.ts index 6dc046fe1eb..901d835ff5b 100644 --- a/tests/server/claude-intercept-integration.test.ts +++ b/tests/server/claude-intercept-integration.test.ts @@ -5,7 +5,7 @@ * and never touches the router's own routes. */ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../../src/config"; @@ -13,17 +13,23 @@ 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 { ensureClaudeInterceptProxyToken, readClaudeInterceptProxyToken } from "../../src/claude/intercept/proxy-auth"; 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; +const previousClaudeDir = process.env.CLAUDE_CONFIG_DIR; let testDir = ""; +let claudeDir = ""; beforeEach(() => { testDir = mkdtempSync(join(tmpdir(), "ocx-claude-intercept-")); + claudeDir = join(testDir, "claude-config"); + mkdirSync(claudeDir, { recursive: true }); process.env.OPENCODEX_HOME = testDir; + process.env.CLAUDE_CONFIG_DIR = claudeDir; process.env.OPENCODEX_API_AUTH_TOKEN = "public-secret"; }); @@ -32,8 +38,11 @@ afterEach(() => { else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiToken; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + if (previousClaudeDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousClaudeDir; if (testDir && existsSync(testDir)) removeTreeWithRetry(testDir); testDir = ""; + claudeDir = ""; }); async function waitForIntercept(): Promise>> { @@ -75,7 +84,8 @@ test("Messages through CONNECT reach the router; other paths relay to the config 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}`; + const proxyToken = ensureClaudeInterceptProxyToken(testDir); + const proxy = `http://opencodex:${proxyToken}@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 @@ -107,6 +117,37 @@ test("Messages through CONNECT reach the router; other paths relay to the config expect(getClaudeInterceptState()).toBeNull(); }, SERVER_BUDGET_MS); +test("an owned legacy unauthenticated env is migrated on start; nothing else is written", async () => { + // Upgrade path: a pre-auth apply wrote a bare loopback URL. A service-style `ocx start` + // must refresh it before the authenticated proxy answers 407 to every CONNECT. + const caCertPath = claudeInterceptCaCertPath(testDir); + writeFileSync(join(claudeDir, "settings.json"), JSON.stringify({ + env: { HTTPS_PROXY: "http://127.0.0.1:10200", NODE_EXTRA_CA_CERTS: caCertPath }, + })); + 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: { intercept: { port: interceptPort } }, + } as unknown as OcxConfig); + const server = startServer(publicPort); + try { + const state = await waitForIntercept(); + const token = readClaudeInterceptProxyToken(testDir); + expect(token).not.toBeNull(); + const written = JSON.parse(readFileSync(join(claudeDir, "settings.json"), "utf8")) as { env: Record }; + expect(written.env.HTTPS_PROXY).toBe(`http://opencodex:${encodeURIComponent(token!)}@127.0.0.1:${state.proxyPort}`); + expect(written.env.NODE_EXTRA_CA_CERTS).toBe(caCertPath); + } finally { + await server.stop(true); + } +}, SERVER_BUDGET_MS); + test("an ephemeral public port starts no proxy unless intercept.port is explicit", async () => { const base = { hostname: "127.0.0.1",