Skip to content
Closed
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: 5 additions & 0 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,11 @@ applies to it as well. Desktop and CLI catalog rewrites share bounded row and me
if adding routed models would exceed a limit, OpenCodex returns the original Anthropic catalog
unchanged rather than publishing a partial list.

The picker relay limits unfinished uploads separately from its overall request capacity. An upload
closes after 30 seconds without body progress or five minutes total; finish or retry the upload
rather than keeping it open. These deadlines stop when the request body completes and do not
limit a long-lived response or SSE subscription. Excess uploads receive an empty 503 response.

### Use opencodex models from the Desktop Code tab (first-party bindings)

In first-party mode the Code tab's model picker belongs to claude.ai: its rows (Opus 5.5,
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/ko/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,11 @@ Picker 모드를 끄거나 Desktop을 완전히 다시 시작할 때까지 Deskt

Picker 모드는 1P의 일부이므로 [1P 계정 위험](#1p직접-선택)도 그대로 적용돼요.

피커 중계는 미완료 업로드 수를 전체 요청 수와 별도로 제한합니다. 본문 전송이 30초간
진행되지 않거나 총 5분이 지나면 해당 업로드가 종료됩니다. 전송을 완료하거나 다시
시도하세요. 본문 전송이 끝나면 제한 타이머가 해제되므로 긴 응답이나 SSE 구독의
유지시간에는 적용되지 않습니다. 업로드 상한을 넘는 요청에는 본문 없는 503이 반환됩니다.

### Code 탭에서 opencodex 모델 쓰기 (1P 바인딩)

1P 모드에서 Code 탭의 모델 선택기는 claude.ai가 채워요. Opus 5.5, Sonnet 5, Haiku 4.5와
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 @@ -2,6 +2,7 @@
"version": 1,
"root": "tests",
"explicit": {
"claude-picker-upload.test.ts": "claude-integration",
"messages-native-scope.test.ts": "claude-integration",
"cli-log-view-filter.test.ts": "cli", "cli-usage-model-search.test.ts": "cli", "cli-companion-usage.test.ts": "cli", "cli-account-key-quota.test.ts": "cli",
"cli-observe-snapshot.test.ts": "cli", "cli-access-data-plane.test.ts": "cli", "cli-access-key-guard.test.ts": "cli", "cli-access-rename.test.ts": "cli",
Expand Down
79 changes: 76 additions & 3 deletions src/claude/intercept/picker-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* HTTP/1.1 server, unchanged. Upstream is one HTTP/1.1 request per client request on both paths.
* Only the bounded bootstrap response is held; other bodies and upgraded sockets relay as streams.
*/
import { createSecureServer } from "node:http2";
import { constants as h2Constants, createSecureServer } from "node:http2";
import type { Http2ServerRequest, Http2ServerResponse, ServerHttp2Session } from "node:http2";
import { createServer, request as httpsRequest } from "node:https";
import type { IncomingMessage, ServerResponse } from "node:http";
Expand All @@ -34,6 +34,10 @@ export interface PickerListenerOptions {
maxEncodedBytes?: number;
/** Test seam: concurrent upstream requests; production uses PICKER_MAX_ACTIVE_UPSTREAMS. */
maxActiveUpstreams?: number;
/** Test seams: production uses the fixed upload concurrency and deadline limits below. */
maxActiveUploads?: number;
uploadIdleTimeoutMs?: number;
uploadTimeoutMs?: number;
log?: (line: string) => void;
}
export interface PickerListenerHandle { port: number; close(): Promise<void> }
Expand All @@ -47,6 +51,10 @@ export const PICKER_MAX_HEADER_BYTES = 64 * 1024;
* Desktop uses (a handful of SSE subscriptions plus bursts of ordinary calls).
*/
export const PICKER_MAX_ACTIVE_UPSTREAMS = 256;
/** Reserve half the aggregate capacity for requests whose upload is already complete. */
export const PICKER_MAX_ACTIVE_UPLOADS = 128;
export const PICKER_UPLOAD_IDLE_TIMEOUT_MS = 30_000;
export const PICKER_UPLOAD_TIMEOUT_MS = 300_000;
/** Advertised per HTTP/2 connection; Chromium queues further requests rather than failing them. */
export const PICKER_MAX_CONCURRENT_STREAMS = 100;
/** A connection that has not sent a complete ClientHello by then is dropped. */
Expand Down Expand Up @@ -152,7 +160,11 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
const upstream = options.upstream ?? { host: "claude.ai", port: 443, servername: "claude.ai" };
const cap = options.maxEncodedBytes ?? BOOTSTRAP_MAX_ENCODED_BYTES;
const maxActiveUpstreams = options.maxActiveUpstreams ?? PICKER_MAX_ACTIVE_UPSTREAMS;
const maxActiveUploads = options.maxActiveUploads ?? PICKER_MAX_ACTIVE_UPLOADS;
const uploadIdleMs = options.uploadIdleTimeoutMs ?? PICKER_UPLOAD_IDLE_TIMEOUT_MS;
const uploadMs = options.uploadTimeoutMs ?? PICKER_UPLOAD_TIMEOUT_MS;
let activeUpstreams = 0;
let activeUploads = 0;
const upgrades = new Set<Duplex>();
const spliced = new Set<Socket>();
const h2Sockets = new Set<Duplex>();
Expand Down Expand Up @@ -184,14 +196,33 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
// reason phrase differs, and sendHead branches on it.
const res = relayRes as ServerResponse;
const h2 = req.httpVersionMajor === 2;
// Bun's h2 compat response emits finish only after both stream halves close.
const responseWritable = h2 ? (req as Http2ServerRequest).stream : res;
// Framing, not method, determines whether a peer can keep sending a body. A bodyless h2
// request carries END_STREAM on its headers; HTTP/1.1 needs length or chunked framing.
const uploading = h2 ? !(req as Http2ServerRequest).stream.endAfterHeaders
: req.headers["transfer-encoding"] !== undefined || Number(req.headers["content-length"] ?? 0) > 0;
const closeInput = () => {
if (h2) {
const stream = (req as Http2ServerRequest).stream;
if (!stream.closed && !stream.destroyed) stream.close(h2Constants.NGHTTP2_CANCEL);
} else if (!req.destroyed) req.destroy();
};
// Refusals answer with an empty response and a fixed log line that carries no request data.
const refuse = (status: 400 | 503) => {
res.writeHead(status, { "Content-Length": "0" });
// Do not leave a refused, still-uploading stream behind after delivering its empty reply.
if (uploading) {
req.once("error", () => res.destroy());
responseWritable.once("finish", closeInput);
}
res.writeHead(status, { "Content-Length": "0", ...(!h2 && uploading ? { Connection: "close" } : {}) });
res.end();
options.log?.(`picker request refused ${status}`);
};
if (h2 && !isRelayableH2Target(req.method, req.url)) { refuse(400); return; }
if (activeUpstreams >= maxActiveUpstreams) { refuse(503); return; }
if (activeUpstreams >= maxActiveUpstreams || (uploading && activeUploads >= maxActiveUploads)) {
refuse(503); return;
}
const method = req.method ?? "GET";
let pathname: string;
// A target like "//[" reads as an authority and throws; refuse it like any unrelayable target.
Expand All @@ -215,13 +246,15 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
const headers = upstreamRequestHeaders(req, omit);
if (bootstrap) headers.push("Accept-Encoding", narrowBootstrapAcceptEncoding());
let upReq: ReturnType<typeof httpsRequest>;
let upstreamResponded = false;
try {
// The HTTP/1.1 client validates the method, path and header values synchronously.
upReq = httpsRequest({
host: upstream.host, port: upstream.port, servername: upstream.servername,
ca: upstream.ca, rejectUnauthorized: true, agent: false,
method, path: req.url, headers, maxHeaderSize: PICKER_MAX_HEADER_BYTES,
}, upRes => {
upstreamResponded = true;
const status = upRes.statusCode ?? 502;
const originalHeaders = filteredHeaders(upRes.rawHeaders);
const sendHead = (raw: string[]) => {
Expand Down Expand Up @@ -293,6 +326,46 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
// Bun's compat response skips its close event for a HEAD reset before end(); the stream's own
// close always fires. Destroying an upstream request that already completed is a no-op.
if (h2) (req as Http2ServerRequest).stream.once("close", onClientClose);
if (uploading) {
activeUploads++;
let uploadFinished = false;
const finishUpload = () => {
if (uploadFinished) return;
uploadFinished = true;
activeUploads--;
clearTimeout(idleTimer);
clearTimeout(totalTimer);
req.off("data", progress);
};
const expireUpload = (kind: "idle" | "deadline") => {
if (uploadFinished) return;
// Cancellation is stream-scoped on h2. Never impose a response/SSE lifetime limit.
clientGone = true;
req.unpipe(upReq);
upReq.destroy();
closeInput();
finishUpload();
options.log?.(`picker upload expired ${kind}`);
};
const idleTimer = setTimeout(() => expireUpload("idle"), uploadIdleMs);
const totalTimer = setTimeout(() => expireUpload("deadline"), uploadMs);
idleTimer.unref(); totalTimer.unref();
const progress = (chunk: Buffer) => { if (chunk.length > 0) idleTimer.refresh(); };
req.on("data", progress);
req.once("end", finishUpload);
req.once("close", finishUpload);
// Upstream close can precede draining a successful reply to a slow downstream. Keep
// upload deadlines armed until that writable finishes, then cancel only its unread input.
const stopUnfinishedUpload = () => {
if (!uploadFinished) { closeInput(); finishUpload(); }
};
upReq.once("close", () => {
if (!upstreamResponded && !res.writableEnded) stopUnfinishedUpload();
});
responseWritable.once("finish", stopUnfinishedUpload);
res.once("close", stopUnfinishedUpload);
if (h2) (req as Http2ServerRequest).stream.once("close", finishUpload);
}
req.pipe(upReq);
};
server.on("request", relay);
Expand Down
22 changes: 18 additions & 4 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,9 @@ Production always uses the configured adjacent ports. Lifecycle tests inject onl
factory and bind the real handlers on kernel-assigned ports; this preserves request handling while
avoiding the false reservation created by probing and closing a port pair before the ephemeral TLS
listener starts. The injected factory does not change production port selection.
The User-Agent is a routing hint, not a trust boundary: a client that fakes it reaches only what
any local process already reaches (the `api.anthropic.com` intercept is on the Claude Code proxy
too; the `claude.ai` relay verifies upstream and adds no credential) and breaks only its own TLS,
because each terminator presents a certificate only its intended client trusts. `claude.ai:443` is
The User-Agent is a routing hint, not client authentication. Each terminator presents the
certificate intended for that client; the `claude.ai` relay verifies upstream and adds no credential.
Listener-wide concurrency and upload-lifetime bounds protect the shared relay capacity separately. `claude.ai:443` is
intercepted only while the runtime's cached
decision is armed: macOS, persisted resolved Desktop mode first-party, Desktop intent on,
`claudeCode.intercept.picker !== false`, no disarm latch, listener up, and the current picker CA
Expand All @@ -214,6 +213,21 @@ HTTP/1.1 request per client request. Incoming requests and ordinary upstream res
a 64 KiB header allowance for browser session cookies; Bun enforces the HTTP/2 inbound bound
natively, counting name + value + 32 bytes per field and rejecting an oversized stream with
`RST_STREAM ENHANCE_YOUR_CALM` before the request handler runs.
The relay retains a 256-request aggregate ceiling and allows at most 128 unfinished uploads
across both HTTP versions and all sessions, leaving headroom for bodyless requests under the aggregate ceiling.
Upload framing, not the method, selects that sub-budget. Each unfinished upload has a 30-second
idle deadline and a five-minute absolute deadline; progress refreshes only the former. Expiry
cancels its upstream and only its h2 stream (or its HTTP/1.1 connection). Completion clears
upload timers and frees upload admission without timing out a long response/SSE stream.
Early replies cancel unfinished input only after the downstream writable finishes; upstream
request closure alone does not prove a buffered reply was delivered. Upload deadlines remain
armed during that wait. Cancellation, failure and shutdown release upload capacity once.
Rejected uploads close after their empty reply's writable finishes, using the underlying h2
stream's finish event rather than its compatibility response's stream-close-dependent finish.
These bounds do not authenticate clients or guarantee availability under sustained churn or
exhaustion by completed requests. `tests/claude-integration/claude-picker-upload.test.ts` covers
cross-session admission, both protocol deadlines, progress, headers-only refusals, byte-exact
early replies under backpressure, SSE admission reuse, failure and shutdown cleanup.
The picker CA (`picker-ca.ts`) carries critical
name constraints permitting only `claude.ai` and excluding every IPv4 and IPv6 address. Its exportable
signing identity is protected by the OS credential store and scoped to the canonical config directory;
Expand Down
Loading
Loading