Skip to content

Commit 0ed2ab5

Browse files
fix(launcher): exclude localhost from proxy in --remote-control (cnighswonger#257)
--remote-control set HTTPS_PROXY but no NO_PROXY, routing every client connection (including to local HTTP/SSE-transport MCP servers on 127.0.0.1) at the cache-fix proxy, which 404s anything but api.anthropic.com. stdio-transport MCPs were unaffected. Fix: add 127.0.0.1,localhost,::1 to NO_PROXY/no_proxy in forward mode, merging into any existing value. Reviewed by Codex (APPROVED). Fixes a v4.3.0 --remote-control regression.
1 parent 4b0722c commit 0ed2ab5

3 files changed

Lines changed: 126 additions & 2 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ Or let the launcher do both steps for you with `--remote-control`:
5454
cache-fix-proxy --remote-control
5555
```
5656

57-
The `--remote-control` flag is the one-command equivalent of the manual wiring above: it starts the proxy in forward-proxy mode, waits for the CA, and launches `claude` pointed at `HTTPS_PROXY` with `NODE_EXTRA_CA_CERTS` set. Without the flag the launcher stays in reverse-proxy mode (sets `ANTHROPIC_BASE_URL`), unchanged. Two things worth knowing: Remote Control does a trusted-device enrollment on first connect that can need a few `/remote-control` retries (a Claude Code step that runs upstream, not a proxy failure); and enabling RC on an already-warm session costs a **single** prompt-cache rebuild (RC adds an `anthropic-beta` the cache keys on), so if you want RC, launching with `--remote-control` from the start avoids that one-time flip. `cache-fix-proxy --help` documents both.
57+
The `--remote-control` flag is the one-command equivalent of the manual wiring above: it starts the proxy in forward-proxy mode, waits for the CA, and launches `claude` pointed at `HTTPS_PROXY` with `NODE_EXTRA_CA_CERTS` set (and adds `127.0.0.1,localhost,::1` to `NO_PROXY` so local services — e.g. HTTP/SSE-transport MCP servers on localhost — bypass the proxy rather than being routed at it; any existing `NO_PROXY` is preserved). Without the flag the launcher stays in reverse-proxy mode (sets `ANTHROPIC_BASE_URL`), unchanged. Two things worth knowing: Remote Control does a trusted-device enrollment on first connect that can need a few `/remote-control` retries (a Claude Code step that runs upstream, not a proxy failure); and enabling RC on an already-warm session costs a **single** prompt-cache rebuild (RC adds an `anthropic-beta` the cache keys on), so if you want RC, launching with `--remote-control` from the start avoids that one-time flip. `cache-fix-proxy --help` documents both.
58+
59+
> If you wire forward-proxy mode manually (setting `HTTPS_PROXY` yourself instead of using `--remote-control`), set `NO_PROXY=127.0.0.1,localhost,::1` as well, or local HTTP-transport MCP servers and other localhost services will be routed at the cache-fix proxy and fail. stdio-transport MCP servers are unaffected (they use pipes, not the network).
5860
5961
How it works: the proxy also handles HTTP `CONNECT`. It MITMs **only** the upstream host (`api.anthropic.com`), terminating TLS with a locally-generated CA so it can run the same extension pipeline, and **blind-tunnels every other CONNECT** (mcp-proxy, telemetry, npm, ...) untouched. On first start it generates a CA under `$CLAUDE_CONFIG_DIR/cache-fix-ca/` (default `~/.claude/cache-fix-ca/`; override with `CACHE_FIX_CA_DIR`); the client must trust it via `NODE_EXTRA_CA_CERTS`. A WebSocket/Upgrade to the upstream host (e.g. `/voice`) is relayed to upstream as-is. Because base URL stays `api.anthropic.com`, all of `/api/oauth/*`, `/v1/agents`, Remote Control credential fetches, etc. pass through untouched and RC stays enabled.
6062

bin/claude-via-proxy.mjs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ async function dispatch() {
7272
" ANTHROPIC_BASE_URL, so Claude Code stays first-party\n" +
7373
" and Remote Control / mobile session visibility keeps\n" +
7474
" working (CC >= 2.1.196 disables it when\n" +
75-
" ANTHROPIC_BASE_URL is set).\n" +
75+
" ANTHROPIC_BASE_URL is set). Also adds localhost to\n" +
76+
" NO_PROXY so local HTTP MCP servers / services bypass\n" +
77+
" the proxy (any existing NO_PROXY is preserved).\n" +
7678
"\nEnvironment:\n" +
7779
" CACHE_FIX_PROXY_PORT Port for the proxy server\n" +
7880
" CACHE_FIX_PROXY_UPSTREAM Upstream URL\n" +
@@ -210,6 +212,23 @@ if (remoteControl) {
210212
claudeEnv.HTTPS_PROXY = proxyUrl;
211213
claudeEnv.https_proxy = proxyUrl;
212214
claudeEnv.NODE_EXTRA_CA_CERTS = caPem;
215+
// Exclude localhost from the proxy. Without this, HTTPS_PROXY routes EVERY
216+
// connection claude makes — including to local services like HTTP/SSE-transport
217+
// MCP servers (e.g. an MCP on 127.0.0.1) — at the cache-fix proxy, which only
218+
// knows how to serve api.anthropic.com and 404s the rest. stdio-transport MCPs
219+
// are unaffected (they're pipes, no network), which is why only network-transport
220+
// local services break. Merge into any existing NO_PROXY rather than clobber it
221+
// (a corporate env may already set one). Set both cases to cover libs that read
222+
// either variable.
223+
const NO_PROXY_LOCAL = "127.0.0.1,localhost,::1";
224+
const mergeNoProxy = (existing) => {
225+
const parts = (existing || "").split(",").map((s) => s.trim()).filter(Boolean);
226+
for (const h of NO_PROXY_LOCAL.split(",")) if (!parts.includes(h)) parts.push(h);
227+
return parts.join(",");
228+
};
229+
const merged = mergeNoProxy(claudeEnv.NO_PROXY || claudeEnv.no_proxy);
230+
claudeEnv.NO_PROXY = merged;
231+
claudeEnv.no_proxy = merged;
213232
} else {
214233
claudeEnv = {
215234
...process.env,

test/proxy-wrapper.test.mjs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,4 +188,107 @@ describe("launch wrapper (claude-via-proxy)", { concurrency: 1 }, () => {
188188
`NODE_EXTRA_CA_CERTS should be the CACHE_FIX_CA_DIR override (${join(caDir, "ca.pem")}), got: ${stdout}`,
189189
);
190190
});
191+
192+
it("--remote-control excludes localhost via NO_PROXY (so local HTTP MCP servers aren't misrouted)", async () => {
193+
// Without NO_PROXY, HTTPS_PROXY routes every connection — including to local
194+
// HTTP/SSE-transport MCP servers on 127.0.0.1 — at the cache-fix proxy, which
195+
// 404s anything that isn't api.anthropic.com. The launcher must exclude
196+
// localhost. The child prints both NO_PROXY and no_proxy.
197+
const script =
198+
'process.stdout.write("NP="+(process.env.NO_PROXY||"UNSET")+' +
199+
'"|np="+(process.env.no_proxy||"UNSET")+"\\n")';
200+
const wrapperProc = fork(WRAPPER_PATH, ["--remote-control", "--proxy-port", "0"], {
201+
stdio: ["ignore", "pipe", "pipe", "ipc"],
202+
env: cleanEnv({ CACHE_FIX_CLAUDE_CMD: `${NODE} -e ${script}` }),
203+
});
204+
205+
let stdout = "";
206+
let stderr = "";
207+
wrapperProc.stdout.on("data", (c) => { stdout += c.toString(); });
208+
wrapperProc.stderr.on("data", (c) => { stderr += c.toString(); });
209+
210+
const code = await new Promise((resolve) => {
211+
wrapperProc.on("exit", (c) => resolve(c));
212+
setTimeout(() => { wrapperProc.kill("SIGTERM"); resolve(null); }, 15000);
213+
});
214+
215+
assert.equal(code, 0, `Expected exit 0, got ${code}. stderr: ${stderr}`);
216+
// Both NO_PROXY and no_proxy must cover localhost.
217+
for (const host of ["127.0.0.1", "localhost", "::1"]) {
218+
assert.ok(stdout.includes(host), `NO_PROXY should include ${host}, got: ${stdout}`);
219+
}
220+
assert.match(stdout, /NP=\S*127\.0\.0\.1/, `NO_PROXY should be set in forward mode, got: ${stdout}`);
221+
assert.match(stdout, /np=\S*127\.0\.0\.1/, `no_proxy should be set in forward mode, got: ${stdout}`);
222+
});
223+
224+
it("--remote-control merges localhost into an existing NO_PROXY rather than clobbering it", async () => {
225+
const script = 'process.stdout.write("NP="+(process.env.NO_PROXY||"UNSET")+"\\n")';
226+
const wrapperProc = fork(WRAPPER_PATH, ["--remote-control", "--proxy-port", "0"], {
227+
stdio: ["ignore", "pipe", "pipe", "ipc"],
228+
env: cleanEnv({ CACHE_FIX_CLAUDE_CMD: `${NODE} -e ${script}`, NO_PROXY: "example.com" }),
229+
});
230+
231+
let stdout = "";
232+
let stderr = "";
233+
wrapperProc.stdout.on("data", (c) => { stdout += c.toString(); });
234+
wrapperProc.stderr.on("data", (c) => { stderr += c.toString(); });
235+
236+
const code = await new Promise((resolve) => {
237+
wrapperProc.on("exit", (c) => resolve(c));
238+
setTimeout(() => { wrapperProc.kill("SIGTERM"); resolve(null); }, 15000);
239+
});
240+
241+
assert.equal(code, 0, `Expected exit 0, got ${code}. stderr: ${stderr}`);
242+
assert.ok(stdout.includes("example.com"), `existing NO_PROXY entry should be preserved, got: ${stdout}`);
243+
assert.ok(stdout.includes("127.0.0.1"), `localhost should be merged in, got: ${stdout}`);
244+
});
245+
246+
it("--remote-control reads a lowercase-only no_proxy and preserves it", async () => {
247+
// The existing value may be set under the lowercase name only; the merge
248+
// must read either variant, not just NO_PROXY.
249+
const script = 'process.stdout.write("NP="+(process.env.NO_PROXY||"UNSET")+"\\n")';
250+
const wrapperProc = fork(WRAPPER_PATH, ["--remote-control", "--proxy-port", "0"], {
251+
stdio: ["ignore", "pipe", "pipe", "ipc"],
252+
env: cleanEnv({ CACHE_FIX_CLAUDE_CMD: `${NODE} -e ${script}`, no_proxy: "corp.internal" }),
253+
});
254+
255+
let stdout = "";
256+
let stderr = "";
257+
wrapperProc.stdout.on("data", (c) => { stdout += c.toString(); });
258+
wrapperProc.stderr.on("data", (c) => { stderr += c.toString(); });
259+
260+
const code = await new Promise((resolve) => {
261+
wrapperProc.on("exit", (c) => resolve(c));
262+
setTimeout(() => { wrapperProc.kill("SIGTERM"); resolve(null); }, 15000);
263+
});
264+
265+
assert.equal(code, 0, `Expected exit 0, got ${code}. stderr: ${stderr}`);
266+
assert.ok(stdout.includes("corp.internal"), `lowercase no_proxy entry should be preserved, got: ${stdout}`);
267+
assert.ok(stdout.includes("127.0.0.1"), `localhost should be merged in, got: ${stdout}`);
268+
});
269+
270+
it("--remote-control does not duplicate a localhost host already present in NO_PROXY", async () => {
271+
const script = 'process.stdout.write("NP="+(process.env.NO_PROXY||"UNSET")+"\\n")';
272+
const wrapperProc = fork(WRAPPER_PATH, ["--remote-control", "--proxy-port", "0"], {
273+
stdio: ["ignore", "pipe", "pipe", "ipc"],
274+
env: cleanEnv({ CACHE_FIX_CLAUDE_CMD: `${NODE} -e ${script}`, NO_PROXY: "127.0.0.1" }),
275+
});
276+
277+
let stdout = "";
278+
let stderr = "";
279+
wrapperProc.stdout.on("data", (c) => { stdout += c.toString(); });
280+
wrapperProc.stderr.on("data", (c) => { stderr += c.toString(); });
281+
282+
const code = await new Promise((resolve) => {
283+
wrapperProc.on("exit", (c) => resolve(c));
284+
setTimeout(() => { wrapperProc.kill("SIGTERM"); resolve(null); }, 15000);
285+
});
286+
287+
assert.equal(code, 0, `Expected exit 0, got ${code}. stderr: ${stderr}`);
288+
// 127.0.0.1 must appear exactly once, not duplicated, and localhost still added.
289+
const np = (stdout.match(/NP=(\S*)/) || [])[1] || "";
290+
const occurrences = np.split(",").filter((h) => h === "127.0.0.1").length;
291+
assert.equal(occurrences, 1, `127.0.0.1 should appear exactly once, got NP=${np}`);
292+
assert.ok(np.split(",").includes("localhost"), `localhost should be added, got NP=${np}`);
293+
});
191294
});

0 commit comments

Comments
 (0)