From 17439ae02c70533d6cb08410f3bd6d06f20a72e8 Mon Sep 17 00:00:00 2001 From: weishu Date: Thu, 11 Jun 2026 00:00:09 +0800 Subject: [PATCH] fix(cli): bypass proxy for loopback addresses at CLI entrypoint (#868) Bun's fetch and node:http honor HTTP_PROXY/HTTPS_PROXY env vars, which can route loopback traffic through a configured proxy (e.g. Surge/Clash). When NO_PROXY doesn't explicitly exclude localhost, this breaks loopback communication: SessionStart hooks fail to arrive (transcripts don't sync, web UI stays empty), runner control client times out, and MCP server connections fail. Normalize NO_PROXY at the CLI entrypoint to always cover loopback (localhost, 127.0.0.1, ::1). Child processes inherit the patched env so their loopback traffic is covered too. Non-loopback traffic continues using the configured proxy. Supersedes the runner control client workaround from #563. --- cli/src/commands/runCli.ts | 3 +++ cli/src/utils/proxyEnv.test.ts | 36 +++++++++++++++++++++++++++++ cli/src/utils/proxyEnv.ts | 42 ++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 cli/src/utils/proxyEnv.test.ts create mode 100644 cli/src/utils/proxyEnv.ts diff --git a/cli/src/commands/runCli.ts b/cli/src/commands/runCli.ts index acd82b1d..3cc404c4 100644 --- a/cli/src/commands/runCli.ts +++ b/cli/src/commands/runCli.ts @@ -3,9 +3,12 @@ import { ensureRuntimeAssets } from '@/runtime/assets' import { isBunCompiled } from '@/projectPath' import { logger } from '@/ui/logger' import { getCliArgs } from '@/utils/cliArgs' +import { ensureLoopbackProxyBypass } from '@/utils/proxyEnv' import { resolveCommand } from './registry' export async function runCli(): Promise { + ensureLoopbackProxyBypass() + const args = getCliArgs() if (args.includes('-v') || args.includes('--version')) { diff --git a/cli/src/utils/proxyEnv.test.ts b/cli/src/utils/proxyEnv.test.ts new file mode 100644 index 00000000..78ebdfd1 --- /dev/null +++ b/cli/src/utils/proxyEnv.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { ensureLoopbackProxyBypass } from './proxyEnv'; + +describe('ensureLoopbackProxyBypass', () => { + it('adds loopback hosts when NO_PROXY is unset', () => { + const env: NodeJS.ProcessEnv = {}; + ensureLoopbackProxyBypass(env); + expect(env.NO_PROXY).toBe('localhost,127.0.0.1,::1'); + expect(env.no_proxy).toBe('localhost,127.0.0.1,::1'); + }); + + it('preserves existing entries and appends missing loopback hosts', () => { + const env: NodeJS.ProcessEnv = { NO_PROXY: 'npmjs.org' }; + ensureLoopbackProxyBypass(env); + expect(env.NO_PROXY).toBe('npmjs.org,localhost,127.0.0.1,::1'); + }); + + it('does not duplicate already-present hosts', () => { + const env: NodeJS.ProcessEnv = { NO_PROXY: 'LOCALHOST, 127.0.0.1' }; + ensureLoopbackProxyBypass(env); + expect(env.NO_PROXY).toBe('LOCALHOST,127.0.0.1,::1'); + }); + + it('reads lowercase no_proxy when NO_PROXY is unset', () => { + const env: NodeJS.ProcessEnv = { no_proxy: 'example.com,localhost,127.0.0.1,::1' }; + ensureLoopbackProxyBypass(env); + expect(env.NO_PROXY).toBe('example.com,localhost,127.0.0.1,::1'); + }); + + it('leaves a wildcard NO_PROXY untouched', () => { + const env: NodeJS.ProcessEnv = { NO_PROXY: '*' }; + ensureLoopbackProxyBypass(env); + expect(env.NO_PROXY).toBe('*'); + expect(env.no_proxy).toBeUndefined(); + }); +}); diff --git a/cli/src/utils/proxyEnv.ts b/cli/src/utils/proxyEnv.ts new file mode 100644 index 00000000..964bf532 --- /dev/null +++ b/cli/src/utils/proxyEnv.ts @@ -0,0 +1,42 @@ +/** + * Loopback proxy bypass. + * + * The CLI talks to itself and to local agents over loopback HTTP (hook + * forwarder -> hook server, control client -> runner, claude -> local MCP + * server). Bun's fetch AND its node:http implementation honor the + * HTTP_PROXY/HTTPS_PROXY env vars, so when the user's shell exports a proxy + * (e.g. Surge/Clash with `HTTP_PROXY=http://127.0.0.1:1080`) without + * excluding localhost in NO_PROXY, every loopback request is routed through + * the proxy — which may forward "127.0.0.1" to a remote node where nothing + * is listening. Symptom: SessionStart hooks never arrive, transcripts never + * sync, web UI stays empty. + * + * Fix: make sure NO_PROXY always covers loopback. Children (claude, runner, + * hook-forwarder) inherit the patched env, so their loopback traffic is + * covered too. Non-loopback traffic keeps using the configured proxy. + */ + +const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '::1']; + +export function ensureLoopbackProxyBypass(env: NodeJS.ProcessEnv = process.env): void { + const existing = env.NO_PROXY ?? env.no_proxy ?? ''; + const entries = existing + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + const present = new Set(entries.map((entry) => entry.toLowerCase())); + if (present.has('*')) { + return; + } + + for (const host of LOOPBACK_HOSTS) { + if (!present.has(host)) { + entries.push(host); + } + } + + const merged = entries.join(','); + env.NO_PROXY = merged; + env.no_proxy = merged; +}