mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
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:
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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user