Skip to content
Merged
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
14 changes: 4 additions & 10 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"version": 1,
"root": "tests",
"explicit": {
"messages-native-scope.test.ts": "claude-integration",
"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",
"cli-access-audio.test.ts": "cli", "cli-access-audio-live.test.ts": "cli", "cli-log-follow.test.ts": "cli", "cli-injection-follow.test.ts": "cli",
Expand Down Expand Up @@ -370,15 +370,9 @@
"claude-desktop-policy.test.ts": "claude-integration",
"claude-cli-picker.test.ts": "claude-integration",
"claude-cli-picker-surface.test.ts": "claude-integration",
"claude-picker-bootstrap.test.ts": "claude-integration",
"claude-picker-ca.test.ts": "claude-integration",
"claude-picker-ca-store.test.ts": "claude-integration",
"claude-picker-listener.test.ts": "claude-integration",
"claude-picker-models.test.ts": "claude-integration",
"claude-picker-recovery.test.ts": "claude-integration",
"claude-picker-runtime.test.ts": "claude-integration",
"claude-picker-startup.test.ts": "claude-integration",
"claude-picker-trust.test.ts": "claude-integration",
"claude-picker-bootstrap.test.ts": "claude-integration", "claude-picker-ca.test.ts": "claude-integration", "claude-picker-ca-store.test.ts": "claude-integration",
"claude-picker-listener.test.ts": "claude-integration", "claude-picker-models.test.ts": "claude-integration", "claude-picker-recovery.test.ts": "claude-integration",
"claude-picker-runtime.test.ts": "claude-integration", "claude-picker-startup.test.ts": "claude-integration", "claude-picker-trust.test.ts": "claude-integration",
"claude-desktop-remote-hub.test.ts": "claude-integration",
"claude-dotenv-provenance-transport.test.ts": "claude-integration",
"claude-estimate-projection.test.ts": "claude-integration",
Expand Down
75 changes: 71 additions & 4 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 Down Expand Up @@ -184,9 +184,35 @@ 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 = (completeReply = false) => {
if (h2) {
const stream = (req as Http2ServerRequest).stream;
if (!stream.closed && !stream.destroyed) {
stream.close(completeReply ? h2Constants.NGHTTP2_NO_ERROR : h2Constants.NGHTTP2_CANCEL);
}
} else if (!req.destroyed) {
if (completeReply) {
// finish is not peer receipt: destroy can truncate bytes still queued in TLS/TCP.
// Discard further input and flush a graceful FIN without waiting for body completion.
req.resume();
req.socket.end();
} else 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.complete) {
req.once("error", () => res.destroy());
responseWritable.once("finish", () => closeInput(true));
}
res.writeHead(status, { "Content-Length": "0", ...(!h2 && uploading && !req.complete ? { Connection: "close" } : {}) });
res.end();
options.log?.(`picker request refused ${status}`);
};
Expand All @@ -209,26 +235,34 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
const fail = () => {
if (clientGone) return;
if (res.headersSent) res.destroy();
else { res.writeHead(502, { "Content-Length": "0" }); res.end(); log(502); }
else {
res.writeHead(502, { "Content-Length": "0", ...(!h2 && uploading && !req.complete ? { Connection: "close" } : {}) });
res.end(); log(502);
}
};
const omit = bootstrap ? new Set(["accept-encoding"]) : new Set<string>();
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[]) => {
if (res.headersSent) return;
// HTTP/2 has no reason phrase; its compat writeHead takes the same flat raw header array.
if (h2) (relayRes as Http2ServerResponse).writeHead(status, raw as unknown as Record<string, string>);
else res.writeHead(status, upRes.statusMessage, raw);
else {
if (uploading && !req.complete) raw.push("Connection", "close");
res.writeHead(status, upRes.statusMessage, raw);
}
log(status);
};
upRes.on("error", fail);
Expand Down Expand Up @@ -293,6 +327,39 @@ 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) {
let uploadFinished = false;
const finishUpload = () => {
if (uploadFinished) return;
uploadFinished = true;
req.off("end", finishUpload);
req.off("close", finishUpload);
upReq.off("close", onUpstreamClose);
responseWritable.off("finish", onResponseFinish);
res.off("close", stopUnfinishedUpload);
if (h2) (req as Http2ServerRequest).stream.off("close", finishUpload);
};
// Upstream close can precede draining a successful reply to a slow downstream.
// Stop relaying input only once that reply's writable finishes or closes.
const stopUnfinishedUpload = (completeReply = false) => {
if (!uploadFinished) {
req.unpipe(upReq);
closeInput(completeReply);
finishUpload();
upReq.destroy();
}
};
const onResponseFinish = () => stopUnfinishedUpload(true);
const onUpstreamClose = () => {
if (!upstreamResponded && !res.writableEnded) stopUnfinishedUpload();
};
req.once("end", finishUpload);
req.once("close", finishUpload);
upReq.once("close", onUpstreamClose);
responseWritable.once("finish", onResponseFinish);
res.once("close", stopUnfinishedUpload);
if (h2) (req as Http2ServerRequest).stream.once("close", finishUpload);
}
req.pipe(upReq);
};
server.on("request", relay);
Expand Down
20 changes: 20 additions & 0 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,26 @@ 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 its 256-request aggregate ceiling across both HTTP versions and all sessions.
Upload framing, not the method, determines whether input cleanup is needed: HTTP/2 headers
without END_STREAM, or HTTP/1.1 transfer encoding or a positive Content-Length.
Rejected unfinished uploads close after their empty reply's writable finishes; HTTP/2 uses the
underlying stream's finish event rather than the compatibility response's finish event, which
waits for both stream halves to close. HTTP/1.1 refusals advertise `Connection: close` only while input is unfinished.
Early replies stop relaying unfinished input only after the downstream writable finishes or the
response closes; upstream request closure alone does not prove a buffered reply was delivered.
After a complete response, HTTP/2 closes only that stream with `RST_STREAM NO_ERROR` (RFC 9113
§8.1), including a complete generated 502. Without a complete downstream response, cleanup uses
`CANCEL` for a premature downstream close or an upstream close without a reply.
HTTP/1.1 refusals, generated 502s and early replies advertise `Connection: close` only while input is unfinished; after the writable finishes,
the listener unpipes the upstream upload, reads and discards remaining input, and ends the socket
gracefully so queued response bytes flush. Errors and premature downstream closes still destroy
the request. Draining adds no upload deadline or byte cap and does not wait for request-body
completion before ending the response side. Body completion removes the input-cleanup listeners,
leaving long-lived responses and SSE subscriptions independent of upload cleanup. Completed HTTP/1.1 uploads retain keep-alive on generated 502s and can reuse the same client socket.
`tests/claude-integration/claude-picker-upload.test.ts` covers headers-only refusals on both
protocols, byte-exact early replies under backpressure, completed uploads with SSE, cancellation,
failure and shutdown cleanup, and healthy sibling streams.
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