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.
This commit is contained in:
weishu
2026-06-11 00:00:09 +08:00
committed by GitHub
parent a6176014fd
commit 17439ae02c
3 changed files with 81 additions and 0 deletions
+36
View File
@@ -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();
});
});
+42
View File
@@ -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;
}