Repository navigation
Description
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 noneResults (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).
Activity
yhay81 commentedon Oct 27, 2026
Still reproduces on Claude Code 2.1.311 (Agent SDK 0.3.311, the latest as of 2026-10-27) and on 2.1.303, macOS 27.0.1 (arm64), with a minimal configuration:
sandbox.enabled: trueandnetwork.allowedDomains: ["speed.cloudflare.com"]. It also reproduces with["*"]and with a single IP literal.Inside the sandbox, 3 runs per command and version:
The same curl request without
Connection: closewas complete in 6 of 6 runs.With a local upstream that closes right after the response (30 runs per size and version),
curl -pwas short in every run from 32 KiB to 8 MiB. At 16 KiB it was short in 25/30 runs on 2.1.311 and 30/30 on 2.1.303. Up to 64 KiB, every short transfer stopped at exactly 8,192 or 16,384 bytes, headers included. Plain-HTTP forwarding and keep-alive requests were never short.Cause. The CONNECT handler of the bundled sandbox-runtime destroys the client side when the upstream closes. This is
src/sandbox/http-proxy.tson sandbox-runtime main (98a4016, lines 951–961), and the same code is in 0.0.81 and in the 2.1.311 binary:pipe()has already calledsocket.end(), butdestroy()drops whatever is still queued for the client. On macOS, the mux hands HTTP clients to this handler over a Unix socket whose buffer is 8 KB (net.local.stream.sendspace), so something is almost always queued at that moment.2.1.311 already includes the mux half of the fix (anthropics/sandbox-runtime#700, which waits for the flush with
closeAfterFlushwhen the backend closes). 2.1.303 still destroyed there, yet from 32 KiB up the failure rate was the same on both versions. The SOCKS handler (src/sandbox/socks-proxy.ts, lines 255–257) has the same teardown. It was short only occasionally, on 8 MiB responses (5 of 60 runs), because its client socket is TCP and has a large buffer.This is tracked in sandbox-runtime as anthropics/sandbox-runtime#606 and anthropics/sandbox-runtime#756. Three fixes are open: anthropics/sandbox-runtime#377 and anthropics/sandbox-runtime#612 cover both handlers, and anthropics/sandbox-runtime#781 covers CONNECT only.
I tested sandbox-runtime 0.0.81's proxy on its own, outside Claude Code, wired through the same mux. Without a change, 67 of 80 CONNECT fetches (Node 24.22) and 68 of 80 (Bun 1.4.2) were short. After replacing the CONNECT and SOCKS
upstream.on('close', () => socket.destroy())withcloseAfterFlush(socket, () => {}), 0 of 800 fetches (CONNECT, SOCKS and keep-alive, 8 KiB to 8 MiB) were short on either runtime.#100346 (kubectl exec/cp losing the tail of the stream) looks like the same cause.