mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 12:50:54 -04:00
fix(proxy): restore direct fetch routing when clearing proxies (#2406)
* fix(proxy): restore direct fetch routing when clearing proxies * fix(proxy): preserve dispatcher ownership and lazy setup * fix(proxy): retire invalidated global TLS dispatchers * fix(proxy): preserve dispatchers retained by fetch callers
This commit is contained in:
+256
-36
@@ -14,6 +14,7 @@ const run = promisify(execFile)
|
||||
const { key, cert } = createTestTlsFixture()
|
||||
let directory: string
|
||||
let modulePath: string
|
||||
let proxyModulePath: string
|
||||
|
||||
beforeAll(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'proxy-no-proxy-'))
|
||||
@@ -23,15 +24,16 @@ beforeAll(async () => {
|
||||
writeFile(join(directory, 'client.key'), key),
|
||||
])
|
||||
modulePath = join(directory, 'network.mjs')
|
||||
proxyModulePath = join(directory, 'proxy.mjs')
|
||||
const entrypoint = join(directory, 'entry.ts')
|
||||
await writeFile(entrypoint, `
|
||||
export { getProxyAgent, getNoProxy, shouldBypassProxy, configureGlobalAgents, clearProxyCache } from ${JSON.stringify(join(import.meta.dir, 'proxy.ts'))};
|
||||
export { getProxyAgent, getProxyFetchOptions, getNoProxy, shouldBypassProxy, configureGlobalAgents, clearProxyCache } from ${JSON.stringify(join(import.meta.dir, 'proxy.ts'))};
|
||||
export { StructuredIO } from ${JSON.stringify(join(import.meta.dir, '../cli/structuredIO.ts'))};
|
||||
export { getCACertificates } from ${JSON.stringify(join(import.meta.dir, 'caCerts.ts'))};
|
||||
export { getMTLSConfig, getMTLSAgent } from ${JSON.stringify(join(import.meta.dir, 'mtls.ts'))};
|
||||
export { clearCACertsCache, getCACertificates } from ${JSON.stringify(join(import.meta.dir, 'caCerts.ts'))};
|
||||
export { clearMTLSCache, getMTLSConfig, getMTLSAgent } from ${JSON.stringify(join(import.meta.dir, 'mtls.ts'))};
|
||||
`)
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entrypoint],
|
||||
entrypoints: [entrypoint, join(import.meta.dir, 'proxy.ts')],
|
||||
target: 'node',
|
||||
plugins: [{
|
||||
name: 'node-proxy-environment-fixture',
|
||||
@@ -71,6 +73,7 @@ beforeAll(async () => {
|
||||
})
|
||||
if (!result.success) throw new AggregateError(result.logs, 'Could not build proxy environment fixture')
|
||||
await writeFile(modulePath, await result.outputs[0].text())
|
||||
await writeFile(proxyModulePath, await result.outputs[1].text())
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -80,6 +83,8 @@ afterAll(async () => {
|
||||
test.each([
|
||||
'combined certificate and no_proxy',
|
||||
'certificate-only',
|
||||
'proxy-clear-ca',
|
||||
'proxy-clear-mtls',
|
||||
] as const)('structured I/O refreshes certificate caches with a %s update', async mode => {
|
||||
const caPath = join(directory, 'ca.pem')
|
||||
const certPath = join(directory, 'client.pem')
|
||||
@@ -89,7 +94,7 @@ test.each([
|
||||
let requests = 0
|
||||
let authorizedRequests = 0
|
||||
const target = createHttpsServer({
|
||||
key, cert, ca: cert, requestCert: true, rejectUnauthorized: true,
|
||||
key, cert, ca: cert, requestCert: mode !== 'proxy-clear-ca', rejectUnauthorized: true,
|
||||
}, (request, response) => {
|
||||
requests++
|
||||
if ('authorized' in request.socket && request.socket.authorized) authorizedRequests++
|
||||
@@ -150,6 +155,7 @@ test.each([
|
||||
const previousGlobalDispatcher = getGlobalDispatcher();
|
||||
network.configureGlobalAgents();
|
||||
const agents = new Set([cached]);
|
||||
const tlsAgents = new Set();
|
||||
const update = async variables => {
|
||||
const io = new network.StructuredIO((async function* () {
|
||||
yield JSON.stringify({ type: 'update_environment_variables', variables }) + '\\n';
|
||||
@@ -161,38 +167,53 @@ test.each([
|
||||
try {
|
||||
await update({
|
||||
NODE_EXTRA_CA_CERTS: config.caPath,
|
||||
CLAUDE_CODE_CLIENT_CERT: config.certPath,
|
||||
CLAUDE_CODE_CLIENT_KEY: config.keyPath,
|
||||
...(config.mode !== 'proxy-clear-ca' && {
|
||||
CLAUDE_CODE_CLIENT_CERT: config.certPath,
|
||||
CLAUDE_CODE_CLIENT_KEY: config.keyPath,
|
||||
}),
|
||||
...(config.mode !== 'certificate-only' && { no_proxy: 'unused.invalid' }),
|
||||
});
|
||||
agents.add(getGlobalDispatcher());
|
||||
const expectedCert = readFileSync(config.certPath, 'utf8');
|
||||
const expectedKey = readFileSync(config.keyPath, 'utf8');
|
||||
assert.deepEqual(network.getMTLSConfig(), { cert: expectedCert, key: expectedKey });
|
||||
assert.ok(network.getCACertificates().includes(readFileSync(config.caPath, 'utf8')));
|
||||
assert.equal(network.getMTLSAgent().options.cert, expectedCert);
|
||||
assert.equal(network.getMTLSAgent().options.key, expectedKey);
|
||||
for (const noProxy of ['unused.invalid', '*.127.0.0.1']) {
|
||||
if (network.getNoProxy() !== noProxy) await update({ no_proxy: noProxy });
|
||||
const dispatcher = network.getProxyAgent(config.proxy);
|
||||
agents.add(dispatcher);
|
||||
assert.notEqual(dispatcher, cached);
|
||||
for (const options of [{ dispatcher }, {}]) {
|
||||
const response = await fetch(config.url, options);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(await response.text(), 'authenticated response');
|
||||
tlsAgents.add(network.getMTLSAgent());
|
||||
if (config.mode.startsWith('proxy-clear')) {
|
||||
const proxied = await fetch(config.url);
|
||||
assert.equal(await proxied.text(), 'authenticated response');
|
||||
await update({ HTTPS_PROXY: '' });
|
||||
agents.add(getGlobalDispatcher());
|
||||
tlsAgents.add(network.getMTLSAgent());
|
||||
const direct = await fetch(config.url);
|
||||
assert.equal(direct.status, 200);
|
||||
assert.equal(await direct.text(), 'authenticated response');
|
||||
} else {
|
||||
const expectedCert = readFileSync(config.certPath, 'utf8');
|
||||
const expectedKey = readFileSync(config.keyPath, 'utf8');
|
||||
assert.deepEqual(network.getMTLSConfig(), { cert: expectedCert, key: expectedKey });
|
||||
assert.ok(network.getCACertificates().includes(readFileSync(config.caPath, 'utf8')));
|
||||
assert.equal(network.getMTLSAgent().options.cert, expectedCert);
|
||||
assert.equal(network.getMTLSAgent().options.key, expectedKey);
|
||||
for (const noProxy of ['unused.invalid', '*.127.0.0.1']) {
|
||||
if (network.getNoProxy() !== noProxy) await update({ no_proxy: noProxy });
|
||||
const dispatcher = network.getProxyAgent(config.proxy);
|
||||
agents.add(dispatcher);
|
||||
assert.notEqual(dispatcher, cached);
|
||||
for (const options of [{ dispatcher }, {}]) {
|
||||
const response = await fetch(config.url, options);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(await response.text(), 'authenticated response');
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('verified');
|
||||
} finally {
|
||||
setGlobalDispatcher(previousGlobalDispatcher);
|
||||
await Promise.all([...agents].map(dispatcher => dispatcher.close()));
|
||||
await Promise.all([...agents].map(dispatcher => dispatcher.destroy()));
|
||||
for (const agent of tlsAgents) agent.destroy();
|
||||
}
|
||||
`, modulePath, JSON.stringify(config)], { env, timeout: 6000 })
|
||||
expect(stdout.trim()).toBe('verified')
|
||||
expect(connects).toBe(2)
|
||||
expect(requests).toBe(4)
|
||||
expect(authorizedRequests).toBe(4)
|
||||
expect(connects).toBe(mode.startsWith('proxy-clear') ? 1 : 2)
|
||||
expect(requests).toBe(mode.startsWith('proxy-clear') ? 2 : 4)
|
||||
expect(authorizedRequests).toBe(mode === 'proxy-clear-ca' ? 0 : mode === 'proxy-clear-mtls' ? 2 : 4)
|
||||
} finally {
|
||||
for (const socket of sockets) socket.destroy()
|
||||
await Promise.all([target, proxy].map(server => new Promise<void>(resolve => {
|
||||
@@ -262,7 +283,7 @@ test.each(['TLS/proxy settings', 'unrelated session token'] as const)(
|
||||
console.log('verified');
|
||||
} finally {
|
||||
setGlobalDispatcher(previousGlobalDispatcher);
|
||||
await Promise.all([...dispatchers].map(dispatcher => dispatcher.close()));
|
||||
await Promise.all([...dispatchers].map(dispatcher => dispatcher.destroy()));
|
||||
for (const agent of tlsAgents) agent.destroy();
|
||||
}
|
||||
`, modulePath, JSON.stringify({ mode, directory })], { env, timeout: 6000 })
|
||||
@@ -274,6 +295,7 @@ for (const mode of [
|
||||
'lowercase-bypass', 'lowercase-proxy', 'empty-lowercase',
|
||||
'refresh-uppercase', 'refresh-lowercase', 'cache-wildcard',
|
||||
'update-uppercase', 'update-lowercase', 'clear-cache',
|
||||
'clear-proxy', 'clear-unset', 'retained-proxy',
|
||||
] as const) {
|
||||
// The Node child has a 6s deadline; allow extra time for cleanup.
|
||||
test(`Node NO_PROXY routing: ${mode}`, async () => {
|
||||
@@ -285,14 +307,17 @@ for (const mode of [
|
||||
response.end('direct')
|
||||
})
|
||||
const proxy = createServer()
|
||||
proxy.on('connect', (_request, client) => {
|
||||
const replacementProxy = mode === 'retained-proxy' ? createServer() : undefined
|
||||
const proxies = [proxy, ...(replacementProxy ? [replacementProxy] : [])]
|
||||
for (const server of proxies) server.on('connect', (_request, client) => {
|
||||
connects++
|
||||
client.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
||||
client.once('data', () => {
|
||||
client.end('HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nproxy')
|
||||
})
|
||||
})
|
||||
for (const server of [target, proxy]) {
|
||||
const servers = [target, ...proxies]
|
||||
for (const server of servers) {
|
||||
server.on('connection', socket => {
|
||||
sockets.add(socket)
|
||||
socket.on('close', () => sockets.delete(socket))
|
||||
@@ -304,6 +329,10 @@ for (const mode of [
|
||||
await once(target, 'listening')
|
||||
proxy.listen(0, '127.0.0.1')
|
||||
await once(proxy, 'listening')
|
||||
if (replacementProxy) {
|
||||
replacementProxy.listen(0, '127.0.0.1')
|
||||
await once(replacementProxy, 'listening')
|
||||
}
|
||||
const env = { ...process.env }
|
||||
for (const key of [
|
||||
'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'NO_PROXY', 'no_proxy',
|
||||
@@ -313,14 +342,19 @@ for (const mode of [
|
||||
const config = {
|
||||
mode,
|
||||
proxy: `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`,
|
||||
replacementProxy: replacementProxy
|
||||
? `http://127.0.0.1:${(replacementProxy.address() as AddressInfo).port}`
|
||||
: undefined,
|
||||
url: `http://127.0.0.1:${(target.address() as AddressInfo).port}/`,
|
||||
}
|
||||
const { stdout } = await run('node', ['--input-type=module', '-e', `
|
||||
import assert from 'node:assert/strict';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const network = await import(pathToFileURL(process.argv[1]));
|
||||
const { mode, proxy, url } = JSON.parse(process.argv[2]);
|
||||
process.env.HTTPS_PROXY = proxy;
|
||||
const { getGlobalDispatcher, setGlobalDispatcher } = await import(pathToFileURL(${JSON.stringify(Bun.resolveSync('undici/index.js', import.meta.dir))}));
|
||||
const { mode, proxy, replacementProxy, url } = JSON.parse(process.argv[2]);
|
||||
const previousGlobalDispatcher = getGlobalDispatcher();
|
||||
if (mode !== 'clear-unset') process.env.HTTPS_PROXY = proxy;
|
||||
const agents = new Set();
|
||||
const agent = () => {
|
||||
const result = network.getProxyAgent(proxy);
|
||||
@@ -341,7 +375,50 @@ for (const mode of [
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (mode.startsWith('lowercase') || mode === 'empty-lowercase') {
|
||||
if (mode === 'retained-proxy') {
|
||||
const retained = network.getProxyFetchOptions().dispatcher;
|
||||
agents.add(retained);
|
||||
network.configureGlobalAgents();
|
||||
assert.equal(getGlobalDispatcher(), retained);
|
||||
await request('proxy');
|
||||
await update({ HTTPS_PROXY: replacementProxy });
|
||||
agents.add(getGlobalDispatcher());
|
||||
assert.notEqual(getGlobalDispatcher(), retained);
|
||||
await request('proxy');
|
||||
await request('proxy', retained);
|
||||
await update({ HTTPS_PROXY: '' });
|
||||
agents.add(getGlobalDispatcher());
|
||||
await request('direct');
|
||||
await request('proxy', retained);
|
||||
} else if (mode === 'clear-proxy' || mode === 'clear-unset') {
|
||||
if (mode === 'clear-proxy') {
|
||||
network.configureGlobalAgents();
|
||||
const installed = agent();
|
||||
let closed = 0;
|
||||
const close = installed.close.bind(installed);
|
||||
installed.close = () => { closed++; return close(); };
|
||||
network.configureGlobalAgents();
|
||||
assert.equal(closed, 0, 'the active cached dispatcher must stay open');
|
||||
await request('proxy');
|
||||
await update({ HTTPS_PROXY: '' });
|
||||
agents.add(getGlobalDispatcher());
|
||||
await request('direct');
|
||||
assert.equal(closed, 0, 'retained proxy dispatchers must stay open');
|
||||
// Reusing the same proxy must use the refreshed cache.
|
||||
process.env.HTTPS_PROXY = proxy;
|
||||
network.configureGlobalAgents();
|
||||
agents.add(getGlobalDispatcher());
|
||||
await request('proxy');
|
||||
await update({ HTTPS_PROXY: '' });
|
||||
} else {
|
||||
await update({ HTTPS_PROXY: '' });
|
||||
}
|
||||
agents.add(getGlobalDispatcher());
|
||||
await request('direct');
|
||||
await update({ HTTPS_PROXY: '' });
|
||||
agents.add(getGlobalDispatcher());
|
||||
await request('direct');
|
||||
} else if (mode.startsWith('lowercase') || mode === 'empty-lowercase') {
|
||||
process.env.NO_PROXY = mode === 'lowercase-bypass' ? 'unused.invalid' : '127.0.0.1';
|
||||
process.env.no_proxy = mode === 'lowercase-bypass' ? '127.0.0.1' :
|
||||
mode === 'empty-lowercase' ? '' : 'unused.invalid';
|
||||
@@ -391,22 +468,165 @@ for (const mode of [
|
||||
}
|
||||
console.log('verified');
|
||||
} finally {
|
||||
for (const dispatcher of agents) await dispatcher.close();
|
||||
setGlobalDispatcher(previousGlobalDispatcher);
|
||||
for (const dispatcher of agents) await dispatcher.destroy();
|
||||
}
|
||||
`, modulePath, JSON.stringify(config)], { env, timeout: 6000 })
|
||||
expect(stdout.trim()).toBe('verified')
|
||||
const expectedConnects = mode === 'lowercase-bypass' ? 0 :
|
||||
const expectedConnects = mode === 'retained-proxy' ? 4 : mode === 'clear-proxy' ? 2 : mode === 'clear-unset' ? 0 : mode === 'lowercase-bypass' ? 0 :
|
||||
mode.startsWith('refresh') ? 3 : mode.startsWith('update') || mode === 'clear-cache' ? 2 : 1
|
||||
const expectedDirect = mode === 'lowercase-bypass' ? 1 :
|
||||
const expectedDirect = mode === 'retained-proxy' ? 1 : mode === 'clear-proxy' ? 3 : mode === 'clear-unset' ? 2 : mode === 'lowercase-bypass' ? 1 :
|
||||
mode.startsWith('lowercase') || mode === 'empty-lowercase' ? 0 :
|
||||
mode.startsWith('update') || mode === 'clear-cache' ? 4 : mode === 'cache-wildcard' ? 3 : 2
|
||||
expect(connects).toBe(expectedConnects)
|
||||
expect(requests).toBe(expectedDirect)
|
||||
} finally {
|
||||
for (const socket of sockets) socket.destroy()
|
||||
await Promise.all([target, proxy].map(server => new Promise<void>(resolve => {
|
||||
await Promise.all(servers.map(server => new Promise<void>(resolve => {
|
||||
server.close(() => resolve())
|
||||
})))
|
||||
}
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
test('empty configuration does not load or install a global dispatcher', async () => {
|
||||
const env = { ...process.env }
|
||||
for (const name of [
|
||||
'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'NO_PROXY', 'no_proxy',
|
||||
'NODE_OPTIONS', 'NODE_EXTRA_CA_CERTS', 'CLAUDE_CODE_CLIENT_CERT',
|
||||
'CLAUDE_CODE_CLIENT_KEY', 'CLAUDE_CODE_CLIENT_KEY_PASSPHRASE',
|
||||
]) delete env[name]
|
||||
const { stdout } = await run('node', ['--input-type=module', '-e', `
|
||||
import assert from 'node:assert/strict';
|
||||
import Module from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const load = Module._load;
|
||||
Module._load = function(specifier, ...args) {
|
||||
assert.ok(!specifier.includes('undici'), 'empty configuration must stay lazy');
|
||||
return load.call(this, specifier, ...args);
|
||||
};
|
||||
const symbols = ['undici.globalDispatcher.1', 'undici.globalDispatcher.2'].map(Symbol.for);
|
||||
try {
|
||||
const network = await import(pathToFileURL(process.argv[1]));
|
||||
// Importing HTTP clients may initialize Node's built-in fetch dispatcher.
|
||||
const before = symbols.map(symbol => globalThis[symbol]);
|
||||
network.configureGlobalAgents();
|
||||
network.configureGlobalAgents();
|
||||
assert.deepEqual(symbols.map(symbol => globalThis[symbol]), before);
|
||||
// The full fixture imports unrelated services which can eagerly load
|
||||
// dispatchers. Only the environment-update operation must stay lazy.
|
||||
Module._load = load;
|
||||
const structured = await import(pathToFileURL(process.argv[2]));
|
||||
const structuredBefore = symbols.map(symbol => globalThis[symbol]);
|
||||
Module._load = function(specifier, ...args) {
|
||||
assert.ok(!specifier.includes('undici'), 'empty update must stay lazy');
|
||||
return load.call(this, specifier, ...args);
|
||||
};
|
||||
const io = new structured.StructuredIO((async function* () {
|
||||
yield JSON.stringify({ type: 'update_environment_variables', variables: { HTTPS_PROXY: '' } }) + '\\n';
|
||||
})());
|
||||
for await (const message of io.structuredInput) assert.fail(message.type);
|
||||
assert.deepEqual(symbols.map(symbol => globalThis[symbol]), structuredBefore);
|
||||
console.log('verified');
|
||||
} finally { Module._load = load; }
|
||||
`, proxyModulePath, modulePath], { env, timeout: 6000 })
|
||||
expect(stdout.trim()).toBe('verified')
|
||||
}, 10000)
|
||||
|
||||
test.each(['shared TLS', 'TLS clear', 'TLS refresh', 'proxy replacement', 'external replacement'] as const)(
|
||||
'global dispatcher ownership: %s', async mode => {
|
||||
const env = { ...process.env }
|
||||
for (const name of [
|
||||
'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'NO_PROXY', 'no_proxy',
|
||||
'NODE_OPTIONS', 'NODE_EXTRA_CA_CERTS', 'CLAUDE_CODE_CLIENT_CERT',
|
||||
'CLAUDE_CODE_CLIENT_KEY', 'CLAUDE_CODE_CLIENT_KEY_PASSPHRASE',
|
||||
]) delete env[name]
|
||||
const { stdout } = await run('node', ['--input-type=module', '-e', `
|
||||
import assert from 'node:assert/strict';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const undici = (await import(pathToFileURL(${JSON.stringify(Bun.resolveSync('undici/index.js', import.meta.dir))}))).default;
|
||||
const network = await import(pathToFileURL(process.argv[1]));
|
||||
const { mode, directory } = JSON.parse(process.argv[2]);
|
||||
const original = undici.getGlobalDispatcher();
|
||||
const setGlobalDispatcher = undici.setGlobalDispatcher;
|
||||
const dispatchers = new Set();
|
||||
undici.setGlobalDispatcher = dispatcher => {
|
||||
dispatchers.add(dispatcher);
|
||||
setGlobalDispatcher(dispatcher);
|
||||
};
|
||||
const countCloses = dispatcher => {
|
||||
const close = dispatcher.close;
|
||||
let count = 0;
|
||||
dispatcher.close = function(...args) { count++; return close.apply(this, args); };
|
||||
return () => count;
|
||||
};
|
||||
const defaultCloses = countCloses(original);
|
||||
try {
|
||||
network.configureGlobalAgents();
|
||||
assert.equal(undici.getGlobalDispatcher(), original);
|
||||
if (mode === 'shared TLS' || mode.startsWith('TLS')) {
|
||||
process.env.NODE_EXTRA_CA_CERTS = directory + '/ca.pem';
|
||||
network.clearCACertsCache();
|
||||
network.clearMTLSCache();
|
||||
network.configureGlobalAgents();
|
||||
const shared = undici.getGlobalDispatcher();
|
||||
assert.notEqual(shared, original, 'CA configuration must install a TLS dispatcher');
|
||||
const sharedCloses = countCloses(shared);
|
||||
network.configureGlobalAgents();
|
||||
assert.equal(undici.getGlobalDispatcher(), shared, 'cached TLS dispatcher must be reused');
|
||||
assert.equal(sharedCloses(), 0, 'cached TLS dispatcher must stay open');
|
||||
if (mode.startsWith('TLS')) {
|
||||
if (mode === 'TLS clear') delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
network.clearCACertsCache();
|
||||
network.clearMTLSCache();
|
||||
network.configureGlobalAgents();
|
||||
const replacement = undici.getGlobalDispatcher();
|
||||
assert.notEqual(replacement, shared, 'invalidated TLS configuration must be replaced');
|
||||
if (mode === 'TLS clear') assert.ok(replacement instanceof undici.Agent);
|
||||
assert.equal(sharedCloses(), 1, 'retired TLS dispatcher must be closed after cache invalidation');
|
||||
network.configureGlobalAgents();
|
||||
if (mode === 'TLS clear') assert.equal(undici.getGlobalDispatcher(), replacement);
|
||||
} else {
|
||||
process.env.HTTPS_PROXY = 'http://127.0.0.1:1';
|
||||
network.configureGlobalAgents();
|
||||
delete process.env.HTTPS_PROXY;
|
||||
network.clearProxyCache();
|
||||
network.configureGlobalAgents();
|
||||
assert.equal(sharedCloses(), 0, 'retained cached TLS dispatcher must stay open');
|
||||
}
|
||||
} else {
|
||||
process.env.HTTPS_PROXY = 'http://127.0.0.1:1';
|
||||
network.configureGlobalAgents();
|
||||
const first = undici.getGlobalDispatcher();
|
||||
const firstCloses = countCloses(first);
|
||||
if (mode === 'external replacement') {
|
||||
const external = new undici.Agent();
|
||||
undici.setGlobalDispatcher(external);
|
||||
delete process.env.HTTPS_PROXY;
|
||||
network.configureGlobalAgents();
|
||||
assert.equal(undici.getGlobalDispatcher(), external);
|
||||
assert.equal(firstCloses(), 0, 'a cached proxy must stay open');
|
||||
} else {
|
||||
process.env.HTTPS_PROXY = 'http://127.0.0.1:2';
|
||||
network.configureGlobalAgents();
|
||||
const second = undici.getGlobalDispatcher();
|
||||
assert.equal(firstCloses(), 0, 'replaced shared proxy must stay open');
|
||||
assert.notEqual(second, first);
|
||||
assert.equal(network.getProxyAgent(process.env.HTTPS_PROXY), second);
|
||||
const recreated = network.getProxyAgent('http://127.0.0.1:1');
|
||||
dispatchers.add(recreated);
|
||||
assert.notEqual(recreated, first, 'retired proxy must leave the cache');
|
||||
}
|
||||
}
|
||||
assert.equal(defaultCloses(), 0, 'the default dispatcher must stay open');
|
||||
console.log('verified');
|
||||
} finally {
|
||||
undici.setGlobalDispatcher = setGlobalDispatcher;
|
||||
setGlobalDispatcher(original);
|
||||
await Promise.all([...dispatchers].map(dispatcher => dispatcher.destroy()));
|
||||
network.getMTLSAgent()?.destroy();
|
||||
}
|
||||
`, modulePath, JSON.stringify({ mode, directory })], { env, timeout: 6000 })
|
||||
expect(stdout.trim()).toBe('verified')
|
||||
}, 10000,
|
||||
)
|
||||
|
||||
@@ -132,9 +132,11 @@ describe('runtime CA certificates through CONNECT', () => {
|
||||
clearMTLSCache()
|
||||
clearProxyCache()
|
||||
configureGlobalAgents() // Eject the test's global axios interceptor.
|
||||
const directDispatcher = getGlobalDispatcher()
|
||||
Object.assign(axios.defaults, defaults)
|
||||
setGlobalDispatcher(dispatcher)
|
||||
if (testDispatcher !== dispatcher) await testDispatcher.close?.()
|
||||
if (testDispatcher !== dispatcher) await testDispatcher.destroy?.()
|
||||
if (directDispatcher !== dispatcher) await directDispatcher.destroy?.()
|
||||
restoreEnv()
|
||||
clearCACertsCache()
|
||||
clearMTLSCache()
|
||||
|
||||
+48
-16
@@ -258,6 +258,10 @@ export function createAxiosInstance(
|
||||
return instance
|
||||
}
|
||||
|
||||
function getProxyAgentCacheKey(uri: string): string {
|
||||
return JSON.stringify([uri, getNoProxy()])
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a proxy agent cached by URI and effective NO_PROXY value.
|
||||
* Include the bypass list because wildcard normalization pins it on the agent.
|
||||
@@ -306,7 +310,7 @@ export const getProxyAgent = memoize((uri: string): undici.Dispatcher => {
|
||||
return typeof Bun === 'undefined'
|
||||
? new undiciMod.Dispatcher1Wrapper(agent)
|
||||
: agent
|
||||
}, (uri: string) => JSON.stringify([uri, getNoProxy()]))
|
||||
}, getProxyAgentCacheKey)
|
||||
|
||||
/**
|
||||
* Get an HTTP agent configured for WebSocket proxy support
|
||||
@@ -395,6 +399,11 @@ export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean }): {
|
||||
* This ensures all HTTP requests use the proxy and/or mTLS if configured
|
||||
*/
|
||||
let proxyInterceptorId: number | undefined
|
||||
let installedDispatcher: undici.Dispatcher | undefined
|
||||
let installedProxyCacheKey: string | undefined
|
||||
let installedTLSDispatcher = false
|
||||
let installedTLSAgent: ReturnType<typeof getMTLSAgent>
|
||||
let ownsInstalledDispatcher = false
|
||||
|
||||
export function configureGlobalAgents(): void {
|
||||
const proxyUrl = getProxyUrl()
|
||||
@@ -438,25 +447,48 @@ export function configureGlobalAgents(): void {
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Set global dispatcher that now respects NO_PROXY via EnvHttpProxyAgent
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
;(require('undici') as typeof undici).setGlobalDispatcher(
|
||||
getProxyAgent(proxyUrl),
|
||||
)
|
||||
} else if (mtlsAgent) {
|
||||
// No proxy but mTLS is configured
|
||||
} else {
|
||||
axios.defaults.httpsAgent = mtlsAgent
|
||||
}
|
||||
|
||||
// Set undici global dispatcher with mTLS
|
||||
const mtlsOptions = getTLSFetchOptions()
|
||||
if (mtlsOptions.dispatcher) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
;(require('undici') as typeof undici).setGlobalDispatcher(
|
||||
mtlsOptions.dispatcher,
|
||||
)
|
||||
// Keep the ordinary direct path lazy. Only clear our proxy/TLS installation
|
||||
// while it is still global; another caller may have installed a replacement.
|
||||
if (!proxyUrl && !mtlsAgent && installedProxyCacheKey === undefined && !installedTLSDispatcher) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const undiciMod = require('undici') as typeof undici
|
||||
if (!proxyUrl && !mtlsAgent && undiciMod.getGlobalDispatcher() !== installedDispatcher) return
|
||||
|
||||
// Reuse our TLS installation while its source configuration remains cached.
|
||||
const cachedTLSDispatcher = !proxyUrl && mtlsAgent && installedTLSDispatcher &&
|
||||
installedTLSAgent === mtlsAgent ? installedDispatcher : undefined
|
||||
const tlsOptions = proxyUrl || cachedTLSDispatcher ? undefined : getTLSFetchOptions()
|
||||
const dispatcher = proxyUrl ? getProxyAgent(proxyUrl) :
|
||||
cachedTLSDispatcher ?? tlsOptions?.dispatcher ?? new undiciMod.Agent({ connect: tlsOptions?.tls })
|
||||
undiciMod.setGlobalDispatcher(dispatcher)
|
||||
|
||||
if (installedDispatcher && installedDispatcher !== dispatcher) {
|
||||
// Evict only the retired proxy, leaving the active dispatcher cached.
|
||||
if (installedProxyCacheKey !== undefined &&
|
||||
getProxyAgent.cache.get(installedProxyCacheKey) === installedDispatcher) {
|
||||
getProxyAgent.cache.delete(installedProxyCacheKey)
|
||||
}
|
||||
// Proxy dispatchers are shared with callers that can retain fetch options.
|
||||
// Eviction does not prove those references are gone, so leave them open.
|
||||
if (ownsInstalledDispatcher || (installedTLSDispatcher && installedTLSAgent !== mtlsAgent)) {
|
||||
try {
|
||||
// Graceful close lets in-flight requests finish without blocking updates.
|
||||
void installedDispatcher.close().catch(() => {})
|
||||
} catch {
|
||||
// Dispatcher cleanup is best effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
installedDispatcher = dispatcher
|
||||
installedProxyCacheKey = proxyUrl ? getProxyAgentCacheKey(proxyUrl) : undefined
|
||||
installedTLSDispatcher = !proxyUrl && !!mtlsAgent
|
||||
installedTLSAgent = mtlsAgent
|
||||
// Only the plain direct dispatcher is private; proxy options are handed out.
|
||||
ownsInstalledDispatcher = !proxyUrl && !mtlsAgent
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user