Skip to content

fix(server): release a refused download's body before reporting it - #732

Open
aniruddhaadak80 wants to merge 1 commit into
CopilotKit:mainfrom
aniruddhaadak80:fix/release-refused-download-body
Open

aniruddhaadak80 wants to merge 1 commit into
CopilotKit:mainfrom
aniruddhaadak80:fix/release-refused-download-body

Conversation

@aniruddhaadak80

Copy link
Copy Markdown

What this changes

A file download that OpenBot refuses because the computer's response carried no usable content-length now releases the response body before it throws.

download() in server/src/computer/client.ts reads content-length to learn how large the download is, and refuses when the header is missing or not a plain integer:

const length = response.headers.get("content-length");
const bytes =
  length !== null && /^\d+$/.test(length) ? Number(length) : Number.NaN;
if (!response.body || !Number.isSafeInteger(bytes) || bytes < 0) {
  throw new ComputerUnavailableError(
    "The assistant's computer returned an invalid file download.",
  );
}

At that point the body is unread, and nothing in the codebase ever reads it — the function throws instead of returning it. So the stream is abandoned while still checked out, holding a transfer and a pooled connection until it is eventually collected. The body can be as large as the whole workspace download budget.

This is reachable in an ordinary deployment. A computer reached through a proxy that re-chunks, or any remote provider behind COMPUTER_AGENT_URL, answers 200 with Transfer-Encoding: chunked and no content-length. Every attempt then leaves a transfer running. A Bot that retries a download turns a single clear refusal into a slow leak instead.

The refusal immediately above it already gets this right — the !response.ok branch consumes its body through response.json() before calling throwMappedError. This change brings the one remaining refusal in line, and uses the release idiom the server already uses in provider-oauth.ts and voice/summary.ts.

The cancel is guarded, because a cancel that fails must not replace the refusal the caller is told about. There is a regression test for exactly that.

Where it runs

  • New state that outlives a request? None. This frees a response body; it adds no state, and nothing is retained between requests.
  • What happens on the second replica? Identical. The change is inside one response path in one process and holds no cross-request state, so a request served by any replica behaves the same. There is no shared state to disagree about.
  • Anything serialised? Nothing. No write, no row, no index. Nothing is persisted and nothing needs to be mutually exclusive.
  • Anything fanned out to a browser? No. The body is being discarded, not delivered; no message reaches a socket on another process.
  • New listener, port, or schedule? None. No listener, port, or schedule is added.

Boundary and audit

  • Every acting call still goes through the gateway: resolve, decide, audit, then act.

    This is not an acting call. It is the refusal path for a download whose metadata could not be trusted, and it stays on that path — the same ComputerUnavailableError is thrown, with the same message, for the same condition.

  • New refusals and new failures each write a row.

    No new refusal is introduced. The condition that refuses is unchanged; only what happens to the socket before the refusal is different.

  • Nothing new is trusted from the client that the server can resolve itself.

    No new input is read. The only value used is the computer's own content-length, and it is still only used to compute a byte count — a length that cannot be trusted is refused, exactly as before.

Changelog

  • A line in CHANGELOG.md under Unreleased.

    Added. It describes the deployment-visible difference: a refused download no longer holds a transfer open.

Proof

What I ran, from the repository root:

  • bun test server/tests/computer-client.test.ts — 27 pass, 0 fail. The two new tests are in the existing computer file downloads block:
    • releases the connection it refuses to hand back builds a ReadableStream with a cancel spy and no content-length, asserts the refusal as before, and asserts the body was released. This test fails on main — it reports cancelled as false, because the body is abandoned at the throw. I confirmed the failure before applying the fix.
    • still names the invalid download when releasing it fails makes cancel() reject and asserts the caller still gets ComputerUnavailableError rather than the cancel's own error.
  • bun run --filter server typecheck — exit code 0.
  • bunx biome lint --error-on-warnings and bunx biome format on the two changed files — clean, no fixes applied.

I have not run this against a live agent-computer behind a re-chunking proxy; the regression test drives the transport's fetchImpl seam directly, which is where the abandoned body was observable.

A download was refused when the computer's response carried no usable
content-length, but the body was left unread: the throw happened with the
stream still checked out. Nothing else ever reads it, and it can be as
large as the whole workspace download budget.

A computer reached through a proxy that re-chunks answers 200 with
Transfer-Encoding and no content-length, so every attempt left a transfer
running and a connection held until it was collected. A Bot retrying a
download turned one refusal into a slow leak rather than one clear error.

The refusal branch above already consumed its body via response.json(); this
brings the remaining refusal in line, and matches the release idiom already
used in provider-oauth.ts and voice/.

A cancel that fails must not replace the refusal the caller is told about,
so it is guarded and the ComputerUnavailableError still surfaces.
Copilot AI balanced review requested due to automatic review settings October 4, 2026 08:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants