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

@@ -225,6 +225,8 @@ interface HarnessOptions {
hostApiToken?: string;
runId?: string;
maxBodyBytes?: number;
maxInflightBodyBytes?: number;
responseTimeoutMs?: number;
lossExitGraceMs?: number;
}
@@ -294,6 +296,12 @@ async function createHarness(options: HarnessOptions = {}): Promise<DuplexE2EHar
PAPERCLIP_BRIDGE_NONCE: nonce,
PAPERCLIP_BRIDGE_TOKEN: bridgeToken,
PAPERCLIP_BRIDGE_MAX_BODY_BYTES: String(maxBodyBytes),
...(options.maxInflightBodyBytes != null
? { PAPERCLIP_BRIDGE_MAX_INFLIGHT_BODY_BYTES: String(options.maxInflightBodyBytes) }
: {}),
...(options.responseTimeoutMs != null
? { PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS: String(options.responseTimeoutMs) }
: {}),
...(options.lossExitGraceMs != null
? { PAPERCLIP_BRIDGE_LOSS_EXIT_GRACE_MS: String(options.lossExitGraceMs) }
: {}),
@@ -738,4 +746,117 @@ describe("duplex bridge local end-to-end harness", () => {
await expect(response.json()).resolves.toEqual({ error: testCase.error });
}
}, 20000);
it("carries a request body of exactly the API limit through the bridge to the API", async () => {
// The bridge cap is the API JSON body limit plus one byte. A body of exactly
// the API limit must pass through the bridge and reach the API. The bridge is
// a buffering backstop, not the policy limit, so it must not reject an in-spec
// body one byte early.
const apiLimitBytes = 10 * 1024 * 1024;
const bridgeCapBytes = apiLimitBytes + 1;
const harness = await createHarness({ maxBodyBytes: bridgeCapBytes });
harnesses.push(harness);
await harness.waitFor(readyPredicate(harness), "the gateway to become ready");
let receivedBodyLength = -1;
harness.api.setResponder((_req, res, body) => {
receivedBodyLength = Buffer.byteLength(body, "utf8");
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
const body = "a".repeat(apiLimitBytes);
expect(Buffer.byteLength(body, "utf8")).toBe(apiLimitBytes);
const response = await fetch(`${harness.baseUrl}/api/issues/issue-1/comments`, {
method: "POST",
headers: {
authorization: `Bearer ${harness.bridgeToken}`,
"content-type": "application/json",
},
body,
});
// The bridge did not reject the body. It reached the API unchanged.
expect(response.status).toBe(200);
expect(receivedBodyLength).toBe(apiLimitBytes);
}, 30000);
it("answers 413 request_too_large, not 502, when the body passes the bridge cap", async () => {
// Regression test: the body-size overflow path threw a generic Error that
// unwound to the 502 catch. A 502 is retryable and leaked the internal
// message. The overflow must answer a clean 413 request_too_large. Assert the
// status code specifically, because the wrong 502 stood for a long time.
const maxBodyBytes = 4096;
const harness = await createHarness({ maxBodyBytes });
harnesses.push(harness);
await harness.waitFor(readyPredicate(harness), "the gateway to become ready");
let apiWasCalled = false;
harness.api.setResponder((_req, res) => {
apiWasCalled = true;
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
const body = "a".repeat(maxBodyBytes + 1024);
const response = await fetch(`${harness.baseUrl}/api/issues/issue-1/comments`, {
method: "POST",
headers: {
authorization: `Bearer ${harness.bridgeToken}`,
"content-type": "application/json",
},
body,
});
expect(response.status).toBe(413);
await expect(response.json()).resolves.toEqual({ error: "request_too_large" });
// The oversize body never reached the API, and the response leaked no
// internal message.
expect(apiWasCalled).toBe(false);
}, 20000);
it("sheds a concurrent body read past the admission bound, then releases the reserve", async () => {
// The gateway reserves one worst-case body per active read against the local
// admission bound. With the bound set to one body, the first read is admitted
// and the second read is shed with a 503. The gateway releases the reserve on
// the response timeout, so a later request is admitted again.
const maxBodyBytes = 4096;
const harness = await createHarness({
maxBodyBytes,
// The bound admits exactly one concurrent body read.
maxInflightBodyBytes: maxBodyBytes,
// A short response deadline releases the first (hung) read fast.
responseTimeoutMs: 700,
});
harnesses.push(harness);
await harness.waitFor(readyPredicate(harness), "the gateway to become ready");
// The first admitted request hangs on the forward until it times out. The
// second request cannot reserve, so the gateway sheds it with a 503.
harness.setForwardMode("hang");
const body = "a".repeat(1024);
const post = () =>
fetch(`${harness.baseUrl}/api/issues/issue-1/comments`, {
method: "POST",
headers: {
authorization: `Bearer ${harness.bridgeToken}`,
"content-type": "application/json",
},
body,
});
const [first, second] = await Promise.all([post(), post()]);
const statuses = [first.status, second.status].sort((a, b) => a - b);
// One request was admitted (it later times out with a 502). One request was
// shed with a 503 before it could buffer a second body.
expect(statuses).toEqual([502, 503]);
const shed = first.status === 503 ? first : second;
await expect(shed.json()).resolves.toEqual({ error: "bridge_busy" });
// The admitted read timed out and released its reserve. A later request is
// admitted again and reaches the API.
harness.setForwardMode("proxy");
harness.api.setResponder((_req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
const later = await post();
expect(later.status).toBe(200);
}, 20000);
});
@@ -5757,10 +5757,10 @@ describe("sandbox duplex gateway", () => {
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: oversizeBody,
});
expect(tooLarge.status).toBe(502);
await expect(tooLarge.json()).resolves.toEqual({
error: "Bridge request body exceeded the configured size limit.",
});
// The body-size overflow answers a clean 413 request_too_large, not a
// retryable 502 with the internal message.
expect(tooLarge.status).toBe(413);
await expect(tooLarge.json()).resolves.toEqual({ error: "request_too_large" });
// The oversized request forwarded no frame. It never left the gateway.
expect(gateway.frames.filter((frame) => frame.type === "request")).toHaveLength(0);
@@ -5819,10 +5819,8 @@ describe("sandbox duplex gateway", () => {
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ body: "x".repeat(64) }),
});
expect(oversizeBody.status).toBe(502);
await expect(oversizeBody.json()).resolves.toEqual({
error: "Bridge request body exceeded the configured size limit.",
});
expect(oversizeBody.status).toBe(413);
await expect(oversizeBody.json()).resolves.toEqual({ error: "request_too_large" });
// No request frame forwarded so far: the guards rejected before forwarding.
const framesBeforeDepth = gateway.frames.filter((frame) => frame.type === "request").length;
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { SSH_COMMAND_OUTPUT_MAX_BUFFER_BYTES } from "./execution-target.js";
import { DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES } from "./sandbox-callback-bridge.js";
describe("SSH command output buffer", () => {
it("pins the SSH output buffer at 1 MiB", () => {
expect(SSH_COMMAND_OUTPUT_MAX_BUFFER_BYTES).toBe(1024 * 1024);
});
it("keeps the SSH output buffer decoupled from the bridge body cap", () => {
// The SSH output buffer once tracked the bridge body cap (cap * 4). At a
// 10 MiB bridge cap that coupling would push the SSH buffer to about 40 MiB
// per stream. The SSH buffer must stay pinned and must not move with the
// bridge cap.
expect(SSH_COMMAND_OUTPUT_MAX_BUFFER_BYTES).not.toBe(
DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES * 4,
);
expect(SSH_COMMAND_OUTPUT_MAX_BUFFER_BYTES).toBeLessThan(
DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES,
);
});
});
@@ -624,6 +624,13 @@ export function formatAdapterExecutionTimeoutStartLogLine(
);
}
// The cap on the buffered output of one SSH command stream (stdout and stderr
// each). This bound is separate from the sandbox bridge body cap and must not
// move with it. An SSH command relays interactive shell output, not a bridge
// request body, so a large bridge body cap must not enlarge this per-stream
// buffer. 1 MiB is enough for the command output the SSH runner carries.
export const SSH_COMMAND_OUTPUT_MAX_BUFFER_BYTES = 1024 * 1024;
function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandManagedRuntimeRunner {
if (target.runner) return target.runner;
throw new Error(
@@ -642,7 +649,7 @@ function adapterExecutionTargetCommandRunner(target: AdapterCommandCapableExecut
return createSshCommandManagedRuntimeRunner({
spec: target.spec,
defaultCwd: target.remoteCwd,
maxBufferBytes: DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES * 4,
maxBufferBytes: SSH_COMMAND_OUTPUT_MAX_BUFFER_BYTES,
});
}
return requireSandboxRunner(target);
@@ -18,7 +18,19 @@ const DEFAULT_BRIDGE_POLL_INTERVAL_MS = 100;
const DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS = 30_000;
const DEFAULT_BRIDGE_STOP_TIMEOUT_MS = 2_000;
const DEFAULT_BRIDGE_MAX_QUEUE_DEPTH = 64;
const DEFAULT_BRIDGE_MAX_BODY_BYTES = 256 * 1024;
// The cap on one whole request body the in-sandbox gateway buffers before it
// forwards the body to the host. The value is the API JSON body limit (10 MiB)
// plus one byte. The `+ 1` is load-bearing: the bridge must not reject a body
// that the API itself accepts. At exactly the API limit the body must pass
// through the bridge and reach the API, so the API issues its own 413. The
// bridge cap is a buffering backstop, not a policy limit. Do not remove the
// `+ 1`; without it the bridge rejects an in-spec body one byte early.
//
// `@paperclipai/adapter-utils` cannot import the API limit from `server`
// (that creates a package cycle), so the literal lives here. A cross-package
// agreement test asserts this value equals `DEFAULT_JSON_BODY_LIMIT_BYTES + 1`
// so the two never drift.
export const DEFAULT_BRIDGE_MAX_BODY_BYTES = 10 * 1024 * 1024 + 1;
// The default cap on the aggregate raw bytes the in-sandbox duplex frame decoder
// retains between chunks. The generated gateway runs in a separate operating-system
// process, so it cannot share the host aggregate byte ledger. It enforces this
@@ -27,6 +39,17 @@ const DEFAULT_BRIDGE_MAX_BODY_BYTES = 256 * 1024;
// PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES. The default is well above one maximum
// frame, so a legitimate single frame never trips it.
const DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES = 8 * 1024 * 1024;
// The cap on the total request-body bytes the in-sandbox gateway may hold
// across all concurrent reads at once. The generated gateway runs in a separate
// operating-system process inside the sandbox, so it cannot share the host
// aggregate byte ledger. At a 10 MiB body cap, the 64-deep pending queue could
// let many concurrent reads each retain a full body and exhaust the sandbox
// process memory before any host limit applies. The gateway reserves one
// worst-case body per active read against this bound, and sheds a new read with
// a 503 when the bound is full. The host passes the value through
// PAPERCLIP_BRIDGE_MAX_INFLIGHT_BODY_BYTES. This bound protects the sandbox
// process; it is separate from the host aggregate byte ledger.
const DEFAULT_BRIDGE_MAX_INFLIGHT_BODY_BYTES = 64 * 1024 * 1024;
// Per-iteration timeout for one poll-loop client call. A healthy control-plane
// round trip finishes in well under one second, so 10s is far above a normal
// iteration and never false-fires on a slow-but-live call. It is also well
@@ -2031,6 +2054,13 @@ const maxBodyBytes = Number(process.env.PAPERCLIP_BRIDGE_MAX_BODY_BYTES || "${DE
const maxDuplexDecoderBytes = Number(
process.env.PAPERCLIP_BRIDGE_MAX_DUPLEX_DECODER_BYTES || "${DEFAULT_BRIDGE_MAX_DUPLEX_DECODER_BYTES}",
);
// The host passes the local in-sandbox body-read admission bound here. The
// gateway reserves one worst-case body (maxBodyBytes) per active read and sheds
// a new read with a 503 when the reserve is full. This bound protects the
// sandbox process memory; it never shares the host aggregate byte ledger.
const maxInflightBodyBytes = Number(
process.env.PAPERCLIP_BRIDGE_MAX_INFLIGHT_BODY_BYTES || "${DEFAULT_BRIDGE_MAX_INFLIGHT_BODY_BYTES}",
);
const heartbeatIntervalMs = Number(
process.env.PAPERCLIP_BRIDGE_HEARTBEAT_INTERVAL_MS || "${DEFAULT_DUPLEX_GATEWAY_HEARTBEAT_INTERVAL_MS}",
);
@@ -2074,6 +2104,37 @@ function normalizeHeaders(headers) {
return out;
}
// Typed marker for a request body that exceeds maxBodyBytes. readBody throws it,
// so each gateway answers a clean 413 request_too_large instead of the generic
// 502. A 502 is retryable by convention and would leak the internal message.
class BridgeBodyTooLargeError extends Error {
constructor() {
super("request_too_large");
this.code = "request_too_large";
}
}
// The running total of reserved body-read bytes across all active reads.
let reservedBodyBytes = 0;
const noopRelease = () => {};
// Reserve capacity for one body read against the local admission bound. Return a
// release function on success, or null when the bound is full. Each caller
// releases on every terminal path: success, size rejection, other error,
// timeout, and connection close.
function reserveBodyRead() {
if (reservedBodyBytes + maxBodyBytes > maxInflightBodyBytes) {
return null;
}
reservedBodyBytes += maxBodyBytes;
let released = false;
return () => {
if (released) return;
released = true;
reservedBodyBytes -= maxBodyBytes;
};
}
async function readBody(req) {
const chunks = [];
let totalBytes = 0;
@@ -2082,7 +2143,7 @@ async function readBody(req) {
chunks.push(nextChunk);
totalBytes += nextChunk.byteLength;
if (totalBytes > maxBodyBytes) {
throw new Error("Bridge request body exceeded the configured size limit.");
throw new BridgeBodyTooLargeError();
}
}
return Buffer.concat(chunks).toString("utf8");
@@ -2147,6 +2208,13 @@ async function runFileGateway() {
return;
}
const requestId = randomUUID();
const hasRequestBody = req.method && req.method !== "GET" && req.method !== "HEAD";
const releaseBodyRead = hasRequestBody ? reserveBodyRead() : noopRelease;
if (!releaseBodyRead) {
writeJsonResponse(res, 503, { error: "bridge_busy" });
return;
}
try {
const requestBody = await readBody(req);
const payload = {
id: requestId,
@@ -2182,7 +2250,14 @@ async function runFileGateway() {
res.setHeader(key, value);
}
res.end(typeof response.body === "string" ? response.body : "");
} finally {
releaseBodyRead();
}
} catch (error) {
if (error && error.code === "request_too_large") {
writeJsonResponse(res, 413, { error: "request_too_large" });
return;
}
writeJsonResponse(res, 502, { error: error instanceof Error ? error.message : String(error) });
}
});
@@ -2442,6 +2517,13 @@ function runDuplexGateway() {
return;
}
const requestId = randomUUID();
const hasRequestBody = req.method && req.method !== "GET" && req.method !== "HEAD";
const releaseBodyRead = hasRequestBody ? reserveBodyRead() : noopRelease;
if (!releaseBodyRead) {
writeJsonResponse(res, 503, { error: "bridge_busy" });
return;
}
try {
const requestBodyBuffer = Buffer.from(await readBody(req), "utf8");
if (unavailable) {
writeJsonResponse(res, 503, { error: "bridge_unavailable" });
@@ -2506,7 +2588,14 @@ function runDuplexGateway() {
res.setHeader(key, value);
}
res.end(typeof response.body === "string" ? response.body : "");
} finally {
releaseBodyRead();
}
} catch (error) {
if (error && error.code === "request_too_large") {
writeJsonResponse(res, 413, { error: "request_too_large" });
return;
}
writeJsonResponse(res, 502, { error: error instanceof Error ? error.message : String(error) });
}
});
@@ -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.