feat(adapter-utils): carry binary bodies and attachment routes over the HTTP/2 sandbox bridge (#12923)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip runs agents in local and remote sandboxes through adapter
utilities
> - The HTTP/2 sandbox bridge decoded every body as UTF-8 text and
rejected non-JSON content
> - This stopped agents from uploading or downloading issue attachments
through that bridge
> - This pull request carries raw bytes, permits the two attachment
routes, and enforces a shared body limit
> - The benefit is correct attachment transfer with a process-wide
memory guard

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The HTTP/2 sandbox bridge forwards request bodies between an agent
sandbox and the Paperclip host. It now supports binary bodies and the
issue attachment routes.

**Current behavior**

The bridge decodes each body as UTF-8 text. It returns HTTP 415 for
content types outside the JSON route list. An agent cannot upload or
download an issue attachment through this transport.

**Proposed behavior**

The bridge carries raw bytes through the forward path. It permits the
attachment upload and content routes. The queue transport and file
gateway keep their existing route behavior. A shared 10 MiB body limit
and process-wide byte reservation protect memory use.

**Reason and benefit**

Attachment clients need byte-preserving transfer. The shared limit keeps
the gateway and host aligned. The reservation prevents concurrent
streams from exceeding the accepted process memory ceiling.

**Breaking changes**

The HTTP/2 bridge accepts two attachment routes and permits binary
content. The queue transport and file gateway keep their previous route
lists and HTTP 415 behavior. No schema or external endpoint changes.

## What Changed

- Carry request and response bodies as raw bytes through the HTTP/2
bridge.
- Permit attachment upload and attachment content routes on the HTTP/2
bridge only.
- Raise the resolved per-body limit to 10 MiB and share it between the
gateway and host.
- Reserve body bytes before allocation and release each stream
reservation on every terminal path.
- Document the body limit, process ceiling, and reservation behavior.

## Verification

- Run `pnpm exec vitest run
packages/adapter-utils/src/http2-bridge-server.test.ts
packages/adapter-utils/src/execution-target-sandbox.test.ts
packages/adapter-utils/src/sandbox-callback-bridge.test.ts`; 226 tests
pass.
- Run `pnpm --filter @paperclipai/adapter-utils typecheck`; it passes.
- Run the direct server TypeScript check with `tsc --noEmit` in
`server/`; it passes with zero errors.
- Verify multipart upload and binary download round trips over HTTP/2
without corruption.
- Verify the queue transport and file gateway return HTTP 415 for the
same routes.
- Verify the host rejects bodies over the resolved limit.
- Verify a denied reservation returns HTTP 503 and allocates no copy.
- Verify stream cleanup releases reservations after completion, error,
abort, timeout, and close.

## Risks

The bridge now accepts larger bodies and binary content. The
process-wide reservation limits total live body bytes to 1 GiB. Route
behavior changes only for the HTTP/2 bridge. The security review found
no blocking issue for this commit range.

## Model Used

OpenAI Codex, GPT-5. The runtime used tool calls and code execution. The
runtime did not expose the context window size. No model-generated code
changes were made for this pull request.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-09-08 14:18:12 -07:00 committed by GitHub
parent e095b84dab
commit 6019e2bd6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 2696 additions and 137 deletions

View File

@ -754,18 +754,49 @@ To add a name or an enum value, extend the literal constant in
### Known behavior: aggregate retained body bytes
The HTTP/2 bridge bounds retained body bytes for one route only. Each route
holds up to 8,388,608 bytes (8 MiB) at its own peak (see
`HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS` in `http2-bridge-server.ts`). The host
process admits up to 128 concurrent routes (see
`DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES` in `plugin-worker-manager.ts`). The
process can therefore retain up to 1,073,741,824 bytes (1 GiB) of body data
across every route at the same time.
Each HTTP/2 bridge route holds up to 168,820,736 bytes (161 MiB) at its own
peak (see `HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS` in `http2-bridge-server.ts`).
The host process admits up to 128 concurrent routes (see
`DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES` in `plugin-worker-manager.ts`). Those
two figures alone would let the process retain up to 21,609,054,208 bytes
(about 20.1 GiB) of body data across every route at the same time.
The process does not reach that figure, on two levels.
`HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES` (`http2-bridge-server.ts`) enforces a
real, live ledger: 1,073,741,824 bytes (1 GiB) across every route, not merely
an accepted paper ceiling. Every HTTP/2 stream creates one `BridgeBodyReservation` owner over
its lifetime, and every source-level full-body buffer that stream retains —
its request-body chunk array, the concatenated request body, the
response-body chunk array, and the concatenated response body — reserves
against that one owner before it allocates. A reservation that would pass the
process total is denied before it copies anything, and the host answers 503
instead of accepting the body. The reservation stays live for the response
body until the HTTP/2 write actually finishes flowing to the peer or the
stream closes, not merely until the write call returns, so a slow or
backpressured peer cannot hold response bytes in memory the ledger no longer
counts.
`HTTP2_BRIDGE_MAX_ROUTE_BODY_BYTES` adds a second, per-route ledger on top of
that process-wide one: each route's own reservations also check a ceiling
scoped to that one route (its own 168,820,736-byte peak from above), so one
busy or malicious route can pass its own ceiling and get denied with a 503,
but it can never spend the whole process-wide total and deny every sibling
route admission. This accounting covers source-level full-body buffers only:
internal Node.js and Undici copies (socket buffers, HTTP/2 frame buffers,
decompression buffers) stay outside it.
The generated gateway process inside the sandbox (`getSandboxCallbackBridgeServerSource`
in `sandbox-callback-bridge.ts`) enforces its own separate ledger, independent
of the two host-side ledgers above: each side bounds only the memory in its
own process. `readBodyBytes` reserves a request body's chunk bytes as they
arrive, then reserves the concatenated buffer's own byte count before
`Buffer.concat` allocates it, against a ceiling of `maxBodyBytes * 8` (4
concurrent bodies, each counted twice for its two live copies). A denied
reservation answers 503 with no forward call. Each request handler releases
its own reservation once the whole request settles: a completed response, a
thrown error, a client abort, or a deadline timeout all reach the same
release call.
This is accepted, known behavior. The process tracks no aggregate byte
ledger across routes: a per-route bound stops one busy route from starving
another route's own budget, but the host enforces no smaller ceiling on the
sum across every route.
Keep every dimension low-cardinality and free of user content.
### Shared skill preparation

View File

@ -10,6 +10,13 @@ import { promisify } from "node:util";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getSandboxDuplexGatewayCodecSource } from "./sandbox-callback-bridge.js";
import {
createBridgeBodyReservation,
getBridgeBodyReservedBytesForTest,
resetBridgeBodyReservationsForTest,
HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS,
HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES,
} from "./http2-bridge-server.js";
import {
__duplexReadinessTesting,
@ -3177,10 +3184,10 @@ describe("sandbox adapter execution targets", () => {
*/
function http2TestRequest(
session: http2.ClientHttp2Session,
request: { method: string; path: string; headers?: Record<string, string>; body?: string },
): Promise<{ status: number; headers: Record<string, string>; body: string }> {
request: { method: string; path: string; headers?: Record<string, string>; body?: Buffer },
): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> {
return new Promise((resolve, reject) => {
const body = Buffer.from(request.body ?? "", "utf8");
const body = request.body ?? Buffer.alloc(0);
const stream = session.request(
{ ":method": request.method, ":path": request.path, ...request.headers },
{ endStream: body.length === 0 },
@ -3197,13 +3204,35 @@ describe("sandbox adapter execution targets", () => {
}
});
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.once("end", () => resolve({ status, headers, body: Buffer.concat(chunks).toString("utf8") }));
stream.once("end", () => resolve({ status, headers, body: Buffer.concat(chunks) }));
stream.once("error", (error) => reject(error));
if (body.length > 0) stream.end(body);
else if (!stream.writableEnded) stream.end();
});
}
// Fixed binary payload for an attachment-content download. Byte 0x89 opens
// the PNG signature and is not valid UTF-8 on its own, so a round trip
// through a text decode step would corrupt it.
const ATTACHMENT_DOWNLOAD_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0x00, 0x7f]);
// Build a minimal multipart/form-data request body with one binary file
// part, plus the matching `content-type` header value.
function buildMultipartAttachmentUpload(fileBytes: Buffer): { body: Buffer; contentType: string } {
const boundary = "paperclip-test-boundary";
const head = Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="upload.bin"\r\n` +
`Content-Type: application/octet-stream\r\n\r\n`,
"utf8",
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8");
return {
body: Buffer.concat([head, fileBytes, tail]),
contentType: `multipart/form-data; boundary=${boundary}`,
};
}
// Start a host API server that records each forwarded request, so a test can
// assert the real token and the run id reach the host, or that a rejected
// request never forwards.
@ -3215,6 +3244,7 @@ describe("sandbox adapter execution targets", () => {
auth: string | null;
runId: string | null;
headers: Record<string, string>;
body: Buffer;
}>;
close: () => Promise<void>;
}> {
@ -3224,21 +3254,35 @@ describe("sandbox adapter execution targets", () => {
auth: string | null;
runId: string | null;
headers: Record<string, string>;
body: Buffer;
}> = [];
const server = createServer((req, res) => {
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
if (typeof value === "string") headers[key] = value;
}
requests.push({
method: req.method ?? "GET",
url: req.url ?? "/",
auth: req.headers.authorization ?? null,
runId: typeof req.headers["x-paperclip-run-id"] === "string" ? req.headers["x-paperclip-run-id"] : null,
headers,
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
requests.push({
method: req.method ?? "GET",
url: req.url ?? "/",
auth: req.headers.authorization ?? null,
runId: typeof req.headers["x-paperclip-run-id"] === "string" ? req.headers["x-paperclip-run-id"] : null,
headers,
body: Buffer.concat(chunks),
});
// An attachment-content download answers with a binary body, so a
// test can assert the bytes reach the caller unchanged. Every other
// route keeps the fixed JSON acknowledgement.
if (req.method === "GET" && /^\/api\/attachments\/[^/]+\/content$/.test(req.url ?? "")) {
res.writeHead(200, { "content-type": "application/octet-stream" });
res.end(ATTACHMENT_DOWNLOAD_BYTES);
return;
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
@ -3298,6 +3342,23 @@ describe("sandbox adapter execution targets", () => {
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
const fallback = counters.find((c) => c.metric === DUPLEX_COUNTER_FALLBACK_TOTAL);
expect(fallback?.dimensions.fallback_reason).toBe("channel_open_failed");
// The queue fallback keeps the 415 gate for the attachment upload path:
// it never admits a binary body, and it never forwards the request to
// the host.
const uploadResponse = await fetch(
`${bridge!.env.PAPERCLIP_API_URL}/api/companies/co-1/issues/issue-1/attachments`,
{
method: "POST",
headers: {
authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`,
"content-type": "application/octet-stream",
},
body: Buffer.from([0x50, 0x4b, 0x03, 0x04]),
},
);
expect(uploadResponse.status).toBe(415);
expect(api.requests).toHaveLength(0);
} finally {
await bridge?.stop();
await api.close();
@ -3762,7 +3823,7 @@ describe("sandbox adapter execution targets", () => {
method: "POST",
path: "/api/secret-admin-route",
headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" },
body: JSON.stringify({ escalate: true }),
body: Buffer.from(JSON.stringify({ escalate: true }), "utf8"),
});
expect(forbidden.status).toBe(403);
expect(api.requests).toHaveLength(0);
@ -3784,6 +3845,39 @@ describe("sandbox adapter execution targets", () => {
runId: "run-http2-403",
});
expect(api.requests[0].headers["x-not-allowed"]).toBeUndefined();
// The two attachment routes are admitted on the http2 path, and a
// binary body reaches the host and returns unchanged in both
// directions: no re-encoding step touches the multipart upload or the
// binary download.
const uploadBytes = Buffer.from([0x00, 0x01, 0xff, 0x7f, 0x80, 0x0d, 0x0a]);
const upload = buildMultipartAttachmentUpload(uploadBytes);
const uploadResponse = await http2TestRequest(sessionRef.current!, {
method: "POST",
path: "/api/companies/co-1/issues/issue-1/attachments",
headers: {
authorization: `Bearer ${bridgeToken}`,
"content-type": upload.contentType,
},
body: upload.body,
});
expect(uploadResponse.status).toBe(200);
expect(api.requests).toHaveLength(2);
expect(api.requests[1]).toMatchObject({
method: "POST",
url: "/api/companies/co-1/issues/issue-1/attachments",
});
expect(api.requests[1].headers["content-type"]).toBe(upload.contentType);
expect(api.requests[1].body.equals(upload.body)).toBe(true);
const downloadResponse = await http2TestRequest(sessionRef.current!, {
method: "GET",
path: "/api/attachments/att-1/content",
headers: { authorization: `Bearer ${bridgeToken}` },
});
expect(downloadResponse.status).toBe(200);
expect(api.requests).toHaveLength(3);
expect(downloadResponse.body.equals(ATTACHMENT_DOWNLOAD_BYTES)).toBe(true);
} finally {
sessionRef.current?.close();
await bridge?.stop();
@ -3791,6 +3885,83 @@ describe("sandbox adapter execution targets", () => {
}
}, 20000);
it("test_http2_forward_preserves_request_and_response_bytes", async () => {
// Byte 0xC3 opens a two-byte UTF-8 sequence; 0x28 is not a valid
// continuation byte, so this body is not valid UTF-8. The forward path
// must carry these exact bytes on the way in, and the host's own
// response bytes on the way out, with no re-encoding step on either leg.
const malformedBytes = Buffer.from([0x7b, 0x22, 0x61, 0x22, 0x3a, 0xc3, 0x28, 0x7d]);
const receivedRequestBodies: Buffer[] = [];
const echoServer = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
receivedRequestBodies.push(Buffer.concat(chunks));
res.writeHead(200, { "content-type": "application/octet-stream" });
res.end(malformedBytes);
});
});
await new Promise<void>((resolve, reject) => {
echoServer.once("error", reject);
echoServer.listen(0, "127.0.0.1", () => resolve());
});
const echoAddress = echoServer.address();
if (!echoAddress || typeof echoAddress === "string") {
throw new Error("Expected the echo server to listen on a TCP port.");
}
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-raw-bytes-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
await mkdir(remoteCwd, { recursive: true });
const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null };
let bridgeToken = "";
const { runner } = makeHttp2SelectionRunner((ctx) => {
bridgeToken = ctx.bridgeToken;
ctx.emitReady();
sessionRef.current = ctx.connectHttp2();
});
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
remoteCwd,
timeoutMs: 30_000,
runner,
effectiveCapabilities: duplexCapabilities(true),
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-raw-bytes",
target,
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: `http://127.0.0.1:${echoAddress.port}`,
enableSandboxDuplexBridge: true,
});
try {
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1");
await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000);
const response = await http2TestRequest(sessionRef.current!, {
method: "POST",
path: "/api/issues/issue-1/comments",
headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/octet-stream" },
body: malformedBytes,
});
expect(response.status).toBe(200);
expect(receivedRequestBodies).toHaveLength(1);
expect(receivedRequestBodies[0]?.equals(malformedBytes)).toBe(true);
expect(response.body.equals(malformedBytes)).toBe(true);
} finally {
sessionRef.current?.close();
await bridge?.stop();
await new Promise<void>((resolve) => echoServer.close(() => resolve()));
}
}, 20000);
// One recording telemetry recorder. It captures every span, counter, and event
// the fixed duplex surface produces, so a test asserts the exact names,
// dimensions, and values. An optional `failEvery` flag makes every method throw,
@ -4234,7 +4405,7 @@ describe("sandbox adapter execution targets", () => {
method: "POST",
path: `/api/issues/${ROUTE_SENTINEL}/comments?secret=${QUERY_SENTINEL}`,
headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" },
body: JSON.stringify({ body: BODY_SENTINEL }),
body: Buffer.from(JSON.stringify({ body: BODY_SENTINEL }), "utf8"),
});
expect(response.status).toBe(200);
await waitForCondition(
@ -5350,6 +5521,11 @@ describe("sandbox adapter execution targets", () => {
// failed, so a retry could double-apply it. The host answers a
// non-retryable 504 with the indeterminate marker instead — the same
// rule `forwardBridgeRequest` already applies on every transport.
//
// `maxBodyBytes: 1` below now bounds the request body too, since the
// host and the gateway share one resolved ceiling: this request carries
// no body, so only the mock host's own response — comfortably over one
// byte — trips the size check this test exists to force.
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-unsafe-indeterminate-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
@ -5389,7 +5565,6 @@ describe("sandbox adapter execution targets", () => {
method: "POST",
path: "/api/issues/issue-1/comments",
headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" },
body: JSON.stringify({ body: "hello" }),
});
expect(response.status).toBe(504);
expect(response.headers["x-paperclip-bridge-outcome"]).toBe("indeterminate");
@ -5481,6 +5656,509 @@ describe("sandbox adapter execution targets", () => {
}
}, 20000);
it("test_a_denied_response_chunk_cancels_the_reader_before_it_copies_the_chunk", async () => {
// Fill the process ledger so only a sliver of headroom remains, then let
// the mock host answer with a response chunk far bigger than that
// sliver. `readBridgeForwardResponseBody` (`execution-target.ts`) must
// deny that chunk's reservation, cancel its reader (closing the
// outbound connection to the mock host), and retain no copy of it.
resetBridgeBodyReservationsForTest();
const filler = createBridgeBodyReservation();
expect(filler.reserve(HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES - 100)).toBe(true);
let resolveHostConnectionClosed: (() => void) | undefined;
const hostConnectionClosed = new Promise<void>((resolve) => {
resolveHostConnectionClosed = resolve;
});
const api = createServer((_req, res) => {
res.writeHead(200, { "content-type": "application/octet-stream" });
// Far bigger than the 100 bytes of headroom the filler above left:
// this one chunk alone must pass the process ceiling.
res.write(Buffer.alloc(1_000, "a"));
res.on("close", () => resolveHostConnectionClosed!());
});
await new Promise<void>((resolve, reject) => {
api.once("error", reject);
api.listen(0, "127.0.0.1", () => resolve());
});
const apiAddress = api.address();
if (!apiAddress || typeof apiAddress === "string") {
throw new Error("Expected the mock host server to listen on a TCP port.");
}
const apiOrigin = `http://127.0.0.1:${apiAddress.port}`;
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-denied-response-chunk-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
await mkdir(remoteCwd, { recursive: true });
const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null };
let bridgeToken = "";
const { runner } = makeHttp2SelectionRunner((ctx) => {
bridgeToken = ctx.bridgeToken;
ctx.emitReady();
sessionRef.current = ctx.connectHttp2();
});
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
remoteCwd,
timeoutMs: 30_000,
runner,
effectiveCapabilities: duplexCapabilities(true),
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-denied-response-chunk",
target,
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: apiOrigin,
enableSandboxDuplexBridge: true,
});
try {
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1");
await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000);
// A capacity denial reaches the client as the retryable 503 the
// HTTP/2 bridge server's own capacity-denial path answers
// (`forwardBridgeRequest` rethrows `BridgeProcessCapacityError` before
// the method-safety classification runs), not the generic 502 that
// classification would give any other response-body read fault.
const response = await http2TestRequest(sessionRef.current!, {
method: "GET",
path: "/api/agents/me",
headers: { authorization: `Bearer ${bridgeToken}` },
});
expect(response.status).toBe(503);
// The reader actually cancelled: the mock host observes its
// connection close, instead of staying open with the chunk
// unacknowledged.
await hostConnectionClosed;
} finally {
sessionRef.current?.close();
await bridge?.stop();
await new Promise<void>((resolve) => api.close(() => resolve()));
// The denied chunk was never retained: releasing the filler is the
// only release this test needs to reach zero. If the denied response
// copy had reserved anything despite being denied, this would be
// nonzero.
filler.release();
expect(getBridgeBodyReservedBytesForTest()).toBe(0);
}
}, 20000);
it("test_a_denied_concatenated_response_body_never_allocates_the_copy", async () => {
// Leave room for the one response chunk but not for the second,
// concatenated copy `readBridgeForwardResponseBody` (`execution-target.ts`)
// builds from it: a correct reader checks the reservation before
// `Buffer.concat` allocates the copy, so the denied concatenated copy
// must never call `Buffer.concat` at all. The same denial must also
// cancel the upstream response reader, instead of leaving it open after
// the throw.
resetBridgeBodyReservationsForTest();
const chunkBytes = 200_000;
const filler = createBridgeBodyReservation();
expect(filler.reserve(HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES - Math.floor(chunkBytes * 1.5))).toBe(true);
const api = createServer((_req, res) => {
res.writeHead(200, { "content-type": "application/octet-stream" });
res.end(Buffer.alloc(chunkBytes, "a"));
});
await new Promise<void>((resolve, reject) => {
api.once("error", reject);
api.listen(0, "127.0.0.1", () => resolve());
});
const apiAddress = api.address();
if (!apiAddress || typeof apiAddress === "string") {
throw new Error("Expected the mock host server to listen on a TCP port.");
}
const apiOrigin = `http://127.0.0.1:${apiAddress.port}`;
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-denied-response-concat-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
await mkdir(remoteCwd, { recursive: true });
const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null };
let bridgeToken = "";
const { runner } = makeHttp2SelectionRunner((ctx) => {
bridgeToken = ctx.bridgeToken;
ctx.emitReady();
sessionRef.current = ctx.connectHttp2();
});
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
remoteCwd,
timeoutMs: 30_000,
runner,
effectiveCapabilities: duplexCapabilities(true),
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-denied-response-concat",
target,
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: apiOrigin,
enableSandboxDuplexBridge: true,
});
const concatSpy = vi.spyOn(Buffer, "concat");
const readerCancelSpy = vi.spyOn(ReadableStreamDefaultReader.prototype, "cancel");
try {
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1");
await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000);
// A capacity denial must reach the client as the retryable 503 the
// HTTP/2 bridge server's own capacity-denial path answers, not the
// generic 502 the method-safety classification would otherwise apply
// to any other response-body read fault (`forwardBridgeRequest`
// rethrows `BridgeProcessCapacityError` before that classification
// runs). This reads the response with a plain string accumulator, not
// `Buffer.concat`, so the spy below counts only the calls the bridge
// code under test makes.
const response = await new Promise<{ status: number; body: string }>((resolve, reject) => {
const stream = sessionRef.current!.request({
":method": "GET",
":path": "/api/agents/me",
authorization: `Bearer ${bridgeToken}`,
});
let status = 0;
let body = "";
stream.setEncoding("utf8");
stream.on("response", (headers) => {
status = Number(headers[":status"]) || 0;
});
stream.on("data", (chunk) => (body += chunk));
stream.once("end", () => resolve({ status, body }));
stream.once("error", reject);
stream.end();
});
expect(response.status).toBe(503);
expect(JSON.parse(response.body)).toEqual({
error: "The bridge host reached its reserved process body byte ceiling. Retry later.",
});
// The GET request itself carries no body, so the host's own read of
// that empty request body still calls `Buffer.concat` on an empty
// array — that call is unrelated to this test. No call may carry any
// response byte, since the denied concatenated response copy must
// never allocate.
for (const [chunks] of concatSpy.mock.calls) {
expect((chunks as Buffer[]).reduce((sum, chunk) => sum + chunk.length, 0)).toBe(0);
}
// The denied concatenated-body reservation must cancel the upstream
// response reader before it throws, instead of leaving it open.
expect(readerCancelSpy).toHaveBeenCalled();
} finally {
readerCancelSpy.mockRestore();
concatSpy.mockRestore();
sessionRef.current?.close();
await bridge?.stop();
await new Promise<void>((resolve) => api.close(() => resolve()));
// The denied concatenated copy reserved nothing: releasing the filler
// is the only release this test needs to reach zero.
filler.release();
expect(getBridgeBodyReservedBytesForTest()).toBe(0);
}
}, 20000);
it("test_a_denied_response_chunk_on_a_mutating_method_is_indeterminate_not_retryable", async () => {
// A POST may already have committed on the host by the time the
// response-body read hits the process capacity ceiling: the host
// delivered response headers before the read even starts. Unlike the
// safe-method case above, this denial must not reach the client as a
// retryable 503 — a caller that retries would apply the mutation twice.
// It must fall through to the same non-retryable indeterminate 504 any
// other response-body read fault on a mutating method gets.
resetBridgeBodyReservationsForTest();
const filler = createBridgeBodyReservation();
expect(filler.reserve(HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES - 100)).toBe(true);
const api = createServer((_req, res) => {
res.writeHead(200, { "content-type": "application/octet-stream" });
// Far bigger than the 100 bytes of headroom the filler above left.
res.end(Buffer.alloc(1_000, "a"));
});
await new Promise<void>((resolve, reject) => {
api.once("error", reject);
api.listen(0, "127.0.0.1", () => resolve());
});
const apiAddress = api.address();
if (!apiAddress || typeof apiAddress === "string") {
throw new Error("Expected the mock host server to listen on a TCP port.");
}
const apiOrigin = `http://127.0.0.1:${apiAddress.port}`;
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-denied-response-mutation-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
await mkdir(remoteCwd, { recursive: true });
const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null };
let bridgeToken = "";
const { runner } = makeHttp2SelectionRunner((ctx) => {
bridgeToken = ctx.bridgeToken;
ctx.emitReady();
sessionRef.current = ctx.connectHttp2();
});
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
remoteCwd,
timeoutMs: 30_000,
runner,
effectiveCapabilities: duplexCapabilities(true),
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-denied-response-mutation",
target,
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: apiOrigin,
enableSandboxDuplexBridge: true,
});
try {
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1");
await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000);
const response = await http2TestRequest(sessionRef.current!, {
method: "POST",
path: "/api/issues/issue-1/comments",
headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" },
});
expect(response.status).toBe(504);
expect(response.headers["x-paperclip-bridge-outcome"]).toBe("indeterminate");
expect(JSON.parse(response.body.toString("utf8"))).toMatchObject({
outcome: "indeterminate",
retryable: false,
});
} finally {
sessionRef.current?.close();
await bridge?.stop();
await new Promise<void>((resolve) => api.close(() => resolve()));
filler.release();
expect(getBridgeBodyReservedBytesForTest()).toBe(0);
}
}, 20000);
it("test_concurrent_request_and_response_bodies_never_pass_the_process_ceiling", async () => {
resetBridgeBodyReservationsForTest();
const bodyBytes = 2 * 1024 * 1024;
const requestBody = Buffer.alloc(bodyBytes, "a");
const samples: number[] = [];
const api = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
// Sampled while this stream's own request-body copies are still
// live and at least one sibling stream may also be mid-flight: the
// real, concurrent, multi-stream shape this test exists to prove.
samples.push(getBridgeBodyReservedBytesForTest());
res.writeHead(200, { "content-type": "application/octet-stream" });
res.end(Buffer.alloc(bodyBytes, "b"));
});
});
await new Promise<void>((resolve, reject) => {
api.once("error", reject);
api.listen(0, "127.0.0.1", () => resolve());
});
const apiAddress = api.address();
if (!apiAddress || typeof apiAddress === "string") {
throw new Error("Expected the mock host server to listen on a TCP port.");
}
const apiOrigin = `http://127.0.0.1:${apiAddress.port}`;
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-ceiling-aggregate-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
await mkdir(remoteCwd, { recursive: true });
const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null };
let bridgeToken = "";
const { runner } = makeHttp2SelectionRunner((ctx) => {
bridgeToken = ctx.bridgeToken;
ctx.emitReady();
sessionRef.current = ctx.connectHttp2();
});
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
remoteCwd,
timeoutMs: 30_000,
runner,
effectiveCapabilities: duplexCapabilities(true),
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-ceiling-aggregate",
target,
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: apiOrigin,
enableSandboxDuplexBridge: true,
});
try {
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1");
await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000);
const responses = await Promise.all(
Array.from({ length: HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS }, () =>
http2TestRequest(sessionRef.current!, {
method: "POST",
path: "/api/issues/abc/comments",
headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/octet-stream" },
body: requestBody,
}),
),
);
for (const response of responses) {
expect(response.status).toBe(200);
expect(response.body.byteLength).toBe(bodyBytes);
}
expect(samples).toHaveLength(HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS);
for (const sample of samples) {
expect(sample).toBeGreaterThan(0);
expect(sample).toBeLessThanOrEqual(HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES);
}
// Every stream's owner released once its forward settled.
await waitForCondition(
() => getBridgeBodyReservedBytesForTest() === 0,
"the process reservation total to return to zero",
2_000,
);
} finally {
sessionRef.current?.close();
await bridge?.stop();
await new Promise<void>((resolve) => api.close(() => resolve()));
}
}, 20000);
it("test_the_host_denies_a_body_over_the_resolved_limit_not_only_the_gateway", async () => {
const api = await startRecordingApiServer();
const sessionRef: { current: http2.ClientHttp2Session | null } = { current: null };
let bridgeToken = "";
const { runner } = makeHttp2SelectionRunner((ctx) => {
bridgeToken = ctx.bridgeToken;
ctx.emitReady();
sessionRef.current = ctx.connectHttp2();
});
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-http2-host-body-limit-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
await mkdir(remoteCwd, { recursive: true });
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
remoteCwd,
timeoutMs: 30_000,
runner,
effectiveCapabilities: duplexCapabilities(true),
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-host-body-limit",
target,
runtimeRootDir: path.join(remoteCwd, ".paperclip-runtime", "codex"),
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: api.origin,
enableSandboxDuplexBridge: true,
// A ceiling far under the body this test sends, resolved for this
// run. `createHttp2BridgeServer()` must receive this same resolved
// value, so the host itself enforces it on the raw wire — this test
// talks to the host directly, with no sandbox-side gateway script in
// between to enforce anything on its own.
maxBodyBytes: 100,
});
try {
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("http2_v1");
await waitForCondition(() => sessionRef.current !== null, "the http2 client session to open", 4000);
const response = await http2TestRequest(sessionRef.current!, {
method: "POST",
path: "/api/issues/abc/comments",
headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" },
body: Buffer.alloc(1_000, "a"),
});
// The oversized body never reaches the host API: the resolved ceiling
// rejects it before any forward call runs, so the mock host records
// no request. (The host's own size-violation path resets the stream
// before it can write a status line, so the client sees no ordinary
// response — this test asserts the one thing that channel does prove:
// the forward call itself never ran.)
expect(api.requests).toHaveLength(0);
expect(response.status).not.toBe(200);
} finally {
sessionRef.current?.close();
await bridge?.stop();
await api.close();
}
}, 20000);
it("test_the_queue_transport_still_forwards_with_no_reservation_owner", async () => {
// The queue transport's `handleRequest` callback
// (`execution-target.ts`'s queue callback) calls `forwardBridgeRequest`
// with no `reservation` option at all. `readBridgeForwardResponseBody`
// must behave exactly as it did before that option existed: it still
// forwards the request and still returns the host's body.
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-queue-no-reservation-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex");
await mkdir(runtimeRootDir, { recursive: true });
const apiServer = createServer((req, res) => {
res.writeHead(201, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, echoedMethod: req.method }));
});
await new Promise<void>((resolve, reject) => {
apiServer.once("error", reject);
apiServer.listen(0, "127.0.0.1", () => resolve());
});
const address = apiServer.address();
if (!address || typeof address === "string") {
throw new Error("Expected the queue-transport test API server to listen on a TCP port.");
}
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "e2b",
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd,
runner: createLocalSandboxRunner(),
timeoutMs: 30_000,
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-queue-no-reservation",
target,
runtimeRootDir,
adapterKey: "codex",
hostApiToken: "real-run-jwt",
hostApiUrl: `http://127.0.0.1:${address.port}`,
});
try {
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
const response = await fetch(`${bridge!.env.PAPERCLIP_API_URL}/api/issues/abc/comments`, {
method: "POST",
headers: {
authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({ body: "hello" }),
});
expect(response.status).toBe(201);
expect(await response.json()).toEqual({ ok: true, echoedMethod: "POST" });
} finally {
await bridge?.stop();
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
}
});
// ---------------------------------------------------------------------------
// Real-PTY replay.
//

