From bfee8b93d8614e957d6143228e3553ffffa7234b Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 5 Jun 2026 18:23:17 +0800 Subject: [PATCH 1/7] feat: honor HTTP_PROXY/HTTPS_PROXY/NO_PROXY for all outbound traffic Install a global undici dispatcher at CLI startup so every in-process fetch (LLM APIs, MCP HTTP, web tools, telemetry, sign-in, update checks) honors the standard proxy variables, and propagate NODE_USE_ENV_PROXY to spawned stdio MCP child processes. Loopback hosts always bypass the proxy; an invalid proxy URL is reported and ignored rather than aborting startup. --- .changeset/http-proxy-support.md | 9 ++ apps/kimi-code/src/main.ts | 5 + docs/en/configuration/env-vars.md | 10 ++ docs/zh/configuration/env-vars.md | 10 ++ packages/agent-core/package.json | 1 + packages/agent-core/src/index.ts | 1 + packages/agent-core/src/mcp/client-stdio.ts | 8 +- packages/agent-core/src/utils/proxy.ts | 121 +++++++++++++++++ packages/agent-core/test/utils/proxy.test.ts | 134 +++++++++++++++++++ packages/node-sdk/src/index.ts | 4 + pnpm-lock.yaml | 9 ++ 11 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 .changeset/http-proxy-support.md create mode 100644 packages/agent-core/src/utils/proxy.ts create mode 100644 packages/agent-core/test/utils/proxy.test.ts diff --git a/.changeset/http-proxy-support.md b/.changeset/http-proxy-support.md new file mode 100644 index 000000000..5b000c8a6 --- /dev/null +++ b/.changeset/http-proxy-support.md @@ -0,0 +1,9 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Honor the standard `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` environment variables for all outbound HTTP(S) traffic — model API calls, MCP servers, web tools, telemetry, sign-in, and update checks. + +A global proxy dispatcher is installed at startup only when a proxy variable is set, so the zero-config default is unchanged. Loopback hosts (`localhost`, `127.0.0.1`, `::1`) always bypass the proxy so local servers (e.g. a localhost MCP server) keep working, and stdio MCP servers running as Node processes honor the proxy automatically. diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 8a7d9fa73..e94472590 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -8,6 +8,7 @@ import { createKimiHarness, flushDiagnosticLogs, + installGlobalProxyDispatcher, log, resolveGlobalLogPath, resolveKimiHome, @@ -117,6 +118,10 @@ const MIGRATE_CLI_OPTIONS: CLIOptions = { export function main(): void { process.title = PROCESS_NAME; installCrashHandlers(); + // Route all outbound fetch through HTTP_PROXY/HTTPS_PROXY (honoring NO_PROXY) + // before any client is constructed. No-op when no proxy variable is set; an + // invalid proxy URL is reported and ignored rather than aborting startup. + installGlobalProxyDispatcher(); installNativeModuleHook(); if (runNativeAssetSmokeIfRequested()) return; diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index cc84510b6..051c8176a 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -163,6 +163,16 @@ The CLI also reads several standard system variables to detect the runtime envir - `WSL_DISTRO_NAME`, `WSLENV`: detect WSL for the clipboard PowerShell bridge - `LOCALAPPDATA`: used on Windows when probing for the Git Bash installation path +## HTTP proxy + +Kimi Code honors the standard proxy environment variables for all outbound HTTP(S) traffic — model API calls, MCP servers, web tools, telemetry, sign-in, and update checks: + +- `HTTP_PROXY` / `http_proxy`: proxy used for `http://` requests +- `HTTPS_PROXY` / `https_proxy`: proxy used for `https://` requests +- `NO_PROXY` / `no_proxy`: comma-separated hosts that bypass the proxy + +The proxy is applied only when one of these variables is set; otherwise connections are made directly. Loopback hosts (`localhost`, `127.0.0.1`, `::1`) always bypass the proxy, so a local server such as a localhost MCP server keeps working when a proxy is configured — add your own internal hosts to `NO_PROXY` to exempt them too. Stdio MCP servers that run as Node processes honor the same settings automatically. + ## Next steps - [Config overrides](./overrides.md) — how environment variables, CLI options, and the config file interact by priority diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 2b9b913a9..ecec03620 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -163,6 +163,16 @@ CLI 还会读取一些标准系统变量来检测运行环境,不会修改它 - `WSL_DISTRO_NAME`、`WSLENV`:检测 WSL,用于剪贴板 PowerShell 桥接 - `LOCALAPPDATA`:Windows 上探测 Git Bash 安装路径 +## HTTP 代理 + +Kimi Code 会遵循标准代理环境变量,让所有出网 HTTP(S) 流量——模型 API 调用、MCP 服务、网络工具、遥测、登录、更新检查——都走代理: + +- `HTTP_PROXY` / `http_proxy`:用于 `http://` 请求的代理 +- `HTTPS_PROXY` / `https_proxy`:用于 `https://` 请求的代理 +- `NO_PROXY` / `no_proxy`:以逗号分隔的、绕过代理的主机列表 + +仅当设置了其中任一变量时才启用代理,否则直连。回环地址(`localhost`、`127.0.0.1`、`::1`)始终绕过代理,因此配置了代理后,本地服务(例如 localhost 上的 MCP 服务)仍能正常工作——你也可以把自己的内网主机加入 `NO_PROXY` 一并放行。以 Node 进程运行的 stdio MCP 服务会自动遵循同样的设置。 + ## 下一步 - [配置覆盖](./overrides.md) — 环境变量、CLI 选项、配置文件的优先级关系 diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index aaa20927b..ef82b788f 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -72,6 +72,7 @@ "retry": "0.13.1", "smol-toml": "^1.6.1", "tar": "^7.5.13", + "undici": "^7.27.1", "yauzl": "^3.3.0", "zod": "catalog:" }, diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 8c28740f3..a35781ccf 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -16,6 +16,7 @@ export { } from './logging/logger'; export { resolveLoggingConfig } from './logging/resolve-config'; export type { ResolveLoggingInput } from './logging/resolve-config'; +export { installGlobalProxyDispatcher } from './utils/proxy'; export type { LogContext, LogEntry, diff --git a/packages/agent-core/src/mcp/client-stdio.ts b/packages/agent-core/src/mcp/client-stdio.ts index 55c31aeb1..47b58fafa 100644 --- a/packages/agent-core/src/mcp/client-stdio.ts +++ b/packages/agent-core/src/mcp/client-stdio.ts @@ -1,5 +1,6 @@ import { ErrorCodes, KimiError } from '#/errors'; import type { McpServerStdioConfig } from '#/config/schema'; +import { proxyEnvForChild } from '#/utils/proxy'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; @@ -216,13 +217,16 @@ class BoundedTail { } // Inherit the parent's env so PATH/HOME/etc. survive — otherwise `npx`/`uvx` -// style stdio servers fail to launch even with a valid config. Explicit -// `config.env` entries still override on conflict. +// style stdio servers fail to launch even with a valid config. A node child +// does not inherit our in-process undici dispatcher, so `proxyEnvForChild` +// adds `NODE_USE_ENV_PROXY` (and a loopback-protected `NO_PROXY`) to make it +// honor the proxy natively. Explicit `config.env` entries still override. function mergeStdioEnv(configEnv?: Record): Record { const merged: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (value !== undefined) merged[key] = value; } + Object.assign(merged, proxyEnvForChild()); if (configEnv !== undefined) Object.assign(merged, configEnv); return merged; } diff --git a/packages/agent-core/src/utils/proxy.ts b/packages/agent-core/src/utils/proxy.ts new file mode 100644 index 000000000..6afd97ffc --- /dev/null +++ b/packages/agent-core/src/utils/proxy.ts @@ -0,0 +1,121 @@ +import { type Dispatcher, EnvHttpProxyAgent, setGlobalDispatcher as undiciSetGlobalDispatcher } from 'undici'; + +type Env = Readonly>; + +// Loopback hosts always bypass the proxy. Neither undici's EnvHttpProxyAgent +// nor Node's `--use-env-proxy` exempt loopback by default, so without this a +// user with HTTP_PROXY set would route `http://localhost:PORT` traffic (e.g. a +// local MCP server) through a corporate proxy that refuses loopback — a +// confusing failure that only proxy users would hit. +const LOOPBACK_NO_PROXY = ['localhost', '127.0.0.1', '::1'] as const; + +// The standard proxy variables undici honors, in both casings. ALL_PROXY is +// intentionally omitted: EnvHttpProxyAgent has no equivalent option, so +// advertising support for it would mislead. +const PROXY_ENV_KEYS = ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY'] as const; + +/** True when any standard HTTP(S) proxy variable is set to a non-blank value. */ +export function isProxyConfigured(env: Env = process.env): boolean { + return PROXY_ENV_KEYS.some((key) => (env[key]?.trim() ?? '').length > 0); +} + +/** + * The effective `NO_PROXY` with loopback hosts guaranteed present so local + * traffic stays direct. Reads both casings (lowercase first when non-blank, + * matching undici), preserves the user's entries, and appends only the missing + * loopback hosts. + * + * The `*` wildcard ("bypass everything") is returned verbatim: undici only + * honors it as an exact-string match, so appending loopback would silently + * defeat the user's explicit opt-out and route all non-loopback traffic + * through the proxy. + */ +export function resolveNoProxy(env: Env = process.env): 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) ?? ''; + const hosts = raw + .split(',') + .map((host) => host.trim()) + .filter((host) => host.length > 0); + if (hosts.includes('*')) return '*'; + for (const loopback of LOOPBACK_NO_PROXY) { + if (!hosts.includes(loopback)) hosts.push(loopback); + } + return hosts.join(','); +} + +/** Builds the proxy dispatcher; injectable so unit tests avoid real sockets. */ +export type ProxyAgentFactory = (options: { noProxy: string }) => Dispatcher; + +const defaultProxyAgentFactory: ProxyAgentFactory = ({ noProxy }) => + // EnvHttpProxyAgent reads HTTP_PROXY/HTTPS_PROXY from process.env itself; we + // only override noProxy to guarantee the loopback bypass. + new EnvHttpProxyAgent({ noProxy }); + +/** + * Build an undici dispatcher that routes outbound `fetch` through + * `HTTP_PROXY`/`HTTPS_PROXY` while honoring the (loopback-augmented) + * `NO_PROXY`. Returns `undefined` when no proxy variable is set, so the + * zero-config majority keeps Node's default dispatcher untouched. + */ +export function createProxyDispatcher( + env: Env = process.env, + makeAgent: ProxyAgentFactory = defaultProxyAgentFactory, +): Dispatcher | undefined { + if (!isProxyConfigured(env)) return undefined; + try { + return makeAgent({ noProxy: resolveNoProxy(env) }); + } catch (error) { + // A malformed proxy URL makes EnvHttpProxyAgent throw synchronously. Don't + // abort startup with a raw stack trace — report it and fall back to direct. + const reason = error instanceof Error ? error.message : String(error); + process.stderr.write(`kimi: ignoring invalid HTTP_PROXY/HTTPS_PROXY (${reason}); connecting directly\n`); + return undefined; + } +} + +export interface InstallProxyDeps { + readonly setGlobalDispatcher: (dispatcher: Dispatcher) => void; + readonly createProxyDispatcher: (env: Env) => Dispatcher | undefined; +} + +const defaultInstallProxyDeps: InstallProxyDeps = { + setGlobalDispatcher: undiciSetGlobalDispatcher, + createProxyDispatcher, +}; + +/** + * Install the proxy dispatcher as the process-wide undici dispatcher so every + * `fetch` — LLM SDKs, in-process MCP HTTP, telemetry, OAuth, web tools, update + * checks, downloads — honors the proxy. Call once at process startup, before + * any network use. No-op (returns `false`) when no proxy variable is set. + */ +export function installGlobalProxyDispatcher( + env: Env = process.env, + deps: InstallProxyDeps = defaultInstallProxyDeps, +): boolean { + const dispatcher = deps.createProxyDispatcher(env); + if (dispatcher === undefined) return false; + deps.setGlobalDispatcher(dispatcher); + return true; +} + +/** + * Environment additions for spawned child node processes (e.g. stdio MCP + * servers) so they honor the proxy natively via Node's `--use-env-proxy` + * without bundling undici. An in-process global dispatcher is NOT inherited + * across a process boundary — only env vars are — so children rely on this. + * + * Returns `{}` when no proxy is configured. `NODE_USE_ENV_PROXY` is harmless + * for non-node children (ignored) and for node children without a proxy var. + * + * Sets `NO_PROXY` in BOTH casings: the child inherits the parent's env, and + * undici reads the lowercase `no_proxy` first — so an inherited un-augmented + * lowercase value would otherwise defeat the loopback protection. + */ +export function proxyEnvForChild(env: Env = process.env): Record { + if (!isProxyConfigured(env)) return {}; + const noProxy = resolveNoProxy(env); + return { NODE_USE_ENV_PROXY: '1', NO_PROXY: noProxy, no_proxy: noProxy }; +} diff --git a/packages/agent-core/test/utils/proxy.test.ts b/packages/agent-core/test/utils/proxy.test.ts new file mode 100644 index 000000000..ecfddd191 --- /dev/null +++ b/packages/agent-core/test/utils/proxy.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createProxyDispatcher, + installGlobalProxyDispatcher, + isProxyConfigured, + proxyEnvForChild, + resolveNoProxy, +} from '../../src/utils/proxy'; + +describe('isProxyConfigured', () => { + it('is false when no proxy variable is set', () => { + expect(isProxyConfigured({})).toBe(false); + }); + + it('is true for HTTP_PROXY and the lowercase form', () => { + expect(isProxyConfigured({ HTTP_PROXY: 'http://p:3128' })).toBe(true); + expect(isProxyConfigured({ http_proxy: 'http://p:3128' })).toBe(true); + }); + + it('is true for HTTPS_PROXY', () => { + expect(isProxyConfigured({ HTTPS_PROXY: 'http://p:3128' })).toBe(true); + }); + + it('ignores blank values', () => { + expect(isProxyConfigured({ HTTP_PROXY: ' ' })).toBe(false); + }); +}); + +describe('resolveNoProxy', () => { + it('adds loopback hosts when NO_PROXY is unset', () => { + expect(resolveNoProxy({})).toBe('localhost,127.0.0.1,::1'); + }); + + it('preserves existing hosts and appends only the missing loopback hosts', () => { + expect(resolveNoProxy({ NO_PROXY: 'example.com, 127.0.0.1' })).toBe( + 'example.com,127.0.0.1,localhost,::1', + ); + }); + + it('reads the lowercase no_proxy', () => { + expect(resolveNoProxy({ no_proxy: 'internal' })).toBe('internal,localhost,127.0.0.1,::1'); + }); + + it('preserves the "*" wildcard verbatim (it must stay an exact match to bypass everything)', () => { + expect(resolveNoProxy({ NO_PROXY: '*' })).toBe('*'); + expect(resolveNoProxy({ NO_PROXY: 'corp, *' })).toBe('*'); + }); + + it('falls through to NO_PROXY when no_proxy is set but blank', () => { + expect(resolveNoProxy({ no_proxy: '', NO_PROXY: 'corp' })).toBe('corp,localhost,127.0.0.1,::1'); + }); +}); + +describe('createProxyDispatcher', () => { + it('returns undefined and never builds an agent when no proxy is set', () => { + const makeAgent = vi.fn(); + expect(createProxyDispatcher({}, makeAgent)).toBeUndefined(); + expect(makeAgent).not.toHaveBeenCalled(); + }); + + it('builds an agent with loopback-protected NO_PROXY when a proxy is set', () => { + const sentinel = { id: 'agent' } as never; + const makeAgent = vi.fn().mockReturnValue(sentinel); + const result = createProxyDispatcher({ HTTP_PROXY: 'http://p:3128', NO_PROXY: 'corp' }, makeAgent); + expect(result).toBe(sentinel); + expect(makeAgent).toHaveBeenCalledWith({ noProxy: 'corp,localhost,127.0.0.1,::1' }); + }); + + it('reports and ignores an invalid proxy configuration instead of crashing', () => { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const makeAgent = vi.fn(() => { + throw new TypeError('Invalid URL'); + }); + try { + expect(createProxyDispatcher({ HTTP_PROXY: 'gibberish' }, makeAgent)).toBeUndefined(); + expect(makeAgent).toHaveBeenCalledTimes(1); + expect(stderr).toHaveBeenCalled(); + } finally { + stderr.mockRestore(); + } + }); +}); + +describe('installGlobalProxyDispatcher', () => { + it('installs the dispatcher exactly once and returns true when a proxy is set', () => { + const dispatcher = { id: 'dispatcher' } as never; + const setGlobalDispatcher = vi.fn(); + const createDispatcher = vi.fn().mockReturnValue(dispatcher); + const installed = installGlobalProxyDispatcher( + { HTTP_PROXY: 'http://p:3128' }, + { setGlobalDispatcher, createProxyDispatcher: createDispatcher }, + ); + expect(installed).toBe(true); + expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); + expect(setGlobalDispatcher).toHaveBeenCalledWith(dispatcher); + }); + + it('installs nothing and returns false when no proxy is set', () => { + const setGlobalDispatcher = vi.fn(); + const createDispatcher = vi.fn().mockReturnValue(undefined); + const installed = installGlobalProxyDispatcher( + {}, + { setGlobalDispatcher, createProxyDispatcher: createDispatcher }, + ); + expect(installed).toBe(false); + expect(setGlobalDispatcher).not.toHaveBeenCalled(); + }); +}); + +describe('proxyEnvForChild', () => { + it('returns an empty object when no proxy is configured', () => { + expect(proxyEnvForChild({})).toEqual({}); + }); + + it('enables Node native env-proxy and protects loopback for spawned node children', () => { + // Sets BOTH casings: a child inherits the parent's env, and undici reads + // the lowercase no_proxy first — so the lowercase form must also carry the + // loopback-augmented value or the protection 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', + no_proxy: 'corp,localhost,127.0.0.1,::1', + }); + }); + + it('passes the "*" wildcard through to the child verbatim in both casings', () => { + expect(proxyEnvForChild({ HTTP_PROXY: 'http://p:3128', NO_PROXY: '*' })).toEqual({ + NODE_USE_ENV_PROXY: '1', + NO_PROXY: '*', + no_proxy: '*', + }); + }); +}); diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 840c738bd..8c5938d57 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -62,6 +62,10 @@ export { } from '@moonshot-ai/agent-core'; export type { LogContext, LogLevel, LogPayload, Logger } from '@moonshot-ai/agent-core'; +// Process-wide HTTP proxy bootstrap — installed once at CLI startup so all +// outbound fetch honors HTTP_PROXY / HTTPS_PROXY / NO_PROXY. +export { installGlobalProxyDispatcher } from '@moonshot-ai/agent-core'; + // Goal completion message builder — single source of truth for the deterministic // "Goal complete · turns · tokens · time" text (live render + persisted message). export { buildGoalCompletionMessage } from '@moonshot-ai/agent-core'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6279480f9..f8135061a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,6 +280,9 @@ importers: tar: specifier: ^7.5.13 version: 7.5.13 + undici: + specifier: ^7.27.1 + version: 7.27.1 yauzl: specifier: ^3.3.0 version: 3.3.0 @@ -5147,6 +5150,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.27.1: + resolution: {integrity: sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==} + engines: {node: '>=20.18.1'} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} @@ -10346,6 +10353,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.27.1: {} + unicode-emoji-modifier-base@1.0.0: {} unified@11.0.5: From ed46887703c0de5c214529670f0e117f5668bc6e Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 5 Jun 2026 20:32:29 +0800 Subject: [PATCH 2/7] feat: support SOCKS proxies via ALL_PROXY Recognize SOCKS proxies (socks5/socks5h/socks4/socks alias) from ALL_PROXY or a socks-scheme HTTP(S)_PROXY, routing traffic through a custom undici connector backed by the socks client (reusing undici's own TLS handling for https). HTTP(S) proxies keep precedence; NO_PROXY and loopback are honored for the SOCKS path too. Child stdio MCP node processes honor HTTP(S) proxies via NODE_USE_ENV_PROXY; SOCKS applies to the main process only. --- .changeset/http-proxy-support.md | 4 +- docs/en/configuration/env-vars.md | 13 +- docs/zh/configuration/env-vars.md | 9 +- packages/agent-core/package.json | 1 + packages/agent-core/src/utils/proxy.ts | 197 ++++++++++++++++--- packages/agent-core/test/utils/proxy.test.ts | 166 ++++++++++++++-- pnpm-lock.yaml | 24 +++ 7 files changed, 364 insertions(+), 50 deletions(-) diff --git a/.changeset/http-proxy-support.md b/.changeset/http-proxy-support.md index 5b000c8a6..9f1c33527 100644 --- a/.changeset/http-proxy-support.md +++ b/.changeset/http-proxy-support.md @@ -4,6 +4,6 @@ "@moonshot-ai/kimi-code": minor --- -Honor the standard `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` environment variables for all outbound HTTP(S) traffic — model API calls, MCP servers, web tools, telemetry, sign-in, and update checks. +Honor the standard `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` environment variables for all outbound traffic — model API calls, MCP servers, web tools, telemetry, sign-in, and update checks. Both HTTP(S) and SOCKS proxies are supported (`ALL_PROXY=socks5://…`, the form used by Clash / V2RayN). -A global proxy dispatcher is installed at startup only when a proxy variable is set, so the zero-config default is unchanged. Loopback hosts (`localhost`, `127.0.0.1`, `::1`) always bypass the proxy so local servers (e.g. a localhost MCP server) keep working, and stdio MCP servers running as Node processes honor the proxy automatically. +A global proxy dispatcher is installed at startup only when a proxy variable is set, so the zero-config default is unchanged. Loopback hosts (`localhost`, `127.0.0.1`, `::1`) always bypass the proxy so local servers (e.g. a localhost MCP server) keep working, and stdio MCP servers running as Node processes honor HTTP(S) proxies automatically. diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 051c8176a..0f40385f7 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -165,13 +165,18 @@ The CLI also reads several standard system variables to detect the runtime envir ## HTTP proxy -Kimi Code honors the standard proxy environment variables for all outbound HTTP(S) traffic — model API calls, MCP servers, web tools, telemetry, sign-in, and update checks: +Kimi Code honors the standard proxy environment variables for all outbound traffic — model API calls, MCP servers, web tools, telemetry, sign-in, and update checks: -- `HTTP_PROXY` / `http_proxy`: proxy used for `http://` requests -- `HTTPS_PROXY` / `https_proxy`: proxy used for `https://` requests +- `HTTP_PROXY` / `http_proxy`: proxy for `http://` requests +- `HTTPS_PROXY` / `https_proxy`: proxy for `https://` requests +- `ALL_PROXY` / `all_proxy`: fallback proxy used when the scheme-specific variable is unset; this is where a SOCKS proxy is usually set - `NO_PROXY` / `no_proxy`: comma-separated hosts that bypass the proxy -The proxy is applied only when one of these variables is set; otherwise connections are made directly. Loopback hosts (`localhost`, `127.0.0.1`, `::1`) always bypass the proxy, so a local server such as a localhost MCP server keeps working when a proxy is configured — add your own internal hosts to `NO_PROXY` to exempt them too. Stdio MCP servers that run as Node processes honor the same settings automatically. +Both HTTP(S) and SOCKS proxies are supported. A SOCKS proxy is recognized by its scheme — `socks5://`, `socks5h://`, `socks4://`, or `socks://` (an alias for `socks5://`) — and is typically set via `ALL_PROXY` (the form used by tools like Clash and V2RayN). An HTTP(S) proxy takes precedence over `ALL_PROXY` for HTTP/HTTPS traffic. + +The proxy is applied only when one of these variables is set; otherwise connections are made directly. Loopback hosts (`localhost`, `127.0.0.1`, `::1`) always bypass the proxy, so a local server such as a localhost MCP server keeps working when a proxy is configured — add your own internal hosts to `NO_PROXY` to exempt them too. + +Stdio MCP servers that run as Node child processes honor `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` automatically; SOCKS proxying applies to Kimi Code's own traffic only. ## Next steps diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index ecec03620..d1b50f299 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -165,13 +165,18 @@ CLI 还会读取一些标准系统变量来检测运行环境,不会修改它 ## HTTP 代理 -Kimi Code 会遵循标准代理环境变量,让所有出网 HTTP(S) 流量——模型 API 调用、MCP 服务、网络工具、遥测、登录、更新检查——都走代理: +Kimi Code 会遵循标准代理环境变量,让所有出网流量——模型 API 调用、MCP 服务、网络工具、遥测、登录、更新检查——都走代理: - `HTTP_PROXY` / `http_proxy`:用于 `http://` 请求的代理 - `HTTPS_PROXY` / `https_proxy`:用于 `https://` 请求的代理 +- `ALL_PROXY` / `all_proxy`:当对应 scheme 的变量未设置时使用的兜底代理;SOCKS 代理通常设在这里 - `NO_PROXY` / `no_proxy`:以逗号分隔的、绕过代理的主机列表 -仅当设置了其中任一变量时才启用代理,否则直连。回环地址(`localhost`、`127.0.0.1`、`::1`)始终绕过代理,因此配置了代理后,本地服务(例如 localhost 上的 MCP 服务)仍能正常工作——你也可以把自己的内网主机加入 `NO_PROXY` 一并放行。以 Node 进程运行的 stdio MCP 服务会自动遵循同样的设置。 +同时支持 HTTP(S) 代理和 SOCKS 代理。SOCKS 代理通过 scheme 识别——`socks5://`、`socks5h://`、`socks4://` 或 `socks://`(`socks5://` 的别名)——通常设在 `ALL_PROXY`(Clash、V2RayN 等工具使用的形式)。对 HTTP/HTTPS 流量,HTTP(S) 代理优先于 `ALL_PROXY`。 + +仅当设置了其中任一变量时才启用代理,否则直连。回环地址(`localhost`、`127.0.0.1`、`::1`)始终绕过代理,因此配置了代理后,本地服务(例如 localhost 上的 MCP 服务)仍能正常工作——你也可以把自己的内网主机加入 `NO_PROXY` 一并放行。 + +以 Node 子进程运行的 stdio MCP 服务会自动遵循 `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`;SOCKS 代理仅作用于 Kimi Code 自身的流量。 ## 下一步 diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index ef82b788f..ea6e70f67 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -71,6 +71,7 @@ "regexp.escape": "^2.0.1", "retry": "0.13.1", "smol-toml": "^1.6.1", + "socks": "^2.8.9", "tar": "^7.5.13", "undici": "^7.27.1", "yauzl": "^3.3.0", diff --git a/packages/agent-core/src/utils/proxy.ts b/packages/agent-core/src/utils/proxy.ts index 6afd97ffc..e046623b4 100644 --- a/packages/agent-core/src/utils/proxy.ts +++ b/packages/agent-core/src/utils/proxy.ts @@ -1,22 +1,92 @@ -import { type Dispatcher, EnvHttpProxyAgent, setGlobalDispatcher as undiciSetGlobalDispatcher } from 'undici'; +import { + Agent, + buildConnector, + type Dispatcher, + EnvHttpProxyAgent, + setGlobalDispatcher as undiciSetGlobalDispatcher, +} from 'undici'; +import { SocksClient } from 'socks'; type Env = Readonly>; -// Loopback hosts always bypass the proxy. Neither undici's EnvHttpProxyAgent -// nor Node's `--use-env-proxy` exempt loopback by default, so without this a -// user with HTTP_PROXY set would route `http://localhost:PORT` traffic (e.g. a -// local MCP server) through a corporate proxy that refuses loopback — a -// confusing failure that only proxy users would hit. +/** A parsed SOCKS proxy endpoint, in the shape the `socks` client expects. */ +export interface SocksProxyConfig { + /** SOCKS protocol version: 4 (socks4/socks4a) or 5 (socks/socks5/socks5h). */ + readonly type: 4 | 5; + readonly host: string; + readonly port: number; + readonly userId?: string; + readonly password?: string; +} + +// Loopback hosts always bypass the proxy. Neither undici's EnvHttpProxyAgent, +// Node's `--use-env-proxy`, nor our SOCKS connector exempt loopback by default, +// so without this a user with a proxy set would route `http://localhost:PORT` +// traffic (e.g. a local MCP server) through the proxy — a confusing failure +// that only proxy users would hit. const LOOPBACK_NO_PROXY = ['localhost', '127.0.0.1', '::1'] as const; -// The standard proxy variables undici honors, in both casings. ALL_PROXY is -// intentionally omitted: EnvHttpProxyAgent has no equivalent option, so -// advertising support for it would mislead. -const PROXY_ENV_KEYS = ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY'] as const; +const SOCKS_SCHEMES = new Set(['socks', 'socks4', 'socks4a', 'socks5', 'socks5h']); + +/** Lowercase URL scheme (without the trailing colon), or undefined if absent. */ +function schemeOf(value: string): string | undefined { + return /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase(); +} + +/** First non-blank value among `keys` (both casings are passed in by callers). */ +function firstNonBlank(env: Env, keys: readonly string[]): string | undefined { + for (const key of keys) { + const value = env[key]?.trim(); + if (value !== undefined && value.length > 0) return value; + } + return undefined; +} + +/** True when an HTTP- or HTTPS-scheme proxy (not a SOCKS one) is configured. */ +function hasHttpProxy(env: Env): boolean { + return [ + firstNonBlank(env, ['http_proxy', 'HTTP_PROXY']), + firstNonBlank(env, ['https_proxy', 'HTTPS_PROXY']), + ].some((value) => value !== undefined && !SOCKS_SCHEMES.has(schemeOf(value) ?? '')); +} + +/** + * Resolve a SOCKS proxy from the environment, or `undefined` if none. A SOCKS + * proxy may be declared via `ALL_PROXY` (the common form for Clash / V2RayN) or + * by putting a `socks*` scheme in `HTTP(S)_PROXY`. `ALL_PROXY` wins, then + * `HTTPS_PROXY`, then `HTTP_PROXY`. `socks://` is an alias for `socks5://`. + */ +export function resolveSocksProxy(env: Env = process.env): SocksProxyConfig | undefined { + const candidates = [ + firstNonBlank(env, ['all_proxy', 'ALL_PROXY']), + firstNonBlank(env, ['https_proxy', 'HTTPS_PROXY']), + firstNonBlank(env, ['http_proxy', 'HTTP_PROXY']), + ]; + for (const value of candidates) { + if (value === undefined) continue; + const scheme = schemeOf(value); + if (scheme === undefined || !SOCKS_SCHEMES.has(scheme)) continue; + let url: URL; + try { + url = new URL(value); + } catch { + continue; + } + const config: SocksProxyConfig = { + type: scheme === 'socks4' || scheme === 'socks4a' ? 4 : 5, + host: url.hostname, + port: url.port ? Number(url.port) : 1080, + ...(url.username ? { userId: decodeURIComponent(url.username) } : {}), + ...(url.password ? { password: decodeURIComponent(url.password) } : {}), + }; + return config; + } + return undefined; +} -/** True when any standard HTTP(S) proxy variable is set to a non-blank value. */ +/** True when any HTTP(S) or SOCKS proxy variable is set to a usable value. */ export function isProxyConfigured(env: Env = process.env): boolean { - return PROXY_ENV_KEYS.some((key) => (env[key]?.trim() ?? '').length > 0); + return hasHttpProxy(env) || resolveSocksProxy(env) !== undefined; } /** @@ -45,32 +115,100 @@ export function resolveNoProxy(env: Env = process.env): string { return hosts.join(','); } -/** Builds the proxy dispatcher; injectable so unit tests avoid real sockets. */ -export type ProxyAgentFactory = (options: { noProxy: string }) => Dispatcher; +/** + * Build a predicate that returns true when a host should bypass the proxy, + * given a `NO_PROXY` string. Matches `*` (all), exact hosts, and subdomains for + * both bare (`example.com`) and leading-dot (`.example.com`) entries. Used for + * the SOCKS path, where bypass is not handled by undici for us. + */ +export function makeNoProxyMatcher(noProxy: string): (host: string) => boolean { + const entries = noProxy + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry.length > 0); + if (entries.includes('*')) return () => true; + const hosts = [...new Set(entries.map((entry) => (entry.startsWith('.') ? entry.slice(1) : entry)))]; + return (host: string) => { + const target = host.toLowerCase(); + return hosts.some((entry) => target === entry || target.endsWith(`.${entry}`)); + }; +} + +export interface ProxyAgentFactories { + /** Build the dispatcher for an HTTP/HTTPS proxy (reads HTTP(S)_PROXY from env). */ + readonly makeHttpAgent: (options: { noProxy: string }) => Dispatcher; + /** Build the dispatcher for a SOCKS proxy. */ + readonly makeSocksAgent: (options: { proxy: SocksProxyConfig; noProxy: string }) => Dispatcher; +} -const defaultProxyAgentFactory: ProxyAgentFactory = ({ noProxy }) => +const defaultMakeHttpAgent: ProxyAgentFactories['makeHttpAgent'] = ({ noProxy }) => // EnvHttpProxyAgent reads HTTP_PROXY/HTTPS_PROXY from process.env itself; we // only override noProxy to guarantee the loopback bypass. new EnvHttpProxyAgent({ noProxy }); +const defaultMakeSocksAgent: ProxyAgentFactories['makeSocksAgent'] = ({ proxy, noProxy }) => { + // undici has no SOCKS support, so we drive a custom connector: tunnel the + // destination through the SOCKS proxy with the `socks` client, then hand the + // established socket back to undici's connector — which performs the TLS + // upgrade for https targets (reusing undici's ALPN/servername handling). + const directConnect = buildConnector({}); + const bypass = makeNoProxyMatcher(noProxy); + const connect: typeof directConnect = (options, callback) => { + if (bypass(options.hostname)) { + directConnect(options, callback); + return; + } + void (async () => { + try { + const isTls = options.protocol === 'https:'; + const port = Number(options.port) || (isTls ? 443 : 80); + const { socket } = await SocksClient.createConnection({ + proxy: { host: proxy.host, port: proxy.port, type: proxy.type, userId: proxy.userId, password: proxy.password }, + command: 'connect', + destination: { host: options.hostname, port }, + }); + if (isTls) { + // Upgrade the SOCKS socket to TLS via undici's own connector. + directConnect({ ...options, httpSocket: socket } as Parameters[0], callback); + } else { + socket.setNoDelay(true); + callback(null, socket); + } + } catch (error) { + callback(error instanceof Error ? error : new Error(String(error)), null); + } + })(); + }; + return new Agent({ connect }); +}; + /** - * Build an undici dispatcher that routes outbound `fetch` through - * `HTTP_PROXY`/`HTTPS_PROXY` while honoring the (loopback-augmented) - * `NO_PROXY`. Returns `undefined` when no proxy variable is set, so the - * zero-config majority keeps Node's default dispatcher untouched. + * Build an undici dispatcher that routes outbound `fetch` through the + * configured proxy while honoring the (loopback-augmented) `NO_PROXY`. An + * HTTP/HTTPS proxy takes precedence for matching traffic; otherwise a SOCKS + * proxy (`ALL_PROXY` or a `socks*` scheme) is used. Returns `undefined` when no + * proxy variable is set, so the zero-config majority keeps Node's default + * dispatcher untouched. */ export function createProxyDispatcher( env: Env = process.env, - makeAgent: ProxyAgentFactory = defaultProxyAgentFactory, + factories: Partial = {}, ): Dispatcher | undefined { - if (!isProxyConfigured(env)) return undefined; + const { makeHttpAgent = defaultMakeHttpAgent, makeSocksAgent = defaultMakeSocksAgent } = factories; try { - return makeAgent({ noProxy: resolveNoProxy(env) }); + if (hasHttpProxy(env)) { + return makeHttpAgent({ noProxy: resolveNoProxy(env) }); + } + const socks = resolveSocksProxy(env); + if (socks !== undefined) { + return makeSocksAgent({ proxy: socks, noProxy: resolveNoProxy(env) }); + } + return undefined; } catch (error) { - // A malformed proxy URL makes EnvHttpProxyAgent throw synchronously. Don't + // A malformed proxy URL makes agent construction throw synchronously. Don't // abort startup with a raw stack trace — report it and fall back to direct. const reason = error instanceof Error ? error.message : String(error); - process.stderr.write(`kimi: ignoring invalid HTTP_PROXY/HTTPS_PROXY (${reason}); connecting directly\n`); + process.stderr.write(`kimi: ignoring invalid proxy configuration (${reason}); connecting directly\n`); return undefined; } } @@ -107,15 +245,14 @@ export function installGlobalProxyDispatcher( * without bundling undici. An in-process global dispatcher is NOT inherited * across a process boundary — only env vars are — so children rely on this. * - * Returns `{}` when no proxy is configured. `NODE_USE_ENV_PROXY` is harmless - * for non-node children (ignored) and for node children without a proxy var. - * - * Sets `NO_PROXY` in BOTH casings: the child inherits the parent's env, and - * undici reads the lowercase `no_proxy` first — so an inherited un-augmented + * Only applies to HTTP/HTTPS proxies: Node's `--use-env-proxy` does not support + * SOCKS, so a SOCKS-only proxy yields `{}` (child SOCKS proxying is out of + * scope). Sets `NO_PROXY` in BOTH casings: the child inherits the parent's env + * and undici reads the lowercase `no_proxy` first, so an inherited un-augmented * lowercase value would otherwise defeat the loopback protection. */ export function proxyEnvForChild(env: Env = process.env): Record { - if (!isProxyConfigured(env)) return {}; + if (!hasHttpProxy(env)) return {}; const noProxy = resolveNoProxy(env); return { NODE_USE_ENV_PROXY: '1', NO_PROXY: noProxy, no_proxy: noProxy }; } diff --git a/packages/agent-core/test/utils/proxy.test.ts b/packages/agent-core/test/utils/proxy.test.ts index ecfddd191..da1b8a4a9 100644 --- a/packages/agent-core/test/utils/proxy.test.ts +++ b/packages/agent-core/test/utils/proxy.test.ts @@ -4,8 +4,10 @@ import { createProxyDispatcher, installGlobalProxyDispatcher, isProxyConfigured, + makeNoProxyMatcher, proxyEnvForChild, resolveNoProxy, + resolveSocksProxy, } from '../../src/utils/proxy'; describe('isProxyConfigured', () => { @@ -25,6 +27,10 @@ describe('isProxyConfigured', () => { it('ignores blank values', () => { expect(isProxyConfigured({ HTTP_PROXY: ' ' })).toBe(false); }); + + it('is true when only a SOCKS proxy (ALL_PROXY) is set', () => { + expect(isProxyConfigured({ ALL_PROXY: 'socks5://127.0.0.1:1080' })).toBe(true); + }); }); describe('resolveNoProxy', () => { @@ -52,29 +58,161 @@ describe('resolveNoProxy', () => { }); }); +describe('resolveSocksProxy', () => { + it('returns undefined when no SOCKS proxy is configured', () => { + expect(resolveSocksProxy({})).toBeUndefined(); + expect(resolveSocksProxy({ HTTP_PROXY: 'http://p:3128' })).toBeUndefined(); + }); + + it('parses ALL_PROXY socks5 and defaults the port to 1080', () => { + expect(resolveSocksProxy({ ALL_PROXY: 'socks5://10.0.0.1' })).toEqual({ + type: 5, + host: '10.0.0.1', + port: 1080, + }); + }); + + it('normalizes the socks:// alias to socks5', () => { + expect(resolveSocksProxy({ ALL_PROXY: 'socks://127.0.0.1:7890' })).toEqual({ + type: 5, + host: '127.0.0.1', + port: 7890, + }); + }); + + it('parses socks4 as type 4', () => { + expect(resolveSocksProxy({ ALL_PROXY: 'socks4://127.0.0.1:1080' })).toEqual({ + type: 4, + host: '127.0.0.1', + port: 1080, + }); + }); + + it('reads credentials from the URL', () => { + expect(resolveSocksProxy({ ALL_PROXY: 'socks5://user:pass@127.0.0.1:1080' })).toEqual({ + type: 5, + host: '127.0.0.1', + port: 1080, + userId: 'user', + password: 'pass', + }); + }); + + it('picks up a SOCKS scheme set in HTTP_PROXY', () => { + expect(resolveSocksProxy({ HTTP_PROXY: 'socks5://127.0.0.1:1080' })).toEqual({ + type: 5, + host: '127.0.0.1', + port: 1080, + }); + }); + + it('prefers ALL_PROXY over a SOCKS value in HTTPS_PROXY', () => { + expect(resolveSocksProxy({ ALL_PROXY: 'socks5://a:1', HTTPS_PROXY: 'socks5://b:2' })).toEqual({ + type: 5, + host: 'a', + port: 1, + }); + }); + + it('is case-insensitive on the scheme', () => { + expect(resolveSocksProxy({ ALL_PROXY: 'SOCKS5://127.0.0.1:1080' })).toEqual({ + type: 5, + host: '127.0.0.1', + port: 1080, + }); + }); +}); + +describe('makeNoProxyMatcher', () => { + it('bypasses everything for the "*" wildcard', () => { + const bypass = makeNoProxyMatcher('*'); + expect(bypass('example.com')).toBe(true); + expect(bypass('127.0.0.1')).toBe(true); + }); + + it('bypasses listed hosts and loopback, not others', () => { + const bypass = makeNoProxyMatcher('localhost,127.0.0.1,::1,corp.internal'); + expect(bypass('localhost')).toBe(true); + expect(bypass('127.0.0.1')).toBe(true); + expect(bypass('corp.internal')).toBe(true); + expect(bypass('example.com')).toBe(false); + }); + + it('matches subdomains for both bare and leading-dot entries', () => { + const bypass = makeNoProxyMatcher('.example.com,foo.org'); + expect(bypass('a.example.com')).toBe(true); + expect(bypass('example.com')).toBe(true); + expect(bypass('sub.foo.org')).toBe(true); + expect(bypass('foo.org')).toBe(true); + expect(bypass('other.com')).toBe(false); + }); + + it('is case-insensitive', () => { + expect(makeNoProxyMatcher('Corp.Internal')('corp.INTERNAL')).toBe(true); + }); + + it('never bypasses when NO_PROXY is empty', () => { + expect(makeNoProxyMatcher('')('example.com')).toBe(false); + }); +}); + describe('createProxyDispatcher', () => { - it('returns undefined and never builds an agent when no proxy is set', () => { - const makeAgent = vi.fn(); - expect(createProxyDispatcher({}, makeAgent)).toBeUndefined(); - expect(makeAgent).not.toHaveBeenCalled(); + it('returns undefined and builds nothing when no proxy is set', () => { + const makeHttpAgent = vi.fn(); + const makeSocksAgent = vi.fn(); + expect(createProxyDispatcher({}, { makeHttpAgent, makeSocksAgent })).toBeUndefined(); + expect(makeHttpAgent).not.toHaveBeenCalled(); + expect(makeSocksAgent).not.toHaveBeenCalled(); + }); + + it('builds an HTTP-proxy agent with loopback-protected NO_PROXY', () => { + const sentinel = { id: 'http' } as never; + const makeHttpAgent = vi.fn().mockReturnValue(sentinel); + const makeSocksAgent = vi.fn(); + const result = createProxyDispatcher( + { HTTP_PROXY: 'http://p:3128', NO_PROXY: 'corp' }, + { makeHttpAgent, makeSocksAgent }, + ); + expect(result).toBe(sentinel); + expect(makeHttpAgent).toHaveBeenCalledWith({ noProxy: 'corp,localhost,127.0.0.1,::1' }); + expect(makeSocksAgent).not.toHaveBeenCalled(); }); - it('builds an agent with loopback-protected NO_PROXY when a proxy is set', () => { - const sentinel = { id: 'agent' } as never; - const makeAgent = vi.fn().mockReturnValue(sentinel); - const result = createProxyDispatcher({ HTTP_PROXY: 'http://p:3128', NO_PROXY: 'corp' }, makeAgent); + it('builds a SOCKS agent when only a SOCKS proxy is configured', () => { + const sentinel = { id: 'socks' } as never; + const makeSocksAgent = vi.fn().mockReturnValue(sentinel); + const makeHttpAgent = vi.fn(); + const result = createProxyDispatcher( + { ALL_PROXY: 'socks5://127.0.0.1:1080', NO_PROXY: 'corp' }, + { makeHttpAgent, makeSocksAgent }, + ); expect(result).toBe(sentinel); - expect(makeAgent).toHaveBeenCalledWith({ noProxy: 'corp,localhost,127.0.0.1,::1' }); + expect(makeSocksAgent).toHaveBeenCalledWith({ + proxy: { type: 5, host: '127.0.0.1', port: 1080 }, + noProxy: 'corp,localhost,127.0.0.1,::1', + }); + expect(makeHttpAgent).not.toHaveBeenCalled(); + }); + + it('prefers an HTTP(S) proxy over a SOCKS ALL_PROXY', () => { + const makeHttpAgent = vi.fn().mockReturnValue({ id: 'http' } as never); + const makeSocksAgent = vi.fn(); + createProxyDispatcher( + { HTTP_PROXY: 'http://p:3128', ALL_PROXY: 'socks5://127.0.0.1:1080' }, + { makeHttpAgent, makeSocksAgent }, + ); + expect(makeHttpAgent).toHaveBeenCalledTimes(1); + expect(makeSocksAgent).not.toHaveBeenCalled(); }); it('reports and ignores an invalid proxy configuration instead of crashing', () => { const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true); - const makeAgent = vi.fn(() => { + const makeHttpAgent = vi.fn(() => { throw new TypeError('Invalid URL'); }); try { - expect(createProxyDispatcher({ HTTP_PROXY: 'gibberish' }, makeAgent)).toBeUndefined(); - expect(makeAgent).toHaveBeenCalledTimes(1); + expect(createProxyDispatcher({ HTTP_PROXY: 'gibberish' }, { makeHttpAgent })).toBeUndefined(); + expect(makeHttpAgent).toHaveBeenCalledTimes(1); expect(stderr).toHaveBeenCalled(); } finally { stderr.mockRestore(); @@ -131,4 +269,8 @@ describe('proxyEnvForChild', () => { no_proxy: '*', }); }); + + it('returns an empty object for a SOCKS-only proxy (children cannot use SOCKS natively)', () => { + expect(proxyEnvForChild({ ALL_PROXY: 'socks5://127.0.0.1:1080' })).toEqual({}); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8135061a..2351ffb92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,9 @@ importers: smol-toml: specifier: ^1.6.1 version: 1.6.1 + socks: + specifier: ^2.8.9 + version: 2.8.9 tar: specifier: ^7.5.13 version: 7.5.13 @@ -3649,6 +3652,10 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4860,10 +4867,18 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smol-toml@1.6.1: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -8660,6 +8675,8 @@ snapshots: ip-address@10.1.0: {} + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} is-array-buffer@3.0.5: @@ -10073,8 +10090,15 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: {} + smol-toml@1.6.1: {} + socks@2.8.9: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + source-map-js@1.2.1: {} source-map@0.6.1: {} From e30eb8919b1f985fc98a8c83dde299866ea82df5 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Sat, 6 Jun 2026 00:33:38 +0800 Subject: [PATCH 3/7] fix: address proxy review comments (env masking, child NO_PROXY, nix hash) - Resolve HTTP(S)_PROXY explicitly via the first non-blank casing so a blank lowercase var can no longer mask a populated uppercase one (the dispatcher installed but went direct), and coerce a SOCKS-scheme value sitting in an HTTP(S) var to '' so it is never handed to EnvHttpProxyAgent. - Reconcile a child's NO_PROXY override across both casings using the first non-blank value run through resolveNoProxy, so a per-server config override is not shadowed by the injected lowercase value, keeps the loopback bypass, and passes '*' through verbatim. - Update flake.nix pnpmDeps hash for the added socks/undici dependencies. --- flake.nix | 2 +- packages/agent-core/src/mcp/client-stdio.ts | 7 +- packages/agent-core/src/utils/proxy.ts | 56 +++++++++++-- packages/agent-core/test/utils/proxy.test.ts | 87 +++++++++++++++++++- 4 files changed, 140 insertions(+), 12 deletions(-) diff --git a/flake.nix b/flake.nix index 78092b71e..42b7e61bc 100644 --- a/flake.nix +++ b/flake.nix @@ -140,7 +140,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-/Kgq76JAgi1NygbnYkBNACUl+U9TO5zwF1MaCzk3n9o="; + hash = "sha256-x5O/+bDRoEW3juoxe3XyvTJxHitQ0hZ2nVXBItkBA5E="; }; nativeBuildInputs = [ diff --git a/packages/agent-core/src/mcp/client-stdio.ts b/packages/agent-core/src/mcp/client-stdio.ts index 47b58fafa..27a733bb2 100644 --- a/packages/agent-core/src/mcp/client-stdio.ts +++ b/packages/agent-core/src/mcp/client-stdio.ts @@ -1,6 +1,6 @@ import { ErrorCodes, KimiError } from '#/errors'; import type { McpServerStdioConfig } from '#/config/schema'; -import { proxyEnvForChild } from '#/utils/proxy'; +import { proxyEnvForChild, reconcileChildNoProxy } from '#/utils/proxy'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; @@ -220,7 +220,9 @@ class BoundedTail { // style stdio servers fail to launch even with a valid config. A node child // does not inherit our in-process undici dispatcher, so `proxyEnvForChild` // adds `NODE_USE_ENV_PROXY` (and a loopback-protected `NO_PROXY`) to make it -// honor the proxy natively. Explicit `config.env` entries still override. +// honor the proxy natively. Explicit `config.env` entries still override — +// including `NO_PROXY`, which `reconcileChildNoProxy` mirrors onto both casings +// so a single-casing override isn't shadowed by the injected value. function mergeStdioEnv(configEnv?: Record): Record { const merged: Record = {}; for (const [key, value] of Object.entries(process.env)) { @@ -228,5 +230,6 @@ function mergeStdioEnv(configEnv?: Record): Record boolean { } export interface ProxyAgentFactories { - /** Build the dispatcher for an HTTP/HTTPS proxy (reads HTTP(S)_PROXY from env). */ - readonly makeHttpAgent: (options: { noProxy: string }) => Dispatcher; + /** Build the dispatcher for an HTTP/HTTPS proxy. */ + readonly makeHttpAgent: (options: { + httpProxy?: string; + httpsProxy?: string; + noProxy: string; + }) => Dispatcher; /** Build the dispatcher for a SOCKS proxy. */ readonly makeSocksAgent: (options: { proxy: SocksProxyConfig; noProxy: string }) => Dispatcher; } -const defaultMakeHttpAgent: ProxyAgentFactories['makeHttpAgent'] = ({ noProxy }) => - // EnvHttpProxyAgent reads HTTP_PROXY/HTTPS_PROXY from process.env itself; we - // only override noProxy to guarantee the loopback bypass. - new EnvHttpProxyAgent({ noProxy }); +const defaultMakeHttpAgent: ProxyAgentFactories['makeHttpAgent'] = ({ httpProxy, httpsProxy, noProxy }) => + // Pass the resolved proxy URLs explicitly: left to itself EnvHttpProxyAgent + // reads `http_proxy ?? HTTP_PROXY`, where a blank lowercase value would mask a + // populated uppercase one and silently disable proxying. noProxy is likewise + // pre-resolved to guarantee the loopback bypass. + new EnvHttpProxyAgent({ httpProxy, httpsProxy, noProxy }); const defaultMakeSocksAgent: ProxyAgentFactories['makeSocksAgent'] = ({ proxy, noProxy }) => { // undici has no SOCKS support, so we drive a custom connector: tunnel the @@ -197,7 +203,19 @@ export function createProxyDispatcher( const { makeHttpAgent = defaultMakeHttpAgent, makeSocksAgent = defaultMakeSocksAgent } = factories; try { if (hasHttpProxy(env)) { - return makeHttpAgent({ noProxy: resolveNoProxy(env) }); + // Resolve each scheme's proxy URL ourselves. Coerce a missing or + // SOCKS-scheme value to '' (falsy to undici) so EnvHttpProxyAgent neither + // builds a broken agent from a socks: URI nor re-reads a blank-masked or + // socks value back from process.env. + const pickHttpProxy = (keys: readonly string[]): string => { + const value = firstNonBlank(env, keys); + return value === undefined || SOCKS_SCHEMES.has(schemeOf(value) ?? '') ? '' : value; + }; + return makeHttpAgent({ + httpProxy: pickHttpProxy(['http_proxy', 'HTTP_PROXY']), + httpsProxy: pickHttpProxy(['https_proxy', 'HTTPS_PROXY']), + noProxy: resolveNoProxy(env), + }); } const socks = resolveSocksProxy(env); if (socks !== undefined) { @@ -256,3 +274,27 @@ export function proxyEnvForChild(env: Env = process.env): Record const noProxy = resolveNoProxy(env); return { NODE_USE_ENV_PROXY: '1', NO_PROXY: noProxy, no_proxy: noProxy }; } + +/** + * Mirror a server config's `NO_PROXY` override onto both casings of the child + * env. undici reads the lowercase `no_proxy` first, so without this the value + * {@link proxyEnvForChild} injected in the other casing would shadow an + * explicit per-server override. + * + * 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 + * `*` passes through verbatim. No-op when config sets no usable `NO_PROXY`. + */ +export function reconcileChildNoProxy( + childEnv: Record, + configEnv?: Record, +): 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 }); + childEnv['NO_PROXY'] = noProxy; + childEnv['no_proxy'] = noProxy; +} diff --git a/packages/agent-core/test/utils/proxy.test.ts b/packages/agent-core/test/utils/proxy.test.ts index da1b8a4a9..f9055d408 100644 --- a/packages/agent-core/test/utils/proxy.test.ts +++ b/packages/agent-core/test/utils/proxy.test.ts @@ -6,6 +6,7 @@ import { isProxyConfigured, makeNoProxyMatcher, proxyEnvForChild, + reconcileChildNoProxy, resolveNoProxy, resolveSocksProxy, } from '../../src/utils/proxy'; @@ -165,7 +166,7 @@ describe('createProxyDispatcher', () => { expect(makeSocksAgent).not.toHaveBeenCalled(); }); - it('builds an HTTP-proxy agent with loopback-protected NO_PROXY', () => { + it('builds an HTTP-proxy agent with resolved proxy URLs and loopback-protected NO_PROXY', () => { const sentinel = { id: 'http' } as never; const makeHttpAgent = vi.fn().mockReturnValue(sentinel); const makeSocksAgent = vi.fn(); @@ -174,7 +175,36 @@ describe('createProxyDispatcher', () => { { makeHttpAgent, makeSocksAgent }, ); expect(result).toBe(sentinel); - expect(makeHttpAgent).toHaveBeenCalledWith({ noProxy: 'corp,localhost,127.0.0.1,::1' }); + expect(makeHttpAgent).toHaveBeenCalledWith( + expect.objectContaining({ httpProxy: 'http://p:3128', noProxy: 'corp,localhost,127.0.0.1,::1' }), + ); + expect(makeSocksAgent).not.toHaveBeenCalled(); + }); + + it('passes the non-blank HTTP_PROXY even when the lowercase form is an empty string', () => { + // undici's EnvHttpProxyAgent reads `http_proxy ?? HTTP_PROXY`, so a blank + // lowercase value would mask the uppercase one — we must resolve and pass + // the proxy URL explicitly, otherwise the dispatcher installs but goes direct. + const makeHttpAgent = vi.fn().mockReturnValue({ id: 'http' } as never); + createProxyDispatcher({ http_proxy: '', HTTP_PROXY: 'http://proxy:3128' }, { makeHttpAgent }); + expect(makeHttpAgent).toHaveBeenCalledWith( + expect.objectContaining({ httpProxy: 'http://proxy:3128' }), + ); + }); + + it('suppresses a SOCKS value sitting in HTTP_PROXY rather than feeding it to the HTTP agent', () => { + // EnvHttpProxyAgent cannot do SOCKS; passing it a socks: URI builds a broken + // ProxyAgent. When HTTPS is a real http-proxy (so the HTTP path is taken), + // the socks-in-HTTP_PROXY value must be coerced away, not forwarded. + const makeHttpAgent = vi.fn().mockReturnValue({ id: 'http' } as never); + const makeSocksAgent = vi.fn(); + createProxyDispatcher( + { HTTP_PROXY: 'socks5://h:1', HTTPS_PROXY: 'http://real:3128' }, + { makeHttpAgent, makeSocksAgent }, + ); + expect(makeHttpAgent).toHaveBeenCalledWith( + expect.objectContaining({ httpProxy: '', httpsProxy: 'http://real:3128' }), + ); expect(makeSocksAgent).not.toHaveBeenCalled(); }); @@ -274,3 +304,56 @@ describe('proxyEnvForChild', () => { expect(proxyEnvForChild({ ALL_PROXY: 'socks5://127.0.0.1:1080' })).toEqual({}); }); }); + +describe('reconcileChildNoProxy', () => { + it('mirrors a config NO_PROXY override onto both casings and re-adds loopback', () => { + // Without mirroring, the lowercase `no_proxy` injected by proxyEnvForChild + // 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', + 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'); + 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'); + 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'); + 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', + no_proxy: 'corp,localhost,127.0.0.1,::1', + }; + reconcileChildNoProxy(childEnv, { NO_PROXY: '*' }); + expect(childEnv['NO_PROXY']).toBe('*'); + expect(childEnv['no_proxy']).toBe('*'); + }); + + it('ignores a config that provides no NO_PROXY or only a blank one', () => { + const childEnv: Record = { NO_PROXY: 'aug', no_proxy: 'aug' }; + reconcileChildNoProxy(childEnv, { OTHER: 'x' }); + expect(childEnv['no_proxy']).toBe('aug'); + reconcileChildNoProxy(childEnv, { no_proxy: '' }); + expect(childEnv['no_proxy']).toBe('aug'); + }); + + it('is a no-op when there is no config', () => { + const childEnv: Record = { no_proxy: 'aug' }; + reconcileChildNoProxy(childEnv, undefined); + expect(childEnv['no_proxy']).toBe('aug'); + }); +}); From 6b4e094712231a89edcbc939c0cb71f40af315e2 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Sat, 6 Jun 2026 00:48:20 +0800 Subject: [PATCH 4/7] fix(proxy): honor http ALL_PROXY, match port-qualified NO_PROXY, note child Node version - Honor an http-scheme ALL_PROXY as the catch-all fallback for both http and https (scheme-specific HTTP(S)_PROXY still wins), so an ALL_PROXY-only setup no longer installs a no-op dispatcher and connects direct. - Make the SOCKS-path NO_PROXY matcher port-aware: a `host:port` entry now matches only that port (with IPv6-safe parsing for `::1` / `[::1]:443`). - Document that child stdio MCP proxying via NODE_USE_ENV_PROXY only applies on Node versions that support it (>= 22.21 / >= 24.5). --- docs/en/configuration/env-vars.md | 2 +- docs/zh/configuration/env-vars.md | 2 +- packages/agent-core/src/mcp/client-stdio.ts | 3 +- packages/agent-core/src/utils/proxy.ts | 76 +++++++++++++++----- packages/agent-core/test/utils/proxy.test.ts | 37 ++++++++++ 5 files changed, 99 insertions(+), 21 deletions(-) diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 0f40385f7..21917cc14 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -176,7 +176,7 @@ Both HTTP(S) and SOCKS proxies are supported. A SOCKS proxy is recognized by its The proxy is applied only when one of these variables is set; otherwise connections are made directly. Loopback hosts (`localhost`, `127.0.0.1`, `::1`) always bypass the proxy, so a local server such as a localhost MCP server keeps working when a proxy is configured — add your own internal hosts to `NO_PROXY` to exempt them too. -Stdio MCP servers that run as Node child processes honor `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` automatically; SOCKS proxying applies to Kimi Code's own traffic only. +Stdio MCP servers that run as Node child processes honor `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` automatically when the child's Node version supports `NODE_USE_ENV_PROXY` (Node ≥ 22.21 or ≥ 24.5); SOCKS proxying applies to Kimi Code's own traffic only. ## Next steps diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index d1b50f299..659ba3b7b 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -176,7 +176,7 @@ Kimi Code 会遵循标准代理环境变量,让所有出网流量——模型 仅当设置了其中任一变量时才启用代理,否则直连。回环地址(`localhost`、`127.0.0.1`、`::1`)始终绕过代理,因此配置了代理后,本地服务(例如 localhost 上的 MCP 服务)仍能正常工作——你也可以把自己的内网主机加入 `NO_PROXY` 一并放行。 -以 Node 子进程运行的 stdio MCP 服务会自动遵循 `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`;SOCKS 代理仅作用于 Kimi Code 自身的流量。 +以 Node 子进程运行的 stdio MCP 服务,在其 Node 版本支持 `NODE_USE_ENV_PROXY` 时(Node ≥ 22.21 或 ≥ 24.5)会自动遵循 `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`;SOCKS 代理仅作用于 Kimi Code 自身的流量。 ## 下一步 diff --git a/packages/agent-core/src/mcp/client-stdio.ts b/packages/agent-core/src/mcp/client-stdio.ts index 27a733bb2..2c8ea2f5b 100644 --- a/packages/agent-core/src/mcp/client-stdio.ts +++ b/packages/agent-core/src/mcp/client-stdio.ts @@ -220,7 +220,8 @@ class BoundedTail { // style stdio servers fail to launch even with a valid config. A node child // does not inherit our in-process undici dispatcher, so `proxyEnvForChild` // adds `NODE_USE_ENV_PROXY` (and a loopback-protected `NO_PROXY`) to make it -// honor the proxy natively. Explicit `config.env` entries still override — +// honor the proxy natively (on a Node version that supports the flag — ≥22.21 +// or ≥24.5). Explicit `config.env` entries still override — // including `NO_PROXY`, which `reconcileChildNoProxy` mirrors onto both casings // so a single-casing override isn't shadowed by the injected value. function mergeStdioEnv(configEnv?: Record): Record { diff --git a/packages/agent-core/src/utils/proxy.ts b/packages/agent-core/src/utils/proxy.ts index 956c26f82..5ed6087b9 100644 --- a/packages/agent-core/src/utils/proxy.ts +++ b/packages/agent-core/src/utils/proxy.ts @@ -42,12 +42,21 @@ function firstNonBlank(env: Env, keys: readonly string[]): string | undefined { return undefined; } -/** True when an HTTP- or HTTPS-scheme proxy (not a SOCKS one) is configured. */ +/** The value if it is an HTTP/HTTPS-scheme proxy (not SOCKS), else undefined. */ +function httpSchemeValue(value: string | undefined): string | undefined { + return value !== undefined && !SOCKS_SCHEMES.has(schemeOf(value) ?? '') ? value : undefined; +} + +/** + * True when an HTTP/HTTPS-scheme proxy is configured — via `HTTP_PROXY`, + * `HTTPS_PROXY`, or an http-scheme `ALL_PROXY` (the catch-all fallback). + */ function hasHttpProxy(env: Env): boolean { return [ firstNonBlank(env, ['http_proxy', 'HTTP_PROXY']), firstNonBlank(env, ['https_proxy', 'HTTPS_PROXY']), - ].some((value) => value !== undefined && !SOCKS_SCHEMES.has(schemeOf(value) ?? '')); + firstNonBlank(env, ['all_proxy', 'ALL_PROXY']), + ].some((value) => httpSchemeValue(value) !== undefined); } /** @@ -116,24 +125,56 @@ export function resolveNoProxy(env: Env = process.env): string { } /** - * Build a predicate that returns true when a host should bypass the proxy, - * given a `NO_PROXY` string. Matches `*` (all), exact hosts, and subdomains for - * both bare (`example.com`) and leading-dot (`.example.com`) entries. Used for + * Build a predicate that returns true when a host (and optional port) should + * bypass the proxy, given a `NO_PROXY` string. Matches `*` (all), exact hosts, + * and subdomains for both bare (`example.com`) and leading-dot (`.example.com`) + * entries; a port-qualified entry (`host:443`) matches only that port. Used for * the SOCKS path, where bypass is not handled by undici for us. */ -export function makeNoProxyMatcher(noProxy: string): (host: string) => boolean { +export function makeNoProxyMatcher(noProxy: string): (host: string, port?: number | string) => boolean { const entries = noProxy .split(',') .map((entry) => entry.trim().toLowerCase()) .filter((entry) => entry.length > 0); if (entries.includes('*')) return () => true; - const hosts = [...new Set(entries.map((entry) => (entry.startsWith('.') ? entry.slice(1) : entry)))]; - return (host: string) => { + const parsed = entries.map(parseNoProxyEntry); + return (host: string, port?: number | string) => { const target = host.toLowerCase(); - return hosts.some((entry) => target === entry || target.endsWith(`.${entry}`)); + const targetPort = port === undefined ? undefined : String(port); + return parsed.some( + ({ host: entry, port: entryPort }) => + (entryPort === undefined || entryPort === targetPort) && + (target === entry || target.endsWith(`.${entry}`)), + ); }; } +/** + * Split a `NO_PROXY` entry into host (leading `.` stripped) and optional port. + * Handles bracketed IPv6 (`[::1]:443`) and avoids mistaking a bare IPv6 + * address's colons (`::1`) for a `host:port` separator. + */ +function parseNoProxyEntry(entry: string): { host: string; port?: string } { + let host = entry; + let port: string | undefined; + if (entry.startsWith('[')) { + const close = entry.indexOf(']'); + host = entry.slice(1, close); + const rest = entry.slice(close + 1); + if (rest.startsWith(':')) port = rest.slice(1); + } else { + const colon = entry.indexOf(':'); + // Only a single colon followed by digits is a port; multiple colons mean a + // bare IPv6 address (e.g. `::1`), which carries no port. + if (colon !== -1 && colon === entry.lastIndexOf(':') && /^\d+$/.test(entry.slice(colon + 1))) { + host = entry.slice(0, colon); + port = entry.slice(colon + 1); + } + } + if (host.startsWith('.')) host = host.slice(1); + return port === undefined ? { host } : { host, port }; +} + export interface ProxyAgentFactories { /** Build the dispatcher for an HTTP/HTTPS proxy. */ readonly makeHttpAgent: (options: { @@ -160,7 +201,7 @@ const defaultMakeSocksAgent: ProxyAgentFactories['makeSocksAgent'] = ({ proxy, n const directConnect = buildConnector({}); const bypass = makeNoProxyMatcher(noProxy); const connect: typeof directConnect = (options, callback) => { - if (bypass(options.hostname)) { + if (bypass(options.hostname, options.port)) { directConnect(options, callback); return; } @@ -203,14 +244,13 @@ export function createProxyDispatcher( const { makeHttpAgent = defaultMakeHttpAgent, makeSocksAgent = defaultMakeSocksAgent } = factories; try { if (hasHttpProxy(env)) { - // Resolve each scheme's proxy URL ourselves. Coerce a missing or - // SOCKS-scheme value to '' (falsy to undici) so EnvHttpProxyAgent neither - // builds a broken agent from a socks: URI nor re-reads a blank-masked or - // socks value back from process.env. - const pickHttpProxy = (keys: readonly string[]): string => { - const value = firstNonBlank(env, keys); - return value === undefined || SOCKS_SCHEMES.has(schemeOf(value) ?? '') ? '' : value; - }; + // Resolve each scheme's proxy URL ourselves, falling back to an + // http-scheme ALL_PROXY (the catch-all). Coerce a missing or SOCKS-scheme + // value to '' (falsy to undici) so EnvHttpProxyAgent neither builds a + // broken agent from a socks: URI nor re-reads a blank-masked value from env. + const allProxy = httpSchemeValue(firstNonBlank(env, ['all_proxy', 'ALL_PROXY'])); + const pickHttpProxy = (keys: readonly string[]): string => + httpSchemeValue(firstNonBlank(env, keys)) ?? allProxy ?? ''; return makeHttpAgent({ httpProxy: pickHttpProxy(['http_proxy', 'HTTP_PROXY']), httpsProxy: pickHttpProxy(['https_proxy', 'HTTPS_PROXY']), diff --git a/packages/agent-core/test/utils/proxy.test.ts b/packages/agent-core/test/utils/proxy.test.ts index f9055d408..9319516ed 100644 --- a/packages/agent-core/test/utils/proxy.test.ts +++ b/packages/agent-core/test/utils/proxy.test.ts @@ -32,6 +32,10 @@ describe('isProxyConfigured', () => { it('is true when only a SOCKS proxy (ALL_PROXY) is set', () => { expect(isProxyConfigured({ ALL_PROXY: 'socks5://127.0.0.1:1080' })).toBe(true); }); + + it('is true for an http-scheme ALL_PROXY', () => { + expect(isProxyConfigured({ ALL_PROXY: 'http://proxy:8080' })).toBe(true); + }); }); describe('resolveNoProxy', () => { @@ -155,6 +159,18 @@ describe('makeNoProxyMatcher', () => { it('never bypasses when NO_PROXY is empty', () => { expect(makeNoProxyMatcher('')('example.com')).toBe(false); }); + + it('matches a port-qualified entry only for the matching port', () => { + const bypass = makeNoProxyMatcher('api.example.com:443'); + expect(bypass('api.example.com', 443)).toBe(true); + expect(bypass('api.example.com', '443')).toBe(true); + expect(bypass('api.example.com', 80)).toBe(false); + }); + + it('still matches a bare IPv6 loopback entry (colons are not a port)', () => { + const bypass = makeNoProxyMatcher('::1'); + expect(bypass('::1')).toBe(true); + }); }); describe('createProxyDispatcher', () => { @@ -208,6 +224,27 @@ describe('createProxyDispatcher', () => { expect(makeSocksAgent).not.toHaveBeenCalled(); }); + it('uses an http-scheme ALL_PROXY as the fallback for both http and https', () => { + const makeHttpAgent = vi.fn().mockReturnValue({ id: 'http' } as never); + const makeSocksAgent = vi.fn(); + createProxyDispatcher({ ALL_PROXY: 'http://proxy:8080' }, { makeHttpAgent, makeSocksAgent }); + expect(makeHttpAgent).toHaveBeenCalledWith( + expect.objectContaining({ httpProxy: 'http://proxy:8080', httpsProxy: 'http://proxy:8080' }), + ); + expect(makeSocksAgent).not.toHaveBeenCalled(); + }); + + it('prefers a scheme-specific proxy over an http ALL_PROXY fallback', () => { + const makeHttpAgent = vi.fn().mockReturnValue({ id: 'http' } as never); + createProxyDispatcher( + { HTTP_PROXY: 'http://specific:1', ALL_PROXY: 'http://all:2' }, + { makeHttpAgent }, + ); + expect(makeHttpAgent).toHaveBeenCalledWith( + expect.objectContaining({ httpProxy: 'http://specific:1', httpsProxy: 'http://all:2' }), + ); + }); + it('builds a SOCKS agent when only a SOCKS proxy is configured', () => { const sentinel = { id: 'socks' } as never; const makeSocksAgent = vi.fn().mockReturnValue(sentinel); From 72cf98e0f9051af0fc7c2c4ead26d08e861e88b0 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Sat, 6 Jun 2026 01:05:34 +0800 Subject: [PATCH 5/7] fix(proxy): IPv6 + wildcard NO_PROXY and per-server child proxy edges - Strip IPv6 brackets from a SOCKS proxy host (e.g. ALL_PROXY=socks5://[::1]:1080) so the socks client connects to the bare address. - Add the bracketed [::1] to the loopback bypass: undici's EnvHttpProxyAgent only exempts IPv6 loopback when the NO_PROXY entry is bracketed (it mis-parses bare ::1). The SOCKS-path matcher normalizes brackets on both sides. - Match *.domain wildcard (and host:port) NO_PROXY entries in the SOCKS matcher. - Compute the child stdio proxy env from the MERGED env so a proxy declared only in a server's config.env also enables NODE_USE_ENV_PROXY. --- packages/agent-core/src/mcp/client-stdio.ts | 24 ++++---- packages/agent-core/src/utils/proxy.ts | 17 ++++-- .../agent-core/test/mcp/client-stdio.test.ts | 23 +++++++- packages/agent-core/test/utils/proxy.test.ts | 55 +++++++++++++------ 4 files changed, 86 insertions(+), 33 deletions(-) diff --git a/packages/agent-core/src/mcp/client-stdio.ts b/packages/agent-core/src/mcp/client-stdio.ts index 2c8ea2f5b..adffda0ca 100644 --- a/packages/agent-core/src/mcp/client-stdio.ts +++ b/packages/agent-core/src/mcp/client-stdio.ts @@ -217,20 +217,24 @@ class BoundedTail { } // Inherit the parent's env so PATH/HOME/etc. survive — otherwise `npx`/`uvx` -// style stdio servers fail to launch even with a valid config. A node child -// does not inherit our in-process undici dispatcher, so `proxyEnvForChild` -// adds `NODE_USE_ENV_PROXY` (and a loopback-protected `NO_PROXY`) to make it -// honor the proxy natively (on a Node version that supports the flag — ≥22.21 -// or ≥24.5). Explicit `config.env` entries still override — -// including `NO_PROXY`, which `reconcileChildNoProxy` mirrors onto both casings -// so a single-casing override isn't shadowed by the injected value. -function mergeStdioEnv(configEnv?: Record): Record { +// style stdio servers fail to launch even with a valid config. `config.env` +// overrides on conflict. A node child does not inherit our in-process undici +// dispatcher, so `proxyEnvForChild` adds `NODE_USE_ENV_PROXY` (and a +// loopback-protected `NO_PROXY`) to make it honor the proxy natively (on a Node +// version that supports the flag — ≥22.21 or ≥24.5). It is computed from the +// MERGED env so a proxy declared only in `config.env` is honored too. +// `reconcileChildNoProxy` then mirrors a single-casing `NO_PROXY` override onto +// both casings so it isn't shadowed by the injected value. +export function mergeStdioEnv( + configEnv?: Record, + parentEnv: Readonly> = process.env, +): Record { const merged: Record = {}; - for (const [key, value] of Object.entries(process.env)) { + for (const [key, value] of Object.entries(parentEnv)) { if (value !== undefined) merged[key] = value; } - Object.assign(merged, proxyEnvForChild()); if (configEnv !== undefined) Object.assign(merged, configEnv); + Object.assign(merged, proxyEnvForChild(merged)); reconcileChildNoProxy(merged, configEnv); return merged; } diff --git a/packages/agent-core/src/utils/proxy.ts b/packages/agent-core/src/utils/proxy.ts index 5ed6087b9..71d5fdc0b 100644 --- a/packages/agent-core/src/utils/proxy.ts +++ b/packages/agent-core/src/utils/proxy.ts @@ -24,7 +24,11 @@ export interface SocksProxyConfig { // so without this a user with a proxy set would route `http://localhost:PORT` // traffic (e.g. a local MCP server) through the proxy — a confusing failure // that only proxy users would hit. -const LOOPBACK_NO_PROXY = ['localhost', '127.0.0.1', '::1'] as const; +// `::1` and the bracketed `[::1]` are both listed: undici's EnvHttpProxyAgent +// only bypasses the IPv6 loopback when the NO_PROXY entry is bracketed (it +// otherwise mis-parses `::1` as host `:` port `1`), while our own SOCKS matcher +// normalizes brackets away — so including both covers every path. +const LOOPBACK_NO_PROXY = ['localhost', '127.0.0.1', '::1', '[::1]'] as const; const SOCKS_SCHEMES = new Set(['socks', 'socks4', 'socks4a', 'socks5', 'socks5h']); @@ -83,7 +87,9 @@ export function resolveSocksProxy(env: Env = process.env): SocksProxyConfig | un } const config: SocksProxyConfig = { type: scheme === 'socks4' || scheme === 'socks4a' ? 4 : 5, - host: url.hostname, + // Strip IPv6 brackets: the `socks` client wants the bare address (`::1`), + // not the URL's bracketed `[::1]`, which it would treat as a hostname. + host: url.hostname.replaceAll(/^\[|\]$/g, ''), port: url.port ? Number(url.port) : 1080, ...(url.username ? { userId: decodeURIComponent(url.username) } : {}), ...(url.password ? { password: decodeURIComponent(url.password) } : {}), @@ -139,7 +145,7 @@ export function makeNoProxyMatcher(noProxy: string): (host: string, port?: numbe if (entries.includes('*')) return () => true; const parsed = entries.map(parseNoProxyEntry); return (host: string, port?: number | string) => { - const target = host.toLowerCase(); + const target = host.toLowerCase().replaceAll(/^\[|\]$/g, ''); const targetPort = port === undefined ? undefined : String(port); return parsed.some( ({ host: entry, port: entryPort }) => @@ -171,7 +177,10 @@ function parseNoProxyEntry(entry: string): { host: string; port?: string } { port = entry.slice(colon + 1); } } - if (host.startsWith('.')) host = host.slice(1); + // Normalize a wildcard domain (`*.example.com`) and a leading-dot + // (`.example.com`) to the bare domain; subdomain matching is handled below. + if (host.startsWith('*.')) host = host.slice(2); + else if (host.startsWith('.')) host = host.slice(1); return port === undefined ? { host } : { host, port }; } diff --git a/packages/agent-core/test/mcp/client-stdio.test.ts b/packages/agent-core/test/mcp/client-stdio.test.ts index 150b7a96e..2e18c5406 100644 --- a/packages/agent-core/test/mcp/client-stdio.test.ts +++ b/packages/agent-core/test/mcp/client-stdio.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { KimiError } from '../../src/errors'; -import { StdioMcpClient } from '../../src/mcp/client-stdio'; +import { mergeStdioEnv, StdioMcpClient } from '../../src/mcp/client-stdio'; const here = import.meta.dirname; const fixture = join(here, 'fixtures', 'mock-stdio-server.mjs'); @@ -246,3 +246,24 @@ describe('StdioMcpClient', () => { expect(closes).toEqual([]); }, 15000); }); + +describe('mergeStdioEnv', () => { + it('enables NODE_USE_ENV_PROXY for a proxy set only in the server config.env', () => { + 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['PATH']).toBe('/usr/bin'); + }); + + it('does not inject NODE_USE_ENV_PROXY when no proxy is configured', () => { + const merged = mergeStdioEnv(undefined, { PATH: '/usr/bin' }); + expect(merged['NODE_USE_ENV_PROXY']).toBeUndefined(); + expect(merged['PATH']).toBe('/usr/bin'); + }); + + it('lets config.env override the parent env', () => { + const merged = mergeStdioEnv({ FOO: 'override' }, { FOO: 'parent', PATH: '/x' }); + expect(merged['FOO']).toBe('override'); + }); +}); diff --git a/packages/agent-core/test/utils/proxy.test.ts b/packages/agent-core/test/utils/proxy.test.ts index 9319516ed..618eaa65c 100644 --- a/packages/agent-core/test/utils/proxy.test.ts +++ b/packages/agent-core/test/utils/proxy.test.ts @@ -40,17 +40,17 @@ describe('isProxyConfigured', () => { describe('resolveNoProxy', () => { it('adds loopback hosts when NO_PROXY is unset', () => { - expect(resolveNoProxy({})).toBe('localhost,127.0.0.1,::1'); + expect(resolveNoProxy({})).toBe('localhost,127.0.0.1,::1,[::1]'); }); it('preserves existing hosts and appends only the missing loopback hosts', () => { expect(resolveNoProxy({ NO_PROXY: 'example.com, 127.0.0.1' })).toBe( - 'example.com,127.0.0.1,localhost,::1', + 'example.com,127.0.0.1,localhost,::1,[::1]', ); }); it('reads the lowercase no_proxy', () => { - expect(resolveNoProxy({ no_proxy: 'internal' })).toBe('internal,localhost,127.0.0.1,::1'); + expect(resolveNoProxy({ no_proxy: 'internal' })).toBe('internal,localhost,127.0.0.1,::1,[::1]'); }); it('preserves the "*" wildcard verbatim (it must stay an exact match to bypass everything)', () => { @@ -59,7 +59,7 @@ describe('resolveNoProxy', () => { }); it('falls through to NO_PROXY when no_proxy is set but blank', () => { - expect(resolveNoProxy({ no_proxy: '', NO_PROXY: 'corp' })).toBe('corp,localhost,127.0.0.1,::1'); + expect(resolveNoProxy({ no_proxy: '', NO_PROXY: 'corp' })).toBe('corp,localhost,127.0.0.1,::1,[::1]'); }); }); @@ -126,6 +126,14 @@ describe('resolveSocksProxy', () => { port: 1080, }); }); + + it('strips IPv6 brackets from the SOCKS proxy host', () => { + expect(resolveSocksProxy({ ALL_PROXY: 'socks5://[::1]:1080' })).toEqual({ + type: 5, + host: '::1', + port: 1080, + }); + }); }); describe('makeNoProxyMatcher', () => { @@ -171,6 +179,17 @@ describe('makeNoProxyMatcher', () => { const bypass = makeNoProxyMatcher('::1'); expect(bypass('::1')).toBe(true); }); + + it('matches a bracketed IPv6 target against a bare ::1 entry', () => { + expect(makeNoProxyMatcher('::1')('[::1]')).toBe(true); + }); + + it('matches a *.domain wildcard entry against subdomains and the apex', () => { + const bypass = makeNoProxyMatcher('*.example.com'); + expect(bypass('api.example.com')).toBe(true); + expect(bypass('example.com')).toBe(true); + expect(bypass('other.com')).toBe(false); + }); }); describe('createProxyDispatcher', () => { @@ -192,7 +211,7 @@ describe('createProxyDispatcher', () => { ); expect(result).toBe(sentinel); expect(makeHttpAgent).toHaveBeenCalledWith( - expect.objectContaining({ httpProxy: 'http://p:3128', noProxy: 'corp,localhost,127.0.0.1,::1' }), + expect.objectContaining({ httpProxy: 'http://p:3128', noProxy: 'corp,localhost,127.0.0.1,::1,[::1]' }), ); expect(makeSocksAgent).not.toHaveBeenCalled(); }); @@ -256,7 +275,7 @@ describe('createProxyDispatcher', () => { expect(result).toBe(sentinel); expect(makeSocksAgent).toHaveBeenCalledWith({ proxy: { type: 5, host: '127.0.0.1', port: 1080 }, - noProxy: 'corp,localhost,127.0.0.1,::1', + noProxy: 'corp,localhost,127.0.0.1,::1,[::1]', }); expect(makeHttpAgent).not.toHaveBeenCalled(); }); @@ -324,8 +343,8 @@ describe('proxyEnvForChild', () => { // loopback-augmented value or the protection 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', - no_proxy: 'corp,localhost,127.0.0.1,::1', + NO_PROXY: 'corp,localhost,127.0.0.1,::1,[::1]', + no_proxy: 'corp,localhost,127.0.0.1,::1,[::1]', }); }); @@ -348,32 +367,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', - no_proxy: 'corp,localhost,127.0.0.1,::1', + NO_PROXY: 'corp,localhost,127.0.0.1,::1,[::1]', + no_proxy: 'corp,localhost,127.0.0.1,::1,[::1]', }; reconcileChildNoProxy(childEnv, { NO_PROXY: 'server.local' }); - 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'); + 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]'); }); 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'); - expect(childEnv['no_proxy']).toBe('lower,localhost,127.0.0.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,[::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'); - 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,[::1]'); + expect(childEnv['no_proxy']).toBe('real.corp,localhost,127.0.0.1,::1,[::1]'); }); it('passes the "*" wildcard override through verbatim', () => { const childEnv: Record = { - NO_PROXY: 'corp,localhost,127.0.0.1,::1', - no_proxy: 'corp,localhost,127.0.0.1,::1', + NO_PROXY: 'corp,localhost,127.0.0.1,::1,[::1]', + no_proxy: 'corp,localhost,127.0.0.1,::1,[::1]', }; reconcileChildNoProxy(childEnv, { NO_PROXY: '*' }); expect(childEnv['NO_PROXY']).toBe('*'); From 54e97c083eab6480596b81a29f5db42a0df6c653 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Sat, 6 Jun 2026 01:26:20 +0800 Subject: [PATCH 6/7] fix(proxy): synthesize HTTP(S)_PROXY from ALL_PROXY for child processes proxyEnvForChild now hands spawned stdio MCP children the resolved HTTP_PROXY/HTTPS_PROXY (in both casings), synthesizing them from an http-scheme ALL_PROXY when no scheme-specific variable is set. Node's --use-env-proxy reads HTTP_PROXY/HTTPS_PROXY (not ALL_PROXY), so an ALL_PROXY-only parent now proxies the child consistently with the main process. Shared resolveHttpProxyUrls helper is reused by createProxyDispatcher and proxyEnvForChild. --- packages/agent-core/src/utils/proxy.ts | 55 +++++++++++++++----- packages/agent-core/test/utils/proxy.test.ts | 24 +++++++-- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/packages/agent-core/src/utils/proxy.ts b/packages/agent-core/src/utils/proxy.ts index 71d5fdc0b..2f475ef1f 100644 --- a/packages/agent-core/src/utils/proxy.ts +++ b/packages/agent-core/src/utils/proxy.ts @@ -63,6 +63,20 @@ function hasHttpProxy(env: Env): boolean { ].some((value) => httpSchemeValue(value) !== undefined); } +/** + * Resolve the effective http/https proxy URLs: the scheme-specific + * `HTTP_PROXY`/`HTTPS_PROXY` (ignoring a SOCKS-scheme value), falling back to an + * http-scheme `ALL_PROXY` catch-all. `undefined` for a scheme with no usable + * value. + */ +function resolveHttpProxyUrls(env: Env): { httpProxy?: string; httpsProxy?: string } { + const allProxy = httpSchemeValue(firstNonBlank(env, ['all_proxy', 'ALL_PROXY'])); + return { + httpProxy: httpSchemeValue(firstNonBlank(env, ['http_proxy', 'HTTP_PROXY'])) ?? allProxy, + httpsProxy: httpSchemeValue(firstNonBlank(env, ['https_proxy', 'HTTPS_PROXY'])) ?? allProxy, + }; +} + /** * Resolve a SOCKS proxy from the environment, or `undefined` if none. A SOCKS * proxy may be declared via `ALL_PROXY` (the common form for Clash / V2RayN) or @@ -253,16 +267,13 @@ export function createProxyDispatcher( const { makeHttpAgent = defaultMakeHttpAgent, makeSocksAgent = defaultMakeSocksAgent } = factories; try { if (hasHttpProxy(env)) { - // Resolve each scheme's proxy URL ourselves, falling back to an - // http-scheme ALL_PROXY (the catch-all). Coerce a missing or SOCKS-scheme - // value to '' (falsy to undici) so EnvHttpProxyAgent neither builds a - // broken agent from a socks: URI nor re-reads a blank-masked value from env. - const allProxy = httpSchemeValue(firstNonBlank(env, ['all_proxy', 'ALL_PROXY'])); - const pickHttpProxy = (keys: readonly string[]): string => - httpSchemeValue(firstNonBlank(env, keys)) ?? allProxy ?? ''; + // Coerce a missing value to '' (falsy to undici) so EnvHttpProxyAgent + // neither builds a broken agent from a socks: URI nor re-reads a + // blank-masked value from env. + const { httpProxy, httpsProxy } = resolveHttpProxyUrls(env); return makeHttpAgent({ - httpProxy: pickHttpProxy(['http_proxy', 'HTTP_PROXY']), - httpsProxy: pickHttpProxy(['https_proxy', 'HTTPS_PROXY']), + httpProxy: httpProxy ?? '', + httpsProxy: httpsProxy ?? '', noProxy: resolveNoProxy(env), }); } @@ -314,14 +325,32 @@ export function installGlobalProxyDispatcher( * * Only applies to HTTP/HTTPS proxies: Node's `--use-env-proxy` does not support * SOCKS, so a SOCKS-only proxy yields `{}` (child SOCKS proxying is out of - * scope). Sets `NO_PROXY` in BOTH casings: the child inherits the parent's env - * and undici reads the lowercase `no_proxy` first, so an inherited un-augmented - * lowercase value would otherwise defeat the loopback protection. + * scope). Everything is set in BOTH casings: the child inherits the parent's + * env and undici reads the lowercase form first, so the lowercase variants must + * also carry the resolved values or the protection/proxying is silently lost. + * + * Because `--use-env-proxy` reads `HTTP_PROXY`/`HTTPS_PROXY` (not `ALL_PROXY`), + * 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 { if (!hasHttpProxy(env)) return {}; const noProxy = resolveNoProxy(env); - return { NODE_USE_ENV_PROXY: '1', NO_PROXY: noProxy, no_proxy: noProxy }; + const result: Record = { + NODE_USE_ENV_PROXY: '1', + NO_PROXY: noProxy, + no_proxy: noProxy, + }; + const { httpProxy, httpsProxy } = resolveHttpProxyUrls(env); + if (httpProxy !== undefined) { + result['HTTP_PROXY'] = httpProxy; + result['http_proxy'] = httpProxy; + } + if (httpsProxy !== undefined) { + result['HTTPS_PROXY'] = httpsProxy; + result['https_proxy'] = httpsProxy; + } + return result; } /** diff --git a/packages/agent-core/test/utils/proxy.test.ts b/packages/agent-core/test/utils/proxy.test.ts index 618eaa65c..91b8bce92 100644 --- a/packages/agent-core/test/utils/proxy.test.ts +++ b/packages/agent-core/test/utils/proxy.test.ts @@ -337,14 +337,30 @@ describe('proxyEnvForChild', () => { expect(proxyEnvForChild({})).toEqual({}); }); - it('enables Node native env-proxy and protects loopback for spawned node children', () => { + it('enables Node native env-proxy, mirrors the proxy URL, and protects loopback', () => { // Sets BOTH casings: a child inherits the parent's env, and undici reads - // the lowercase no_proxy first — so the lowercase form must also carry the - // loopback-augmented value or the protection is silently defeated. + // the lowercase form first — so both NO_PROXY and the proxy URL must carry + // 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]', + HTTP_PROXY: 'http://p:3128', + http_proxy: 'http://p:3128', + }); + }); + + it('synthesizes scheme-specific proxies from an http-scheme ALL_PROXY for the child', () => { + // Node's --use-env-proxy reads HTTP_PROXY/HTTPS_PROXY, not ALL_PROXY, so an + // 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]', + HTTP_PROXY: 'http://proxy:8080', + http_proxy: 'http://proxy:8080', + HTTPS_PROXY: 'http://proxy:8080', + https_proxy: 'http://proxy:8080', }); }); @@ -353,6 +369,8 @@ describe('proxyEnvForChild', () => { NODE_USE_ENV_PROXY: '1', NO_PROXY: '*', no_proxy: '*', + HTTP_PROXY: 'http://p:3128', + http_proxy: 'http://p:3128', }); }); From 6cf1d3ca012c11e81f7b6b71c6d8b02cd015de73 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Sat, 6 Jun 2026 01:39:32 +0800 Subject: [PATCH 7/7] chore(changeset): tighten proxy changeset wording --- .changeset/http-proxy-support.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.changeset/http-proxy-support.md b/.changeset/http-proxy-support.md index 9f1c33527..698f6a584 100644 --- a/.changeset/http-proxy-support.md +++ b/.changeset/http-proxy-support.md @@ -4,6 +4,4 @@ "@moonshot-ai/kimi-code": minor --- -Honor the standard `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` environment variables for all outbound traffic — model API calls, MCP servers, web tools, telemetry, sign-in, and update checks. Both HTTP(S) and SOCKS proxies are supported (`ALL_PROXY=socks5://…`, the form used by Clash / V2RayN). - -A global proxy dispatcher is installed at startup only when a proxy variable is set, so the zero-config default is unchanged. Loopback hosts (`localhost`, `127.0.0.1`, `::1`) always bypass the proxy so local servers (e.g. a localhost MCP server) keep working, and stdio MCP servers running as Node processes honor HTTP(S) proxies automatically. +Honor the standard `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` environment variables, including SOCKS proxies, for all outbound traffic.