feat(duplex): raise bridge body cap to the API JSON limit and add safety bounds

Raise the sandbox-to-host duplex bridge body cap from 256 KiB to the API JSON
body limit plus one byte (10 MiB + 1). The bridge is a buffering backstop, not
the policy limit, so a body of exactly the API limit passes through and the API
issues its own 413. Fix and bound the change with these parts:

- Add DEFAULT_JSON_BODY_LIMIT_BYTES and derive the "10mb" string from it so the
  byte form and the string form cannot drift. A cross-package test asserts the
  bridge cap equals this byte limit plus one.
- Answer a clean 413 request_too_large, not a retryable 502 with the internal
  message, when a request body passes the bridge cap. readBody throws a typed
  error that each gateway maps to 413.
- Add local in-sandbox body-read admission control. The gateway reserves one
  worst-case body per active read against a process-owned bound and sheds a new
  read with 503 when the bound is full. It releases the reserve on every
  terminal path. This protects the sandbox process; it is separate from the host
  ledger.
- Pin the SSH command output buffer at 1 MiB with its own constant, so it no
  longer tracks the bridge body cap.
- Raise the host pre-bind buffer to 16 MiB, sized from the body cap and the
  base64 body-chunk expansion, so one in-spec body cannot terminate its route.

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Priya RamanandPaperclip committed 2026-08-23 18:21:06 +00:00
1 parent 63df7ad2b3
commit de5a1bdbe7
9 files changed
+321 -13

No files matched your search

@@ -786,4 +786,36 @@ describe("plugin worker manager duplex channel route", () => {
await handle.stop().catch(() => undefined);
}
});
it("keeps the route open while all 40 pre-bind body chunks of one maximum body arrive", async () => {
// The default pre-bind buffer cap is 16 MiB. One maximum bridge body rides 40
// body_chunk frames of 349,528 base64 characters each, which is 13,981,120
// characters. That total plus the request envelope must arrive before the
// route binds without ending the route. The old 8 MiB cap was below this
// total, so one in-spec body could terminate its own route. The default cap
// (no override) proves 16 MiB covers the 40-chunk maximum.
const chunkChars = 349_528;
const chunkCount = 40;
const perChunk = "a".repeat(chunkChars);
const handle = makeDuplexHandle();
try {
await handle.start();
const session = await handle.openDuplexChannel(
duplexOpenInput({
workerSessionId: "ws-A",
data: Array.from({ length: chunkCount }, () => ({ chunk: perChunk })),
exitCode: 0,
}),
);
let receivedChars = 0;
session.onData((chunk) => {
receivedChars += chunk.length;
});
// The route ends on the worker exit (exitCode 0), not on the pre-bind byte
// bound. A pre-bind bound trip would resolve exitCode null instead.
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
expect(receivedChars).toBe(chunkChars * chunkCount);
} finally {
await handle.stop().catch(() => undefined);
}
}, 30000);
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_BRIDGE_MAX_BODY_BYTES } from "@paperclipai/adapter-utils/sandbox-callback-bridge";
import { DEFAULT_JSON_BODY_LIMIT, DEFAULT_JSON_BODY_LIMIT_BYTES } from "./body-limits.js";
describe("JSON body limit constants", () => {
it("keeps the byte form at 10 MiB", () => {
expect(DEFAULT_JSON_BODY_LIMIT_BYTES).toBe(10 * 1024 * 1024);
});
it("derives the string form from the byte form so the two cannot drift", () => {
// `express.json` parses the string form. The byte form derives the string,
// so a change to one changes the other.
expect(DEFAULT_JSON_BODY_LIMIT).toBe("10mb");
expect(DEFAULT_JSON_BODY_LIMIT).toBe(`${DEFAULT_JSON_BODY_LIMIT_BYTES / (1024 * 1024)}mb`);
});
it("keeps the bridge body cap at the API limit plus one byte", () => {
// The bridge cap lives in `@paperclipai/adapter-utils` and cannot import the
// API limit from `server` (that creates a package cycle). This test imports
// both packages and asserts the two agree. The `+ 1` lets a body of exactly
// the API limit pass through the bridge so the API issues its own 413.
expect(DEFAULT_BRIDGE_MAX_BODY_BYTES).toBe(DEFAULT_JSON_BODY_LIMIT_BYTES + 1);
});
});
+6 -1
View File
@@ -1,4 +1,9 @@
export const DEFAULT_JSON_BODY_LIMIT = "10mb";
// The default maximum size of one inline JSON request body the API accepts.
// The byte form and the string form must agree: `express.json` parses the
// string form, and other code (the sandbox bridge agreement test) compares the
// byte form. Derive the string form from the byte form so the two cannot drift.
export const DEFAULT_JSON_BODY_LIMIT_BYTES = 10 * 1024 * 1024;
export const DEFAULT_JSON_BODY_LIMIT = `${DEFAULT_JSON_BODY_LIMIT_BYTES / (1024 * 1024)}mb`;
export const PORTABLE_JSON_BODY_LIMIT = "64mb";
export const PORTABLE_JSON_BODY_LIMIT_BYTES = 64 * 1024 * 1024;
+9 -1
View File
@@ -193,8 +193,16 @@ const MAX_DUPLEX_CHANNEL_CHUNK_CHARS = 1_000_000;
* The default maximum cumulative characters the host buffers for one duplex
* channel route before a data listener attaches. A worker that streams data
* before the consumer binds cannot grow the host buffer without limit.
*
* The value derives from the bridge body cap `DEFAULT_BRIDGE_MAX_BODY_BYTES`
* (10 MiB + 1) and the base64 body-chunk expansion. One maximum body rides 40
* body_chunk frames of 349,528 base64 characters each, which is 13,981,120
* characters, plus the request envelope and per-frame overhead. 16 MiB covers
* that (about 2 MiB of headroom), so one in-spec request cannot terminate its
* own route before the data listener binds. Keep this cap coupled to the bridge
* body cap: if the body cap moves, re-size this value from the new cap.
*/
const MAX_DUPLEX_CHANNEL_PRE_BIND_CHARS = 8 * 1024 * 1024;
const MAX_DUPLEX_CHANNEL_PRE_BIND_CHARS = 16 * 1024 * 1024;
/**
* The default maximum number of data frames the host buffers for one duplex
* channel route before a data listener attaches.