View File

@ -35,6 +35,7 @@ import {
createSandboxCallbackBridgeAsset,
createSandboxCallbackBridgeToken,
DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES,
HTTP2_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST,
SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT,
SANDBOX_CALLBACK_BRIDGE_HTTP2_MODE,
sandboxCallbackBridgeDirectories,
@ -45,6 +46,8 @@ import {
} from "./sandbox-callback-bridge.js";
import {
createHttp2BridgeServer,
BridgeProcessCapacityError,
type BridgeBodyReservation,
type Http2BridgeForwardHandler,
} from "./http2-bridge-server.js";
import {
@ -87,12 +90,18 @@ import type { RunnerIngressEndpoint } from "./runner-connectivity.js";
export type { RuntimeProgressSink } from "./runtime-progress.js";
export function postedIssueCommentLogMarker(method: string, requestPath: string, status: number, body: string) {
export function postedIssueCommentLogMarker(
method: string,
requestPath: string,
status: number,
body: Buffer | string,
) {
if (method !== "POST" || !/^\/api\/issues\/[^/]+\/comments$/.test(requestPath) || status < 200 || status >= 300) {
return null;
}
const bodyText = typeof body === "string" ? body : body.toString("utf8");
try {
const parsed = JSON.parse(body) as { id?: unknown };
const parsed = JSON.parse(bodyText) as { id?: unknown };
return typeof parsed.id === "string" && parsed.id.length > 0 ? `comment id: ${parsed.id}\n` : null;
} catch {
return null;
@ -646,12 +655,19 @@ function preferredSandboxShell(target: AdapterSandboxExecutionTarget): "bash" |
type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterSandboxExecutionTarget;
// The Secure Shell command runner's own output buffer. This value used to
// derive from the bridge body limit (`DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES
// * 4`), so a bridge limit rise silently grew it too. It now stands on its
// own local constant, independent of the bridge body limit, so a later
// bridge limit change never resizes this buffer as a side effect.
const SSH_COMMAND_MAX_BUFFER_BYTES = 1024 * 1024;
function adapterExecutionTargetCommandRunner(target: AdapterCommandCapableExecutionTarget): CommandManagedRuntimeRunner {
if (target.transport === "ssh") {
return createSshCommandManagedRuntimeRunner({
spec: target.spec,
defaultCwd: target.remoteCwd,
maxBufferBytes: DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES * 4,
maxBufferBytes: SSH_COMMAND_MAX_BUFFER_BYTES,
});
}
return requireSandboxRunner(target);
@ -1617,15 +1633,30 @@ function bridgeResponseBodyLimitError(maxBodyBytes: number): Error {
}
/**
* Read the forward response body into a string. The per-request `maxBodyBytes`
* limit rejects a body larger than the configured per-request ceiling.
* Read the forward response body into a `Buffer`, with no text decoding. The
* per-request `maxBodyBytes` limit rejects a body larger than the configured
* per-request ceiling.
*
* This function reserves no process-wide byte budget: it enforces only the
* one request's own ceiling. See the "Known behavior: aggregate retained
* body bytes" section in `doc/observability.md` for the accepted aggregate
* ceiling this leaves across every concurrent route.
* When the caller passes a `reservation`, this reserves each chunk's bytes
* against it immediately after `reader.read()` yields the chunk, and before
* `Buffer.from(value)` copies it the allocation happens inside that
* expression, so reserving only before the later `chunks.push` would let the
* copy happen first. It also reserves the concatenated buffer's own byte
* count before `Buffer.concat` allocates it: the chunk array and the
* concatenated buffer are two separate live copies. A denied reservation
* cancels the reader and throws {@link BridgeProcessCapacityError}, copying
* no further chunk. This function never releases the reservation; the
* stream owner that created it does, once the whole forward call settles.
*
* With no `reservation`, this function enforces only the one request's own
* ceiling, exactly as it did before this parameter existed the queue
* transport calls it with no reservation, and its behavior must not change.
*/
async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: number): Promise<string> {
async function readBridgeForwardResponseBody(
response: Response,
maxBodyBytes: number,
reservation?: BridgeBodyReservation,
): Promise<Buffer> {
const rawContentLength = response.headers.get("content-length");
if (rawContentLength) {
const contentLength = Number.parseInt(rawContentLength, 10);
@ -1635,7 +1666,7 @@ async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: n
}
if (!response.body) {
return "";
return Buffer.alloc(0);
}
const reader = response.body.getReader();
@ -1651,9 +1682,17 @@ async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: n
await reader.cancel().catch(() => undefined);
throw bridgeResponseBodyLimitError(maxBodyBytes);
}
if (reservation && !reservation.reserve(chunkBytes)) {
await reader.cancel().catch(() => undefined);
throw new BridgeProcessCapacityError();
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, totalBytes).toString("utf8");
if (reservation && !reservation.reserve(totalBytes)) {
await reader.cancel().catch(() => undefined);
throw new BridgeProcessCapacityError();
}
return Buffer.concat(chunks, totalBytes);
}
const PROCESS_SESSION_PROXY_SCRIPT = "paperclip-process-session-proxy.mjs";
@ -4099,14 +4138,24 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
path: string;
query: string;
headers: Record<string, string>;
/** The file bridge passes the whole request body here as one string. */
body?: string;
/** The file bridge passes the whole request body here as one string.
* The HTTP/2 bridge passes it as the raw `Buffer` it read off the wire. */
body?: string | Buffer;
},
signal?: AbortSignal,
options?: {
suppressDebugLog?: boolean;
/**
* The caller's stream reservation owner, if it has one. The HTTP/2
* bridge passes the stream's own owner here, so the response body copy
* reserves against the same ceiling the request body copy already
* reserved against. The queue transport passes no owner, so its
* response-body read enforces only the per-request size ceiling, exactly
* as it did before this option existed.
*/
reservation?: BridgeBodyReservation;
},
): Promise<{ status: number; headers: Record<string, string>; body: string }> => {
): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> => {
const method = request.method.trim().toUpperCase() || "GET";
// The per-request debug log prints the method, the path, and the query. The
// duplex path suppresses it, so no route or query rides a log line there. The
@ -4131,14 +4180,19 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
const timeoutSignal = AbortSignal.timeout(forwardTimeoutMs);
const forwardSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
// Build the request-body init. A GET or a HEAD carries no body. The file
// bridge passes the whole body as one string.
// bridge passes the whole body as one string; the HTTP/2 bridge passes it
// as a raw `Buffer`. Undici accepts a `Buffer` request body directly (a
// `Buffer` is an `ArrayBufferView`), so neither shape needs a conversion.
// The cast below only bridges a `BodyInit` typing gap: the DOM library
// type this project's ambient `RequestInit` resolves to excludes a
// `Buffer`, though Undici accepts one at runtime.
const forwardInit: RequestInit = {
method,
headers,
signal: forwardSignal,
};
if (method !== "GET" && method !== "HEAD" && typeof request.body === "string") {
forwardInit.body = request.body;
if (method !== "GET" && method !== "HEAD" && request.body !== undefined) {
forwardInit.body = request.body as BodyInit;
}
const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), forwardInit);
if (emitDebugLog) {
@ -4159,10 +4213,23 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
// non-retryable 504 and marks the outcome indeterminate, exactly like an
// aborted in-flight forward. The in-sandbox server maps the indeterminate 504
// to a non-retryable 409 for both the file bridge and the duplex broker.
let responseBody: string;
let responseBody: Buffer;
try {
responseBody = await readBridgeForwardResponseBody(response, maxBodyBytes);
responseBody = await readBridgeForwardResponseBody(response, maxBodyBytes, options?.reservation);
} catch (error) {
// A denied reservation is retryable capacity pressure for a safe
// method, not a body-read fault: rethrow it before the method-safety
// classification below runs, so it reaches the HTTP/2 bridge's own
// capacity-denial catch (which answers the retryable 503 and settles
// the stream) instead of this function turning it into a 502. For an
// unsafe (mutating) method, the host has already delivered response
// headers by this point, so it may already have committed the
// mutation. Rethrowing there too would let the retryable 503 reach a
// caller that repeats the request, applying the mutation twice. An
// unsafe method's capacity denial falls through to the same
// non-retryable indeterminate 504 any other response-body read fault
// gets below.
if (error instanceof BridgeProcessCapacityError && isSafeBridgeMethod(method)) throw error;
if (isSafeBridgeMethod(method)) {
// The method is safe, so a retry cannot double-apply a mutation. Return a
// retryable 502 with no indeterminate marker, so the gateway passes it
@ -4170,9 +4237,12 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
return {
status: 502,
headers: { "content-type": "application/json" },
body: JSON.stringify({
error: error instanceof Error ? error.message : String(error),
}),
body: Buffer.from(
JSON.stringify({
error: error instanceof Error ? error.message : String(error),
}),
"utf8",
),
};
}
return {
@ -4181,11 +4251,14 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
"content-type": "application/json",
"x-paperclip-bridge-outcome": "indeterminate",
},
body: JSON.stringify({
error: error instanceof Error ? error.message : String(error),
outcome: "indeterminate",
retryable: false,
}),
body: Buffer.from(
JSON.stringify({
error: error instanceof Error ? error.message : String(error),
outcome: "indeterminate",
retryable: false,
}),
"utf8",
),
};
}
const commentMarker = postedIssueCommentLogMarker(method, request.path, response.status, responseBody);
@ -4387,10 +4460,10 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
path: request.pathname,
query: request.query,
headers: request.headers,
body: request.body.toString("utf8"),
body: request.body,
},
request.signal,
{ suppressDebugLog: true },
{ suppressDebugLog: true, reservation: request.reservation },
);
duplexObservability.recordRequest({ latencyMs: Date.now() - dispatchStartMs, outcome: "ok" });
return { status: result.status, headers: result.headers, body: result.body };
@ -4403,6 +4476,13 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
const http2Server = createHttp2BridgeServer({
bridgeToken,
forwardRequest: http2ForwardRequest,
routes: HTTP2_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST,
// The same resolved limit the launch environment hands the
// sandbox-side gateway (`PAPERCLIP_BRIDGE_MAX_BODY_BYTES`,
// below), so the host check and the gateway check enforce one
// value instead of the host silently falling back to the
// package default.
maxBodyBytes,
onGoaway: () => recordHttp2Loss("session_goaway"),
onSessionError: () => recordHttp2Loss("session_error"),
});
@ -4510,7 +4590,13 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
maxBodyBytes,
getRuntimeParentContext: input.getRuntimeParentContext,
runtimeSpan: input.runtimeSpan,
handleRequest: async (request, options) => forwardBridgeRequest(request, options?.signal),
// The queue transport writes the response body to a text file, so this
// is the one place the forward path decodes the response `Buffer` to a
// UTF-8 string. The queue's own on-wire behavior does not change.
handleRequest: async (request, options) => {
const result = await forwardBridgeRequest(request, options?.signal);
return { status: result.status, headers: result.headers, body: result.body.toString("utf8") };
},
});
server = await startSandboxCallbackBridgeServer({
runner,

File diff suppressed because it is too large Load Diff

View File

@ -52,23 +52,32 @@ export const HTTP2_BRIDGE_ENABLE_PUSH = false;
* Open streams. The host keeps one forward, its request body, and its
* response body alive for the life of a stream, and before this file binds
* each forward to its own stream's abort signal a forward can outlive its
* stream's own HTTP/2 slot until the forward's own timeout runs out. Counting
* every retained `Buffer` and string copy of one stream's request and
* response body against the {@link DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES}
* body limit (`sandbox-callback-bridge.ts`) gives an accounting peak of eight
* times that limit for one live forward. This bound is the per-route
* in-flight-body budget: `HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS * 8 *
* DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES` bytes = 4 * 8 * 262,144
* bytes = 8,388,608 bytes for one route.
* stream's own HTTP/2 slot until the forward's own timeout runs out. The
* forward path carries a request body and a response body as raw `Buffer`
* values with no string copy. Counting every retained `Buffer` copy of one
* stream's request and response body against the
* {@link DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES} body limit
* (`sandbox-callback-bridge.ts`) gives an accounting peak of four times that
* limit for one live forward. This bound is the per-route in-flight-body
* budget: `HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS * 4 *
* DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES` bytes = 4 * 4 * 10,551,296
* bytes = 168,820,736 bytes (161 MiB) for one route. {@link
* HTTP2_BRIDGE_MAX_ROUTE_BODY_BYTES} enforces this figure as a real, live
* cap on every reservation, so one busy route cannot pass it, no matter how
* much of the process-wide ceiling below still sits free.
*
* Known aggregate behavior: this budget applies to one route only. The host
* process admits up to `DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES` (128, in
* `plugin-worker-manager.ts`) routes at the same time, and each route holds
* its own 8,388,608-byte peak. The process can therefore retain up to
* 1,073,741,824 bytes (1 GiB) of live body data across every route at once.
* This document accepts that ceiling: the host tracks no process-wide byte
* total, so no single route can starve another route's own budget, but the
* host also enforces no smaller sum across every route.
* Aggregate behavior: the host process admits up to
* `DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES` (128, in `plugin-worker-manager.ts`)
* routes at the same time. The aggregate across every route is bounded too:
* every stream's {@link BridgeBodyReservation} owner also reserves against
* the shared {@link HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES} total
* (1,073,741,824 bytes, 1 GiB), so the process retains no more than that
* many live body bytes no matter how many routes or streams run at once. One
* full-size stream's four retained copies cost `4 * 10,551,296` =
* 42,205,184 bytes of that total, so the process admits at least 25
* concurrent full-size streams, spread across at least six routes each at
* their own per-route ceiling, before it starts denying the rest with a 503
* response.
*/
export const HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS = 4;
/** One decompressed header list. The Node default is 65535. */
@ -112,6 +121,176 @@ export const HTTP2_BRIDGE_SERVER_OPTIONS: http2.ServerOptions = {
streamResetBurst: HTTP2_BRIDGE_STREAM_RESET_BURST,
};
// ---------------------------------------------------------------------------
// Process-wide body byte reservation
// ---------------------------------------------------------------------------
/**
* The most process memory, in bytes, this file lets every route hold in live
* request and response body buffers at the same time. Every
* {@link BridgeBodyReservation} owner reserves against this one shared
* total, so no combination of concurrent streams, across every route, can
* retain more than this many bytes at once. This value keeps the accepted
* process ceiling at 1,073,741,824 bytes (1 GiB) the same ceiling
* `doc/observability.md` already accepted before the per-body limit rose to
* 10 MiB now enforced by this reservation instead of left as an unenforced
* document note. See the per-route budget comment above for the full
* accounting.
*/
export const HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES = 1024 * 1024 * 1024;
/**
* The most memory, in bytes, one route (one {@link createHttp2BridgeServer}
* call, one sandbox run's bridge session) may hold in live request and
* response body buffers at the same time, on top of the shared process-wide
* ceiling above. This is the same per-route figure the budget comment above
* already derives from stream concurrency: naming it here and checking it on
* every reservation stops one busy route from spending the whole
* process-wide ceiling and denying every sibling route admission. See that
* comment for the full accounting.
*/
export const HTTP2_BRIDGE_MAX_ROUTE_BODY_BYTES =
HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS * 4 * DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES;
// The process-wide running total, in bytes, every `BridgeBodyReservation`
// owner reserves against. Module-scope state is correct here: one host
// process runs one bridge, and every route and every stream in that process
// must share the same ceiling.
let reservedProcessBodyBytes = 0;
/**
* One route's own running total, in bytes, against
* {@link HTTP2_BRIDGE_MAX_ROUTE_BODY_BYTES}. `createHttp2BridgeServer`
* creates exactly one ledger per route and every stream that route ever
* handles reserves against it, so one route's own activity can never pass
* its own ceiling, regardless of how much of the process-wide total remains
* free for other routes.
*/
export interface BridgeRouteBodyLedger {
/**
* Reserve `byteCount` more bytes against this route's own ceiling. Returns
* `false`, and reserves nothing, when the new route total would pass
* {@link HTTP2_BRIDGE_MAX_ROUTE_BODY_BYTES}.
*/
reserve(byteCount: number): boolean;
/** Release `byteCount` bytes this route previously reserved. */
release(byteCount: number): void;
/** The bytes this route currently holds. */
readonly reservedBytes: number;
}
/** Create one fresh {@link BridgeRouteBodyLedger}, holding zero bytes. One
* `createHttp2BridgeServer` call creates exactly one, before its first
* stream, and every stream that route ever handles shares it. */
export function createBridgeRouteBodyLedger(): BridgeRouteBodyLedger {
let reservedBytes = 0;
return {
reserve(byteCount: number): boolean {
if (reservedBytes + byteCount > HTTP2_BRIDGE_MAX_ROUTE_BODY_BYTES) {
return false;
}
reservedBytes += byteCount;
return true;
},
release(byteCount: number): void {
reservedBytes -= byteCount;
},
get reservedBytes(): number {
return reservedBytes;
},
};
}
/**
* One HTTP/2 stream's reservation owner. `handleStream` creates exactly one
* owner per stream and releases it in its existing `finally` block, so every
* live request or response body buffer that stream produces reserves
* against the same owner, and the process reclaims those bytes exactly one
* time when the stream ends.
*/
export interface BridgeBodyReservation {
/**
* Reserve `byteCount` more bytes against the process-wide total, and
* against this owner's route ledger when it has one. Returns `false` and
* reserves nothing against either total when either check fails. A failed
* reservation allocates nothing: the caller must not copy the bytes it
* asked to reserve.
*/
reserve(byteCount: number): boolean;
/**
* Release every byte this owner currently holds. Safe to call more than
* one time: a second call releases nothing.
*/
release(): void;
/** The bytes this owner currently holds. */
readonly heldBytes: number;
}
/**
* Create one fresh {@link BridgeBodyReservation} owner, holding zero bytes.
* A caller that passes `routeLedger` also checks and reserves against that
* route's own ceiling on every call, isolating this owner's route from every
* other route sharing the process-wide total. A caller with no route to
* isolate (a test filling only the process-wide total, for example) omits
* it, and this owner checks the process-wide ceiling alone.
*/
export function createBridgeBodyReservation(routeLedger?: BridgeRouteBodyLedger): BridgeBodyReservation {
let heldBytes = 0;
let released = false;
return {
reserve(byteCount: number): boolean {
if (released) return false;
if (reservedProcessBodyBytes + byteCount > HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES) {
return false;
}
if (routeLedger && !routeLedger.reserve(byteCount)) {
return false;
}
reservedProcessBodyBytes += byteCount;
heldBytes += byteCount;
return true;
},
release(): void {
if (released) return;
released = true;
reservedProcessBodyBytes -= heldBytes;
routeLedger?.release(heldBytes);
heldBytes = 0;
},
get heldBytes(): number {
return heldBytes;
},
};
}
/**
* A reservation owner denied a request or response body copy because the
* process-wide ceiling would otherwise be passed. The stream handler answers
* 503 for this error, not 413: a 413 tells a caller its own body is too
* large; a 503 tells a caller the host is busy and to retry later.
*/
export class BridgeProcessCapacityError extends Error {
constructor() {
super("The bridge host reached its reserved process body byte ceiling. Retry later.");
this.name = "BridgeProcessCapacityError";
}
}
/**
* Test-only. Reset the process-wide reservation total to zero. A test file
* that exercises {@link createBridgeBodyReservation} must call this between
* tests, so a reservation one test left unreleased cannot lower the ceiling
* for a later test.
*/
export function resetBridgeBodyReservationsForTest(): void {
reservedProcessBodyBytes = 0;
}
/** Test-only. Read the current process-wide reservation total. */
export function getBridgeBodyReservedBytesForTest(): number {
return reservedProcessBodyBytes;
}
// ---------------------------------------------------------------------------
// Duplex channel adapter
// ---------------------------------------------------------------------------
@ -463,6 +642,24 @@ export const DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_LIFETIME_CEILING_MS = 480_000;
* session that carries a stalled stream would otherwise hold `close()` open
* forever, because `session.close()` waits for every open stream to end. */
export const DEFAULT_HTTP2_BRIDGE_CLOSE_GRACE_MS = 5_000;
/** The default bound the capacity-denial (503) response path waits for its
* queued write to settle before it force-destroys the stream. A normal,
* draining peer settles well inside this bound, so it still receives the
* full 503 body. A stalled peer that never grants the flow-control credit
* the write needs would otherwise hold this stream's reservation and
* {@link HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS} slot open forever. */
export const DEFAULT_HTTP2_BRIDGE_CAPACITY_DENIAL_SETTLE_DEADLINE_MS = 5_000;
/** The default bound the completed-response (normal, non-denial) write path
* waits for its queued write to settle before it force-destroys the stream.
* A normal, draining peer settles well inside this bound. A stalled peer
* that grants no flow-control credit would otherwise hold this stream's
* reservation and {@link HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS} slot open
* forever the same failure mode the capacity-denial path already guards
* against. Set above {@link DEFAULT_HTTP2_BRIDGE_CAPACITY_DENIAL_SETTLE_DEADLINE_MS}
* because a completed response can carry a full-size body (up to the
* configured body-byte ceiling), not just a small JSON error payload, so a
* slow-but-genuine peer needs more room to drain it. */
export const DEFAULT_HTTP2_BRIDGE_RESPONSE_WRITE_SETTLE_DEADLINE_MS = 30_000;
function startHttp2BridgePingWatchdog(
session: http2.ServerHttp2Session,
@ -517,7 +714,7 @@ function startHttp2BridgePingWatchdog(
export interface Http2BridgeForwardResult {
status: number;
headers?: Record<string, string>;
body?: Buffer | string;
body?: Buffer;
}
/**
@ -538,6 +735,16 @@ export interface Http2BridgeForwardRequest {
* never fires for any other stream or for the session.
*/
signal: AbortSignal;
/**
* This stream's one {@link BridgeBodyReservation} owner. A forward handler
* that itself retains a full response body buffer `execution-target.ts`
* does, through `forwardBridgeRequest`'s optional `reservation` option
* reserves against this same owner, so the request body and the response
* body of one stream share one ceiling. `handleStream` releases this owner
* exactly one time, after the forward call settles; the forward handler
* must never release it.
*/
reservation: BridgeBodyReservation;
}
export type Http2BridgeForwardHandler = (
@ -587,6 +794,14 @@ export interface CreateHttp2BridgeServerOptions {
* session to close on its own before it force-destroys the session. The
* default is {@link DEFAULT_HTTP2_BRIDGE_CLOSE_GRACE_MS}. */
closeGraceMs?: number;
/** The bound the capacity-denial (503) response path waits for its queued
* write to settle before it force-destroys the stream. The default is
* {@link DEFAULT_HTTP2_BRIDGE_CAPACITY_DENIAL_SETTLE_DEADLINE_MS}. */
capacityDenialSettleDeadlineMs?: number;
/** The bound the completed-response (normal) write path waits for its
* queued write to settle before it force-destroys the stalled stream. The
* default is {@link DEFAULT_HTTP2_BRIDGE_RESPONSE_WRITE_SETTLE_DEADLINE_MS}. */
responseWriteSettleDeadlineMs?: number;
/** The cap, in bytes, on data this server holds once a bound `Duplex`
* reports its readable side is full (`push()` returns `false`). Past this
* cap the server treats the channel as stuck, not merely slow: see
@ -667,13 +882,21 @@ export interface Http2BridgeBodyBounds {
* stream ends for any reason at all a normal end, an error, a timeout- or
* shutdown-triggered `destroy()`, or a peer reset so the promise always
* settles and the caller never awaits a stream that already went away.
*
* `onChunk` and `onEnd` decide what the read retains, if anything:
* {@link readHttp2StreamBody} accumulates chunks and reserves against a
* {@link BridgeBodyReservation}; {@link drainHttp2StreamBody} discards every
* chunk and reserves nothing. Either callback may throw to reject the read
* (a denied reservation, for example) the throw destroys the stream the
* same way a size, idle, or lifetime fault does.
*/
function readHttp2StreamBody(
function readOrDrainHttp2StreamBody<T>(
stream: http2.ServerHttp2Stream,
bounds: Http2BridgeBodyBounds,
): Promise<Buffer> {
onChunk: (chunk: Buffer, totalBytes: number) => void,
onEnd: () => T,
): Promise<T> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let totalBytes = 0;
let settled = false;
let idleTimer: ReturnType<typeof setTimeout>;
@ -709,18 +932,153 @@ function readHttp2StreamBody(
stream.destroy();
return;
}
chunks.push(chunk);
try {
onChunk(chunk, totalBytes);
} catch (error) {
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
// A denied reservation answers 503 through the caller's own
// `respondJson` call, after this promise rejects — destroying the
// stream here, before that call runs, would make it a no-op (a
// destroyed stream refuses `.respond()`). The caller destroys the
// stream itself, once its response is actually on the wire. Every
// other body-read fault has no such response to protect, so it
// destroys the stream immediately, exactly as before.
if (!(error instanceof BridgeProcessCapacityError)) {
stream.destroy();
}
return;
}
// The chunk is real progress, so the peer is not stalled: reset the
// idle bound. The lifetime ceiling timer above does not reset here.
armIdleTimer();
});
stream.once("end", () => settle(() => resolve(Buffer.concat(chunks))));
stream.once("end", () => {
let result: T;
try {
result = onEnd();
} catch (error) {
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
return;
}
settle(() => resolve(result));
});
stream.once("error", (error) => settle(() => reject(error instanceof Error ? error : new Error(String(error)))));
stream.once("aborted", () => settle(() => reject(new Error("Bridge request stream aborted."))));
stream.once("close", () => settle(() => reject(new Error("Bridge request stream closed before it completed."))));
});
}
/**
* Read one request body into a `Buffer`. When the caller passes a
* `reservation`, this reserves each chunk's bytes against it before the
* chunk joins the retained array, and reserves the concatenated buffer's own
* byte count before `Buffer.concat` allocates it the chunk array and the
* concatenated buffer are two separate live copies, so both reserve. A
* denied reservation rejects with {@link BridgeProcessCapacityError} and
* destroys the stream, retaining no further chunk.
*/
function readHttp2StreamBody(
stream: http2.ServerHttp2Stream,
bounds: Http2BridgeBodyBounds,
reservation?: BridgeBodyReservation,
): Promise<Buffer> {
const chunks: Buffer[] = [];
let retainedBytes = 0;
return readOrDrainHttp2StreamBody(
stream,
bounds,
(chunk, totalBytes) => {
if (reservation && !reservation.reserve(chunk.byteLength)) {
throw new BridgeProcessCapacityError();
}
chunks.push(chunk);
retainedBytes = totalBytes;
},
() => {
if (reservation && !reservation.reserve(retainedBytes)) {
throw new BridgeProcessCapacityError();
}
return Buffer.concat(chunks);
},
);
}
/**
* Drain and discard one denied stream's request body, under the same size,
* idle, and lifetime bounds an authenticated request gets, but retaining no
* chunk and reserving no bytes. `denyRequest` calls this instead of
* {@link readHttp2StreamBody}, so a stream that never carries a valid bridge
* token cannot retain a full body buffer merely by sending one.
*/
function drainHttp2StreamBody(stream: http2.ServerHttp2Stream, bounds: Http2BridgeBodyBounds): Promise<void> {
return readOrDrainHttp2StreamBody(
stream,
bounds,
() => {
// No `chunks.push`: a denied stream's body content never reaches the
// forward handler, so this reader retains no chunk and reserves
// nothing against the process ceiling.
},
() => undefined,
);
}
/**
* Wait until a stream's queued write actually leaves process memory, or
* until the stream closes for any other reason. `stream.end(body)` only
* queues `body` for asynchronous transmission: Node keeps the bytes in
* memory until HTTP/2 flow control lets them flow, which a slow or
* backpressured peer can delay well past the moment `end()` returns. A
* caller that reserves the response body's bytes must hold that reservation
* across this whole wait, not merely across the call to `end()`, or a
* backpressured response keeps bytes in memory the ledger already believes
* it reclaimed.
*
* `finish` is the normal settle: every queued byte reached the session. A
* `close` or an `error` settle the wait the same way, so a peer reset or a
* destroyed stream cannot leave a caller waiting forever for a `finish`
* that will never come.
*
* `deadlineMs`, when given, bounds the wait itself: past that many
* milliseconds with none of the three events above, this resolves anyway.
* A peer that neither drains the write nor resets nor errors the stream
* one that simply stalls, granting no flow-control credit would otherwise
* hold the wait open forever with none of the three settle events ever
* firing. The caller stays responsible for destroying the stream once this
* resolves; a bounded resolve here does not by itself free the stream's
* slot or its reservation.
*
* The returned `settled` flag tells the caller which way this resolved:
* `true` for a genuine `finish`/`close`/`error` event, `false` for the
* deadline. A caller that must free the stream's slot only when the peer
* truly stalled reads this flag instead of destroying an already-finished
* stream unconditionally.
*/
function waitForHttp2StreamWriteToSettle(
stream: http2.ServerHttp2Stream,
deadlineMs?: number,
): Promise<{ settled: boolean }> {
if (stream.writableFinished || stream.destroyed || stream.closed) return Promise.resolve({ settled: true });
return new Promise((resolve) => {
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
const onSettle = (settled: boolean): void => {
stream.removeListener("finish", onFinish);
stream.removeListener("close", onFinish);
stream.removeListener("error", onFinish);
if (deadlineTimer) clearTimeout(deadlineTimer);
resolve({ settled });
};
const onFinish = (): void => onSettle(true);
stream.once("finish", onFinish);
stream.once("close", onFinish);
stream.once("error", onFinish);
if (deadlineMs !== undefined) {
deadlineTimer = setTimeout(() => onSettle(false), deadlineMs);
deadlineTimer.unref?.();
}
});
}
function respondJson(stream: http2.ServerHttp2Stream, status: number, body: unknown): void {
if (stream.destroyed || stream.closed) return;
try {
@ -741,7 +1099,10 @@ function respondJson(stream: http2.ServerHttp2Stream, status: number, body: unkn
* peer that leaves the body unfinished keeps the stream open, holding one
* of the {@link HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS} slots for as long as
* it chooses. Nothing awaits the discard; the caller has already answered
* the request and moves on to the next stream.
* the request and moves on to the next stream. The discard retains no
* chunk and reserves no process byte budget: a stream that fails the bridge
* token check gets no reservation, so it cannot retain a full body buffer
* merely by sending one.
*/
function denyRequest(
stream: http2.ServerHttp2Stream,
@ -751,7 +1112,7 @@ function denyRequest(
): void {
respondJson(stream, status, body);
if (stream.destroyed || stream.closed) return;
readHttp2StreamBody(stream, bounds).catch(() => {
drainHttp2StreamBody(stream, bounds).catch(() => {
// The idle or lifetime bound above already destroyed the stream, or the
// peer reset it first. Either way the slot is free; the discarded body
// content is irrelevant to a denial.
@ -773,6 +1134,10 @@ export function createHttp2BridgeServer(options: CreateHttp2BridgeServerOptions)
const requestBodyLifetimeCeilingMs =
options.requestBodyLifetimeCeilingMs ?? DEFAULT_HTTP2_BRIDGE_REQUEST_BODY_LIFETIME_CEILING_MS;
const closeGraceMs = options.closeGraceMs ?? DEFAULT_HTTP2_BRIDGE_CLOSE_GRACE_MS;
const capacityDenialSettleDeadlineMs =
options.capacityDenialSettleDeadlineMs ?? DEFAULT_HTTP2_BRIDGE_CAPACITY_DENIAL_SETTLE_DEADLINE_MS;
const responseWriteSettleDeadlineMs =
options.responseWriteSettleDeadlineMs ?? DEFAULT_HTTP2_BRIDGE_RESPONSE_WRITE_SETTLE_DEADLINE_MS;
const maxBufferedReadBytes = options.maxBufferedReadBytes ?? DEFAULT_HTTP2_BRIDGE_MAX_BUFFERED_READ_BYTES;
const readBackpressureStallMs =
options.readBackpressureStallMs ?? DEFAULT_HTTP2_BRIDGE_READ_BACKPRESSURE_STALL_MS;
@ -787,6 +1152,11 @@ export function createHttp2BridgeServer(options: CreateHttp2BridgeServerOptions)
const server = http2.createServer(HTTP2_BRIDGE_SERVER_OPTIONS);
const activeSessions = new Set<http2.ServerHttp2Session>();
// One route ledger for this one `createHttp2BridgeServer` call. Every
// stream this route ever handles reserves against it, so this route's own
// activity can never pass its own share of the process-wide ceiling and
// deny a sibling route admission.
const routeLedger = createBridgeRouteBodyLedger();
async function handleStream(
stream: http2.ServerHttp2Stream,
@ -804,6 +1174,17 @@ export function createHttp2BridgeServer(options: CreateHttp2BridgeServerOptions)
stream.once("close", abortForThisStream);
stream.once("aborted", abortForThisStream);
stream.once("error", abortForThisStream);
// One reservation owner for this one stream's entire life. Every live
// request or response body buffer this stream produces reserves against
// this same owner; the `finally` block below releases it exactly one
// time, on every exit path (a deny, a body-read fault, a forward fault,
// or a completed response), including a peer reset or a timeout, both of
// which route through the abort listeners above into the forward call's
// combined signal, so the owner keeps its bytes reserved until that
// forward call actually settles. It reserves against this route's own
// ledger too, so it can never spend more than this route's own share of
// the process-wide ceiling.
const reservation = createBridgeBodyReservation(routeLedger);
try {
// Accepted security fix 4: the constant-time bridge-token compare runs
// before route processing and before header processing. This host check
@ -838,8 +1219,24 @@ export function createHttp2BridgeServer(options: CreateHttp2BridgeServerOptions)
let body: Buffer;
try {
body = await readHttp2StreamBody(stream, bodyBounds);
body = await readHttp2StreamBody(stream, bodyBounds, reservation);
} catch (error) {
if (error instanceof BridgeProcessCapacityError) {
// The reader above left this stream open (undestroyed) exactly so
// this response could reach the wire. `respondJson` only queues
// the 503 write; destroying the stream right after queues a
// RST_STREAM before a backpressured peer drains it, so the client
// can see a reset instead of the 503. Wait for the write to
// settle first, then destroy to free this stream's
// concurrent-stream slot. The wait carries its own deadline: a
// stalled peer that grants no flow-control credit would otherwise
// never settle the write, holding this stream's reservation and
// slot open forever.
respondJson(stream, 503, { error: error.message });
await waitForHttp2StreamWriteToSettle(stream, capacityDenialSettleDeadlineMs);
if (!stream.destroyed) stream.destroy();
return;
}
respondJson(stream, 413, { error: error instanceof Error ? error.message : String(error) });
return;
}
@ -853,8 +1250,23 @@ export function createHttp2BridgeServer(options: CreateHttp2BridgeServerOptions)
headers: sanitizedHeaders,
body,
signal: controller.signal,
reservation,
});
} catch (error) {
if (error instanceof BridgeProcessCapacityError) {
// The forward handler's own response reader denies its reservation
// and cancels there, but leaves this stream open exactly so this
// 503 can reach the wire — the same contract the request-body
// capacity denial above keeps. `respondJson` only queues the
// write, so wait for it to settle first (bounded, so a stalled
// peer that grants no flow-control credit cannot hold this
// stream's reservation and slot open forever), then destroy to
// free the slot.
respondJson(stream, 503, { error: error.message });
await waitForHttp2StreamWriteToSettle(stream, capacityDenialSettleDeadlineMs);
if (!stream.destroyed) stream.destroy();
return;
}
respondJson(stream, 502, { error: error instanceof Error ? error.message : String(error) });
return;
}
@ -868,6 +1280,21 @@ export function createHttp2BridgeServer(options: CreateHttp2BridgeServerOptions)
try {
stream.respond(responseHeaders);
stream.end(result.body);
// `end()` only queues `result.body`; a slow or backpressured peer
// can hold those bytes in process memory well after this call
// returns. Wait for the write to actually settle before the
// `finally` block below releases the reservation those bytes hold.
// The wait carries its own deadline: a peer that grants no
// flow-control credit and never resets or errors the stream would
// otherwise hold this stream's reservation and
// HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS slot open forever, the same
// failure mode the capacity-denial path above already guards
// against. A genuinely stalled stream gets force-destroyed once the
// deadline passes; a stream that settled on its own (`finish`,
// `close`, or `error`) is left alone, since it is already ending or
// ended.
const { settled } = await waitForHttp2StreamWriteToSettle(stream, responseWriteSettleDeadlineMs);
if (!settled && !stream.destroyed) stream.destroy();
} catch {
// The peer reset the stream (RST_STREAM) between dispatch and response.
// One stream's write fault stays local to that stream.
@ -876,7 +1303,12 @@ export function createHttp2BridgeServer(options: CreateHttp2BridgeServerOptions)
// Every path above reaches this exactly once: a deny, a body-read
// fault, a forward fault, or a completed response. A completed stream
// must leak no listener; the session, not this one stream, outlives
// the handler.
// the handler. The reservation release is idempotent, but this is
// still the one place this stream's owner ever releases: releasing
// here, after the response write above has actually settled, keeps
// the owner's bytes reserved for the whole time Node still holds a
// live copy of them, not merely until the write call returns.
reservation.release();
stream.removeListener("close", abortForThisStream);
stream.removeListener("aborted", abortForThisStream);
stream.removeListener("error", abortForThisStream);

View File

@ -14,7 +14,9 @@ import {
createFileSystemSandboxCallbackBridgeQueueClient,
createSandboxCallbackBridgeAsset,
createSandboxCallbackBridgeToken,
getSandboxBridgeProcessBodyLedgerSource,
getSandboxCallbackBridgeServerSource,
HTTP2_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST,
sandboxCallbackBridgeDirectories,
syncRemoteTextFileWithHashSkip,
syncSandboxCallbackBridgeEntrypoint,
@ -966,6 +968,24 @@ describe("sandbox callback bridge", () => {
await expect(nonJsonResponse.json()).resolves.toEqual({
error: "Bridge only accepts JSON request bodies.",
});
// The queue transport keeps its 415 gate for the attachment upload path
// too: it carries a string envelope only, so it never admits a binary body.
const attachmentOctetStreamResponse = await fetch(
`${bridge.baseUrl}/api/companies/co-1/issues/issue-1/attachments`,
{
method: "POST",
headers: {
authorization: `Bearer ${bridgeToken}`,
"content-type": "application/octet-stream",
},
body: Buffer.from([0x50, 0x4b, 0x03, 0x04]),
},
);
expect(attachmentOctetStreamResponse.status).toBe(415);
await expect(attachmentOctetStreamResponse.json()).resolves.toEqual({
error: "Bridge only accepts JSON request bodies.",
});
});
it("returns a 502 when the host response times out", async () => {
@ -1425,6 +1445,44 @@ describe("sandbox callback bridge", () => {
`Route not allowed: ${request.method} ${request.path}`,
);
}
// The HTTP/2 route list adds the two binary attachment routes on top of
// the documented heartbeat surface.
const http2Allowed: Array<{ method: string; path: string }> = [
{ method: "POST", path: "/api/companies/co-1/issues/issue-1/attachments" },
{ method: "GET", path: "/api/attachments/att-1/content" },
];
for (const request of http2Allowed) {
expect(
authorizeSandboxCallbackBridgeRequestWithRoutes(request, HTTP2_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST),
).toBeNull();
}
const http2Denied: Array<{ method: string; path: string }> = [
// Wrong method for each attachment rule.
{ method: "GET", path: "/api/companies/co-1/issues/issue-1/attachments" },
{ method: "POST", path: "/api/attachments/att-1/content" },
// Extra path segment for each attachment rule.
{ method: "POST", path: "/api/companies/co-1/issues/issue-1/attachments/att-1" },
{ method: "GET", path: "/api/attachments/att-1/content/extra" },
];
for (const request of http2Denied) {
expect(
authorizeSandboxCallbackBridgeRequestWithRoutes(request, HTTP2_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST),
).toBe(`Route not allowed: ${request.method} ${request.path}`);
}
});
it("denies both attachment routes on the default (queue) route list", () => {
const attachmentRequests: Array<{ method: string; path: string }> = [
{ method: "POST", path: "/api/companies/co-1/issues/issue-1/attachments" },
{ method: "GET", path: "/api/attachments/att-1/content" },
];
for (const request of attachmentRequests) {
expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBe(
`Route not allowed: ${request.method} ${request.path}`,
);
}
});
it("marks command-managed bridge operations with the bridge execution channel", async () => {
@ -3252,7 +3310,11 @@ describe("sandbox callback bridge", () => {
bridgeToken,
forwardRequest: async (request) => {
seenBodies.push(request.body);
return { status: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ ok: true }) };
return {
status: 200,
headers: { "content-type": "application/json" },
body: Buffer.from(JSON.stringify({ ok: true }), "utf8"),
};
},
});
@ -3278,19 +3340,22 @@ describe("sandbox callback bridge", () => {
it("forwards malformed UTF-8 bytes to the HTTP/2 host handler unchanged", async () => {
const bridgeToken = createSandboxCallbackBridgeToken();
const seenBodies: Buffer[] = [];
// Byte 0xC3 opens a two-byte UTF-8 sequence; 0x28 is not a valid
// continuation byte, so this body is not valid UTF-8. The gateway does
// not decode or validate the body, so these exact bytes must still
// arrive at the host handler unchanged, and the same bytes must return
// to the caller unchanged. The host answers with a non-JSON content
// type, so the round trip proves the gateway applies no format-specific
// handling on the response leg either.
const malformedBytes = Buffer.from([0x7b, 0x22, 0x61, 0x22, 0x3a, 0xc3, 0x28, 0x7d]);
const gateway = await startHttp2GatewayForTest({
bridgeToken,
forwardRequest: async (request) => {
seenBodies.push(request.body);
return { status: 200, headers: {}, body: "" };
return { status: 200, headers: { "content-type": "application/octet-stream" }, body: malformedBytes };
},
});
// Byte 0xC3 opens a two-byte UTF-8 sequence; 0x28 is not a valid
// continuation byte, so this body is not valid UTF-8. The gateway does
// not decode or validate the body, so these exact bytes must still
// arrive at the host handler unchanged.
const malformedBytes = Buffer.from([0x7b, 0x22, 0x61, 0x22, 0x3a, 0xc3, 0x28, 0x7d]);
const response = await fetch(`${gateway.baseUrl}/api/issues/issue-1/comments`, {
method: "POST",
headers: {
@ -3302,6 +3367,8 @@ describe("sandbox callback bridge", () => {
expect(response.status).toBe(200);
expect(seenBodies).toHaveLength(1);
expect(seenBodies[0]?.equals(malformedBytes)).toBe(true);
const responseBytes = Buffer.from(await response.arrayBuffer());
expect(responseBytes.equals(malformedBytes)).toBe(true);
}, 15_000);
it("rejects a request body over maxBodyBytes on the HTTP/2 path before it forwards a byte", async () => {
@ -3313,7 +3380,7 @@ describe("sandbox callback bridge", () => {
maxBodyBytes,
forwardRequest: async () => {
forwardCalls += 1;
return { status: 200, headers: {}, body: "" };
return { status: 200, headers: {}, body: Buffer.alloc(0) };
},
});
@ -3333,6 +3400,133 @@ describe("sandbox callback bridge", () => {
expect(forwardCalls).toBe(0);
}, 15_000);
/** A resolvable gate a test can await, then release on its own schedule. */
function createGate(): { reached: Promise<void>; reach: () => void } {
let reach!: () => void;
const reached = new Promise<void>((resolve) => {
reach = resolve;
});
return { reached, reach };
}
it("denies a body once concurrent reservations reach the gateway's own process ledger ceiling, then admits again once they release", async () => {
// The per-body maxBodyBytes cap alone does not bound how many bodies
// this gateway process holds in memory at once. The generated gateway's
// own process ledger gives it a second, independent ceiling: 4
// concurrent max-size bodies, each counted twice (the retained chunk
// array and the concatenated copy), exactly fills maxBodyBytes * 8. This
// drives 4 concurrent requests to that exact ceiling, held open by a
// forward call that does not return, then proves a 5th is denied and a
// later one succeeds once the 4 held requests release their bytes.
const bridgeToken = createSandboxCallbackBridgeToken();
const maxBodyBytes = 400;
const holdBody = Buffer.alloc(maxBodyBytes, 0x42);
const HOLD_COUNT = 4;
const reachedGates = Array.from({ length: HOLD_COUNT }, () => createGate());
const releaseGates = Array.from({ length: HOLD_COUNT }, () => createGate());
let forwardCalls = 0;
const gateway = await startHttp2GatewayForTest({
bridgeToken,
maxBodyBytes,
forwardRequest: async () => {
const callIndex = forwardCalls;
forwardCalls += 1;
if (callIndex < HOLD_COUNT) {
reachedGates[callIndex]!.reach();
await releaseGates[callIndex]!.reached;
}
return { status: 200, headers: {}, body: Buffer.alloc(0) };
},
});
const postHoldBody = () =>
fetch(`${gateway.baseUrl}/api/issues/issue-1/comments`, {
method: "POST",
headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" },
body: holdBody,
});
const holdResponses = Promise.all(Array.from({ length: HOLD_COUNT }, () => postHoldBody()));
// Each held request's own forward call started, which only happens
// after readBodyBytes already reserved that request's bytes, so all 4
// reservations are live once every gate below resolves.
await Promise.all(reachedGates.map((gate) => gate.reached));
const deniedResponse = await postHoldBody();
expect(deniedResponse.status).toBe(503);
await expect(deniedResponse.json()).resolves.toMatchObject({
error: "The bridge gateway process reached its reserved body byte ceiling. Retry later.",
});
// The denial happened during the body read, before this stream's own
// forward call ever ran.
expect(forwardCalls).toBe(HOLD_COUNT);
releaseGates.forEach((gate) => gate.reach());
const responses = await holdResponses;
for (const response of responses) {
expect(response.status).toBe(200);
}
// Every held reservation released once its own forward call settled: a
// fresh request at the same size now succeeds again.
const recoveredResponse = await postHoldBody();
expect(recoveredResponse.status).toBe(200);
}, 15_000);
it("maps an indeterminate host outcome to a non-retryable 409 on the HTTP/2 gateway for an unsafe method", async () => {
// The host answers a mutating request's response-body capacity denial with a
// 504 and the indeterminate outcome header, exactly like an aborted in-flight
// forward: the host may have already committed the mutation, so the status
// must not be retryable. The HTTP/2 gateway must map that 504 to a 409, the
// same map the file gateway already applies, so a standard retry policy does
// not repeat the mutation. The outcome header and body must survive the map.
const bridgeToken = createSandboxCallbackBridgeToken();
const indeterminateBody = JSON.stringify({ error: "response body over limit", outcome: "indeterminate" });
const gateway = await startHttp2GatewayForTest({
bridgeToken,
forwardRequest: async () => ({
status: 504,
headers: { "content-type": "application/json", "x-paperclip-bridge-outcome": "indeterminate" },
body: Buffer.from(indeterminateBody, "utf8"),
}),
});
const response = await fetch(`${gateway.baseUrl}/api/issues/issue-1/comments`, {
method: "POST",
headers: { authorization: `Bearer ${bridgeToken}`, "content-type": "application/json" },
body: JSON.stringify({ note: "test" }),
});
expect(response.status).toBe(409);
expect(response.status).toBeLessThan(500);
expect(response.headers.get("x-paperclip-bridge-outcome")).toBe("indeterminate");
const responseBytes = Buffer.from(await response.arrayBuffer());
expect(responseBytes.toString("utf8")).toBe(indeterminateBody);
}, 15_000);
it("passes through a safe method's retryable 503 on the HTTP/2 gateway unchanged", async () => {
// A safe method's response-body capacity denial carries no indeterminate
// outcome header, so the gateway must keep it retryable: the map above must
// not fire on a plain 503.
const bridgeToken = createSandboxCallbackBridgeToken();
const gateway = await startHttp2GatewayForTest({
bridgeToken,
forwardRequest: async () => ({
status: 503,
headers: { "content-type": "application/json" },
body: Buffer.from(JSON.stringify({ error: "response body over limit" }), "utf8"),
}),
});
const response = await fetch(`${gateway.baseUrl}/api/issues/issue-1/comments`, {
method: "GET",
headers: { authorization: `Bearer ${bridgeToken}` },
});
expect(response.status).toBe(503);
expect(response.headers.get("x-paperclip-bridge-outcome")).toBeNull();
}, 15_000);
it("rejects a request body over maxBodyBytes on the queue path before it writes the queue file", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-queue-maxbody-"));
cleanupDirs.push(rootDir);
@ -3453,3 +3647,40 @@ describe("sandbox callback bridge", () => {
expect(seenRequests[0]?.body).toBe(requestBodyText);
});
});
interface EmbeddedBridgeProcessBodyLedger {
reserve(byteCount: number): boolean;
release(byteCount: number): void;
readonly reservedBytes: number;
}
// This describe block covers the zero-dependency process body-byte ledger
// every generated gateway embeds (`BRIDGE_PROCESS_BODY_LEDGER_SOURCE`),
// exercised directly with no spawned process involved, the same way the
// codec source in `execution-target-sandbox.test.ts` gets its own direct
// coverage.
describe("embedded sandbox gateway process body ledger", () => {
it("reserves and releases bytes against its own ceiling, denying only once it is exceeded", () => {
const ledgerFactory = new Function(
`${getSandboxBridgeProcessBodyLedgerSource()}\nreturn createBridgeProcessBodyLedger;`,
) as unknown as () => (maxBytes: number) => EmbeddedBridgeProcessBodyLedger;
const createBridgeProcessBodyLedger = ledgerFactory();
const ledger = createBridgeProcessBodyLedger(100);
expect(ledger.reserve(60)).toBe(true);
expect(ledger.reservedBytes).toBe(60);
// A denied reservation reserves nothing: the total stays exactly what
// the first call reserved.
expect(ledger.reserve(41)).toBe(false);
expect(ledger.reservedBytes).toBe(60);
expect(ledger.reserve(40)).toBe(true);
expect(ledger.reservedBytes).toBe(100);
ledger.release(60);
expect(ledger.reservedBytes).toBe(40);
// Room freed by the release admits a request the full ceiling would
// have denied.
expect(ledger.reserve(60)).toBe(true);
expect(ledger.reservedBytes).toBe(100);
});
});

View File

@ -20,7 +20,23 @@ 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;
// A `BridgeBodyReservation` owner (`http2-bridge-server.ts`) now bounds the
// process-wide total of live request and response body bytes at
// `HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES` (1 GiB), so this per-body limit can
// rise to the same ceiling `MAX_ATTACHMENT_BYTES`
// (`server/src/attachment-types.ts`) already accepts, with no unbounded
// growth in process memory.
//
// The attachment upload route carries its file inside a multipart body, so a
// file at the exact `MAX_ATTACHMENT_BYTES` ceiling needs more than
// `MAX_ATTACHMENT_BYTES` raw bytes to cross the bridge: the multipart
// boundary line, each part's `Content-Disposition` and `Content-Type`
// headers, and the small JSON metadata field this route also accepts all add
// bytes on top of the file content the server measures. This headroom
// covers that framing with a wide margin, so a valid maximum-size attachment
// never fails at the bridge before the server ever sees it.
const BRIDGE_MULTIPART_FRAMING_HEADROOM_BYTES = 64 * 1024;
const DEFAULT_BRIDGE_MAX_BODY_BYTES = 10 * 1024 * 1024 + BRIDGE_MULTIPART_FRAMING_HEADROOM_BYTES;
// 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
@ -183,6 +199,16 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa
{ method: "DELETE", path: /^\/api\/routine-triggers\/[^/]+$/ },
] as const;
// The HTTP/2 bridge carries the request body as raw bytes, so it can admit
// the two binary attachment routes. The queue transport keeps
// `DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST`, because its envelope
// carries a string body only.
export const HTTP2_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCallbackBridgeRouteRule[] = [
...DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST,
{ method: "POST", path: /^\/api\/companies\/[^/]+\/issues\/[^/]+\/attachments$/ },
{ method: "GET", path: /^\/api\/attachments\/[^/]+\/content$/ },
] as const;
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST = [
"accept",
"content-type",
@ -2039,6 +2065,48 @@ export function getSandboxDuplexGatewayCodecSource(): string {
return DUPLEX_GATEWAY_CODEC_SOURCE;
}
/**
* Zero-dependency source for the gateway's own aggregate body-byte ledger.
* `readBodyBytes` in the generated gateway reserves against one instance of
* this ledger before it retains a chunk and before it concatenates the final
* buffer, so a burst of concurrent requests cannot grow the gateway
* process's own memory without limit. This ledger is separate from, and
* independent of, the ceiling `http2-bridge-server.ts` enforces on the host
* side of the bridge connection: each side bounds only the memory in its own
* process. It uses no global beyond plain JavaScript, so it embeds inside the
* gateway template literal with no escape. A test wraps this source directly
* to exercise `reserve`/`release`, the same way
* {@link getSandboxDuplexGatewayCodecSource} lets a test exercise the codec.
*/
const BRIDGE_PROCESS_BODY_LEDGER_SOURCE = `function createBridgeProcessBodyLedger(maxBytes) {
let reservedBytes = 0;
return {
reserve(byteCount) {
if (reservedBytes + byteCount > maxBytes) {
return false;
}
reservedBytes += byteCount;
return true;
},
release(byteCount) {
reservedBytes -= byteCount;
},
get reservedBytes() {
return reservedBytes;
},
};
}`;
/**
* Return the exact zero-dependency ledger source the generated gateway
* embeds. A test wraps this source and calls `createBridgeProcessBodyLedger`
* to prove the embedded copy reserves and releases bytes correctly, with no
* spawned process involved.
*/
export function getSandboxBridgeProcessBodyLedgerSource(): string {
return BRIDGE_PROCESS_BODY_LEDGER_SOURCE;
}
export function getSandboxCallbackBridgeServerSource(): string {
return `import { randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
@ -2119,6 +2187,32 @@ process.on("unhandledRejection", (reason) => {
// gateway ignores it.
${DUPLEX_GATEWAY_CODEC_SOURCE}
// The embedded zero-dependency process body-byte ledger. Both gateway modes
// use it: readBodyBytes reserves against it, and each mode's request
// handler releases what it reserved once the body is no longer needed.
${BRIDGE_PROCESS_BODY_LEDGER_SOURCE}
// The multiplier matches HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS (4) in
// http2-bridge-server.ts, doubled because readBodyBytes reserves a body's
// bytes twice: once for the retained chunk array, once for the concatenated
// copy, since both are live buffers at once. This gives the gateway process
// its own aggregate ceiling on live request-body bytes, so a burst of
// concurrent requests cannot grow this process's memory without limit, even
// though every individual body already passes the maxBodyBytes check below.
// This ceiling is independent of, and separate from, the ceiling the host
// enforces on its own side of the bridge connection.
const maxProcessBodyBytes = maxBodyBytes * 8;
const processBodyLedger = createBridgeProcessBodyLedger(maxProcessBodyBytes);
// A denied process-ledger reservation answers 503: the sandbox client should
// retry once other in-flight bodies finish and release their bytes, the same
// retry contract the host side gives for its own capacity denial.
class BridgeProcessCapacityError extends Error {
constructor() {
super("The bridge gateway process reached its reserved body byte ceiling. Retry later.");
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@ -2136,22 +2230,49 @@ function normalizeHeaders(headers) {
return out;
}
// Reserves each chunk's bytes against the process ledger before the chunk
// joins the retained array, and reserves the concatenated buffer's own byte
// count before Buffer.concat allocates it, mirroring the order
// readHttp2StreamBody enforces on the host side. Returns the body buffer
// together with a release function: the caller must call release exactly
// once, after the body is no longer needed, so its reserved bytes return to
// the ledger on completion, on an error the caller raises later, on a client
// abort, and on a timeout — every path funnels through the caller's own
// finally block. A read that fails here (the size limit, or a denied
// process reservation) releases its own partial reservation immediately, so
// no caller-side release call is needed for that path.
async function readBodyBytes(req) {
const chunks = [];
let totalBytes = 0;
for await (const chunk of req) {
const nextChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
chunks.push(nextChunk);
totalBytes += nextChunk.byteLength;
if (totalBytes > maxBodyBytes) {
throw new Error("Bridge request body exceeded the configured size limit.");
let reservedBytes = 0;
try {
for await (const chunk of req) {
const nextChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += nextChunk.byteLength;
if (totalBytes > maxBodyBytes) {
throw new Error("Bridge request body exceeded the configured size limit.");
}
if (!processBodyLedger.reserve(nextChunk.byteLength)) {
throw new BridgeProcessCapacityError();
}
reservedBytes += nextChunk.byteLength;
chunks.push(nextChunk);
}
if (!processBodyLedger.reserve(totalBytes)) {
throw new BridgeProcessCapacityError();
}
reservedBytes += totalBytes;
const body = Buffer.concat(chunks);
return { body, release: () => processBodyLedger.release(reservedBytes) };
} catch (error) {
processBodyLedger.release(reservedBytes);
throw error;
}
return Buffer.concat(chunks);
}
async function readBody(req) {
return (await readBodyBytes(req)).toString("utf8");
const { body, release } = await readBodyBytes(req);
return { body: body.toString("utf8"), release };
}
function tokensMatch(received) {
@ -2211,6 +2332,12 @@ async function runFileGateway() {
}
const server = createServer(async (req, res) => {
// readBody reserves the body's bytes against the process ledger and
// hands back a release function; this holds it so the finally below
// releases those bytes exactly once no matter how this handler ends —
// its normal completion, a thrown error, a client abort, or a deadline
// timeout all reach the same finally.
let releaseBodyReservation = null;
try {
const auth = req.headers.authorization || "";
const receivedToken = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
@ -2236,7 +2363,8 @@ async function runFileGateway() {
return;
}
const requestId = randomUUID();
const requestBody = await readBody(req);
const { body: requestBody, release } = await readBody(req);
releaseBodyReservation = release;
const payload = {
id: requestId,
method: req.method || "GET",
@ -2284,7 +2412,13 @@ async function runFileGateway() {
}
res.end(typeof response.body === "string" ? response.body : "");
} catch (error) {
writeJsonResponse(res, 502, { error: error instanceof Error ? error.message : String(error) });
// A denied process-ledger reservation is retryable: the caller should
// try again once other in-flight bodies release their bytes. Every
// other body-read or handling fault stays a generic 502.
const status = error instanceof BridgeProcessCapacityError ? 503 : 502;
writeJsonResponse(res, status, { error: error instanceof Error ? error.message : String(error) });
} finally {
releaseBodyReservation?.();
}
});
@ -2465,6 +2599,12 @@ function runHttp2Gateway() {
}
const server = createServer(async (req, res) => {
// readBodyBytes reserves the body's bytes against the process ledger
// and hands back a release function; this holds it so the finally
// below releases those bytes exactly once no matter how this handler
// ends — its normal completion, a thrown error, a client abort, or a
// deadline timeout all reach the same finally.
let releaseBodyReservation = null;
try {
const auth = req.headers.authorization || "";
const receivedToken = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
@ -2477,12 +2617,8 @@ function runHttp2Gateway() {
return;
}
const url = new URL(req.url || "/", "http://127.0.0.1");
const contentType = typeof req.headers["content-type"] === "string" ? req.headers["content-type"] : "";
if (req.method && req.method !== "GET" && req.method !== "HEAD" && !/json/i.test(contentType)) {
writeJsonResponse(res, 415, { error: "Bridge only accepts JSON request bodies." });
return;
}
const requestBodyBuffer = await readBodyBytes(req);
const { body: requestBodyBuffer, release } = await readBodyBytes(req);
releaseBodyReservation = release;
let response;
try {
response = await forwardOverHttp2({
@ -2496,14 +2632,32 @@ function runHttp2Gateway() {
writeJsonResponse(res, 502, { error: error instanceof Error ? error.message : String(error) });
return;
}
res.statusCode = typeof response.status === "number" ? response.status : 200;
// The host marks a possibly-committed mutation with an indeterminate outcome.
// The host cannot cancel a host operation that is in flight, so the mutation
// may have committed before the worker aborted the handler. A 5xx status is
// retryable by convention, so a caller that retries 5xx would apply the
// mutation twice. Map the indeterminate outcome to a non-retryable 409, so a
// standard retry policy does not repeat the request. The outcome header and
// body stay, so a caller that reads them still sees the indeterminate result.
const bridgeOutcome = (response.headers || {})["x-paperclip-bridge-outcome"];
if (bridgeOutcome === "indeterminate") {
res.statusCode = 409;
} else {
res.statusCode = typeof response.status === "number" ? response.status : 200;
}
for (const [key, value] of Object.entries(response.headers || {})) {
if (typeof value !== "string" || key.toLowerCase() === "content-length") continue;
res.setHeader(key, value);
}
res.end(response.body);
} catch (error) {
writeJsonResponse(res, 502, { error: error instanceof Error ? error.message : String(error) });
// A denied process-ledger reservation is retryable: the caller should
// try again once other in-flight bodies release their bytes. Every
// other body-read or handling fault stays a generic 502.
const status = error instanceof BridgeProcessCapacityError ? 503 : 502;
writeJsonResponse(res, status, { error: error instanceof Error ? error.message : String(error) });
} finally {
releaseBodyReservation?.();
}
});

View File

@ -3558,11 +3558,18 @@ export interface PluginWorkerManagerOptions {
*
* Known aggregate behavior: this ceiling bounds route count only, not
* retained bytes. Each HTTP/2 bridge route bounds its own retained body
* bytes to 8,388,608 bytes (see `HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS` in
* `http2-bridge-server.ts`), so this route ceiling caps the process's
* aggregate retained body bytes at 128 * 8,388,608 = 1,073,741,824 bytes
* (1 GiB). This is accepted, known behavior, not a defect: the process
* tracks no aggregate byte ledger across routes.
* bytes to 168,820,736 bytes (see `HTTP2_BRIDGE_MAX_CONCURRENT_STREAMS` in
* `http2-bridge-server.ts`), so this route ceiling alone would let the
* process's aggregate retained body bytes reach 128 * 168,820,736 =
* 21,609,054,208 bytes (about 20.1 GiB) if nothing else bounded it. It does
* not reach that figure: every stream's `BridgeBodyReservation` owner
* (`http2-bridge-server.ts`) reserves against the shared
* `HTTP2_BRIDGE_MAX_PROCESS_BODY_BYTES` total (1,073,741,824 bytes, 1 GiB)
* across every route, so that reservation not this route ceiling is the
* process's real aggregate-byte bound. `HTTP2_BRIDGE_MAX_ROUTE_BODY_BYTES`
* (`http2-bridge-server.ts`) adds a second bound scoped to one route at a
* time, so one busy route cannot spend that whole process-wide total by
* itself and deny every sibling route admission.
*/
export const DEFAULT_MAX_CONCURRENT_DUPLEX_ROUTES = 128;