[BUG] macOS sandbox proxy truncates response bodies and resets the connection when the server closes it (`Connection: close`)
Summary
With the Bash sandbox enabled on macOS, HTTPS responses that end with the server closing the connection (for example, because the request sent Connection: close) often arrive truncated, then the client socket gets an ECONNRESET. The same requests succeed every time outside the sandbox, both directly and through a minimal CONNECT proxy.
HTTP clients that miss that late socket error never see the end of the body. The process then waits forever, or exits with code 0 once its event loop is empty. One real case is next build: Next.js 14 (node-fetch v2) and 15 (https.request behind a per-request proxy agent) download Google Fonts this way. In my runs the build hung for 30+ minutes with the webpack build worker on, or, with the worker off, exited 0 having built nothing.
Environment
- Claude Code 2.1.286, non-interactive (
claude -p), sandbox enabled through--settings - macOS 27.0.1 (26A434), arm64
- Node.js v26.10.0,
NODE_USE_ENV_PROXY=1, sonode:httpsuses the sandbox'sHTTPS_PROXY
Reproduction
settings.json:
{"sandbox": {"enabled": true, "failIfUnavailable": true, "allowUnsandboxedCommands": false,
"autoAllowBashIfSandboxed": true, "network": {"allowedDomains": ["fonts.gstatic.com"]}}}
repro.cjs downloads 7 small files in parallel and waits for each response's end:
const https = require("node:https");
const mode = process.argv[2] || "https"; // "https" | "close" | "fetch"
const urls = [
"https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7W0Q5n-wU.woff2",
"https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7W0Q5n-wU.woff2",
"https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7W0Q5n-wU.woff2",
"https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7W0Q5n-wU.woff2",
"https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7W0Q5n-wU.woff2",
"https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7W0Q5n-wU.woff2",
"https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7W0Q5nw.woff2",
];
const get = (u) => mode === "fetch"
? fetch(u).then((r) => r.arrayBuffer()).then((b) => b.byteLength)
: new Promise((resolve, reject) => {
const opts = mode === "close" ? { headers: { Connection: "close" } } : {};
https.get(u, opts, (res) => {
let n = 0;
const expected = Number(res.headers["content-length"]);
res.on("data", (c) => (n += c.length));
res.on("end", () => resolve(n));
const fail = (e) => { e.detail = `${n}/${expected} bytes`; reject(e); };
res.on("error", fail);
res.req.on("error", fail);
}).on("error", reject);
});
let done = false;
Promise.all(urls.map(get)).then(
(s) => { done = true; console.log("ok", s.reduce((a, b) => a + b, 0)); },
(e) => { done = true; console.log("error", e.code || e.message, e.detail || "before response"); });
process.on("exit", () => { if (!done) console.log("STALLED"); });
Run it 10 times per mode, with Bash inside the sandbox:
claude -p "Run exactly: bash run.sh" --tools Bash --settings settings.json --permission-mode acceptEdits --permission-prompts none
Results (10 runs each)
| Where | https (keep-alive) | close (Connection: close) | built-in fetch |
|---|---|---|---|
| Outside the sandbox, direct | 10/10 ok | 6/6 ok | 10/10 ok |
| Outside the sandbox, through a minimal Node CONNECT proxy | — | 6/6 ok | — |
| Inside the Claude Code sandbox | 10/10 ok | 3/10 ok, 7/10 ECONNRESET mid-body | 10/10 ok |
The failing runs had already received part of the body when the reset arrived, for example 6054/10280, 7432/11272 and 8810/11272 bytes. So the client does get some bytes, and then the connection is reset instead of the rest of the data and a normal close (FIN).
Expected
When the upstream server closes the connection, the sandbox proxy should forward all of the remaining response bytes and then close the client side normally. It should not reset it.
Impact
- Silent data loss for any tool that sends
Connection: close. That includes node-fetch v2, which does so by default. - Hangs in common builds.
next build(Next.js 14 and 15) stalls while downloadingnext/font/googlefonts. Agents running in the sandbox cannot kill the stuck processes (the process list is not visible), so the session waits until an outer timeout.
Workaround
Use a client that keeps the connection alive, such as Node's built-in fetch (undici), which succeeded 10/10. For Next.js, I preload a module that swaps only next/font's downloader for one built on the built-in fetch. With that, the same project builds in about 10 s inside the sandbox (Next 14: 8/8, Next 15: 3/3; before the workaround Next 15 failed 0/3).