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
11 changes: 6 additions & 5 deletions .claude/docs/launch-and-wrapper.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,12 @@ out from under a running child.

## Outbound proxy

`src/outbound-proxy.ts`. When `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` are set in clodex's environment,
`installOutboundProxyDispatcher()` (called at the top of `main()`) installs undici's
`EnvHttpProxyAgent` as the global fetch dispatcher, so every fetch-based call (OAuth device
flow/refresh, model-list and models.dev refresh, AI-SDK upstream calls) honors them. Without proxy
env vars it is a no-op.
`src/outbound-proxy.ts`. `installOutboundDispatcher()` (called at the top of `main()`) always
installs the package undici dispatcher globally with HTTP/2 disabled. It uses `EnvHttpProxyAgent`
when `HTTP_PROXY`/`HTTPS_PROXY` are configured, so every fetch-based call (OAuth device flow/refresh,
model-list and models.dev refresh, AI-SDK upstream calls) honors those variables and `NO_PROXY`;
otherwise it uses a direct `Agent`. Pinning fetch to HTTP/1.1 prevents Node 26's bundled undici 8
from retaining a destroyed pooled HTTP/2 session and failing every later request to that origin.

Transports that do not use the undici dispatcher share the same resolver: the `ws`-based OAuth
Responses WebSocket gets an `https-proxy-agent` CONNECT tunnel via `outboundWsProxyAgent()`, and the
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ These bite from outside the subsystem that owns them, so they live here rather t
- **`node-gyp-build` is a deliberate direct dependency that no clodex source imports.** Routine
"remove the unused dependency" cleanup breaks fresh installs. Reason in
`.claude/docs/patcher.md`.
- **clodex always installs package undici's global fetch dispatcher with HTTP/2 disabled**
(`installOutboundDispatcher()` at the top of `main()`), proxy env or not. Node 26's bundled
undici 8 negotiates HTTP/2 and keeps a dead pooled session forever after a fatal TLS alert, so
every request to that origin fails until restart (#233); Node 24 CI cannot see that. Do not gate
the install on proxy env again and do not drop the explicit `allowH2: false` because "undici 7
already defaults to it" — the option is what survives an undici 8 bump.
- **Every AI SDK generation entry point must resolve its timeout and retry budget through
`src/upstream-retry.ts`.** Anthropic- and OpenAI-format `streamText` consumers abort at idle and
total deadlines; `generateText` consumers abort at total only. Cancellation remains cooperative
Expand Down
9 changes: 5 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ import {
startConfiguredHttpProxy,
} from './http-proxy/index.js';
import { runPatchCommand, runLaunchPatchCheck } from './patcher.js';
import { installOutboundProxyDispatcher } from './outbound-proxy.js';
import { installOutboundDispatcher } from './outbound-proxy.js';
const STARTER_CLAUDE_FLAGS = new Set(['--dry-run', '--trace', '--fast', '--endpoint', '--proxy', '--save-mode', '--help', '-h', '--version', '-v']);
const CLODEX_LAUNCH_FLAGS = new Set(['--provider', '--model', '--context']);

Expand Down Expand Up @@ -1643,9 +1643,10 @@ export async function runClaudeCommand(parsed: ParsedArgs): Promise<number> {
}

export async function main(args: string[] = process.argv.slice(2)): Promise<number> {
// Honor HTTP_PROXY/HTTPS_PROXY/NO_PROXY for clodex's own outbound calls
// (no-op when no proxy env var is set; never throws).
await installOutboundProxyDispatcher();
// Pin clodex's fetch calls to HTTP/1.1 so Node 26 cannot retain a destroyed
// HTTP/2 session, while still honoring HTTP_PROXY/HTTPS_PROXY/NO_PROXY.
// Installation is idempotent and warns rather than throwing on failure.
await installOutboundDispatcher();

const parsed = parseArgs(args);

Expand Down
4 changes: 4 additions & 0 deletions src/http-proxy/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,10 @@ export async function startHttpProxy(options: HttpProxyOptions): Promise<HttpPro
return;
}

// Node's http server drops its own 'error' listener once it hands the
// socket to 'connect'. Without a replacement a client reset -- or an EPIPE
// on the 400 write below -- surfaces as an uncaughtException (issue #233).
clientSocket.once('error', () => clientSocket.destroy());
const target = authorityParts(req.url ?? '');
if (!target) {
clientSocket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
Expand Down
37 changes: 22 additions & 15 deletions src/outbound-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
// src/outbound-proxy.ts — make clodex's OWN outbound network calls honor
// HTTP_PROXY / HTTPS_PROXY / NO_PROXY.
// src/outbound-proxy.ts — control clodex's OWN outbound fetch transport and
// honor HTTP_PROXY / HTTPS_PROXY / NO_PROXY.
//
// Node's fetch (undici) ignores proxy env vars by default, so OAuth device
// flow/token refresh, model-list refresh, models.dev fetches, and upstream
// OpenAI calls made through the AI SDK would all bypass a corporate proxy.
// installOutboundProxyDispatcher() installs undici's EnvHttpProxyAgent as the
// global fetch dispatcher — but only when a proxy env var is actually set, so
// proxy-less environments are completely unaffected.
// installOutboundDispatcher() installs the package undici dispatcher
// globally: EnvHttpProxyAgent when proxy env is configured, or Agent otherwise.
//
// Node 26's bundled undici 8 turns on HTTP/2 in fetch(). If a peer tears down a
// pooled h2 session with a fatal TLS alert, Node marks it destroyed but never
// closes it, undici never evicts it, and every later request to that origin
// fails immediately with ERR_HTTP2_INVALID_SESSION until restart (issue #233).
// Both dispatcher variants therefore disable HTTP/2 explicitly.
//
// The OAuth Responses WebSocket transport and raw first-party passthrough do
// not go through the undici dispatcher. outboundHttpProxyAgent() builds an
Expand Down Expand Up @@ -109,27 +114,29 @@ export function proxyUrlTargetsListener(
let dispatcherInstalled = false;

/** Reset the install-once latch (tests only). */
export function resetOutboundProxyDispatcherForTests(): void {
export function resetOutboundDispatcherForTests(): void {
dispatcherInstalled = false;
}

/**
* Install undici's EnvHttpProxyAgent as the global fetch dispatcher when any
* proxy env var is set. Idempotent. A failure warns and falls back to direct
* connections — it must never break the CLI.
* Install package undici's global fetch dispatcher with HTTP/2 disabled,
* honoring proxy env vars when present. Idempotent. A failure warns and keeps
* Node's existing dispatcher — it must never break the CLI.
*/
export async function installOutboundProxyDispatcher(): Promise<boolean> {
export async function installOutboundDispatcher(): Promise<boolean> {
if (dispatcherInstalled) return true;
if (!hasOutboundProxyEnv()) return false;
try {
const { EnvHttpProxyAgent, setGlobalDispatcher } = await import('undici');
setGlobalDispatcher(new EnvHttpProxyAgent());
const { Agent, EnvHttpProxyAgent, setGlobalDispatcher } = await import('undici');
const dispatcher = hasOutboundProxyEnv()
? new EnvHttpProxyAgent({ allowH2: false })
: new Agent({ allowH2: false });
setGlobalDispatcher(dispatcher);
dispatcherInstalled = true;
return true;
} catch (err) {
console.error(
'clodex: HTTP(S)_PROXY is set but installing the outbound proxy dispatcher failed; '
+ `using direct connections (${err instanceof Error ? err.message : String(err)})`,
'clodex: installing the outbound fetch dispatcher failed; '
+ `continuing with Node's existing dispatcher (${err instanceof Error ? err.message : String(err)})`,
);
return false;
}
Expand Down
16 changes: 15 additions & 1 deletion src/upstream-forward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,21 @@ export function anthropicUpstreamHeaders(

export class UpstreamUnreachableError extends Error {
constructor(cause: unknown) {
super(`Upstream unreachable: ${cause instanceof Error ? cause.message : String(cause)}`);
const detail = cause instanceof Error ? cause.message : String(cause);
const directCode = cause !== null && typeof cause === 'object'
? (cause as { code?: unknown }).code
: undefined;
const nestedCause = cause instanceof Error ? cause.cause : undefined;
const nestedCode = nestedCause !== null && typeof nestedCause === 'object'
? (nestedCause as { code?: unknown }).code
: undefined;
const code = typeof directCode === 'string'
? directCode
: typeof nestedCode === 'string' ? nestedCode : undefined;
const detailWithCode = code && !detail.includes(code)
? (detail ? `${detail} (${code})` : code)
: detail;
super(`Upstream unreachable: ${detailWithCode}`, { cause });
this.name = 'UpstreamUnreachableError';
}
}
Expand Down
77 changes: 77 additions & 0 deletions tests/http-proxy-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,83 @@ describe('selective HTTP proxy', () => {
}
});

it('handles a client reset in a passthrough CONNECT tunnel and tears down upstream', async () => {
let acceptUpstream!: (socket: net.Socket) => void;
const upstreamAccepted = new Promise<net.Socket>(resolve => { acceptUpstream = resolve; });
const upstreamServer = net.createServer(socket => {
socket.on('error', () => {});
socket.on('data', data => socket.write(data));
acceptUpstream(socket);
});
const upstreamPort = await listen(upstreamServer);
const proxy = await startHttpProxy({ routes: [] });
const client = net.connect(proxy.port, proxy.host);
client.on('error', () => {});
const uncaught: Error[] = [];
const onUncaught = (error: Error): void => { uncaught.push(error); };
process.prependListener('uncaughtException', onUncaught);
let upstreamSocket: net.Socket | undefined;

try {
await once(client, 'connect');
client.write(
`CONNECT 127.0.0.1:${upstreamPort} HTTP/1.1\r\n`
+ `Host: 127.0.0.1:${upstreamPort}\r\n\r\n`,
);
const [established] = await once(client, 'data') as [Buffer];
expect(established.toString()).toContain('200 Connection Established');
upstreamSocket = await upstreamAccepted;

client.write('ping');
const [echoed] = await once(client, 'data') as [Buffer];
expect(echoed.toString()).toBe('ping');
const upstreamClosed = once(upstreamSocket, 'close');
client.resetAndDestroy();
await Promise.race([
upstreamClosed,
new Promise((_, reject) => setTimeout(
() => reject(new Error('upstream tunnel socket did not close after client reset')),
1_000,
)),
]);
await new Promise(resolve => setImmediate(resolve));

expect(uncaught).toEqual([]);
expect(upstreamSocket.destroyed).toBe(true);
} finally {
process.off('uncaughtException', onUncaught);
client.destroy();
upstreamSocket?.destroy();
await proxy.close();
await new Promise<void>(resolve => upstreamServer.close(() => resolve()));
}
});

it('handles a client reset while answering a malformed CONNECT authority', async () => {
const proxy = await startHttpProxy({ routes: [] });
const client = net.connect(proxy.port, proxy.host);
client.on('error', () => {});
const uncaught: Error[] = [];
const onUncaught = (error: Error): void => { uncaught.push(error); };
process.prependListener('uncaughtException', onUncaught);

try {
await once(client, 'connect');
// '[' is not a valid authority, so the handler takes the 400 branch.
client.write('CONNECT [ HTTP/1.1\r\nHost: x\r\n\r\n');
// Reset before the 400 is written so the write hits a dead socket.
await new Promise(resolve => setImmediate(resolve));
client.resetAndDestroy();
await new Promise(resolve => setTimeout(resolve, 200));

expect(uncaught).toEqual([]);
} finally {
process.off('uncaughtException', onUncaught);
client.destroy();
await proxy.close();
}
});

it('forwards first-party request bytes and auth unchanged', async () => {
const certificates = ensureHttpProxyCertificates();
const inferenceLogPath = join(testHome, 'anthropic-inference.jsonl');
Expand Down
Loading
Loading