Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs-site/src/content/docs/fr/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port public+100>` et
`NODE_EXTRA_CA_CERTS=~/.opencodex/claude-intercept/ca.pem`. Seuls Claude Code lancé par Desktop
`HTTPS_PROXY=http://opencodex:<jeton propre à l'installation>@127.0.0.1:<port public+100>` 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
Expand Down
5 changes: 3 additions & 2 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<per-install token>@127.0.0.1:10200",
"NODE_EXTRA_CA_CERTS": "<home>/.opencodex/claude-intercept/ca.pem"
}
}
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs-site/src/content/docs/ja/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 の信頼ストアには
Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/ko/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
신뢰 저장소에 설치되지 않습니다.
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 18 additions & 5 deletions src/claude/desktop-first-party.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<OcxConfig, "claudeCode" | "port">,
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<OcxConfig, "claudeCode" | "port">,
opencodexConfigDir = getConfigDir(),
): DesktopFirstPartyTarget {
return firstPartyTarget(config, opencodexConfigDir, ensureClaudeInterceptProxyToken(opencodexConfigDir));
}

export interface DesktopFirstPartyInspection {
Expand All @@ -128,7 +137,11 @@ export function inspectDesktopFirstParty(
config: Pick<OcxConfig, "claudeCode" | "port" | "runtimeRole">,
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),
Expand Down
21 changes: 19 additions & 2 deletions src/claude/intercept/connect-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { timingSafeEqual } from "node:crypto";
import { BlockList, createServer, connect, isIP, type Server, type Socket } from "node:net";

/**
Expand All @@ -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. */
Expand Down Expand Up @@ -90,7 +93,15 @@ function splice(client: Socket, upstream: Socket, pending: Uint8Array): void {
upstream.pipe(client);
}

function handleConnection(socket: Socket, options: Required<Pick<ConnectProxyOptions, "interceptPort" | "interceptHosts" | "dialUpstream">>): 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<Pick<ConnectProxyOptions, "interceptPort" | "interceptHosts" | "dialUpstream" | "authToken">>): void {
let head: Buffer = Buffer.alloc(0);
socket.on("error", () => socket.destroy());
socket.setTimeout(HEAD_TIMEOUT_MS, () => respond(socket, 408, "Request Timeout"));
Expand All @@ -107,13 +118,18 @@ function handleConnection(socket: Socket, options: Required<Pick<ConnectProxyOpt
}
socket.off("data", onData);
socket.pause();
const target = parseConnectRequestLine(head.subarray(0, end).toString("latin1"));
const requestHead = head.subarray(0, end).toString("latin1");
const target = parseConnectRequestLine(requestHead);
// 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 (!proxyAuthorized(requestHead, options.authToken)) {
respond(socket, 407, "Proxy Authentication Required");
return;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
if (isLoopbackTarget(target.host)) {
respond(socket, 403, "Forbidden");
return;
Expand Down Expand Up @@ -152,6 +168,7 @@ function handleConnection(socket: Socket, options: Required<Pick<ConnectProxyOpt
export function startConnectProxy(port: number, options: ConnectProxyOptions): Promise<ConnectProxyHandle> {
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 })),
};
Expand Down
87 changes: 87 additions & 0 deletions src/claude/intercept/proxy-auth.ts
Original file line number Diff line number Diff line change
@@ -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.
}
}
}
13 changes: 13 additions & 0 deletions src/claude/intercept/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -68,7 +70,17 @@ export async function startClaudeIntercept<T>(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<T>({
leaf,
dispatch: options.dispatch,
Expand All @@ -79,6 +91,7 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
try {
proxy = await startConnectProxy(claudeInterceptProxyPort(options.config, options.publicPort), {
interceptPort: listener.port!,
authToken,
});
} catch (error) {
await listener.stop(true);
Expand Down
Loading
Loading