diff --git a/.changeset/http-proxy-support.md b/.changeset/http-proxy-support.md new file mode 100644 index 000000000..698f6a584 --- /dev/null +++ b/.changeset/http-proxy-support.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Honor the standard `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` environment variables, including SOCKS proxies, for all outbound traffic. 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..21917cc14 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -163,6 +163,21 @@ 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 traffic — model API calls, MCP servers, web tools, telemetry, sign-in, and update checks: + +- `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 + +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 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 - [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..659ba3b7b 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -163,6 +163,21 @@ CLI 还会读取一些标准系统变量来检测运行环境,不会修改它 - `WSL_DISTRO_NAME`、`WSLENV`:检测 WSL,用于剪贴板 PowerShell 桥接 - `LOCALAPPDATA`:Windows 上探测 Git Bash 安装路径 +## HTTP 代理 + +Kimi Code 会遵循标准代理环境变量,让所有出网流量——模型 API 调用、MCP 服务、网络工具、遥测、登录、更新检查——都走代理: + +- `HTTP_PROXY` / `http_proxy`:用于 `http://` 请求的代理 +- `HTTPS_PROXY` / `https_proxy`:用于 `https://` 请求的代理 +- `ALL_PROXY` / `all_proxy`:当对应 scheme 的变量未设置时使用的兜底代理;SOCKS 代理通常设在这里 +- `NO_PROXY` / `no_proxy`:以逗号分隔的、绕过代理的主机列表 + +同时支持 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 服务,在其 Node 版本支持 `NODE_USE_ENV_PROXY` 时(Node ≥ 22.21 或 ≥ 24.5)会自动遵循 `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`;SOCKS 代理仅作用于 Kimi Code 自身的流量。 + ## 下一步 - [配置覆盖](./overrides.md) — 环境变量、CLI 选项、配置文件的优先级关系 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/package.json b/packages/agent-core/package.json index aaa20927b..ea6e70f67 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -71,7 +71,9 @@ "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", "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..adffda0ca 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, reconcileChildNoProxy } from '#/utils/proxy'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; @@ -216,13 +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. Explicit -// `config.env` entries still override on conflict. -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; } 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 new file mode 100644 index 000000000..2f475ef1f --- /dev/null +++ b/packages/agent-core/src/utils/proxy.ts @@ -0,0 +1,378 @@ +import { + Agent, + buildConnector, + type Dispatcher, + EnvHttpProxyAgent, + setGlobalDispatcher as undiciSetGlobalDispatcher, +} from 'undici'; +import { SocksClient } from 'socks'; + +type Env = Readonly>; + +/** 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. +// `::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']); + +/** 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; +} + +/** 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']), + firstNonBlank(env, ['all_proxy', 'ALL_PROXY']), + ].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 + * 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, + // 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) } : {}), + }; + return config; + } + return undefined; +} + +/** True when any HTTP(S) or SOCKS proxy variable is set to a usable value. */ +export function isProxyConfigured(env: Env = process.env): boolean { + return hasHttpProxy(env) || resolveSocksProxy(env) !== undefined; +} + +/** + * 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(','); +} + +/** + * 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, port?: number | string) => boolean { + const entries = noProxy + .split(',') + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry.length > 0); + if (entries.includes('*')) return () => true; + const parsed = entries.map(parseNoProxyEntry); + return (host: string, port?: number | string) => { + const target = host.toLowerCase().replaceAll(/^\[|\]$/g, ''); + 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); + } + } + // 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 }; +} + +export interface ProxyAgentFactories { + /** 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'] = ({ 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 + // 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, options.port)) { + 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 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, + factories: Partial = {}, +): Dispatcher | undefined { + const { makeHttpAgent = defaultMakeHttpAgent, makeSocksAgent = defaultMakeSocksAgent } = factories; + try { + if (hasHttpProxy(env)) { + // 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: httpProxy ?? '', + httpsProxy: httpsProxy ?? '', + 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 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 proxy configuration (${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. + * + * 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). 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); + 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; +} + +/** + * 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/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 new file mode 100644 index 000000000..91b8bce92 --- /dev/null +++ b/packages/agent-core/test/utils/proxy.test.ts @@ -0,0 +1,433 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createProxyDispatcher, + installGlobalProxyDispatcher, + isProxyConfigured, + makeNoProxyMatcher, + proxyEnvForChild, + reconcileChildNoProxy, + resolveNoProxy, + resolveSocksProxy, +} 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); + }); + + 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', () => { + it('adds loopback hosts when NO_PROXY is unset', () => { + 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,[::1]', + ); + }); + + it('reads the lowercase no_proxy', () => { + 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)', () => { + 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,[::1]'); + }); +}); + +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, + }); + }); + + 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', () => { + 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); + }); + + 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); + }); + + 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', () => { + 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 resolved proxy URLs and 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( + expect.objectContaining({ httpProxy: 'http://p:3128', noProxy: 'corp,localhost,127.0.0.1,::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(); + }); + + 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); + 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(makeSocksAgent).toHaveBeenCalledWith({ + proxy: { type: 5, host: '127.0.0.1', port: 1080 }, + noProxy: 'corp,localhost,127.0.0.1,::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 makeHttpAgent = vi.fn(() => { + throw new TypeError('Invalid URL'); + }); + try { + expect(createProxyDispatcher({ HTTP_PROXY: 'gibberish' }, { makeHttpAgent })).toBeUndefined(); + expect(makeHttpAgent).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, mirrors the proxy URL, and protects loopback', () => { + // Sets BOTH casings: a child inherits the parent's env, and undici reads + // 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', + }); + }); + + 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: '*', + HTTP_PROXY: 'http://p:3128', + http_proxy: 'http://p:3128', + }); + }); + + 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({}); + }); +}); + +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,[::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,[::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,[::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,[::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,[::1]', + no_proxy: 'corp,localhost,127.0.0.1,::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'); + }); +}); 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..2351ffb92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,9 +277,15 @@ 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 + undici: + specifier: ^7.27.1 + version: 7.27.1 yauzl: specifier: ^3.3.0 version: 3.3.0 @@ -3646,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'} @@ -4857,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'} @@ -5147,6 +5165,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'} @@ -8653,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: @@ -10066,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: {} @@ -10346,6 +10377,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.27.1: {} + unicode-emoji-modifier-base@1.0.0: {} unified@11.0.5: