Skip to content
Open
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 .changeset/fix-child-no-proxy-bracketed-ipv6.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 18 additions & 4 deletions packages/agent-core-v2/src/_base/utils/proxy.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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']);

Expand Down Expand Up @@ -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(',');
Expand Down Expand Up @@ -230,9 +243,9 @@ export function installGlobalProxyDispatcher(
return true;
}

export function proxyEnvForChild(env: Env): Record<string, string> {
export function proxyEnvForChild(env: Env, isNodeRuntime = false): Record<string, string> {
if (!hasHttpProxy(env)) return {};
const noProxy = resolveNoProxy(env);
const noProxy = resolveNoProxyForChild(env, isNodeRuntime);
const result: Record<string, string> = {
NODE_USE_ENV_PROXY: '1',
NO_PROXY: noProxy,
Expand All @@ -253,12 +266,13 @@ export function proxyEnvForChild(env: Env): Record<string, string> {
export function reconcileChildNoProxy(
childEnv: Record<string, string>,
configEnv?: Record<string, string>,
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;
}
15 changes: 12 additions & 3 deletions packages/agent-core-v2/src/agent/mcp/client-stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand Down Expand Up @@ -184,13 +184,22 @@ function resolveStdioCwd(configCwd: string | undefined, defaultCwd: string | und
export function mergeStdioEnv(
configEnv?: Record<string, string>,
parentEnv: Readonly<Record<string, string | undefined>> = process.env,
command?: string,
): Record<string, string> {
const merged: Record<string, string> = {};
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';
}
8 changes: 4 additions & 4 deletions packages/agent-core-v2/test/_base/utils/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand All @@ -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');
});
});
12 changes: 11 additions & 1 deletion packages/agent-core-v2/test/agent/mcp/client-stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand All @@ -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');
});
});
15 changes: 12 additions & 3 deletions packages/agent-core/src/mcp/client-stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand Down Expand Up @@ -256,13 +256,22 @@ function isWindowsAbsolutePath(value: string): boolean {
export function mergeStdioEnv(
configEnv?: Record<string, string>,
parentEnv: Readonly<Record<string, string | undefined>> = process.env,
command?: string,
): Record<string, string> {
const merged: Record<string, string> = {};
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';
}
34 changes: 29 additions & 5 deletions packages/agent-core/src/utils/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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) ?? '';
Expand All @@ -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(',');
Expand Down Expand Up @@ -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<string, string> {
export function proxyEnvForChild(env: Env = process.env, isNodeRuntime = false): Record<string, string> {
if (!hasHttpProxy(env)) return {};
const noProxy = resolveNoProxy(env);
const noProxy = resolveNoProxyForChild(env, isNodeRuntime);
const result: Record<string, string> = {
NODE_USE_ENV_PROXY: '1',
NO_PROXY: noProxy,
Expand All @@ -361,18 +384,19 @@ export function proxyEnvForChild(env: Env = process.env): Record<string, string>
*
* 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<string, string>,
configEnv?: Record<string, string>,
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;
}
12 changes: 11 additions & 1 deletion packages/agent-core/test/mcp/client-stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand All @@ -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');
});
});
28 changes: 14 additions & 14 deletions packages/agent-core/test/utils/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand All @@ -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',
Expand Down Expand Up @@ -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<string, string> = {
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<string, string> = { 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<string, string> = { 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<string, string> = {
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('*');
Expand Down