diff --git a/.changeset/fix-child-no-proxy-bracketed-ipv6.md b/.changeset/fix-child-no-proxy-bracketed-ipv6.md new file mode 100644 index 0000000000..9250d69d47 --- /dev/null +++ b/.changeset/fix-child-no-proxy-bracketed-ipv6.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix stdio MCP servers (Python httpx-based) crashing with `httpx.InvalidURL` when a proxy is configured. The bracketed `[::1]` loopback entry is no longer injected into child process `NO_PROXY`; only the bare `::1` is used, which all major HTTP client libraries parse correctly. diff --git a/packages/agent-core-v2/src/_base/utils/proxy.ts b/packages/agent-core-v2/src/_base/utils/proxy.ts index 7a4e06806d..ae5028c684 100644 --- a/packages/agent-core-v2/src/_base/utils/proxy.ts +++ b/packages/agent-core-v2/src/_base/utils/proxy.ts @@ -1,6 +1,10 @@ /** * Resolve and install proxy configuration for outbound `fetch` and spawned * child processes (HTTP/HTTPS and SOCKS, honoring `NO_PROXY`). + * + * Child NO_PROXY is runtime-aware: Node children (undici) require the bracketed + * `[::1]` loopback entry, while non-Node children (Python httpx, Go) crash on + * it and only accept bare `::1` (#1931). */ import { @@ -23,6 +27,7 @@ export interface SocksProxyConfig { } const LOOPBACK_NO_PROXY = ['localhost', '127.0.0.1', '::1', '[::1]'] as const; +const LOOPBACK_NO_PROXY_NON_NODE_CHILD = ['localhost', '127.0.0.1', '::1'] as const; const SOCKS_SCHEMES = new Set(['socks', 'socks4', 'socks4a', 'socks5', 'socks5h']); @@ -91,13 +96,21 @@ export function isProxyConfigured(env: Env): boolean { } export function resolveNoProxy(env: Env): string { + return resolveNoProxyWith(env, LOOPBACK_NO_PROXY); +} + +export function resolveNoProxyForChild(env: Env, isNodeRuntime = false): string { + return resolveNoProxyWith(env, isNodeRuntime ? LOOPBACK_NO_PROXY : LOOPBACK_NO_PROXY_NON_NODE_CHILD); +} + +function resolveNoProxyWith(env: Env, loopbacks: readonly string[]): string { const raw = [env['no_proxy'], env['NO_PROXY']].find((value) => (value?.trim() ?? '').length > 0) ?? ''; const hosts = raw .split(',') .map((host) => host.trim()) .filter((host) => host.length > 0); if (hosts.includes('*')) return '*'; - for (const loopback of LOOPBACK_NO_PROXY) { + for (const loopback of loopbacks) { if (!hosts.includes(loopback)) hosts.push(loopback); } return hosts.join(','); @@ -230,9 +243,9 @@ export function installGlobalProxyDispatcher( return true; } -export function proxyEnvForChild(env: Env): Record { +export function proxyEnvForChild(env: Env, isNodeRuntime = false): Record { if (!hasHttpProxy(env)) return {}; - const noProxy = resolveNoProxy(env); + const noProxy = resolveNoProxyForChild(env, isNodeRuntime); const result: Record = { NODE_USE_ENV_PROXY: '1', NO_PROXY: noProxy, @@ -253,12 +266,13 @@ export function proxyEnvForChild(env: Env): Record { export function reconcileChildNoProxy( childEnv: Record, configEnv?: Record, + isNodeRuntime = false, ): void { const override = [configEnv?.['no_proxy'], configEnv?.['NO_PROXY']].find( (value) => (value?.trim() ?? '').length > 0, ); if (override === undefined) return; - const noProxy = resolveNoProxy({ no_proxy: override, NO_PROXY: override }); + const noProxy = resolveNoProxyForChild({ no_proxy: override, NO_PROXY: override }, isNodeRuntime); childEnv['NO_PROXY'] = noProxy; childEnv['no_proxy'] = noProxy; } diff --git a/packages/agent-core-v2/src/agent/mcp/client-stdio.ts b/packages/agent-core-v2/src/agent/mcp/client-stdio.ts index d5d5ebab49..9052f72166 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-stdio.ts +++ b/packages/agent-core-v2/src/agent/mcp/client-stdio.ts @@ -50,7 +50,7 @@ export class StdioMcpClient implements MCPClient { this.transport = new StdioClientTransport({ command: config.command, args: config.args, - env: mergeStdioEnv(config.env), + env: mergeStdioEnv(config.env, undefined, config.command), cwd: resolveStdioCwd(config.cwd, options.defaultCwd), stderr: 'pipe', }); @@ -184,13 +184,22 @@ function resolveStdioCwd(configCwd: string | undefined, defaultCwd: string | und export function mergeStdioEnv( configEnv?: Record, parentEnv: Readonly> = process.env, + command?: string, ): Record { const merged: Record = {}; for (const [key, value] of Object.entries(parentEnv)) { if (value !== undefined) merged[key] = value; } if (configEnv !== undefined) Object.assign(merged, configEnv); - Object.assign(merged, proxyEnvForChild(merged)); - reconcileChildNoProxy(merged, configEnv); + const isNode = isNodeCommand(command); + Object.assign(merged, proxyEnvForChild(merged, isNode)); + reconcileChildNoProxy(merged, configEnv, isNode); return merged; } + +/** True when the command is a Node.js runtime (node, npx, tsx, etc.). */ +function isNodeCommand(command?: string): boolean { + if (command === undefined) return false; + const base = command.replaceAll('\\', '/').split('/').pop()?.replace(/\.(exe|cmd|bat)$/i, '').toLowerCase(); + return base === 'node' || base === 'npx' || base === 'tsx' || base === 'ts-node'; +} diff --git a/packages/agent-core-v2/test/_base/utils/proxy.test.ts b/packages/agent-core-v2/test/_base/utils/proxy.test.ts index 404e47dd6d..ed5db94e9b 100644 --- a/packages/agent-core-v2/test/_base/utils/proxy.test.ts +++ b/packages/agent-core-v2/test/_base/utils/proxy.test.ts @@ -130,8 +130,8 @@ describe('proxy utilities', () => { expect(proxyEnvForChild({ ALL_PROXY: 'socks5://127.0.0.1:1080' })).toEqual({}); expect(proxyEnvForChild({ HTTP_PROXY: 'http://p:3128', NO_PROXY: 'corp' })).toEqual({ NODE_USE_ENV_PROXY: '1', - NO_PROXY: 'corp,localhost,127.0.0.1,::1,[::1]', - no_proxy: 'corp,localhost,127.0.0.1,::1,[::1]', + NO_PROXY: 'corp,localhost,127.0.0.1,::1', + no_proxy: 'corp,localhost,127.0.0.1,::1', HTTP_PROXY: 'http://p:3128', http_proxy: 'http://p:3128', }); @@ -141,7 +141,7 @@ describe('proxy utilities', () => { no_proxy: 'aug', }; reconcileChildNoProxy(childEnv, { no_proxy: '', NO_PROXY: 'real.corp' }); - expect(childEnv['NO_PROXY']).toBe('real.corp,localhost,127.0.0.1,::1,[::1]'); - expect(childEnv['no_proxy']).toBe('real.corp,localhost,127.0.0.1,::1,[::1]'); + expect(childEnv['NO_PROXY']).toBe('real.corp,localhost,127.0.0.1,::1'); + expect(childEnv['no_proxy']).toBe('real.corp,localhost,127.0.0.1,::1'); }); }); diff --git a/packages/agent-core-v2/test/agent/mcp/client-stdio.test.ts b/packages/agent-core-v2/test/agent/mcp/client-stdio.test.ts index 9594884313..178dfdadfa 100644 --- a/packages/agent-core-v2/test/agent/mcp/client-stdio.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/client-stdio.test.ts @@ -304,7 +304,7 @@ describe('mergeStdioEnv', () => { const merged = mergeStdioEnv({ HTTP_PROXY: 'http://corp:3128' }, { PATH: '/usr/bin' }); expect(merged['HTTP_PROXY']).toBe('http://corp:3128'); expect(merged['NODE_USE_ENV_PROXY']).toBe('1'); - expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1,[::1]'); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1'); expect(merged['PATH']).toBe('/usr/bin'); }); @@ -324,4 +324,14 @@ describe('mergeStdioEnv', () => { await rm(dir, { recursive: true, force: true }); expect(mergeStdioEnv(undefined, { PATH: dir })['PATH']).toBe(dir); }); + + it('preserves bracketed [::1] in NO_PROXY for a Node child command', () => { + const merged = mergeStdioEnv({ HTTP_PROXY: 'http://corp:3128' }, { PATH: '/usr/bin' }, 'node'); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1,[::1]'); + }); + + it('omits bracketed [::1] in NO_PROXY for a non-Node child command', () => { + const merged = mergeStdioEnv({ HTTP_PROXY: 'http://corp:3128' }, { PATH: '/usr/bin' }, 'python'); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1'); + }); }); diff --git a/packages/agent-core/src/mcp/client-stdio.ts b/packages/agent-core/src/mcp/client-stdio.ts index 267056d428..b86bc3cf66 100644 --- a/packages/agent-core/src/mcp/client-stdio.ts +++ b/packages/agent-core/src/mcp/client-stdio.ts @@ -65,7 +65,7 @@ export class StdioMcpClient implements MCPClient { this.transport = new StdioClientTransport({ command: config.command, args: config.args, - env: mergeStdioEnv(config.env), + env: mergeStdioEnv(config.env, undefined, config.command), cwd: resolveStdioCwd(config.cwd, options.defaultCwd), stderr: 'pipe', }); @@ -256,13 +256,22 @@ function isWindowsAbsolutePath(value: string): boolean { export function mergeStdioEnv( configEnv?: Record, parentEnv: Readonly> = process.env, + command?: string, ): Record { const merged: Record = {}; for (const [key, value] of Object.entries(parentEnv)) { if (value !== undefined) merged[key] = value; } if (configEnv !== undefined) Object.assign(merged, configEnv); - Object.assign(merged, proxyEnvForChild(merged)); - reconcileChildNoProxy(merged, configEnv); + const isNode = isNodeCommand(command); + Object.assign(merged, proxyEnvForChild(merged, isNode)); + reconcileChildNoProxy(merged, configEnv, isNode); return merged; } + +/** True when the command is a Node.js runtime (node, npx, tsx, etc.). */ +function isNodeCommand(command?: string): boolean { + if (command === undefined) return false; + const base = command.replaceAll('\\', '/').split('/').pop()?.replace(/\.(exe|cmd|bat)$/i, '').toLowerCase(); + return base === 'node' || base === 'npx' || base === 'tsx' || base === 'ts-node'; +} diff --git a/packages/agent-core/src/utils/proxy.ts b/packages/agent-core/src/utils/proxy.ts index 2f475ef1f9..5f1eb2d5fc 100644 --- a/packages/agent-core/src/utils/proxy.ts +++ b/packages/agent-core/src/utils/proxy.ts @@ -30,6 +30,13 @@ export interface SocksProxyConfig { // normalizes brackets away — so including both covers every path. const LOOPBACK_NO_PROXY = ['localhost', '127.0.0.1', '::1', '[::1]'] as const; +// Child processes (e.g. stdio MCP servers) must NOT receive the bracketed `[::1]`: +// Non-Node child processes (e.g. Python stdio MCP servers) must NOT receive the +// bracketed `[::1]`: Python's httpx parses `all://[::1]` and treats `:1]` as the +// port, raising `httpx.InvalidURL`. Node children (with NODE_USE_ENV_PROXY=1) +// still need the bracketed form because undici mis-parses bare `::1`. +const LOOPBACK_NO_PROXY_NON_NODE_CHILD = ['localhost', '127.0.0.1', '::1'] as const; + const SOCKS_SCHEMES = new Set(['socks', 'socks4', 'socks4a', 'socks5', 'socks5h']); /** Lowercase URL scheme (without the trailing colon), or undefined if absent. */ @@ -130,6 +137,22 @@ export function isProxyConfigured(env: Env = process.env): boolean { * through the proxy. */ export function resolveNoProxy(env: Env = process.env): string { + return resolveNoProxyWith(env, LOOPBACK_NO_PROXY); +} + +/** + * Like {@link resolveNoProxy} but selects the loopback list based on the child + * runtime. Node children (undici via NODE_USE_ENV_PROXY) need the bracketed + * `[::1]`; non-Node children (Python httpx, Go, etc.) crash on it and only + * accept bare `::1`. + * + * @see https://github.com/MoonshotAI/kimi-code/issues/1931 + */ +export function resolveNoProxyForChild(env: Env = process.env, isNodeRuntime = false): string { + return resolveNoProxyWith(env, isNodeRuntime ? LOOPBACK_NO_PROXY : LOOPBACK_NO_PROXY_NON_NODE_CHILD); +} + +function resolveNoProxyWith(env: Env, loopbacks: readonly string[]): string { // Prefer the first non-blank casing; an empty `no_proxy=''` must not mask a // populated `NO_PROXY` (`??` would, since `''` is not nullish). const raw = [env['no_proxy'], env['NO_PROXY']].find((value) => (value?.trim() ?? '').length > 0) ?? ''; @@ -138,7 +161,7 @@ export function resolveNoProxy(env: Env = process.env): string { .map((host) => host.trim()) .filter((host) => host.length > 0); if (hosts.includes('*')) return '*'; - for (const loopback of LOOPBACK_NO_PROXY) { + for (const loopback of loopbacks) { if (!hosts.includes(loopback)) hosts.push(loopback); } return hosts.join(','); @@ -333,9 +356,9 @@ export function installGlobalProxyDispatcher( * an http-scheme `ALL_PROXY` is synthesized into the scheme-specific variables * so an `ALL_PROXY`-only parent still proxies the child. */ -export function proxyEnvForChild(env: Env = process.env): Record { +export function proxyEnvForChild(env: Env = process.env, isNodeRuntime = false): Record { if (!hasHttpProxy(env)) return {}; - const noProxy = resolveNoProxy(env); + const noProxy = resolveNoProxyForChild(env, isNodeRuntime); const result: Record = { NODE_USE_ENV_PROXY: '1', NO_PROXY: noProxy, @@ -361,18 +384,19 @@ export function proxyEnvForChild(env: Env = process.env): Record * * Uses the first NON-blank casing (a blank `no_proxy=''` must not mask a * populated `NO_PROXY`, mirroring {@link resolveNoProxy}) and runs the value - * back through {@link resolveNoProxy} so the loopback bypass is preserved and + * back through {@link resolveNoProxyForChild} so the loopback bypass is preserved and * `*` passes through verbatim. No-op when config sets no usable `NO_PROXY`. */ export function reconcileChildNoProxy( childEnv: Record, configEnv?: Record, + isNodeRuntime = false, ): void { const override = [configEnv?.['no_proxy'], configEnv?.['NO_PROXY']].find( (value) => (value?.trim() ?? '').length > 0, ); if (override === undefined) return; - const noProxy = resolveNoProxy({ no_proxy: override, NO_PROXY: override }); + const noProxy = resolveNoProxyForChild({ no_proxy: override, NO_PROXY: override }, isNodeRuntime); childEnv['NO_PROXY'] = noProxy; childEnv['no_proxy'] = noProxy; } diff --git a/packages/agent-core/test/mcp/client-stdio.test.ts b/packages/agent-core/test/mcp/client-stdio.test.ts index ff2c4296dd..a9eac714e9 100644 --- a/packages/agent-core/test/mcp/client-stdio.test.ts +++ b/packages/agent-core/test/mcp/client-stdio.test.ts @@ -315,7 +315,7 @@ describe('mergeStdioEnv', () => { const merged = mergeStdioEnv({ HTTP_PROXY: 'http://corp:3128' }, { PATH: '/usr/bin' }); expect(merged['HTTP_PROXY']).toBe('http://corp:3128'); expect(merged['NODE_USE_ENV_PROXY']).toBe('1'); - expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1,[::1]'); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1'); expect(merged['PATH']).toBe('/usr/bin'); }); @@ -329,4 +329,14 @@ describe('mergeStdioEnv', () => { const merged = mergeStdioEnv({ FOO: 'override' }, { FOO: 'parent', PATH: '/x' }); expect(merged['FOO']).toBe('override'); }); + + it('preserves bracketed [::1] in NO_PROXY for a Node child command', () => { + const merged = mergeStdioEnv({ HTTP_PROXY: 'http://corp:3128' }, { PATH: '/usr/bin' }, 'node'); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1,[::1]'); + }); + + it('omits bracketed [::1] in NO_PROXY for a non-Node child command', () => { + const merged = mergeStdioEnv({ HTTP_PROXY: 'http://corp:3128' }, { PATH: '/usr/bin' }, 'python'); + expect(merged['NO_PROXY']).toBe('localhost,127.0.0.1,::1'); + }); }); diff --git a/packages/agent-core/test/utils/proxy.test.ts b/packages/agent-core/test/utils/proxy.test.ts index 91b8bce927..2bcae2cb1c 100644 --- a/packages/agent-core/test/utils/proxy.test.ts +++ b/packages/agent-core/test/utils/proxy.test.ts @@ -343,8 +343,8 @@ describe('proxyEnvForChild', () => { // the resolved value or the protection/proxying is silently defeated. expect(proxyEnvForChild({ HTTP_PROXY: 'http://p:3128', NO_PROXY: 'corp' })).toEqual({ NODE_USE_ENV_PROXY: '1', - NO_PROXY: 'corp,localhost,127.0.0.1,::1,[::1]', - no_proxy: 'corp,localhost,127.0.0.1,::1,[::1]', + NO_PROXY: 'corp,localhost,127.0.0.1,::1', + no_proxy: 'corp,localhost,127.0.0.1,::1', HTTP_PROXY: 'http://p:3128', http_proxy: 'http://p:3128', }); @@ -355,8 +355,8 @@ describe('proxyEnvForChild', () => { // ALL_PROXY-only parent must hand the child the scheme-specific vars. expect(proxyEnvForChild({ ALL_PROXY: 'http://proxy:8080' })).toEqual({ NODE_USE_ENV_PROXY: '1', - NO_PROXY: 'localhost,127.0.0.1,::1,[::1]', - no_proxy: 'localhost,127.0.0.1,::1,[::1]', + NO_PROXY: 'localhost,127.0.0.1,::1', + no_proxy: 'localhost,127.0.0.1,::1', HTTP_PROXY: 'http://proxy:8080', http_proxy: 'http://proxy:8080', HTTPS_PROXY: 'http://proxy:8080', @@ -385,32 +385,32 @@ describe('reconcileChildNoProxy', () => { // would shadow the server config's uppercase override (undici reads // lowercase first); the override must also keep the loopback bypass. const childEnv: Record = { - NO_PROXY: 'corp,localhost,127.0.0.1,::1,[::1]', - no_proxy: 'corp,localhost,127.0.0.1,::1,[::1]', + NO_PROXY: 'corp,localhost,127.0.0.1,::1', + no_proxy: 'corp,localhost,127.0.0.1,::1', }; reconcileChildNoProxy(childEnv, { NO_PROXY: 'server.local' }); - expect(childEnv['NO_PROXY']).toBe('server.local,localhost,127.0.0.1,::1,[::1]'); - expect(childEnv['no_proxy']).toBe('server.local,localhost,127.0.0.1,::1,[::1]'); + expect(childEnv['NO_PROXY']).toBe('server.local,localhost,127.0.0.1,::1'); + expect(childEnv['no_proxy']).toBe('server.local,localhost,127.0.0.1,::1'); }); it('prefers the first non-blank casing (lowercase) and keeps loopback', () => { const childEnv: Record = { NO_PROXY: 'aug', no_proxy: 'aug' }; reconcileChildNoProxy(childEnv, { no_proxy: 'lower', NO_PROXY: 'upper' }); - expect(childEnv['NO_PROXY']).toBe('lower,localhost,127.0.0.1,::1,[::1]'); - expect(childEnv['no_proxy']).toBe('lower,localhost,127.0.0.1,::1,[::1]'); + expect(childEnv['NO_PROXY']).toBe('lower,localhost,127.0.0.1,::1'); + expect(childEnv['no_proxy']).toBe('lower,localhost,127.0.0.1,::1'); }); it('does not let a blank lowercase no_proxy mask a populated NO_PROXY', () => { const childEnv: Record = { NO_PROXY: 'aug', no_proxy: 'aug' }; reconcileChildNoProxy(childEnv, { no_proxy: '', NO_PROXY: 'real.corp' }); - expect(childEnv['NO_PROXY']).toBe('real.corp,localhost,127.0.0.1,::1,[::1]'); - expect(childEnv['no_proxy']).toBe('real.corp,localhost,127.0.0.1,::1,[::1]'); + expect(childEnv['NO_PROXY']).toBe('real.corp,localhost,127.0.0.1,::1'); + expect(childEnv['no_proxy']).toBe('real.corp,localhost,127.0.0.1,::1'); }); it('passes the "*" wildcard override through verbatim', () => { const childEnv: Record = { - NO_PROXY: 'corp,localhost,127.0.0.1,::1,[::1]', - no_proxy: 'corp,localhost,127.0.0.1,::1,[::1]', + NO_PROXY: 'corp,localhost,127.0.0.1,::1', + no_proxy: 'corp,localhost,127.0.0.1,::1', }; reconcileChildNoProxy(childEnv, { NO_PROXY: '*' }); expect(childEnv['NO_PROXY']).toBe('*');