Skip to content

[BUG] macOS sandbox proxy truncates response bodies and resets the connection when the server closes it (Connection: close) #99650

Open

Description

@focuslight-nr

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, so node:https uses the sandbox's HTTPS_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 downloading next/font/google fonts. 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

  1. yhay81 commented on Oct 27, 2026

    @yhay81

    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: true and network.allowedDomains: ["speed.cloudflare.com"]. It also reproduces with ["*"] and with a single IP literal.

    Inside the sandbox, 3 runs per command and version:

    curl -sS --http1.1 -H 'Connection: close' -o /dev/null -w '%{size_download}\n' 'https://speed.cloudflare.com/__down?bytes=1048576'
    # exit code 18; 999,406 to 1,040,348 of 1,048,576 bytes; short in 5 of 6 runs
    python3 -c 'import urllib.request as u; u.urlopen(u.Request("https://speed.cloudflare.com/__down?bytes=1048576", headers={"User-Agent": "repro"})).read()'
    # http.client.IncompleteRead in 6 of 6 runs

    The same curl request without Connection: close was complete in 6 of 6 runs.

    With a local upstream that closes right after the response (30 runs per size and version), curl -p was 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.ts on sandbox-runtime main (98a4016, lines 951–961), and the same code is in 0.0.81 and in the 2.1.311 binary:

    upstream.pipe(socket)
    socket.pipe(upstream)
    // ...
    socket.on('close', () => upstream.destroy())
    upstream.on('close', () => socket.destroy())

    pipe() has already called socket.end(), but destroy() 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 closeAfterFlush when 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()) with closeAfterFlush(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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions